This week I was reading an open-source guard for agent wallets. It checked unlimited approvals, Permit signatures and long expiries, the usual suspects. It had no idea EIP-7702 authorizations existed. The author fixed it within a few hours, but it stuck with me, because that signature is the most powerful one an Ethereum key can produce now, and some tooling still treats it as an edge case.
Since Pectra went live on mainnet on May 7, 2025, any EOA can point its address at a contract with one signature. After that, every call to the address runs that contract's code with the EOA's balance and storage. The wallet keeps its address and its key, and it also becomes a smart account.
That's a great feature. It also moved the wallet attack surface overnight. Here's what I'd check if you write a delegate contract, a wallet, or a protocol that takes calls from EOAs.
How the delegation works
EIP-7702 adds transaction type 0x04. It carries an authorization_list, and each entry is a tuple:
[chain_id, address, nonce, y_parity, r, s]
The signature covers keccak256(0x05 || rlp([chain_id, address, nonce])). Whoever signed it is the account being delegated. That signer doesn't have to be the one sending the transaction, which is what makes sponsored transactions possible. It's also what lets a drainer submit an authorization a user signed on a phishing site five minutes ago.
Once a tuple is processed, the account's code becomes 23 bytes:
0xef0100 || address
That's the delegation designator. Calls to the account (and a transaction sent to it) load the code at address and run it in the account's context. Code-reading opcodes don't follow the pointer: EXTCODESIZE returns 23 and you read the designator itself, which is how you tell a delegated account apart.
The delegation stays until the owner signs a new one. Delegating to address(0) clears the code and the account is a plain EOA again.
The signature is the deployment
With a normal smart account you deploy a contract and then use it. Here the signature is the whole deployment. The tuple has a chain id, a target and a nonce. No scope, no expiry, no value cap. Whoever signs it hands their address to whatever code lives at address.
It went the way you'd expect. In May 2025 Scam Sniffer flagged a user who lost about $146,000 to malicious 7702 batch transactions tied to Inferno Drainer: the account got delegated and the tokens moved out in one go. On August 24, 2025, a similar batch took $1.54 million in wstETH, cbBTC and NFTs from a single victim.
There's a second pattern that often gets mixed with this one. A month after Pectra, Wintermute found that over 97% of all 7702 delegations on mainnet pointed to copies of the same sweeper contract. Those were accounts whose keys had already leaked. 7702 didn't steal the keys, it just made sweeping every incoming deposit cheaper than running a frontrunning bot. Different problem, same signature.
If you build a wallet, the authorization deserves its own signing screen, with the target contract named in plain words. It's a bigger grant than any approval.
The delegate contract is the wallet's whole security
Even when a user delegates on purpose to the contract they meant to, that contract is now the only thing standing between the account and anyone who calls it. The simplest failure is a function with no access control:
contract VulnerableDelegate {
function execute(address to, uint256 value, bytes calldata data) external payable {
(bool ok, ) = to.call{value: value}(data);
require(ok);
}
}
Anyone who knows a delegated address can call execute and send its funds anywhere. The user authorized the contract, not each call, so every call has to be authenticated inside it.
The spec's "Secure delegation" section spells out what the account's signature over a call has to cover, and it's a good review checklist:
- A nonce, or the same signature replays.
-
value, or a sponsor can change how much ETH goes out. -
gas, or a sponsor can starve the call so it fails. -
targetandcalldata, or a sponsor can route the call anywhere.
Then there's initialization. A delegation runs no constructor and no initcode, so delegates use an initialize function, the same as proxies. If the delegation and the initialize call land in separate transactions, someone can call initialize first with their own owner. Batch them in the same transaction, or make initialize check a signature from the account's own key.
And storage. The delegation can be swapped, the account's storage can't. If the user moves from a delegate that keeps a bool in slot 0 to one that keeps a uint256 there, the new one starts with garbage. Use ERC-7201 namespaced storage and assume you won't be the last delegate this account ever has. It's the same discipline as upgradeable proxies.
chain_id = 0
An authorization with chain_id = 0 is valid on every EVM chain. The spec allows it on purpose, so a wallet can ask for one signature that delegates to the same contract address everywhere.
The catch is that the user signs once and the attacker gets to choose where it lands. The nonce in the tuple has to match the account's nonce on that chain, so it doesn't apply everywhere at once. But it stays valid on any chain where the nonce matches, today or whenever the account's nonce gets there. One phished signature is a takeover waiting on every chain the user touches, as long as the same contract address holds attacker code there.
My view: wallets should refuse to sign chain_id = 0 by default, and delegates shouldn't ask for it unless their code really behaves the same on every chain they might be deployed to.
If your contract takes calls from EOAs
This is the part that affects people who never touch a delegate.
require(msg.sender == tx.origin) used to mean "this is the top frame of the transaction". The spec says plainly that 7702 breaks that. A delegated account can be tx.origin and still run arbitrary code, so if you used the check as a reentrancy guard or an anti-bot gate, it no longer holds for those users. Use a real reentrancy lock (ReentrancyGuard or a transient-storage mutex) for the first, and accept that the second is gone.
The code.length checks flip the other way. A delegated account has 23 bytes of code, so anything that treats code.length > 0 as "this is a contract" now sees your user as a contract. Two places I'd look:
-
ETH pushed to users. A plain
call{value: x}to a delegated account runs its delegate'sreceiveorfallback. That can revert, burn gas or re-enter. If a loop pays a list of users, one delegated user can now break the loop. -
safeTransferFromand_safeMint. OpenZeppelin's ERC-721 callsonERC721Receivedwhenever the recipient has code. If the user's delegate doesn't implement it, minting to that user reverts. Delegate authors should implement the token receiver hooks, and protocols should know some users will fail here.
If you really need to know whether a caller is delegated, check the designator:
function isDelegated(address account) internal view returns (bool) {
bytes memory code = account.code;
return code.length == 23
&& code[0] == bytes1(0xef)
&& code[1] == bytes1(0x01)
&& code[2] == bytes1(0x00);
}
Since London no contract can be deployed with code starting with 0xef (EIP-3541), so the prefix is unambiguous.
Sponsors and paymasters
7702 was designed to work with ERC-4337, so a delegated EOA can be the sender of a UserOperation and validation runs its delegate's code. If you run a paymaster that sponsors "accounts using our wallet", remember the user can swap the delegate with one signature at any time. Checking the implementation once at onboarding isn't enough. Read the account's code (0xef0100 plus the delegate address) when you sponsor, and only pay for delegates you know.
The same goes for any guard that signs on a user's behalf. A delegated account's code tells you its current delegate. If it points somewhere you don't expect, stop.
The short version
Writing a delegate
- Is every call authenticated, with the signature covering nonce, value, gas, target and calldata?
- Is
initializebatched with the delegation or signed by the account's key? - Is storage namespaced (ERC-7201)?
- Do you implement the token receiver hooks?
Building a wallet
- Does the 7702 authorization get its own screen, with the target named?
- Do you refuse
chain_id = 0by default?
Writing a protocol
- Anything relying on
tx.origin == msg.sender? - Anything that treats
code.length > 0as "contract", including ETH pushes and safe transfers to users?
If you've shipped a delegate already, I'm curious what you did about initialization: batched with the delegation, or a signature check?
Adapted from a piece our team published on zealynx.io.
I wrote this with help from AI for drafting and editing. The opinions and the audit experience are mine.
Top comments (2)
For revocation UX it helps to put a one click delegation to address(0) right next to token approvals, because most people who review approvals never look at their account's code.
Yup, most people never look at their own account's code, so a revoke right next to the approvals list would help a lot. For a phished signature that closes it for good, since the drainer only got that one tuple and the nonce has moved on. For a compromised signer it only buys time though. Binance 14 cleared its delegation to address(0) almost four hours after it was set, and per CoinDesk the deposits that kept coming in were swept twice more before that. Whoever could sign the first one could have signed another at the next nonce.