Permit3: allowances for positions, not just tokens

Permit2 made token approvals safe: one contract to approve, a cap, an expiry, one place to revoke. But most of what you own on-chain is not a token balance — it is collateral, borrow capacity, staked ETH, vault shares — and the only way to authorize an operation on those is a per-protocol boolean switch with no amount and no expiry. Permit3 adds a second allowance book for exactly those operations, and it turns out to be the missing piece for intents that do more than swap.


Illustration for “Permit3: allowances for positions, not just tokens”

Every approval you have ever signed answers one question: how much of a token sitting in my wallet may this contract move? Permit2 made that question answerable well — approve one hub per token, then hand out capped, expiring, revocable allowances to everything else.

The question is also narrower than it looks. Most of the value a DeFi user holds is not a balance in their wallet. It is collateral supplied to Aave, borrow capacity against it, ETH staked in Lido, shares in a vault, a position in a Morpho market. Pulling value out of those is not transferFrom — it is borrow, withdraw, unstake, claim, redeem, each with its own on-behalf-of mechanism, and none of them fit the shape Permit2 gates.

So the moment a contract needs to act on your positions rather than your balances, the approval model falls off a cliff — from "1000 USDC, expires Friday, revocable in one call" to "yes, forever, unlimited."

Permit3 — the allowance hub we built for the 1delta settlement layer — is Permit2 plus a second allowance book that covers exactly that gap. This piece is about what the gap is, how the second book closes it, how you would integrate it into any contract (not just ours), and why it is a precondition for intents that express more than a swap.

IN YOUR WALLET Token balances — what Permit2 gates USDC · WETH · any ERC-20 you hold transferFrom(owner → to) amount cap · expiry · single revoke surface signature transfers · batch · lockdown the user decides how much, and for how long IN YOUR POSITIONS Everything else — gated by nothing borrow · withdraw collateral · unstake · claim approveDelegation(m, max) · allow(m, true) no amount · no expiry · one switch per protocol nowhere to enumerate them, nowhere to revoke the user decides yes or no, once, forever

1. What Permit2 fixed, and where it stops

Permit2's contribution was to move the approval out of the token. You approve the hub once per ERC-20, and every downstream contract gets its authority from the hub instead of from the token. That single move bought a lot:

  • Amount caps and expirations on allowances that ERC-20 never had.
  • Signature transfers — a one-shot, off-chain-signed authorization that writes no storage and needs no prior approval to the spender.
  • Unordered (bitmap) nonces, so signatures can be issued and consumed out of order and cancelled individually.
  • lockdown — one transaction that zeroes a batch of allowances.
  • Witness binding, so a transfer signature can be tied to the specific action it was signed for.

What it did not change is the shape of the thing being authorized. Every path through Permit2 ends in transferFrom(owner, to, amount) on a token the owner holds. That is a hard boundary, not an implementation detail: a borrow does not move a token from the user's balance, it mints debt against their collateral. There is no from to pull from.

The result is that the authority for position operations lives in each protocol's own delegation primitive:

aaveVariableDebtToken.approveDelegation(module, type(uint256).max);
comet.allow(module, true);
morpho.setAuthorization(module, true);

Look at what those grants have in common. Two of them are booleans — there is no amount to cap even if you wanted one. None of them expire. None of them are scoped to a single action or a single market. They live in three different contracts with three different names, so there is no place a wallet can enumerate what you have granted, and no single call that takes it back. And because they are all-or-nothing, the app asking for one has to ask for permanent, unbounded power over a debt position in order to do a single leveraged trade.

The usual patch is an admin-controlled whitelist: the protocol keeps a list of approved modules, and users trust the list. That conflates two decisions that belong to different parties. Which protocols am I willing to touch is the user's call. How much may this thing pull from me, right now, for this specific action is a per-action call — and a governance-owned whitelist answers neither.

2. The second book

Permit3 keeps Permit2's token book and adds a taker book next to it: an allowance over operations that pull value out of a position.

keyconsumed bygates
Token book(user, spender, token)transferFrom(user, to, token, amount)ERC-20 balances
Taker book(user, spender, ref)take(module, user, amount, receiver, data)borrow · withdraw · unstake · claim · redeem

Both books hold the same packed record — amount, expiration — and both are keyed by spender, the contract allowed to consume the allowance. The one new idea is the third key:

bytes32 ref = keccak256(data);

data is the exact byte string the executing module will decode. For an Aave borrow it is abi.encode(pool, asset, rateMode). For a Comet collateral withdrawal, abi.encode(comet, collateralAsset). For Morpho Blue, the full MarketParams struct. So the allowance key is the position identity, and there is no canonicalization layer in between where the two could drift apart: what the user hashed is byte-for-byte what the module executes.

