diff --git a/contracts/mocks/Stateless.sol b/contracts/mocks/Stateless.sol index 97e79085bfb..b98ecbc4a41 100644 --- a/contracts/mocks/Stateless.sol +++ b/contracts/mocks/Stateless.sol @@ -32,6 +32,7 @@ import {ERC7579Utils} from "../account/utils/draft-ERC7579Utils.sol"; import {ERC7913P256Verifier} from "../utils/cryptography/verifiers/ERC7913P256Verifier.sol"; import {ERC7913RSAVerifier} from "../utils/cryptography/verifiers/ERC7913RSAVerifier.sol"; import {Heap} from "../utils/structs/Heap.sol"; +import {IndirectCall} from "../utils/IndirectCall.sol"; import {InteroperableAddress} from "../utils/draft-InteroperableAddress.sol"; import {Math} from "../utils/math/Math.sol"; import {MerkleProof} from "../utils/cryptography/MerkleProof.sol"; diff --git a/contracts/utils/IndirectCall.sol b/contracts/utils/IndirectCall.sol new file mode 100644 index 00000000000..88baafc5983 --- /dev/null +++ b/contracts/utils/IndirectCall.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Address} from "./Address.sol"; +import {Create2} from "./Create2.sol"; + +/** + * @dev Helper contract for performing potentially dangerous calls through a relay the hide the address of the + * original sender. + * + * Some contract are required to perform arbitrary action controlled by user input. This is dangerous if the contract + * has special permissions, or holds assets. In such cases, using a relay contract can be useful to change the + * msg.sender of the outgoing call. This pattern is used in the ERC-4337 entrypoint that relies on a helper called the + * "senderCreator" when calling account factories. Similarly ERC-6942 does factory calls that could be dangerous if + * performed directly. + * + * This contract provides a `indirectCall` that can be used to perform dangerous calls. These calls are indirect + * through a minimal relayer. + */ +library IndirectCall { + function indirectCall(address target, bytes memory data) internal returns (bool, bytes memory) { + return indirectCall(target, 0, data); + } + + function indirectCall(address target, uint256 value, bytes memory data) internal returns (bool, bytes memory) { + return indirectCall(target, value, data, bytes32(0)); + } + + function indirectCall(address target, bytes memory data, bytes32 salt) internal returns (bool, bytes memory) { + return indirectCall(target, 0, data, salt); + } + + function indirectCall( + address target, + uint256 value, + bytes memory data, + bytes32 salt + ) internal returns (bool, bytes memory) { + return getRelayer(salt).call{value: value}(abi.encodePacked(target, data)); + } + + function getRelayer() internal returns (address) { + return getRelayer(bytes32(0)); + } + + function getRelayer(bytes32 salt) internal returns (address) { + // [Relayer details] + // + // deployment prefix: 3d602f80600a3d3981f3 + // deployed bytecode: 60133611600a575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e5f3d91602d57fd5bf3 + // bytecode hash: 7bc0ea09c689dc0a6de3865d8789dae51a081efcf6569589ddae4b677df5dd3f + // + // offset | bytecode | opcode | stack + // -------|----------|----------------|-------- + // 0x0000 | 6013 | push1 0x13 | 0x13 + // 0x0002 | 36 | calldatasize | cds 0x13 + // 0x0003 | 11 | gt | (cds>0x13) + // 0x0004 | 600a | push1 0x0a | 0x0a (cds>0x13) + // 0x0006 | 57 | jumpi | 0x0a (cds>0x13) + // 0x0007 | 5f | push0 | 0 + // 0x0008 | 5f | push0 | 0 0 + // 0x0009 | fd | revert | + // 0x000a | 5b | jumpdest | + // 0x000b | 6014 | push1 0x14 | 0x14 + // 0x000d | 36 | calldatasize | cds 0x14 + // 0x000e | 03 | sub | (cds-0x14) + // 0x000f | 6014 | push1 0x14 | 0x14 (cds-0x14) + // 0x0011 | 5f | push0 | 0 0x14 (cds-0x14) + // 0x0012 | 37 | calldatacopy | + // 0x0013 | 5f | push0 | 0 + // 0x0014 | 5f | push0 | 0 0 + // 0x0015 | 6014 | push1 0x14 | 0x14 0 0 + // 0x0017 | 36 | calldatasize | cds 0x14 0 0 + // 0x0018 | 03 | sub | (cds-0x14) 0 0 + // 0x0019 | 5f | push0 | 0 (cds-0x14) 0 0 + // 0x001a | 34 | callvalue | value 0 (cds-0x14) 0 0 + // 0x001b | 5f | push0 | 0 value 0 (cds-0x14) 0 0 + // 0x001c | 35 | calldataload | cd[0] value 0 (cds-0x14) 0 0 + // 0x001d | 6060 | push1 0x60 | 0x60 cd[0] value 0 (cds-0x14) 0 0 + // 0x001f | 1c | shr | target value 0 (cds-0x14) 0 0 + // 0x0020 | 5a | gas | gas target value 0 (cds-0x14) 0 0 + // 0x0021 | f1 | call | suc + // 0x0022 | 3d | returndatasize | rds suc + // 0x0023 | 5f | push0 | 0 rds suc + // 0x0024 | 5f | push0 | 0 0 rds suc + // 0x0025 | 3e | returndatacopy | suc + // 0x0026 | 5f | push0 | 0 suc + // 0x0027 | 3d | returndatasize | rds 0 suc + // 0x0028 | 91 | swap2 | suc 0 rds + // 0x0029 | 602d | push1 0x2d | 0x2d suc 0 rds + // 0x002b | 57 | jumpi | 0 rds + // 0x002c | fd | revert | + // 0x002d | 5b | jumpdest | 0 rds + // 0x002e | f3 | return | + + // Create2 address computation, and deploy it if not yet available + address relayer = Create2.computeAddress( + salt, + 0x7bc0ea09c689dc0a6de3865d8789dae51a081efcf6569589ddae4b677df5dd3f + ); + if (relayer.code.length == 0) { + assembly ("memory-safe") { + mstore(0x19, 0x1436035f345f3560601c5af13d5f5f3e5f3d91602d57fd5bf3) + mstore(0x00, 0x3d602f80600a3d3981f360133611600a575f5ffd5b6014360360145f375f5f60) + if iszero(create2(0, 0, 0x39, salt)) { + returndatacopy(0, 0, returndatasize()) + revert(0, returndatasize()) + } + } + } + return relayer; + } +} diff --git a/test/utils/IndirectCall.test.js b/test/utils/IndirectCall.test.js new file mode 100644 index 00000000000..3ee23aa0528 --- /dev/null +++ b/test/utils/IndirectCall.test.js @@ -0,0 +1,180 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +async function fixture() { + const [admin, receiver] = await ethers.getSigners(); + + const mock = await ethers.deployContract('$IndirectCall'); + const computeRelayerAddress = (salt = ethers.ZeroHash) => + ethers.getCreate2Address(mock.target, salt, '0x7bc0ea09c689dc0a6de3865d8789dae51a081efcf6569589ddae4b677df5dd3f'); + + const authority = await ethers.deployContract('$AccessManager', [admin]); + const target = await ethers.deployContract('$AccessManagedTarget', [authority]); + + return { mock, target, receiver, computeRelayerAddress }; +} + +describe('IndirectCall', 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.$indirectCall( + 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$indirectCall_address_bytes') + .withArgs(true, '0x'); + }); + + it('target success (with value)', async function () { + const value = 42n; + + const tx = this.mock.$indirectCall( + 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$indirectCall_address_uint256_bytes').withArgs(true, '0x'); + }); + + it('target revert', async function () { + const tx = this.mock.$indirectCall( + ethers.Typed.address(this.target), + ethers.Typed.bytes(this.target.interface.encodeFunctionData('fnRestricted', [])), + ); + + await expect(tx) + .to.emit(this.mock, 'return$indirectCall_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(); + + // 20 bytes (address + empty data) - OK + await expect( + this.mock.runner.sendTransaction({ to: this.relayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525' }), + ).to.not.be.reverted; + + // 19 bytes (not enough for an address) - REVERT + await expect( + this.mock.runner.sendTransaction({ to: this.relayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce5' }), + ).to.be.revertedWithoutReason(); + + // 0 bytes (not enough for an address) - REVERT + await expect(this.mock.runner.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.$indirectCall( + 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$indirectCall_address_bytes_bytes32') + .withArgs(true, '0x'); + }); + + it('target success (with value)', async function () { + const value = 42n; + + const tx = this.mock.$indirectCall( + 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$indirectCall_address_uint256_bytes_bytes32').withArgs(true, '0x'); + }); + + it('target revert', async function () { + const tx = this.mock.$indirectCall( + 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$indirectCall_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)); + + // 20 bytes (address + empty data) - OK + await expect( + this.mock.runner.sendTransaction({ to: this.relayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce525' }), + ).to.not.be.reverted; + + // 19 bytes (not enough for an address) - REVERT + await expect( + this.mock.runner.sendTransaction({ to: this.relayer, data: '0x7859821024E633C5dC8a4FcF86fC52e7720Ce5' }), + ).to.be.revertedWithoutReason(); + + // 0 bytes (not enough for an address) - REVERT + await expect(this.mock.runner.sendTransaction({ to: this.relayer, data: '0x' })).to.be.revertedWithoutReason(); + }); + }); +});