foundry icon indicating copy to clipboard operation
foundry copied to clipboard

Foundry incorrectly asseses gas costs when using proxy

Open novaknole opened this issue 1 year ago • 3 comments

Component

Forge

Have you ensured that all of these are up to date?

  • [X] Foundry
  • [X] Foundryup

What version of Foundry are you on?

forge 0.2.0 (f479e94 2024-06-01T00:19:17.421178000Z)

What command(s) is the bug in?

forge test

Operating System

macOS (Apple Silicon)

Describe the bug

Assume the following contract:

contract Nice is UUPSUpgradeable {
    function test(
        IDAO.Action[] calldata _actions,
        uint256 _allowFailureMap,
        bytes calldata _metadata
    ) public {
        console.log(gasleft());
    }
}

There're 2 scenarios and the problem/bug happens only when Nice is deployed with proxy.

Nice nice = new Nice();
Nice proxy = Nice(createProxyAndCall(address(nice), bytes("")));

Then, inside the tests, I do:

// This prints 373000
(bool success, ) = address(proxy).call{gas: 380000}(
    abi.encodeWithSelector(Nice.test.selector, new IDAO.Action[](0), 0, "dummy")
);
// This prints 412375
(bool success, ) = address(proxy).call{gas: 420000}(
    abi.encodeWithSelector(Nice.test.selector, new IDAO.Action[](0), 0, "dummy")
);

What this means is before getting to gasleft():

  • it spent 380000 - 373000 = 7000 in first case
  • in 2nd case, it spent 420000 - 412375=7625.

Why did it spend different amounts ? The only thing changed was gaslimit in the .call. Also, this difference only happens when the Nice is deployed through proxy. If it's deployed with new directly, we don't got the problem.

The reason I ended up with this issue is I want to achieve the following:

  1. figure out what the gas costs for the function is, so that I can call it with estimatedGas - 1000. So I need the estimatedGas. I calculate it with console.log(vm.lastCallGas().gasTotalUsed);, but when I call the function with the gasLimit of the same amount of gasTotalUsed, the call fails and asks me to send more than +1500 gas.

novaknole avatar Jun 28 '24 11:06 novaknole

If I understood your issue right, then couldn't this be just a proxy overhead?

when calling a Nice created directly, you are just executing its bytecode, and when it's called through proxy there's additional cost of reading implementation from storage, copying calldata into memory for delegatecall, and doing DELEGATECALL itself.

klkvr avatar Jun 30 '24 03:06 klkvr

@klkvr

ofc, I know that. The problem is different.

// This prints 373000
(bool success, ) = address(proxy).call{gas: 380000}(
    abi.encodeWithSelector(Nice.test.selector, new IDAO.Action[](0), 0, "dummy")
);
// This prints 412375
(bool success, ) = address(proxy).call{gas: 420000}(
    abi.encodeWithSelector(Nice.test.selector, new IDAO.Action[](0), 0, "dummy")
);

Passing different gasLimit is what causes it to print different values. Please,read the original question again.

novaknole avatar Jun 30 '24 03:06 novaknole

I see, then that's likely EIP-150

If a call asks for more gas than the maximum allowed amount (i.e. the total amount of gas remaining in the parent after subtracting the gas cost of the call and memory expansion), do not return an OOG error; instead, if a call asks for more gas than all but one 64th of the maximum allowed amount, call with all but one 64th of the maximum allowed amount of gas

625 is exactly 1/64th of a diferrence between your gas limit amounts (40000/64). And this reduction only takes place for a proxy because the condition "call asks for more gas than the maximum allowed amount" does not hold for a direct call from a test contract to Nice, but does hold for internal delegatecall from proxy to Nice

klkvr avatar Jun 30 '24 04:06 klkvr

@klkvr I truly don't get what you mean.

Remember, that I specify so big gas limits(380000 and 420000) where the function maximum needs 50000 to succeed). So your logic doesn't seem to work here. When I call Nice, sure that proxy gets called and all the gas is forwarded, but note that 64th rule only works if I don't specify the gas limit manually. Try this in remix and you will see. Also, I don't understand why the proxy makes any difference because my manual gas is forwarder to proxy which forwards all the gas to the Nice's logic contract.

novaknole avatar Jun 30 '24 14:06 novaknole

@novaknole when delegatecall tries to use entire gas limit of a proxy call, EVM wouldn't let you forward all left gas, and will instead only let you use 63/64th of it.

So, if by the time we reach delegatecall in proxy gasleft() is 378.920, implementation will receive 378920*63/64=373000, and if it's 40.000 higher (418.920), then it will receive 418920*63/64=412375, which is approximately what's happening in your case

klkvr avatar Jul 01 '24 14:07 klkvr

@klkvr thanks for so many follow-ups.

Would you know why on the debugger, the top most element at the time of delegatecall opcode shows the total gas amount instead of 63/64 of it ?

novaknole avatar Jul 02 '24 06:07 novaknole

This is because the gas reduction is performed after the opcode is being invoked. So if gas limit of 100.000 is passed to the opcode, the delegatecall frame will start with 98437 remaining

klkvr avatar Jul 02 '24 19:07 klkvr

@klkvr Thanks so much.

Would you happen to know how to test the following ? In one of my contracts(function execute), it expects multiple calldatas and uses call opcode to call each one of them. Due to my requirements, I need to track when the call fails out of gas error. See below(since solidity doesn't have OOG exception implemented).

Something like:

function execute(address[] tos, bytes[] memory datas) {
  for(uint i = 0; i <datas.length; i++) {
     uint256 gasBefore = gasleft();
     (bool success, ) = tos[i].call{value:0}(datas[i]);
     uint256 gasAfter = gasleft();
     if (gasAfter < gasBefore / 64) {
          revert insufficientGas();
      }
  }
}

The test I was thinking to write would consist of these steps.

  1. call execute normally and get vm.lastCallGas().gasTotalUsed
  2. Then, revert the state(go back to old snapshot before calling execute, so cold access would be in place) and now, pass the amount of gas manually to execute as the amount: vm.lastCallGas().gasTotalUsed - 1000 (I took 1000 just to ensure that it would fail with insufficientFunds custom error).

I wonder isn't there any more straightforward way to test such thing ?

novaknole avatar Jul 02 '24 19:07 novaknole

Marking as resolved by explanations above

zerosnacks avatar Jul 16 '24 15:07 zerosnacks