diff --git a/abi/generated.ts b/abi/generated.ts index e0637bf3..010f2f41 100644 --- a/abi/generated.ts +++ b/abi/generated.ts @@ -362,6 +362,327 @@ export const guardedMulticallerAbi = [ }, ] as const +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// GuardedMulticaller2 +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const guardedMulticaller2Abi = [ + { + type: 'constructor', + inputs: [ + { name: '_owner', internalType: 'address', type: 'address' }, + { name: '_name', internalType: 'string', type: 'string' }, + { name: '_version', internalType: 'string', type: 'string' }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'DEFAULT_ADMIN_ROLE', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'MULTICALL_SIGNER_ROLE', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'eip712Domain', + outputs: [ + { name: 'fields', internalType: 'bytes1', type: 'bytes1' }, + { name: 'name', internalType: 'string', type: 'string' }, + { name: 'version', internalType: 'string', type: 'string' }, + { name: 'chainId', internalType: 'uint256', type: 'uint256' }, + { name: 'verifyingContract', internalType: 'address', type: 'address' }, + { name: 'salt', internalType: 'bytes32', type: 'bytes32' }, + { name: 'extensions', internalType: 'uint256[]', type: 'uint256[]' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: '_multicallSigner', internalType: 'address', type: 'address' }, + { name: '_reference', internalType: 'bytes32', type: 'bytes32' }, + { + name: '_calls', + internalType: 'struct GuardedMulticaller2.Call[]', + type: 'tuple[]', + components: [ + { name: 'target', internalType: 'address', type: 'address' }, + { name: 'functionSignature', internalType: 'string', type: 'string' }, + { name: 'data', internalType: 'bytes', type: 'bytes' }, + ], + }, + { name: '_deadline', internalType: 'uint256', type: 'uint256' }, + { name: '_signature', internalType: 'bytes', type: 'bytes' }, + ], + name: 'execute', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'role', internalType: 'bytes32', type: 'bytes32' }], + name: 'getRoleAdmin', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_user', internalType: 'address', type: 'address' }], + name: 'grantMulticallSignerRole', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'role', internalType: 'bytes32', type: 'bytes32' }, + { name: 'account', internalType: 'address', type: 'address' }, + ], + name: 'grantRole', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '_reference', internalType: 'bytes32', type: 'bytes32' }], + name: 'hasBeenExecuted', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'role', internalType: 'bytes32', type: 'bytes32' }, + { name: 'account', internalType: 'address', type: 'address' }, + ], + name: 'hasRole', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'role', internalType: 'bytes32', type: 'bytes32' }, + { name: 'account', internalType: 'address', type: 'address' }, + ], + name: 'renounceRole', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '_user', internalType: 'address', type: 'address' }], + name: 'revokeMulticallSignerRole', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'role', internalType: 'bytes32', type: 'bytes32' }, + { name: 'account', internalType: 'address', type: 'address' }, + ], + name: 'revokeRole', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'interfaceId', internalType: 'bytes4', type: 'bytes4' }], + name: 'supportsInterface', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'view', + }, + { type: 'event', anonymous: false, inputs: [], name: 'EIP712DomainChanged' }, + { + type: 'event', + anonymous: false, + inputs: [ + { + name: '_multicallSigner', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: '_reference', + internalType: 'bytes32', + type: 'bytes32', + indexed: true, + }, + { + name: '_calls', + internalType: 'struct GuardedMulticaller2.Call[]', + type: 'tuple[]', + components: [ + { name: 'target', internalType: 'address', type: 'address' }, + { name: 'functionSignature', internalType: 'string', type: 'string' }, + { name: 'data', internalType: 'bytes', type: 'bytes' }, + ], + indexed: false, + }, + { + name: '_deadline', + internalType: 'uint256', + type: 'uint256', + indexed: false, + }, + ], + name: 'Multicalled', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'role', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { + name: 'previousAdminRole', + internalType: 'bytes32', + type: 'bytes32', + indexed: true, + }, + { + name: 'newAdminRole', + internalType: 'bytes32', + type: 'bytes32', + indexed: true, + }, + ], + name: 'RoleAdminChanged', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'role', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { + name: 'account', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'sender', + internalType: 'address', + type: 'address', + indexed: true, + }, + ], + name: 'RoleGranted', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'role', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { + name: 'account', + internalType: 'address', + type: 'address', + indexed: true, + }, + { + name: 'sender', + internalType: 'address', + type: 'address', + indexed: true, + }, + ], + name: 'RoleRevoked', + }, + { type: 'error', inputs: [], name: 'EmptyCallArray' }, + { + type: 'error', + inputs: [{ name: '_deadline', internalType: 'uint256', type: 'uint256' }], + name: 'Expired', + }, + { + type: 'error', + inputs: [ + { + name: '_call', + internalType: 'struct GuardedMulticaller2.Call', + type: 'tuple', + components: [ + { name: 'target', internalType: 'address', type: 'address' }, + { name: 'functionSignature', internalType: 'string', type: 'string' }, + { name: 'data', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + name: 'FailedCall', + }, + { + type: 'error', + inputs: [ + { + name: '_call', + internalType: 'struct GuardedMulticaller2.Call', + type: 'tuple', + components: [ + { name: 'target', internalType: 'address', type: 'address' }, + { name: 'functionSignature', internalType: 'string', type: 'string' }, + { name: 'data', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + name: 'InvalidFunctionSignature', + }, + { + type: 'error', + inputs: [{ name: '_reference', internalType: 'bytes32', type: 'bytes32' }], + name: 'InvalidReference', + }, + { type: 'error', inputs: [], name: 'InvalidShortString' }, + { + type: 'error', + inputs: [ + { + name: '_call', + internalType: 'struct GuardedMulticaller2.Call', + type: 'tuple', + components: [ + { name: 'target', internalType: 'address', type: 'address' }, + { name: 'functionSignature', internalType: 'string', type: 'string' }, + { name: 'data', internalType: 'bytes', type: 'bytes' }, + ], + }, + ], + name: 'NonContractAddress', + }, + { + type: 'error', + inputs: [{ name: '_reference', internalType: 'bytes32', type: 'bytes32' }], + name: 'ReusedReference', + }, + { + type: 'error', + inputs: [{ name: 'str', internalType: 'string', type: 'string' }], + name: 'StringTooLong', + }, + { + type: 'error', + inputs: [{ name: '_signature', internalType: 'bytes', type: 'bytes' }], + name: 'UnauthorizedSignature', + }, + { + type: 'error', + inputs: [ + { name: '_multicallSigner', internalType: 'address', type: 'address' }, + ], + name: 'UnauthorizedSigner', + }, +] as const + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // ImmutableERC1155 ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/abi/index.ts b/abi/index.ts index 1230252d..8242d7f6 100644 --- a/abi/index.ts +++ b/abi/index.ts @@ -4,6 +4,7 @@ import { immutableErc721MintByIdAbi as ImmutableERC721MintByIdAbi, immutableErc1155Abi as ImmutableERC1155Abi, paymentSplitterAbi as PaymentSplitterAbi, + guardedMulticaller2Abi as GuardedMulticaller2Abi, } from "./generated"; export { @@ -12,4 +13,5 @@ export { ImmutableERC721MintByIdAbi, ImmutableERC1155Abi, PaymentSplitterAbi, + GuardedMulticaller2Abi, }; diff --git a/audits/multicall/202408-threat-model-multicaller.md b/audits/multicall/202408-threat-model-multicaller.md new file mode 100644 index 00000000..f3cfe85a --- /dev/null +++ b/audits/multicall/202408-threat-model-multicaller.md @@ -0,0 +1,86 @@ +# Background + +The Guarded Multi-Caller contract is a security enhanced multi-caller contract. The Multicall contract pattern allows multiple smart contract function calls to be grouped into a single call. This provides the guarantee that function calls are executed atomically in a single transaction - they either all succeed or they all revert. + +Additionally, this has the benefit that it reduces the number of JSON RPC requests that need to be sent to the blockchain's RPC provider. The security enhancements offered by the Guarded Multi-caller system are described in the sections below. + +# Architecture + +## Contract High-Level Design + +The core of the `Guarded Multi-caller` system is `Multi-call`, allowing for the minting and burning of multiple NFTs from various collections in a single transaction. Due to the high level of security needed when working with NFTs, additional safety measures have been implemented to meet security standards. + +- `Signature Validation` prevents multi-call instructions from random parties and only allows multi-call instructions from trusted parties. +- `Signer Access Control` manages these trusted parties. +- `References` provide anti-replay protection and help sync with any web2 system listening to events. + +## System High-Level Design +--- +![alt text](202309-threat-model-multicaller/architecture.png "Architecture") +### Components +--- +| Component | Ownership | Description | +|------------------------------ |----------- |------------------------------------------------------------------------------------------------------------------------- | +| Client | Customer | This can be a game client or a mobile client that the players interact with. | +| Central Authority | Customer | It generates a list of function calls to be executed and gets a valid signature for those calls from `Multi-call Signer`. | +| Multi-call Signer | Customer | It takes a list of function calls and generates a valid signature using a `EOA` with `MULTICALL_SIGNER_ROLE`. | +| Guarded Multicaller Contract | Customer | It validates an input signature and executes an authorized list of function calls. | + +### Flow +--- +Let’s look at the flow for basic crafting, where players burn one NFT from the `ERC721Card` contract and mint a new NFT on the `ERC721Pet` contract: + +1. An `EOA` with `DEFAULT_ADMIN_ROLE` calls the `Guarded Multi-caller` contract to permit mint function `mint(address,uint256)` with the `ERC721Pet` contract. +2. An `EOA` with `DEFAULT_ADMIN_ROLE` calls the `ERC721Pet` contract to grant `MINTER_ROLE` to the `Guarded Multi-caller` contract. +3. A client requests the `Central Authority` to generate a list of function calls to burn and mint and request a signature from the `Multi-call Signer`. +4. The `Multi-call Signer` uses an account with `MULTICALL_SIGNER_ROLE` to sign the function calls and returns the signature back to the `Central Authority`. +5. The `Central Authority` returns the list of function calls and the signature to the client. +6. The `Client` approves the `Guarded Multi-caller` contract as a spender for their `ERC721Card` NFT. +7. The `Client` submits a transaction to the `Guarded Multi-caller` contract to execute the list of function calls. +8. The `GuardedMulticaller` contract calls the `ERC721Card` contract to burn the NFT and calls the `ERC721Pet` contract to mint a new NFT to the player’s wallet. + +# Attack Surfaces + +## Compromised Admin Keys +The compromised admin keys are able to assign the `MULTICALL_SIGNER_ROLE` to malicious parties and allow them to generate signatures that are valid to invoke function calls that are damaging, e.g. minting tokens on ERC20 contracts to attackers' addresses, or burning tokens from wallets that grant the contract approvals to their tokens. + +## Compromised Signer Keys +The compromised signer keys can generate signatures that are valid to invoke function calls that are damaging, e.g. minting tokens on ERC20 contracts to attackers' addresses, or burning tokens from wallets that grant the contract approvals to their tokens. + +# Attack Mitigation + +- The keys associated with the `DEFAULT_ADMIN_ROLE` and `MULTICALL_SIGNER_ROLE` should be operated by a secure manner, for example a multi-signature wallet such that an attacker would need to compromise multiple signers simultaneously, or a securely stored hardware wallet. +- If admin keys are compromised, admins of token contracts should revoke `MINTER_ROLE` granted on the Guarded Multi-caller contract. +- If signer keys are compromised, admins of Guarded Multi-caller contracts should revoke the signer keys. +- If users grant access to their tokens, they should only grant approvals to the tokens needed for the multi-call transaction. If users have to grant access to all of their tokens, they should revoke the access immediately after the multi-call transaction is completed. With smart contract wallet's batch transaction, users can batch approvals, multi-call transaction, or approval revocation in one batch transaction. +- If the last account with DEFAULT_ADMIN_ROLE accidentally renounces or revokes their role, the contract becomes permissionless, and admins can no longer grant/revoke accesses. The following should happen: + - Accounts with `MULTICALL_SIGNER_ROLE` should renounce their roles + - A new Guarded Multi-caller contract should be deployed. + - All roles/accesses granted to the contract should be revoked. + +# Functions + +Functions that _change_ state: +| Name | Function Selector | Access Control | +| ------------------------------------------------------------- | ----------------- | --------------------- | +| execute(address,bytes32,(address,string,bytes)[],uint256,bytes) | 29d4244c | Caller must have a valid signature | +| grantMulticallSignerRole(address) | 5910fd78 | DEFAULT_ADMIN_ROLE | +| grantRole(bytes32,address) | 2f2ff15d | DEFAULT_ADMIN_ROLE | +| renounceRole(bytes32,address) | 36568abe | Caller must have role to be renounced | +| revokeMulticallSignerRole(address) | 8f8d6ba0 | DEFAULT_ADMIN_ROLE | +| revokeRole(bytes32,address) | d547741f | DEFAULT_ADMIN_ROLE | + +Functions that _do not change_ state (they are all permissionless): +| Name | Function Selector | +| ------------------------------------------------------------- | ----------------- | +| DEFAULT_ADMIN_ROLE() | a217fddf | +| eip712Domain() | 84b0196e | +| getRoleAdmin(bytes32) | 248a9ca3 | +| hasBeenExecuted(bytes32) | 3ddd8215 | +| hasRole(bytes32,address) | 91d14854 | +| supportsInterface(bytes4) | 01ffc9a7 | +| MULTICALL_SIGNER_ROLE() | 953ff95b | + +## Tests + +`forge test` will run all the related tests. diff --git a/contracts/mocks/MockFunctions.sol b/contracts/mocks/MockFunctions.sol index 1d4eacec..c0e2b6ad 100644 --- a/contracts/mocks/MockFunctions.sol +++ b/contracts/mocks/MockFunctions.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.19; contract MockFunctions { + error RevertWithData(uint256 value); + // solhint-disable-next-line no-empty-blocks function succeed() public pure { // This function is intentionally left empty to simulate a successful call @@ -17,4 +19,13 @@ contract MockFunctions { function nonPermitted() public pure { // This function is intentionally left empty to simulate a non-permitted action } + + function succeedWithUint256(uint256 value) public pure returns (uint256) { + return value; + } + + function revertWithData(uint256 value) public pure { + // solhint-disable-next-line custom-errors,reason-string + revert RevertWithData(value); + } } diff --git a/contracts/multicall/GuardedMulticaller2.sol b/contracts/multicall/GuardedMulticaller2.sol new file mode 100644 index 00000000..46d901a0 --- /dev/null +++ b/contracts/multicall/GuardedMulticaller2.sol @@ -0,0 +1,239 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +// Signature Validation +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; + +// Access Control +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; + +// Reentrancy Guard +import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; + +// EIP-712 Typed Structs +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; + +/** + * + * @title GuardedMulticaller2 contract + * @author Immutable + * @notice This contract is used to batch calls to other contracts. + * @dev This contract is not designed to be upgradeable. If an issue is found with this contract, + * a new version will be deployed. All approvals granted to this contract will be revoked before + * a new version is deployed. Approvals will be granted to the new contract. + */ +contract GuardedMulticaller2 is AccessControl, ReentrancyGuard, EIP712 { + /// @dev Struct for call data + struct Call { + address target; + string functionSignature; + bytes data; + } + + /// @dev Mapping of reference to executed status + mapping(bytes32 ref => bool executed) private replayProtection; + + /// @dev Only those with MULTICALL_SIGNER_ROLE can generate valid signatures for execute function. + bytes32 public constant MULTICALL_SIGNER_ROLE = bytes32("MULTICALL_SIGNER_ROLE"); + + /// @dev EIP712 typehash for call + bytes32 internal constant CALL_TYPEHASH = keccak256("Call(address target,string functionSignature,bytes data)"); + + /// @dev EIP712 typehash for execute function + bytes32 internal constant MULTICALL_TYPEHASH = + keccak256( + "Multicall(bytes32 ref,Call[] call,uint256 deadline)Call(address target,string functionSignature,bytes data)" + ); + + /// @dev Event emitted when execute function is called + event Multicalled(address indexed _multicallSigner, bytes32 indexed _reference, Call[] _calls, uint256 _deadline); + + /// @dev Error thrown when reference is invalid + error InvalidReference(bytes32 _reference); + + /// @dev Error thrown when reference has already been executed + error ReusedReference(bytes32 _reference); + + /// @dev Error thrown when deadline is expired + error Expired(uint256 _deadline); + + /// @dev Error thrown when signer is not authorized + error UnauthorizedSigner(address _multicallSigner); + + /// @dev Error thrown when signature is invalid + error UnauthorizedSignature(bytes _signature); + + /// @dev Error thrown when call array is empty + error EmptyCallArray(); + + /// @dev Error thrown when call reverts + error FailedCall(Call _call, bytes _returnData); + + /// @dev Error thrown when target address is not a contract + error NonContractAddress(Call _call); + + /// @dev Error thrown when function signature is invalid + error InvalidFunctionSignature(Call _call); + + /** + * + * @notice Grants DEFAULT_ADMIN_ROLE to the contract creator + * @param _owner Owner of the contract + * @param _name Name of the contract + * @param _version Version of the contract + */ + // solhint-disable-next-line no-unused-vars + constructor(address _owner, string memory _name, string memory _version) EIP712(_name, _version) { + _grantRole(DEFAULT_ADMIN_ROLE, _owner); + } + + /** + * + * @notice Execute a list of calls. Returned data from calls are ignored. + * The signature must be generated by an address with MULTICALL_SIGNER_ROLE + * The signature must be valid + * The signature must not be expired + * The reference must be unique + * The reference must not be executed before + * The list of calls must not be empty + * The list of calls is executed in order + * + * @param _multicallSigner Address of an approved signer + * @param _reference Reference + * @param _calls List of calls + * @param _deadline Expiration timestamp + * @param _signature Signature of the multicall signer + */ + // slither-disable-start low-level-calls,cyclomatic-complexity + // solhint-disable-next-line code-complexity + function execute( + address _multicallSigner, + bytes32 _reference, + Call[] calldata _calls, + uint256 _deadline, + bytes calldata _signature + ) external nonReentrant { + // solhint-disable-next-line not-rely-on-time + if (_deadline < block.timestamp) { + revert Expired(_deadline); + } + if (_reference == 0) { + revert InvalidReference(_reference); + } + if (replayProtection[_reference]) { + revert ReusedReference(_reference); + } + if (_calls.length == 0) { + revert EmptyCallArray(); + } + for (uint256 i = 0; i < _calls.length; i++) { + if (bytes(_calls[i].functionSignature).length == 0) { + revert InvalidFunctionSignature(_calls[i]); + } + if (_calls[i].target.code.length == 0) { + revert NonContractAddress(_calls[i]); + } + } + if (!hasRole(MULTICALL_SIGNER_ROLE, _multicallSigner)) { + revert UnauthorizedSigner(_multicallSigner); + } + + // Signature validation + if ( + !SignatureChecker.isValidSignatureNow( + _multicallSigner, + _hashTypedData(_reference, _calls, _deadline), + _signature + ) + ) { + revert UnauthorizedSignature(_signature); + } + + replayProtection[_reference] = true; + + // Multicall + for (uint256 i = 0; i < _calls.length; i++) { + bytes4 functionSelector = bytes4(keccak256(bytes(_calls[i].functionSignature))); + bytes memory callData = abi.encodePacked(functionSelector, _calls[i].data); + // solhint-disable avoid-low-level-calls + // slither-disable-next-line calls-loop + (bool success, bytes memory returnData) = _calls[i].target.call(callData); + if (!success) { + // Look for revert reason and bubble it up if present + if (returnData.length < 4) { + revert FailedCall(_calls[i], returnData); + } + // solhint-disable-next-line no-inline-assembly + assembly { + // The easiest way to bubble the revert reason is using memory via assembly + revert(add(returnData, 32), mload(returnData)) + } + } + } + + emit Multicalled(_multicallSigner, _reference, _calls, _deadline); + } + + // slither-disable-end low-level-calls,cyclomatic-complexity + + /** + * @notice Grants MULTICALL_SIGNER_ROLE to a user. Only DEFAULT_ADMIN_ROLE can call this function. + * + * @param _user User to grant MULTICALL_SIGNER_ROLE to + */ + function grantMulticallSignerRole(address _user) external onlyRole(DEFAULT_ADMIN_ROLE) { + grantRole(MULTICALL_SIGNER_ROLE, _user); + } + + /** + * @notice Revokes MULTICALL_SIGNER_ROLE for a user. Only DEFAULT_ADMIN_ROLE can call this function. + * + * @param _user User to grant MULTICALL_SIGNER_ROLE to + */ + function revokeMulticallSignerRole(address _user) external onlyRole(DEFAULT_ADMIN_ROLE) { + revokeRole(MULTICALL_SIGNER_ROLE, _user); + } + + /** + * @notice Gets whether the reference has been executed before. + * + * @param _reference Reference to check + */ + function hasBeenExecuted(bytes32 _reference) external view returns (bool) { + return replayProtection[_reference]; + } + + /** + * + * @dev Returns hash of array of calls + * + * @param _calls Array of calls + */ + function _hashCallArray(Call[] calldata _calls) internal pure returns (bytes32) { + bytes32[] memory hashedCallArr = new bytes32[](_calls.length); + for (uint256 i = 0; i < _calls.length; i++) { + hashedCallArr[i] = keccak256( + abi.encode(CALL_TYPEHASH, _calls[i].target, _calls[i].functionSignature, _calls[i].data) + ); + } + return keccak256(abi.encode(hashedCallArr)); + } + + /** + * + * @dev Returns EIP712 message hash for given parameters + * + * @param _reference Reference + * @param _calls List of calls + * @param _deadline Expiration timestamp + */ + function _hashTypedData( + bytes32 _reference, + Call[] calldata _calls, + uint256 _deadline + ) internal view returns (bytes32) { + return + _hashTypedDataV4(keccak256(abi.encode(MULTICALL_TYPEHASH, _reference, _hashCallArray(_calls), _deadline))); + } +} diff --git a/contracts/multicall/README.md b/contracts/multicall/README.md index 1a53f8f1..dd86f380 100644 --- a/contracts/multicall/README.md +++ b/contracts/multicall/README.md @@ -1,4 +1,33 @@ -# GuardedMulticaller +# GuardedMulticaller2 + +The GuardedMulticaller2 is a signatured-based multi-call contract. It provides functionality to call multiple functions across different target contracts, the function signatures are validated to ensure they are permitted. In use cases such as crafting and Primary sales, the GuardedMulticaller2 executes mint, burn, or transfer functions on different target contracts in a single transaction. + +### Features + +- Signature validation: Only approved signers can authorise the `execute` on the multicall contract. +- Expiry: Ability to set an expiry for the multicall. +- References: Map multicall executions to a reference string to be used by the application. + +# Status + +Contract audits and threat models: + +| Description | Date |Version Audited | Link to Report | +|---------------------------|------------------|-----------------|----------------| +| V2 Threat Model | | --- | [202408-threat-model-multicaller](../../audits/multicall/202408-threat-model-multicaller.md) | +| V1 Threat Model | Sept 26, 2023 | --- | [202309-threat-model-multicaller](../../audits/multicall/202309-threat-model-multicaller.md) | +| V1 External audit | Sept 26, 2023 | [e59b72a](https://github.com/immutable/contracts/blob/e59b72a69294bd6d5857a1e2d019044bbfb14632/contracts/multicall) | [202309-external-audit-multicaller](../../audits/multicall/202309-external-audit-multicaller.pdf) | + + +# Architecture + +The architecture of the GuardedMulticaller system is shown below. + +![GuardedMulticaller Architecture](../../audits/multicall/202309-threat-model-multicaller/architecture.png) + +--- +--- +# GuardedMulticaller [DEPRECATED] The GuardedMulticaller contract provides functionality to call multiple functions across different target contracts, the function signatures are validated to ensure they are permitted. Currently one of the use cases we have is in the Primary Sales flow, the GuardedMulticaller executes `transferFrom()` and `safeMint()` functions on different target contracts in a single transaction. diff --git a/test/multicall/GuardedMulticaller2.t.sol b/test/multicall/GuardedMulticaller2.t.sol new file mode 100644 index 00000000..7e73a6f9 --- /dev/null +++ b/test/multicall/GuardedMulticaller2.t.sol @@ -0,0 +1,281 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity 0.8.19; + +import "forge-std/Test.sol"; +import {GuardedMulticaller2} from "../../contracts/multicall/GuardedMulticaller2.sol"; +import {MockFunctions} from "../../contracts/mocks/MockFunctions.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {SigUtils} from "./SigUtils.t.sol"; + +contract GuardedMulticaller2Test is Test { + GuardedMulticaller2 gmc; + SigUtils sigUtils; + MockFunctions target; + MockFunctions target1; + + address defaultAdmin = makeAddr("defaultAdmin"); + address signer; + uint256 signerPk; + + event Multicalled( + address indexed _multicallSigner, + bytes32 indexed _reference, + GuardedMulticaller2.Call[] _calls, + uint256 _deadline + ); + + function setUp() public { + target = new MockFunctions(); + target1 = new MockFunctions(); + (signer, signerPk) = makeAddrAndKey("signer"); + + gmc = new GuardedMulticaller2(defaultAdmin, "name", "1"); + vm.prank(defaultAdmin); + gmc.grantMulticallSignerRole(signer); + + sigUtils = new SigUtils("name", "1", address(gmc)); + } + + function test_Roles() public { + assertTrue(gmc.hasRole(gmc.DEFAULT_ADMIN_ROLE(), defaultAdmin)); + assertTrue(gmc.hasRole(gmc.MULTICALL_SIGNER_ROLE(), signer)); + } + + function test_Execute() public { + bytes32 ref = keccak256("ref"); + uint256 deadline = block.timestamp + 1; + GuardedMulticaller2.Call[] memory calls = new GuardedMulticaller2.Call[](3); + calls[0] = GuardedMulticaller2.Call( + address(target), + "succeedWithUint256(uint256)", + abi.encodePacked(uint256(42)) + ); + calls[1] = GuardedMulticaller2.Call(address(target), "succeed()", ""); + calls[2] = GuardedMulticaller2.Call( + address(target1), + "succeedWithUint256(uint256)", + abi.encodePacked(uint256(42)) + ); + + bytes32 digest = sigUtils.hashTypedData(ref, calls, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.expectCall(address(target), abi.encodeCall(target.succeedWithUint256, (uint256(42)))); + vm.expectCall(address(target), abi.encodeCall(target.succeed, ())); + vm.expectCall(address(target1), abi.encodeCall(target1.succeedWithUint256, (uint256(42)))); + vm.expectEmit(true, true, false, true, address(gmc)); + emit Multicalled(signer, ref, calls, deadline); + + gmc.execute(signer, ref, calls, deadline, signature); + + assertTrue(gmc.hasBeenExecuted(ref)); + } + + function test_RevertWhen_ExecuteExpired() public { + bytes32 ref = keccak256("ref"); + uint256 deadline = block.timestamp - 1; + GuardedMulticaller2.Call[] memory calls = new GuardedMulticaller2.Call[](1); + calls[0] = GuardedMulticaller2.Call( + address(target), + "succeedWithUint256(uint256)", + abi.encodePacked(uint256(42)) + ); + + bytes32 digest = sigUtils.hashTypedData(ref, calls, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.expectRevert(abi.encodeWithSelector(GuardedMulticaller2.Expired.selector, deadline)); + + gmc.execute(signer, ref, calls, deadline, signature); + } + + function test_RevertWhen_ExecuteInvalidReference() public { + bytes32 ref = ""; + uint256 deadline = block.timestamp + 1; + GuardedMulticaller2.Call[] memory calls = new GuardedMulticaller2.Call[](1); + calls[0] = GuardedMulticaller2.Call( + address(target), + "succeedWithUint256(uint256)", + abi.encodePacked(uint256(42)) + ); + + bytes32 digest = sigUtils.hashTypedData(ref, calls, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.expectRevert(abi.encodeWithSelector(GuardedMulticaller2.InvalidReference.selector, ref)); + + gmc.execute(signer, ref, calls, deadline, signature); + } + + function test_RevertWhen_ExecuteReusedReference() public { + bytes32 ref = "ref"; + uint256 deadline = block.timestamp + 1; + GuardedMulticaller2.Call[] memory calls = new GuardedMulticaller2.Call[](1); + calls[0] = GuardedMulticaller2.Call( + address(target), + "succeedWithUint256(uint256)", + abi.encodePacked(uint256(42)) + ); + + bytes32 digest = sigUtils.hashTypedData(ref, calls, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + gmc.execute(signer, ref, calls, deadline, signature); + + vm.expectRevert(abi.encodeWithSelector(GuardedMulticaller2.ReusedReference.selector, ref)); + gmc.execute(signer, ref, calls, deadline, signature); + } + + function test_RevertWhen_ExecuteEmptyCallArray() public { + bytes32 ref = "ref"; + uint256 deadline = block.timestamp + 1; + GuardedMulticaller2.Call[] memory calls = new GuardedMulticaller2.Call[](0); + + bytes32 digest = sigUtils.hashTypedData(ref, calls, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.expectRevert(GuardedMulticaller2.EmptyCallArray.selector); + + gmc.execute(signer, ref, calls, deadline, signature); + } + + function test_RevertWhen_ExecuteNonContractAddress() public { + bytes32 ref = "ref"; + uint256 deadline = block.timestamp + 1; + GuardedMulticaller2.Call[] memory calls = new GuardedMulticaller2.Call[](1); + calls[0] = GuardedMulticaller2.Call(address(0), "succeedWithUint256(uint256)", abi.encodePacked(uint256(42))); + + bytes32 digest = sigUtils.hashTypedData(ref, calls, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.expectRevert(abi.encodeWithSelector(GuardedMulticaller2.NonContractAddress.selector, calls[0])); + + gmc.execute(signer, ref, calls, deadline, signature); + } + + function test_RevertWhen_ExecuteUnauthorizedSigner() public { + (address fakeSigner, uint256 fakeSignerPk) = makeAddrAndKey("fakeSigner"); + bytes32 ref = keccak256("ref"); + uint256 deadline = block.timestamp + 1; + GuardedMulticaller2.Call[] memory calls = new GuardedMulticaller2.Call[](1); + calls[0] = GuardedMulticaller2.Call( + address(target), + "succeedWithUint256(uint256)", + abi.encodePacked(uint256(42)) + ); + + bytes32 digest = sigUtils.hashTypedData(ref, calls, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(fakeSignerPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.expectRevert(abi.encodeWithSelector(GuardedMulticaller2.UnauthorizedSigner.selector, fakeSigner)); + + gmc.execute(fakeSigner, ref, calls, deadline, signature); + } + + function test_RevertWhen_ExecuteUnauthorizedSignature() public { + (, uint256 fakeSignerPk) = makeAddrAndKey("fakeSigner"); + bytes32 ref = keccak256("ref"); + uint256 deadline = block.timestamp + 1; + GuardedMulticaller2.Call[] memory calls = new GuardedMulticaller2.Call[](1); + calls[0] = GuardedMulticaller2.Call( + address(target), + "succeedWithUint256(uint256)", + abi.encodePacked(uint256(42)) + ); + + bytes32 digest = sigUtils.hashTypedData(ref, calls, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(fakeSignerPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.expectRevert(abi.encodeWithSelector(GuardedMulticaller2.UnauthorizedSignature.selector, signature)); + + gmc.execute(signer, ref, calls, deadline, signature); + } + + function test_RevertWhen_ExecuteFailedCall() public { + bytes32 ref = keccak256("ref"); + uint256 deadline = block.timestamp + 1; + GuardedMulticaller2.Call[] memory calls = new GuardedMulticaller2.Call[](1); + calls[0] = GuardedMulticaller2.Call(address(target), "revertWithNoReason()", ""); + + bytes32 digest = sigUtils.hashTypedData(ref, calls, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.expectRevert(abi.encodeWithSelector(GuardedMulticaller2.FailedCall.selector, calls[0], "")); + + gmc.execute(signer, ref, calls, deadline, signature); + } + + function test_RevertWhen_ExecuteRevokeMinterRole() public { + bytes32 ref = keccak256("ref"); + uint256 deadline = block.timestamp + 1; + GuardedMulticaller2.Call[] memory calls = new GuardedMulticaller2.Call[](1); + calls[0] = GuardedMulticaller2.Call( + address(target), + "succeedWithUint256(uint256)", + abi.encodePacked(uint256(42)) + ); + + bytes32 digest = sigUtils.hashTypedData(ref, calls, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + gmc.execute(signer, ref, calls, deadline, signature); + + vm.startPrank(defaultAdmin); + gmc.revokeMulticallSignerRole(signer); + vm.stopPrank(); + + bytes32 ref1 = keccak256("ref1"); + bytes32 digest1 = sigUtils.hashTypedData(ref1, calls, deadline); + (v, r, s) = vm.sign(signerPk, digest1); + bytes memory signature1 = abi.encodePacked(r, s, v); + + vm.expectRevert(abi.encodeWithSelector(GuardedMulticaller2.UnauthorizedSigner.selector, signer)); + gmc.execute(signer, ref1, calls, deadline, signature1); + bool executed = gmc.hasBeenExecuted(ref1); + assertFalse(executed); + } + + function test_RevertWhen_ExecuteBubbleUpRevertReason() public { + bytes32 ref = keccak256("ref"); + uint256 deadline = block.timestamp + 1; + GuardedMulticaller2.Call[] memory calls = new GuardedMulticaller2.Call[](1); + calls[0] = GuardedMulticaller2.Call(address(target), "revertWithData(uint256)", abi.encodePacked(uint256(42))); + + bytes32 digest = sigUtils.hashTypedData(ref, calls, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.expectRevert(abi.encodeWithSelector(MockFunctions.RevertWithData.selector, uint256(42))); + + gmc.execute(signer, ref, calls, deadline, signature); + } + + function test_RevertWhen_ExecuteInvalidFunctionSignature() public { + bytes32 ref = keccak256("ref"); + uint256 deadline = block.timestamp + 1; + GuardedMulticaller2.Call[] memory calls = new GuardedMulticaller2.Call[](1); + calls[0] = GuardedMulticaller2.Call(address(target), "", abi.encodePacked(uint256(42))); + + bytes32 digest = sigUtils.hashTypedData(ref, calls, deadline); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.expectRevert(abi.encodeWithSelector(GuardedMulticaller2.InvalidFunctionSignature.selector, calls[0])); + + gmc.execute(signer, ref, calls, deadline, signature); + } + + // TODO: test reentrancy + function test_RevertWhen_ExecuteReentrant() public {} +} diff --git a/test/multicall/SigUtils.t.sol b/test/multicall/SigUtils.t.sol new file mode 100644 index 00000000..967b9b91 --- /dev/null +++ b/test/multicall/SigUtils.t.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {GuardedMulticaller2} from "../../contracts/multicall/GuardedMulticaller2.sol"; + +contract SigUtils { + bytes32 private constant _TYPE_HASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + bytes32 internal constant CALL_TYPEHASH = keccak256("Call(address target,string functionSignature,bytes data)"); + + bytes32 internal constant MULTICALL_TYPEHASH = + keccak256( + "Multicall(bytes32 ref,Call[] call,uint256 deadline)Call(address target,string functionSignature,bytes data)" + ); + + bytes32 private immutable cachedDomainSeparator; + + constructor(string memory _name, string memory _version, address _verifyingContract) { + cachedDomainSeparator = keccak256(abi.encode(_TYPE_HASH, keccak256(bytes(_name)), keccak256(bytes(_version)), block.chainid, _verifyingContract)); + } + + function _hashCallArray(GuardedMulticaller2.Call[] calldata _calls) internal pure returns (bytes32) { + bytes32[] memory hashedCallArr = new bytes32[](_calls.length); + for (uint256 i = 0; i < _calls.length; i++) { + hashedCallArr[i] = keccak256( + abi.encode(CALL_TYPEHASH, _calls[i].target, _calls[i].functionSignature, _calls[i].data) + ); + } + return keccak256(abi.encode(hashedCallArr)); + } + + function hashTypedData( + bytes32 _reference, + GuardedMulticaller2.Call[] calldata _calls, + uint256 _deadline + ) public view returns (bytes32) { + bytes32 digest = keccak256(abi.encode(MULTICALL_TYPEHASH, _reference, _hashCallArray(_calls), _deadline)); + return keccak256(abi.encodePacked("\x19\x01", cachedDomainSeparator, digest)); + } +} diff --git a/wagmi.config.ts b/wagmi.config.ts index da002317..9e90c2e4 100644 --- a/wagmi.config.ts +++ b/wagmi.config.ts @@ -6,6 +6,7 @@ import ImmutableERC721 from "./foundry-out/ImmutableERC721.sol/ImmutableERC721.j import ImmutableERC721MintByID from "./foundry-out/ImmutableERC721MintByID.sol/ImmutableERC721MintByID.json"; import ImmutableERC1155 from "./foundry-out/ImmutableERC1155.sol/ImmutableERC1155.json"; import PaymentSplitter from "./foundry-out/PaymentSplitter.sol/PaymentSplitter.json"; +import GuardedMulticaller2 from "./foundry-out/GuardedMulticaller2.sol/GuardedMulticaller2.json"; // https://github.com/wevm/viem/discussions/1009 export default defineConfig({ @@ -31,5 +32,9 @@ export default defineConfig({ name: "PaymentSplitter", abi: PaymentSplitter.abi as Abi, }, + { + name: "GuardedMulticaller2", + abi: GuardedMulticaller2.abi as Abi, + }, ], });