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. Without one, an intent cannot name anything but a token as its input.


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 vocabulary collapses: "1000 USDC, expires Friday, revocable in one call" becomes "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. What follows is the gap itself, how the second book closes it, how to integrate it into any contract (not just ours), and why intents that express more than a swap depend on it.

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);

Those grants share a shape. 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, module, 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 new idea is what identifies a position: the module that will act, plus a ref derived from the exact bytes it will decode.

bytes32 ref = keccak256(data);   // == permit3.refFor(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.

The module sits in the key alongside the ref, not just in the data. That matters because those data layouts are deliberately minimal, so two different modules can decode the same bytes to the same ref - an Aave V2 and an Aave V3 borrow module both read (pool, asset, rateMode). Keying on (module, ref) gives each its own allowance bucket: a grant to a borrow module can never be spent dispatching a withdraw module, whatever the data. "One module, one blast radius" holds per allowance, not merely per signature.

Spending it looks like this:

// user, once per position - names the module, and the same bytes the executor will pass
permit3.approveTaker(spender, module, 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][module][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 keyed by both spender and module, a standing taker allowance can only ever be consumed by the contract the user named, dispatching the module the user named - a third party, or the same spender pointed at a different module, has no allowance under that key 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, module, ref, …) or one signature for both books ② PERMIT3 — TWO BOOKS TOKEN BOOK (user, spender, token) → amount, expiry TAKER BOOK (user, spender, module, 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 everything protocol-specific, and each one performs exactly one operation. That is the property that makes an approval readable: 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 needs explaining. 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(spender, module, ref, 1000e6, expiry) decomposes into an operation (the module address, a signed field in its own right), 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." A module can go further and implement an optional describe(data) view that turns the ref into that sentence, and the settlement lens surfaces the live per-position allowance an order will draw, so a frontend never has to reconstruct either.

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, invalidateUnorderedNonces cancels signed permits before anyone consumes them, and lockdownAll does all three at once - token grants, taker grants and signed-permit nonces revoked in a single transaction. 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 - lockdownAll covers the Permit3 side in one call, and the per-protocol delegation is the only leg that remains per-protocol.

A subtler two-layer case is the funding fallback. When a spender pulls a token, Permit3 tries the allowance book first and, if that leg fails, falls back to a direct ERC-20 approval the payer may also hold - so for a payer holding both, revoking the Permit3 side alone does not stop the pull. That is deliberate (a direct approval is the broader grant), but a payer who wants Permit3 to be the binding gate can opt into a strict mode that refuses the fallback, turning revokeToken and expiries back into real kill switches.

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 the approval model underneath it now covers the same set of legs - 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 - now exists as a hub primitive; what remains is wiring it into the settlement fill loop, which today dispatches every position leg through a standing allowance, so a one-shot draw needs that loop to be permit-aware. Batch revocation across both books already ships as lockdownAll. 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