Spending it looks like this:

// user, once per position — same bytes the executor will pass
permit3.approveTaker(spender, keccak256(data), 1_000e6, uint48(block.timestamp + 1 hours));

// spender, later
permit3.take(module, user, 1_000e6, receiver, data);
//   ref = keccak256(data)
//   _spend(takerAllowance[user][msg.sender][ref], 1_000e6)   ← decrement first
//   ITakerModule(module).takeOnBehalf(user, 1_000e6, receiver, data)

Decrement-then-call is enforced by the hub, not by the module, so a buggy or malicious module cannot skip the gate. take is nonReentrant, so a module cannot call back in to widen its own allowance mid-operation. And because the book is spender-keyed, a standing taker allowance can only ever be consumed by the contract the user named — a third party calling take with the same data has no allowance under its own address and reverts.

The rest of Permit3 is Permit2, ported with attribution (every file carries a provenance block naming what came from where). Signature transfers are close to a straight port — the EIP-712 type strings are byte-identical, so existing signing tooling produces them unchanged, while the digests differ because the domain names a different contract, which is what stops a signature from crossing between the two.

Four deviations matter enough to state plainly, because identical-looking code behaves differently:

  1. expiration == 0 means never expires. Permit2 does the opposite — it rewrites a zero to block.timestamp, so the grant dies at the end of that block.
  2. Token-book key order is [user][spender][token], not Permit2's [owner][token][spender], so it lines up with the taker book. Off-chain slot derivations do not carry over.
  3. One nonce space. A single per-owner bitmap covers signed grants and signature transfers alike, and invalidateUnorderedNonces cancels both kinds. Allocate nonces per owner, not per message type.
  4. A signed grant is one batch spanning both books, with each leg naming its own spender — and it can carry a witness. Permit2 binds a witness only to transfers.

That last one is what makes a single signature useful, and it is the subject of section 4.

3. How it integrates

Permit3 is deployed once per chain, is not upgradeable, has no owner, no admin role and no whitelist. Integrating it means three parts that each know very little about each other.

① THE USER GRANTS once, per ERC-20: token.approve(permit3) per token, capped + expiring: approveToken(spender, …) per position, capped + expiring: approveTaker(spender, ref, …) or one signature for both books ② PERMIT3 — TWO BOOKS TOKEN BOOK (user, spender, token) → amount, expiry TAKER BOOK (user, spender, ref) ref = keccak256(data) → amount, expiry decrement first, then call out ③ MODULE → PROTOCOL takeOnBehalf(user, amount, receiver, data) one module = one operation, identified by its address Aave borrow · Comet withdraw Morpho supply · Lido unstake → the protocol-native call THE SPENDER — any contract the user approved: a settlement engine, a router, a vault, an agent permit3.transferFrom(user, to, token, amount) · permit3.take(module, user, amount, receiver, data)

The hub knows nothing about lending, staking or vaults. It holds two mappings and dispatches.

Modules hold all the protocol-specific plumbing, and each one performs exactly one operation. That is the property that makes an approval legible: AaveV3BorrowModule is unambiguously a borrow authorization, and a compromised borrow module cannot be used to withdraw collateral. A module is small — one protocol call, plus an optional permit3.transferFrom if the operation needs to pull an ERC-20 mid-flight — and it must enforce msg.sender == permit3 as its first statement. Two interfaces cover both directions: ITakerModule.takeOnBehalf pulls value out of a position, IMakerModule.makeOnBehalf pushes value in (deposit, repay), the latter gated by the token book alone.

The spender is your contract. It is whatever the user approved, and it calls transferFrom and take. Nothing about it is registered anywhere; there is no list to get on.

Adding a protocol means deploying a module. The hub and the interfaces do not change, and nobody has to approve the module for it to exist — only the users who choose to grant it authority.

Four ways to grant authority

They differ in how long the authority lives and what it costs to create:

PathSignature?Survives the call?
approveToken / approveTakernoyes — until spent, expired or revoked
permitBatch / permitBatchWithWitnessone per grantyes — until spent or expired
permitTransferFrom (signature transfer)one per transferno — nothing is written
AllowanceHolder.execnono — zeroed before returning

The last one is worth a note. AllowanceHolder is a port of 0x's design: the user approves the holder on the ERC-20, then calls exec, which grants an ephemeral allowance, makes the call, and zeroes the allowance before returning. No signature, no standing approval to the consuming contract. It is deliberately standalone and unprivileged — exec makes an arbitrary call from the holder's address, so anything the holder is trusted with, everyone is trusted with. Folding it into Permit3 would be a total bypass of the module gate, which is why it sits outside as its own contract with its own guards.

