Module 3: Modern Token Approval Patterns
Difficulty: Intermediate
Estimated reading time: ~40 minutes | Exercises: ~5-6 hours
π Table of Contents
The Approval Problem
- Why Traditional Approvals Are Broken
- EIP-2612 β Permit
- OpenZeppelin ERC20Permit
- Build Exercise: PermitVault
Permit2
- How Permit2 Works
- SignatureTransfer vs AllowanceTransfer
- Permit2 Design Details
- Reading Permit2 Source Code
- Build Exercise: Permit2Vault
Security
π‘ The Approval Problem and EIP-2612
π‘ Concept: Why Traditional Approvals Are Broken
Why this matters: Every DeFi user has experienced the friction: βApprove USDCβ β wait β βSwap USDCβ β wait. This two-step dance isnβt just annoyingβit costs billions in wasted gas annually and creates a massive attack surface. Users who approved a protocol in 2021 still have active unlimited approvals today, forgotten but exploitable.
The problems with ERC-20 approve β transferFrom:
| Problem | Impact | Example |
|---|---|---|
| Two transactions per interaction | 2x gas costs, poor UX | Approve tx alone costs ~46k gas (21k base + ~25k execution) |
| Infinite approvals as default | All tokens at risk if protocol hacked | π° Euler Finance (March 2023): $197M drained |
| No expiration | Forgotten approvals persist forever | Approvals from 2020 still active today |
| No batch revocation | 1 tx per token per spender to revoke | Users have 50+ active approvals on average |
π¨ Real-world impact:
When protocols get hacked (Euler Finance March 2023, KyberSwap November 2023), attackers drain not just deposited funds but all tokens users have approved. The approval system turns every protocol into a potential honeypot.
β‘ Check your own approvals: Visit Revoke.cash and see how many active unlimited approvals you have. Most users are shocked.
π DeFi Pattern Connection
Where the approval problem hits hardest:
-
DEX Routers (Uniswap, 1inch, Paraswap)
- Users approve the router contract with unlimited amounts
- Router gets upgraded β old router still has active approvals
- Attack surface grows with every protocol upgrade
-
Lending Protocols (Aave, Compound)
- Users approve the lending pool to pull collateral
- Pool gets exploited β all approved tokens at risk, not just deposited ones
- Euler Finance ($197M hack) exploited exactly this pattern
-
Yield Aggregators (Yearn, Beefy)
- Users approve the vault β vault approves the strategy β strategy approves the underlying protocol
- Chain of approvals: one weak link compromises everything
- This is why approval hygiene became a security requirement
The evolution:
2017-2020: approve(MAX_UINT256) everywhere β "set it and forget it"
2021-2022: approve(exact amount) gaining traction β better but still 2 txs
2023+: Permit2 β single approval, signature-based, expiring
πΌ Job Market Context
Interview question you WILL be asked:
βWhatβs wrong with the traditional ERC-20 approval model?β
What to say (30-second answer): βThree fundamental problems: two transactions per interaction wastes gas and creates UX friction; infinite approvals create a persistent attack surface where a protocol hack drains all approved tokens, not just deposited ones; and no built-in expiration means forgotten approvals from years ago remain exploitable. Permit2 solves all three by centralizing approval management with signature-based, time-bounded permits.β
Follow-up question:
βHow would you handle approvals in a protocol youβre building today?β
What to say: βIβd integrate Permit2 as the primary token ingress path with a fallback to standard approve for edge cases. For protocols that still need direct approvals, Iβd enforce exact amounts instead of unlimited, and emit events that frontends can use to help users track and revoke.β
Interview Red Flags:
- π© βJust use
approve(type(uint256).max)β β shows no security awareness - π© Not knowing about Permit2 in 2025-2026
- π© Canβt explain the Euler Finance attack vector
Pro tip: Check Revoke.cash for your own wallet before interviews. Being able to say βI had 47 active unlimited approvals and revoked them all last weekβ shows you practice what you preach β security-conscious teams love that.
π‘ Concept: EIP-2612 β Permit
Why this matters: Single-transaction UX is table stakes in 2025-2026. Protocols that still require two transactions lose users to competitors. EIP-2612 unlocks the βapprove + action in one clickβ experience users expect.
Introduced in EIP-2612, formalized EIP-712 typed data signing
What it does:
EIP-2612 introduced permit()βa function that allows approvals via EIP-712 signed messages instead of on-chain transactions:
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external;
The user signs a message off-chain (free, no gas), and anyone can submit the signature on-chain to set the approval. This enables single-transaction flows: the dApp collects the permit signature, then calls a function that first executes the permit and then performs the operationβall in one transaction.
How it works under the hood:
- Token contract stores a
noncesmapping and exposes aDOMAIN_SEPARATOR(EIP-712) - User signs an EIP-712 typed data message containing: owner, spender, value, nonce, deadline
- Anyone can call
permit()with the signature - Contract verifies the signature via
ecrecover, checks the nonce and deadline, and sets the allowance - Nonce increments to prevent replay β¨
π The critical limitation:
The token contract itself must implement EIP-2612. Tokens deployed before the standard (USDT, WETH on Ethereum mainnet, most early ERC-20s) donβt support it. This is the gap that Permit2 fills.
| Token | Ethereum Mainnet | Polygon | Arbitrum | Optimism |
|---|---|---|---|---|
| USDC | β Has permit (V2.2+) | β Has permit | β Has permit | β Has permit |
| USDT | β No permit | β No permit | β No permit | β No permit |
| WETH | β No permit | β Has permit | β Has permit | β Has permit |
| DAI | β Has permit* | β Has permit | β Has permit | β Has permit |
*DAIβs permit predates EIP-2612 but inspired it. USDC mainnet gained permit support via the FiatToken V2.2 proxy upgrade (domain: {name: "USDC", version: "2"}).
β‘ Common pitfall: Not all tokens support permit β USDT doesnβt on any chain, and WETH on Ethereum mainnet (the original WETH9 contract from 2017) doesnβt either. Try calling
DOMAIN_SEPARATOR()via staticcall before assuming permit support β if it reverts, the token doesnβt implement EIP-2612. Note:supportsInterfacedoes NOT work for EIP-2612 detection because the standard doesnβt define an interface ID. Even tokens that DO support permit may use different domain versions (e.g., USDC usesversion: "2").
π» Quick Try:
Check if a token supports EIP-2612 on Etherscan. Search for any token (e.g., UNI):
- Go to βRead Contractβ
- Look for
DOMAIN_SEPARATOR()β if it exists, the token supports EIP-712 signing - Look for
nonces(address)β if it exists alongside DOMAIN_SEPARATOR, it supports EIP-2612 - Try calling
DOMAIN_SEPARATOR()and decode the result β youβll see the chain ID, contract address, name, and version baked in
Now try the same with USDT β no DOMAIN_SEPARATOR, no nonces. This is why Permit2 exists.
π Deep Dive: EIP-712 Domain Separator Structure
Why this matters: The domain separator is the security anchor for all permit signatures. It prevents cross-chain and cross-contract replay attacks. Understanding its structure is essential for debugging signature failures.
Visual structure:
DOMAIN_SEPARATOR = keccak256(abi.encode(
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β keccak256("EIP712Domain(string name,string version, β
β uint256 chainId,address verifyingContract)") β
β β
β keccak256(bytes("USD Coin")) β token name β
β keccak256(bytes("2")) β version string β
β 1 β chainId (mainnet) β
β 0xA0b8...eB48 β contract address β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
))
The full permit digest (what the user actually signs):
digest = keccak256(abi.encodePacked(
"\x19\x01", β EIP-191 prefix (prevents raw tx collision)
DOMAIN_SEPARATOR, β binds to THIS contract on THIS chain
keccak256(abi.encode(
ββββββββββββββββββββββββββββββββββββββββββββββ
β PERMIT_TYPEHASH β
β owner: 0xAlice... β
β spender: 0xVault... β
β value: 1000000 (1 USDC) β
β nonce: 0 (first permit) β
β deadline: 1700000000 (expiration) β
ββββββββββββββββββββββββββββββββββββββββββββββ
))
))
Why each field matters:
\x19\x01: Prevents the signed data from being a valid Ethereum transaction (security critical)- chainId: Same contract on Ethereum vs Arbitrum produces different digests β no cross-chain replay
- verifyingContract: Signature for USDC canβt be replayed on DAI
- nonce: Increments after each use β no same-contract replay
- deadline: Limits time window β forgotten signatures expire
Common debugging scenario:
"Invalid signature" error? Check:
1. Is DOMAIN_SEPARATOR computed with the correct chainId? (fork vs mainnet)
2. Is the nonce correct? (check token.nonces(owner))
3. Is the typehash correct? (exact string match required)
4. Did you use \x19\x01 prefix? (not \x19\x00)
ποΈ Real usage:
Most modern tokens implement EIP-2612:
- DAI was the first (DAIβs
permitpredates EIP-2612 but inspired it) - Uniswap V2 LP tokens
- All OpenZeppelin ERC20Permit tokens
π Read: OpenZeppelinβs ERC20Permit Implementation
Source: @openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol
π How to Study ERC20Permit:
-
Start with
EIP712.solβ the domain separator base contract- Find where
_domainSeparatorV4()is computed - Trace how
chainIdandaddress(this)get baked in - This is the security anchor β understand it before
permit()
- Find where
-
Read
Nonces.solβ replay protection- Simple: a
mapping(address => uint256)that increments - Note: sequential nonces (0, 1, 2β¦) β contrast with Permit2βs bitmap nonces later
- Simple: a
-
Read
ERC20Permit.permit()β the core function- Follow the flow: build struct hash β build digest β
ecrecoverβ_approve - Map each line to the EIP-712 visual diagram above
- Notice: the function is ~10 lines. The complexity is in the standard, not the code
- Follow the flow: build struct hash β build digest β
-
Compare with DAIβs permit β the non-standard variant
- DAI uses
allowed(bool) instead ofvalue(uint256) - Different function signature = different selector
- This is why production code needs to handle both
- DAI uses
Donβt get stuck on: The _useNonce internal function β itβs just return nonces[owner]++. Focus on understanding the full digest construction flow.
π Deep dive: Read EIP-712 to understand how typed data signing prevents phishing (compared to raw
personal_sign). The domain separator binds signatures to specific contracts on specific chains. QuickNode - EIP-2612 Permit Guide provides a hands-on tutorial. Cyfrin Updraft - EIP-712 covers typed structured data hashing with security examples.
π DeFi Pattern Connection
Where EIP-2612 permit appears in production:
-
Aave V3 Deposits
// Single-tx deposit: permit + supply in one call function supplyWithPermit( address asset, uint256 amount, address onBehalfOf, uint16 referralCode, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external;Aaveβs Pool contract calls
IERC20Permit(asset).permit(...)thensafeTransferFromβ same pattern youβll build in the PermitVault exercise. -
Uniswap V2 LP Token Removal
- Uniswap V2 LP tokens implement EIP-2612
- Users can sign a permit to approve the router, then remove liquidity in one transaction
- This was one of the earliest production uses of permit
-
OpenZeppelinβs
ERC20Wrapper- Wrapped tokens (like WETH alternatives) use permit for gasless wrapping
depositForwith permit = wrap + deposit atomically
The limitation that led to Permit2: All these only work if the token itself implements EIP-2612. For tokens like USDT, WETH (mainnet), and thousands of pre-2021 tokens β youβre back to two transactions. This gap is exactly what Permit2 fills (next topic).
Connection to Module 1: The EIP-712 typed data signing uses
abi.encodefor struct hashing β the same encoding you studied withabi.encodeCall. Custom errors (Module 1) are also critical here: permit failures need clear error messages for debugging.
πΌ Job Market Context
Interview question you WILL be asked:
βExplain how EIP-2612 permit works.β
What to say (30-second answer):
βEIP-2612 adds a permit function to ERC-20 tokens that accepts an EIP-712 signed message instead of an on-chain approve transaction. The user signs a typed data message containing the spender, amount, nonce, and deadline off-chain β which is free β and anyone can submit that signature on-chain to set the allowance. This enables single-transaction flows where the protocol calls permit and transferFrom in the same tx.β
Follow-up question:
βWhatβs the relationship between EIP-712 and EIP-2612?β
What to say: βEIP-712 is the general standard for typed structured data signing β it defines domain separators and type hashes that prevent cross-chain and cross-contract replay. EIP-2612 is a specific application of EIP-712 for token approvals. The domain separator includes chainId and the token contract address, so a USDC permit on Ethereum canβt be replayed on Arbitrum.β
Interview Red Flags:
- π© Confusing EIP-2612 with Permit2 β theyβre different systems
- π© Not knowing that many tokens donβt support permit (USDT, mainnet WETH)
- π© Canβt explain the role of the domain separator
Pro tip: Knowing the DAI permit story shows depth β DAI had permit() before EIP-2612 existed and actually inspired the standard, but uses a slightly different signature format (allowed boolean instead of value uint256). This is a common gotcha in production code.
β οΈ The Classic Approve Race Condition
Before EIP-2612, there was already a well-known vulnerability with approve():
// Scenario: Alice approved Bob for 100 tokens, now wants to change to 50
// Step 1: Alice calls approve(Bob, 50)
// Step 2: Bob sees the pending tx and front-runs with transferFrom(Alice, Bob, 100)
// Step 3: Alice's approve(50) executes β Bob now has 50 allowance
// Step 4: Bob calls transferFrom(Alice, Bob, 50)
// Result: Bob stole 150 tokens instead of the intended 100β50 change
Production pattern: Always approve to 0 first, then approve the new amount:
token.approve(spender, 0); // Reset to zero
token.approve(spender, newAmount); // Set new value
OpenZeppelinβs forceApprove handles this automatically. EIP-2612 avoids this entirely because each permit signature is nonce-bound β you canβt βchangeβ a permit, you just sign a new one with the next nonce.
β οΈ Common Mistakes
// β WRONG: Assuming all tokens support permit
function deposit(address token, uint256 amount, ...) external {
IERC20Permit(token).permit(...); // Reverts for USDT, mainnet WETH, etc.
IERC20(token).transferFrom(msg.sender, address(this), amount);
}
// β
CORRECT: Check permit support or use try/catch with fallback
function deposit(address token, uint256 amount, ...) external {
try IERC20Permit(token).permit(...) {} catch {}
// Falls back to pre-existing allowance if permit isn't supported
IERC20(token).transferFrom(msg.sender, address(this), amount);
}
// β WRONG: Hardcoding DOMAIN_SEPARATOR β breaks on chain forks
bytes32 constant DOMAIN_SEP = 0xabc...; // Computed at deployment on chain 1
// β
CORRECT: Recompute if chainId changes (OpenZeppelin pattern)
function DOMAIN_SEPARATOR() public view returns (bytes32) {
if (block.chainid == _CACHED_CHAIN_ID) return _CACHED_DOMAIN_SEPARATOR;
return _buildDomainSeparator(); // Recompute for different chain
}
// β WRONG: Not checking the nonce before building the digest
bytes32 digest = buildPermitDigest(owner, spender, value, 0, deadline);
// ^ hardcoded nonce 0!
// β
CORRECT: Always read the current nonce from the token
uint256 nonce = token.nonces(owner);
bytes32 digest = buildPermitDigest(owner, spender, value, nonce, deadline);
π― Build Exercise: PermitVault
Workspace: workspace/src/part1/module3/exercise1-permit-vault/ β starter file: PermitVault.sol, tests: PermitVault.t.sol
- Create an ERC-20 token with EIP-2612 permit support (extend OpenZeppelinβs
ERC20Permit) - Write a
Vaultcontract that accepts deposits via permitβa single function that callspermit()thentransferFrom()in one transaction - Write Foundry tests using
vm.sign()to generate valid permit signatures:
function testDepositWithPermit() public {
// vm.createWallet or use vm.addr + vm.sign
(address user, uint256 privateKey) = makeAddrAndKey("user");
// Build the permit digest
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01",
token.DOMAIN_SEPARATOR(),
keccak256(abi.encode(
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"),
user,
address(vault),
amount,
token.nonces(user),
deadline
))
));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);
vault.depositWithPermit(amount, deadline, v, r, s);
}
- Test edge cases:
- Expired deadline (should revert)
- Wrong nonce (should revert)
- Signature replay (second call with same signature should revert)
π― Goal: Understand the full signature flow from construction to verification. This is the foundation for Permit2.
π Summary: The Approval Problem
β Covered:
- Traditional approval problems β 2 transactions, infinite approvals, no expiration
- EIP-2612 permit β off-chain signatures for approvals
- EIP-712 typed data β domain separators prevent replay attacks
- Token compatibility β not all tokens support permit
Next: Permit2, the universal approval infrastructure used by Uniswap V4, UniswapX, and modern DeFi
π‘ Permit2 β Universal Approval Infrastructure
π‘ Concept: How Permit2 Works
Why this matters: Permit2 is now the standard for token approvals in modern DeFi. Uniswap V4, UniswapX, Cowswap, 1inch, and most protocols launched after 2023 use it. Understanding Permit2 is non-negotiable for reading production code.
Deployed by Uniswap Labs, canonical deployment at
0x000000000022D473030F116dDEE9F6B43aC78BA3(same address on all EVM chains)
The key insight:
Instead of requiring every token to implement permit(), Permit2 sits as a middleman. Users approve Permit2 once per token (standard ERC-20 approve), and then Permit2 manages all subsequent approvals via signatures.
Traditional: User β approve(Protocol A) β approve(Protocol B) β approve(Protocol C)
Permit2: User β approve(Permit2) [once per token, forever]
Then: sign(permit for Protocol A) β sign(permit for Protocol B) β ...
Why this is genius:
- β Works with any ERC-20 (no permit support required)
- β One on-chain approval per token, ever
- β All subsequent protocol interactions use free off-chain signatures
- β Built-in expiration and revocation
π» Quick Try:
Check Permit2βs deployment on Etherscan:
- Go to βRead Contractβ β call
DOMAIN_SEPARATOR()β compare it to your tokenβs domain separator. Different contracts, different domains - Check the βWrite Contractβ tab β find
permitTransferFromandpermit(the two modes) - Try
nonceBitmap(address,uint256)with your address and word index0β youβll see0(no nonces used). After using a Permit2-integrated dApp, check again
Now go to Revoke.cash and search your wallet address. Look for βPermit2β in the approvals list β if youβve used Uniswap recently, youβll see a max approval to Permit2 for each token youβve traded.
π Intermediate Example: Permit2 vs EIP-2612 Side by Side
Before diving into Permit2βs internals, see how the two approaches differ from a protocol developerβs perspective:
// ββ Approach 1: EIP-2612 (only works if token supports permit) ββ
function depositWithPermit(
IERC20Permit token, uint256 amount,
uint256 deadline, uint8 v, bytes32 r, bytes32 s
) external {
// Step 1: Execute the permit on the TOKEN contract
token.permit(msg.sender, address(this), amount, deadline, v, r, s);
// Step 2: Transfer tokens (now approved)
IERC20(address(token)).transferFrom(msg.sender, address(this), amount);
}
// ββ Approach 2: Permit2 (works with ANY ERC-20) ββ
function depositWithPermit2(
ISignatureTransfer.PermitTransferFrom calldata permit,
ISignatureTransfer.SignatureTransferDetails calldata details,
bytes calldata signature
) external {
// Single call: Permit2 verifies signature AND transfers tokens
PERMIT2.permitTransferFrom(permit, details, msg.sender, signature);
// That's it β Permit2 handled everything
}
Key differences:
| EIP-2612 | Permit2 | |
|---|---|---|
| Token requirement | Must implement permit() | Any ERC-20 |
| On-chain calls | permit() + transferFrom() | One call to Permit2 |
| Signature target | Token contract | Permit2 contract |
| Nonce system | Sequential (0, 1, 2, β¦) | Bitmap (any order) |
| Adoption | Tokens that opted in | Universal (any ERC-20) |
π DeFi Pattern Connection
Where Permit2 is now standard:
-
Uniswap V4 β all token transfers go through Permit2
- The PoolManager doesnβt call
transferFromon tokens directly - Permit2 is the single token ingress/egress point
- Combined with flash accounting (Module 2), this means: sign once, swap through multiple pools, settle once
- The PoolManager doesnβt call
-
UniswapX β intent-based trading built on witness data
- Users sign a Permit2 permit that includes swap order details as witness
- Fillers (market makers) can execute the order and receive tokens atomically
- This is the foundation of the βintentβ paradigm youβll study in Part 3
-
Cowswap β batch auctions with Permit2
- Users sign permits for their sell orders
- Solvers batch-settle multiple orders in one transaction
- Permit2βs bitmap nonces enable parallel order collection
-
1inch Fusion β similar intent-based architecture
- Permit2 enables gasless limit orders
- Users sign, resolvers execute
The pattern: If youβre building a DeFi protocol in 2025-2026, Permit2 integration is expected. Protocols that still require direct approve are considered legacy.
Connection to Module 2: Permit2 + transient storage = Uniswap V4βs entire token flow. Users sign Permit2 permits, the PoolManager tracks deltas in transient storage (flash accounting), and settlement happens once at the end.
πΌ Job Market Context
Interview question you WILL be asked:
βHow does Permit2 work and why is it better than EIP-2612?β
What to say (30-second answer): βPermit2 is a universal approval infrastructure deployed by Uniswap. Users do one standard ERC-20 approve to the Permit2 contract per token, then all subsequent protocol interactions use EIP-712 signed messages. It has two modes: SignatureTransfer for one-time stateless permits with bitmap nonces that enable parallel signatures, and AllowanceTransfer for persistent time-bounded allowances packed into single storage slots. The key advantage over EIP-2612 is universality β it works with any ERC-20, not just tokens that implement permit.β
Follow-up question:
βWhatβs the risk of everyone approving a single contract like Permit2? Isnβt that a single point of failure?β
What to say: βValid concern. Permit2 is a singleton β if it had a critical bug, every protocol and user relying on it would be affected. The tradeoff is that one heavily-audited, immutable contract is easier to secure than thousands of individual protocol approvals. Permit2 is non-upgradeable (no proxy), has been audited multiple times, and has held billions in effective approvals since 2022 without incident. The risk is concentrated but well-managed, versus the traditional model where risk is scattered across many less-audited contracts.β
Interview Red Flags:
- π© βPermit2 is just Uniswapβs version of permitβ β shows superficial understanding
- π© Not knowing the difference between SignatureTransfer and AllowanceTransfer
- π© Canβt explain why Permit2 uses bitmap nonces instead of sequential
Pro tip: Mention that Permit2 is deployed at the same address on every EVM chain (0x000000000022D473030F116dDEE9F6B43aC78BA3) using CREATE2. This detail shows you understand deployment patterns and cross-chain consistency β topics covered in Module 7.
π‘ Concept: SignatureTransfer vs AllowanceTransfer
Permit2 has two modes of operation, implemented as two logical components within a single contract:
π SignatureTransfer β One-time, stateless permits
The user signs a message authorizing a specific transfer. The signature is consumed in the transaction and can never be replayed (nonce-based). No approval state is stored.
Best for: Infrequent interactions, maximum security (e.g., one-time swap, NFT purchase)
interface ISignatureTransfer {
struct PermitTransferFrom {
TokenPermissions permitted; // token address + max amount
uint256 nonce; // unique per-signature, bitmap-based
uint256 deadline; // expiration timestamp
}
struct TokenPermissions {
address token;
uint256 amount;
}
struct SignatureTransferDetails {
address to; // recipient
uint256 requestedAmount; // actual amount (β€ permitted amount)
}
function permitTransferFrom(
PermitTransferFrom memory permit,
SignatureTransferDetails calldata transferDetails,
address owner,
bytes calldata signature
) external;
}
π AllowanceTransfer β Persistent, time-bounded allowances
More like traditional approvals but with expiration and better batch management. The user signs a permit to set an allowance, then the spender can transfer within that allowance until it expires.
Best for: Frequent interactions (e.g., a DEX router you use regularly)
interface IAllowanceTransfer {
struct PermitSingle {
PermitDetails details;
address spender;
uint256 sigDeadline;
}
struct PermitDetails {
address token;
uint160 amount; // Note: uint160, not uint256
uint48 expiration; // When the allowance expires
uint48 nonce; // Sequential nonce
}
function permit(
address owner,
PermitSingle memory permitSingle,
bytes calldata signature
) external;
function transferFrom(
address from,
address to,
uint160 amount,
address token
) external;
}
π‘ Concept: Permit2 Design Details
Key design decisions to understand:
1. Bitmap nonces (SignatureTransfer):
Instead of sequential nonces, SignatureTransfer uses a bitmapβeach nonce is a single bit in a 256-bit word. This means nonces can be consumed in any order, enabling parallel signature collection. The nonce space is (wordIndex, bitIndex)βeffectively unlimited unique nonces.
Why this matters: UniswapX collects multiple signatures from users for different orders in parallel. Bitmap nonces mean order1 can settle before order2 even if it was signed later. β¨
π Deep Dive: Bitmap Nonces β How They Work
The problem with sequential nonces:
EIP-2612 nonces: 0 β 1 β 2 β 3 β ...
User signs order A (nonce 0) and order B (nonce 1) in parallel.
Order B CANNOT execute β it needs nonce 1, but current nonce is 0.
Order A MUST go first. If order A fails or gets stuck β order B is also blocked.
Sequential nonces force serial execution β no parallelism possible.
Bitmap nonces solve this β any nonce can be used in any order:
Nonce value: uint256 β split into two parts
ββββββββββββββββββββββββββββββββββββ¬βββββββββββββββ
β Word index (bits 8-255) β Bit position β
β Which 256-bit word to use β (bits 0-7) β
β 248 bits β 2^248 words β 0-255 β
ββββββββββββββββββββββββββββββββββββ΄βββββββββββββββ
Example: nonce = 0x0000...0103
Word index = 0x0000...01 = 1 (second word)
Bit position = 0x03 = 3 (fourth bit)
Visual β consuming nonces in any order:
Nonce bitmap storage (per user, per spender):
Word 0: [0][0][0][0][0][0][0][0] ... [0][0][0][0] β 256 bits
Word 1: [0][0][0][0][0][0][0][0] ... [0][0][0][0] β 256 bits
Word 2: [0][0][0][0][0][0][0][0] ... [0][0][0][0] β 256 bits
...
Step 1: User signs order A with nonce 259 (word=1, bit=3)
Word 1: [0][0][0][1][0][0][0][0] ... [0][0][0][0] β bit 3 flipped!
Step 2: User signs order B with nonce 2 (word=0, bit=2)
Word 0: [0][0][1][0][0][0][0][0] ... [0][0][0][0] β bit 2 flipped!
Step 3: Order B settles FIRST (nonce 2) β β
works!
Step 4: Order A settles SECOND (nonce 259) β β
also works!
Sequential nonces would have failed at step 3.
The Solidity implementation:
// Simplified from Permit2's _useUnorderedNonce
function _useUnorderedNonce(address from, uint256 nonce) internal {
// Split nonce into word index and bit position
uint256 wordIndex = nonce >> 8; // First 248 bits
uint256 bitIndex = nonce & 0xff; // Last 8 bits (0-255)
uint256 bit = 1 << bitIndex; // Create bitmask
// Load the bitmap word
uint256 word = nonceBitmap[from][wordIndex];
// Check if already used
if (word & bit != 0) revert InvalidNonce(); // Bit already set!
// Mark as used (flip the bit)
nonceBitmap[from][wordIndex] = word | bit;
}
Why this is clever:
- Each 256-bit word stores 256 individual nonces β gas efficient (one SLOAD for 256 nonces)
- 2^248 possible words β effectively unlimited nonce space
- Any order of consumption β enables parallel signature collection
- One storage read + one storage write per nonce check
π Deep dive: Uniswap - SignatureTransfer Reference explains how the bitmap stores 256 bits per word, with the first 248 bits of the nonce selecting the word and the last 8 bits selecting the bit position.
2. uint160 amounts (AllowanceTransfer):
Allowances are stored as uint160, not uint256. This allows packing the amount, expiration (uint48), and nonce (uint48) into a single storage slot for gas efficiency.
// β
Packed storage: 160 + 48 + 48 = 256 bits (one slot)
struct PackedAllowance {
uint160 amount;
uint48 expiration;
uint48 nonce;
}
π Deep Dive: Packed AllowanceTransfer Storage
The problem: Storing allowance state naively costs 3 storage slots (amount, expiration, nonce) = 60,000+ gas for a cold write. By packing into one slot: 20,000 gas. Thatβs 3x savings per permit.
Memory layout (one storage slot = 256 bits):
ββββββββββββββββββββββββββββββββββββββ¬βββββββββββββββ¬βββββββββββββββ
β amount (160 bits) β expiration β nonce β
β β (48 bits) β (48 bits) β
β Max: 2^160 - 1 β Max: 2^48-1 β Max: 2^48-1 β
β β 1.46 Γ 10^48 tokens β β year 8.9M β β 281T noncesβ
ββββββββββββββββββββββββββββββββββββββΌβββββββββββββββΌβββββββββββββββ€
β bits 96-255 β bits 48-95 β bits 0-47 β
ββββββββββββββββββββββββββββββββββββββ΄βββββββββββββββ΄βββββββββββββββ
256 bits total (1 slot)
Why uint160 is enough:
- ERC-20
totalSupplyis uint256, but no real token has more than ~10^28 tokens - uint160 max β 1.46 Γ 10^48 β billions of times larger than any token supply
- The tradeoff is negligible: slightly smaller theoretical max for 3x gas savings
Why uint48 expiration is enough:
- uint48 max = 281,474,976,710,655
- As a Unix timestamp: thatβs approximately year 8,921,556
- Safe for ~7 million years of expiration timestamps
Why uint48 nonces are enough:
- AllowanceTransfer uses sequential nonces (unlike SignatureTransferβs bitmaps)
- uint48 max β 281 trillion
- At 1 permit per second: lasts 8.9 million years
- In practice, a user might use a few thousand nonces in their lifetime
Comparison to Module 1βs BalanceDelta:
| BalanceDelta | PackedAllowance | |
|---|---|---|
| Total size | 256 bits | 256 bits |
| Packing | 2 Γ int128 | uint160 + uint48 + uint48 |
| Purpose | Two token amounts | Amount + time + counter |
| Access pattern | Bit shifting | Struct packing (Solidity handles it) |
Connection to Module 1: This is the same slot-packing optimization you studied with
BalanceDeltain Module 1, but here Solidityβs struct packing handles the bit manipulation automatically β no manual shifting needed.
3. Witness data (permitWitnessTransferFrom):
SignatureTransfer supports an extended mode where the user signs not just the transfer details but also arbitrary βwitnessβ dataβextra context that the receiving contract cares about.
Example: UniswapX uses this to include the swap order details in the permit signature, ensuring the user approved both the token transfer and the specific swap parameters atomically.
// User signs: transfer 1000 USDC + witness: slippage=1%, path=USDCβWETH
function permitWitnessTransferFrom(
PermitTransferFrom memory permit,
SignatureTransferDetails calldata transferDetails,
address owner,
bytes32 witness, // Hash of extra data
string calldata witnessTypeString, // EIP-712 type definition
bytes calldata signature
) external;
π Deep dive: The witness pattern is central to intent-based systems. Read UniswapXβs ResolvedOrder to see how witness data encodes an entire swap order in the permit signature. Cyfrin - Full Guide to Implementing Permit2 provides step-by-step integration patterns.
πΌ Job Market Context: Permit2 Internals
Interview question:
βSignatureTransfer vs AllowanceTransfer β when would you use each?β
What to say (30-second answer): βSignatureTransfer for maximum security β each signature is consumed immediately with a unique nonce, no persistent state. Best for one-off operations like swaps or NFT purchases. AllowanceTransfer for convenience β set a time-bounded allowance once, then the protocol can pull tokens repeatedly until it expires. Best for protocols users interact with frequently, like a DEX router they use daily.β
Follow-up question:
βWhy does Permit2 use bitmap nonces instead of sequential?β
What to say: βSequential nonces force serial execution β if you sign order A (nonce 0) and order B (nonce 1), order B canβt settle before order A. Bitmap nonces use a bit-per-nonce model where any nonce can be consumed in any order. This is essential for intent-based systems like UniswapX, where users sign multiple orders that may be filled by different solvers at different times. Each nonce is a single bit in a 256-bit word, so one storage slot covers 256 unique nonces.β
Interview Red Flags:
- π© Canβt explain when to choose SignatureTransfer over AllowanceTransfer
- π© Doesnβt understand why bitmap nonces enable parallel execution
- π© Thinks AllowanceTransferβs uint160 amount is a limitation (itβs a deliberate packing optimization)
Pro tip: If youβre interviewing at a protocol that integrates Permit2, know which mode they use. Uniswap V4 uses SignatureTransfer (one-time, stateless). If the protocol has recurring interactions (like a lending pool), they likely use AllowanceTransfer. Showing you checked their codebase before the interview is a strong signal.
β οΈ Common Mistakes
// β WRONG: Using SignatureTransfer for frequent interactions
// User must sign a new permit for EVERY deposit β bad UX for daily users
function deposit(PermitTransferFrom calldata permit, ...) external {
PERMIT2.permitTransferFrom(permit, details, msg.sender, signature);
}
// β
BETTER: Use AllowanceTransfer for protocols users interact with regularly
// User sets a time-bounded allowance once, then deposits freely
function deposit(uint256 amount) external {
PERMIT2.transferFrom(msg.sender, address(this), uint160(amount), token);
}
// β WRONG: Forgetting that AllowanceTransfer uses uint160, not uint256
function deposit(uint256 amount) external {
// This will silently truncate amounts > type(uint160).max!
PERMIT2.transferFrom(msg.sender, address(this), uint160(amount), token);
}
// β
CORRECT: Validate the amount fits in uint160
function deposit(uint256 amount) external {
require(amount <= type(uint160).max, "Amount exceeds uint160");
PERMIT2.transferFrom(msg.sender, address(this), uint160(amount), token);
}
// β WRONG: Not approving Permit2 first β the one-time ERC-20 approve step
// Users need to: approve(PERMIT2, MAX) once per token BEFORE using permits
// Your dApp must check and prompt this approval
// β
CORRECT: Check Permit2 allowance in your frontend
// if (token.allowance(user, PERMIT2) == 0) β prompt approve tx
// Then use Permit2 signatures for all subsequent interactions
π Read: Permit2 Source Code
Source: github.com/Uniswap/permit2
Read these contracts in order:
src/interfaces/ISignatureTransfer.solβ the interface tells you the mental modelsrc/SignatureTransfer.solβ focus onpermitTransferFromand the nonce bitmap logic in_useUnorderedNoncesrc/interfaces/IAllowanceTransfer.solβ compare the interface to SignatureTransfersrc/AllowanceTransfer.solβ focus onpermit,transferFrom, and how allowance state is packedsrc/libraries/SignatureVerification.solβ handles EOA signatures, EIP-2098 compact signatures, and EIP-1271 contract signatures
EIP-2098 compact signatures: Standard ECDSA signatures are 65 bytes (
r[32] +s[32] +v[1]). EIP-2098 encodes them in 64 bytes by packingvinto the highest bit ofs(sincevis always 27 or 28, only 1 bit is needed). Permit2βsSignatureVerificationaccepts both formats β if the signature is 64 bytes, it extractsvfroms. This saves ~1 byte of calldata per signature (~16 gas), which adds up in batch operations.
ποΈ Read: Permit2 Integration in the Wild
Source: Uniswap Universal Router uses Permit2 for all token ingress.
Look at how V3SwapRouter calls permit2.permitTransferFrom to pull tokens from users who have signed permits. Compare this to the old V2/V3 routers that required approve first.
Real-world data: After Uniswap deployed Universal Router with Permit2 in November 2022, ~80% of swaps now use permit-based approvals instead of on-chain approves. Dune Analytics dashboard
π How to Study Permit2 Source Code
Start here β the 5-step approach:
-
Start with interfaces β
ISignatureTransfer.solandIAllowanceTransfer.sol- These tell you the mental model before implementation details
- Map the struct names to concepts:
PermitTransferFrom= one-time,PermitSingle= persistent
-
Read
SignatureTransfer.permitTransferFromβ follow one complete flow- Entry point β signature verification β nonce consumption β token transfer
- Focus on: what gets checked, in what order, and what reverts look like
-
Understand
_useUnorderedNonceβ the bitmap nonce system- This is the cleverest part β draw the bitmap on paper
- Trace through with a concrete nonce value (e.g., nonce = 515 β word 2, bit 3)
-
Read
AllowanceTransfer.permitandtransferFromβ compare with SignatureTransfer- Notice: permit sets state, transferFrom reads state (two-step)
- Contrast with SignatureTransfer where everything happens in one call
-
Study
SignatureVerification.solβ the signature validation library- Handles three signature types: standard (65 bytes), compact EIP-2098 (64 bytes), and EIP-1271 (smart contract)
- This connects directly to Module 4βs account abstraction β smart wallets use EIP-1271
Donβt get stuck on: The assembly optimizations in the verification library. Understand the concept first (verify signature β check nonce β transfer tokens), then revisit the low-level details.
What to look for:
- How errors are defined and when each one reverts
- The
witnessparameter inpermitWitnessTransferFromβ this is how UniswapX binds order data to signatures - How batch operations (
permitTransferFromfor arrays) reuse the single-transfer logic β Permit2 supportsPermitBatchTransferFromandPermitBatchfor multi-token transfers in a single signature, which is how protocols like 1inch and Cowswap handle complex multi-asset swaps
π― Build Exercise: Permit2Vault
Workspace: workspace/src/part1/module3/exercise2-permit2-vault/ β starter file: Permit2Vault.sol, tests: Permit2Vault.t.sol
Build a Vault contract that integrates with Permit2 for both transfer modes:
-
Setup: Fork mainnet in Foundry to interact with the deployed Permit2 contract at
0x000000000022D473030F116dDEE9F6B43aC78BA3 -
SignatureTransfer deposit: Implement
depositWithSignaturePermit()βthe user signs a one-time permit, the vault callspermitTransferFromon Permit2 to pull tokens -
AllowanceTransfer deposit: Implement
depositWithAllowancePermit()βthe user first signs an allowance permit (setting a time-bounded approval on Permit2), then the vault callstransferFromon Permit2 -
Witness data: Extend the SignatureTransfer version to include a
depositIdas witness dataβthe user signs both the transfer and the specific deposit theyβre authorizing -
Test both paths with Foundryβs
vm.sign()to generate valid EIP-712 signatures
// Hint: Permit2 is already deployed on mainnet
// Fork test setup:
function setUp() public {
vm.createSelectFork("mainnet");
permit2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
// ...
}
β οΈ Running these tests β mainnet fork required:
This exercise forks Ethereum mainnet to interact with the real, deployed Permit2 contract. You need an RPC endpoint:
# 1. Get a free RPC URL from one of these providers:
# - Alchemy: https://www.alchemy.com/ (free tier: 300M compute units/month)
# - Infura: https://www.infura.io/ (free tier: 100k requests/day)
# - Ankr: https://www.ankr.com/ (free public endpoint, slower)
# 2. Set it as an environment variable:
export MAINNET_RPC_URL="https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY"
# 3. Run the tests:
forge test --match-contract Permit2VaultTest --fork-url $MAINNET_RPC_URL -vvv
# Tip: Add the export to your .bashrc / .zshrc so you don't have to set it every session.
# You'll need this for many exercises later (Part 2 onwards) that fork mainnet.
The tests pin a specific block number (19_000_000) so results are deterministic β the first run downloads and caches that blockβs state, subsequent runs are fast.
- Gas savings:
- Traditional approve β deposit requires two on-chain transactions: approve (~46k gas) + deposit
- Permit2 SignatureTransfer: one transaction (signature is off-chain and free) β saves the entire approve tx
- The tests include a gas measurement to demonstrate this advantage
π― Goal: Hands-on with the Permit2 contract so you recognize its patterns when you see them in Uniswap V4, UniswapX, and other modern DeFi protocols. The witness data extension is particularly importantβitβs central to intent-based systems youβll study in Part 3.
π Summary: Permit2
β Covered:
- Permit2 architecture β SignatureTransfer vs AllowanceTransfer
- Bitmap nonces β parallel signature collection
- Packed storage β uint160 amounts for gas efficiency
- Witness data β binding extra context to permit signatures
- Real usage β 80% of Uniswap swaps use Permit2
Next: Security considerations and attack vectors
β οΈ Security Considerations and Edge Cases
π‘ Concept: Permit/Permit2 Attack Vectors
Why this matters: Signature-based approvals introduce new attack surfaces. The bad guys know these patternsβyou need to know them better.
π¨ 1. Signature replay:
If a signature isnβt properly scoped (chain ID, contract address, nonce), it can be replayed on other chains or after contract upgrades.
Protection:
- β EIP-712 domain separators prevent cross-contract/cross-chain replay
- β Nonces prevent same-contract replay
- β Deadlines limit time window
β‘ Common pitfall: Forgetting to include
block.chainidin your domain separator. Your signatures will be valid on all forks (Ethereum mainnet, Goerli, Sepolia with same contract address).
π¨ 2. Permit front-running:
A signed permit is public once submitted in a transaction. An attacker can extract the signature from the mempool and use it in their own transaction.
Example attack:
- Alice signs permit: approve 1000 USDC to VaultA
- Alice submits tx:
vaultA.depositWithPermit(...) - Attacker sees tx in mempool, extracts signature
- Attacker submits (with higher gas):
permit(...)β now Attacker can calltransferFrom
Protection:
- β
Permit2βs
permitTransferFromrequires a specifictoaddressβonly the designated recipient can receive the tokens - β οΈ AllowanceTransferβs
permit()can still be front-run to set the allowance early, but this just wastes the userβs gas (not a fund loss)
π¨ 3. Permit phishing:
An attacker tricks a user into signing a permit message that approves tokens to the attackerβs contract. The signed message looks harmless to the user but authorizes a transfer.
π° Real attacks:
- February 2023: βApprove Blur marketplaceβ phishing stole $230k
- March 2024: βPermit for airdrop claimβ phishing campaign
- 2024 total: $314M lost to permit phishing attacks
Protection:
- β Wallet UIs must clearly display what a user is signing
- β As a protocol: never ask users to sign permits for contracts they donβt recognize
- β User education: βIf you didnβt initiate the action, donβt signβ
β‘ Common pitfall: Your dAppβs UI shows βSign to depositβ but the permit is actually approving tokens to an intermediary contract. Users canβt verify the
spenderaddress. Be transparent about what the signature authorizes.
π Deep dive: Gate.io - Permit2 Phishing Analysis documents real attacks with $314M lost in 2024. Eocene - Permit2 Risk Analysis covers security implications. SlowMist - Examining Permit Signatures analyzes off-chain signature attack vectors.
π¨ 4. Nonce invalidation (self-service revocation):
Users can call Permit2βs invalidateUnorderedNonces(uint256 wordPos, uint256 mask) to proactively invalidate specific bitmap nonces β effectively revoking any pending SignatureTransfer permits that use those nonces. Note: only the nonce owner can call this function (it operates on msg.senderβs nonces), so this is not a griefing vector β itβs a safety feature.
When this matters:
- β User signed a permit but wants to cancel it before itβs used
- β User suspects their signature was leaked or phished
- β
Frontend should offer a βcancel pending permitβ button that calls
invalidateUnorderedNonces
π DeFi Pattern Connection
Where permit security matters across protocols:
-
Approval-Based Attack Surface
- Traditional approvals: each protocol is an independent attack vector
- Permit2: centralizes approval management β single point of audit, but also single point of failure
- If Permit2 had a bug, ALL protocols using it would be affected (hasnβt happened β itβs been extensively audited)
-
Cross-Protocol Phishing Campaigns
- Attackers target users of popular protocols (Uniswap, Aave, OpenSea)
- Fake βclaim airdropβ sites request permit signatures
- The signature looks legitimate (EIP-712 typed data) but authorizes tokens to the attacker
- This is why wallet signature display is a security-critical UX problem
-
MEV and Permit Front-Running
- Flashbots bundles can include permit transactions
- Searchers can extract permit signatures from the public mempool
- Production protocols must handle the case where someone else executes the permit first
- This is why the try/catch pattern (below) is mandatory, not optional
-
Smart Contract Wallet Compatibility
- EOAs sign with
ecrecover(v, r, s) - Smart wallets (ERC-4337, Module 4) sign with EIP-1271 (
isValidSignature) - Permit2βs
SignatureVerificationhandles both β future-proof - Your protocol must not assume signatures always come from EOAs
- EOAs sign with
The pattern: Signature-based systems shift the attack surface from on-chain (contract exploits) to off-chain (social engineering, phishing). Build defensively β always use try/catch for permits, validate all parameters, and never trust that a permit signature is βsafeβ just because itβs valid.
β οΈ Common Mistakes
Mistakes that get caught in audits:
-
Not wrapping permit in try/catch
// β WRONG: Reverts if permit was already used (front-run) token.permit(owner, spender, value, deadline, v, r, s); token.transferFrom(owner, address(this), value); // β CORRECT: Handle permit failure gracefully try token.permit(owner, spender, value, deadline, v, r, s) {} catch {} // If permit failed, maybe someone already executed it β check allowance token.transferFrom(owner, address(this), value); // Will fail if allowance insufficient -
Forgetting to validate deadline on your side
// β WRONG: Relying only on the token's deadline check function deposit(uint256 deadline, ...) external { token.permit(..., deadline, ...); // Token checks, but late revert wastes gas } // β CORRECT: Check deadline early to save gas on failure function deposit(uint256 deadline, ...) external { require(block.timestamp <= deadline, "Permit expired"); token.permit(..., deadline, ...); } -
Not handling DAIβs non-standard permit
// DAI uses: permit(holder, spender, nonce, expiry, allowed, v, r, s) // EIP-2612 uses: permit(owner, spender, value, deadline, v, r, s) // They have different function signatures and parameter types! // Production code needs to detect and handle both -
Using
msg.senderas the permit owner without verification// β WRONG: Anyone can submit someone else's permit function deposit(uint256 amount, ...) external { token.permit(msg.sender, ...); // What if the signature is for a different owner? } // β CORRECT: The permit's owner field must match // Or better: let Permit2 handle this β it verifies owner internally
πΌ Job Market Context
Interview question you WILL be asked:
βWhat are the security risks of signature-based approvals?β
What to say (30-second answer): βThree main risks: phishing, front-running, and implementation bugs. Phishing is the biggest β $314M was lost in 2024 to fake permit signature requests. Front-running is a protocol-level concern β if a permit signature is submitted publicly, someone can execute it before the intended transaction, so protocols must use try/catch and check allowances as fallback. Implementation risks include forgetting domain separator validation, mismatched nonces, and not supporting both EIP-2612 and Permit2 paths.β
Follow-up question:
βHow do you handle permit failures in production?β
What to say: βAlways wrap permit calls in try/catch. If the permit fails β whether from front-running, expiry, or the token not supporting it β check if the allowance is already sufficient and proceed with transferFrom. This pattern is used by OpenZeppelinβs SafeERC20 and is considered mandatory in production DeFi code.β
Follow-up question:
βHow would you protect users from permit phishing?β
What to say:
βOn the protocol side: use Permit2βs SignatureTransfer with a specific to address so tokens can only go to the intended recipient, not an attacker. Include witness data to bind the permit to a specific action. On the wallet side: clearly display what the user is signing β the spender address, amount, and expiration β in human-readable format. But ultimately, phishing is a UX problem more than a smart contract problem.β
Interview Red Flags:
- π© Not knowing about the try/catch pattern for permits
- π© βPermit is safe because it uses cryptographic signaturesβ β ignores phishing
- π© Canβt explain the difference between front-running a permit vs stealing funds
Pro tip: Mention the $314M lost to permit phishing in 2024. It shows you track real-world security incidents, not just theoretical attack vectors. DeFi security teams value practical awareness over academic knowledge.
π Read: OpenZeppelinβs SafeERC20 Permit Handling
Source: SafeERC20.sol
π How to Study SafeERC20.sol:
-
Start with
safeTransfer/safeTransferFromβ the simpler functions- See how they wrap low-level
.call()and check both success AND return data - This handles tokens that donβt return
bool(like USDT on mainnet)
- See how they wrap low-level
-
Read
forceApproveβ the non-obvious function- Some tokens (USDT) revert if you
approvewhen allowance is already non-zero forceApprovehandles this: triesapprove(0)first, thenapprove(amount)- This is a real production gotcha youβll encounter
- Some tokens (USDT) revert if you
-
Study the permit try/catch pattern β the security-critical function
- Look for how they handle permit failure as a non-fatal event
- The key insight: if permit fails (front-run, already used), check if allowance is already sufficient
- This is the defensive pattern every DeFi protocol should use
-
Trace one complete flow β deposit with permit
- User signs permit β protocol calls
safePermit()β if fails, fallback to existing allowance βsafeTransferFrom() - Draw this as a flowchart with the success and failure paths
- User signs permit β protocol calls
Donβt get stuck on: The assembly in _callOptionalReturn β itβs handling tokens with non-standard return values. Understand the concept (some tokens donβt return bool) and move on.
Pattern:
// β
SAFE: Handle permit failures gracefully
try IERC20Permit(token).permit(...) {
// Permit succeeded
} catch {
// Permit failed (already used, front-run, or token doesn't support it)
// Check if allowance is sufficient anyway
require(IERC20(token).allowance(owner, spender) >= value, "Insufficient allowance");
}
π― Build Exercise: SafePermit
Workspace: workspace/src/part1/module3/exercise3-safe-permit/ β starter file: SafePermit.sol, tests: SafePermit.t.sol
-
Write a test demonstrating permit front-running:
- User signs and submits permit
- Attacker intercepts signature from mempool
- Attacker uses signature first
- Userβs transaction fails or succeeds with reduced impact
-
Implement a safe permit wrapper that uses try/catch:
function safePermit(IERC20Permit token, ...) internal { try token.permit(...) { // Success } catch { // Check allowance is sufficient anyway require(token.allowance(owner, spender) >= value, "Permit failed and allowance insufficient"); } } -
Test with a non-EIP-2612 token (e.g., mainnet USDT):
- Verify your vault still works with the standard approve flow as a fallback
- Test graceful degradation: if permit is unavailable, require pre-approval
-
Phishing simulation:
- Create a malicious contract that requests permits
- Show how a user signing a βdepositβ permit could actually be approving a malicious spender
- Demonstrate what wallet UIs should display to prevent this
π― Goal: Understand the real security landscape of signature-based approvals so you build defensive patterns from the start.
π Summary: Security
β Covered:
- Signature replay protection β domain separators, nonces, deadlines
- Front-running attacks β how to prevent with Permit2βs design
- Phishing attacks β $314M lost in 2024, wallet UI responsibility
- Safe permit patterns β try/catch and graceful degradation
Key takeaway: Permit and Permit2 enable amazing UX but require defensive coding. Always use try/catch, validate signatures carefully, and never trust user-submitted permit data without verification.
π Cross-Module Concept Links
Backward references (β concepts from earlier modules):
| Module 3 Concept | Builds on | Where |
|---|---|---|
| EIP-712 typed data signing | abi.encode for struct hashing, abi.encodeCall for type safety | M1 β abi.encodeCall |
| Permit failure errors | Custom errors for clear revert reasons | M1 β Custom Errors |
| Packed AllowanceTransfer storage | BalanceDelta slot packing, bit manipulation | M1 β BalanceDelta |
| Permit2 + flash accounting | Transient storage for Uniswap V4 token flow | M2 β Transient Storage |
| Temporary approvals via transient storage | EIP-1153 use cases beyond reentrancy guards | M2 β DeFi Use Cases |
Forward references (β concepts youβll use later):
| Module 3 Concept | Used in | Where |
|---|---|---|
| EIP-1271 signature validation | Smart wallet permit support, account abstraction | M4 β Account Abstraction |
| EIP-712 domain separators | Test signature construction in Foundry | M5 β Foundry |
| Permit2 singleton deployment | CREATE2 deterministic addresses, cross-chain consistency | M7 β Deployment |
| Safe permit try/catch pattern | Proxy upgrade safety, defensive coding patterns | M6 β Proxy Patterns |
Part 2 connections:
| Module 3 Concept | Part 2 Module | How it connects |
|---|---|---|
| Token approval hygiene | M1 β Token Mechanics | Weird ERC-20 behaviors (fee-on-transfer, rebasing) interact with approval flows |
| Permit2 SignatureTransfer | M2 β AMMs | Uniswap V4 token ingress β all swaps flow through Permit2 |
| Bitmap nonces + witness data | M2 β AMMs | UniswapX intent-based trading relies on parallel signature collection |
| Permit2 AllowanceTransfer | M4 β Lending | Lending protocols use time-bounded allowances for recurring deposits |
| Permit2 integration patterns | M5 β Flash Loans | Flash loan protocols integrate Permit2 for token sourcing |
| Permit phishing + front-running | M8 β DeFi Security | $314M lost in 2024 β signature-based attack surface analysis |
| Full Permit2 integration | M9 β Integration Capstone | Capstone project requires Permit2 as token ingress path |
π Production Study Order
Read these files in order to build progressive understanding of signature-based approvals in production:
| # | File | Why | Lines |
|---|---|---|---|
| 1 | OZ Nonces.sol | Simplest nonce pattern β sequential counter for replay protection | ~20 |
| 2 | OZ EIP712.sol | Domain separator construction β the security anchor for all typed signing | ~80 |
| 3 | OZ ERC20Permit.sol | Complete EIP-2612 implementation β see how Nonces + EIP712 compose | ~40 |
| 4 | Permit2 ISignatureTransfer.sol | Interface-first β understand the mental model before implementation | ~60 |
| 5 | Permit2 SignatureTransfer.sol | One-time permits + bitmap nonces β the core innovation | ~120 |
| 6 | Permit2 AllowanceTransfer.sol | Persistent allowances with packed storage β compare with SignatureTransfer | ~150 |
| 7 | OZ SafeERC20.sol | Try/catch permit pattern β the defensive standard for production code | ~100 |
| 8 | UniswapX ResolvedOrder.sol | Witness data in production β how intent-based trading binds order params to signatures | ~80 |
Reading strategy: Files 1β3 build EIP-2612 understanding from primitives. Files 4β6 cover Permit2βs two modes. File 7 is the defensive pattern every protocol needs. File 8 shows the cutting edge β witness data powering intent-based DeFi.
π Resources
EIP-2612 β Permit
- EIP-2612 specification β permit function standard
- EIP-712 specification β typed structured data hashing and signing
- OpenZeppelin ERC20Permit β production implementation
- DAI permit implementation β the original (predates EIP-2612)
Permit2
- Permit2 repository β source code and docs
- Permit2 deployment addresses β same address on all chains
- Uniswap Universal Router β Permit2 integration example
- Permit2 integration guide β official docs
- Dune: Permit2 adoption metrics β usage stats
Security
- OpenZeppelin SafeERC20 β safe permit handling patterns
- Revoke.cash β check your active approvals
- EIP-1271 specification β signature validation for smart accounts (covered in Module 4)
Advanced Topics
- UniswapX ResolvedOrder β witness data in production
- EIP-2098 compact signatures β 64-byte vs 65-byte signatures
Navigation: β Module 2: EVM Changes | Module 4: Account Abstraction β