diff --git a/kit/contracts/contracts/Bond.sol b/kit/contracts/contracts/Bond.sol index f7a485291..ec1a6a420 100644 --- a/kit/contracts/contracts/Bond.sol +++ b/kit/contracts/contracts/Bond.sol @@ -6,7 +6,7 @@ import { ERC20Burnable } from "@openzeppelin/contracts/token/ERC20/extensions/ER import { ERC20Pausable } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol"; import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; import { ERC20Capped } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol"; -import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol"; +import { ERC20MultiSigAccessControl } from "./extensions/ERC20MultiSigAccessControl.sol"; import { ERC20Blocklist } from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC20Blocklist.sol"; import { ERC20Custodian } from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC20Custodian.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -29,7 +29,7 @@ contract Bond is ERC20Capped, ERC20Burnable, ERC20Pausable, - AccessControl, + ERC20MultiSigAccessControl, ERC20Permit, ERC20Blocklist, ERC20Custodian, @@ -154,9 +154,11 @@ contract Bond is uint256 _maturityDate, uint256 _faceValue, address _underlyingAsset, + uint256 signatureThreshold, address forwarder ) ERC20(name, symbol) + ERC20MultiSigAccessControl(signatureThreshold) ERC20Permit(name) ERC20Capped(_cap) ERC2771Context(forwarder) @@ -245,6 +247,25 @@ contract Bond is /// @param to The address that will receive the minted tokens /// @param amount The quantity of tokens to create in base units function mint(address to, uint256 amount) public onlyRole(SUPPLY_MANAGEMENT_ROLE) { + if (signatureThreshold > 1) revert MultiSigRequired(); + _mint(to, amount); + } + + /// @notice Creates new tokens and assigns them to an address using a multi-signature mechanism + /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE. Emits a Transfer event. + /// @param to The address that will receive the minted tokens + /// @param amount The quantity of tokens to create in base units + /// @param signatures An array of EIP-712 signatures from role holders + /// @param operationId A unique identifier for this operation (prevents replay) + function mintWithMultisig( + address to, + uint256 amount, + bytes[] calldata signatures, + bytes32 operationId + ) + external + withMultisig(SUPPLY_MANAGEMENT_ROLE, signatures, operationId, keccak256(abi.encode("MINT", to, amount))) + { _mint(to, amount); } @@ -275,7 +296,26 @@ contract Bond is /// @notice Closes off the bond at maturity /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE after maturity date /// @dev Requires sufficient underlying assets for all potential redemptions - function mature() external onlyRole(SUPPLY_MANAGEMENT_ROLE) { + function mature() public onlyRole(SUPPLY_MANAGEMENT_ROLE) { + if (signatureThreshold > 1) revert MultiSigRequired(); + _mature(); + } + + /// @notice Closes off the bond at maturity using a multi-signature mechanism + /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE after maturity date + /// @param signatures An array of EIP-712 signatures from role holders + /// @param operationId A unique identifier for this operation (prevents replay) + function matureWithMultisig( + bytes[] calldata signatures, + bytes32 operationId + ) + external + withMultisig(SUPPLY_MANAGEMENT_ROLE, signatures, operationId, keccak256(abi.encode("MATURITY"))) + { + _mature(); + } + + function _mature() internal { if (block.timestamp < maturityDate) revert BondNotYetMatured(); if (isMatured) revert BondAlreadyMatured(); @@ -303,6 +343,28 @@ contract Bond is /// @param to The address to send the underlying assets to /// @param amount The amount of underlying assets to withdraw function withdrawUnderlyingAsset(address to, uint256 amount) external onlyRole(SUPPLY_MANAGEMENT_ROLE) { + if (signatureThreshold > 1) revert MultiSigRequired(); + _withdrawUnderlyingAsset(to, amount); + } + + /// @notice Allows withdrawing excess underlying assets using a multi-signature mechanism + /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE + /// @param to The address to send the underlying assets to + /// @param amount The amount of underlying assets to withdraw + function withdrawUnderlyingAssetWithMultisig( + address to, + uint256 amount, + bytes[] calldata signatures, + bytes32 operationId + ) + external + withMultisig( + SUPPLY_MANAGEMENT_ROLE, + signatures, + operationId, + keccak256(abi.encode("WITHDRAW_UNDERLYING_ASSET", to, amount)) + ) + { _withdrawUnderlyingAsset(to, amount); } @@ -310,6 +372,29 @@ contract Bond is /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE /// @param to The address to send the underlying assets to function withdrawExcessUnderlyingAssets(address to) external onlyRole(SUPPLY_MANAGEMENT_ROLE) { + if (signatureThreshold > 1) revert MultiSigRequired(); + uint256 withdrawable = withdrawableUnderlyingAmount(); + if (withdrawable == 0) revert InsufficientUnderlyingBalance(); + + _withdrawUnderlyingAsset(to, withdrawable); + } + + /// @notice Allows withdrawing all excess underlying assets using a multi-signature mechanism + /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE + /// @param to The address to send the underlying assets to + function withdrawExcessUnderlyingAssetsWithMultisig( + address to, + bytes[] calldata signatures, + bytes32 operationId + ) + external + withMultisig( + SUPPLY_MANAGEMENT_ROLE, + signatures, + operationId, + keccak256(abi.encode("WITHDRAW_EXCESS_UNDERLYING_ASSETS")) + ) + { uint256 withdrawable = withdrawableUnderlyingAmount(); if (withdrawable == 0) revert InsufficientUnderlyingBalance(); diff --git a/kit/contracts/contracts/BondFactory.sol b/kit/contracts/contracts/BondFactory.sol index b6d4215b3..55192a6f5 100644 --- a/kit/contracts/contracts/BondFactory.sol +++ b/kit/contracts/contracts/BondFactory.sol @@ -51,15 +51,26 @@ contract BondFactory is ReentrancyGuard, ERC2771Context { uint256 cap, uint256 maturityDate, uint256 faceValue, - address underlyingAsset + address underlyingAsset, + uint256 signatureThreshold ) external nonReentrant returns (address bond) { bytes32 salt = _calculateSalt(name, symbol, decimals, isin); - address predicted = - predictAddress(_msgSender(), name, symbol, decimals, isin, cap, maturityDate, faceValue, underlyingAsset); + address predicted = predictAddress( + _msgSender(), + name, + symbol, + decimals, + isin, + cap, + maturityDate, + faceValue, + underlyingAsset, + signatureThreshold + ); if (isFactoryToken[predicted]) revert AddressAlreadyDeployed(); Bond newBond = new Bond{ salt: salt }( @@ -72,6 +83,7 @@ contract BondFactory is ReentrancyGuard, ERC2771Context { maturityDate, faceValue, underlyingAsset, + signatureThreshold, trustedForwarder() ); @@ -103,7 +115,8 @@ contract BondFactory is ReentrancyGuard, ERC2771Context { uint256 cap, uint256 maturityDate, uint256 faceValue, - address underlyingAsset + address underlyingAsset, + uint256 signatureThreshold ) public view @@ -123,6 +136,7 @@ contract BondFactory is ReentrancyGuard, ERC2771Context { maturityDate, faceValue, underlyingAsset, + signatureThreshold, trustedForwarder() ) ) diff --git a/kit/contracts/contracts/Equity.sol b/kit/contracts/contracts/Equity.sol index 428783dd8..3702c8942 100644 --- a/kit/contracts/contracts/Equity.sol +++ b/kit/contracts/contracts/Equity.sol @@ -5,7 +5,7 @@ import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { ERC20Burnable } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; import { ERC20Pausable } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol"; import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; -import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol"; +import { ERC20MultiSigAccessControl } from "./extensions/ERC20MultiSigAccessControl.sol"; import { ERC20Blocklist } from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC20Blocklist.sol"; import { ERC20Custodian } from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC20Custodian.sol"; import { ERC20Votes } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; @@ -26,7 +26,7 @@ contract Equity is ERC20, ERC20Burnable, ERC20Pausable, - AccessControl, + ERC20MultiSigAccessControl, ERC20Permit, ERC20Blocklist, ERC20Custodian, @@ -92,9 +92,11 @@ contract Equity is string memory isin_, string memory equityClass_, string memory equityCategory_, + uint256 signatureThreshold, address forwarder ) ERC20(name, symbol) + ERC20MultiSigAccessControl(signatureThreshold) ERC20Permit(name) ERC2771Context(forwarder) { @@ -177,6 +179,25 @@ contract Equity is /// @param to The address that will receive the minted tokens /// @param amount The quantity of tokens to create in base units function mint(address to, uint256 amount) public onlyRole(SUPPLY_MANAGEMENT_ROLE) { + if (signatureThreshold > 1) revert MultiSigRequired(); + _mint(to, amount); + } + + /// @notice Creates new tokens and assigns them to an address using a multi-signature mechanism + /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE. Emits a Transfer event. + /// @param to The address that will receive the minted tokens + /// @param amount The quantity of tokens to create in base units + /// @param signatures An array of EIP-712 signatures from role holders + /// @param operationId A unique identifier for this operation (prevents replay) + function mintWithMultisig( + address to, + uint256 amount, + bytes[] calldata signatures, + bytes32 operationId + ) + external + withMultisig(SUPPLY_MANAGEMENT_ROLE, signatures, operationId, keccak256(abi.encode("MINT", to, amount))) + { _mint(to, amount); } diff --git a/kit/contracts/contracts/EquityFactory.sol b/kit/contracts/contracts/EquityFactory.sol index f62599655..ed94c2321 100644 --- a/kit/contracts/contracts/EquityFactory.sol +++ b/kit/contracts/contracts/EquityFactory.sol @@ -47,20 +47,30 @@ contract EquityFactory is ReentrancyGuard, ERC2771Context { uint8 decimals, string memory isin, string memory equityClass, - string memory equityCategory + string memory equityCategory, + uint256 signatureThreshold ) external nonReentrant returns (address token) { // Check if address is already deployed - address predicted = predictAddress(_msgSender(), name, symbol, decimals, isin, equityClass, equityCategory); + address predicted = + predictAddress(_msgSender(), name, symbol, decimals, isin, equityClass, equityCategory, signatureThreshold); if (isFactoryToken[predicted]) revert AddressAlreadyDeployed(); bytes32 salt = _calculateSalt(name, symbol, decimals, isin); Equity newToken = new Equity{ salt: salt }( - name, symbol, decimals, _msgSender(), isin, equityClass, equityCategory, trustedForwarder() + name, + symbol, + decimals, + _msgSender(), + isin, + equityClass, + equityCategory, + signatureThreshold, + trustedForwarder() ); token = address(newToken); @@ -87,7 +97,8 @@ contract EquityFactory is ReentrancyGuard, ERC2771Context { uint8 decimals, string memory isin, string memory equityClass, - string memory equityCategory + string memory equityCategory, + uint256 signatureThreshold ) public view @@ -114,6 +125,7 @@ contract EquityFactory is ReentrancyGuard, ERC2771Context { isin, equityClass, equityCategory, + signatureThreshold, trustedForwarder() ) ) diff --git a/kit/contracts/contracts/Fund.sol b/kit/contracts/contracts/Fund.sol index 105c523c8..f9cbc3a65 100644 --- a/kit/contracts/contracts/Fund.sol +++ b/kit/contracts/contracts/Fund.sol @@ -5,7 +5,7 @@ import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { ERC20Burnable } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; import { ERC20Pausable } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol"; import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; -import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol"; +import { ERC20MultiSigAccessControl } from "./extensions/ERC20MultiSigAccessControl.sol"; import { ERC20Blocklist } from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC20Blocklist.sol"; import { ERC20Custodian } from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC20Custodian.sol"; import { ERC20Votes } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; @@ -27,7 +27,7 @@ contract Fund is ERC20, ERC20Burnable, ERC20Pausable, - AccessControl, + ERC20MultiSigAccessControl, ERC20Permit, ERC20Blocklist, ERC20Custodian, @@ -114,9 +114,11 @@ contract Fund is uint16 managementFeeBps_, string memory fundClass_, string memory fundCategory_, + uint256 signatureThreshold, address forwarder ) ERC20(name, symbol) + ERC20MultiSigAccessControl(signatureThreshold) ERC20Permit(name) ERC2771Context(forwarder) { @@ -208,6 +210,25 @@ contract Fund is /// @param to The address that will receive the minted tokens /// @param amount The quantity of tokens to create in base units function mint(address to, uint256 amount) external onlyRole(SUPPLY_MANAGEMENT_ROLE) { + if (signatureThreshold > 1) revert MultiSigRequired(); + _mint(to, amount); + } + + /// @notice Creates new tokens and assigns them to an address using a multi-signature mechanism + /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE. Emits a Transfer event. + /// @param to The address that will receive the minted tokens + /// @param amount The quantity of tokens to create in base units + /// @param signatures An array of EIP-712 signatures from role holders + /// @param operationId A unique identifier for this operation (prevents replay) + function mintWithMultisig( + address to, + uint256 amount, + bytes[] calldata signatures, + bytes32 operationId + ) + external + withMultisig(SUPPLY_MANAGEMENT_ROLE, signatures, operationId, keccak256(abi.encode("MINT", to, amount))) + { _mint(to, amount); } @@ -309,6 +330,41 @@ contract Fund is /// @param to The recipient address /// @param amount The amount to withdraw function withdrawToken(address token, address to, uint256 amount) external onlyRole(SUPPLY_MANAGEMENT_ROLE) { + if (signatureThreshold > 1) revert MultiSigRequired(); + _withdrawToken(token, to, amount); + } + + /// @notice Withdraws mistakenly sent tokens from the contract using a multi-signature mechanism + /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE. Cannot withdraw this token. + /// @param token The token to withdraw + /// @param to The recipient address + /// @param amount The amount to withdraw + /// @param signatures An array of EIP-712 signatures from role holders + /// @param operationId A unique identifier for this operation (prevents replay) + function withdrawTokenWithMultisig( + address token, + address to, + uint256 amount, + bytes[] calldata signatures, + bytes32 operationId + ) + external + withMultisig( + SUPPLY_MANAGEMENT_ROLE, + signatures, + operationId, + keccak256(abi.encode("WITHDRAW_TOKEN", token, to, amount)) + ) + { + _withdrawToken(token, to, amount); + } + + /// @notice Internal function to withdraw mistakenly sent tokens + /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE. Cannot withdraw this token. + /// @param token The token to withdraw + /// @param to The recipient address + /// @param amount The amount to withdraw + function _withdrawToken(address token, address to, uint256 amount) internal { if (token == address(0)) revert InvalidTokenAddress(); if (to == address(0)) revert InvalidTokenAddress(); if (amount == 0) return; diff --git a/kit/contracts/contracts/FundFactory.sol b/kit/contracts/contracts/FundFactory.sol index e0e91d399..e47736397 100644 --- a/kit/contracts/contracts/FundFactory.sol +++ b/kit/contracts/contracts/FundFactory.sol @@ -50,21 +50,32 @@ contract FundFactory is ReentrancyGuard, ERC2771Context { string memory isin, string memory fundClass, string memory fundCategory, - uint16 managementFeeBps + uint16 managementFeeBps, + uint256 signatureThreshold ) external nonReentrant returns (address token) { // Check if address is already deployed - address predicted = - predictAddress(_msgSender(), name, symbol, decimals, isin, fundClass, fundCategory, managementFeeBps); + address predicted = predictAddress( + _msgSender(), name, symbol, decimals, isin, fundClass, fundCategory, managementFeeBps, signatureThreshold + ); if (isFactoryFund[predicted]) revert AddressAlreadyDeployed(); bytes32 salt = _calculateSalt(name, symbol, decimals, isin); Fund newToken = new Fund{ salt: salt }( - name, symbol, decimals, _msgSender(), isin, managementFeeBps, fundClass, fundCategory, trustedForwarder() + name, + symbol, + decimals, + _msgSender(), + isin, + managementFeeBps, + fundClass, + fundCategory, + signatureThreshold, + trustedForwarder() ); token = address(newToken); @@ -93,7 +104,8 @@ contract FundFactory is ReentrancyGuard, ERC2771Context { string memory isin, string memory fundClass, string memory fundCategory, - uint16 managementFeeBps + uint16 managementFeeBps, + uint256 signatureThreshold ) public view @@ -121,6 +133,7 @@ contract FundFactory is ReentrancyGuard, ERC2771Context { managementFeeBps, fundClass, fundCategory, + signatureThreshold, trustedForwarder() ) ) diff --git a/kit/contracts/contracts/StableCoin.sol b/kit/contracts/contracts/StableCoin.sol index 9874dc89f..5d63ef82d 100644 --- a/kit/contracts/contracts/StableCoin.sol +++ b/kit/contracts/contracts/StableCoin.sol @@ -5,7 +5,7 @@ import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { ERC20Burnable } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; import { ERC20Pausable } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol"; import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; -import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol"; +import { ERC20MultiSigAccessControl } from "./extensions/ERC20MultiSigAccessControl.sol"; import { ERC20Blocklist } from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC20Blocklist.sol"; import { ERC20Collateral } from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC20Collateral.sol"; import { ERC20Custodian } from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC20Custodian.sol"; @@ -25,7 +25,7 @@ contract StableCoin is ERC20, ERC20Burnable, ERC20Pausable, - AccessControl, + ERC20MultiSigAccessControl, ERC20Permit, ERC20Blocklist, ERC20Collateral, @@ -106,9 +106,11 @@ contract StableCoin is address initialOwner, string memory isin_, uint48 collateralLivenessSeconds, + uint256 signatureThreshold, address forwarder ) ERC20(name, symbol) + ERC20MultiSigAccessControl(signatureThreshold) ERC20Permit(name) ERC20Collateral(collateralLivenessSeconds) ERC2771Context(forwarder) @@ -185,6 +187,28 @@ contract StableCoin is /// @param to The address that will receive the minted tokens /// @param amount The quantity of tokens to create in base units function mint(address to, uint256 amount) public onlyRole(SUPPLY_MANAGEMENT_ROLE) { + if (signatureThreshold > 1) revert MultiSigRequired(); + (uint256 collateralAmount,) = collateral(); + if (collateralAmount < totalSupply() + amount) revert InsufficientCollateral(); + + _mint(to, amount); + } + + /// @notice Creates new tokens and assigns them to an address using a multi-signature mechanism + /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE. Requires sufficient collateral. + /// @param to The address that will receive the minted tokens + /// @param amount The quantity of tokens to create in base units + /// @param signatures An array of EIP-712 signatures from role holders + /// @param operationId A unique identifier for this operation (prevents replay) + function mintWithMultisig( + address to, + uint256 amount, + bytes[] calldata signatures, + bytes32 operationId + ) + external + withMultisig(SUPPLY_MANAGEMENT_ROLE, signatures, operationId, keccak256(abi.encode("MINT", to, amount))) + { (uint256 collateralAmount,) = collateral(); if (collateralAmount < totalSupply() + amount) revert InsufficientCollateral(); @@ -210,6 +234,27 @@ contract StableCoin is /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE. Requires collateral >= total supply. /// @param amount New collateral amount function updateCollateral(uint256 amount) public onlyRole(SUPPLY_MANAGEMENT_ROLE) { + if (signatureThreshold > 1) revert MultiSigRequired(); + _updateCollateral(amount); + } + + /// @notice Updates the proven collateral amount with a timestamp using a multi-signature mechanism + /// @dev Only callable by addresses with SUPPLY_MANAGEMENT_ROLE. Requires collateral >= total supply. + /// @param amount New collateral amount + /// @param signatures An array of EIP-712 signatures from role holders + /// @param operationId A unique identifier for this operation (prevents replay) + function updateCollateralWithMultisig( + uint256 amount, + bytes[] calldata signatures, + bytes32 operationId + ) + external + withMultisig(SUPPLY_MANAGEMENT_ROLE, signatures, operationId, keccak256(abi.encode("UPDATE_COLLATERAL", amount))) + { + _updateCollateral(amount); + } + + function _updateCollateral(uint256 amount) internal { if (amount < totalSupply()) revert InsufficientCollateral(); uint256 oldAmount = _collateralProof.amount; diff --git a/kit/contracts/contracts/StableCoinFactory.sol b/kit/contracts/contracts/StableCoinFactory.sol index be0a7bd5a..75f245e42 100644 --- a/kit/contracts/contracts/StableCoinFactory.sol +++ b/kit/contracts/contracts/StableCoinFactory.sol @@ -46,7 +46,8 @@ contract StableCoinFactory is ReentrancyGuard, ERC2771Context { string memory symbol, uint8 decimals, string memory isin, - uint48 collateralLivenessSeconds + uint48 collateralLivenessSeconds, + uint256 signatureThreshold ) external nonReentrant @@ -59,7 +60,14 @@ contract StableCoinFactory is ReentrancyGuard, ERC2771Context { bytes32 salt = _calculateSalt(name, symbol, decimals, isin); StableCoin newToken = new StableCoin{ salt: salt }( - name, symbol, decimals, _msgSender(), isin, collateralLivenessSeconds, trustedForwarder() + name, + symbol, + decimals, + _msgSender(), + isin, + collateralLivenessSeconds, + signatureThreshold, + trustedForwarder() ); token = address(newToken); diff --git a/kit/contracts/contracts/extensions/ERC20MultiSigAccessControl.sol b/kit/contracts/contracts/extensions/ERC20MultiSigAccessControl.sol new file mode 100644 index 000000000..5f7a6a9ec --- /dev/null +++ b/kit/contracts/contracts/extensions/ERC20MultiSigAccessControl.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/** + * @title ERC20MultiSigAccessControl + * @notice An abstract contract implementing a flexible multi-signature mechanism for AccessControl roles + * @dev Extends OpenZeppelin's AccessControl and EIP712 to provide role-based multi-signature functionality + * This contract allows for M-of-N signature requirements for specific operations, where M is the + * signature threshold and N is the number of addresses holding a particular role. + * + * Key features: + * - Uses EIP-712 for secure off-chain message signing + * - Prevents signature replay attacks through unique operation IDs + * - Supports flexible signature thresholds + * - Integrates with OpenZeppelin's AccessControl for role management + * + * Security considerations: + * - Operation IDs must be unique to prevent replay attacks + * - Signatures must come from unique addresses holding the required role + * - The signature threshold can be adjusted by the admin but must remain > 0 + * + * How to use: + * 1. Off-chain, produce a unique `operationId` (e.g. a random hash) + * 2. Build an `operationHash` describing the function's data (e.g., "MINT", to, amount) + * 3. Have multiple addresses holding `role` sign the EIP-712 typed data + * 4. Call the function on-chain with `withMultisig(role, signatures, operationId, operationHash)` + * 5. If enough valid signatures are provided, the function executes once + */ +abstract contract ERC20MultiSigAccessControl is AccessControl, EIP712 { + using ECDSA for bytes32; + + /** + * @notice The minimum number of valid signatures required for multi-sig operations + * @dev Must be greater than 0. Can be updated by admin. + */ + uint256 public signatureThreshold; + + /** + * @dev Tracks used operation IDs to prevent replay. + * Once an operationId is used, it can't be reused. + */ + mapping(bytes32 => bool) private _usedOperationIds; + + /** + * @notice Error for invalid signature threshold + * @dev Thrown when the provided signature threshold is 0 + */ + error InvalidSignatureThreshold(); + + /** + * @notice Error for multi-signature required + * @dev Thrown when the signature threshold is greater than 1 + */ + error MultiSigRequired(); + + /** + * @notice Initializes the contract with a signature threshold + * @dev Sets up the initial signature requirement for multi-sig operations + * @param signatureThreshold_ The initial number of required signatures (must be > 0) + */ + constructor(uint256 signatureThreshold_) { + if (signatureThreshold_ == 0) revert InvalidSignatureThreshold(); + signatureThreshold = signatureThreshold_; + } + + /** + * @notice The EIP-712 typehash for multi-signature operations + * @dev Keccak256 hash of the EIP-712 encoded type for multi-sig operations + * Used in the EIP-712 signing and verification process + */ + bytes32 public constant MULTISIG_TYPEHASH = + keccak256("MultiSigOperation(bytes32 operationId,bytes32 operationHash)"); + + /** + * @notice Enforces M-of-N signature requirements for function execution + * @dev Modifier that validates EIP-712 signatures from role holders + * - Prevents replay attacks by tracking used operation IDs + * - Validates signature count against threshold + * - Verifies each signer holds the required role + * - Counts only unique valid signers + * - Marks operation ID as used after validation + * @param role The AccessControl role required for valid signatures + * @param signatures Array of EIP-712 signatures from role holders + * @param operationId Unique identifier for this operation (prevents replay) + * @param operationHash Hash representing the operation details + */ + modifier withMultisig(bytes32 role, bytes[] calldata signatures, bytes32 operationId, bytes32 operationHash) { + require(!_usedOperationIds[operationId], "Operation ID already used"); + require(signatures.length >= signatureThreshold, "Not enough signatures"); + + // 1. Build the typed data (struct) hash + bytes32 structHash = keccak256(abi.encode(MULTISIG_TYPEHASH, operationId, operationHash)); + + // 2. Final EIP-712 digest + bytes32 digest = _hashTypedDataV4(structHash); + + // 3. Recover signers and ensure they have `role`, counting distinct addresses + address[] memory signers = new address[](signatures.length); + uint256 validSignersCount = 0; + + for (uint256 i = 0; i < signatures.length; i++) { + address recovered = digest.recover(signatures[i]); + if (hasRole(role, recovered)) { + // Check for duplicates + bool alreadyCounted = false; + for (uint256 j = 0; j < i; j++) { + if (signers[j] == recovered) { + alreadyCounted = true; + break; + } + } + if (!alreadyCounted) { + signers[i] = recovered; + validSignersCount++; + } + } + } + + require(validSignersCount >= signatureThreshold, "Insufficient valid signers with required role"); + + // 4. Mark operationId as used + _usedOperationIds[operationId] = true; + + // 5. Proceed to the function body + _; + } + + /** + * @notice Updates the number of signatures required for multi-sig operations + * @dev Can only be called by addresses with the DEFAULT_ADMIN_ROLE + * @param newThreshold The new signature threshold (must be > 0) + */ + function setSignatureThreshold(uint256 newThreshold) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(newThreshold > 0, "Threshold must be > 0"); + signatureThreshold = newThreshold; + } + + /** + * @notice Checks if a specific operation ID has been used + * @dev Used to verify if an operation can still be executed or has already been processed + * @param operationId The unique identifier to check + * @return bool True if the operation ID has been used, false otherwise + */ + function isOperationUsed(bytes32 operationId) external view returns (bool) { + return _usedOperationIds[operationId]; + } +}