Alice switches wallet apps. Her old one had delegated her EOA to its own smart-account contract under EIP-7702; the new one signs a fresh authorisation and points the same address at its own. The new app opens, shows her account, and tells her the owner is 0x0000000000000000000000000000000000000007.
She can't sign anything. The recovery path is initialise(), and initialise() reverts with AlreadyInitialised(). Nobody was hacked. Both delegate contracts are correct, audited, and sensible read on their own. The account is simply gone, and the reason is that one of them wrote to slot 0 and the other one read from it.
Delegation changes the code pointer, not the account
Pectra activated EIP-7702 on mainnet on 7 May 2025, and the mental model most of us carried over from proxies is subtly wrong in a way that matters here.
When an EOA signs an authorisation tuple — [chain_id, address, nonce, y_parity, r, s] — the protocol writes a 23-byte delegation designator into the account's code field: 0xef0100 || address. That's it. EXTCODESIZE on the account returns 23. Calls into the address execute the delegate's code in the context of the EOA. Signing a new authorisation overwrites those 23 bytes.
What it does not do is touch storage. The account's storage trie is keyed by the EOA's address and persists across every delegation it will ever have, forward and backward, forever. EIP-7702's own security considerations are blunt about the two consequences: clearing storage is "an operation that is not natively offered by the protocol", and the EIP "does not provide developers the opportunity to run initcode and set storage slots during delegation".
So a 7702 delegate is not a proxy implementation with a private storage region. It's one of an unbounded, unknowable set of tenants sharing a disk that nobody can format. The previous tenant may have written anything anywhere, and you cannot ask them what.
Two correct contracts, one dead account
Here are the two delegates, stripped to the part that matters.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
/// Vendor A's delegate. Perfectly sound on its own.
contract SessionWallet {
uint256 public nonce; // slot 0
address public owner; // slot 1
error NotOwner();
error AlreadyInitialised();
function initialise(address _owner) external {
if (owner != address(0)) revert AlreadyInitialised();
owner = _owner;
}
function execute(address to, bytes calldata data) external payable {
if (msg.sender != owner) revert NotOwner();
nonce++;
(bool ok, ) = to.call{value: msg.value}(data);
require(ok, "call failed");
}
}
/// Vendor B's delegate. Also sound on its own. Same two fields, opposite order.
contract GuardianWallet {
address public owner; // slot 0
uint256 public nonce; // slot 1
address[] public guardians; // slot 2
error NotOwner();
error AlreadyInitialised();
function initialise(address _owner, address[] calldata _guardians) external {
if (owner != address(0)) revert AlreadyInitialised();
owner = _owner;
guardians = _guardians;
}
function execute(address to, bytes calldata data) external payable {
if (msg.sender != owner) revert NotOwner();
nonce++;
(bool ok, ) = to.call{value: msg.value}(data);
require(ok, "call failed");
}
}
Neither author did anything wrong. They disagreed about field order, which in any other context is a matter of taste. forge inspect src/Wallets.sol:SessionWallet storageLayout and the same for GuardianWallet show the whole bug:
SessionWallet nonce slot=0 offset=0 type=t_uint256
SessionWallet owner slot=1 offset=0 type=t_address
GuardianWallet owner slot=0 offset=0 type=t_address
GuardianWallet nonce slot=1 offset=0 type=t_uint256
GuardianWallet guardians slot=2 offset=0 type=t_array(t_address)dyn_storage
A Foundry test reproduces the stranding end to end. vm.signAndAttachDelegation is the cheatcode that installs a designator the way a real authorisation would, so the migration in the test is the migration a user performs.
contract CollisionTest is Test {
uint256 constant ALICE_PK = 0xa11ce;
address alice;
SessionWallet vendorA;
GuardianWallet vendorB;
function setUp() public {
alice = vm.addr(ALICE_PK);
vendorA = new SessionWallet();
vendorB = new GuardianWallet();
}
function test_redelegationStrandsTheAccount() public {
// Alice delegates to vendor A and uses the account seven times.
vm.signAndAttachDelegation(address(vendorA), ALICE_PK);
SessionWallet(alice).initialise(alice);
for (uint256 i = 0; i < 7; i++) {
vm.prank(alice);
SessionWallet(alice).execute(address(0xdead), "");
}
assertEq(vm.load(alice, bytes32(0)), bytes32(uint256(7)));
// Alice switches vendor. Only the designator changes. Storage does not.
vm.signAndAttachDelegation(address(vendorB), ALICE_PK);
assertEq(vm.load(alice, bytes32(0)), bytes32(uint256(7)));
// Vendor B reads owner from slot 0 and finds vendor A's call counter.
assertEq(GuardianWallet(alice).owner(), address(7));
// Alice cannot act, and cannot initialise her way out: slot 0 is non-zero.
address[] memory guardians = new address[](0);
vm.expectRevert(GuardianWallet.AlreadyInitialised.selector);
GuardianWallet(alice).initialise(alice, guardians);
}
}
The account now has an owner nobody holds the key for, and the one-shot initialiser that would fix it is permanently tripped. Note where the stranding comes from: not from a missing access check, but from the fact that 7702 gives you no initcode hook, so initialisation has to be a separate call guarded by a flag, and the flag lives in storage you don't control.
Swap vendor A's call counter for any low slot that a previous delegate let an arbitrary address land in — a module registry, a cached lastCaller, a session-key pointer — and the brick becomes a takeover. Same bug, worse ending.
The argument
A 7702 delegate does not own its storage. Treat the low slots as a shared, permanently allocated, never-reclaimable region, and write nothing there.
That's not a style preference, and it isn't satisfied by "we audited our layout". Your layout is fine. The collision is a property of the pair, and you will never see the other half of the pair, because it's a contract written by someone you've never met for a user who hasn't chosen your wallet yet.
Namespacing is the part you can do unilaterally
ERC-7201 is Final and does exactly one useful thing: it derives a pseudorandom base slot from a string.
erc7201(id) = keccak256(keccak256(id) - 1) & ~0xff
The - 1 means the value being hashed is not itself a keccak256 output, so no mapping key or array index can be made to land on your base. The & ~0xff aligns the base to a 256-slot boundary, giving the namespace room to grow without walking into the next one. What's left is 248 bits of entropy, which moves a near-certain collision at slot 0 into the territory where you stop thinking about it.
Since Solidity 0.8.29 you can apply it in the contract header with layout at, and since 0.8.35 erc7201 is a compiler builtin, so the string is right there in the source instead of a hand-pasted hex constant:
/// @custom:storage-location erc7201:vendorb.storage.GuardianWallet
contract GuardianWalletNamespaced layout at erc7201("vendorb.storage.GuardianWallet") {
address public owner;
uint256 public nonce;
address[] public guardians;
}
Same fields, same code, different home. The layout that comes out of solc 0.8.37:
owner slot=0x756f73da7cd5f86e444ccddfba38192319feadcf3318c873c016d08938adff00
nonce slot=0x756f73da7cd5f86e444ccddfba38192319feadcf3318c873c016d08938adff01
guardians slot=0x756f73da7cd5f86e444ccddfba38192319feadcf3318c873c016d08938adff02
A few rules worth knowing before you reach for it: the specifier goes on the most-derived contract only, shifts the whole linearised hierarchy by the same amount, can't be used on abstract contracts, interfaces or libraries, and doesn't affect transient variables.
Namespacing alone isn't discovery
Picking a unique base protects accounts that pass through your delegate. It tells a wallet switching to you nothing about what the last one did, which is the half of the problem that needs a standard.
That's ERC-7779, still a Draft, and the first ERC written specifically for EIP-7702. It asks a delegate to answer two questions:
interface IInteroperableDelegatedAccount {
/// A namespace identifying the account, e.g. vendor and version.
function accountId() external view returns (string memory);
/// Every storage base this account has ever used, append-only.
function accountStorageBases() external view returns (bytes32[] memory);
}
interface IRedelegableDelegatedAccount {
/// Best-effort cleanup, called before the account is redelegated.
function onRedelegation() external returns (bool);
}
A wallet about to ask for an authorisation calls accountStorageBases() first and checks for overlap before it asks the user to sign. The base list itself lives at an ERC-7201 slot derived from InteroperableDelegatedAccount.ERC.Storage and is append-only, so history survives migrations. onRedelegation() is optional and explicitly best-effort — notably, the ERC warns it must not reset replay protection, since that would re-enable signature replay.
Be honest about the chicken and egg: a delegate that never implemented ERC-7779 returns nothing, and that's most of them today. Implement it, but don't let it stand in for namespacing. Namespacing works against counterparties who have never heard of you, which is the only threat model available here.
And storage hygiene is necessary, not sufficient. If your replay counter lives in account storage, a hostile or merely careless intervening delegate can zero it and make an old signed message valid again; Base's EIP7702Proxy writeup moves nonces into a separate singleton for exactly that reason.
Check this week
Three commands, five minutes.
Run forge inspect src/YourWallet.sol:YourWallet storageLayout on anything you ship as a 7702 delegate. If any state variable reports a small decimal slot, you have a collision surface. There is no safe small number; the fix isn't to start at slot 50.
Then look at a live delegated account. cast code <address> on a delegated EOA returns 23 bytes beginning 0xef0100 — the trailing 20 are the delegate. Follow it with cast storage <address> 0 and see what is actually sitting in the slot your contract would call owner. One caveat if you go on to read a namespaced slot: cast storage wants slots above u64::MAX in hex, so pass the base as 0x756f...ff00, not as a decimal.
If both commands come back boring, you've confirmed it rather than assumed it. That's the whole difference.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.