← Ayo Osunjuyigbe

· Web3

How to implement a 7702 delegate that is not a backdoor

A few days ago I wrote about EIP-7702 phishing: one authorization tuple, then the account runs someone else’s code until you revoke. That piece was about signing the wrong address.

This one is about signing the right address and still getting wrecked. The spec is not subtle about it:

A poorly implemented delegate can allow a malicious actor to take near complete control over a signer’s EOA.

That sentence is in EIP-7702 itself, under “Secure delegation.” It is not a blog exaggeration. The authorization tuple only picks a contract. Everything after that — who can call you, what they can move, whether a sponsor can rewrite the call — lives in that contract. If the contract is a public call(to, data, value), you have published a power of attorney over the EOA.

I keep seeing people treat 7702 as “4337 but I keep my address.” It is not. 4337 deploys a wallet you own. 7702 points your existing account at shared bytecode that many other EOAs may also point at. Multiple wallets can sit on the same delegate at the same time. That is the feature. It is also why a missing onlyOwner is not a one-user bug. It is a class of accounts.

What the spec actually requires you to sign over

The type-4 authorization is not an operation. It is a pointer. Every later operation that the delegate performs has to be authenticated again, by the EOA key, over the things a sponsor or a random caller could otherwise change.

EIP-7702 lists four. Auditors treat them as five because target and calldata are one bullet in the spec and two failure modes in practice.

Replay protection (a nonce). The delegate must keep its own nonce — or some equivalent, a bitmap, a session id — and the inner signature must cover it. The type-4 nonce only protects the installation. It does not protect the tenth batch you sign next month. Without an inner nonce, a signature is a coupon. Anyone who saw it can replay it.

value. Sponsorship is the point of 7702. Someone else submits the outer transaction and pays gas. If the inner signature does not bind how much ETH the callee receives, the sponsor can attach a different value and change what the callee does. Unexpected ETH on a payable function is an old trick. It still works.

gas. Same sponsor. If gas is not bound, they can starve the inner call so it reverts after your nonce has moved, or after a first hop has already run. Griefing, not always theft, but enough to brick a flow you thought was atomic.

target and calldata. Miss either of these and the sponsor picks the contract and the function. That is arbitrary execution under your account. There is no version of “we signed the batch shape, they fill in the addresses later” that is safe.

Omit any one of those and you have not built a wallet. You have built Nethermind’s example from April, which I am going to quote because it is short and people still ship it:

function doSomething(bytes memory _data, address _to, uint _value) public payable {
    (bool success, ) = _to.call{value: _value}(_data);
    require(success);
}

No check that the caller is the EOA. No signature. No nonce. Anyone who knows you delegated to this contract can drain it. Remix is enough to demonstrate it. Nethermind did, on 8 April, a month before Pectra.

The constructor also does not run in the EOA’s context. There is no initcode on a type-4 transaction. If your mental model is “deploy wallet, constructor sets owner,” that model is wrong here. Storage on the EOA starts empty unless you initialize it in a signed call after the pointer exists.

Ambire’s ~200 lines, and why that number keeps coming up

ethereum.org’s 7702 page uses Ambire as the example of keeping the trusted surface small. Ambire’s own post says the same: the smart contract implementation is about 200 lines, on purpose, because the contract is an extension of the user’s EOA and a flaw is catastrophic.

Look at AmbireAccount7702. The 7702-specific file is a thin wrapper, not the whole 200 lines. They inherit the existing Ambire account and override privileges so that:

  • the account itself is privileged
  • the canonical ERC-4337 EntryPoint (0x0000000071727De22E5E9d8BAf0edAc6f37da032) is treated as a known caller
  • everything else goes through the already-audited privilege map

The EntryPoint address is a constant. Not a storage slot. Their comment is the whole design: they do not want to write storage on the EOA just to remember which EntryPoint they trust. If EntryPoint ever changes, users authorize a new contract that hardcodes the new address. That is a 7702 authorization, which the user can see, instead of a silent storage write that a frontrunner can win.

That is the opposite of “let’s add a setter.” A setter is how you get an unprotected initialize.

I am not saying you should fork Ambire and ship it. I am saying the instinct is correct: hardcoded trust, namespaced storage they already had (getAmbireStorage()), no extra slots after authorization, and the heavy logic lives in code that has been through a pile of audits. Ambire claims more than ten. ethereum.org lists 0x5A7FC11397E9a8AD41BF10bf13F22B0a63f96f6d as the known Ambire implementation.