4. What the second book enables

One signature for a whole action. permitBatchWithWitness grants token allowances and taker allowances in a single EIP-712 message, each leg naming its own spender, with the whole grant bound to a witness — in practice, the hash of the action consuming it. The signature that opens the allowances is the same signature that authorizes what they are for, so it cannot be lifted onto a different action. A leveraged position that needs a borrow, a token pull and a deposit is one signature, not one signature plus two on-chain delegations plus an approval.

A cap on debt. Comet's allow and Morpho's setAuthorization are booleans; the protocol layer has no amount to enforce. With the taker book in front of them, the amount gate exists again, and it is denominated per position and per action.

Approvals a wallet can render. approveTaker(module, ref, 1000e6, expiry) decomposes into an operation (the module address), a position (the ref preimage, which is the same bytes the module decodes), a cap and an expiry. That is enough to write "borrow up to 1,000 USDC from this Aave market, until Friday" into an approval dialog instead of "grant unlimited delegation."

Actions on positions become composable inputs. This is the structural one. Once authority over a position can be granted the same way authority over a balance is, a contract can compose them in a single transaction — borrow here, swap, supply there, repay elsewhere — without ever holding standing power over any of it. Each leg is separately capped and separately revocable.

Relayed and gasless flows. Because the allowance key is the module's own data, an EIP-2612 permit or a protocol delegation signature can ride along inside those bytes and be replayed atomically by the executor, removing the last pre-transaction the user would otherwise have to send.

Revocation that is actually reachable. revokeToken, revokeTaker, lockdown and lockdownTakers zero grants in batches, and invalidateUnorderedNonces cancels signed permits before anyone consumes them — one contract, one surface, both books.

What to watch out for

An infinite taker allowance is worse than an infinite token approval: token compromise drains balances, taker compromise can incur max-LTV debt and route the proceeds elsewhere. Approve-max is the wrong default here, and the UX should not offer it the way wallets do for ERC-20.

Revocation is two-layer. Permit3 gates the amount, but the protocol-native delegation still exists underneath, so fully locking out a module means revoking in both places. A revokeAll(module) helper that bundles the pair per protocol is on the list and not yet built.

And the ref is keccak256(data) with no module address mixed in, so two modules that decode the same layout — an Aave V2 and an Aave V3 borrow module both reading (pool, asset, rateMode) — share a ref. Consuming it still requires the user to have named the module in the action being executed, so it is not a bypass, but "one module, one blast radius" holds per signature rather than per allowance.

5. Looking forward: universal intents

We argued that intents shipped far narrower than they were pitched — that "declare any outcome" became "sign this swap." The usual explanation is that settlement engines are swap-shaped. They mostly are not: several of them execute arbitrary call arrays. The narrowness lives one layer up, in the language the user is allowed to sign.

Part of why that language stayed narrow is that there was only one thing a signature could safely authorize: move this token out of my wallet. Every general primitive underneath was gated by an authority model that could express nothing else. An intent format has no reason to offer a field for "borrow against my collateral" if signing it would mean handing over an unbounded, permanent delegation first.

ONE SIGNED INTENT “be 3× long ETH, stay above 1.4” one EIP-712 message one nonce, one witness no prior delegation wallet leg — pull USDC token book borrow leg — new debt taker book withdraw leg — collateral taker book supply / repay leg maker module ANY EXECUTOR the solver the user’s signature named every leg capped every leg expiring every leg revocable

With a second book, the input side of an intent stops being "tokens I am holding." It becomes any position the user can authorize a bounded draw against — which is what it takes to sign "refinance my Aave debt into whichever market is cheapest," "delever me to a health factor of 1.6," "migrate this position to Base," or "be 3× long and keep me solvent." Those are not swaps with extra fields. They are intents whose inputs are positions, and until the authority model could express a capped, expiring, position-scoped grant, there was no safe way for a user to sign one.

That is the whole reason Permit3 exists on our side: a general settlement layer is only as general as the authority a user can hand it. We have the settlement engine, and now the approval model underneath it speaks the same vocabulary — legs that are tokens and legs that are positions, gated identically, revocable identically, in one signature.

The parts still missing are known and small. permitTake — a signature that authorizes exactly one module dispatch without leaving an allowance behind, the taker-book counterpart to permitTransferFrom — would remove the last storage write on the single-signature path. revokeAll would collapse the two-layer revocation into one call. Neither changes the shape.

What does change the shape is a wallet that shows position allowances the way it shows token allowances today.

Where to go next

← All posts