Ethereum's Pectra upgrade shipped ERC-7702 — native account abstraction that lets any EOA temporarily become a smart contract. The UX wins are real: transaction batching, gas sponsorship, social recovery. But here's what nobody's talking about loudly enough: ERC-7702 retroactively broke security assumptions in thousands of deployed DeFi contracts.
If your protocol uses tx.origin, relies on EOAs being "dumb" accounts, or assumes msg.sender == tx.origin means no reentrancy — you're exposed. This isn't theoretical. Exploits are already circulating.
Let's break down the five attack surfaces, show real code patterns, and give you the exact mitigations.
1. The tx.origin Identity Crisis
The Old Assumption
// "Only EOAs can call this" — wrong after ERC-7702
require(msg.sender == tx.origin, "No contracts allowed");
Before Pectra, msg.sender == tx.origin was a reasonable (if imperfect) heuristic to ensure the caller wasn't a contract. Many protocols used this as an anti-reentrancy guard or to restrict access to "real users."
What Changed
With ERC-7702, an EOA can delegate to arbitrary contract code while keeping its address. Now tx.origin and msg.sender can both point to an address that's executing complex contract logic — including reentrant calls.
The Attack
// Attacker's EOA delegates to this contract via ERC-7702
contract MaliciousDelegation {
function attack(address target) external {
// msg.sender == tx.origin == attacker's EOA address
// Passes the "no contracts" check
IVault(target).withdrawAll(); // Reenters freely
}
}
The Fix
Stop using tx.origin for security logic. Period.
// Use proper reentrancy guards instead
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureVault is ReentrancyGuard {
function withdraw(uint256 amount) external nonReentrant {
// No tx.origin check needed
_processWithdrawal(msg.sender, amount);
}
}
Audit checklist item: grep -rn "tx.origin" contracts/ — every hit is now a potential vulnerability.
2. The One-Click Drain: Delegation Phishing
How It Works
ERC-7702 introduces AUTH — a new transaction type where users sign a delegation to a contract address. This is where phishing gets terrifying.
Before Pectra: A phishing attack could steal one token approval at a time. The user sees "Approve USDC for [contract]" and might catch it.
After Pectra: A single delegation signature grants a malicious contract full execution authority over the EOA. The contract can then:
- Drain all ERC-20 balances across every protocol
- Liquidate lending positions
- Revoke all existing approvals
- Bridge remaining assets to another chain
- All in one atomic transaction
The Anatomy of an Attack
User signs: AUTH(delegate=0xMalicious, nonce=42)
0xMalicious.execute():
├── USDC.transfer(attacker, balance)
├── WETH.transfer(attacker, balance)
├── Aave.withdraw(all_deposits)
├── Compound.redeem(all_cTokens)
├── Uniswap.removeLiquidity(all_positions)
└── Bridge.sendToL2(attacker_l2_addr, total)
Mitigation for Wallet Developers
- Simulate before signing: Show users the full execution trace of any delegation
- Domain separation: Clearly distinguish delegation signatures from regular transaction signatures
- Time-bound delegations: Encourage contracts that implement expiring delegation windows
- Allowlisted delegates only: Maintain curated registries of audited delegation contracts
Mitigation for Protocol Teams
// Implement explicit delegation-aware access control
contract DelegationAwareVault {
mapping(address => bool) public trustedDelegates;
modifier onlyTrustedDelegate() {
// Check if caller is executing delegated code
if (_isDelegated(msg.sender)) {
require(
trustedDelegates[_getDelegateContract(msg.sender)],
"Untrusted delegate"
);
}
_;
}
}
3. The Front-Running Initialization Race
The Problem
Traditional smart contracts initialize via constructors, which run atomically at deployment. ERC-7702 delegated accounts don't have this luxury — initialization happens in a separate transaction after the delegation is set.
The Attack Window
Block N: User sets delegation → AUTH(delegate=SafeWallet)
Block N+1: User calls initialize(owner=user)
// But attacker sees the pending AUTH in the mempool...
Block N: User sets delegation → AUTH(delegate=SafeWallet)
Block N+1: ATTACKER calls initialize(owner=attacker) ← front-runs
Block N+2: User's initialize() reverts — attacker owns the account
Real Impact
If your delegation contract stores an owner variable that controls withdrawals, and initialization isn't protected, an attacker can claim ownership of any EOA that delegates to your contract.
The Fix
contract SecureDelegation {
// Use ERC-7201 namespaced storage to prevent collisions
bytes32 private constant STORAGE_SLOT =
keccak256("SecureDelegation.storage.v1");
struct Storage {
address owner;
bool initialized;
}
function initialize() external {
Storage storage s = _getStorage();
// Only the EOA itself can initialize
require(msg.sender == address(this), "Only self-init");
require(!s.initialized, "Already initialized");
s.owner = msg.sender;
s.initialized = true;
}
function _getStorage() private pure returns (Storage storage s) {
bytes32 slot = STORAGE_SLOT;
assembly { s.slot := slot }
}
}
Key insight: Use require(msg.sender == address(this)) for initialization — this ensures only the account owner (via their signing key) can set up the delegation, because with ERC-7702, address(this) in delegated code resolves to the EOA's address.
4. Storage Collision Landmines
The Scenario
An EOA delegates to Contract A, which stores data at slot 0. Later, the user switches delegation to Contract B, which also uses slot 0 but for a different purpose.
// Contract A: slot 0 = owner (address)
contract WalletA {
address public owner; // slot 0
}
// Contract B: slot 0 = balance (uint256)
contract WalletB {
uint256 public balance; // slot 0
}
// After switching A → B:
// balance = uint256(owner_address) = some huge number
// User appears to have billions in "balance"
Why This Is Worse Than It Sounds
- Cross-delegation state corruption can bypass balance checks, access controls, and invariants
- Storage persists on the EOA even after delegation changes
- No automatic cleanup mechanism exists
- Different contracts with overlapping storage layouts will silently corrupt each other's state
Defensive Pattern
// Always use ERC-7201 namespaced storage
contract SafeDelegation {
/// @custom:storage-location erc7201:SafeDelegation.main
struct MainStorage {
address owner;
uint256 nonce;
mapping(address => bool) authorized;
}
// keccak256("SafeDelegation.main") - 1
bytes32 private constant MAIN_STORAGE = 0x...;
function _getMainStorage() private pure returns (MainStorage storage $) {
assembly { $.slot := MAIN_STORAGE }
}
}
For auditors: Check every delegation contract for raw storage slot usage. If it's not using ERC-7201 namespaced storage, flag it as high severity.
5. The Whitelist Bypass via Delegation Chains
The Attack
Many DeFi protocols maintain whitelists of approved addresses. With ERC-7702, a whitelisted EOA can delegate to arbitrary code, effectively turning a single whitelisted address into a gateway for unlimited unauthorized actors.
Protocol whitelist: [Alice_EOA, Bob_EOA, ...]
Alice_EOA delegates to ProxyContract via ERC-7702
ProxyContract accepts calls from anyone
Attacker calls ProxyContract → executes as Alice_EOA → passes whitelist check
Why Existing Checks Fail
// This check is now broken:
require(whitelist[msg.sender], "Not whitelisted");
// msg.sender = Alice's EOA address (still whitelisted)
// But the actual caller is the attacker, routing through Alice's delegation
The Fix
Protocols using whitelists must now verify both the address and its delegation status:
function isGenuineEOA(address account) internal view returns (bool) {
uint256 size;
assembly { size := extcodesize(account) }
if (size > 0) {
return false;
}
return true;
}
modifier onlyWhitelistedEOA(address account) {
require(whitelist[account], "Not whitelisted");
require(isGenuineEOA(account), "Delegated accounts not allowed");
_;
}
The Audit Toolkit: What to Do Right Now
1. Scan Your Codebase
# Find all tx.origin usage
grep -rn "tx.origin" contracts/ --include="*.sol"
# Find msg.sender == tx.origin anti-reentrancy patterns
grep -rn "msg.sender.*==.*tx.origin" contracts/ --include="*.sol"
# Find extcodesize-based EOA checks (also broken)
grep -rn "extcodesize" contracts/ --include="*.sol"
2. Run Static Analysis
# Slither has new ERC-7702 detectors
slither . --detect erc7702-unsafe-origin,delegation-reentrancy
# Aderyn (Cyfrin) also added Pectra-aware rules
aderyn . --rules pectra-compat
3. Add Formal Verification for Critical Invariants
// Halmos symbolic test
function check_noReentrancy(uint256 amount) public {
uint256 balanceBefore = vault.balanceOf(address(this));
vault.withdraw(amount);
uint256 balanceAfter = vault.balanceOf(address(this));
assert(balanceBefore - balanceAfter == amount);
}
4. Update Your Threat Model
Add these to your audit checklist:
- [ ] No security-critical
tx.originchecks - [ ] Initialization functions are front-running resistant
- [ ] Storage uses ERC-7201 namespaced patterns
- [ ] Whitelist logic accounts for delegation
- [ ] Reentrancy guards don't rely on EOA assumptions
- [ ] Integration tests with ERC-7702 delegated callers
The Bottom Line
ERC-7702 is the biggest change to Ethereum's execution model since EIP-1559. It fundamentally alters who msg.sender can be and what an "externally owned account" means. Every DeFi protocol deployed before Pectra should be re-audited with these five attack surfaces in mind.
The good news: the mitigations are straightforward. The bad news: most protocols haven't applied them yet.
Don't be the next postmortem.
This article is part of the DeFi Security Research series by DreamWork Security. We publish weekly deep-dives into smart contract vulnerabilities, audit methodologies, and defensive tooling. Follow for more.
Top comments (0)