Skip to content

Commit dde766b

Browse files
ernestognwAmxx
andauthored
Add SimulateCall library (OpenZeppelin#6290)
Co-authored-by: Hadrien Croubois <hadrien.croubois@gmail.com>
1 parent 573beab commit dde766b

File tree

8 files changed

+252
-14
lines changed

8 files changed

+252
-14
lines changed

.changeset/flat-flies-hear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'openzeppelin-solidity': minor
3+
---
4+
5+
`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.

contracts/mocks/CallReceiverMock.sol

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,9 @@ contract CallReceiverMock {
8888
}
8989
}
9090

91-
function mockFunctionExtra() public payable {
91+
function mockFunctionExtra() public payable returns (address, uint256) {
9292
emit MockFunctionCalledExtra(msg.sender, msg.value);
93+
return (msg.sender, msg.value);
9394
}
9495
}
9596

contracts/mocks/Stateless.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {SafeERC20} from "../token/ERC20/utils/SafeERC20.sol";
5454
import {ShortStrings} from "../utils/ShortStrings.sol";
5555
import {SignatureChecker} from "../utils/cryptography/SignatureChecker.sol";
5656
import {SignedMath} from "../utils/math/SignedMath.sol";
57+
import {SimulateCall} from "../utils/SimulateCall.sol";
5758
import {StorageSlot} from "../utils/StorageSlot.sol";
5859
import {Strings} from "../utils/Strings.sol";
5960
import {Time} from "../utils/types/Time.sol";

contracts/utils/README.adoc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Miscellaneous contracts and libraries containing utility functions you can use t
4242
* {RelayedCall}: A library for performing calls that use minimal and predictable relayers to hide the sender.
4343
* {RLP}: Library for encoding and decoding data in Ethereum's Recursive Length Prefix format.
4444
* {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.
45+
* {SimulateCall}: Library for simulating contract calls, enabling safe inspection of call results without affecting on-chain state.
4546
* {SlotDerivation}: Methods for deriving storage slot from ERC-7201 namespaces as well as from constructions such as mapping and arrays.
4647
* {StorageSlot}: Methods for accessing specific storage slots formatted as common primitive types.
4748
* {Strings}: Common operations for strings formatting.
@@ -149,6 +150,8 @@ Ethereum contracts have no native concept of an interface, so applications must
149150

150151
{{ShortStrings}}
151152

153+
{{SimulateCall}}
154+
152155
{{SlotDerivation}}
153156

154157
{{StorageSlot}}

contracts/utils/RelayedCall.sol

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,35 @@ pragma solidity ^0.8.20;
1818
*/
1919
library RelayedCall {
2020
/// @dev Relays a call to the target contract through a dynamically deployed relay contract.
21-
function relayCall(address target, bytes memory data) internal returns (bool, bytes memory) {
22-
return relayCall(target, 0, data);
21+
function relayCall(address target, bytes memory data) internal returns (bool success, bytes memory retData) {
22+
return relayCall(target, 0, data, bytes32(0));
2323
}
2424

25-
/// @dev Same as {relayCall} but with a value.
26-
function relayCall(address target, uint256 value, bytes memory data) internal returns (bool, bytes memory) {
25+
/// @dev Same as {relayCall-address-bytes} but with a value.
26+
function relayCall(
27+
address target,
28+
uint256 value,
29+
bytes memory data
30+
) internal returns (bool success, bytes memory retData) {
2731
return relayCall(target, value, data, bytes32(0));
2832
}
2933

30-
/// @dev Same as {relayCall} but with a salt.
31-
function relayCall(address target, bytes memory data, bytes32 salt) internal returns (bool, bytes memory) {
34+
/// @dev Same as {relayCall-address-bytes} but with a salt.
35+
function relayCall(
36+
address target,
37+
bytes memory data,
38+
bytes32 salt
39+
) internal returns (bool success, bytes memory retData) {
3240
return relayCall(target, 0, data, salt);
3341
}
3442

35-
/// @dev Same as {relayCall} but with a salt and a value.
43+
/// @dev Same as {relayCall-address-bytes} but with a salt and a value.
3644
function relayCall(
3745
address target,
3846
uint256 value,
3947
bytes memory data,
4048
bytes32 salt
41-
) internal returns (bool, bytes memory) {
49+
) internal returns (bool success, bytes memory retData) {
4250
return getRelayer(salt).call{value: value}(abi.encodePacked(target, data));
4351
}
4452

contracts/utils/SimulateCall.sol

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
/**
6+
* @dev Library for simulating external calls and inspecting the result of the call while reverting any state changes
7+
* of events the call may have produced.
8+
*
9+
* This pattern is useful when you need to simulate the result of a call without actually executing it on-chain. Since
10+
* the address of the sender is preserved, this supports simulating calls that perform token swap that use the caller's
11+
* balance, or any operation that is restricted to the caller.
12+
*/
13+
library SimulateCall {
14+
/// @dev Simulates a call to the target contract through a dynamically deployed simulator.
15+
function simulateCall(address target, bytes memory data) internal returns (bool success, bytes memory retData) {
16+
return simulateCall(target, 0, data);
17+
}
18+
19+
/// @dev Same as {simulateCall-address-bytes} but with a value.
20+
function simulateCall(
21+
address target,
22+
uint256 value,
23+
bytes memory data
24+
) internal returns (bool success, bytes memory retData) {
25+
(success, retData) = getSimulator().delegatecall(abi.encodePacked(target, value, data));
26+
success = !success; // getSimulator() returns the success value inverted
27+
}
28+
29+
/**
30+
* @dev Returns the simulator address.
31+
*
32+
* The simulator REVERTs on success and RETURNs on failure, preserving the return data in both cases.
33+
*
34+
* * A failed target call returns the return data and succeeds in our context (no state changes).
35+
* * A successful target call causes a revert in our context (undoing all state changes) while still
36+
* capturing the return data.
37+
*/
38+
function getSimulator() internal returns (address instance) {
39+
// [Simulator details]
40+
// deployment prefix: 60315f8160095f39f3
41+
// deployed bytecode: 60333611600a575f5ffd5b6034360360345f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd
42+
//
43+
// offset | bytecode | opcode | stack
44+
// -------|-------------|----------------|--------
45+
// 0x0000 | 6033 | push1 0x33 | 0x33
46+
// 0x0002 | 36 | calldatasize | cds 0x33
47+
// 0x0003 | 11 | gt | (cds>0x33)
48+
// 0x0004 | 600a | push1 0x0a | 0x0a (cds>0x33)
49+
// 0x0006 | 57 | jumpi |
50+
// 0x0007 | 5f | push0 | 0
51+
// 0x0008 | 5f | push0 | 0 0
52+
// 0x0009 | fd | revert |
53+
// 0x000a | 5b | jumpdest |
54+
// 0x000b | 6034 | push1 0x34 | 0x34
55+
// 0x000d | 36 | calldatasize | cds 0x34
56+
// 0x000e | 03 | sub | (cds-0x34)
57+
// 0x000f | 6034 | push1 0x34 | 0x34 (cds-0x34)
58+
// 0x0011 | 5f | push0 | 0 0x34 (cds-0x34)
59+
// 0x0012 | 37 | calldatacopy |
60+
// 0x0013 | 5f | push0 | 0
61+
// 0x0014 | 5f | push0 | 0 0
62+
// 0x0015 | 6034 | push1 0x34 | 0x34 0 0
63+
// 0x0017 | 36 | calldatasize | cds 0x34 0 0
64+
// 0x0018 | 03 | sub | (cds-0x34) 0 0
65+
// 0x0019 | 5f | push0 | 0 (cds-0x34) 0 0
66+
// 0x001a | 6014 | push1 0x14 | 0x14 0 (cds-0x34) 0 0
67+
// 0x001c | 35 | calldataload | cd[0x14] 0 (cds-0x34) 0 0
68+
// 0x001d | 5f | push0 | 0 cd[0x14] 0 (cds-0x34) 0 0
69+
// 0x001e | 35 | calldataload | cd[0] cd[0x14] 0 (cds-0x34) 0 0
70+
// 0x001f | 6060 | push1 0x60 | 0x60 cd[0] cd[0x14] 0 (cds-0x34) 0 0
71+
// 0x0021 | 1c | shr | target cd[0x14] 0 (cds-0x34) 0 0
72+
// 0x0022 | 5a | gas | gas target cd[0x14] 0 (cds-0x34) 0 0
73+
// 0x0023 | f1 | call | suc
74+
// 0x0024 | 3d | returndatasize | rds suc
75+
// 0x0025 | 5f | push0 | 0 rds suc
76+
// 0x0026 | 5f | push0 | 0 0 rds suc
77+
// 0x0027 | 3e | returndatacopy | suc
78+
// 0x0028 | 5f | push0 | 0 suc
79+
// 0x0029 | 3d | returndatasize | rds 0 suc
80+
// 0x002a | 91 | swap2 | suc 0 rds
81+
// 0x002b | 602f | push1 0x2f | 0x2f suc 0 rds
82+
// 0x002d | 57 | jumpi | 0 rds
83+
// 0x002e | f3 | return |
84+
// 0x002f | 5b | jumpdest | 0 rds
85+
// 0x0030 | fd | revert |
86+
assembly ("memory-safe") {
87+
let fmp := mload(0x40)
88+
89+
// build initcode at FMP
90+
mstore(add(fmp, 0x20), 0x5f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd)
91+
mstore(fmp, 0x60315f8160095f39f360333611600a575f5ffd5b603436036034)
92+
let initcodehash := keccak256(add(fmp, 0x06), 0x3a)
93+
94+
// compute create2 address
95+
mstore(0x40, initcodehash)
96+
mstore(0x20, 0)
97+
mstore(0x00, address())
98+
mstore8(0x0b, 0xff)
99+
instance := and(keccak256(0x0b, 0x55), shr(96, not(0)))
100+
101+
// if simulator not yet deployed, deploy it
102+
if iszero(extcodesize(instance)) {
103+
if iszero(create2(0, add(fmp, 0x06), 0x3a, 0)) {
104+
returndatacopy(fmp, 0x00, returndatasize())
105+
revert(fmp, returndatasize())
106+
}
107+
}
108+
109+
// cleanup fmp space used as scratch
110+
mstore(0x40, fmp)
111+
}
112+
}
113+
}

test/utils/RelayedCall.test.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,17 @@ describe('RelayedCall', function () {
6464
it('target success (with value)', async function () {
6565
const value = 42n;
6666

67+
// fund the mock
68+
await this.other.sendTransaction({ to: this.mock.target, value });
69+
70+
// perform relayed call
6771
const tx = this.mock.$relayCall(
6872
ethers.Typed.address(this.receiver),
6973
ethers.Typed.uint256(value),
7074
ethers.Typed.bytes('0x'),
71-
ethers.Typed.overrides({ value }),
7275
);
7376

74-
await expect(tx).to.changeEtherBalances([this.mock, this.relayer, this.receiver], [0n, 0n, value]);
77+
await expect(tx).to.changeEtherBalances([this.mock, this.relayer, this.receiver], [-value, 0n, value]);
7578
await expect(tx).to.emit(this.mock, 'return$relayCall_address_uint256_bytes').withArgs(true, '0x');
7679
});
7780

@@ -97,7 +100,7 @@ describe('RelayedCall', function () {
97100
).to.be.revertedWithoutReason();
98101
});
99102

100-
it('input format', async function () {
103+
it('relayer input format', async function () {
101104
// deploy relayer
102105
await this.mock.$getRelayer();
103106

@@ -158,15 +161,18 @@ describe('RelayedCall', function () {
158161
it('target success (with value)', async function () {
159162
const value = 42n;
160163

164+
// fund the mock
165+
await this.other.sendTransaction({ to: this.mock.target, value });
166+
167+
// perform relayed call
161168
const tx = this.mock.$relayCall(
162169
ethers.Typed.address(this.receiver),
163170
ethers.Typed.uint256(value),
164171
ethers.Typed.bytes('0x'),
165172
ethers.Typed.bytes32(this.salt),
166-
ethers.Typed.overrides({ value }),
167173
);
168174

169-
await expect(tx).to.changeEtherBalances([this.mock, this.relayer, this.receiver], [0n, 0n, value]);
175+
await expect(tx).to.changeEtherBalances([this.mock, this.relayer, this.receiver], [-value, 0n, value]);
170176
await expect(tx).to.emit(this.mock, 'return$relayCall_address_uint256_bytes_bytes32').withArgs(true, '0x');
171177
});
172178

test/utils/SimulatedCall.test.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
const { ethers } = require('hardhat');
2+
const { expect } = require('chai');
3+
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
4+
5+
const value = 42n;
6+
7+
async function fixture() {
8+
const [receiver, other] = await ethers.getSigners();
9+
10+
const mock = await ethers.deployContract('$SimulateCall');
11+
const simulator = ethers.getCreate2Address(
12+
mock.target,
13+
ethers.ZeroHash,
14+
ethers.keccak256(
15+
ethers.concat([
16+
'0x60315f8160095f39f3',
17+
'0x60333611600a575f5ffd5b6034360360345f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd',
18+
]),
19+
),
20+
);
21+
22+
const target = await ethers.deployContract('$CallReceiverMock');
23+
24+
// fund the mock contract (for tests that use value)
25+
await other.sendTransaction({ to: mock, value });
26+
27+
return { mock, target, receiver, other, simulator };
28+
}
29+
30+
describe('SimulateCall', function () {
31+
beforeEach(async function () {
32+
Object.assign(this, await loadFixture(fixture));
33+
});
34+
35+
it('automatic simulator deployment', async function () {
36+
await expect(ethers.provider.getCode(this.simulator)).to.eventually.equal('0x');
37+
38+
// First call performs deployment
39+
await expect(this.mock.$getSimulator()).to.emit(this.mock, 'return$getSimulator').withArgs(this.simulator);
40+
41+
await expect(ethers.provider.getCode(this.simulator)).to.eventually.not.equal('0x');
42+
43+
// Following calls use the same simulator
44+
await expect(this.mock.$getSimulator()).to.emit(this.mock, 'return$getSimulator').withArgs(this.simulator);
45+
});
46+
47+
describe('simulated call', function () {
48+
it('target success', async function () {
49+
const txPromise = this.mock.$simulateCall(
50+
ethers.Typed.address(this.target),
51+
ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionWithArgsReturn', [10, 20])),
52+
);
53+
54+
await expect(txPromise).to.changeEtherBalances([this.mock, this.simulator, this.target], [0n, 0n, 0n]);
55+
await expect(txPromise)
56+
.to.emit(this.mock, 'return$simulateCall_address_bytes')
57+
.withArgs(true, ethers.AbiCoder.defaultAbiCoder().encode(['uint256', 'uint256'], [10, 20]))
58+
.to.not.emit(this.target, 'MockFunctionCalledWithArgs');
59+
});
60+
61+
it('target success (with value)', async function () {
62+
// perform simulated call
63+
const txPromise = this.mock.$simulateCall(
64+
ethers.Typed.address(this.target),
65+
ethers.Typed.uint256(value),
66+
ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionExtra')),
67+
);
68+
69+
await expect(txPromise).to.changeEtherBalances([this.mock, this.simulator, this.target], [0n, 0n, 0n]);
70+
await expect(txPromise)
71+
.to.emit(this.mock, 'return$simulateCall_address_uint256_bytes')
72+
.withArgs(true, ethers.AbiCoder.defaultAbiCoder().encode(['address', 'uint256'], [this.mock.target, value]))
73+
.to.not.emit(this.target, 'MockFunctionCalledExtra');
74+
});
75+
76+
it('target revert', async function () {
77+
const txPromise = this.mock.$simulateCall(
78+
ethers.Typed.address(this.target),
79+
ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionRevertsReason')),
80+
);
81+
82+
await expect(txPromise).to.changeEtherBalances([this.mock, this.simulator, this.target], [0n, 0n, 0n]);
83+
await expect(txPromise)
84+
.to.emit(this.mock, 'return$simulateCall_address_bytes')
85+
.withArgs(false, this.target.interface.encodeErrorResult('Error', ['CallReceiverMock: reverting']));
86+
});
87+
88+
it('target revert (with value)', async function () {
89+
const txPromise = this.mock.$simulateCall(
90+
ethers.Typed.address(this.target),
91+
ethers.Typed.uint256(value),
92+
ethers.Typed.bytes(this.target.interface.encodeFunctionData('mockFunctionRevertsReason')),
93+
);
94+
95+
await expect(txPromise).to.changeEtherBalances([this.mock, this.simulator, this.target], [0n, 0n, 0n]);
96+
await expect(txPromise)
97+
.to.emit(this.mock, 'return$simulateCall_address_uint256_bytes')
98+
.withArgs(false, this.target.interface.encodeErrorResult('Error', ['CallReceiverMock: reverting']));
99+
});
100+
});
101+
});

0 commit comments

Comments
 (0)