Curious case of delegate call

Warm Introduction

Most devs starting to learn solidity find delegate-call a bit complex to understand. Most articles online provide a good explanation of the theory behind delegate-call but I want to take a different approach here. I would provide a basic introduction about delegate-calls first and then jump straight into implementing it based on the foundation developed. At the end of this article, I would leave you with an unanswered question which will lead us to learn advanced concepts about delegate-call in the next blog.

Delegate call

A delegate-call is a special low-level function call in solidity in which logic present in the function of the callee is executed in the context of the caller contract. By context I mean the storage of the caller contract. So a delegate call combines the calling contract's storage, balance and then executes the logic in the callee contract as if the logic was part of the calling contract.

Why do we need a delegate call

  1. Contracts Upgradability: Without delegate-calls providing upgrades to dapps is a tedious process. If the delegate call was not present we need to write explicit logic to copy storage from previous versions to the latest version. With the help of delegate-call, we can implement the proxy pattern to make our core dapp logic upgradable. Proxy pattern is a contract design pattern in which there is a proxy contract which maintains storage and a core logic contract consisting of necessary function logic. First, the core logic contract is deployed and then the address of the logic contract is stored in the storage of the proxy contract. To update our dapp we just need to change the linking address in the proxy contract to the newly deployed logic contract.

  2. Gas Fees: Delegate call enables us to save on gas fees because of the way it is inherently built. Since delegate-call automatically uses the storage of caller, balance and msg.sender we don't need to pay additional gas for explicitly copying that data.

  3. Code reusability: Using delegate calls we can use other contract's logic(even which writes to storage) without re-implementing it again. This promotes modular and efficient code development without the need for replicating the same logic.


The Curious Case

Let's say there are 3 contracts A, B, and C. A function in contract A makes delegate-call to a function in contract B, which in turn makes a normal call to a function in contract C. Now who would be the msg.sender to contract C? Will it be Contract B? or Contract A? Let's implement and find it out.

We would be using Remix for investigating this case. Remix is an online IDE for smart contract development in Solidity. Contract C must be deployed first then contract B and then contract A. The reason for this is that we need the address of contract C which we get after deploying it, in contract B. Same reason can be given for why we need to deploy contract A after deploying contract B and contract C. For faster deployment and testing all the contracts are deployed on the local test-net called Remix VM(Shanghai).

Contract C

Contract C is a very basic one which contains the address of the owner in its storage and a function which modifies the owner based on who is calling it. I haven't added any modifier to protect the changeOwner() function from being called by any address just to keep it simple and also it doesn't add any value to the case we are investigating.

// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.7.0 <0.9.0;

contract c{
    address public owner;

    constructor(){}

    function changeOwner() public {
        owner = msg.sender;
    }
}

Contract B

Contract B is similar to contract C except that it has an additional address variable in storage which points to the address of contract C and a function(changeOwnerC()) which performs call to changeOwner() function in contract C.

In order to call a function in another contract we need to have the address at which the contract is deployed(addressOfCallee) and the signature of the function which we want to call( abi.encodeWithSignature("functionToBeCalled()") ). Call in solidity returns both status of the call made and data returned by the call in bytes format.

// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.7.0 <0.9.0;

contract b{
    address public owner;
    address public unUsedVar;
    address public contractC = 0x0fC5025C764cE34df352757e82f7B5c4Df39A836;

    constructor(){}

    function changeOwner() public {
        owner = msg.sender;
    }

    function changeOwnerC() public {
        (bool success, bytes memory data) = contractC.call(abi.encodeWithSignature("changeOwner()"));
        require(success, "tx failed");
    }
}

If you had a keen look at the contract B you would have found that I have explicitly declared an address variable called unUsedVar. Have a thought about what would happen if we removed it. Let's get back to it in the end.

Contract A

After deploying both contract B and contract C we now store those addresses in the storage of contract A. Contract A performs delegate calls to both functions in contract B. Syntax for delegate call is almost similar to normal call, you just to do delegatecall() instead of call() on callee's contract address.

// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.7.0 <0.9.0;

contract a{
    address public owner;
    address public contractB= 0xb27A31f1b0AF2946B7F582768f03239b1eC07c2c;
    address public contractC = 0x0fC5025C764cE34df352757e82f7B5c4Df39A836;

    constructor(){}

    function changeOwner() public {
        owner = msg.sender;
    }

    function changeOwnerDelegate() public {
        (bool success, bytes memory data) = contractB.delegatecall(abi.encodeWithSignature("changeOwner()"));
        require(success, "tx failed");
    }

    function changeOwnerDelegateC() public {
        (bool success, bytes memory data) = contractB.delegatecall(abi.encodeWithSignature("changeOwnerC()"));
        require(success, "tx failed");
    }
}

Now that we have deployed all the required contracts let's see what actually happens.

Moment of Truth

Below you can find the address of deployed contracts on Remix.

Right after deploying contract C the address of the owner is 0x000. Since we didn't explicitly set the address of the owner in contract C's constructor(), the default value for the address data type in solidity is set.

Below you can see the changes after calling changeOwnerDelegateC() in contract A. When we call changeOwnerDelegateC() in contract A it makes a delegateCall() to contract B which further makes a standard call to contract C to update the owner in contract C. Interestingly you will find out that the updated value of the owner in contract C is not the address of contract B but in fact, it is the deployed address of contract A.

When we make a delegate call from contract A to contract B, internally what happens is that the compiler loads function logic in contract B into contract A and then makes a normal call to contract C, hence we see the address of contract A as the owner in contract C.


Closing thoughts

That's it for this article, pat yourself for making it to the end. Hold on.... have you figured out the Curious case of unUsedVar in contract B? No worries we will discuss that in the next blog. Until then keep the curiosity flame alive!

References