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:
sender
is the Caller
contract.origin
is the account who sent the transaction to execute Caller.makeCalls
.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:
sender
is the EOA!origin
is also the EOA!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.
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.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
.