Ask most developers what token.allowance(owner, spender) returns and they’ll get it right. Ask what a wallet has actually approved for Uniswap, and most tooling gets it wrong, because since 2022 the answer usually isn’t a single ERC-20 approval — it’s two. Permit2 sits between the token and the app, and it has its own allowance mapping with its own expiration. Read only the ERC-20 side and you’ll see a wallet holding an unlimited approval that, in practice, lets nothing move.
TL;DR
Permit2 (0x000000000022D473030F116dDEE9F6B43aC78BA3, identical address on
Ethereum, Base, Polygon, and BNB Chain) adds a second allowance layer on top
of ERC-20’s. The wrapper approval — token.allowance(owner, PERMIT2) — is
usually unlimited and granted once. The real, per-app permission is
permit2.allowance(owner, token, spender), which returns an amount, an
expiration timestamp, and a nonce. Audit that second call, not the first.
Why Permit2 exists
Before Permit2, every dApp you used needed its own ERC-20 approval: one approve() transaction per token, per spender, before the first swap or deposit would work. Permit2, deployed by Uniswap Labs in 2022 and now integrated by most of the DeFi routers your users touch — Uniswap’s Universal Router, 1inch, 0x, Matcha — collapses that into a single approval. Approve Permit2 once per token, and every Permit2-integrated app can request time-boxed, revocable spend permission from Permit2 itself, off-chain, via a signature instead of a transaction.
That’s the pitch. The part that trips up anyone reading allowances programmatically is that this design means “how much can this token move” is now a two-step question, not a one-step lookup.
The two-layer allowance model
| Layer | Call | What it means |
|---|---|---|
| 1. Wrapper | token.allowance(owner, PERMIT2_ADDRESS) |
How much of the token the owner has approved Permit2 itself to pull. Usually the max uint256 — apps prompt for this once, up front, so the user never has to approve again. |
| 2. Sub-allowance | permit2.allowance(owner, token, spender) |
How much a specific app (the spender) is currently permitted to pull, and until when. This is the permission that actually lets a swap or deposit execute. |
A wallet can hold an unlimited layer-1 approval and a zero layer-2 sub-allowance for every app that exists — meaning nothing can currently move, despite the alarming-looking 115792089237316195423570985008687907853269984665640564039457584007913129639935 sitting in the wrapper approval. Conversely, revoking the layer-1 approval kills every layer-2 permission at once, which is why revocation tools default to touching layer 1. If you’re auditing exposure rather than nuking it, layer 2 is the number that matters.
Permit2’s ABI, resolved live against the verified contract:
DOMAIN_SEPARATOR() -> bytesallowance(owner: address, token: address, spender: address) -> (amount: uint160, expiration: uint48, nonce: uint48)nonceBitmap(owner: address, wordPos: uint256) -> uint256nonceBitmap belongs to a different Permit2 flow — single-use, off-chain SignatureTransfer permits — and never shows up in allowance(). This guide covers the AllowanceTransfer flow, the one with a persistent, queryable, revocable sub-allowance.
Reading the layer-1 wrapper approval
Permit2 is deployed at the same address via deterministic CREATE2 on every evmquery chain, so the same query works across Ethereum, Base, Polygon, and BNB Chain with nothing but the chain identifier changed. Here’s the wrapper approval for USDC and DAI on a real Ethereum wallet:
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "usdc": { "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }, "dai": { "address": "0x6B175474E89094C44Da98b954EedeAC495271d0F" } }, "context": { "wallet": "sol_address", "permit2": "sol_address" } }, "context": { "wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "permit2": "0x000000000022D473030F116dDEE9F6B43aC78BA3" }, "expression": "{\"usdc\": usdc.allowance(wallet, permit2), \"dai\": dai.allowance(wallet, permit2)}" }' | python3 -m json.toolRun live against vitalik.eth’s main wallet, this returns:
{ "result": { "value": { "usdc": { "value": "0" }, "dai": { "value": "115792089237316195423570985008687907853269984665640564039457584007913129639935" } }, "type": "map<string, sol_int>" }, "meta": { "blockNumber": "25803904", "totalCalls": 2, "totalRounds": 1 }}Zero USDC approved to Permit2 — this wallet has never gone through a Permit2-integrated flow with USDC. DAI, on the other hand, shows the max uint256: an unlimited wrapper approval, consistent with the >= 2^160 - 1-style threshold that flags an approval as “unlimited” rather than a specific amount (the same convention evmquery’s own Token Allowance Checker uses).
An unlimited layer-1 approval is not, by itself, evidence of anything
Every wallet that has ever swapped through a Permit2-integrated router has an unlimited layer-1 approval for whatever token it swapped. That’s the intended UX — it’s what lets the next swap skip the approval transaction. It says nothing about which apps currently hold spend permission. For that, you need layer 2.
Reading the layer-2 sub-allowance
This is the call that answers “can this specific app move my tokens right now.” permit2.allowance(owner, token, spender) returns a struct — amount, expiration, nonce — not a bare integer:
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "permit2": { "address": "0x000000000022D473030F116dDEE9F6B43aC78BA3" } }, "context": { "wallet": "sol_address", "dai": "sol_address", "spender": "sol_address" } }, "context": { "wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "dai": "0x6B175474E89094C44Da98b954EedeAC495271d0F", "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD" }, "expression": "permit2.allowance(wallet, dai, spender)" }' | python3 -m json.toolSpender here is one of Uniswap’s Universal Router deployments on Ethereum — Uniswap has shipped several Universal Router versions over time, so treat this as an illustrative spender, not a permanently canonical address; resolve the current one from your own integration or the Uniswap deployments page before using it in production. Against vitalik.eth’s wallet, the result is:
{ "result": { "value": { "amount": "0", "expiration": "0", "nonce": "0" }, "type": "SEL_Struct_permit2_allowance" }, "meta": { "blockNumber": "25803905", "totalCalls": 1, "totalRounds": 1 }}Put the two results side by side and the picture is complete: an unlimited wrapper approval on DAI, and a zero sub-allowance to this particular router. Nothing this router can currently pull from that DAI balance without a fresh signature — the wrapper approval alone tells you nothing about that. This is also exactly why a bare token.allowance() check is the wrong tool for auditing Permit2-era approval risk: the number that actually gates a transfer lives in a different contract, keyed by a third argument the ERC-20 standard doesn’t have.
Batching a multi-token audit in one call
The same wallet, the same spender, three tokens — one Multicall3 round trip instead of three requests:
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "permit2": { "address": "0x000000000022D473030F116dDEE9F6B43aC78BA3" } }, "context": { "wallet": "sol_address", "usdc": "sol_address", "weth": "sol_address", "dai": "sol_address", "spender": "sol_address" } }, "context": { "wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "weth": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "dai": "0x6B175474E89094C44Da98b954EedeAC495271d0F", "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD" }, "expression": "{\"usdc\": permit2.allowance(wallet, usdc, spender), \"weth\": permit2.allowance(wallet, weth, spender), \"dai\": permit2.allowance(wallet, dai, spender)}" }' | python3 -m json.tool{ "meta": { "blockNumber": "25803905", "totalCalls": 3, "totalRounds": 1 }, "units": { "consumed": 4 }}Three eth_calls, one round trip. For a wallet-risk dashboard or an agent that needs to answer “what could actually move from this address right now,” this is the shape to reach for: fix the owner and spender, vary the token list, and read the whole exposure surface in one request. See Multicall3: batch EVM contract reads for the batching mechanics underneath this.
Checking expiration yourself
allowance() is a plain storage read — it returns whatever was last written, whether or not that grant has since lapsed. The Permit2 interface documents expiration as “a timestamp at which a spender’s token allowances become invalid,” and that check happens in the transfer path, not in the view function. A nonzero amount with an expiration in the past is a stale, no-longer-usable grant that allowance() will still happily report as if it were live.
Compare expiration to the current time yourself
If you’re building anything that decides “is this spend permission currently active” — a risk score, a revocation prompt, an agent’s go/no-go check — don’t stop at amount > 0. Compare the returned expiration (a Unix timestamp) against the current time. Permit2’s default UI grants typically expire in 30 days; a nonzero amount past its expiration is not a live permission, even though the contract will keep returning it until someone overwrites or explicitly revokes it.
Next steps
- Token Allowance Checker: a free, no-signup tool for the layer-1 ERC-20 side of this — paste a token, owner, and spender to see the raw approval
- Multicall3: batch EVM contract reads: the batching mechanics behind the multi-token audit above
- ERC-4626 Vault Share Price: another two-call DeFi read where the obvious first guess turns out to be the wrong number
- evmquery for developers: what else the API can read besides approvals



