Solidity: `call` vs `delegatecall`

I came across this topic while reading the phenomenal “Mastering Ethereum” and thought it was worth writing a small post about it.

From the book:

Solidity offers some even more “low-level” functions for calling other contracts.
These correspond directly to EVM opcodes of the same name and allow us to construct a contract-to-contract call manually.

These 2 functions are call and delegatecall and the main difference is that the latter doesn’t change the msg context at all.

Let’s see a quick example to illustrate this difference:

pragma solidity ^0.8.10;

contract Called {
  event callEvent(address sender, address origin, address from);
  function callMe() public {
    emit callEvent(msg.sender, tx.origin, address(this));
  }
}

contract Caller {
  function makeCalls(address _contractAddress) public {   address(_contractAddress).call(abi.encodeWithSignature("callMe()"));
  address(_contractAddress).delegatecall(abi.encodeWithSignature("callMe()"));
  }
}

In the example above Caller interacts with Called’s callMe function in 2 different ways: one using call, another one using delegatecall.

callMe will emit a callEvent every time it’s called so we can inspect what the context (msg, tx and this) looks like in both scenarios.

In order to test this out, I created a simple script in Hardhat to compile both contracts, perform the calls and inspect the events to see how they look:

$> mkdir call_tests && cd call_tests
$> npx hardhat

scripts/makeCall.js 👇🏽

  const hre = require('hardhat');

  const main = async () => {
    const LibraryFactory = await hre.ethers.getContractFactory('CalledLibrary');
    const library = await LibraryFactory.deploy();
    const CallerFactory = await hre.ethers.getContractFactory('Caller', {
      libraries: {
        CalledLibrary: library.address,
      },
    });
    const CalledFactory = await hre.ethers.getContractFactory('Called');

    const caller = await CallerFactory.deploy();
    const called = await CalledFactory.deploy();

    const tx = await caller.makeCalls(called.address);
    const res = await tx.wait();

    const eventAbi = [
      'event callEvent(address sender, address origin, address from)',
    ];
    const iface = new hre.ethers.utils.Interface(eventAbi);

    const signer = await hre.ethers.getSigner();

    console.log(`EOA address: ${signer.address}`);
    console.log(`Caller contract address: ${caller.address}`);
    console.log(`Called contract address: ${called.address}`);

    res.events.map(event => console.log(iface.parseLog(event)));
  };

  main();

The script above will deploy both Caller and Called contracts and then it will call makeCalls function from Caller.

After waiting for the transaction to be confirmed, it logs the events that were fired and 3 addresses: the account that sent the transaction, and the contracts’. This will allow us to inspect the context of each call.

To run the script:

$> npx hardhat run scripts/makeCalls.js

which will print something like this (I trimmed the output and addresses to make it more readable):

EOA address: 0xf39F...2266
Caller contract address: 0xe7f1...512
Called contract address: 0x9fE4...6e0

LogDescription {
  ...,
  args: [
    ...,
    sender: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512',
    origin: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
    from: '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0'
  ]
}
LogDescription {
  ...,
  args: [
    '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
    '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
    '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512',
    sender: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
    origin: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
    from: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512'
  ]
}

Ok, let’s see what happened.

Both event logs. displayed above were fired from the same contract, Called. BUT, as you can see, the context is quite different.

The first one was fired after Caller contract executed Called’s callMe() function, by using call .

In this case:

  1. sender is the Caller contract.
  2. origin is the account who sent the transaction to execute Caller.makeCalls.
  3. from is the Called contract.

This is all what you might be expecting. The origin is always the EOA, msg.sender is the address from where a particular function is being called, and this (from, in the logs) is the contract you’re referring too.

Let’s see how the other event looks like, then:

  1. sender is the EOA!
  2. origin is also the EOA!
  3. from is the Caller contract, instead of Called (the contract which is actually emitting the event).

What happened?

What happened is that when you call another contract’s function using delegatecall it actually inherits the execution context of the contract who is performing the call, in this case Caller.

The context will behave as if you’ve copied and pasted the callMe() function into Caller’s contract.

Conclusion

  • call and delegatecall call are flexible but also dangerous ways of interacting with other contracts, we must use them with caution. They’re both “blind” calls into a function and they can expose your contract to security risks.
  • the main difference between them is that while call just executes the function in the context of the contract it was defined, delegatecall inherits the execution context, meaning that the function will behave as it was defined in the contract that’s using delegatecall.
Subscribe to frimoldi.eth
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.