📳Understanding Delegatecall

Solidity, Ethereum's smart contract programming language, provides several low-level functions to interact with other contracts. One of the most powerful, yet potentially confusing, is the delegatecall. This article breaks down the intricacies of delegatecall, illustrating how it works and the use-cases and potential pitfalls associated with it.

What is delegatecall?

At its core, delegatecall is a type of function call in Solidity that invokes a method in another contract without changing the context of the call. Specifically, it calls a function from another contract using the storage of the calling contract. This means that while the code of the called contract is executed, all storage writes, reads, and state changes will apply to the calling contract.

While the concept of delegatecall might seem abstract, a hands-on example can make it clearer.

Example:

Imagine two contracts, StorageContract and LogicContract.

// StorageContract.sol
pragma solidity ^0.8.0;

contract StorageContract {
    uint256 public data;

    function setData(uint256 _data) public {
        data = _data;
    }

    function executeLogic(address logicAddress, bytes memory _data) public {
        (bool success,) = logicAddress.delegatecall(_data);
        require(success);
    }
}

// LogicContract.sol
pragma solidity ^0.8.0;

contract LogicContract {
    function increaseData() public {
        StorageContract sc = StorageContract(msg.sender);
        uint256 currentData = sc.data();
        sc.setData(currentData + 1);
    }
}

Here, StorageContract has a state variable data and a method to set that data. It also has an executeLogic method that uses delegatecall to execute some logic contained in another contract, which we intend to be LogicContract.

LogicContract has a method increaseData that reads the current data from StorageContract and increments it by one.

When you call the executeLogic method of StorageContract with the address of LogicContract and the call data for increaseData, here's what happens:

  1. LogicContract's increaseData method is invoked via delegatecall.

  2. Inside increaseData, the msg.sender remains the address of StorageContract. This is crucial. It doesn't change to the address of LogicContract.

  3. The increaseData method fetches the current data from StorageContract, increments it, and writes it back to StorageContract.

From the outside, it appears as though StorageContract simply increased its data variable. But in reality, the logic to do so came from LogicContract, all thanks to the delegatecall functionality.

This separation allows for modular design. If, in the future, you wanted to change the way data is increased (maybe by doubling it instead of incrementing), you could deploy a new logic contract and point the StorageContract to use that for its logic, without affecting the actual data stored.

This example visually demonstrates the power and flexibility of delegatecall and how, when used correctly, it can allow for dynamic interactions between contracts.

Extended Example:

Consider three contracts - PrimaryContract, IntermediateContract, and FinalContract.

// PrimaryContract.sol
pragma solidity ^0.8.0;

contract PrimaryContract {
    function triggerChain() public {
        IntermediateContract ic = new IntermediateContract();
        ic.triggerLogic();
    }
}

// IntermediateContract.sol
pragma solidity ^0.8.0;

contract IntermediateContract {
    function triggerLogic() public {
        FinalContract fc = new FinalContract();
        fc.emitEvent();
    }
}

// FinalContract.sol
pragma solidity ^0.8.0;

contract FinalContract {
    event SenderEvent(address sender);

    function emitEvent() public {
        emit SenderEvent(msg.sender);
    }
}

Chain of Actions:

  1. Initial Call: Bob's EOA (Externally Owned Account) calls the triggerChain function of PrimaryContract.

  2. DelegateCall in PrimaryContract: Inside the triggerChain function of PrimaryContract, there's a DELEGATECALL to the triggerLogic function of IntermediateContract. Remember, with DELEGATECALL, the code of triggerLogic in IntermediateContract executes within the context of PrimaryContract. So, msg.sender remains Bob's EOA.

  3. Regular Call in IntermediateContract's triggerLogic Function: The triggerLogic function in IntermediateContract performs a regular call to the emitEvent function of FinalContract.

  4. Event Emission in FinalContract: The emitEvent function in FinalContract emits an event. Given that the triggerLogic function from IntermediateContract was invoked via DELEGATECALL from PrimaryContract, msg.sender stays as Bob's EOA. Therefore, when the emitEvent function in FinalContract is called, msg.sender remains Bob's EOA. As a result, the SenderEvent emitted in FinalContract will capture Bob's EOA as the sender.

Outcome: FinalContract emits an event with Bob's EOA address as the published data, even though the logic ran across multiple contracts.

Incorporating this example provides a real-world scenario to further understand the behavior of delegatecall and its implications on msg.sender.

How does delegatecall differ from other calls?

Solidity provides various inter-contract function call mechanisms, each serving different purposes:

  1. Regular Calls (this.f(), B.f()): Executes function f in contract B. Any state changes happen within contract B.

  2. call: A low-level call mechanism where you have more granular control over data sent and gas provided. State changes occur in the called contract.

  3. delegatecall: Executes the code of another contract, but storage writes/reads are made in the calling contract's context.

  4. staticcall: Similar to delegatecall, but cannot change state (read-only).

The primary distinction of delegatecall is the fact that it allows for the code in another contract to be executed in the context of the calling contract's storage.

Use-cases for delegatecall

  1. Upgradable Contracts: A common use case is creating upgradable smart contracts. The contract logic (code) is separated from the data storage. When the logic needs to be updated, a new contract is deployed, and the original contract uses delegatecall to invoke the new logic while retaining the original storage.

  2. Libraries: Instead of duplicating code across multiple contracts, functions can be stored in a library and invoked using delegatecall, saving gas.

Pitfalls and Dangers

  1. Storage Layout Mismatch: The biggest risk of using delegatecall is a storage collision. Since the called contract uses the storage layout of the calling contract, a mismatch can result in unintended overwrites.

  2. Unintended Side Effects: A called contract might not be aware of the state variables and structures of the calling contract, leading to unintended side effects.

  3. Increased Attack Surface: delegatecall increases the attack surface of a contract since bugs or vulnerabilities in the called contract can affect the calling contract.

  4. Precompiled Contract Vulnerabilities: If the delegatecall interacts with precompiled contracts, certain vulnerabilities specific to those precompiled contracts can be exploited.

Best Practices

  1. Storage Consistency: Ensure that the storage layout of both contracts is consistent. When updating contracts, always append new state variables; don't change the order of existing ones.

  2. Clear Documentation: Document the contracts and their interactions meticulously.

  3. Thorough Testing: Extensively test all interactions, especially when using delegatecall.

  4. Avoid Complexity: If possible, avoid making things overly complicated. The more complex the interactions, the higher the chance for errors.

Conclusion

delegatecall is a powerful feature in Solidity, enabling dynamic contract interactions and upgradability. However, its power comes with increased responsibility. Developers must ensure they understand its workings deeply and employ rigorous testing and security practices to prevent potential pitfalls.

Any questions on the material so far? Ask Omar Inuwa:

LinkedIn, Twitter

Last updated