Permit2 Allowances: The Second Approval Layer Most Tools Never Check

How to read Permit2's two-layer approval system with a REST API — the token-level wrapper approval, and the per-app sub-allowance that actually decides what a router can move.

evmquery team··7 min read
Share
Permit2 allowances — reading the two-layer approval system with a REST API

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() -> bytes
allowance(owner: address, token: address, spender: address) -> (amount: uint160, expiration: uint48, nonce: uint48)
nonceBitmap(owner: address, wordPos: uint256) -> uint256

nonceBitmap 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.tool

Run 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.tool

Spender 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

Share

Audit a wallet's Permit2 exposure in one request

Free tier, no credit card. Point the expression below at any owner, token, and spender.