The reason why ethereum internal transactions might fail

Mark Kop - Apr 28 '22 - - Dev Community

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.

out of gas

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

🔁 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

SenderWithEmit

SenderWithRequire

Etherscan:
https://rinkeby.etherscan.io/tx/0x28f185043e771486e1bdc10810004214793606e9ff2345279f1d7a9898992e62
EthTx.Info:
https://ethtx.info/rinkeby/0x28f185043e771486e1bdc10810004214793606e9ff2345279f1d7a9898992e62

SenderWithRequire

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

Sender

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.

Metamask Gas Limit

And changing it to ⛽ 374,330 gas, the transaction was able to complete with success.

Etherscan:
https://rinkeby.etherscan.io/tx/0x85b7e7cee51055d91b5f1d20f3d710388f65406b46077c868eedbef2435e1ce2

etherscan print

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.

Gas Limit and Usage comparison

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.

SenderWithRequire transaction

⚙️ 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)
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Metamask (or any provider) receive a transaction with no gasLimit
  2. 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
  3. 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!

⛓️ References

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .