UPDATE: Mystery solved! I've updated this blog post with the explanation.
Hi there!
I've been doing some Ethernaut challenges and ended up having some trouble on #9: King.
The solution for the challenge itself is pretty straight forward, we just have to create a smart contract without receive and fallback functions so the King contract will fail to transfer ether back when trying to reclaim kingship.
However, this new smart contract won't be able to first claim kingship with the .call
return values are not handled. That's because the internal .transfer
transaction will run out of gas - and I have no idea why.
Here, let me simplify the contracts and provide some code and links.
📝 Contracts
In the following code snippet, we have 4 contracts: Sender
, SenderWithRequire
, SenderWithEmit
and Receiver
.
Here, Receiver
will play the King contract role.
// SPDX-License-Identifier: None
pragma solidity 0.6.0;
contract SenderWithRequire {
function send(address payable receiver) public payable {
(bool success,) = receiver.call.gas(10000000).value(msg.value)("");
require(success, "Failed to send value!");
}
}
contract SenderWithEmit {
event Debug(bool success);
function send(address payable receiver) public payable {
(bool success,) = receiver.call.gas(10000000).value(msg.value)("");
emit Debug(success);
}
}
contract Sender {
function send(address payable receiver) public payable {
receiver.call.gas(10000000).value(msg.value)("");
}
}
contract Receiver {
bool public hasReceived;
receive() external payable {
hasReceived = true;
payable(address(0)).transfer(msg.value);
}
}
🔁 Transactions
If we deploy and trigger send
functions for both SenderWithEmit
and SenderWithRequire
, the Receiver
will receive the value correctly and transfer it to the address 0 as expected.
SenderWithEmit
Etherscan:
https://rinkeby.etherscan.io/tx/0x99fea99167ed4353ab55486bb9ac55a1779e923bbe91ffbb1675b46a68b657ef
EthTx.Info:
https://ethtx.info/rinkeby/0x99fea99167ed4353ab55486bb9ac55a1779e923bbe91ffbb1675b46a68b657ef
SenderWithRequire
Etherscan:
https://rinkeby.etherscan.io/tx/0x28f185043e771486e1bdc10810004214793606e9ff2345279f1d7a9898992e62
EthTx.Info:
https://ethtx.info/rinkeby/0x28f185043e771486e1bdc10810004214793606e9ff2345279f1d7a9898992e62
But if we try to send ether using the Sender
contract, it will run out of gas in its internal transfer transaction
Sender (out of gas)
Etherscan:
https://rinkeby.etherscan.io/tx/0xc25f37922c439d4fc22cfd06f0c493a89af4517e1ec258658bc2990327a66976
EthTx.Info:
https://ethtx.info/rinkeby/0xc25f37922c439d4fc22cfd06f0c493a89af4517e1ec258658bc2990327a66976
Which is pretty odd, since it should spend less gas than the previous implementations.
💡 The explanation
Thanks to Ernesto Garcia and his contacts on OpenZeppelin, I was able to understand what was happening.
Metamask was not obtaining the Gas Limit value provided by Remix and therefore called eth_estimateGas to figure out how much its value should be used for the transaction.
It turns out that this estimate only takes into account the main transaction - the send
function itself - and not the underlying transaction transfer
.
In the Metamask screenshot below we can see that the provider's estimate was ⛽ 37,433 gas.
And changing it to ⛽ 374,330 gas, the transaction was able to complete with success.
Etherscan:
https://rinkeby.etherscan.io/tx/0x85b7e7cee51055d91b5f1d20f3d710388f65406b46077c868eedbef2435e1ce2
Take a look at the Gas Limit & Usage by Txn
fields below and notice how the failing transaction was consuming all the gas limit and the succeeding txn used only 16% of the ⛽ 374,330 gas limit.
What about the other Sender contracts with require
and emit
?
Checking the SenderWithRequire transaction again, it's possible to see that the gas estimate was set to ⛽ 45,150 gas, which was 94% of the gas the consumed in the transaction.
That's why they were always working; thanks to the extra code, their gas limit estimates were just enough to handle the internal transfer
call.
⚙️ The internal works
For further investigation, we can take a look at how the provider estimates gas limit.
If you send transactions without gas limits or gas prices via RPC API, geth uses Estimate() or SuggestPrice() instead. Remix uses these, too. These behaviors are of geth v1.8.23. Different versions may work differently.
EstimateGas
input: block number (default: "pending"), 'gas limit' of the transaction (default: gas limit of the…
According to the answer to the StackOverflow question above, geth uses a binary search to try finding a minimal gas to run the transaction on the given block number.
The implementation for this behavior can be validated in the following code from transaction_args.go
// Estimate the gas usage if necessary.
if args.Gas == nil {
// These fields are immutable during the estimation, safe to
// pass the pointer directly.
data := args.data()
callArgs := TransactionArgs{
From: args.From,
To: args.To,
GasPrice: args.GasPrice,
MaxFeePerGas: args.MaxFeePerGas,
MaxPriorityFeePerGas: args.MaxPriorityFeePerGas,
Value: args.Value,
Data: (*hexutil.Bytes)(&data),
AccessList: args.AccessList,
}
pendingBlockNr := rpc.BlockNumberOrHashWithNumber(rpc.PendingBlockNumber)
estimated, err := DoEstimateGas(ctx, b, callArgs, pendingBlockNr, b.RPCGasCap())
if err != nil {
return err
}
args.Gas = &estimated
log.Trace("Estimate gas usage automatically", "gas", args.Gas)
}
The DoEstimateGas
function is declared at api.go and contains the binary search logic mentioned before.
According to Ernesto, we can simplify the flow as the following:
- Metamask (or any provider) receive a transaction with no gasLimit
- If the provider is able to fill the gasLimit (like geth), it'll do it. Otherwise, it'll forward an eth_estimateGas call to a node and use the value returned
- Send the transaction with the gasLimit gotten
🏁 Conclusion
A somewhat simple Ethernaut challenge ended up uncovering a fascinating gas related behavior that helped me to understand how Ethereum transactions work.
I also had the opportunity to meet and discuss the topic with Ernesto, a friendly OpenZeppelin developer. Thanks buddy!