diff --git a/.changeset/flat-flies-hear.md b/.changeset/flat-flies-hear.md new file mode 100644 index 00000000000..db01d69ce36 --- /dev/null +++ b/.changeset/flat-flies-hear.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`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. 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/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}} diff --git a/contracts/utils/RelayedCall.sol b/contracts/utils/RelayedCall.sol index e7e5ee02089..27534cb6309 100644 --- a/contracts/utils/RelayedCall.sol +++ b/contracts/utils/RelayedCall.sol @@ -18,27 +18,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) { - return relayCall(target, 0, data); + function relayCall(address target, bytes memory data) internal returns (bool success, bytes memory retData) { + return relayCall(target, 0, data, bytes32(0)); } - /// @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)); } diff --git a/contracts/utils/SimulateCall.sol b/contracts/utils/SimulateCall.sol new file mode 100644 index 00000000000..12a96459e3e --- /dev/null +++ b/contracts/utils/SimulateCall.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +/** + * @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 + * 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 { + /// @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); + } + + /// @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) { + (success, retData) = getSimulator().delegatecall(abi.encodePacked(target, value, data)); + success = !success; // getSimulator() returns the success value inverted + } + + /** + * @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 + // deployed bytecode: 60333611600a575f5ffd5b6034360360345f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd + // + // offset | bytecode | opcode | stack + // -------|-------------|----------------|-------- + // 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 | 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 | 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") { + let fmp := mload(0x40) + + // build initcode at FMP + mstore(add(fmp, 0x20), 0x5f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd) + mstore(fmp, 0x60315f8160095f39f360333611600a575f5ffd5b603436036034) + let initcodehash := keccak256(add(fmp, 0x06), 0x3a) + + // compute create2 address + 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, add(fmp, 0x06), 0x3a, 0)) { + 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..685d090e913 100644 --- a/test/utils/RelayedCall.test.js +++ b/test/utils/RelayedCall.test.js @@ -64,14 +64,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'); }); @@ -97,7 +100,7 @@ describe('RelayedCall', function () { ).to.be.revertedWithoutReason(); }); - it('input format', async function () { + it('relayer input format', async function () { // deploy relayer await this.mock.$getRelayer(); @@ -158,15 +161,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'); }); diff --git a/test/utils/SimulatedCall.test.js b/test/utils/SimulatedCall.test.js new file mode 100644 index 00000000000..ab2e875543e --- /dev/null +++ b/test/utils/SimulatedCall.test.js @@ -0,0 +1,101 @@ +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(); + + const mock = await ethers.deployContract('$SimulateCall'); + const simulator = ethers.getCreate2Address( + mock.target, + ethers.ZeroHash, + ethers.keccak256( + ethers.concat([ + '0x60315f8160095f39f3', + '0x60333611600a575f5ffd5b6034360360345f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd', + ]), + ), + ); + + 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 }; +} + +describe('SimulateCall', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + 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 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('target success (with value)', async function () { + // 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')), + ); + + 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'); + }); + + 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'])); + }); + + 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'])); + }); + }); +});