diff --git a/.changeset/clear-tools-refuse.md b/.changeset/clear-tools-refuse.md new file mode 100644 index 00000000000..96e838e6c98 --- /dev/null +++ b/.changeset/clear-tools-refuse.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`RelayedCall`: Add a library to perform indirect calls through minimal and predictable relayers. diff --git a/contracts/mocks/Stateless.sol b/contracts/mocks/Stateless.sol index fd0cf398eb0..87549d29c94 100644 --- a/contracts/mocks/Stateless.sol +++ b/contracts/mocks/Stateless.sol @@ -41,6 +41,7 @@ import {NoncesKeyed} from "../utils/NoncesKeyed.sol"; import {P256} from "../utils/cryptography/P256.sol"; import {Packing} from "../utils/Packing.sol"; import {Panic} from "../utils/Panic.sol"; +import {RelayedCall} from "../utils/RelayedCall.sol"; import {RSA} from "../utils/cryptography/RSA.sol"; import {SafeCast} from "../utils/math/SafeCast.sol"; import {SafeERC20} from "../token/ERC20/utils/SafeERC20.sol"; diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index a536e3b51f8..3b1e41e078c 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -7,41 +7,42 @@ Miscellaneous contracts and libraries containing utility functions you can use t * {Math}, {SignedMath}: Implementation of various arithmetic functions. * {SafeCast}: Checked downcasting functions to avoid silent truncation. - * {ReentrancyGuard}: A modifier that can prevent reentrancy during certain functions. - * {ReentrancyGuardTransient}: Variant of {ReentrancyGuard} that uses transient storage (https://eips.ethereum.org/EIPS/eip-1153[EIP-1153]). - * {Pausable}: A common emergency response mechanism that can pause functionality while a remediation is pending. * {Nonces}: Utility for tracking and verifying address nonces that only increment. * {NoncesKeyed}: Alternative to {Nonces}, that support keyed nonces following https://eips.ethereum.org/EIPS/eip-4337#semi-abstracted-nonce-support[ERC-4337 specifications]. + * {Pausable}: A common emergency response mechanism that can pause functionality while a remediation is pending. + * {ReentrancyGuard}: A modifier that can prevent reentrancy during certain functions. + * {ReentrancyGuardTransient}: Variant of {ReentrancyGuard} that uses transient storage (https://eips.ethereum.org/EIPS/eip-1153[EIP-1153]). * {ERC165}, {ERC165Checker}: Utilities for inspecting interfaces supported by contracts. * {BitMaps}: A simple library to manage boolean value mapped to a numerical index in an efficient way. + * {Checkpoints}: A data structure to store values mapped to a strictly increasing key. Can be used for storing and accessing values over time. + * {CircularBuffer}: A data structure to store the last N values pushed to it. + * {DoubleEndedQueue}: An implementation of a https://en.wikipedia.org/wiki/Double-ended_queue[double ended queue] whose values can be added or removed from both sides. Useful for FIFO and LIFO structures. * {EnumerableMap}: A type like Solidity's https://solidity.readthedocs.io/en/latest/types.html#mapping-types[`mapping`], but with key-value _enumeration_: this will let you know how many entries a mapping has, and iterate over them (which is not possible with `mapping`). * {EnumerableSet}: Like {EnumerableMap}, but for https://en.wikipedia.org/wiki/Set_(abstract_data_type)[sets]. Can be used to store privileged accounts, issued IDs, etc. - * {DoubleEndedQueue}: An implementation of a https://en.wikipedia.org/wiki/Double-ended_queue[double ended queue] whose values can be added or removed from both sides. Useful for FIFO and LIFO structures. - * {CircularBuffer}: A data structure to store the last N values pushed to it. - * {Checkpoints}: A data structure to store values mapped to a strictly increasing key. Can be used for storing and accessing values over time. * {Heap}: A library that implements a https://en.wikipedia.org/wiki/Binary_heap[binary heap] in storage. * {MerkleTree}: A library with https://wikipedia.org/wiki/Merkle_Tree[Merkle Tree] data structures and helper functions. - * {Create2}: Wrapper around the https://blog.openzeppelin.com/getting-the-most-out-of-create2/[`CREATE2` EVM opcode] for safe use without having to deal with low-level assembly. * {Address}: Collection of functions for overloading Solidity's https://docs.soliditylang.org/en/latest/types.html#address[`address`] type. * {Arrays}: Collection of functions that operate on https://docs.soliditylang.org/en/latest/types.html#arrays[`arrays`]. * {Base64}: On-chain base64 and base64URL encoding according to https://datatracker.ietf.org/doc/html/rfc4648[RFC-4648]. + * {Blockhash}: A library for accessing historical block hashes beyond the standard 256 block limit utilizing EIP-2935's historical blockhash functionality. * {Bytes}: Common operations on bytes objects. + * {CAIP2}, {CAIP10}: Libraries for formatting and parsing CAIP-2 and CAIP-10 identifiers. * {Calldata}: Helpers for manipulating calldata. - * {Strings}: Common operations for strings formatting. + * {Comparators}: A library that contains comparator functions to use with the {Heap} library. + * {Context}: A utility for abstracting the sender and calldata in the current execution context. + * {Create2}: Wrapper around the https://blog.openzeppelin.com/getting-the-most-out-of-create2/[`CREATE2` EVM opcode] for safe use without having to deal with low-level assembly. + * {InteroperableAddress}: Library for formatting and parsing ERC-7930 interoperable addresses. + * {Memory}: A utility library to manipulate memory. + * {Multicall}: Abstract contract with a utility to allow batching together multiple calls in a single transaction. Useful for allowing EOAs to perform multiple operations at once. + * {Packing}: A library for packing and unpacking multiple values into bytes32. + * {Panic}: A library to revert with https://docs.soliditylang.org/en/v0.8.20/control-structures.html#panic-via-assert-and-error-via-require[Solidity panic codes]. + * {RelayedCall}: A library for performing calls that use minimal and predictable relayers to hide the sender. * {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. * {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. - * {TransientSlot}: Primitives for reading from and writing to transient storage (only value types are currently supported). - * {Multicall}: Abstract contract with a utility to allow batching together multiple calls in a single transaction. Useful for allowing EOAs to perform multiple operations at once. - * {Context}: A utility for abstracting the sender and calldata in the current execution context. - * {Packing}: A library for packing and unpacking multiple values into bytes32 - * {Panic}: A library to revert with https://docs.soliditylang.org/en/v0.8.20/control-structures.html#panic-via-assert-and-error-via-require[Solidity panic codes]. - * {Comparators}: A library that contains comparator functions to use with the {Heap} library. - * {CAIP2}, {CAIP10}: Libraries for formatting and parsing CAIP-2 and CAIP-10 identifiers. - * {Memory}: A utility library to manipulate memory. - * {InteroperableAddress}: Library for formatting and parsing ERC-7930 interoperable addresses. - * {Blockhash}: A library for accessing historical block hashes beyond the standard 256 block limit utilizing EIP-2935's historical blockhash functionality. + * {Strings}: Common operations for strings formatting. * {Time}: A library that provides helpers for manipulating time-related objects, including a `Delay` type. + * {TransientSlot}: Primitives for reading from and writing to transient storage (only value types are currently supported). [NOTE] ==== @@ -58,15 +59,15 @@ Because Solidity does not support generic types, {EnumerableMap} and {Enumerable == Security -{{ReentrancyGuard}} +{{Nonces}} -{{ReentrancyGuardTransient}} +{{NoncesKeyed}} {{Pausable}} -{{Nonces}} +{{ReentrancyGuard}} -{{NoncesKeyed}} +{{ReentrancyGuardTransient}} == Introspection @@ -84,15 +85,15 @@ Ethereum contracts have no native concept of an interface, so applications must {{BitMaps}} -{{EnumerableMap}} +{{Checkpoints}} -{{EnumerableSet}} +{{CircularBuffer}} {{DoubleEndedQueue}} -{{CircularBuffer}} +{{EnumerableMap}} -{{Checkpoints}} +{{EnumerableSet}} {{Heap}} @@ -100,46 +101,48 @@ Ethereum contracts have no native concept of an interface, so applications must == Libraries -{{Create2}} - {{Address}} {{Arrays}} {{Base64}} +{{Blockhash}} + {{Bytes}} +{{CAIP10}} + +{{CAIP2}} + {{Calldata}} -{{Strings}} +{{Comparators}} -{{ShortStrings}} +{{Context}} -{{SlotDerivation}} +{{Create2}} -{{StorageSlot}} +{{InteroperableAddress}} -{{TransientSlot}} +{{Memory}} {{Multicall}} -{{Context}} - {{Packing}} {{Panic}} -{{Comparators}} - -{{CAIP2}} +{{RelayedCall}} -{{CAIP10}} +{{ShortStrings}} -{{Memory}} +{{SlotDerivation}} -{{InteroperableAddress}} +{{StorageSlot}} -{{Blockhash}} +{{Strings}} {{Time}} + +{{TransientSlot}} diff --git a/contracts/utils/RelayedCall.sol b/contracts/utils/RelayedCall.sol new file mode 100644 index 00000000000..7ec69ccd200 --- /dev/null +++ b/contracts/utils/RelayedCall.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +/** + * @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 + * calls and ERC-6942 for safe factory interactions. + * + * When privileged contracts need to make arbitrary external calls based on user input, calling the target directly + * can be risky because the target sees the privileged contract as `msg.sender` and could exploit this trust + * relationship. This library solves this by deploying minimal relay contracts that act as intermediaries, ensuring + * the target only sees the unprivileged relay address as `msg.sender`. + * + * For example, instead of `target.call(data)` where the target sees this contract as `msg.sender`, use + * {relayCall} where the target sees a relay address as `msg.sender`. + */ +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); + } + + /// @dev Same as {relayCall} but with a value. + function relayCall(address target, uint256 value, bytes memory data) internal returns (bool, bytes memory) { + 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) { + return relayCall(target, 0, data, salt); + } + + /// @dev Same as {relayCall} but with a salt and a value. + function relayCall( + address target, + uint256 value, + bytes memory data, + bytes32 salt + ) internal returns (bool, bytes memory) { + return getRelayer(salt).call{value: value}(abi.encodePacked(target, data)); + } + + /// @dev Same as {getRelayer} but with a `bytes32(0)` default salt. + function getRelayer() internal returns (address) { + return getRelayer(bytes32(0)); + } + + /// @dev Returns the relayer address for a given salt. + function getRelayer(bytes32 salt) internal returns (address relayer) { + // [Relayer details] + // + // deployment prefix: 60475f8160095f39f3 + // deployed bytecode: 73331460133611166022575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e5f3d91604557fd5bf3 + // + // 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 | 5f | push0 | 0 suc + // 0x003f | 3d | returndatasize | rds 0 suc + // 0x0040 | 91 | swap2 | suc 0 rds + // 0x0041 | 6045 | push1 0x45 | 0x45 suc 0 rds + // 0x0043 | 57 | jumpi | 0 rds + // 0x0044 | fd | revert | + // 0x0045 | 5b | jumpdest | 0 rds + // 0x0046 | f3 | return | + + assembly ("memory-safe") { + let fmp := mload(0x40) + + // build initcode at FMP + mstore(add(fmp, 0x46), 0x60145f375f5f601436035f345f3560601c5af13d5f5f3e5f3d91604557fd5bf3) + mstore(add(fmp, 0x26), 0x331460133611166022575f5ffd5b60143603) + mstore(add(fmp, 0x14), address()) + mstore(add(fmp, 0), 0x60475f8160095f39f373) + let initcodehash := keccak256(add(fmp, 0x16), 0x50) + + // 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), 0x50, salt)) { + returndatacopy(fmp, 0, 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 new file mode 100644 index 00000000000..39d16fcb1c1 --- /dev/null +++ b/test/utils/RelayedCall.test.js @@ -0,0 +1,217 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { impersonate } = require('../helpers/account'); + +async function fixture() { + const [admin, receiver, other] = await ethers.getSigners(); + + const mock = await ethers.deployContract('$RelayedCall'); + const computeRelayerAddress = (salt = ethers.ZeroHash) => + ethers.getCreate2Address( + mock.target, + salt, + ethers.keccak256( + ethers.concat([ + '0x60475f8160095f39f373', + mock.target, + '0x331460133611166022575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e5f3d91604557fd5bf3', + ]), + ), + ); + + const authority = await ethers.deployContract('$AccessManager', [admin]); + const target = await ethers.deployContract('$AccessManagedTarget', [authority]); + + return { mock, target, receiver, other, computeRelayerAddress }; +} + +describe('RelayedCall', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('default (zero) salt', function () { + beforeEach(async function () { + this.relayer = await this.computeRelayerAddress(); + }); + + it('automatic relayer deployment', async function () { + await expect(ethers.provider.getCode(this.relayer)).to.eventually.equal('0x'); + + // First call performs deployment + await expect(this.mock.$getRelayer()).to.emit(this.mock, 'return$getRelayer').withArgs(this.relayer); + + await expect(ethers.provider.getCode(this.relayer)).to.eventually.not.equal('0x'); + + // Following calls use the same relayer + await expect(this.mock.$getRelayer()).to.emit(this.mock, 'return$getRelayer').withArgs(this.relayer); + }); + + describe('relayed call', function () { + it('target success', async function () { + const tx = this.mock.$relayCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('fnUnrestricted', [])), + ); + await expect(tx) + .to.emit(this.target, 'CalledUnrestricted') + .withArgs(this.relayer) + .to.emit(this.mock, 'return$relayCall_address_bytes') + .withArgs(true, '0x'); + }); + + it('target success (with value)', async function () { + const value = 42n; + + 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.emit(this.mock, 'return$relayCall_address_uint256_bytes').withArgs(true, '0x'); + }); + + it('target revert', async function () { + const tx = this.mock.$relayCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('fnRestricted', [])), + ); + + await expect(tx) + .to.emit(this.mock, 'return$relayCall_address_bytes') + .withArgs(false, this.target.interface.encodeErrorResult('AccessManagedUnauthorized', [this.relayer])); + }); + }); + + it('direct call to the relayer', async function () { + // deploy relayer + await this.mock.$getRelayer(); + + // unauthorized caller + await expect( + this.other.sendTransaction({ to: this.relayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525' }), + ).to.be.revertedWithoutReason(); + }); + + it('input format', async function () { + // deploy relayer + await this.mock.$getRelayer(); + + // 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.relayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525' }), + ).to.not.be.reverted; + + // 19 bytes (not enough for an address) - REVERT + await expect( + mockAsWallet.sendTransaction({ to: this.relayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce5' }), + ).to.be.revertedWithoutReason(); + + // 0 bytes (not enough for an address) - REVERT + await expect(mockAsWallet.sendTransaction({ to: this.relayer, 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); + }); + + it('automatic relayer deployment', async function () { + await expect(ethers.provider.getCode(this.relayer)).to.eventually.equal('0x'); + + // First call performs deployment + await expect(this.mock.$getRelayer(ethers.Typed.bytes32(this.salt))) + .to.emit(this.mock, 'return$getRelayer_bytes32') + .withArgs(this.relayer); + + await expect(ethers.provider.getCode(this.relayer)).to.eventually.not.equal('0x'); + + // Following calls use the same relayer + await expect(this.mock.$getRelayer(ethers.Typed.bytes32(this.salt))) + .to.emit(this.mock, 'return$getRelayer_bytes32') + .withArgs(this.relayer); + }); + + describe('relayed call', function () { + it('target success', async function () { + const tx = this.mock.$relayCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('fnUnrestricted', [])), + ethers.Typed.bytes32(this.salt), + ); + await expect(tx) + .to.emit(this.target, 'CalledUnrestricted') + .withArgs(this.relayer) + .to.emit(this.mock, 'return$relayCall_address_bytes_bytes32') + .withArgs(true, '0x'); + }); + + it('target success (with value)', async function () { + const value = 42n; + + 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.emit(this.mock, 'return$relayCall_address_uint256_bytes_bytes32').withArgs(true, '0x'); + }); + + it('target revert', async function () { + const tx = this.mock.$relayCall( + 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$relayCall_address_bytes_bytes32') + .withArgs(false, this.target.interface.encodeErrorResult('AccessManagedUnauthorized', [this.relayer])); + }); + }); + + it('direct call to the relayer', async function () { + // deploy relayer + await this.mock.$getRelayer(ethers.Typed.bytes32(this.salt)); + + // unauthorized caller + await expect( + this.other.sendTransaction({ to: this.relayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525' }), + ).to.be.revertedWithoutReason(); + }); + + it('input format', async function () { + // deploy relayer + await this.mock.$getRelayer(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.relayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525' }), + ).to.not.be.reverted; + + // 19 bytes (not enough for an address) - REVERT + await expect( + mockAsWallet.sendTransaction({ to: this.relayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce5' }), + ).to.be.revertedWithoutReason(); + + // 0 bytes (not enough for an address) - REVERT + await expect(mockAsWallet.sendTransaction({ to: this.relayer, data: '0x' })).to.be.revertedWithoutReason(); + }); + }); +});