call vs delegatecall vs staticcall: Three Ways to Run Someone Else's Code
What a low-level call is underneath the typed syntax, how delegatecall and staticcall change the rules, and what a successful call does and does not prove.
TL;DR
- All three do the same thing: reach an address, hand it some bytes, get back a success flag and some bytes. What differs is whose storage the code runs against.
callis the ordinary case: the other contract's code, the other contract's storage.delegatecallruns the other contract's code against this contract's storage, at this address, with this contract's caller.staticcalliscallwith every state change refused, and the refusal comes from the machine, not the compiler.- A
truesuccess flag means the code did not revert. It does not mean there was code at that address.
What is a low-level call in Solidity?
Underneath the typed syntax, one contract reaching another is four things: an address, a blob of bytes, optionally some ether, and a pair of values back, a boolean saying it did not revert and the raw bytes it returned. Nothing else crosses the boundary.
Here is that shape with nothing on top, from OpenZeppelin's LowLevelCall library:
function callNoReturn(address target, uint256 value, bytes memory data) internal returns (bool success) {
assembly ("memory-safe") {
success := call(gas(), target, value, add(data, 0x20), mload(data), 0x00, 0x00)
}
}
Read the arguments in order: gas to forward, address to reach, ether to send, where the data starts, how long it is, and two zeros saying "do not copy the answer yet". That is the entire interface between two contracts on the EVM.
The typed call you normally write is the compiler building that blob. token.transfer(to, value) hashes the signature to four bytes, appends the encoded arguments, and performs exactly the operation above. Convenience, not a second mechanism.
What does call do?
call is the ordinary case, the one you have used all along without naming it. The code at the target address runs, it reads and writes the target's own storage, and inside it address(this) is the target and msg.sender is your contract. Yours is just another caller.
OpenZeppelin's Address library wraps it, and the opening of that wrapper is the operation in order:
function functionCallWithValue(address target, bytes memory data, uint256 value) internal returns (bytes memory) {
if (address(this).balance < value) {
revert Errors.InsufficientBalance(address(this).balance, value);
}
bool success = LowLevelCall.callNoReturn(target, value, data);
if (success && (LowLevelCall.returnDataSize() > 0 || target.code.length > 0)) {
return LowLevelCall.returnData();
} else if (success) {
revert AddressEmptyCode(target);
Check the balance, perform the call, sort the outcome. Note the value parameter: call is the only one of the three that can carry ether, which makes it the mechanism behind paying an address needing more than a fixed gas stipend to receive. That story is in payable, receive, and fallback.
Why does delegatecall write to this contract's storage?
delegatecall fetches the code at the target address and runs it here. The instructions come from over there, but every storage slot they touch is this contract's. address(this) is still this contract, and msg.sender is still whoever called this contract, not the contract itself.
Set the two spellings side by side and the difference is one word and one missing argument:
function delegatecallNoReturn(address target, bytes memory data) internal returns (bool success) {
assembly ("memory-safe") {
success := delegatecall(gas(), target, add(data, 0x20), mload(data), 0x00, 0x00)
}
}
There is no value slot. The Solidity docs are explicit that the value option exists only on call, and that msg.sender and msg.value carry through a delegatecall unchanged. The called code sees the world as your contract does.
A strange arrangement until you see what it is for. Here is the whole fallback function in OpenZeppelin's Proxy:
fallback() external payable virtual {
_fallback();
}
Any call naming no function this contract has lands here, and _fallback forwards it, via delegatecall, to an implementation at another address. The storage stays put. The code comes from elsewhere and can be repointed later. That is the whole idea of the proxy pattern, and the reason delegatecall exists. More in delegatecall explained and the proxy pattern.
What does staticcall refuse to do?
staticcall behaves exactly like call, with one rule added: while it runs, no state may change. Not a storage write, not an event, not a contract creation, not an ether transfer. The instruction that tries aborts, and the whole call comes back failed.
function staticcallNoReturn(address target, bytes memory data) internal view returns (bool success) {
assembly ("memory-safe") {
success := staticcall(gas(), target, add(data, 0x20), mload(data), 0x00, 0x00)
}
}
Like delegatecall, it has no value argument, because sending ether is itself a state change.
The part worth slowing down on is who enforces the rule. view and pure, covered in view vs pure, are promises the compiler checks while building your file, and it only sees code it can read. staticcall is enforced by the machine at execution time, on code the compiler never saw, so the guarantee holds whatever is deployed at the target. That is why the wrapper in Address can be view at all:
function functionStaticCall(address target, bytes memory data) internal view returns (bytes memory) {
The keyword is a claim about this file. The opcode is a fact about the run.
What is the difference between call, delegatecall and staticcall?
call | delegatecall | staticcall | |
|---|---|---|---|
| Whose code runs | The target's | The target's | The target's |
| Whose storage is read | The target's | This contract's | The target's |
| Whose storage is written | The target's | This contract's | Neither, writes are refused |
address(this) inside | The target | This contract | The target |
msg.sender inside | This contract | Unchanged, whoever called this contract | This contract |
| Can send ether | Yes, via a value argument | No such argument, msg.value is carried through | No |
| Can write state | Yes | Yes, to this contract's storage | No |
Where you meet each differs sharply. call is everywhere, usually behind typed syntax, and visible in the raw whenever a contract sends ether or reaches an address whose interface it does not know at compile time. delegatecall appears in two places and almost nowhere else: proxies, and libraries needing the calling contract's storage. staticcall is what the compiler emits every time you call a view function on another contract, so you have written thousands without typing the word.
What does a successful low-level call actually prove?
It proves the code at that address ran to the end without reverting. That is all. It does not tell you there was any code there, nor that the code did what its name suggests.
An address with nothing deployed accepts a call and returns success with no data, because there were no instructions to fail. A library that wants to know it reached real code asks separately:
/**
* @dev There's no code at `target` (it is not a contract).
*/
error AddressEmptyCode(address target);
The branches above show where that error is raised. Success plus return data means real code ran. Success with no return data and target.code.length of zero means nothing ran, so the library refuses rather than report a result it does not have. It carries that check because the machine will not, and the same lines appear word for word in functionStaticCall and functionDelegateCall.
Why does a low-level call hand back bytes memory?
Because the machine has no idea what a uint256 is. It returns a run of bytes and a count, and bytes memory is exactly that: a length, followed by that many bytes.
function returnData() internal pure returns (bytes memory result) {
assembly ("memory-safe") {
result := mload(0x40)
mstore(result, returndatasize())
returndatacopy(add(result, 0x20), 0x00, returndatasize())
mstore(0x40, add(result, add(0x20, returndatasize())))
}
}
Four steps: take the next free spot in memory, write the length there, copy that many bytes in after it, move the free pointer past what you wrote. Length first, contents second.
So the answer means nothing until you say what it should be. That is what abi.decode is for, and why low-level calls so often end in a decode into an expected type. Typed syntax writes that decode for you, plus a check that the data is the right shape. The low-level form hands both back.
Delete the line and ask what breaks
- Change
calltodelegatecallincallNoReturnand it will not compile, becausedelegatecalltakes novalueargument. The missing slot states what the instruction cannot do. - Delete the
target.code.length > 0check fromfunctionCallWithValueand a call to an address with nothing deployed returns success and empty data, so the library reports a result nobody produced. - Change
staticcalltocallinstaticcallNoReturnand theviewonfunctionStaticCallstops compiling, because a plaincallmight change state and the compiler will not sign a promise it cannot keep. - Delete the
fallbackfromProxyand every call not naming one of the proxy's own functions reverts. The proxy stops standing in for anything.
Related questions
What is the difference between call and delegatecall?
call runs the target's code against the target's storage, and the target sees your contract as msg.sender. delegatecall runs the target's code against your storage, at your address, with your own caller kept as msg.sender.
Can delegatecall send ether?
No. The value option exists only on call. A delegatecall carries the current msg.value through unchanged, so the called code sees whatever ether arrived with the outer call, but sends none itself.
What is staticcall used for?
Reading from another contract without permitting any change. The compiler emits one whenever you call a view or pure function on another contract, which is why you rarely write the word.
Does a successful low-level call mean the function worked? No. It means execution finished without reverting. An address with no code returns success and no data, which is why libraries check the target's code length separately.
Why does a low-level call return bytes memory?
Because the return value is a raw run of bytes with a length, and the machine has no type to attach. You decode it into the type you expect, usually with abi.decode.
Is call the same as a normal function call? The normal one is built on it. Typed syntax encodes the selector and arguments, calls, and decodes the result. The low-level form hands you all three jobs.
Where to go next
Three instructions, one shape: an address, some bytes, a flag and some bytes back. Once you see that shape underneath the typed syntax, one contract calling another stops being a special event.
The one that changes the rules deserves its own sitting, because a great deal of deployed code is reached through it: delegatecall explained. For the method that got you here, applied end to end on a real file, see how to read a smart contract.
Tagged