Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flat-flies-hear.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion contracts/mocks/CallReceiverMock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
1 change: 1 addition & 0 deletions contracts/mocks/Stateless.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions contracts/utils/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -149,6 +150,8 @@ Ethereum contracts have no native concept of an interface, so applications must

{{ShortStrings}}

{{SimulateCall}}

{{SlotDerivation}}

{{StorageSlot}}
Expand Down
24 changes: 16 additions & 8 deletions contracts/utils/RelayedCall.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down
113 changes: 113 additions & 0 deletions contracts/utils/SimulateCall.sol
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
16 changes: 11 additions & 5 deletions test/utils/RelayedCall.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand All @@ -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();

Expand Down Expand Up @@ -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');
});

Expand Down
101 changes: 101 additions & 0 deletions test/utils/SimulatedCall.test.js
Original file line number Diff line number Diff line change
@@ -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']));
});
});
});