From 6b6f66fbb56b62acbd1ae458a07fcdf3a6af4106 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 15 Jan 2026 23:57:28 -0600 Subject: [PATCH 01/18] Add reverting relayer to RelayedCall --- .changeset/flat-flies-hear.md | 5 + contracts/mocks/AccessManagedTarget.sol | 3 +- contracts/utils/RelayedCall.sol | 156 ++++++++++++++++- test/utils/RelayedCall.test.js | 213 +++++++++++++++++++++++- 4 files changed, 365 insertions(+), 12 deletions(-) create mode 100644 .changeset/flat-flies-hear.md diff --git a/.changeset/flat-flies-hear.md b/.changeset/flat-flies-hear.md new file mode 100644 index 00000000000..ba66ced2fbe --- /dev/null +++ b/.changeset/flat-flies-hear.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`RelayedCall`: Add reverting relayer functions (`getRevertingRelayer`, `relayRevertingCall`) that allow inspection of return data by reverting with the call result. diff --git a/contracts/mocks/AccessManagedTarget.sol b/contracts/mocks/AccessManagedTarget.sol index 673feedaac6..f6ef0663f28 100644 --- a/contracts/mocks/AccessManagedTarget.sol +++ b/contracts/mocks/AccessManagedTarget.sol @@ -14,8 +14,9 @@ abstract contract AccessManagedTarget is AccessManaged { emit CalledRestricted(msg.sender); } - function fnUnrestricted() public { + function fnUnrestricted() public returns (uint256) { emit CalledUnrestricted(msg.sender); + return 42; } function setIsConsumingScheduledOp(bool isConsuming, bytes32 slot) external { diff --git a/contracts/utils/RelayedCall.sol b/contracts/utils/RelayedCall.sol index e7e5ee02089..1ca293a3571 100644 --- a/contracts/utils/RelayedCall.sol +++ b/contracts/utils/RelayedCall.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.20; +import {LowLevelCall} from "./LowLevelCall.sol"; + /** * @dev Library for performing external calls through dynamically deployed relay contracts that hide the original * caller's address from the target contract. This pattern is used in ERC-4337's EntryPoint for account factory @@ -18,27 +20,35 @@ pragma solidity ^0.8.20; */ library RelayedCall { /// @dev Relays a call to the target contract through a dynamically deployed relay contract. - function relayCall(address target, bytes memory data) internal returns (bool, bytes memory) { + function relayCall(address target, bytes memory data) internal returns (bool success, bytes memory retData) { return relayCall(target, 0, data); } - /// @dev Same as {relayCall} but with a value. - function relayCall(address target, uint256 value, bytes memory data) internal returns (bool, bytes memory) { + /// @dev Same as {relayCall-address-bytes} but with a value. + function relayCall( + address target, + uint256 value, + bytes memory data + ) internal returns (bool success, bytes memory retData) { return relayCall(target, value, data, bytes32(0)); } - /// @dev Same as {relayCall} but with a salt. - function relayCall(address target, bytes memory data, bytes32 salt) internal returns (bool, bytes memory) { + /// @dev Same as {relayCall-address-bytes} but with a salt. + function relayCall( + address target, + bytes memory data, + bytes32 salt + ) internal returns (bool success, bytes memory retData) { return relayCall(target, 0, data, salt); } - /// @dev Same as {relayCall} but with a salt and a value. + /// @dev Same as {relayCall-address-bytes} but with a salt and a value. function relayCall( address target, uint256 value, bytes memory data, bytes32 salt - ) internal returns (bool, bytes memory) { + ) internal returns (bool success, bytes memory retData) { return getRelayer(salt).call{value: value}(abi.encodePacked(target, data)); } @@ -130,4 +140,136 @@ library RelayedCall { mstore(0x40, fmp) } } + + /// @dev Relays a call through a reverting relayer that reverts with the return data, allowing inspection + function relayRevertingCall( + address target, + bytes memory data + ) internal returns (bool success, bytes memory retData) { + return relayRevertingCall(target, 0, data, bytes32(0)); + } + + /// @dev Same as {relayRevertingCall-address-bytes} but with a salt for deterministic relayer address. + function relayRevertingCall( + address target, + bytes memory data, + bytes32 salt + ) internal returns (bool success, bytes memory retData) { + return relayRevertingCall(target, 0, data, salt); + } + + /// @dev Same as {relayRevertingCall-address-bytes} but with a value. + function relayRevertingCall( + address target, + uint256 value, + bytes memory data + ) internal returns (bool success, bytes memory retData) { + return relayRevertingCall(target, value, data, bytes32(0)); + } + + /// @dev Same as {relayRevertingCall-address-bytes} but with a salt and a value. + function relayRevertingCall( + address target, + uint256 value, + bytes memory data, + bytes32 salt + ) internal returns (bool success, bytes memory retData) { + LowLevelCall.callNoReturn(getRevertingRelayer(salt), value, abi.encodePacked(target, data)); + retData = LowLevelCall.returnData(); + uint256 retDataSize = LowLevelCall.returnDataSize(); + bytes1 result = retData[retDataSize - 1]; + assembly ("memory-safe") { + success := result + mstore(retData, sub(retDataSize, 1)) + } + return (success, retData); + } + + /// @dev Same as {getRelayer} but with a `bytes32(0)` default salt. + function getRevertingRelayer() internal returns (address) { + return getRevertingRelayer(bytes32(0)); + } + + /// @dev Returns the reverting relayer address for a given salt. + function getRevertingRelayer(bytes32 salt) internal returns (address relayer) { + // [Reverting Relayer details] + // + // deployment prefix: 60465f8160095f39f3 + // deployed bytecode: 73331460133611166022575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e3d533d6001015ffd + // + // offset | bytecode | opcode | stack + // -------|-------------|----------------|-------- + // 0x0000 | 73 | push20 | + // 0x0015 | 33 | address | + // 0x0016 | 14 | eq | access + // 0x0017 | 6013 | push1 0x13 | 0x13 access + // 0x0019 | 36 | calldatasize | cds 0x13 access + // 0x001a | 11 | gt | (cds>0x13) access + // 0x001b | 16 | and | (cds>0x13 && access) + // 0x001c | 6022 | push1 0x22 | 0x22 (cds>0x13 && access) + // 0x001e | 57 | jumpi | + // 0x001f | 5f | push0 | 0 + // 0x0020 | 5f | push0 | 0 0 + // 0x0021 | fd | revert | + // 0x0022 | 5b | jumpdest | + // 0x0023 | 6014 | push1 0x14 | 0x14 + // 0x0025 | 36 | calldatasize | cds 0x14 + // 0x0026 | 03 | sub | (cds-0x14) + // 0x0027 | 6014 | push1 0x14 | 0x14 (cds-0x14) + // 0x0029 | 5f | push0 | 0 0x14 (cds-0x14) + // 0x002a | 37 | calldatacopy | + // 0x002b | 5f | push0 | 0 + // 0x002c | 5f | push0 | 0 0 + // 0x002d | 6014 | push1 0x14 | 0x14 0 0 + // 0x002f | 36 | calldatasize | cds 0x14 0 0 + // 0x0030 | 03 | sub | (cds-0x14) 0 0 + // 0x0031 | 5f | push0 | 0 (cds-0x14) 0 0 + // 0x0032 | 34 | callvalue | value 0 (cds-0x14) 0 0 + // 0x0033 | 5f | push0 | 0 value 0 (cds-0x14) 0 0 + // 0x0034 | 35 | calldataload | cd[0] value 0 (cds-0x14) 0 0 + // 0x0035 | 6060 | push1 0x60 | 0x60 cd[0] value 0 (cds-0x14) 0 0 + // 0x0037 | 1c | shr | target value 0 (cds-0x14) 0 0 + // 0x0038 | 5a | gas | gas target value 0 (cds-0x14) 0 0 + // 0x0039 | f1 | call | suc + // 0x003a | 3d | returndatasize | rds suc + // 0x003b | 5f | push0 | 0 rds suc + // 0x003c | 5f | push0 | 0 0 rds suc + // 0x003d | 3e | returndatacopy | suc + // 0x003e | 3d | returndatasize | rds suc + // 0x003f | 53 | mstore8 | + // 0x0040 | 3d | returndatasize | rds + // 0x0041 | 6001 | push1 0x01 | 0x01 rds + // 0x0043 | 01 | add | (rds+0x01) + // 0x0044 | 5f | push0 | 0 (rds+0x01) + // 0x0045 | fd | revert | + + assembly ("memory-safe") { + let fmp := mload(0x40) + + // build initcode at FMP + mstore(add(fmp, 0x45), 0x0360145f375f5f601436035f345f3560601c5af13d5f5f3e3d533d6001015ffd) + mstore(add(fmp, 0x25), 0x331460133611166022575f5ffd5b601436) + mstore(add(fmp, 0x14), address()) + mstore(add(fmp, 0x00), 0x60465f8160095f39f373) + let initcodehash := keccak256(add(fmp, 0x16), 0x4f) + + // compute create2 address + mstore(0x40, initcodehash) + mstore(0x20, salt) + mstore(0x00, address()) + mstore8(0x0b, 0xff) + relayer := and(keccak256(0x0b, 0x55), shr(96, not(0))) + + // is relayer not yet deployed, deploy it + if iszero(extcodesize(relayer)) { + if iszero(create2(0, add(fmp, 0x16), 0x4f, salt)) { + returndatacopy(fmp, 0x00, returndatasize()) + revert(fmp, returndatasize()) + } + } + + // cleanup fmp space used as scratch + mstore(0x40, fmp) + } + } } diff --git a/test/utils/RelayedCall.test.js b/test/utils/RelayedCall.test.js index 39d16fcb1c1..cfd5b67323a 100644 --- a/test/utils/RelayedCall.test.js +++ b/test/utils/RelayedCall.test.js @@ -19,11 +19,23 @@ async function fixture() { ]), ), ); + const computeRevertingRelayerAddress = (salt = ethers.ZeroHash) => + ethers.getCreate2Address( + mock.target, + salt, + ethers.keccak256( + ethers.concat([ + '0x60465f8160095f39f373', + mock.target, + '0x331460133611166022575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e3d533d6001015ffd', + ]), + ), + ); const authority = await ethers.deployContract('$AccessManager', [admin]); const target = await ethers.deployContract('$AccessManagedTarget', [authority]); - return { mock, target, receiver, other, computeRelayerAddress }; + return { mock, target, receiver, other, computeRelayerAddress, computeRevertingRelayerAddress }; } describe('RelayedCall', function () { @@ -34,6 +46,7 @@ describe('RelayedCall', function () { describe('default (zero) salt', function () { beforeEach(async function () { this.relayer = await this.computeRelayerAddress(); + this.revertingRelayer = await this.computeRevertingRelayerAddress(); }); it('automatic relayer deployment', async function () { @@ -48,6 +61,22 @@ describe('RelayedCall', function () { await expect(this.mock.$getRelayer()).to.emit(this.mock, 'return$getRelayer').withArgs(this.relayer); }); + it('automatic reverting relayer deployment', async function () { + await expect(ethers.provider.getCode(this.revertingRelayer)).to.eventually.equal('0x'); + + // First call performs deployment + await expect(this.mock.$getRevertingRelayer()) + .to.emit(this.mock, 'return$getRevertingRelayer') + .withArgs(this.revertingRelayer); + + await expect(ethers.provider.getCode(this.revertingRelayer)).to.eventually.not.equal('0x'); + + // Following calls use the same reverting relayer + await expect(this.mock.$getRevertingRelayer()) + .to.emit(this.mock, 'return$getRevertingRelayer') + .withArgs(this.revertingRelayer); + }); + describe('relayed call', function () { it('target success', async function () { const tx = this.mock.$relayCall( @@ -58,7 +87,7 @@ describe('RelayedCall', function () { .to.emit(this.target, 'CalledUnrestricted') .withArgs(this.relayer) .to.emit(this.mock, 'return$relayCall_address_bytes') - .withArgs(true, '0x'); + .withArgs(true, BigInt(42)); }); it('target success (with value)', async function () { @@ -87,6 +116,47 @@ describe('RelayedCall', function () { }); }); + describe('relayed reverting call', function () { + it('target success', async function () { + const tx = this.mock.$relayRevertingCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('fnUnrestricted', [])), + ); + await expect(tx) + .to.emit(this.mock, 'return$relayRevertingCall_address_bytes') + .withArgs(true, BigInt(42)) + .to.not.emit(this.target, 'CalledUnrestricted'); + }); + + it('target success (with value)', async function () { + const value = 42n; + + const tx = this.mock.$relayRevertingCall( + ethers.Typed.address(this.receiver), + ethers.Typed.uint256(value), + ethers.Typed.bytes('0x'), + ethers.Typed.overrides({ value }), + ); + + await expect(tx).to.not.changeEtherBalances([this.mock, this.revertingRelayer, this.receiver], [0n, 0n, value]); + await expect(tx).to.emit(this.mock, 'return$relayRevertingCall_address_uint256_bytes').withArgs(true, '0x'); + }); + + it('target revert', async function () { + const tx = this.mock.$relayRevertingCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('fnRestricted', [])), + ); + + await expect(tx) + .to.emit(this.mock, 'return$relayRevertingCall_address_bytes') + .withArgs( + false, + this.target.interface.encodeErrorResult('AccessManagedUnauthorized', [this.revertingRelayer]), + ); + }); + }); + it('direct call to the relayer', async function () { // deploy relayer await this.mock.$getRelayer(); @@ -97,7 +167,17 @@ describe('RelayedCall', function () { ).to.be.revertedWithoutReason(); }); - it('input format', async function () { + it('direct call to the reverting relayer', async function () { + // deploy reverting relayer + await this.mock.$getRevertingRelayer(); + + // unauthorized caller + await expect( + this.other.sendTransaction({ to: this.revertingRelayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525' }), + ).to.be.revertedWithoutReason(); + }); + + it('relayer input format', async function () { // deploy relayer await this.mock.$getRelayer(); @@ -117,12 +197,39 @@ describe('RelayedCall', function () { // 0 bytes (not enough for an address) - REVERT await expect(mockAsWallet.sendTransaction({ to: this.relayer, data: '0x' })).to.be.revertedWithoutReason(); }); + + it('reverting relayer input format', async function () { + // deploy reverting relayer + await this.mock.$getRevertingRelayer(); + + // impersonate mock to pass caller checks + const mockAsWallet = await impersonate(this.mock.target); + + // 20 bytes (address + empty data) - OK + await expect( + mockAsWallet.sendTransaction({ + to: this.revertingRelayer, + data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525', + }), + ).to.be.reverted; + + // 19 bytes (not enough for an address) - REVERT + await expect( + mockAsWallet.sendTransaction({ to: this.revertingRelayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce5' }), + ).to.be.revertedWithoutReason(); + + // 0 bytes (not enough for an address) - REVERT + await expect( + mockAsWallet.sendTransaction({ to: this.revertingRelayer, data: '0x' }), + ).to.be.revertedWithoutReason(); + }); }); describe('random salt', function () { beforeEach(async function () { this.salt = ethers.hexlify(ethers.randomBytes(32)); this.relayer = await this.computeRelayerAddress(this.salt); + this.revertingRelayer = await this.computeRevertingRelayerAddress(this.salt); }); it('automatic relayer deployment', async function () { @@ -141,6 +248,22 @@ describe('RelayedCall', function () { .withArgs(this.relayer); }); + it('automatic reverting relayer deployment', async function () { + await expect(ethers.provider.getCode(this.revertingRelayer)).to.eventually.equal('0x'); + + // First call performs deployment + await expect(this.mock.$getRevertingRelayer(ethers.Typed.bytes32(this.salt))) + .to.emit(this.mock, 'return$getRevertingRelayer_bytes32') + .withArgs(this.revertingRelayer); + + await expect(ethers.provider.getCode(this.revertingRelayer)).to.eventually.not.equal('0x'); + + // Following calls use the same reverting relayer + await expect(this.mock.$getRevertingRelayer(ethers.Typed.bytes32(this.salt))) + .to.emit(this.mock, 'return$getRevertingRelayer_bytes32') + .withArgs(this.revertingRelayer); + }); + describe('relayed call', function () { it('target success', async function () { const tx = this.mock.$relayCall( @@ -152,7 +275,7 @@ describe('RelayedCall', function () { .to.emit(this.target, 'CalledUnrestricted') .withArgs(this.relayer) .to.emit(this.mock, 'return$relayCall_address_bytes_bytes32') - .withArgs(true, '0x'); + .withArgs(true, BigInt(42)); }); it('target success (with value)', async function () { @@ -183,6 +306,52 @@ describe('RelayedCall', function () { }); }); + describe('relayed reverting call', function () { + it('target success', async function () { + const tx = this.mock.$relayRevertingCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('fnUnrestricted', [])), + ethers.Typed.bytes32(this.salt), + ); + await expect(tx) + .to.emit(this.mock, 'return$relayRevertingCall_address_bytes_bytes32') + .withArgs(true, BigInt(42)) + .to.not.emit(this.target, 'CalledUnrestricted'); + }); + + it('target success (with value)', async function () { + const value = 42n; + + const tx = this.mock.$relayRevertingCall( + ethers.Typed.address(this.receiver), + ethers.Typed.uint256(value), + ethers.Typed.bytes('0x'), + ethers.Typed.bytes32(this.salt), + ethers.Typed.overrides({ value }), + ); + + await expect(tx).to.not.changeEtherBalances([this.mock, this.revertingRelayer, this.receiver], [0n, 0n, value]); + await expect(tx) + .to.emit(this.mock, 'return$relayRevertingCall_address_uint256_bytes_bytes32') + .withArgs(true, '0x'); + }); + + it('target revert', async function () { + const tx = this.mock.$relayRevertingCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('fnRestricted', [])), + ethers.Typed.bytes32(this.salt), + ); + + await expect(tx) + .to.emit(this.mock, 'return$relayRevertingCall_address_bytes_bytes32') + .withArgs( + false, + this.target.interface.encodeErrorResult('AccessManagedUnauthorized', [this.revertingRelayer]), + ); + }); + }); + it('direct call to the relayer', async function () { // deploy relayer await this.mock.$getRelayer(ethers.Typed.bytes32(this.salt)); @@ -193,6 +362,16 @@ describe('RelayedCall', function () { ).to.be.revertedWithoutReason(); }); + it('direct call to the reverting relayer', async function () { + // deploy reverting relayer + await this.mock.$getRevertingRelayer(ethers.Typed.bytes32(this.salt)); + + // unauthorized caller + await expect( + this.other.sendTransaction({ to: this.revertingRelayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525' }), + ).to.be.revertedWithoutReason(); + }); + it('input format', async function () { // deploy relayer await this.mock.$getRelayer(ethers.Typed.bytes32(this.salt)); @@ -213,5 +392,31 @@ describe('RelayedCall', function () { // 0 bytes (not enough for an address) - REVERT await expect(mockAsWallet.sendTransaction({ to: this.relayer, data: '0x' })).to.be.revertedWithoutReason(); }); + + it('reverting relayer input format', async function () { + // deploy reverting relayer + await this.mock.$getRevertingRelayer(ethers.Typed.bytes32(this.salt)); + + // impersonate mock to pass caller checks + const mockAsWallet = await impersonate(this.mock.target); + + // 20 bytes (address + empty data) - OK + await expect( + mockAsWallet.sendTransaction({ + to: this.revertingRelayer, + data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525', + }), + ).to.be.reverted; + + // 19 bytes (not enough for an address) - REVERT + await expect( + mockAsWallet.sendTransaction({ to: this.revertingRelayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce5' }), + ).to.be.revertedWithoutReason(); + + // 0 bytes (not enough for an address) - REVERT + await expect( + mockAsWallet.sendTransaction({ to: this.revertingRelayer, data: '0x' }), + ).to.be.revertedWithoutReason(); + }); }); }); From 7fb28f1e624e06ed74ff4944a8c96fda1e1a5202 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 21 Jan 2026 14:20:22 +0100 Subject: [PATCH 02/18] Apply suggestion from @Amxx --- contracts/utils/RelayedCall.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/utils/RelayedCall.sol b/contracts/utils/RelayedCall.sol index 1ca293a3571..bc304e8156f 100644 --- a/contracts/utils/RelayedCall.sol +++ b/contracts/utils/RelayedCall.sol @@ -21,7 +21,7 @@ import {LowLevelCall} from "./LowLevelCall.sol"; library RelayedCall { /// @dev Relays a call to the target contract through a dynamically deployed relay contract. function relayCall(address target, bytes memory data) internal returns (bool success, bytes memory retData) { - return relayCall(target, 0, data); + return relayCall(target, 0, data, bytes32(0)); } /// @dev Same as {relayCall-address-bytes} but with a value. From 621e25cdf26bf234579094211ce52268ddff4060 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 21 Jan 2026 14:21:50 +0100 Subject: [PATCH 03/18] reorder for consistency --- contracts/utils/RelayedCall.sol | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/contracts/utils/RelayedCall.sol b/contracts/utils/RelayedCall.sol index bc304e8156f..66ee503da34 100644 --- a/contracts/utils/RelayedCall.sol +++ b/contracts/utils/RelayedCall.sol @@ -149,22 +149,22 @@ library RelayedCall { return relayRevertingCall(target, 0, data, bytes32(0)); } - /// @dev Same as {relayRevertingCall-address-bytes} but with a salt for deterministic relayer address. + /// @dev Same as {relayRevertingCall-address-bytes} but with a value. function relayRevertingCall( address target, - bytes memory data, - bytes32 salt + uint256 value, + bytes memory data ) internal returns (bool success, bytes memory retData) { - return relayRevertingCall(target, 0, data, salt); + return relayRevertingCall(target, value, data, bytes32(0)); } - /// @dev Same as {relayRevertingCall-address-bytes} but with a value. + /// @dev Same as {relayRevertingCall-address-bytes} but with a salt for deterministic relayer address. function relayRevertingCall( address target, - uint256 value, - bytes memory data + bytes memory data, + bytes32 salt ) internal returns (bool success, bytes memory retData) { - return relayRevertingCall(target, value, data, bytes32(0)); + return relayRevertingCall(target, 0, data, salt); } /// @dev Same as {relayRevertingCall-address-bytes} but with a salt and a value. From f68e576c741faccd802082d6848a3a72e7080f44 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 21 Jan 2026 14:53:12 +0100 Subject: [PATCH 04/18] remove LowLevelCall dependency, handling the call nativelly --- contracts/utils/RelayedCall.sol | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/contracts/utils/RelayedCall.sol b/contracts/utils/RelayedCall.sol index 66ee503da34..09e895e2ee8 100644 --- a/contracts/utils/RelayedCall.sol +++ b/contracts/utils/RelayedCall.sol @@ -3,8 +3,6 @@ pragma solidity ^0.8.20; -import {LowLevelCall} from "./LowLevelCall.sol"; - /** * @dev Library for performing external calls through dynamically deployed relay contracts that hide the original * caller's address from the target contract. This pattern is used in ERC-4337's EntryPoint for account factory @@ -174,15 +172,19 @@ library RelayedCall { bytes memory data, bytes32 salt ) internal returns (bool success, bytes memory retData) { - LowLevelCall.callNoReturn(getRevertingRelayer(salt), value, abi.encodePacked(target, data)); - retData = LowLevelCall.returnData(); - uint256 retDataSize = LowLevelCall.returnDataSize(); - bytes1 result = retData[retDataSize - 1]; + // Calls to a reverting relayer always revert. No need to check the success flag. + (, retData) = getRevertingRelayer(salt).call{value: value}(abi.encodePacked(target, data)); + assembly ("memory-safe") { - success := result - mstore(retData, sub(retDataSize, 1)) + // length of the returned buffer (result/reason + success byte) + let length := mload(retData) + + // extract the success byte (last byte of the returned buffer) + success := and(mload(add(retData, length)), 0xff) + + // shrink retData to exclude the success byte + mstore(retData, sub(length, 1)) } - return (success, retData); } /// @dev Same as {getRelayer} but with a `bytes32(0)` default salt. From 9294a05d13aa56ecf58b6854f0d3645c8b957103 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 21 Jan 2026 15:08:13 +0100 Subject: [PATCH 05/18] update tests --- test/utils/RelayedCall.test.js | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/test/utils/RelayedCall.test.js b/test/utils/RelayedCall.test.js index cfd5b67323a..8c178ff9b8d 100644 --- a/test/utils/RelayedCall.test.js +++ b/test/utils/RelayedCall.test.js @@ -93,14 +93,17 @@ describe('RelayedCall', function () { it('target success (with value)', async function () { const value = 42n; + // fund the mock + await this.other.sendTransaction({ to: this.mock.target, value }); + + // perform relayed call const tx = this.mock.$relayCall( ethers.Typed.address(this.receiver), ethers.Typed.uint256(value), ethers.Typed.bytes('0x'), - ethers.Typed.overrides({ value }), ); - await expect(tx).to.changeEtherBalances([this.mock, this.relayer, this.receiver], [0n, 0n, value]); + await expect(tx).to.changeEtherBalances([this.mock, this.relayer, this.receiver], [-value, 0n, value]); await expect(tx).to.emit(this.mock, 'return$relayCall_address_uint256_bytes').withArgs(true, '0x'); }); @@ -131,14 +134,17 @@ describe('RelayedCall', function () { it('target success (with value)', async function () { const value = 42n; + // fund the mock + await this.other.sendTransaction({ to: this.mock.target, value }); + + // perform relayed call const tx = this.mock.$relayRevertingCall( ethers.Typed.address(this.receiver), ethers.Typed.uint256(value), ethers.Typed.bytes('0x'), - ethers.Typed.overrides({ value }), ); - await expect(tx).to.not.changeEtherBalances([this.mock, this.revertingRelayer, this.receiver], [0n, 0n, value]); + await expect(tx).to.changeEtherBalances([this.mock, this.revertingRelayer, this.receiver], [0n, 0n, 0n]); await expect(tx).to.emit(this.mock, 'return$relayRevertingCall_address_uint256_bytes').withArgs(true, '0x'); }); @@ -205,7 +211,7 @@ describe('RelayedCall', function () { // impersonate mock to pass caller checks const mockAsWallet = await impersonate(this.mock.target); - // 20 bytes (address + empty data) - OK + // 20 bytes (address + empty data) - valid input - REVERT await expect( mockAsWallet.sendTransaction({ to: this.revertingRelayer, @@ -281,15 +287,18 @@ describe('RelayedCall', function () { it('target success (with value)', async function () { const value = 42n; + // fund the mock + await this.other.sendTransaction({ to: this.mock.target, value }); + + // perform relayed call const tx = this.mock.$relayCall( ethers.Typed.address(this.receiver), ethers.Typed.uint256(value), ethers.Typed.bytes('0x'), ethers.Typed.bytes32(this.salt), - ethers.Typed.overrides({ value }), ); - await expect(tx).to.changeEtherBalances([this.mock, this.relayer, this.receiver], [0n, 0n, value]); + await expect(tx).to.changeEtherBalances([this.mock, this.relayer, this.receiver], [-value, 0n, value]); await expect(tx).to.emit(this.mock, 'return$relayCall_address_uint256_bytes_bytes32').withArgs(true, '0x'); }); @@ -322,15 +331,18 @@ describe('RelayedCall', function () { it('target success (with value)', async function () { const value = 42n; + // fund the mock + await this.other.sendTransaction({ to: this.mock.target, value }); + + // perform relayed call const tx = this.mock.$relayRevertingCall( ethers.Typed.address(this.receiver), ethers.Typed.uint256(value), ethers.Typed.bytes('0x'), ethers.Typed.bytes32(this.salt), - ethers.Typed.overrides({ value }), ); - await expect(tx).to.not.changeEtherBalances([this.mock, this.revertingRelayer, this.receiver], [0n, 0n, value]); + await expect(tx).to.changeEtherBalances([this.mock, this.revertingRelayer, this.receiver], [0n, 0n, 0n]); await expect(tx) .to.emit(this.mock, 'return$relayRevertingCall_address_uint256_bytes_bytes32') .withArgs(true, '0x'); @@ -400,7 +412,7 @@ describe('RelayedCall', function () { // impersonate mock to pass caller checks const mockAsWallet = await impersonate(this.mock.target); - // 20 bytes (address + empty data) - OK + // 20 bytes (address + empty data) - valid input - REVERT await expect( mockAsWallet.sendTransaction({ to: this.revertingRelayer, From 0025c3892eba2dcbf578310ac4d2aa253ba3c863 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 21 Jan 2026 21:38:01 -0600 Subject: [PATCH 06/18] Split into SimulateCall library --- .changeset/flat-flies-hear.md | 2 +- contracts/mocks/AccessManagedTarget.sol | 3 +- contracts/utils/RelayedCall.sol | 136 --------------- contracts/utils/SimulateCall.sol | 140 ++++++++++++++++ test/utils/RelayedCall.test.js | 213 +----------------------- test/utils/SimulatedCall.test.js | 156 +++++++++++++++++ 6 files changed, 299 insertions(+), 351 deletions(-) create mode 100644 contracts/utils/SimulateCall.sol create mode 100644 test/utils/SimulatedCall.test.js diff --git a/.changeset/flat-flies-hear.md b/.changeset/flat-flies-hear.md index ba66ced2fbe..5999ae28390 100644 --- a/.changeset/flat-flies-hear.md +++ b/.changeset/flat-flies-hear.md @@ -2,4 +2,4 @@ 'openzeppelin-solidity': minor --- -`RelayedCall`: Add reverting relayer functions (`getRevertingRelayer`, `relayRevertingCall`) that allow inspection of return data by reverting with the call result. +`SimulateCall`: Add new library with simulator functions (`getSimulator`, `simulateCall`) that allow inspection of return data by reverting with the call result without making state changes. diff --git a/contracts/mocks/AccessManagedTarget.sol b/contracts/mocks/AccessManagedTarget.sol index f6ef0663f28..673feedaac6 100644 --- a/contracts/mocks/AccessManagedTarget.sol +++ b/contracts/mocks/AccessManagedTarget.sol @@ -14,9 +14,8 @@ abstract contract AccessManagedTarget is AccessManaged { emit CalledRestricted(msg.sender); } - function fnUnrestricted() public returns (uint256) { + function fnUnrestricted() public { emit CalledUnrestricted(msg.sender); - return 42; } function setIsConsumingScheduledOp(bool isConsuming, bytes32 slot) external { diff --git a/contracts/utils/RelayedCall.sol b/contracts/utils/RelayedCall.sol index 09e895e2ee8..27534cb6309 100644 --- a/contracts/utils/RelayedCall.sol +++ b/contracts/utils/RelayedCall.sol @@ -138,140 +138,4 @@ library RelayedCall { mstore(0x40, fmp) } } - - /// @dev Relays a call through a reverting relayer that reverts with the return data, allowing inspection - function relayRevertingCall( - address target, - bytes memory data - ) internal returns (bool success, bytes memory retData) { - return relayRevertingCall(target, 0, data, bytes32(0)); - } - - /// @dev Same as {relayRevertingCall-address-bytes} but with a value. - function relayRevertingCall( - address target, - uint256 value, - bytes memory data - ) internal returns (bool success, bytes memory retData) { - return relayRevertingCall(target, value, data, bytes32(0)); - } - - /// @dev Same as {relayRevertingCall-address-bytes} but with a salt for deterministic relayer address. - function relayRevertingCall( - address target, - bytes memory data, - bytes32 salt - ) internal returns (bool success, bytes memory retData) { - return relayRevertingCall(target, 0, data, salt); - } - - /// @dev Same as {relayRevertingCall-address-bytes} but with a salt and a value. - function relayRevertingCall( - address target, - uint256 value, - bytes memory data, - bytes32 salt - ) internal returns (bool success, bytes memory retData) { - // Calls to a reverting relayer always revert. No need to check the success flag. - (, retData) = getRevertingRelayer(salt).call{value: value}(abi.encodePacked(target, data)); - - assembly ("memory-safe") { - // length of the returned buffer (result/reason + success byte) - let length := mload(retData) - - // extract the success byte (last byte of the returned buffer) - success := and(mload(add(retData, length)), 0xff) - - // shrink retData to exclude the success byte - mstore(retData, sub(length, 1)) - } - } - - /// @dev Same as {getRelayer} but with a `bytes32(0)` default salt. - function getRevertingRelayer() internal returns (address) { - return getRevertingRelayer(bytes32(0)); - } - - /// @dev Returns the reverting relayer address for a given salt. - function getRevertingRelayer(bytes32 salt) internal returns (address relayer) { - // [Reverting Relayer details] - // - // deployment prefix: 60465f8160095f39f3 - // deployed bytecode: 73331460133611166022575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e3d533d6001015ffd - // - // offset | bytecode | opcode | stack - // -------|-------------|----------------|-------- - // 0x0000 | 73 | push20 | - // 0x0015 | 33 | address | - // 0x0016 | 14 | eq | access - // 0x0017 | 6013 | push1 0x13 | 0x13 access - // 0x0019 | 36 | calldatasize | cds 0x13 access - // 0x001a | 11 | gt | (cds>0x13) access - // 0x001b | 16 | and | (cds>0x13 && access) - // 0x001c | 6022 | push1 0x22 | 0x22 (cds>0x13 && access) - // 0x001e | 57 | jumpi | - // 0x001f | 5f | push0 | 0 - // 0x0020 | 5f | push0 | 0 0 - // 0x0021 | fd | revert | - // 0x0022 | 5b | jumpdest | - // 0x0023 | 6014 | push1 0x14 | 0x14 - // 0x0025 | 36 | calldatasize | cds 0x14 - // 0x0026 | 03 | sub | (cds-0x14) - // 0x0027 | 6014 | push1 0x14 | 0x14 (cds-0x14) - // 0x0029 | 5f | push0 | 0 0x14 (cds-0x14) - // 0x002a | 37 | calldatacopy | - // 0x002b | 5f | push0 | 0 - // 0x002c | 5f | push0 | 0 0 - // 0x002d | 6014 | push1 0x14 | 0x14 0 0 - // 0x002f | 36 | calldatasize | cds 0x14 0 0 - // 0x0030 | 03 | sub | (cds-0x14) 0 0 - // 0x0031 | 5f | push0 | 0 (cds-0x14) 0 0 - // 0x0032 | 34 | callvalue | value 0 (cds-0x14) 0 0 - // 0x0033 | 5f | push0 | 0 value 0 (cds-0x14) 0 0 - // 0x0034 | 35 | calldataload | cd[0] value 0 (cds-0x14) 0 0 - // 0x0035 | 6060 | push1 0x60 | 0x60 cd[0] value 0 (cds-0x14) 0 0 - // 0x0037 | 1c | shr | target value 0 (cds-0x14) 0 0 - // 0x0038 | 5a | gas | gas target value 0 (cds-0x14) 0 0 - // 0x0039 | f1 | call | suc - // 0x003a | 3d | returndatasize | rds suc - // 0x003b | 5f | push0 | 0 rds suc - // 0x003c | 5f | push0 | 0 0 rds suc - // 0x003d | 3e | returndatacopy | suc - // 0x003e | 3d | returndatasize | rds suc - // 0x003f | 53 | mstore8 | - // 0x0040 | 3d | returndatasize | rds - // 0x0041 | 6001 | push1 0x01 | 0x01 rds - // 0x0043 | 01 | add | (rds+0x01) - // 0x0044 | 5f | push0 | 0 (rds+0x01) - // 0x0045 | fd | revert | - - assembly ("memory-safe") { - let fmp := mload(0x40) - - // build initcode at FMP - mstore(add(fmp, 0x45), 0x0360145f375f5f601436035f345f3560601c5af13d5f5f3e3d533d6001015ffd) - mstore(add(fmp, 0x25), 0x331460133611166022575f5ffd5b601436) - mstore(add(fmp, 0x14), address()) - mstore(add(fmp, 0x00), 0x60465f8160095f39f373) - let initcodehash := keccak256(add(fmp, 0x16), 0x4f) - - // compute create2 address - mstore(0x40, initcodehash) - mstore(0x20, salt) - mstore(0x00, address()) - mstore8(0x0b, 0xff) - relayer := and(keccak256(0x0b, 0x55), shr(96, not(0))) - - // is relayer not yet deployed, deploy it - if iszero(extcodesize(relayer)) { - if iszero(create2(0, add(fmp, 0x16), 0x4f, salt)) { - returndatacopy(fmp, 0x00, returndatasize()) - revert(fmp, returndatasize()) - } - } - - // cleanup fmp space used as scratch - mstore(0x40, fmp) - } - } } diff --git a/contracts/utils/SimulateCall.sol b/contracts/utils/SimulateCall.sol new file mode 100644 index 00000000000..f397afda81c --- /dev/null +++ b/contracts/utils/SimulateCall.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.5.0) (utils/SimulateCall.sol) + +pragma solidity ^0.8.20; + +/** + * @dev Library for simulating external calls through dynamically deployed simulator contracts that revert with + * the return data, allowing inspection of call results without state changes. + * + * This pattern is useful when you need to simulate the result of a call without actually executing it on-chain, + * or when you need to isolate the caller's address from the target contract. + */ +library SimulateCall { + /// @dev Simulates a call to the target contract through a dynamically deployed simulator. + function simulateCall(address target, bytes memory data) internal returns (bool success, bytes memory retData) { + return simulateCall(target, 0, data, bytes32(0)); + } + + /// @dev Same as {simulateCall-address-bytes} but with a value. + function simulateCall( + address target, + uint256 value, + bytes memory data + ) internal returns (bool success, bytes memory retData) { + return simulateCall(target, value, data, bytes32(0)); + } + + /// @dev Same as {simulateCall-address-bytes} but with a salt for deterministic simulator address. + function simulateCall( + address target, + bytes memory data, + bytes32 salt + ) internal returns (bool success, bytes memory retData) { + return simulateCall(target, 0, data, salt); + } + + /// @dev Same as {simulateCall-address-bytes} but with a salt and a value. + function simulateCall( + address target, + uint256 value, + bytes memory data, + bytes32 salt + ) internal returns (bool success, bytes memory retData) { + // Calls to a simulator always revert. No need to check the success flag. + (, retData) = getSimulator(salt).call{value: value}(abi.encodePacked(target, data)); + + assembly ("memory-safe") { + // length of the returned buffer (result/reason + success byte) + let length := mload(retData) + + // extract the success byte (last byte of the returned buffer) + success := and(mload(add(retData, length)), 0xff) + + // shrink retData to exclude the success byte + mstore(retData, sub(length, 1)) + } + } + + /// @dev Same as {getSimulator} but with a `bytes32(0)` default salt. + function getSimulator() internal returns (address) { + return getSimulator(bytes32(0)); + } + + /// @dev Returns the simulator address for a given salt. + function getSimulator(bytes32 salt) internal returns (address simulator) { + // [Simulator details] + // + // deployment prefix: 602e5f8160095f39f3 + // deployed bytecode: 60133611600a575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e3d533d6001015ffd + // + // offset | bytecode | opcode | stack + // -------|-------------|----------------|-------- + // 0x0000 | 6013 | push1 0x13 | 0x13 + // 0x0002 | 36 | calldatasize | cds 0x13 + // 0x0003 | 11 | gt | (cds>0x13) + // 0x0004 | 600a | push1 0x0a | 0x0a (cds>0x13) + // 0x0006 | 57 | jumpi | + // 0x0007 | 5f | push0 | 0 + // 0x0008 | 5f | push0 | 0 0 + // 0x0009 | fd | revert | + // 0x000a | 5b | jumpdest | + // 0x000b | 6014 | push1 0x14 | 0x14 + // 0x000d | 36 | calldatasize | cds 0x14 + // 0x000e | 03 | sub | (cds-0x14) + // 0x000f | 6014 | push1 0x14 | 0x14 (cds-0x14) + // 0x0011 | 5f | push0 | 0 0x14 (cds-0x14) + // 0x0012 | 37 | calldatacopy | + // 0x0013 | 5f | push0 | 0 + // 0x0014 | 5f | push0 | 0 0 + // 0x0015 | 6014 | push1 0x14 | 0x14 0 0 + // 0x0017 | 36 | calldatasize | cds 0x14 0 0 + // 0x0018 | 03 | sub | (cds-0x14) 0 0 + // 0x0019 | 5f | push0 | 0 (cds-0x14) 0 0 + // 0x001a | 34 | callvalue | value 0 (cds-0x14) 0 0 + // 0x001b | 5f | push0 | 0 value 0 (cds-0x14) 0 0 + // 0x001c | 35 | calldataload | cd[0] value 0 (cds-0x14) 0 0 + // 0x001d | 6060 | push1 0x60 | 0x60 cd[0] value 0 (cds-0x14) 0 0 + // 0x001f | 1c | shr | target value 0 (cds-0x14) 0 0 + // 0x0020 | 5a | gas | gas target value 0 (cds-0x14) 0 0 + // 0x0021 | f1 | call | suc + // 0x0022 | 3d | returndatasize | rds suc + // 0x0023 | 5f | push0 | 0 rds suc + // 0x0024 | 5f | push0 | 0 0 rds suc + // 0x0025 | 3e | returndatacopy | suc + // 0x0026 | 3d | returndatasize | rds suc + // 0x0027 | 53 | mstore8 | + // 0x0028 | 3d | returndatasize | rds + // 0x0029 | 6001 | push1 0x01 | 0x01 rds + // 0x002b | 01 | add | (rds+0x01) + // 0x002c | 5f | push0 | 0 (rds+0x01) + // 0x002d | fd | revert | + + assembly ("memory-safe") { + // build initcode at scratch space + mstore(0x20, 0x0360145f375f5f601436035f345f3560601c5af13d5f5f3e3d533d6001015ffd) + mstore(0x00, 0x602e5f8160095f39f360133611600a575f5ffd5b601436) + let initcodehash := keccak256(0x09, 0x37) + + let fmp := mload(0x40) // cache free memory pointer + + // compute create2 address + mstore(add(fmp, 0x40), initcodehash) + mstore(add(fmp, 0x20), salt) + mstore(add(fmp, 0x00), address()) + mstore8(add(fmp, 0x0b), 0xff) + simulator := and(keccak256(add(fmp, 0x0b), 0x55), shr(96, not(0))) + + // if simulator not yet deployed, deploy it + if iszero(extcodesize(simulator)) { + if iszero(create2(0, 0x09, 0x37, salt)) { + returndatacopy(fmp, 0x00, returndatasize()) + revert(fmp, returndatasize()) + } + } + + // cleanup fmp space used as scratch + mstore(0x40, fmp) + } + } +} diff --git a/test/utils/RelayedCall.test.js b/test/utils/RelayedCall.test.js index 8c178ff9b8d..922027a6762 100644 --- a/test/utils/RelayedCall.test.js +++ b/test/utils/RelayedCall.test.js @@ -19,23 +19,11 @@ async function fixture() { ]), ), ); - const computeRevertingRelayerAddress = (salt = ethers.ZeroHash) => - ethers.getCreate2Address( - mock.target, - salt, - ethers.keccak256( - ethers.concat([ - '0x60465f8160095f39f373', - mock.target, - '0x331460133611166022575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e3d533d6001015ffd', - ]), - ), - ); const authority = await ethers.deployContract('$AccessManager', [admin]); const target = await ethers.deployContract('$AccessManagedTarget', [authority]); - return { mock, target, receiver, other, computeRelayerAddress, computeRevertingRelayerAddress }; + return { mock, target, receiver, other, computeRelayerAddress }; } describe('RelayedCall', function () { @@ -46,7 +34,6 @@ describe('RelayedCall', function () { describe('default (zero) salt', function () { beforeEach(async function () { this.relayer = await this.computeRelayerAddress(); - this.revertingRelayer = await this.computeRevertingRelayerAddress(); }); it('automatic relayer deployment', async function () { @@ -61,22 +48,6 @@ describe('RelayedCall', function () { await expect(this.mock.$getRelayer()).to.emit(this.mock, 'return$getRelayer').withArgs(this.relayer); }); - it('automatic reverting relayer deployment', async function () { - await expect(ethers.provider.getCode(this.revertingRelayer)).to.eventually.equal('0x'); - - // First call performs deployment - await expect(this.mock.$getRevertingRelayer()) - .to.emit(this.mock, 'return$getRevertingRelayer') - .withArgs(this.revertingRelayer); - - await expect(ethers.provider.getCode(this.revertingRelayer)).to.eventually.not.equal('0x'); - - // Following calls use the same reverting relayer - await expect(this.mock.$getRevertingRelayer()) - .to.emit(this.mock, 'return$getRevertingRelayer') - .withArgs(this.revertingRelayer); - }); - describe('relayed call', function () { it('target success', async function () { const tx = this.mock.$relayCall( @@ -119,50 +90,6 @@ describe('RelayedCall', function () { }); }); - describe('relayed reverting call', function () { - it('target success', async function () { - const tx = this.mock.$relayRevertingCall( - ethers.Typed.address(this.target), - ethers.Typed.bytes(this.target.interface.encodeFunctionData('fnUnrestricted', [])), - ); - await expect(tx) - .to.emit(this.mock, 'return$relayRevertingCall_address_bytes') - .withArgs(true, BigInt(42)) - .to.not.emit(this.target, 'CalledUnrestricted'); - }); - - it('target success (with value)', async function () { - const value = 42n; - - // fund the mock - await this.other.sendTransaction({ to: this.mock.target, value }); - - // perform relayed call - const tx = this.mock.$relayRevertingCall( - ethers.Typed.address(this.receiver), - ethers.Typed.uint256(value), - ethers.Typed.bytes('0x'), - ); - - await expect(tx).to.changeEtherBalances([this.mock, this.revertingRelayer, this.receiver], [0n, 0n, 0n]); - await expect(tx).to.emit(this.mock, 'return$relayRevertingCall_address_uint256_bytes').withArgs(true, '0x'); - }); - - it('target revert', async function () { - const tx = this.mock.$relayRevertingCall( - ethers.Typed.address(this.target), - ethers.Typed.bytes(this.target.interface.encodeFunctionData('fnRestricted', [])), - ); - - await expect(tx) - .to.emit(this.mock, 'return$relayRevertingCall_address_bytes') - .withArgs( - false, - this.target.interface.encodeErrorResult('AccessManagedUnauthorized', [this.revertingRelayer]), - ); - }); - }); - it('direct call to the relayer', async function () { // deploy relayer await this.mock.$getRelayer(); @@ -173,16 +100,6 @@ describe('RelayedCall', function () { ).to.be.revertedWithoutReason(); }); - it('direct call to the reverting relayer', async function () { - // deploy reverting relayer - await this.mock.$getRevertingRelayer(); - - // unauthorized caller - await expect( - this.other.sendTransaction({ to: this.revertingRelayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525' }), - ).to.be.revertedWithoutReason(); - }); - it('relayer input format', async function () { // deploy relayer await this.mock.$getRelayer(); @@ -203,39 +120,12 @@ describe('RelayedCall', function () { // 0 bytes (not enough for an address) - REVERT await expect(mockAsWallet.sendTransaction({ to: this.relayer, data: '0x' })).to.be.revertedWithoutReason(); }); - - it('reverting relayer input format', async function () { - // deploy reverting relayer - await this.mock.$getRevertingRelayer(); - - // impersonate mock to pass caller checks - const mockAsWallet = await impersonate(this.mock.target); - - // 20 bytes (address + empty data) - valid input - REVERT - await expect( - mockAsWallet.sendTransaction({ - to: this.revertingRelayer, - data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525', - }), - ).to.be.reverted; - - // 19 bytes (not enough for an address) - REVERT - await expect( - mockAsWallet.sendTransaction({ to: this.revertingRelayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce5' }), - ).to.be.revertedWithoutReason(); - - // 0 bytes (not enough for an address) - REVERT - await expect( - mockAsWallet.sendTransaction({ to: this.revertingRelayer, data: '0x' }), - ).to.be.revertedWithoutReason(); - }); }); describe('random salt', function () { beforeEach(async function () { this.salt = ethers.hexlify(ethers.randomBytes(32)); this.relayer = await this.computeRelayerAddress(this.salt); - this.revertingRelayer = await this.computeRevertingRelayerAddress(this.salt); }); it('automatic relayer deployment', async function () { @@ -254,22 +144,6 @@ describe('RelayedCall', function () { .withArgs(this.relayer); }); - it('automatic reverting relayer deployment', async function () { - await expect(ethers.provider.getCode(this.revertingRelayer)).to.eventually.equal('0x'); - - // First call performs deployment - await expect(this.mock.$getRevertingRelayer(ethers.Typed.bytes32(this.salt))) - .to.emit(this.mock, 'return$getRevertingRelayer_bytes32') - .withArgs(this.revertingRelayer); - - await expect(ethers.provider.getCode(this.revertingRelayer)).to.eventually.not.equal('0x'); - - // Following calls use the same reverting relayer - await expect(this.mock.$getRevertingRelayer(ethers.Typed.bytes32(this.salt))) - .to.emit(this.mock, 'return$getRevertingRelayer_bytes32') - .withArgs(this.revertingRelayer); - }); - describe('relayed call', function () { it('target success', async function () { const tx = this.mock.$relayCall( @@ -315,55 +189,6 @@ describe('RelayedCall', function () { }); }); - describe('relayed reverting call', function () { - it('target success', async function () { - const tx = this.mock.$relayRevertingCall( - ethers.Typed.address(this.target), - ethers.Typed.bytes(this.target.interface.encodeFunctionData('fnUnrestricted', [])), - ethers.Typed.bytes32(this.salt), - ); - await expect(tx) - .to.emit(this.mock, 'return$relayRevertingCall_address_bytes_bytes32') - .withArgs(true, BigInt(42)) - .to.not.emit(this.target, 'CalledUnrestricted'); - }); - - it('target success (with value)', async function () { - const value = 42n; - - // fund the mock - await this.other.sendTransaction({ to: this.mock.target, value }); - - // perform relayed call - const tx = this.mock.$relayRevertingCall( - ethers.Typed.address(this.receiver), - ethers.Typed.uint256(value), - ethers.Typed.bytes('0x'), - ethers.Typed.bytes32(this.salt), - ); - - await expect(tx).to.changeEtherBalances([this.mock, this.revertingRelayer, this.receiver], [0n, 0n, 0n]); - await expect(tx) - .to.emit(this.mock, 'return$relayRevertingCall_address_uint256_bytes_bytes32') - .withArgs(true, '0x'); - }); - - it('target revert', async function () { - const tx = this.mock.$relayRevertingCall( - ethers.Typed.address(this.target), - ethers.Typed.bytes(this.target.interface.encodeFunctionData('fnRestricted', [])), - ethers.Typed.bytes32(this.salt), - ); - - await expect(tx) - .to.emit(this.mock, 'return$relayRevertingCall_address_bytes_bytes32') - .withArgs( - false, - this.target.interface.encodeErrorResult('AccessManagedUnauthorized', [this.revertingRelayer]), - ); - }); - }); - it('direct call to the relayer', async function () { // deploy relayer await this.mock.$getRelayer(ethers.Typed.bytes32(this.salt)); @@ -374,16 +199,6 @@ describe('RelayedCall', function () { ).to.be.revertedWithoutReason(); }); - it('direct call to the reverting relayer', async function () { - // deploy reverting relayer - await this.mock.$getRevertingRelayer(ethers.Typed.bytes32(this.salt)); - - // unauthorized caller - await expect( - this.other.sendTransaction({ to: this.revertingRelayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525' }), - ).to.be.revertedWithoutReason(); - }); - it('input format', async function () { // deploy relayer await this.mock.$getRelayer(ethers.Typed.bytes32(this.salt)); @@ -404,31 +219,5 @@ describe('RelayedCall', function () { // 0 bytes (not enough for an address) - REVERT await expect(mockAsWallet.sendTransaction({ to: this.relayer, data: '0x' })).to.be.revertedWithoutReason(); }); - - it('reverting relayer input format', async function () { - // deploy reverting relayer - await this.mock.$getRevertingRelayer(ethers.Typed.bytes32(this.salt)); - - // impersonate mock to pass caller checks - const mockAsWallet = await impersonate(this.mock.target); - - // 20 bytes (address + empty data) - valid input - REVERT - await expect( - mockAsWallet.sendTransaction({ - to: this.revertingRelayer, - data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525', - }), - ).to.be.reverted; - - // 19 bytes (not enough for an address) - REVERT - await expect( - mockAsWallet.sendTransaction({ to: this.revertingRelayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce5' }), - ).to.be.revertedWithoutReason(); - - // 0 bytes (not enough for an address) - REVERT - await expect( - mockAsWallet.sendTransaction({ to: this.revertingRelayer, data: '0x' }), - ).to.be.revertedWithoutReason(); - }); }); }); diff --git a/test/utils/SimulatedCall.test.js b/test/utils/SimulatedCall.test.js new file mode 100644 index 00000000000..67840560c9d --- /dev/null +++ b/test/utils/SimulatedCall.test.js @@ -0,0 +1,156 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +async function fixture() { + const [receiver, other] = await ethers.getSigners(); + + const mock = await ethers.deployContract('$SimulateCall'); + const computeSimulatorAddress = (salt = ethers.ZeroHash) => + ethers.getCreate2Address( + mock.target, + salt, + ethers.keccak256( + ethers.concat([ + '0x602e5f8160095f39f3', + '0x60133611600a575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e3d533d6001015ffd', + ]), + ), + ); + + const target = await ethers.deployContract('$CallReceiverMock'); + + return { mock, target, receiver, other, computeSimulatorAddress }; +} + +describe('SimulateCall', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('default (zero) salt', function () { + beforeEach(async function () { + this.simulator = await this.computeSimulatorAddress(); + }); + + it('automatic simulator deployment', async function () { + await expect(ethers.provider.getCode(this.simulator)).to.eventually.equal('0x'); + + // First call performs deployment + await expect(this.mock.$getSimulator()).to.emit(this.mock, 'return$getSimulator').withArgs(this.simulator); + + await expect(ethers.provider.getCode(this.simulator)).to.eventually.not.equal('0x'); + + // Following calls use the same simulator + await expect(this.mock.$getSimulator()).to.emit(this.mock, 'return$getSimulator').withArgs(this.simulator); + }); + + describe('simulated call', function () { + it('target success', async function () { + const tx = this.mock.$simulateCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionWithArgsReturn', [10, 20])), + ); + await expect(tx) + .to.emit(this.mock, 'return$simulateCall_address_bytes') + .withArgs(true, ethers.AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256'], [10, 20])) + .to.not.emit(this.target, 'MockFunctionCalledWithArgs'); + }); + + it('target success (with value)', async function () { + const value = 42n; + + // fund the mock + await this.other.sendTransaction({ to: this.mock.target, value }); + + // perform simulated call + const tx = this.mock.$simulateCall( + ethers.Typed.address(this.receiver), + ethers.Typed.uint256(value), + ethers.Typed.bytes('0x'), + ); + + await expect(tx).to.changeEtherBalances([this.mock, this.simulator, this.receiver], [0n, 0n, 0n]); + await expect(tx).to.emit(this.mock, 'return$simulateCall_address_uint256_bytes').withArgs(true, '0x'); + }); + + it('target revert', async function () { + const tx = this.mock.$simulateCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionRevertsReason', [])), + ); + + await expect(tx) + .to.emit(this.mock, 'return$simulateCall_address_bytes') + .withArgs(false, this.target.interface.encodeErrorResult('Error', ['CallReceiverMock: reverting'])); + }); + }); + }); + + describe('random salt', function () { + beforeEach(async function () { + this.salt = ethers.hexlify(ethers.randomBytes(32)); + this.simulator = await this.computeSimulatorAddress(this.salt); + }); + + it('automatic simulator deployment', async function () { + await expect(ethers.provider.getCode(this.simulator)).to.eventually.equal('0x'); + + // First call performs deployment + await expect(this.mock.$getSimulator(ethers.Typed.bytes32(this.salt))) + .to.emit(this.mock, 'return$getSimulator_bytes32') + .withArgs(this.simulator); + + await expect(ethers.provider.getCode(this.simulator)).to.eventually.not.equal('0x'); + + // Following calls use the same simulator + await expect(this.mock.$getSimulator(ethers.Typed.bytes32(this.salt))) + .to.emit(this.mock, 'return$getSimulator_bytes32') + .withArgs(this.simulator); + }); + + describe('simulated call', function () { + it('target success', async function () { + const tx = this.mock.$simulateCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionWithArgsReturn', [10, 20])), + ethers.Typed.bytes32(this.salt), + ); + await expect(tx) + .to.emit(this.mock, 'return$simulateCall_address_bytes_bytes32') + .withArgs(true, ethers.AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256'], [10, 20])) + .to.not.emit(this.target, 'MockFunctionCalledWithArgs'); + }); + + it('target success (with value)', async function () { + const value = 42n; + + // fund the mock + await this.other.sendTransaction({ to: this.mock.target, value }); + + // perform simulated call + const tx = this.mock.$simulateCall( + ethers.Typed.address(this.receiver), + ethers.Typed.uint256(value), + ethers.Typed.bytes('0x'), + ethers.Typed.bytes32(this.salt), + ); + + await expect(tx).to.changeEtherBalances([this.mock, this.simulator, this.receiver], [0n, 0n, 0n]); + await expect(tx).to.emit(this.mock, 'return$simulateCall_address_uint256_bytes_bytes32').withArgs(true, '0x'); + }); + + it('target revert', async function () { + const tx = this.mock.$simulateCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionRevertsReason', [])), + ethers.Typed.bytes32(this.salt), + ); + + await expect(tx) + .to.emit(this.mock, 'return$simulateCall_address_bytes_bytes32') + .withArgs(false, this.target.interface.encodeErrorResult('Error', ['CallReceiverMock: reverting'])); + }); + }); + }); +}); From 600add41391ff8e9eb8cad9bb53dc39e335687e6 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 21 Jan 2026 21:39:30 -0600 Subject: [PATCH 07/18] up --- test/utils/RelayedCall.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/utils/RelayedCall.test.js b/test/utils/RelayedCall.test.js index 922027a6762..685d090e913 100644 --- a/test/utils/RelayedCall.test.js +++ b/test/utils/RelayedCall.test.js @@ -58,7 +58,7 @@ describe('RelayedCall', function () { .to.emit(this.target, 'CalledUnrestricted') .withArgs(this.relayer) .to.emit(this.mock, 'return$relayCall_address_bytes') - .withArgs(true, BigInt(42)); + .withArgs(true, '0x'); }); it('target success (with value)', async function () { @@ -155,7 +155,7 @@ describe('RelayedCall', function () { .to.emit(this.target, 'CalledUnrestricted') .withArgs(this.relayer) .to.emit(this.mock, 'return$relayCall_address_bytes_bytes32') - .withArgs(true, BigInt(42)); + .withArgs(true, '0x'); }); it('target success (with value)', async function () { From 1ea0072f770ec15c3c9b7a8774c06cbfaf6dab23 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Wed, 21 Jan 2026 21:40:04 -0600 Subject: [PATCH 08/18] up --- contracts/utils/SimulateCall.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/utils/SimulateCall.sol b/contracts/utils/SimulateCall.sol index f397afda81c..4d4ce17921d 100644 --- a/contracts/utils/SimulateCall.sol +++ b/contracts/utils/SimulateCall.sol @@ -1,5 +1,4 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.5.0) (utils/SimulateCall.sol) pragma solidity ^0.8.20; From eee8a28fa8ef3a98d6c12e0204358668f4afb7d6 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 22 Jan 2026 09:36:49 +0100 Subject: [PATCH 09/18] Refactor SimulateCall to delegatecall into a single relayer (no more salt) --- contracts/mocks/CallReceiverMock.sol | 3 +- contracts/utils/SimulateCall.sol | 129 +++++++------------ test/utils/SimulatedCall.test.js | 178 +++++++++------------------ 3 files changed, 104 insertions(+), 206 deletions(-) diff --git a/contracts/mocks/CallReceiverMock.sol b/contracts/mocks/CallReceiverMock.sol index 8d699ef5f54..8ee23cfad84 100644 --- a/contracts/mocks/CallReceiverMock.sol +++ b/contracts/mocks/CallReceiverMock.sol @@ -88,8 +88,9 @@ contract CallReceiverMock { } } - function mockFunctionExtra() public payable { + function mockFunctionExtra() public payable returns (address, uint256) { emit MockFunctionCalledExtra(msg.sender, msg.value); + return (msg.sender, msg.value); } } diff --git a/contracts/utils/SimulateCall.sol b/contracts/utils/SimulateCall.sol index 4d4ce17921d..91ebe50cba5 100644 --- a/contracts/utils/SimulateCall.sol +++ b/contracts/utils/SimulateCall.sol @@ -12,7 +12,7 @@ pragma solidity ^0.8.20; library SimulateCall { /// @dev Simulates a call to the target contract through a dynamically deployed simulator. function simulateCall(address target, bytes memory data) internal returns (bool success, bytes memory retData) { - return simulateCall(target, 0, data, bytes32(0)); + return simulateCall(target, 0, data); } /// @dev Same as {simulateCall-address-bytes} but with a value. @@ -21,112 +21,77 @@ library SimulateCall { uint256 value, bytes memory data ) internal returns (bool success, bytes memory retData) { - return simulateCall(target, value, data, bytes32(0)); + (success, retData) = getSimulator().delegatecall(abi.encodePacked(target, value, data)); + success = !success; } - /// @dev Same as {simulateCall-address-bytes} but with a salt for deterministic simulator address. - function simulateCall( - address target, - bytes memory data, - bytes32 salt - ) internal returns (bool success, bytes memory retData) { - return simulateCall(target, 0, data, salt); - } - - /// @dev Same as {simulateCall-address-bytes} but with a salt and a value. - function simulateCall( - address target, - uint256 value, - bytes memory data, - bytes32 salt - ) internal returns (bool success, bytes memory retData) { - // Calls to a simulator always revert. No need to check the success flag. - (, retData) = getSimulator(salt).call{value: value}(abi.encodePacked(target, data)); - - assembly ("memory-safe") { - // length of the returned buffer (result/reason + success byte) - let length := mload(retData) - - // extract the success byte (last byte of the returned buffer) - success := and(mload(add(retData, length)), 0xff) - - // shrink retData to exclude the success byte - mstore(retData, sub(length, 1)) - } - } - - /// @dev Same as {getSimulator} but with a `bytes32(0)` default salt. - function getSimulator() internal returns (address) { - return getSimulator(bytes32(0)); - } - - /// @dev Returns the simulator address for a given salt. - function getSimulator(bytes32 salt) internal returns (address simulator) { + /// @dev Returns the simulator address. + function getSimulator() internal returns (address instance) { // [Simulator details] - // - // deployment prefix: 602e5f8160095f39f3 - // deployed bytecode: 60133611600a575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e3d533d6001015ffd + // deployment prefix: 60315f8160095f39f3 + // deployed bytecode: 60333611600a575f5ffd5b6034360360345f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd // // offset | bytecode | opcode | stack // -------|-------------|----------------|-------- - // 0x0000 | 6013 | push1 0x13 | 0x13 - // 0x0002 | 36 | calldatasize | cds 0x13 - // 0x0003 | 11 | gt | (cds>0x13) - // 0x0004 | 600a | push1 0x0a | 0x0a (cds>0x13) + // 0x0000 | 6033 | push1 0x33 | 0x33 + // 0x0002 | 36 | calldatasize | cds 0x33 + // 0x0003 | 11 | gt | (cds>0x33) + // 0x0004 | 600a | push1 0x0a | 0x0a (cds>0x33) // 0x0006 | 57 | jumpi | // 0x0007 | 5f | push0 | 0 // 0x0008 | 5f | push0 | 0 0 // 0x0009 | fd | revert | // 0x000a | 5b | jumpdest | - // 0x000b | 6014 | push1 0x14 | 0x14 - // 0x000d | 36 | calldatasize | cds 0x14 - // 0x000e | 03 | sub | (cds-0x14) - // 0x000f | 6014 | push1 0x14 | 0x14 (cds-0x14) - // 0x0011 | 5f | push0 | 0 0x14 (cds-0x14) + // 0x000b | 6034 | push1 0x34 | 0x34 + // 0x000d | 36 | calldatasize | cds 0x34 + // 0x000e | 03 | sub | (cds-0x34) + // 0x000f | 6034 | push1 0x34 | 0x34 (cds-0x34) + // 0x0011 | 5f | push0 | 0 0x34 (cds-0x34) // 0x0012 | 37 | calldatacopy | // 0x0013 | 5f | push0 | 0 // 0x0014 | 5f | push0 | 0 0 - // 0x0015 | 6014 | push1 0x14 | 0x14 0 0 - // 0x0017 | 36 | calldatasize | cds 0x14 0 0 - // 0x0018 | 03 | sub | (cds-0x14) 0 0 - // 0x0019 | 5f | push0 | 0 (cds-0x14) 0 0 - // 0x001a | 34 | callvalue | value 0 (cds-0x14) 0 0 - // 0x001b | 5f | push0 | 0 value 0 (cds-0x14) 0 0 - // 0x001c | 35 | calldataload | cd[0] value 0 (cds-0x14) 0 0 - // 0x001d | 6060 | push1 0x60 | 0x60 cd[0] value 0 (cds-0x14) 0 0 - // 0x001f | 1c | shr | target value 0 (cds-0x14) 0 0 - // 0x0020 | 5a | gas | gas target value 0 (cds-0x14) 0 0 - // 0x0021 | f1 | call | suc - // 0x0022 | 3d | returndatasize | rds suc - // 0x0023 | 5f | push0 | 0 rds suc - // 0x0024 | 5f | push0 | 0 0 rds suc - // 0x0025 | 3e | returndatacopy | suc - // 0x0026 | 3d | returndatasize | rds suc - // 0x0027 | 53 | mstore8 | - // 0x0028 | 3d | returndatasize | rds - // 0x0029 | 6001 | push1 0x01 | 0x01 rds - // 0x002b | 01 | add | (rds+0x01) - // 0x002c | 5f | push0 | 0 (rds+0x01) - // 0x002d | fd | revert | - + // 0x0015 | 6034 | push1 0x34 | 0x34 0 0 + // 0x0017 | 36 | calldatasize | cds 0x34 0 0 + // 0x0018 | 03 | sub | (cds-0x34) 0 0 + // 0x0019 | 5f | push0 | 0 (cds-0x34) 0 0 + // 0x001a | 6014 | push1 0x14 | 0x14 0 (cds-0x34) 0 0 + // 0x001c | 35 | calldataload | cd[0x14] 0 (cds-0x34) 0 0 + // 0x001d | 5f | push0 | 0 cd[0x14] 0 (cds-0x34) 0 0 + // 0x001e | 35 | calldataload | cd[0] cd[0x14] 0 (cds-0x34) 0 0 + // 0x001f | 6060 | push1 0x60 | 0x60 cd[0] cd[0x14] 0 (cds-0x34) 0 0 + // 0x0021 | 1c | shr | target cd[0x14] 0 (cds-0x34) 0 0 + // 0x0022 | 5a | gas | gas target cd[0x14] 0 (cds-0x34) 0 0 + // 0x0023 | f1 | call | suc + // 0x0024 | 3d | returndatasize | rds suc + // 0x0025 | 5f | push0 | 0 rds suc + // 0x0026 | 5f | push0 | 0 0 rds suc + // 0x0027 | 3e | returndatacopy | suc + // 0x0028 | 5f | push0 | 0 suc + // 0x0029 | 3d | returndatasize | rds 0 suc + // 0x002a | 91 | swap2 | suc 0 rds + // 0x002b | 602f | push1 0x2f | 0x2f suc 0 rds + // 0x002d | 57 | jumpi | 0 rds + // 0x002e | f3 | return | + // 0x002f | 5b | jumpdest | 0 rds + // 0x0030 | fd | revert | assembly ("memory-safe") { // build initcode at scratch space - mstore(0x20, 0x0360145f375f5f601436035f345f3560601c5af13d5f5f3e3d533d6001015ffd) - mstore(0x00, 0x602e5f8160095f39f360133611600a575f5ffd5b601436) - let initcodehash := keccak256(0x09, 0x37) + mstore(0x20, 0x5f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd) + mstore(0x00, 0x60315f8160095f39f360333611600a575f5ffd5b603436036034) + let initcodehash := keccak256(0x06, 0x3a) let fmp := mload(0x40) // cache free memory pointer // compute create2 address mstore(add(fmp, 0x40), initcodehash) - mstore(add(fmp, 0x20), salt) + mstore(add(fmp, 0x20), 0) mstore(add(fmp, 0x00), address()) mstore8(add(fmp, 0x0b), 0xff) - simulator := and(keccak256(add(fmp, 0x0b), 0x55), shr(96, not(0))) + instance := and(keccak256(add(fmp, 0x0b), 0x55), shr(96, not(0))) // if simulator not yet deployed, deploy it - if iszero(extcodesize(simulator)) { - if iszero(create2(0, 0x09, 0x37, salt)) { + if iszero(extcodesize(instance)) { + if iszero(create2(0, 0x06, 0x3a, 0)) { returndatacopy(fmp, 0x00, returndatasize()) revert(fmp, returndatasize()) } diff --git a/test/utils/SimulatedCall.test.js b/test/utils/SimulatedCall.test.js index 67840560c9d..88ca90f0049 100644 --- a/test/utils/SimulatedCall.test.js +++ b/test/utils/SimulatedCall.test.js @@ -6,21 +6,20 @@ async function fixture() { const [receiver, other] = await ethers.getSigners(); const mock = await ethers.deployContract('$SimulateCall'); - const computeSimulatorAddress = (salt = ethers.ZeroHash) => - ethers.getCreate2Address( - mock.target, - salt, - ethers.keccak256( - ethers.concat([ - '0x602e5f8160095f39f3', - '0x60133611600a575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e3d533d6001015ffd', - ]), - ), - ); + const simulator = ethers.getCreate2Address( + mock.target, + ethers.ZeroHash, + ethers.keccak256( + ethers.concat([ + '0x60315f8160095f39f3', + '0x60333611600a575f5ffd5b6034360360345f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd', + ]), + ), + ); const target = await ethers.deployContract('$CallReceiverMock'); - return { mock, target, receiver, other, computeSimulatorAddress }; + return { mock, target, receiver, other, simulator }; } describe('SimulateCall', function () { @@ -28,129 +27,62 @@ describe('SimulateCall', function () { Object.assign(this, await loadFixture(fixture)); }); - describe('default (zero) salt', function () { - beforeEach(async function () { - this.simulator = await this.computeSimulatorAddress(); - }); - - it('automatic simulator deployment', async function () { - await expect(ethers.provider.getCode(this.simulator)).to.eventually.equal('0x'); - - // First call performs deployment - await expect(this.mock.$getSimulator()).to.emit(this.mock, 'return$getSimulator').withArgs(this.simulator); + it('automatic simulator deployment', async function () { + await expect(ethers.provider.getCode(this.simulator)).to.eventually.equal('0x'); - await expect(ethers.provider.getCode(this.simulator)).to.eventually.not.equal('0x'); + // First call performs deployment + await expect(this.mock.$getSimulator()).to.emit(this.mock, 'return$getSimulator').withArgs(this.simulator); - // Following calls use the same simulator - await expect(this.mock.$getSimulator()).to.emit(this.mock, 'return$getSimulator').withArgs(this.simulator); - }); + await expect(ethers.provider.getCode(this.simulator)).to.eventually.not.equal('0x'); - describe('simulated call', function () { - it('target success', async function () { - const tx = this.mock.$simulateCall( - ethers.Typed.address(this.target), - ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionWithArgsReturn', [10, 20])), - ); - await expect(tx) - .to.emit(this.mock, 'return$simulateCall_address_bytes') - .withArgs(true, ethers.AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256'], [10, 20])) - .to.not.emit(this.target, 'MockFunctionCalledWithArgs'); - }); - - it('target success (with value)', async function () { - const value = 42n; - - // fund the mock - await this.other.sendTransaction({ to: this.mock.target, value }); - - // perform simulated call - const tx = this.mock.$simulateCall( - ethers.Typed.address(this.receiver), - ethers.Typed.uint256(value), - ethers.Typed.bytes('0x'), - ); - - await expect(tx).to.changeEtherBalances([this.mock, this.simulator, this.receiver], [0n, 0n, 0n]); - await expect(tx).to.emit(this.mock, 'return$simulateCall_address_uint256_bytes').withArgs(true, '0x'); - }); - - it('target revert', async function () { - const tx = this.mock.$simulateCall( - ethers.Typed.address(this.target), - ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionRevertsReason', [])), - ); - - await expect(tx) - .to.emit(this.mock, 'return$simulateCall_address_bytes') - .withArgs(false, this.target.interface.encodeErrorResult('Error', ['CallReceiverMock: reverting'])); - }); - }); + // Following calls use the same simulator + await expect(this.mock.$getSimulator()).to.emit(this.mock, 'return$getSimulator').withArgs(this.simulator); }); - describe('random salt', function () { - beforeEach(async function () { - this.salt = ethers.hexlify(ethers.randomBytes(32)); - this.simulator = await this.computeSimulatorAddress(this.salt); + describe('simulated call', function () { + it('target success', async function () { + const txPromise = this.mock.$simulateCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionWithArgsReturn', [10, 20])), + ); + + await expect(txPromise).to.changeEtherBalances([this.mock, this.simulator, this.target], [0n, 0n, 0n]); + await expect(txPromise) + .to.emit(this.mock, 'return$simulateCall_address_bytes') + .withArgs(true, ethers.AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256'], [10, 20])) + .to.not.emit(this.target, 'MockFunctionCalledWithArgs'); }); - it('automatic simulator deployment', async function () { - await expect(ethers.provider.getCode(this.simulator)).to.eventually.equal('0x'); + it('target success (with value)', async function () { + const value = 42n; - // First call performs deployment - await expect(this.mock.$getSimulator(ethers.Typed.bytes32(this.salt))) - .to.emit(this.mock, 'return$getSimulator_bytes32') - .withArgs(this.simulator); + // fund the mock + await this.other.sendTransaction({ to: this.mock.target, value }); - await expect(ethers.provider.getCode(this.simulator)).to.eventually.not.equal('0x'); + // perform simulated call + const txPromise = this.mock.$simulateCall( + ethers.Typed.address(this.target), + ethers.Typed.uint256(value), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionExtra')), + ); - // Following calls use the same simulator - await expect(this.mock.$getSimulator(ethers.Typed.bytes32(this.salt))) - .to.emit(this.mock, 'return$getSimulator_bytes32') - .withArgs(this.simulator); + await expect(txPromise).to.changeEtherBalances([this.mock, this.simulator, this.target], [0n, 0n, 0n]); + await expect(txPromise) + .to.emit(this.mock, 'return$simulateCall_address_uint256_bytes') + .withArgs(true, ethers.AbiCoder.defaultAbiCoder().encode(['address', 'uint256'], [this.mock.target, value])) + .to.not.emit(this.target, 'MockFunctionCalledExtra'); }); - describe('simulated call', function () { - it('target success', async function () { - const tx = this.mock.$simulateCall( - ethers.Typed.address(this.target), - ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionWithArgsReturn', [10, 20])), - ethers.Typed.bytes32(this.salt), - ); - await expect(tx) - .to.emit(this.mock, 'return$simulateCall_address_bytes_bytes32') - .withArgs(true, ethers.AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256'], [10, 20])) - .to.not.emit(this.target, 'MockFunctionCalledWithArgs'); - }); - - it('target success (with value)', async function () { - const value = 42n; - - // fund the mock - await this.other.sendTransaction({ to: this.mock.target, value }); - - // perform simulated call - const tx = this.mock.$simulateCall( - ethers.Typed.address(this.receiver), - ethers.Typed.uint256(value), - ethers.Typed.bytes('0x'), - ethers.Typed.bytes32(this.salt), - ); - - await expect(tx).to.changeEtherBalances([this.mock, this.simulator, this.receiver], [0n, 0n, 0n]); - await expect(tx).to.emit(this.mock, 'return$simulateCall_address_uint256_bytes_bytes32').withArgs(true, '0x'); - }); - - it('target revert', async function () { - const tx = this.mock.$simulateCall( - ethers.Typed.address(this.target), - ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionRevertsReason', [])), - ethers.Typed.bytes32(this.salt), - ); - - await expect(tx) - .to.emit(this.mock, 'return$simulateCall_address_bytes_bytes32') - .withArgs(false, this.target.interface.encodeErrorResult('Error', ['CallReceiverMock: reverting'])); - }); + it('target revert', async function () { + const txPromise = this.mock.$simulateCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionRevertsReason')), + ); + + await expect(txPromise).to.changeEtherBalances([this.mock, this.simulator, this.target], [0n, 0n, 0n]); + await expect(txPromise) + .to.emit(this.mock, 'return$simulateCall_address_bytes') + .withArgs(false, this.target.interface.encodeErrorResult('Error', ['CallReceiverMock: reverting'])); }); }); }); From 63deb8fe2e716a71b3e0ca02c8838216b3307bc8 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 22 Jan 2026 13:37:53 +0100 Subject: [PATCH 10/18] Put initcode at FMP and compute create2 in scratch space initcode size is smaller, makking for a smaller memory expansion. --- contracts/utils/SimulateCall.sol | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/contracts/utils/SimulateCall.sol b/contracts/utils/SimulateCall.sol index 91ebe50cba5..0427882842d 100644 --- a/contracts/utils/SimulateCall.sol +++ b/contracts/utils/SimulateCall.sol @@ -75,23 +75,23 @@ library SimulateCall { // 0x002f | 5b | jumpdest | 0 rds // 0x0030 | fd | revert | assembly ("memory-safe") { - // build initcode at scratch space - mstore(0x20, 0x5f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd) - mstore(0x00, 0x60315f8160095f39f360333611600a575f5ffd5b603436036034) - let initcodehash := keccak256(0x06, 0x3a) + let fmp := mload(0x40) - let fmp := mload(0x40) // cache free memory pointer + // build initcode at FMP + mstore(add(fmp, 0x20), 0x5f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd) + mstore(add(fmp, 0x00), 0x60315f8160095f39f360333611600a575f5ffd5b603436036034) + let initcodehash := keccak256(add(fmp, 0x06), 0x3a) // compute create2 address - mstore(add(fmp, 0x40), initcodehash) - mstore(add(fmp, 0x20), 0) - mstore(add(fmp, 0x00), address()) - mstore8(add(fmp, 0x0b), 0xff) - instance := and(keccak256(add(fmp, 0x0b), 0x55), shr(96, not(0))) + mstore(0x40, initcodehash) + mstore(0x20, 0) + mstore(0x00, address()) + mstore8(0x0b, 0xff) + instance := and(keccak256(0x0b, 0x55), shr(96, not(0))) // if simulator not yet deployed, deploy it if iszero(extcodesize(instance)) { - if iszero(create2(0, 0x06, 0x3a, 0)) { + if iszero(create2(0, add(fmp, 0x06), 0x3a, 0)) { returndatacopy(fmp, 0x00, returndatasize()) revert(fmp, returndatasize()) } From b0b5795ebd77449803822b54da870218ef5dd97c Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Thu, 22 Jan 2026 13:43:13 +0100 Subject: [PATCH 11/18] add docs --- contracts/mocks/Stateless.sol | 1 + contracts/utils/README.adoc | 3 +++ 2 files changed, 4 insertions(+) diff --git a/contracts/mocks/Stateless.sol b/contracts/mocks/Stateless.sol index 30188a8f4be..0669c675248 100644 --- a/contracts/mocks/Stateless.sol +++ b/contracts/mocks/Stateless.sol @@ -54,6 +54,7 @@ import {SafeERC20} from "../token/ERC20/utils/SafeERC20.sol"; import {ShortStrings} from "../utils/ShortStrings.sol"; import {SignatureChecker} from "../utils/cryptography/SignatureChecker.sol"; import {SignedMath} from "../utils/math/SignedMath.sol"; +import {SimulateCall} from "../utils/SimulateCall.sol"; import {StorageSlot} from "../utils/StorageSlot.sol"; import {Strings} from "../utils/Strings.sol"; import {Time} from "../utils/types/Time.sol"; diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index 629f3863fd7..d6b0f720f3e 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -42,6 +42,7 @@ Miscellaneous contracts and libraries containing utility functions you can use t * {RelayedCall}: A library for performing calls that use minimal and predictable relayers to hide the sender. * {RLP}: Library for encoding and decoding data in Ethereum's Recursive Length Prefix format. * {ShortStrings}: Library to encode (and decode) short strings into (or from) a single bytes32 slot for optimizing costs. Short strings are limited to 31 characters. + * {SimulateCall}: Library for simulating contract calls, enabling safe inspection of call results without affecting on-chain state. * {SlotDerivation}: Methods for deriving storage slot from ERC-7201 namespaces as well as from constructions such as mapping and arrays. * {StorageSlot}: Methods for accessing specific storage slots formatted as common primitive types. * {Strings}: Common operations for strings formatting. @@ -149,6 +150,8 @@ Ethereum contracts have no native concept of an interface, so applications must {{ShortStrings}} +{{SimulateCall}} + {{SlotDerivation}} {{StorageSlot}} From abdc231463bfdc2d3bb8976bb60f052da03e1c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Thu, 22 Jan 2026 10:35:45 -0600 Subject: [PATCH 12/18] Update contracts/utils/SimulateCall.sol Co-authored-by: Hadrien Croubois --- contracts/utils/SimulateCall.sol | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/contracts/utils/SimulateCall.sol b/contracts/utils/SimulateCall.sol index 0427882842d..f2b6d3c5baa 100644 --- a/contracts/utils/SimulateCall.sol +++ b/contracts/utils/SimulateCall.sol @@ -3,11 +3,12 @@ pragma solidity ^0.8.20; /** - * @dev Library for simulating external calls through dynamically deployed simulator contracts that revert with - * the return data, allowing inspection of call results without state changes. + * @dev Library for simulating external calls and inspecting the result of the call while reverting any state changes + * of events the call may have produced. * - * This pattern is useful when you need to simulate the result of a call without actually executing it on-chain, - * or when you need to isolate the caller's address from the target contract. + * This pattern is useful when you need to simulate the result of a call without actually executing it on-chain. Since + * the addess of the sender is preserved, this supports simulating calls that perform token swap that use the caller's + * balance, or any operation that is restricted to the caller. */ library SimulateCall { /// @dev Simulates a call to the target contract through a dynamically deployed simulator. From 63c3cb4d45575a33049c7b3b18f4be7c88b4419b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Thu, 22 Jan 2026 10:35:51 -0600 Subject: [PATCH 13/18] Update .changeset/flat-flies-hear.md Co-authored-by: Hadrien Croubois --- .changeset/flat-flies-hear.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/flat-flies-hear.md b/.changeset/flat-flies-hear.md index 5999ae28390..db01d69ce36 100644 --- a/.changeset/flat-flies-hear.md +++ b/.changeset/flat-flies-hear.md @@ -2,4 +2,4 @@ 'openzeppelin-solidity': minor --- -`SimulateCall`: Add new library with simulator functions (`getSimulator`, `simulateCall`) that allow inspection of return data by reverting with the call result without making state changes. +`SimulateCall`: Add a new call simulation utilities that allow inspecting return data from contract calls by executing them in a non-mutating, revert-based context. From 42011b8eb63275d445d86a352baef9b0ea332ac1 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 22 Jan 2026 10:49:03 -0600 Subject: [PATCH 14/18] Lint --- contracts/utils/SimulateCall.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/utils/SimulateCall.sol b/contracts/utils/SimulateCall.sol index f2b6d3c5baa..3f93a3d7da2 100644 --- a/contracts/utils/SimulateCall.sol +++ b/contracts/utils/SimulateCall.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.20; /** - * @dev Library for simulating external calls and inspecting the result of the call while reverting any state changes + * @dev Library for simulating external calls and inspecting the result of the call while reverting any state changes * of events the call may have produced. * * This pattern is useful when you need to simulate the result of a call without actually executing it on-chain. Since From 3c4529fa986475f1caba345556aa8a0758d5536c Mon Sep 17 00:00:00 2001 From: ernestognw Date: Thu, 22 Jan 2026 10:49:57 -0600 Subject: [PATCH 15/18] up --- contracts/utils/SimulateCall.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/utils/SimulateCall.sol b/contracts/utils/SimulateCall.sol index 3f93a3d7da2..5968f8e5bab 100644 --- a/contracts/utils/SimulateCall.sol +++ b/contracts/utils/SimulateCall.sol @@ -7,7 +7,7 @@ pragma solidity ^0.8.20; * of events the call may have produced. * * This pattern is useful when you need to simulate the result of a call without actually executing it on-chain. Since - * the addess of the sender is preserved, this supports simulating calls that perform token swap that use the caller's + * the address of the sender is preserved, this supports simulating calls that perform token swap that use the caller's * balance, or any operation that is restricted to the caller. */ library SimulateCall { From 8cd677807b38e5eb3ebd02d504e4a96daf85e4ab Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 23 Jan 2026 09:50:37 +0100 Subject: [PATCH 16/18] add tests value+revert --- test/utils/SimulatedCall.test.js | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/test/utils/SimulatedCall.test.js b/test/utils/SimulatedCall.test.js index 88ca90f0049..ab2e875543e 100644 --- a/test/utils/SimulatedCall.test.js +++ b/test/utils/SimulatedCall.test.js @@ -2,6 +2,8 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const value = 42n; + async function fixture() { const [receiver, other] = await ethers.getSigners(); @@ -19,6 +21,9 @@ async function fixture() { const target = await ethers.deployContract('$CallReceiverMock'); + // fund the mock contract (for tests that use value) + await other.sendTransaction({ to: mock, value }); + return { mock, target, receiver, other, simulator }; } @@ -54,11 +59,6 @@ describe('SimulateCall', function () { }); it('target success (with value)', async function () { - const value = 42n; - - // fund the mock - await this.other.sendTransaction({ to: this.mock.target, value }); - // perform simulated call const txPromise = this.mock.$simulateCall( ethers.Typed.address(this.target), @@ -84,5 +84,18 @@ describe('SimulateCall', function () { .to.emit(this.mock, 'return$simulateCall_address_bytes') .withArgs(false, this.target.interface.encodeErrorResult('Error', ['CallReceiverMock: reverting'])); }); + + it('target revert (with value)', async function () { + const txPromise = this.mock.$simulateCall( + ethers.Typed.address(this.target), + ethers.Typed.uint256(value), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionRevertsReason')), + ); + + await expect(txPromise).to.changeEtherBalances([this.mock, this.simulator, this.target], [0n, 0n, 0n]); + await expect(txPromise) + .to.emit(this.mock, 'return$simulateCall_address_uint256_bytes') + .withArgs(false, this.target.interface.encodeErrorResult('Error', ['CallReceiverMock: reverting'])); + }); }); }); From ee703d7bd43ba4e52a38332d87a71f1562776469 Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 23 Jan 2026 14:03:59 -0600 Subject: [PATCH 17/18] Review comment --- contracts/utils/SimulateCall.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/utils/SimulateCall.sol b/contracts/utils/SimulateCall.sol index 5968f8e5bab..f3ac90077c1 100644 --- a/contracts/utils/SimulateCall.sol +++ b/contracts/utils/SimulateCall.sol @@ -80,7 +80,7 @@ library SimulateCall { // build initcode at FMP mstore(add(fmp, 0x20), 0x5f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd) - mstore(add(fmp, 0x00), 0x60315f8160095f39f360333611600a575f5ffd5b603436036034) + mstore(fmp, 0x60315f8160095f39f360333611600a575f5ffd5b603436036034) let initcodehash := keccak256(add(fmp, 0x06), 0x3a) // compute create2 address From ed49f878c4427c2444a76c1b9351f37a93395d5c Mon Sep 17 00:00:00 2001 From: ernestognw Date: Fri, 23 Jan 2026 14:49:29 -0600 Subject: [PATCH 18/18] Explain inverted revert mechanism --- contracts/utils/SimulateCall.sol | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/contracts/utils/SimulateCall.sol b/contracts/utils/SimulateCall.sol index f3ac90077c1..12a96459e3e 100644 --- a/contracts/utils/SimulateCall.sol +++ b/contracts/utils/SimulateCall.sol @@ -23,10 +23,18 @@ library SimulateCall { bytes memory data ) internal returns (bool success, bytes memory retData) { (success, retData) = getSimulator().delegatecall(abi.encodePacked(target, value, data)); - success = !success; + success = !success; // getSimulator() returns the success value inverted } - /// @dev Returns the simulator address. + /** + * @dev Returns the simulator address. + * + * The simulator REVERTs on success and RETURNs on failure, preserving the return data in both cases. + * + * * A failed target call returns the return data and succeeds in our context (no state changes). + * * A successful target call causes a revert in our context (undoing all state changes) while still + * capturing the return data. + */ function getSimulator() internal returns (address instance) { // [Simulator details] // deployment prefix: 60315f8160095f39f3