If your delegate is 2,000 lines of “modules, hooks, plugins, upgrade admin,” you are not being more complete. You are being harder to audit while sitting on the same blast radius.

MetaMask’s whitelist is a product decision, not a nice-to-have

ethereum.org is explicit: wallets will whitelist delegation contracts. dApps should not collect 7702 signatures. There is no standard for a website to request an authorization tuple. Apps go through wallet_sendCalls (ERC-5792) or through wallet-managed modules (ERC-6900). The wallet picks the delegate.

MetaMask’s delegation framework sits at 0x63c0c19a282a1b52b07dd5a65b58948a07dae32b on the known-implementations table. The 7702 piece of it (EIP7702StatelessDeleGator / EIP7702DeleGatorCore) recovers the signature and requires it to be the EOA itself — ECDSA.recover(...) == address(this). Under 7702, address(this) is the EOA. The signer that controls the DeleGator must be that EOA. DelegationManager and EntryPoint are immutable constructor args, not storage the user initializes later.

Their toolkit is also written to talk to any ERC-4337 bundler or paymaster, on any chain. ethereum.org calls that out because the other failure mode is a delegate that hard-codes one relayer. If that server dies, the account is bricked for anything that needed sponsorship. Batching approve-and-swap does not need a relayer. Gas abstraction does. The recommendation on that page is public-mempool bundlers against EntryPoint 0.8, so anyone can sponsor if they have a valid UserOp.

Hardware wallets are supposed to be stricter. The consensus in that group is: do not expose arbitrary delegation. Allow the contracts on the ethereum.org table, consider others case by case. The current list, all marked audited:

Address Who
0x000000009B1D0aF20D8C6d0A44e162d11F9b8f00 Uniswap / calibur
0x69007702764179f14F51cdce752f4f775d74E139 Alchemy modular account
0x5A7FC11397E9a8AD41BF10bf13F22B0a63f96f6d Ambire
0x63c0c19a282a1b52b07dd5a65b58948a07dae32b MetaMask delegation framework
0x4Cd241E8d1510e30b2076397afc7508Ae59C66c9 Ethereum Foundation AA team
0x17c11FDdADac2b341F2455aFe988fec4c3ba26e3 Luganodes batch contract

If you are writing a delegate and it is not on that list, assume no serious wallet will let a user point at you from a dapp prompt. That is the intended state. A webpage that says “enable smart account” and hands you a raw tuple is the phishing flow from last week, regardless of how clean your Solidity is.

The proxy contradiction, and when chain_id = 0 makes it worse

ethereum.org says two things that look like they disagree.

Under phishing: only delegate to an immutable contract, never a proxy, and only CREATE2 with standard initcode — no metamorphic contracts — so the deployer cannot put different bytecode at the same address on another chain.

Under extensibility: you are encouraged to delegate to a proxy so you can upgrade logic without a new 7702 authorization. They even point at Safe’s SafeEIP7702Proxy. Then they list the cost: you are trusting an external team not to upgrade to something unsafe.

Both are on the same page. They apply to different signatures.

If the user signs chain_id = 0, that tuple is valid on every 7702 chain. A proxy or a metamorphic CREATE2 at that address is a cross-chain gun. The deployer (or the admin key) can land different code on Base than they landed on mainnet, or upgrade later, and the same signature still points at “that address.” Immutable + CREATE2 with fixed initcode is the only way chain_id = 0 is even discussable. I still would not offer chain_id = 0 as a default. I said that last week. I mean it more after sitting in the delegate code.

If the user signs a specific chain id, a proxy is a product choice. Alchemy’s modular account (0x6900…E139) is on the whitelist and is built around ERC-6900 modules. MetaMask’s core comment says any child that adds state must use namespaced storage for safe upgrades. That is the proxy/module world. The user is trusting that wallet’s upgrade process, which is at least a known process, not a random admin on a phishing page.

Do not mix the two. Do not ship a chain_id = 0 authorization to a proxy. Do not tell users “it’s the same address on every chain” unless the bytecode is the same and cannot change.

Init: the constructor you do not get

