diff --git a/contracts/utils/RelayedCall.sol b/contracts/utils/RelayedCall.sol new file mode 100644 index 00000000000..9d7074ce4c7 --- /dev/null +++ b/contracts/utils/RelayedCall.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Address} from "./Address.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 `_relayedCall` that can be used to perform dangerous calls. These calls are relayed + * through a minimal relayer. This relayer is deployed at construction and its address is stored in immutable storage. + */ +abstract contract RelayedCall { + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + /// TODO: should be internal, but hardhat-exposed doesn't expose that correctly in 0.3.19 + address public immutable _relayer = _deployRelayer(); + + function _relayedCallStrict(address target, bytes memory data) internal returns (bytes memory) { + return _relayedCallStrict(target, 0, data); + } + + function _relayedCallStrict(address target, uint256 value, bytes memory data) internal returns (bytes memory) { + (bool success, bytes memory returndata) = _relayedCall(target, value, data); + return Address.verifyCallResult(success, returndata); + } + + function _relayedCall(address target, bytes memory data) internal returns (bool, bytes memory) { + return _relayedCall(target, 0, data); + } + + function _relayedCall(address target, uint256 value, bytes memory data) internal returns (bool, bytes memory) { + return _relayer.call{value: value}(abi.encodePacked(target, data)); + } + + function _deployRelayer() private returns (address addr) { + // deployment prefix: 3d602f80600a3d3981f3 + // deployed bytecode: 60133611600a575f5ffd5b6014360360145f375f5f601436035f345f3560601c5af13d5f5f3e5f3d91602d57fd5bf3 + // + // 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 | + assembly ("memory-safe") { + mstore(0x19, 0x1436035f345f3560601c5af13d5f5f3e5f3d91602d57fd5bf3) + mstore(0x00, 0x3d602f80600a3d3981f360133611600a575f5ffd5b6014360360145f375f5f60) + addr := create2(0, 0, 0x39, 0) + if iszero(addr) { + let ptr := mload(0x40) + returndatacopy(ptr, 0, returndatasize()) + revert(ptr, returndatasize()) + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 2b2d87faee4..5b02d78d2a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "globals": "^16.0.0", "graphlib": "^2.1.8", "hardhat": "^2.22.7", - "hardhat-exposed": "^0.3.15", + "hardhat-exposed": "^0.3.19", "hardhat-gas-reporter": "^2.1.0", "hardhat-ignore-warnings": "^0.2.11", "husky": "^9.1.7", @@ -2734,25 +2734,6 @@ "node": ">=8" } }, - "node_modules/array.prototype.findlast": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.3.tgz", - "integrity": "sha512-kcBubumjciBg4JKp5KTKtI7ec7tRefPk88yjkWJwaVKYd9QfTaxcsOxoMNKd7iBr447zCfDV0z1kOF47umv42g==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array.prototype.flat": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", @@ -5611,13 +5592,14 @@ } }, "node_modules/hardhat-exposed": { - "version": "0.3.15", - "resolved": "https://registry.npmjs.org/hardhat-exposed/-/hardhat-exposed-0.3.15.tgz", - "integrity": "sha512-jqxErCnSWGYf4vAkLmh3H3u+IuLuCLw/EVeV13z1JKJMJAd/iO+G283n8T124S/Q2BF/BoA2zgzYAlqXgNyKew==", + "version": "0.3.19", + "resolved": "https://registry.npmjs.org/hardhat-exposed/-/hardhat-exposed-0.3.19.tgz", + "integrity": "sha512-vVye5TurJu8dWeo4ma+EfLAOQaJyica4uncd0/BGPO2tmexuDwZUmE1vYx81PlP4Iak3wqkNTEPxWQaE2ZnKnw==", "dev": true, + "license": "MIT", "dependencies": { - "micromatch": "^4.0.4", - "solidity-ast": "^0.4.52" + "micromatch": "^4.0.8", + "solidity-ast": "^0.4.59" }, "peerDependencies": { "hardhat": "^2.3.0" @@ -10083,13 +10065,11 @@ } }, "node_modules/solidity-ast": { - "version": "0.4.52", - "resolved": "https://registry.npmjs.org/solidity-ast/-/solidity-ast-0.4.52.tgz", - "integrity": "sha512-iOya9BSiB9jhM8Vf40n8lGELGzwrUc57rl5BhfNtJ5cvAaMvRcNlHeAMNvqJJyjoUnczqRbHqdivEqK89du3Cw==", + "version": "0.4.60", + "resolved": "https://registry.npmjs.org/solidity-ast/-/solidity-ast-0.4.60.tgz", + "integrity": "sha512-UwhasmQ37ji1ul8cIp0XlrQ/+SVQhy09gGqJH4jnwdo2TgI6YIByzi0PI5QvIGcIdFOs1pbSmJW1pnWB7AVh2w==", "dev": true, - "dependencies": { - "array.prototype.findlast": "^1.2.2" - } + "license": "MIT" }, "node_modules/solidity-comments": { "version": "0.0.2", diff --git a/package.json b/package.json index 8282c38430a..75ea8062033 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "globals": "^16.0.0", "graphlib": "^2.1.8", "hardhat": "^2.22.7", - "hardhat-exposed": "^0.3.15", + "hardhat-exposed": "^0.3.19", "hardhat-gas-reporter": "^2.1.0", "hardhat-ignore-warnings": "^0.2.11", "husky": "^9.1.7", diff --git a/test/utils/RelayedCall.test.js b/test/utils/RelayedCall.test.js new file mode 100644 index 00000000000..2426c34bea2 --- /dev/null +++ b/test/utils/RelayedCall.test.js @@ -0,0 +1,62 @@ +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('$RelayedCall'); + const relayer = await mock._relayer(); + + const authority = await ethers.deployContract('$AccessManager', [admin]); + const target = await ethers.deployContract('$AccessManagedTarget', [authority]); + + return { mock, relayer, target, receiver }; +} + +describe('RelayedCall', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('relayed call', function () { + it('target success', async function () { + await expect( + this.mock.$_relayedCallStrict(this.target, this.target.interface.encodeFunctionData('fnUnrestricted', [])), + ) + .to.emit(this.target, 'CalledUnrestricted') + .withArgs(this.relayer); + }); + + it('target success (with value)', async function () { + const value = 42n; + await expect(this.mock.$_relayedCallStrict(this.receiver, value, '0x', { value })).to.changeEtherBalances( + [this.mock, this.relayer, this.receiver], + [0n, 0n, value], + ); + }); + + it('target revert', async function () { + await expect( + this.mock.$_relayedCallStrict(this.target, this.target.interface.encodeFunctionData('fnRestricted', [])), + ) + .to.be.revertedWithCustomError(this.target, 'AccessManagedUnauthorized') + .withArgs(this.relayer); + }); + }); + + it('direct call to the relayer', async function () { + // 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(); + }); +});