EIP-7702 Vulnerability Patterns

December 22, 2025

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) │ │
│ │ • 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 (
100 gas) │ │
│ │ • 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