Nethermind’s second heading is the one people skip. Constructor runs at delegate deploy, in the deployer’s context. It does not run when an EOA points at that code. Alice delegates to a contract whose constructor set owner = deployer. On Alice’s account, owner is whatever happens to be in that slot — probably zero — unless she initializes.

The spec’s “Front running initialization” section is the required fix: the setup calldata must be signed by the EOA key and checked with ecrecover. ethereum.org names two patterns that satisfy that idea:

  • initWithSig — init arguments are in a signature from the authority
  • init only callable from EntryPoint, so it sits behind 4337 validation (Safe’s docs go this way)

Batching init with the type-4 transaction is necessary so a stranger cannot call initialize in the gap between two transactions. It is not sufficient if init itself is public and unsized. The frontrunner can submit your authorization tuple — they are allowed to, they are not tx.origin — and their own init calldata, in one transaction.

Alchemy’s line, repeated on ethereum.org: an unprotected initializer on a 7702 proxy is account takeover.

Re-init is the same bug later. If initialize can run twice, the second caller is the new owner.

Storage survives the pointer

Delegating does not clear storage. Redelegating does not clear it either. ethereum.org’s example is the one I use: slot was a bool, new delegate treats it as a uint. You get leftover bits and “undefined” is a polite word.

If you write a delegate, assume you are not the last one. ERC-7201 namespaced storage, or a single namespaced struct like Ambire’s getAmbireStorage(), so you are not sitting on slot 0 fighting the next contract. OpenZeppelin’s upgradeable-proxy discipline applies even if you personally never upgrade. The user can.

Also remember the leftover key. ethereum.org again: delegating to a Safe does not make the account a Safe. The EOA key can still bypass the policy. Your delegate can be perfect and the user can still sign a raw type-4 that points somewhere else. Design for that. Do not promise “this is now a multisig.”

What I check before I let anyone delegate

Nethermind’s April note plus the spec plus the ethereum.org page is enough to run a first pass without inventing a firm’s marketing list. This is what I would walk before I let anyone I know delegate to a new contract.

On the delegate

  1. Every path that can move value or set approvals verifies a signature from the authority.
  2. That signature covers nonce (or equivalent), value, gas, target, and calldata.
  3. There is no public / external execute-anything helper. Nethermind’s doSomething is the test. If you have one, you have failed.
  4. Init is initWithSig or EntryPoint-gated. Not initialize that anyone can call. Not re-runnable.
  5. No constructor logic that you expect to run on the EOA.
  6. Storage is namespaced. Slot 0 is not “owner.”
  7. No single hardcoded relayer. Bundlers are interchangeable. If you must trust EntryPoint, hardcode the canonical one like Ambire, and accept that a new EntryPoint means a new delegate.
  8. chain_id = 0 is off unless the contract is immutable, CREATE2, and you have a written reason the user needs one signature on every EVM.
  9. If it is a proxy, the admin story is written down, and you never pair it with chain_id = 0.
  10. ERC-4337 compatibility if you want wallets to take you seriously — EntryPoint 0.8, validateUserOp that cannot be used as a drain, 1271 that only accepts the EOA.

On protocols that accept calls from “EOAs”

  1. msg.sender == tx.origin is not “top of stack, not a contract.” A 7702 account can call you and both addresses match. Use a real reentrancy guard. OpenZeppelin’s, or a transient-storage mutex, as ethereum.org suggests.
  2. If your threat model cannot accept delegated callers, check the first three bytes of msg.sender.code for 0xef0100 and refuse.

On relayers

  1. The spec warns you separately: the authorized account can invalidate the authorization (bump nonce) or sweep assets so you pay gas and get nothing. Bonds or reputation. Do not assume the inner signature means you will be repaid.

On wallets

  1. Only the known-implementations table, plus case-by-case. Show the full delegate address. Never collect a 7702 tuple in a dapp.

I wrote last week that Inferno did not need a zero-day, they needed a confirm screen that hid the authorization list. The other half is this: a confirm screen that shows the address does not help if the address is a 40-line contract with a public call. The whitelist exists because most people cannot read the contract. The 200-line instinct exists because the people who can still miss a missing nonce.

If you are building a delegate in this window — Pectra has been live since May, the phishing kits are already productized — start from the spec’s four bullets, steal Ambire’s “no extra storage after auth” habit, and assume MetaMask will never point a user at you until someone on that table has a reason to. That is not gatekeeping. That is what “the contract is an extension of the EOA” actually costs.

References