ETHLabs city hero image
A mental model for understanding delegatecall


Quiz:
The following chain of function calls is executed:

Example chain of calls and delegatecalls
In the context of the call from C to D, msg.sender is which of the following?
a) EOA A
b) Contract B
c) Contract C
d) Contract D



The delegatecall opcode is a low level call that is nearly identical to the call opcode, except that the code executed at the targeted contract is run in the context of the calling contract. This means:
  • Storage is updated in the calling contract, not the targeted contract
  • msg.sender is unchanged
  • msg.value is unchanged


This allows us to do fun things like implementing libraries or proxy patterns:

Delegatecalls used in proxy pattern

Where in these calls from users to the proxy contracts:
  • The function invoked on the logic contract updates state in the proxy contract
  • The implementation contract msg.sender is the user's address
  • The implementation contract msg.value is the ETH the user sent in the proxy contract call


Let's revisit the question from the beginning of this thread and write some code to prove that msg.sender is Contract B:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import "hardhat/console.sol";

contract B {
    error DelegateCallFailed();

    function bFunc(address c, address d) external returns (bytes memory) {
        console.log("B msg.sender: ", msg.sender);

        // delegatecall function cFunc() on contract C and 
        // pass it the address of contract D
        (bool success, bytes memory data) = 
            c.delegatecall(abi.encodeWithSignature("cFunc(address)", d));

        if (!success) revert DelegateCallFailed();
        return data;
    }
}

contract C {
    function cFunc(address d) external view {
        console.log("C msg.sender: ", msg.sender);

        // Call function dFunc on contract D
        D(d).dFunc();
    }
}

contract D {
    function dFunc() external view {
        console.log("D msg.sender: ", msg.sender);
    }
}

Once we've deployed these three contracts, we can use our EOA A to call function bFunc on Contract B, passing it the addresses of the other two contracts. Inspecting our console.logs, we see:

Console.logs from terminal
Where:
0x5B38Da...EOA A
0xF896bB...Contract B

Let's update our function call diagram with these msg.senders for clarity:
Example chain of calls and delegatecalls

Now I'd like to introduce a mental model for thinking about delegatecall and the context it is being executed within:

Whenever a function on a target contract is invoked via delegatecall, imagine the targeted function temporarily exists on the calling contract.

The calling contract is delegating functionality from another contract to within its own context.


Let's apply this mental model to the example function call chain from above.

In the three contracts we wrote earlier, we can prove this mental model works by moving function cFunc out of Contract C, and into Contract B, and get rid of Contract C altogether:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import "hardhat/console.sol";

contract B {
    error DelegateCallFailed();

    function bFunc(address d) external view {
        console.log("B msg.sender: ", msg.sender);

        // Call function cFunc that is now inside this contract
        cFunc(d);
    }

    function cFunc(address d) public view {
        console.log("C msg.sender: ", msg.sender);

        // Call function dFunc on contract D
        D(d).dFunc();
    }
}

contract D {
    function dFunc() external view {
        console.log("D msg.sender: ", msg.sender);
    }
}


Since this code is functionally the same as our original code with the delegatecall, it should now be obvious that the call to Contract Dwas being invoked from within the context of Contract B, and therefore msg.sender inside dFunc is Contract B.Example chain of calls and delegatecalls

So functionally this is what's meant when delegatecall is explained as "code at the target address is executed in the context of the calling contract and msg.sender and msg.value do not change their values."

This mental model can also be used to understand why msg.value is preserved, and why storage is updated on the calling contract, and not the targeted contract in a delegatecall.

If cFunc in our first codebase updated state, it's now obvious that it would update state in Contract B. This is why the storage layout must be the same in both the calling contract and targeted contract when delegatecall is used.

I hope this thread helped you understand delegatecall at a deeper level, and will help you in future smart contract development or audits.