A hands-on demonstration of how delegatecall works in Solidity and how storage layout mismatches lead to silent state corruption. Built as a practical exercise to understand one of the most dangerous vulnerability classes in smart contract development — the same class responsible for several high-profile DeFi exploits.
- How delegatecall borrows code from an implementation contract and executes it in the proxy's storage context
- Why state is stored in the proxy, not the implementation, when using delegatecall
- How a storage layout mismatch between the proxy and implementation silently corrupts state
- How the corrupted proxy writes to the wrong storage slot — overwriting an address variable with an incrementing uint256
.
├── src/
│ ├── Implementation.sol # Simple counter contract with an increment function
│ ├── Proxy.sol # Proxy with matching storage layout — works correctly
│ └── CorruptedProxy.sol # Proxy with mismatched storage layout — demonstrates corruption
├── script/
│ └── DeployImplementation.s.sol # Deploys the Implementation contract
└── test/
└── unit/
└── ImplementationTest.t.sol # Unit and fuzz tests for both scenarios
- Foundry installed
forge install
forge buildforge testforge test -vvvvIn one terminal, start Anvil:
anvilIn another terminal:
forge script script/DeployImplementation.s.sol --rpc-url http://localhost:8545 --private-key $PRIVATE_KEY --broadcastA minimal counter contract. Contains a single uint256 number variable and an increment function that adds 1 to it. This contract is never called directly in the proxy pattern — it only provides the logic that the proxy borrows via delegatecall.
A proxy contract with a storage layout that matches the implementation exactly. Both have uint256 number in slot 0. When callIncrement is called, delegatecall runs the implementation's increment function in the proxy's context — slot 0 in the proxy is incremented correctly.
An intentionally broken proxy that introduces a storage layout mismatch. An address myAddress variable is inserted before number, pushing number from slot 0 to slot 1. The implementation still writes to slot 0 — but in the corrupted proxy, slot 0 is myAddress. Each delegatecall increments the address variable instead of the number variable.
call |
delegatecall |
|
|---|---|---|
| Code executed | Implementation's | Implementation's |
| Storage modified | Implementation's | Proxy's |
msg.sender inside |
Proxy's address | Original caller |
address(this) inside |
Implementation's address | Proxy's address |
Solidity assigns each state variable a storage slot based on its declaration order — slot 0, slot 1, slot 2 and so on. delegatecall does not use variable names to determine where to write — it uses slot numbers. If the proxy and implementation have variables in different slot positions, delegatecall will write to the wrong variable in the proxy.
Proxy (correct):
slot 0 → number (uint256) ← implementation writes here ✓
CorruptedProxy:
slot 0 → myAddress (address) ← implementation writes here ✗
slot 1 → number (uint256) ← never touched
After calling callIncrement on the corrupted proxy:
corruptedProxy.number()remains 0 — the intended variable is never written tocorruptedProxy.myAddress()changes from its original value to a corrupted address — the wrong slot was written toimplementation.number()remains 0 in both cases — the implementation's own storage is always untouched by delegatecall
| Test | What It Checks |
|---|---|
testNumberIncreasesOnProxy |
delegatecall increments the proxy's number and leaves the implementation's number at zero |
testFuzz_NumberIncreasesOnProxyAfterCallingMultipleTimes |
The proxy's number increments correctly across a random number of delegatecall invocations |
| Test | What It Checks |
|---|---|
testNumberDoesNotChangeOnCorruptedProxy |
The number variable in the corrupted proxy is never incremented and the implementation's number stays at zero |
testAddressVariableChangesOnCorruptedProxy |
The address in slot 0 changes after a delegatecall — proving the wrong slot was written to |
testFuzz_NumberIncreasesOnCorruptedProxyAfterCallingMultipleTimes |
The slot 0 address value increases by exactly the number of delegatecall invocations — proving each call writes 1 to slot 0 |
Storage collision bugs have caused some of the most significant exploits in DeFi history. Any protocol that uses the proxy pattern — which includes the majority of production DeFi — is vulnerable to this class of bug if the storage layout between proxy and implementation is not carefully managed.
The ERC1967 standard exists specifically to address part of this problem by storing the implementation address in a slot far outside the normal range, preventing it from colliding with implementation variables. UUPS and Transparent Proxy patterns build further protections on top of this foundation.
Understanding storage collisions at this level is essential for auditing any upgradeable contract system.
MIT