EIP-7702 Vulnerability Patterns
EIP-7702 (Set EOA account code) was introduced in the Prague hard fork (2025) to enable account abstraction for EOAs. It allows an EOA to temporarily delegate its execution to a contract by including an authorization tuple in a transaction. This fundamentally changes the security model of EOAs - they can now execute arbitrary contract code while still appearing as simple externally-owned accounts.
This article summarizes 5 common EIP-7702 vulnerability patterns with real audit cases to help you quickly master EIP-7702 auditing.
EIP-7702 Core Concepts
EIP-7702 introduces a new EIP-2718 transaction type known as “Set Code Transaction”, where TransactionType is SET_CODE_TX_TYPE (0x04), i.e., Type-4 transaction.
What Changes with EIP-7702?
┌─────────────────────────────────────────────────────────────────┐
│ Before EIP-7702 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ EOA (Externally Owned Account): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • Has private key, can sign transactions │ │
│ │ • Has NO code (code hash = keccak256("")) │ │
│ │ • Can only transfer ETH or call other contracts │ │
│ │ • msg.sender == tx.origin always means "simple EOA" │ │
│ │ • Cannot execute complex logic atomically │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Contract Account: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • Has code deployed at address │ │
│ │ • Can execute arbitrary logic │ │
│ │ • msg.sender != tx.origin when called by EOA │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Clear separation: EOA = signer, Contract = executor │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ After EIP-7702 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Delegated EOA: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • EOA signs authorization: (chainId, address, nonce) │ │
│ │ • EOA's code pointer → delegated contract │ │
│ │ • EOA can now execute contract code! │ │
│ │ • msg.sender == tx.origin STILL TRUE for delegated EOA │ │
│ │ • But EOA can now run complex logic │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Security model broken: │
│ • "msg.sender == tx.origin means simple EOA" no longer true │
│ • EOA can now have callbacks, execute CREATE, etc. │
│ • EOA's nonce can be bumped by CREATE inside delegated code │
└─────────────────────────────────────────────────────────────────┘
Authorization Tuple Structure
EIP-7702 Authorization Tuple:
┌────────────────────────────────────────────────────────────────┐
│ │
│ authorization = (chainId, address, nonce, y_parity, r, s) │
│ │ │ │ │ └─┬─┘ │
│ │ │ │ │ │ │
│ │ │ │ │ └─ ECDSA│
│ │ │ │ │ signature│
│ │ │ │ │ components│
│ │ │ │ │ │
│ │ │ │ └─ Signature │
│ │ │ │ recovery bit │
│ │ │ │ (0 or 1) │
│ │ │ │ │
│ │ │ └─ EOA's current nonce │
│ │ └─ Target contract to delegate to│
│ └─ Chain this delegation is valid for │
│ (0 = valid on all chains!) │
│ │
│ (y_parity, r, s) = EOA's ECDSA signature over the payload │
│ Proves the EOA owner authorized this │
│ │
│ Signature covers: keccak256(MAGIC || chainId || address │
│ || nonce) │
│ │
│ When included in a Type-4 transaction: │
│ 1. EVM verifies signature is from the EOA │
│ 2. Sets EOA.code = 0xef0100 || delegated_address │
│ 3. Calls to EOA now execute delegated contract's code │
│ 4. Delegation persists until cleared or replaced │
│ │
│ To clear delegation: set address = 0x0000...0000 │
│ This resets EOA.code to empty (normal EOA state) │
│ │
└────────────────────────────────────────────────────────────────┘
Delegation Indicator
The delegation indicator is a 23-byte pointer stored in the EOA’s code field, similar to a symlink:
What is it? (Analogy)
┌────────────────────────────────────────────────────────────────┐
│ │
│ Unix symlink: /usr/bin/python → /usr/bin/python3.11 │
│ EIP-7702: Alice.code = 0xef0100 || 0x1234... │
│ │
│ Call Alice → EVM follows pointer → Executes code at 0x1234 │
│ │
│ Structure (23 bytes): │
│ ┌──────────┬──────────────────────────────────────────────┐ │
│ │ 0xef0100 │ 0x1234...5678 (delegated contract address) │ │
│ │ 3 bytes │ 20 bytes │ │
│ └──────────┴──────────────────────────────────────────────┘ │
│ │
│ Why 0xef? EIP-3541 banned deploying contracts starting │
│ with 0xef, so EVM can safely identify this as delegation. │
│ │
└────────────────────────────────────────────────────────────────┘
Where is it Stored? (Ethereum Client Structure)
┌────────────────────────────────────────────────────────────────┐
│ │
│ State Trie (LevelDB) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ key: keccak256(address) │ │
│ │ value: RLP([nonce, balance, storageRoot, codeHash]) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ │ codeHash points to │
│ ▼ │
│ Code Database (same LevelDB) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Normal EOA: codeHash = EMPTY_CODE_HASH (no entry) │ │
│ │ Normal contract: codeHash → [608060405234801561001...] │ │
│ │ Delegated EOA: codeHash → [0xef0100 || address] │ │
│ │ └────────┬───────────┘ │ │
│ │ delegation indicator │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
Account Type Comparison:
┌────────────────────────────────────────────────────────────────┐
│ │
│ │ EOA │ Contract │ Delegated EOA │
│ ────────────────┼──────────┼────────────┼────────────────────│
│ Has code │ No │ Yes │ Yes (23B pointer) │
│ Has storage │ No │ Yes │ No (initially) │
│ Can sign txs │ Yes │ No │ Yes │
│ Can exec code │ No │ Yes │ Yes (delegated) │
│ Nonce meaning │ tx cnt │ CREATE# │ tx + CREATE [1] │
│ EXTCODESIZE │ 0 │ code len │ 23 │
│ │
│ [1] EIP-7702 Backward Compatibility: │
│ "Once an account has been delegated, the account may │
│ call a create operation during execution, causing the │
│ nonce to increase." │
│ This breaks the invariant: "An EOA nonce may not │
│ increase after transaction execution has begun." │
│ Source: https://eips.ethereum.org/EIPS/eip-7702 │
│ │
└────────────────────────────────────────────────────────────────┘
How EVM Handles Delegation (Code Execution):
┌────────────────────────────────────────────────────────────────┐
│ │
│ Setup: Alice.code = 0xef0100||0x1234... (delegated EOA) │
│ Contract B wants to interact with Alice │
│ │
│ ─────────────────────────────────────────────────────────────│
│ CALL(Alice, data): │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 1. EVM sees 0xef0100 prefix → delegation indicator │ │
│ │ 2. Load bytecode from 0x1234... │ │
│ │ 3. Execute in Alice's context: │ │
│ │ • address(this) = Alice • storage = Alice's │ │
│ │ • msg.sender = B • balance = Alice's │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ─────────────────────────────────────────────────────────────│
│ DELEGATECALL(Alice, data): (from Contract B) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 1. EVM sees 0xef0100 prefix → delegation indicator │ │
│ │ 2. Load bytecode from 0x1234... │ │
│ │ 3. Execute in B's context (DELEGATECALL semantics): │ │
│ │ • address(this) = B • storage = B's │ │
│ │ • msg.sender = B's caller • balance = B's │ │
│ │ │ │
│ │ ⚠️ Code from 0x1234 runs, but modifies B's state! │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ─────────────────────────────────────────────────────────────│
│ Summary: │
│ • CALL/STATICCALL → delegated code, target's context │
│ • DELEGATECALL → delegated code, caller's context │
│ • CALLCODE → delegated code, caller's context (legacy)│
│ │
└────────────────────────────────────────────────────────────────┘
Affected Operations - Code Reading (CODESIZE vs EXTCODESIZE):
┌────────────────────────────────────────────────────────────────┐
│ │
│ CODESIZE / CODECOPY: │
│ Operate on the EXECUTING code (delegated contract's code) │
│ │
│ EXTCODESIZE / EXTCODECOPY: │
│ Operate on the STORED code (delegation indicator itself) │
│ │
│ Example (Alice delegated to Contract at 0x1234...): │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ During delegated execution: │ │
│ │ │ │
│ │ CODESIZE → returns size of code at 0x1234... │ │
│ │ EXTCODESIZE(Alice) → returns 23 (size of 0xef0100||addr)│ │
│ │ │ │
│ │ ⚠️ These return DIFFERENT values! │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Code hash also differs: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ EXTCODEHASH(Alice) = keccak256(0xef0100 || 0x1234...) │ │
│ │ ≠ keccak256(code at 0x1234...) │ │
│ │ │ │
│ │ The code hash reflects the delegation indicator, │ │
│ │ NOT the actual executing code! │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
Special Cases:
┌────────────────────────────────────────────────────────────────┐
│ │
│ 1. Delegation to Precompile (e.g., 0x01-0x09): │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Retrieved code is considered EMPTY │ │
│ │ CALL/DELEGATECALL executes empty code → succeeds │ │
│ │ with no execution (just consumes base gas) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 2. Delegation Chain/Loop: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Alice → 0xef0100||Bob │ │
│ │ Bob → 0xef0100||Charlie │ │
│ │ │ │
│ │ EVM only follows ONE level! │ │
│ │ CALL(Alice) → loads Bob's delegation indicator │ │
│ │ → does NOT follow to Charlie │ │
│ │ → executes 0xef0100||Charlie as code │ │
│ │ → likely reverts (invalid opcode 0xef) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
Security Implications
┌────────────────────────────────────────────────────────────────┐
│ │
│ 1. Cross-Chain Replay (if chainId = 0): │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ User signs: (chainId=0, addr=0x123, nonce=5) │ │
│ │ │ │
│ │ Valid on: │ │
│ │ • Ethereum mainnet │ │
│ │ • Arbitrum │ │
│ │ • Any EVM chain with EIP-7702 support │ │
│ │ │ │
│ │ If 0x123 has different code on different chains… │ │
│ │ User might be delegating to malicious contract! │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 2. No Deadline = Indefinite Validity: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Signed authorization never expires │ │
│ │ Can be used years later if nonce matches │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 3. EOA Code Execution: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Delegated EOA can: │ │
│ │ • Execute callbacks during token transfers │ │
│ │ • Call CREATE/CREATE2 (bumps nonce!) │ │
│ │ • Perform complex atomic operations │ │
│ │ • Pass “msg.sender == tx.origin” checks │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
### The tx.origin Assumption is Broken
**Understanding `msg.sender` vs `tx.origin`:**
- `tx.origin`: The EOA that initiated the transaction (always an EOA, never changes throughout the call chain)
- `msg.sender`: The immediate caller of the current function (can be EOA or contract)
Before EIP-7702:
┌────────────────────────────────────────────────────────────────┐
│ │
│ function onlyEOA() external { │
│ require(msg.sender == tx.origin, “No contracts!”); │
│ } │
│ │
│ This check was used to: │
│ • Prevent flash loan attacks (can’t be called mid-tx) │
│ • Ensure caller is “simple” EOA without code │
│ • Block reentrancy from contract callbacks │
│ │
│ EOA ─────► Contract │
│ │ │
│ └─ msg.sender = tx.origin = EOA ✓ (passes check) │
│ │
│ EOA ─────► Contract A ─────► Contract B │
│ │ │ │
│ │ └─ msg.sender = A, tx.origin = EOA │
│ │ msg.sender != tx.origin ✗ (blocked) │
│ │ │
│ └─ tx.origin = EOA (always the original signer) │
│ │
└────────────────────────────────────────────────────────────────┘
After EIP-7702:
┌────────────────────────────────────────────────────────────────┐
│ │
│ Delegated EOA (EOA with delegation to contract code) │
│ │ │
│ │ Initiates transaction │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ EOA is tx.origin AND msg.sender │ │
│ │ But EOA is now executing CONTRACT CODE! │ │
│ │ │ │
│ │ msg.sender == tx.origin ✓ (check passes) │ │
│ │ But delegated code can: │ │
│ │ • Make external calls with callbacks │ │
│ │ • Execute complex logic atomically │ │
│ │ • Perform reentrancy attacks │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ The assumption “tx.origin == msg.sender means simple EOA” │
│ is now BROKEN! │
│ │
└────────────────────────────────────────────────────────────────┘
---
## Vulnerability Patterns
### 1. EIP-7702 Bypasses tx.origin Check for Reentrancy Lock Activation
**Prerequisite Knowledge:**
The Compact uses a "tstorish" pattern for cross-chain compatibility: on pre-Cancun chains it uses SSTORE for reentrancy locks, then switches to cheaper TSTORE after chain upgrade via `__activateTstore()`. This function is protected by `msg.sender == tx.origin` to prevent contracts from calling it mid-transaction during reentrancy. The assumption was that only simple EOAs satisfy this check. However, EIP-7702 delegated EOAs also satisfy `msg.sender == tx.origin` while being able to execute arbitrary contract logic, breaking this security assumption.
**Root Cause:**
The `__activateTstore` function uses `msg.sender == tx.origin` to ensure only EOAs can trigger the transient storage activation. This check assumes EOAs cannot execute complex logic. With EIP-7702, a delegated EOA can call `__activateTstore` (passing the check) while also executing reentrancy attack code, allowing the attacker to bypass the reentrancy lock during the storage mode transition.
**Attack Vector:**
The attacker deploys a MaliciousToken (with callback in transferFrom) and an AttackWallet contract, then delegates their EOA to AttackWallet via EIP-7702. When calling batchDeposit([MaliciousToken, USDC]), the MaliciousToken.transferFrom triggers a callback to AttackWallet, which calls `__activateTstore` (passing the `msg.sender == tx.origin` check since the delegated EOA is both). This switches the lock from sstore to tstore mid-execution. AttackWallet then reenters deposit - since tstore slot is empty (default 0), the reentrancy lock is bypassed, enabling a double-spend attack.
**Business Scenario:**
Background 1: SSTORE vs TSTORE
┌────────────────────────────────────────────────────────────────┐
│ │
│ SSTORE (Storage Store) - Persistent Storage │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ • Data stored on blockchain permanently │ │
│ │ • Persists after transaction ends │ │
│ │ • Expensive gas cost (20,000 gas for first write) │ │100 gas) │ │
│ │ • Use case: user balances, contract state │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ TSTORE (Transient Store) - EIP-1153, Cancun Upgrade │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ • Data exists only during current transaction │ │
│ │ • Automatically cleared after transaction ends │ │
│ │ • Much cheaper gas cost (
│ │ • Perfect for reentrancy locks! │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
Background 2: Why Balance Difference Matters (Fee-on-Transfer Tokens)
┌────────────────────────────────────────────────────────────────┐
│ │
│ Some tokens deduct fees on transfer: │
│ • User calls deposit(100 tokens) │
│ • Token has 1% transfer fee │
│ • Contract actually receives only 99 tokens │
│ │
│ Solution: Measure balance before and after transfer │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ function deposit(token, amount) { │ │
│ │ uint256 before = token.balanceOf(address(this)); │ │
│ │ token.transferFrom(msg.sender, address(this), amount);│ │
│ │ uint256 received = token.balanceOf(this) - before; │ │
│ │ _credit(msg.sender, received); // Credit actual amt │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
Background 3: Reentrancy Attack on Balance-Based Accounting
┌────────────────────────────────────────────────────────────────┐
│ │
│ Without reentrancy protection, attacker can double-count: │
│ │
│ Initial state: contract balance = 100 tokens │
│ │
│ deposit_1(20 tokens) { │
│ initialBalance = 100 │
│ transferFrom(20) → callback → deposit_2(20) { │
│ // Callback triggers BEFORE outer transfer completes │
│ // Contract balance still 100 at this point │
│ initialBalance = 100 │
│ transferFrom(20) // Now balance = 120 │
│ balanceAfter = 120 │
│ credit(120 - 100 = 20) ✓ │
│ } │
│ // Outer transfer now completes: balance 120 → 140 │
│ balanceAfter = 140 │
│ credit(140 - 100 = 40) ✓ // Over-counted by 20! │
│ } │
│ │
│ Actually deposited: 20 + 20 = 40 tokens │
│ Credits received: 20 + 40 = 60 tokens │
│ Attacker profit: 20 tokens (stolen from other depositors) │
│ │
└────────────────────────────────────────────────────────────────┘
Background 4: What is “Tstorish” Pattern?
┌────────────────────────────────────────────────────────────────┐
│ │
│ Problem: Protocol deploys on multiple chains, some don’t │
│ support TSTORE yet (pre-Cancun EVM) │
│ │
│ Solution: “Tstorish” - Smart storage type selection │
│ │
│ constructor() { │
│ bool tstoreSupported = _testTload(); │
│ if (tstoreSupported) { │
│ // Use efficient TSTORE directly │
│ _setLock = _setTstore; │
│ _getLock = _getTstore; │
│ } else { │
│ // Fallback to SSTORE until chain upgrades │
│ _setLock = _setSstoreFallback; │
│ _getLock = _getSstoreFallback; │
│ } │
│ } │
│ │
│ // Called after chain upgrades to enable TSTORE │
│ function __activateTstore() external { │
│ require(msg.sender == tx.origin); // Only EOA allowed │
│ require(!_tstoreSupport); // Not yet activated │
│ require(_testTload()); // Now supported │
│ _tstoreSupport = true; // Switch to TSTORE │
│ } │
│ │
└────────────────────────────────────────────────────────────────┘
Background 5: Why Restrict __activateTstore to EOA Only?
┌────────────────────────────────────────────────────────────────┐
│ │
│ Developer’s Concern: │
│ “If a contract can call __activateTstore, an attacker could │
│ call it during reentrancy, switching the lock storage type │
│ mid-transaction, bypassing protection.” │
│ │
│ Developer’s Assumption: │
│ “msg.sender == tx.origin means direct EOA call. │
│ EOAs can’t execute complex logic, so they can’t call this │
│ function mid-transaction during a callback.” │
│ │
│ How the Check Works: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ EOA directly calls contract: │ │
│ │ EOA ──────────► Contract │ │
│ │ msg.sender = EOA, tx.origin = EOA ✓ Equal │ │
│ │ │ │
│ │ EOA calls through another contract: │ │
│ │ EOA ───► Contract A ───► Contract B │ │
│ │ In B: msg.sender = A, tx.origin = EOA ✗ Not equal │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ EIP-7702 Breaks This Assumption: │
│ Delegated EOA still satisfies msg.sender == tx.origin, │
│ but can execute arbitrary contract logic in a single tx! │
│ │
└────────────────────────────────────────────────────────────────┘
Background 6: How Does Callback Trigger?
┌────────────────────────────────────────────────────────────────┐
│ │
│ This vulnerability uses attacker-controlled malicious token │
│ via batchDeposit, NOT ERC777/ERC1155 standard callbacks. │
│ │
│ The Compact supports batchDeposit for multiple tokens: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ function batchDeposit(TokenDeposit[] deposits) { │ │
│ │ _acquireLock(); │ │
│ │ for (deposit in deposits) { │ │
│ │ initialBalance = deposit.token.balanceOf(this); │ │
│ │ deposit.token.transferFrom(sender, this, amt); │ │
│ │ // Malicious token’s transferFrom triggers callback!│ │
│ │ received = deposit.token.balanceOf(this) - init; │ │
│ │ _credit(sender, received); │ │
│ │ } │ │
│ │ _releaseLock(); │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Attack Setup: │
│ 1. Attacker deploys MaliciousToken with callback in transfer │
│ 2. Attacker delegates EOA to AttackWallet contract │
│ 3. Call batchDeposit([MaliciousToken, USDC]) │
│ 4. MaliciousToken.transferFrom calls back to AttackWallet │
│ 5. AttackWallet executes attack logic │
│ │
│ Malicious Token Example: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ contract MaliciousToken { │ │
│ │ function transferFrom(from, to, amount) { │ │
│ │ // Normal transfer logic… │ │
│ │ balances[from] -= amount; │ │
│ │ balances[to] += amount; │ │
│ │ │ │
│ │ // Callback to attacker! │ │
│ │ if (from.code.length > 0) { │ │
│ │ ICallback(from).onTransfer(to, amount); │ │
│ │ } │ │
│ │ } │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Why Delegated EOA Receives Callback: │
│ • Normal EOA: code.length = 0, no callback │
│ • Delegated EOA: code.length = 23 (delegation indicator) │
│ • Check passes, callback executes AttackWallet code │
│ │
└────────────────────────────────────────────────────────────────┘
Vulnerability Trigger Conditions:
┌────────────────────────────────────────────────────────────────┐
│ │
│ This vulnerability requires specific conditions: │
│ │
│ 1. The Compact deployed on pre-Cancun chain (no TSTORE) │
│ → Deployment sets _tstoreSupport = false, uses SSTORE lock │
│ │
│ 2. Chain upgrades, skipping Cancun directly to Prague │
│ → Now TSTORE is available │
│ → EIP-7702 is also available │
│ │
│ 3. Attacker must act before honest user calls __activateTstore│
│ → Activation can only happen once │
│ │
└────────────────────────────────────────────────────────────────┘
Attack Execution Flow:
┌────────────────────────────────────────────────────────────────┐
│ │
│ Preparation: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 1. Deploy MaliciousToken (has callback in transferFrom) │ │
│ │ 2. Deploy AttackWallet contract │ │
│ │ 3. Attacker’s EOA delegates to AttackWallet via EIP-7702 │ │
│ │ 4. Prepare batchDeposit call: │ │
│ │ - Include MaliciousToken (trigger for callback) │ │
│ │ - Include USDC (target token to double-spend) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Execution: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Attacker’s Delegated EOA │ │
│ │ │ │ │
│ │ │ Call batchDeposit([MaliciousToken, USDC]) │ │
│ │ ▼ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ The Compact Contract │ │ │
│ │ │ │ │ │
│ │ │ batchDeposit() { │ │ │
│ │ │ // Reentrancy lock - using SSTORE │ │ │
│ │ │ require(sload(LOCK) == 0); ✓ │ │ │
│ │ │ sstore(LOCK, 1); // Lock acquired │ │ │
│ │ │ │ │ │
│ │ │ // Process first token: MaliciousToken │ │ │
│ │ │ for (token in tokens) { │ │ │
│ │ │ initBal = token.balanceOf(this); │ │ │
│ │ │ token.transferFrom(attacker, this, amount); │ │ │
│ │ │ │ │ │ │
│ │ │ │ MaliciousToken.transferFrom triggers callback│ │ │
│ │ │ ▼ │ │ │
│ │ └──────┼─────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ AttackWallet.onTransfer() callback │ │ │
│ │ │ (Executes in attacker’s delegated EOA context) │ │ │
│ │ │ │ │ │
│ │ │ Step 1: Call __activateTstore() │ │ │
│ │ │ │ │ │ │
│ │ │ │ Check: msg.sender == tx.origin ? │ │ │
│ │ │ │ msg.sender = Attacker’s Delegated EOA │ │ │
│ │ │ │ tx.origin = Attacker’s Delegated EOA │ │ │
│ │ │ │ Result: ✓ Equal! Check passes! │ │ │
│ │ │ │ │ │ │
│ │ │ └→ _tstoreSupport = true │ │ │
│ │ │ Lock now switches from SSTORE to TSTORE! │ │ │
│ │ │ │ │ │
│ │ │ Step 2: Call batchDeposit([USDC]) again │ │ │
│ │ │ │ │ │ │
│ │ │ ▼ │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Second batchDeposit (Reentrancy) │ │ │
│ │ │ │ │ │
│ │ │ // Reentrancy lock - NOW using TSTORE! │ │ │
│ │ │ require(tload(LOCK) == 0); │ │ │
│ │ │ │ │ │ │
│ │ │ │ TSTORE was never set, default = 0 │ │ │
│ │ │ │ SSTORE lock = 1, but ignored now! │ │ │
│ │ │ ▼ │ │ │
│ │ │ ✓ Check passes! Reentrancy succeeds! │ │ │
│ │ │ │ │ │
│ │ │ // Normal USDC deposit executes │ │ │
│ │ │ initBal = 1000 USDC │ │ │
│ │ │ transferFrom(attacker, this, 500) │ │ │
│ │ │ balanceAfter = 1500 USDC │ │ │
│ │ │ credit(attacker, 500) // First credit │ │ │
│ │ │ │ │ │
│ │ │ tstore(LOCK, 0); // Unlock │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ │ Callback returns, outer batchDeposit continues │ │
│ │ ▼ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Continue outer batchDeposit │ │ │
│ │ │ │ │ │
│ │ │ // Process next token: USDC │ │ │
│ │ │ initBal = 1000 USDC // Recorded BEFORE callback! │ │ │
│ │ │ // 500 USDC already transferred during callback │ │ │
│ │ │ transferFrom(attacker, this, 500) │ │ │
│ │ │ balanceAfter = 2000 USDC │ │ │
│ │ │ credit(attacker, 2000-1000 = 1000) // Overcounted!│ │ │
│ │ │ │ │ │
│ │ │ tstore(LOCK, 0); // Unlock │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Result: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Actually deposited: 500 + 500 = 1000 USDC │ │
│ │ Credits received: 500 + 1000 = 1500 USDC │ │
│ │ Attacker profit: 500 USDC │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
Lock State Timeline:
┌────────────────────────────────────────────────────────────────┐
│ │
│ Timepoint │ SSTORE │ TSTORE │ Which Used │ State │
│ ─────────────────────┼────────┼────────┼────────────┼─────────│
│ Initial │ 0 │ 0 │ SSTORE │ Unlocked│
│ Outer deposit starts │ 1 │ 0 │ SSTORE │ Locked │
│ Callback activates │ 1 │ 0 │ → TSTORE │ Switch! │
│ Inner deposit check │ 1 │ 0 │ TSTORE │ Unlocked│
│ Inner deposit locks │ 1 │ 1 │ TSTORE │ Locked │
│ Inner deposit unlocks│ 1 │ 0 │ TSTORE │ Unlocked│
│ Outer continues │ 1 │ 0 │ TSTORE │ Unlocked│
│ │
│ Problem: When switching lock type, SSTORE value not synced │
│ to TSTORE. Inner check reads TSTORE = 0, bypasses! │
│ │
└────────────────────────────────────────────────────────────────┘
Why Malicious Token is Needed:
┌────────────────────────────────────────────────────────────────┐
│ │
│ If only depositing USDC (standard ERC20), no callback: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ deposit(USDC, 100) { │ │
│ │ lock(); │ │
│ │ USDC.transferFrom(…); // No callback │ │
│ │ credit(); │ │
│ │ unlock(); │ │
│ │ } │ │
│ │ // No opportunity to execute attack code mid-transaction │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Using malicious token to gain execution: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ batchDeposit([MaliciousToken, USDC]) { │ │
│ │ lock(); │ │
│ │ MaliciousToken.transferFrom(…); │ │
│ │ │ │ │
│ │ └→ onTransfer() { // Gained execution! │ │
│ │ __activateTstore(); │ │
│ │ deposit(USDC); // Reenter │ │
│ │ } │ │
│ │ USDC.transferFrom(…); // Continue processing │ │
│ │ credit(); │ │
│ │ unlock(); │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ batchDeposit allows depositing multiple tokens at once │
│ Attacker adds their malicious token to gain callback control │
│ Then double-spends other valuable tokens in the same batch │
│ │
└────────────────────────────────────────────────────────────────┘
**Source:**
- **[spearbit/2025-07-Uniswap-The-Compact]** Attacker can bypass reentrancy lock to double-spend deposit (`medium-1.md`)
- `src/lib/ConstructorLogic.sol`
```solidity
function __activateTstore() external {
// VULNERABILITY: EIP-7702 delegated EOA passes this check
if (msg.sender != tx.origin) {
revert OnlyDirectCalls();
}
// ...
_tstoreSupport = true; // Switches lock from sstore to tstore
}
EIP-7702 Audit Checklist
High-Risk Scenarios: Smart wallets, account abstraction implementations, any contract that relies on tx.origin checks.
| # | Check Item | Related Pattern |
|---|---|---|
| ✅ | Does the code rely on msg.sender == tx.origin for security? This check no longer guarantees a simple EOA |
Pattern 1 |
InfiniteSec