delegatecall in Solidity: Another Contract's Code, This Contract's Storage
What delegatecall actually does, how it differs from an ordinary call, and a line-by-line reading of the assembly block every proxy in Ethereum is built on.
TL;DR
delegatecallruns another contract's code against this contract's storage, with this contract's address and this contract's caller. Every other property follows from that one sentence.- An ordinary
callruns the other contract's code in the other contract's context. Same instructions, different machine. - The assembly block in a proxy is four moves: copy the calldata in, delegate, copy the answer back, return or revert with it.
outandoutsizeare zero because the size of the answer is not known until the call has already finished.- When you find this shape in a file, the code you actually wanted to read is at a different address.
What does delegatecall do in Solidity?
delegatecall runs another contract's code against this contract's storage. The code comes from the address you name. Everything else stays here: the storage that gets read and written, the address the code sees as its own, and the caller it sees as msg.sender. Borrowed instructions, local state.
OpenZeppelin's Proxy contract opens with a description of exactly that arrangement:
/**
* @dev This abstract contract provides a fallback function that delegates all calls to another contract using the EVM
* instruction `delegatecall`. We refer to the second contract as the _implementation_ behind the proxy, and it has to
* be specified by overriding the virtual {_implementation} function.
*
* Additionally, delegation to the implementation can be triggered manually through the {_fallback} function, or to a
* different contract through the {_delegate} function.
*
* The success and return data of the delegated call will be returned back to the caller of the proxy.
*/
Two contracts, two jobs. One holds the state and receives the calls. The other holds the bytecode that gets run. That second one is the implementation, and the word is worth learning: every codebase uses it for the contract whose code is borrowed.
What is the difference between call and delegatecall?
An ordinary call runs the other contract's code in the other contract's context: its storage, its address, and you as the caller. delegatecall keeps the context here. Same code either way, two different machines executing it, and that single swap is the whole distinction.
Four properties change, and they are the four worth memorising:
Ordinary call | delegatecall | |
|---|---|---|
| Whose code runs | The other contract's | The other contract's |
| Whose storage is read and written | The other contract's | This contract's |
What address(this) is | The other contract | This contract |
What msg.sender is | This contract | Whoever called this contract |
The msg.sender row is the one that catches people. Under an ordinary call, the contract you call sees you, the intermediate contract, as its caller. Under delegatecall no new frame is entered, so the borrowed code sees whoever started the transaction. If msg.sender is how a contract knows who it is talking to, delegatecall is the operation that does not disturb the answer.
What does the assembly inside a proxy fallback actually do?
Four moves, in order. Copy the incoming calldata into memory, run delegatecall on it, copy whatever came back into memory, then hand that back to the caller: as a return value if the call succeeded, as a revert if it did not. The Yul is long, the plot is short.
Here is the canonical version in full, comments and all:
function _delegate(address implementation) internal virtual {
assembly {
// Copy msg.data. We take full control of memory in this inline assembly
// block because it will not return to Solidity code. We overwrite the
// Solidity scratch pad at memory position 0.
calldatacopy(0x00, 0x00, calldatasize())
// Call the implementation.
// out and outsize are 0 because we don't know the size yet.
let result := delegatecall(gas(), implementation, 0x00, calldatasize(), 0x00, 0x00)
// Copy the returned data.
returndatacopy(0x00, 0x00, returndatasize())
switch result
// delegatecall returns 0 on error.
case 0 {
revert(0x00, returndatasize())
}
default {
return(0x00, returndatasize())
}
}
}
Take it one instruction at a time and the frightening names turn out to be descriptions.
calldatacopy(0x00, 0x00, calldatasize()) copies the incoming call data into memory. Three arguments: write to memory position zero, read from calldata position zero, and take calldatasize() bytes, which is all of them. Nothing clever, just a move from one place to another so the next instruction has something to point at.
delegatecall(gas(), implementation, 0x00, calldatasize(), 0x00, 0x00) is the operation itself. Forward all remaining gas, use the code at implementation, and the input is the calldatasize() bytes sitting at memory position zero, which is exactly what was just copied there.
returndatacopy(0x00, 0x00, returndatasize()) is the mirror image of the first line. Whatever the borrowed code produced, copy all of it back into memory at position zero.
switch result is Yul's two-branch test. delegatecall puts 1 in result on success and 0 on failure, so case 0 is the failure path and default is everything else.
Why are the out and outsize arguments both zero?
Because at the moment of the call nobody knows how big the answer will be. The file says so in its own comment. Those two arguments would tell the EVM where to put returned data and how much of it to keep, so instead the code skips them and copies the data afterwards.
The comment is one line and it explains itself:
// Call the implementation.
// out and outsize are 0 because we don't know the size yet.
A proxy forwards calls it has never seen. It cannot know whether the answer is a single boolean or a long array, so reserving a fixed amount of space in advance would mean guessing.
So the code declines to guess. It passes zero for both, which means "do not write the return data anywhere", and then picks the data up afterwards with returndatacopy, by which point returndatasize() is a known number rather than a prediction. Do the thing first, measure the result second.
Why does _delegate never return to Solidity?
Because the last thing it does is return or revert, which are EVM instructions that end the whole transaction frame, not Solidity keywords that hand a value back to the calling function. The doc comment above the function states it plainly, and it is the reason the function is written in assembly.
/**
* @dev Delegates the current call to `implementation`.
*
* This function does not return to its internal call site, it will return directly to the external caller.
*/
"Does not return to its internal call site" is precise, and follows from the switch:
switch result
// delegatecall returns 0 on error.
case 0 {
revert(0x00, returndatasize())
}
default {
return(0x00, returndatasize())
}
Both branches take the same two arguments: start at memory position zero, use returndatasize() bytes, and both send back the borrowed code's answer verbatim. Only the instruction differs, and with it whether the caller receives a result or a revert reason. The proxy adds nothing and edits nothing, which is why the header comment can promise that success and return data arrive back unchanged.
This is also why the first comment in the block says the code takes full control of memory and overwrites Solidity's scratch pad at position zero. Solidity normally reserves that region for its own use. Here it is safe to trample, because execution never comes back to any Solidity that would care.
Why are upgradeable contracts built on delegatecall?
Because storage and code end up at different addresses. The storage lives at the proxy, which never moves, and the code lives at an implementation address the proxy looks up every time. Point the lookup somewhere else and the same stored data is served by different logic.
The lookup is a single function, and the base contract deliberately leaves it unimplemented:
/**
* @dev This is a virtual function that should be overridden so it returns the address to which the fallback
* function and {_fallback} should delegate.
*/
function _implementation() internal view virtual returns (address);
No body, just a signature, so Proxy is abstract and something must inherit from it and supply the answer. That is the virtual and override machinery from Solidity inheritance doing structural work: the whole variability of the proxy pattern is concentrated into one overridable function.
Everything else is fixed:
/**
* @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if no other
* function in the contract matches the call data.
*/
fallback() external payable virtual {
_fallback();
}
A call arrives, matches no function here, lands in fallback, and gets delegated. Because the storage being written is the proxy's own, the two addresses share one layout: the implementation's variables are numbered slots, and it reads and writes the proxy's slots by those numbers. EVM storage layout is the piece that makes that arrangement make sense.
How do you use delegatecall without writing assembly?
You call it as a member of the address type and pass ABI-encoded calldata, which is what OpenZeppelin's Address library wraps. It returns a success flag and the raw returned bytes, and the library turns an unsuccessful one into a revert so it reads like an ordinary function call.
/**
* @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`],
* but performing a delegate call.
*/
function functionDelegateCall(address target, bytes memory data) internal returns (bytes memory) {
bool success = LowLevelCall.delegatecallNoReturn(target, data);
if (success && (LowLevelCall.returnDataSize() > 0 || target.code.length > 0)) {
return LowLevelCall.returnData();
} else if (success) {
revert AddressEmptyCode(target);
} else if (LowLevelCall.returnDataSize() > 0) {
LowLevelCall.bubbleRevert();
} else {
revert Errors.FailedCall();
}
}
Same operation, ordinary Solidity around it. Success with data, or with a target that has code, gives you the bytes. Success against an address holding no code reverts with AddressEmptyCode, because a call to an empty address succeeds trivially and would otherwise look like a real result. Failure re-throws the reason if there is one, and a generic error if not.
The reason Proxy does not use this helper is the previous section: a proxy must return the implementation's answer directly to the external caller, and this function returns bytes memory to whoever called it inside Solidity.
Delete the line and ask what breaks
- Change
delegatecalltocallin_delegateand the implementation starts reading and writing its own storage instead of the proxy's. The proxy's stored data stops being touched, andmsg.senderbecomes the proxy rather than the user. - Delete the
calldatacopyline anddelegatecallforwards whatever happened to be in memory at position zero. The implementation can no longer tell which function was requested. - Replace the two zeros at the end of the
delegatecallline with a fixed size and any answer longer than that is cut short. The comment above the line is explaining why that guess is not available. - Swap the final
returnfor a Solidityreturnstatement and the function stops working as a proxy, because control comes back to_fallbackand the returned bytes have to be handled there instead of being passed straight out.
Related questions
What is the difference between call, delegatecall and staticcall?
call runs the target's code in the target's context. delegatecall runs the target's code in the caller's context. staticcall is a call that is not permitted to modify any state, which is how view functions are invoked across contracts.
Does delegatecall change msg.sender?
No, and that is one of its defining properties. The borrowed code sees the same msg.sender the delegating contract saw, because no new call frame with a new caller is entered. msg.value is preserved for the same reason.
Where does delegatecall write its state changes?
Into the storage of the contract that issued the delegatecall, not the contract whose code ran. The implementation's state variable declarations describe slot numbers, and those slots belong to the proxy.
Why is the proxy fallback written in assembly? Because it has to forward arbitrary calldata it cannot type, and return arbitrary data directly to the external caller. Solidity's function machinery works in declared types and returns to the internal call site, so neither requirement can be expressed in plain Solidity.
What does returndatasize do? It reports how many bytes the most recent call returned. It is only meaningful after the call has completed, which is exactly why the proxy copies return data afterwards instead of reserving space beforehand.
Can a contract delegatecall to an address with no code?
Yes, and it succeeds while doing nothing, which is why functionDelegateCall checks target.code.length before treating an empty response as a real result.
Where to go next
One sentence carries this entire topic: another contract's code, this contract's storage. The contrast table, the assembly block, and the whole idea of an upgradeable contract are consequences of it rather than separate facts to learn.
The reading habit is the part that transfers. When a file's fallback is a short block of Yul ending in return and revert, stop looking for the logic in that file, because it is not there: the address in _implementation() is the thing you actually wanted to open. That is the same move as any other unknown name in how to read a smart contract, just pointing at an address instead of an import. Next, the pattern built on top of it: the proxy pattern in Solidity.
Tagged