Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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 new library with simulator functions (`getSimulator`, `simulateCall`) that allow inspection of return data by reverting with the call result without making state changes.
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
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
104 changes: 104 additions & 0 deletions contracts/utils/SimulateCall.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: MIT

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);
}

/// @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;
}

/// @dev Returns the simulator address.
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") {
// build initcode at scratch space
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), 0)
mstore(add(fmp, 0x00), address())
mstore8(add(fmp, 0x0b), 0xff)
instance := and(keccak256(add(fmp, 0x0b), 0x55), shr(96, not(0)))

// if simulator not yet deployed, deploy it
if iszero(extcodesize(instance)) {
if iszero(create2(0, 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
88 changes: 88 additions & 0 deletions test/utils/SimulatedCall.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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 simulator = ethers.getCreate2Address(
mock.target,
ethers.ZeroHash,
ethers.keccak256(
ethers.concat([
'0x60315f8160095f39f3',
'0x60333611600a575f5ffd5b6034360360345f375f5f603436035f6014355f3560601c5af13d5f5f3e5f3d91602f57f35bfd',
]),
),
);

const target = await ethers.deployContract('$CallReceiverMock');

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 () {
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),
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']));
});
});
});
Loading