After 5 years of reading and writing Solidity, I thought I knew how external calls worked.
After 5 years of reading and writing Solidity, I should have known better.
It all started when I faced an impossible piece of Solidity code in a new L2.
It just couldn't work. If all I knew about Solidity was right, then the contract JUST. COULDN'T. WORK. Plain and simple. All of it was so obviously broken. Yet somehow, it wasn't.
Tests were green. A dummy testnet had been running for weeks. The system had undergone many security reviews. Shouldn't such a broken code have been reported and fixed already? Even another more popular L2 was using similar code.
Everywhere I looked contradicted what I knew about Solidity external calls. Could I be so wrong?
My debugging skills failed me. There were so many moving pieces. If you've ever tried to debug a transaction hitting a predeploy calling a custom precompile that ABI-decodes stuff in a custom version of geth of a L2 that forked another L2's code, you feel me.
Disbelief evolved into hopelessness. The temptation of blind faith intensified. But I wouldn't give in! Luckily, because I was only hours away from sudden enlightenment, understanding and relief.
Some people find revealing truth in a religious book. Others skimming self-help books in airport lounges. I found it in line 2718 of a C++ file.
Check then call
The high-level Solidity syntax for an external call can look like this:
If you compile that, after the always useful warning for not having included a license identifier, you'll find this bytecode:
Unsurprisingly, the compiler translates the Solidity high-level call to the CALL
opcode. Oversimplifying you say? Ok, let's dig a bit deeper.
Those who've dealt with Solidity longer than one DeFi summer know that the compiler includes safety checks.
Before a CALL
, the compiler puts bytecode to validate that the call's target has code. It places an EXTCODESIZE
, including the necessary logic to reach a REVERT
before the CALL
in case the EXTCODESIZE
of the target is 0.
But even a developer that arrived late to the DeFi summer 2021 and has been coding Solidity ever since, just to be ready for the next bull market, knows that. They may have seen it in the bytecode, or, the more mentally sane, may have read it in Solidity's docs:
Due to the fact that the EVM considers a call to a non-existing contract to always succeed, Solidity includes an extra check using the extcodesize opcode when performing external calls. This ensures that the contract that is about to be called either actually exists (it contains code) or an exception is raised.
I was convinced of the above. So much that when I first saw something like this code, I found it hard to believe:
pragma solidity ^0.8.0;
interface IPrecompile {
function foo() external returns (uint256);
function bar() external;
}
contract Example {
// Function to execute a custom precompile
function doSomething() external {
// [...]
IPrecompile(customPrecompileAddress).foo();
}
}
Before diving into it, let's make sure we're on the same page.
On precompiles
Precompiles are EVM accounts that don't have bytecode stored, but can execute code. Their executable code is stored in the nodes' themselves. Usually you'll find them in the lowest range of possible addresses.
To execute a precompile, you call the address where it lives. For example, ecrecover
is one precompile of the EVM at address 0x00...01
.
Let's see its code:
cast code 0x0000000000000000000000000000000000000001
0x
Told you, it doesn't have EVM bytecode. Its actual code is in the node.
While Ethereum has its own precompiles, there's nothing preventing L2s from including new ones into their nodes. It can be a powerful way to supercharge the EVM's functionality.
Calling precompiles from Solidity
Precompiles don't have EVM bytecode. And I thought Solidity wouldn't allow high-level calls to an account with no bytecode. It'd revert before reaching the call.
So, to call a precompile, I'd use Solidity low-level calls. The ones that operate on addresses instead of contract instances. These kind of calls don't include the EXTCODESIZE
check, as the docs explain.
For example, to call a precompile at 0x04:
// Call precompile at address 0x04
(, bytes memory returndata) = address(4).call(somedata)
The standard EVM precompiles are rather straightforward, so it's simple to call them that way. You send some raw bytes of data, they perform some calculation, and return a raw set of bytes with the results.
Solc does have built-in functions to call some (but not all) precompiles, like ecrecover
. Just to spare you from writing low-level calls. But that's not relevant here.
Precompiles of L2s can be more complex than the "standard" ones in the EVM. They may include different functions within a single precompile. For instance, there could be a precompile that implemented the interface we saw earlier:
interface IPrecompile {
function foo() external returns (uint256);
function bar() external;
}
So, assuming the precompile can somehow handle it (we'll see an example later), you might call its foo
function with something along these lines:
(, bytes memory returndata) = address(customPrecompileAddress).call(abi.encodeWithSelector(IPrecompile.foo.selector));
uint256 result = abi.decode(returndata, (uint256));
But not with a high-level call like this:
uint256 result = IPrecompile(precompileAddress).foo();
That would fail. I'm telling you. The documentation I read says so, we saw the EXTCODESIZE
check earlier.
C'mon please, don't insist, it won't work.
Nah, just kidding. The high-level call works too. To understand why, let's first create a custom precompile, then do some tests, and finally inspect how solc really works under the hood.
Adding a new precompile
Let's start by creating a custom precompile in the core/vm/contracts.go
file of go-ethereum.
The precompile I'll create checks the input bytes against the function selectors for foo
and bar
. When the selector for foo
matches, it returns the number 43. When the selector for bar
matches, it returns nothing.
type myPrecompile struct{}
func (p *myPrecompile) RequiredGas(_ []byte) uint64 {
return 0
}
func (p *myPrecompile) Run(input []byte) ([]byte, error) {
if len(input) < 4 {
return nil, errors.New("short input")
}
if input[0] == 0xC2 && input[1] == 0x98 && input[2] == 0x55 && input[3] == 0x78 { // function selector of `foo()`
return common.LeftPadBytes([]byte{43}, 32), nil
} else if input[0] == 0xFE && input[1] == 0xBB && input[2] == 0x0F && input[3] == 0x7E { // function selector of `bar()
return nil, nil
} else {
return nil, errors.New("bad input")
}
}
The precompile will be at the 0x0b
address:
var PrecompiledContractsCancun = map[common.Address]PrecompiledContract{
// [...]
common.BytesToAddress([]byte{0x0b}): &myPrecompile{},
}
Then build go-ethereum (make geth
) and run it in dev mode (./build/bin/geth --dev --http
).
Validate the precompile is alive with cast:
All set! Let's move on to Solidity now.
Calling the custom precompile
Time to call the foo
function of the new precompile I created at address 0x0b
.
I'll use a high-level call. According to what I knew, this should NOT work. It should revert before triggering the call, because the EXTCODESIZE
check included by the compiler will return 0 for the 0x0b
address, and therefore reach a REVERT
in the bytecode.
Here's a simple Hardhat test to execute it:
describe("PrecompileCaller", function () {
let precompileCaller;
before(async function () {
const PrecompileCallerFactory = await ethers.getContractFactory("PrecompileCaller");
precompileCaller = await PrecompileCallerFactory.deploy();
});
it("Calls foo", async function () {
await precompileCaller.callFoo();
});
});
$ yarn hardhat test --network localhost
PrecompileCaller
✔ Calls foo
1 passing (224ms)
Wat? That shouldn't have worked 🤔
Let's see. If calling foo
works, then calling bar
should also work. I'll add some code in the contract to call the precompile's bar
function.
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
interface IPrecompile {
function foo() external returns (uint256);
function bar() external;
}
contract PrecompileCaller {
// Somehow this works
function callFoo() external {
uint256 result = IPrecompile(address(0x0b)).foo();
require(result == 43, "Unexpected result");
}
// If calling `foo` works, this should also work
function callBar() external {
IPrecompile(address(0x0b)).bar();
}
}
The extended Hardhat test now looks like:
const { expect } = require("chai");
describe("PrecompileCaller", function () {
let precompileCaller;
before(async function () {
const PrecompileCallerFactory = await ethers.getContractFactory("PrecompileCaller");
precompileCaller = await PrecompileCallerFactory.deploy();
});
it("Calls foo", async function () {
// This works (doesn't revert)
await precompileCaller.callFoo();
});
it("Calls bar", async function () {
// This should also work. Does it?
await precompileCaller.callBar();
});
});
$ yarn hardhat test --network localhost
PrecompileCaller
✔ Calls foo
1) Calls bar
1 passing (252ms)
1 failing
1) PrecompileCaller
Calls bar:
ProviderError: execution reverted
Crap.
I don't know how calls work
See? I told you. I didn't know how calls work. After all these years. Here's the Solidity code again:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
interface IPrecompile {
function foo() external returns (uint256);
function bar() external;
}
contract PrecompileCaller {
// Somehow this works
function callFoo() external {
uint256 result = IPrecompile(address(0x0b)).foo();
require(result == 43, "Unexpected result");
}
// Somehow this doesn't work
function callBar() external {
IPrecompile(address(0x0b)).bar();
}
}
We're in easy mode here. There's one clear difference between the two functions in this example. The real case was more difficult and I couldn't see it so clearly.
Yes, the difference is in the returns. Could a declared return value have something to do with all this?
No checks if return
This is how I learned that Solidity doesn't always include the EXTCODESIZE
check in high-level calls.
Let's analyse the Yul code produced for the functions callFoo
and callBar
of the PrecompileCaller
contract of the example.
For callFoo
:
function fun_callFoo_32() {
// ...
let _3 := call(gas(), expr_21_address, 0, _1, sub(_2, _1), _1, 32)
For callBar
:
function fun_callBar_45() {
// ...
if iszero(extcodesize(expr_41_address)) { revert_error_0cc013b6b3b6beabea4e3a74a6d380f0df81852ca99887912475e1f66b2a2c20() }
// ...
let _8 := call(gas(), expr_41_address, 0, _6, sub(_7, _6), _6, 0)
In callFoo
, the compiler didn't include an EXTCODESIZE
check before the call. Opposite to what it did in callBar
. Why would it do that?
The answer lies buried in line 2718 and 2719 of this C++ file. Lo and behold:
We do not need to check extcodesize if we expect return data, since if there is no code, the call will return empty data and the ABI decoder will revert.
What does this mean?
Remember the interface I used in Solidity:
interface IPrecompile {
function foo() external returns (uint256);
function bar() external;
}
Based on this definition, the compiler expects foo
to return something (a uint256
). Because of that, it won't place an EXTCODESIZE
check prior to calling it!
Solc assumes that if the target has no code, in practice there won't be return data anyway, and therefore attempting to ABI-decode no return data to the declared return type (the uint256
) will inevitably fail. So it might as well skip the code size check prior to the call.
Adding to my confusion, the compiler didn't always behave this way. Skipping the code size check for external calls when return data is expected was introduced in 0.8.10. That means +2 years ago. I guess I was late to find out?
Even after writing this article, I thought the documentation was incomplete and outdated. Well, it kind of isn't. My dear matta found that this special behavior is documented in another section, which I hadn't read 🤦
There's room to improve that documentation. So we proposed a small PR to make them clearer and more consistent.
I wish I could say that I now know how Solidity calls work. But there might be a new surprise waiting for me around the corner.
Want more stories? Subscribe to the blog!
It's cool. It's free. And we don't spam.Too lazy to do that.
i'm a subscriboooooor