Foundry incorrectly asseses gas costs when using proxy
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 = 7000in 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:
- figure out what the gas costs for the function is, so that I can call it with
estimatedGas - 1000. So I need theestimatedGas. I calculate it withconsole.log(vm.lastCallGas().gasTotalUsed);, but when I call the function with the gasLimit of the same amount ofgasTotalUsed, the call fails and asks me to send more than +1500 gas.
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
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.
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 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 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 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 ?
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 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.
- call
executenormally and getvm.lastCallGas().gasTotalUsed - 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 toexecuteas the amount:vm.lastCallGas().gasTotalUsed - 1000(I took 1000 just to ensure that it would fail withinsufficientFundscustom error).
I wonder isn't there any more straightforward way to test such thing ?
Marking as resolved by explanations above