Skip to content

Conversation

ernestognw
Copy link
Member

@ernestognw ernestognw commented Jun 20, 2024

Alternative to #5091
Fixes #5013
Requires #5189

PR Checklist

  • Tests
  • Documentation
  • Changeset entry (run npx changeset add)

Copy link

changeset-bot bot commented Jun 20, 2024

🦋 Changeset detected

Latest commit: 6a74179

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
openzeppelin-solidity Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@ernestognw ernestognw marked this pull request as ready for review June 26, 2024 22:55
@ernestognw ernestognw requested review from Amxx and cairoeth June 27, 2024 23:41
Copy link
Collaborator

@Amxx Amxx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments:

  • I don't think all the memory library is necessary here (for example the loadByte function). Agreeing on the low level call part should be our priority, and IMO we should not include additional things that are independant.

  • We should thing of the "low level call" functions as an extension of the Address library, in terms of features AND naming. Ideally, in 6.0 I would move them all in the same file (IMO they are different ways, which different security guarantees of doing the same thing)

  • Part of what makes low level call efficient is when you don't use abi.encode to prepare the call. This library doesn't support that. IMO its fine for now, but we should consider adding that later.

Copy link
Collaborator

@Amxx Amxx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any case where the bubble is something other that the return data that we received from a call that was not successfull ?

I think that by definition of "bubble", it implies that its already an error that was reverted by an unsuccessfull call. Therefore, I'm would argue maybe the "low level call" function should include a boolean to select is yes/no we want to bubble any revert we get.

It could look like

    function callEmpty(address target, bytes memory data, uint256 value, bool bubble) internal returns (bool success) {
        assembly ("memory-safe") {
            success := call(gas(), target, value, add(data, 0x20), mload(data), 0x00, 0x00)
            if and(iszero(success), bubble) {
                let ptr := mload(0x40)
                returndatacopy(ptr, 0, returndatasize())
                revert(ptr, returndatasize())
            }
        }
    }

    function callScratch(address target, bytes memory data, uint256 value, bool bubble) internal returns (bool success, uint256 size, bytes32 x, bytes32 y) {
        assembly ("memory-safe") {
            success := call(gas(), target, value, add(data, 0x20), mload(data), 0x00, 0x40)
            if and(iszero(success), bubble) {
                let ptr := mload(0x40)
                returndatacopy(ptr, 0, returndatasize())
                revert(ptr, returndatasize())
            }
            size := returndatasize()
            x := mload(0x00)
            y := mload(0x20)
        }
    }

    function callFull(address target, bytes memory data, uint256 value, bool bubble) internal returns (bool success, bytes memory returndata) {
        assembly ("memory-safe") {
            success := call(gas(), target, value, add(data, 0x20), mload(data), 0x00, 0x00)

            returndata := mload(0x40)
            returndatacopy(add(returndata, 0x20), 0, returndatasize())
            if and(iszero(success), bubble) {
                revert(add(returndata, 0x20), returndatasize())
            }
            mstore(returndata, returndatasize())
            mstore(0x40, add(add(returndata, 0x20), returndatasize()))
        }
    }

Note: The RETURNDATASIZE opcode is only 2 gas, and DUPX are 3 gas. So it turns out caching the returndatasize in the stack and dupping it is counterproductive !

EDIT: We decided to NOT do that bool bubble because the deicision to bubble or not might depend on the returndatasize we get.

@Amxx Amxx requested a review from a team as a code owner July 9, 2025 20:12
@Amxx
Copy link
Collaborator

Amxx commented Jul 9, 2025

I updated the PR following our discussion of the day. I'm curious what the gas comparaison script tells us.

Note: the changes for SafeERC20 will be overwritten by the SafeERC20 PR.

Copy link
Collaborator

@Amxx Amxx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a lot of changes following yesterday's discussion. Only thing I havent done yet is polishing the contracts/utils/LowLevelCall.sol natspecs.

@ernestognw Let me know what you think !

@Amxx
Copy link
Collaborator

Amxx commented Jul 10, 2025

Gas number form CI

@Amxx
Copy link
Collaborator

Amxx commented Jul 15, 2025

Current remarquable numbers:

  • AccessManager.execute(address,bytes): -124 gas on average
  • AccountERC7579.validateUserOp: -88 gas on average (-1833 in the worst case)
  • Create2.deploy: -7500 gas on average
  • ERC20Multicall: -530 gas (for batches of 2 calls)

Comment on lines +83 to +92
bool success = LowLevelCall.callNoReturn(target, value, data);
if (success && (LowLevelCall.returnDataSize() > 0 || target.code.length > 0)) {
return LowLevelCall.returnData();
} else if (success) {
revert AddressEmptyCode(target);
} else if (LowLevelCall.returnDataSize() > 0) {
LowLevelCall.bubbleRevert();
} else {
revert Errors.FailedCall();
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This saves a few more gas compared to your numbers here.
Same pattern in functionCallWithValue(..) & functionStaticCall(..) saves gas.

Suggested change
bool success = LowLevelCall.callNoReturn(target, value, data);
if (success && (LowLevelCall.returnDataSize() > 0 || target.code.length > 0)) {
return LowLevelCall.returnData();
} else if (success) {
revert AddressEmptyCode(target);
} else if (LowLevelCall.returnDataSize() > 0) {
LowLevelCall.bubbleRevert();
} else {
revert Errors.FailedCall();
}
if (LowLevelCall.callNoReturn(target, value, data)) {
if (LowLevelCall.returnDataSize() > 0 || target.code.length > 0) {
return LowLevelCall.returnData();
}
revert AddressEmptyCode(target);
}
if (LowLevelCall.returnDataSize() > 0) {
LowLevelCall.bubbleRevert();
} else {
revert Errors.FailedCall();
}

Copy link
Collaborator

@Amxx Amxx Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally find it less readable. How must does it save ? I want cases do we save ?

Currently the "happy path" (the one that leads to return LowLevelCall.returnData();, and that we want to optimize), has a single if. Your proposal adds a second if to that path. I'm worried we might be optimising error costs at the expense of the happy path.

@@ -76,26 +80,50 @@ library Address {
if (address(this).balance < value) {
revert Errors.InsufficientBalance(address(this).balance, value);
}
(bool success, bytes memory returndata) = target.call{value: value}(data);
return verifyCallResultFromTarget(target, success, returndata);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think verifyCallResultFromTarget(..) is not used internally anymore (and not tested).
We can eventually in 5.x add unit tests for it (and deprecate usage until 6.0 removal if it is considered to be useless for developer, which am not sure about)

@@ -76,26 +80,50 @@ library Address {
if (address(this).balance < value) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about using functionCallWithValue(..) instead of verifyCallResult(..) in Governor & Timelock?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

functionCallWithValue will revert with AddressEmptyCode if you try to do a call to an EOA. If we use it in Governor/Timelock, then they would not be able to send ETH to an EOA. We have a test for that.

Amxx
Amxx previously approved these changes Aug 22, 2025
@Amxx Amxx requested review from arr00 and removed request for cairoeth August 22, 2025 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Consider simplified and gas-efficient alternatives to Address.sol to perform low level calls Low level call library
4 participants