diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..64d3d6a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5ea661b..398266f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -23,7 +23,7 @@ If you have any questions or need further assistance, feel free to reach out in Describe the purpose of the PR so that if you looked at it in 6 months, it would be clear from the overview why this was created. E.g.: ```md -This PR entails the implementation of #2. It introduces the owner role functionality and the following actions were implemented: +This PR entails the implementation of emmanuelJet/MultiSigEnterpriseVault#2. It introduces the owner role functionality and the following actions were implemented: - **[feat]** `OwnerContract` with owner functionalities - **[perf]** Optimized `foundry.toml` file diff --git a/foundry.toml b/foundry.toml index fc855a1..0b45836 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,4 +4,10 @@ out = "out" libs = ["lib"] solc = "0.8.27" +[fmt] +tab_width = 2 +quote_style = "single" +multiline_func_header = "params_first" +single_line_statement_blocks = "preserve" + # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/script/Counter.s.sol b/script/Counter.s.sol deleted file mode 100644 index 10f592d..0000000 --- a/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity ^0.8.27; - -import {Script, console} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/script/MultiSigEnterpriseVault.s.sol b/script/MultiSigEnterpriseVault.s.sol new file mode 100644 index 0000000..a1bc739 --- /dev/null +++ b/script/MultiSigEnterpriseVault.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import {Script, console} from 'forge-std/Script.sol'; +import {MultiSigEnterpriseVault} from '../src/MultiSigEnterpriseVault.sol'; + +contract MultiSigEnterpriseVaultScript is Script { + MultiSigEnterpriseVault internal vault; + address internal vaultAddress; + + function setUp() public {} + + function run() public { + vm.startBroadcast(); + + uint256 initialThreshold = 3; + uint256 initialOwnerOverrideLimit = 3 days; + address vaultOwner = makeAddr('vaultOwner'); + + vault = new MultiSigEnterpriseVault(vaultOwner, initialThreshold, initialOwnerOverrideLimit); + vaultAddress = address(vault); + + console.log('MultiSigVault Contract Address:', vaultAddress); + + vm.stopBroadcast(); + } +} diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index 6e9533e..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity ^0.8.27; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/MultiSigEnterpriseVault.sol b/src/MultiSigEnterpriseVault.sol new file mode 100644 index 0000000..808aee0 --- /dev/null +++ b/src/MultiSigEnterpriseVault.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import {User} from './components/User.sol'; +import {AddressUtils} from './libraries/AddressUtils.sol'; +import {IMultiSigEnterpriseVault} from './interfaces/IMultiSigEnterpriseVault.sol'; + +/** + * @title MultiSig Enterprise Vault Contract + * @author Emmanuel Joseph (JET) + */ +contract MultiSigEnterpriseVault is User, IMultiSigEnterpriseVault { + /// @notice The current threshold required for signatory approval. + uint256 public signatoryThreshold; + + /** + * @dev Initializes the MultiSigEnterpriseVault with the owner, initial signatory threshold, and owner override limit. + * @param owner The address of the contract owner. + * @param initialThreshold The initial threshold for signatory approval. + * @param initialOwnerOverrideLimit The initial timelock limit for owner override. + */ + constructor( + address owner, + uint256 initialThreshold, + uint256 initialOwnerOverrideLimit + ) User(owner, initialOwnerOverrideLimit) { + if (AddressUtils.isValidUserAddress(owner)) { + signatoryThreshold = initialThreshold; + } + } + + /** + * @notice Owner updates the signatory threshold for the vault. + * @param newThreshold The new threshold value for signatory approval. + * @dev Only callable by the owner of the contract. + */ + function ownerUpdateSignatoryThreshold( + uint256 newThreshold + ) public onlyOwner { + if (totalSigners() >= signatoryThreshold) { + revert SignersApprovalRequired(); + } + + signatoryThreshold = newThreshold; + emit ThresholdUpdated(newThreshold); + } +} diff --git a/src/components/User.sol b/src/components/User.sol new file mode 100644 index 0000000..b6e0a66 --- /dev/null +++ b/src/components/User.sol @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import '../libraries/Counters.sol'; +import {IUser} from '../interfaces/IUser.sol'; +import {OwnerRole} from './roles/OwnerRole.sol'; +import {SignerRole} from './roles/SignerRole.sol'; +import {RoleType} from '../utilities/VaultEnums.sol'; +import {ExecutorRole} from './roles/ExecutorRole.sol'; +import {UserProfile} from '../utilities/VaultStructs.sol'; +import {AddressUtils} from '../libraries/AddressUtils.sol'; + +/** + * @title User Contract + * @author Emmanuel Joseph (JET) + * @dev Manages user profiles and integrates with the Owner role for user administration within the MultiSigVault system. + */ +abstract contract User is OwnerRole, ExecutorRole, SignerRole, IUser { + using Counters for Counters.Counter; + using AddressUtils for address; + + /// @notice Using Counter library for total users + Counters.Counter private _userCount; + + /// @notice Mapping to store UserProfile by their address + mapping(address => UserProfile) private _users; + + /** + * @dev Constructor to initialize the User and Owner role contracts. + * @param ownerAddress The address of the owner to be assigned the OWNER_ROLE. + * @param initialOwnerOverrideLimit The initial timelock limit for owner override. + */ + constructor( + address ownerAddress, + uint256 initialOwnerOverrideLimit + ) OwnerRole(ownerAddress, initialOwnerOverrideLimit) { + _addUser(ownerAddress, RoleType.OWNER); + } + + /** + * @dev Modifier to restrict access to functions to only vault users. + * Reverts with `AccessControlUnauthorizedSigner` if the caller is not the signer. + */ + modifier onlyUser() { + if (!_isUser(_msgSender())) { + revert InvalidUserProfile(_msgSender()); + } + _; + } + + /** + * @notice Returns the total number of users. + * @return uint256 The total number of users. + * @dev Only callable by the owner. + */ + function totalUsers() public view onlyOwner returns (uint256) { + return _userCount.current(); + } + + /** + * @notice Returns the user profile object of a given address. + * + * @param user The address of the user whose profile is requested. + * @return UserProfile The user profile object associated with the provided address. + * @dev Requirements: + * - Limited to the owner account. + * - `user` cannot be the zero address. + */ + function getUserProfile( + address user + ) public view onlyOwner returns (UserProfile memory) { + if (!_isUser(user)) { + revert InvalidUserProfile(user); + } + return _users[user]; + } + + /** + * @notice Adds a new executor. + * @param newExecutor The address of the new executor. + * @dev Only callable by the owner. + */ + function addExecutor( + address newExecutor + ) public onlyOwner { + _addExecutor(newExecutor); + _addUser(newExecutor, RoleType.EXECUTOR); + } + + /** + * @notice Updates the executor by replacing the old executor with a new one. + * @param newExecutor The address of the new executor. + * @dev Only callable by the owner. + */ + function updateExecutor( + address newExecutor + ) public onlyOwner { + address oldExecutor = executor(); + if (oldExecutor.isValidUserAddress() && newExecutor.isValidUserAddress()) { + _updateExecutor(newExecutor); + _removeUser(oldExecutor); + _addUser(newExecutor, RoleType.EXECUTOR); + } + } + + /** + * @notice Removes the current executor. + * @dev Only callable by the owner. + * + * NOTE: Removing executor will leave the contract without an executor, + * thereby disabling any functionality that is only available to the executor. + */ + function removeExecutor() public onlyOwner { + address oldExecutor = executor(); + if (oldExecutor.isValidUserAddress()) { + _removeExecutor(); + _removeUser(oldExecutor); + } + } + + /** + * @notice Adds a new signer user. + * @param newSigner The address of the new signer. + * @dev Only callable by the owner. + */ + function addSigner( + address newSigner + ) public onlyOwner { + _addSigner(newSigner); + _addUser(newSigner, RoleType.SIGNER); + } + + /** + * @notice Removes an existing signer and deletes the user's profile. + * @param signer The address of the signer to be removed. + * @dev Only callable by the owner. + */ + function removeSigner( + address signer + ) public onlyOwner { + if (signer.isValidUserAddress()) { + _removeSigner(signer); + _removeUser(signer); + } + } + + /** + * @notice Checks if an address is a user. + * @param user The address to check. + * @return status True if the address is a user, otherwise false. + */ + function _isUser( + address user + ) internal view returns (bool status) { + status = _users[user].user != address(0); + } + + /** + * @dev Adds a user profile and assigns a role. + * + * @param user The address of the user. + * @param role The role type assigned to the user. + * @dev This is a private function to store user details. + */ + function _addUser(address user, RoleType role) private { + _users[user] = UserProfile(user, role, block.timestamp); + _userCount.increment(); + } + + /** + * @notice Removes a user's profile. + * @param user The address of the user to be removed. + */ + function _removeUser( + address user + ) private { + UserProfile storage profile = _users[user]; + if (profile.user.isValidUserAddress()) { + delete _users[user]; + _userCount.decrement(); + } + } +} diff --git a/src/components/roles/ExecutorRole.sol b/src/components/roles/ExecutorRole.sol new file mode 100644 index 0000000..7cf3807 --- /dev/null +++ b/src/components/roles/ExecutorRole.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import {AccessControl} from '@openzeppelin/contracts/access/AccessControl.sol'; +import {EXECUTOR_ROLE, OWNER_ROLE} from '../../utilities/VaultConstants.sol'; +import {IExecutorRole} from '../../interfaces/roles/IExecutorRole.sol'; +import {AddressUtils} from '../../libraries/AddressUtils.sol'; + +/** + * @title Executor Role Contract + * @author Emmanuel Joseph (JET) + * @dev Abstract contract providing the logic for the Executor role in the MultiSigVault system. + */ +abstract contract ExecutorRole is AccessControl, IExecutorRole { + using AddressUtils for address; + + /// @dev Stores the address of the current executor. + address private _executor; + + /** + * @dev Modifier to restrict access to functions to only the executor. + * Reverts with `AccessControlUnauthorizedExecutor` if the caller is not the executor. + */ + modifier onlyExecutor() { + if (!hasRole(EXECUTOR_ROLE, _msgSender())) { + revert AccessControlUnauthorizedExecutor(_msgSender()); + } + _; + } + + /** + * @notice Returns the address of the current executor. + * @return The address of the executor. + */ + function executor() public view returns (address) { + return _executor; + } + + /** + * @notice Adds a new executor. + * @param newExecutor The address of the new executor. + * @dev Only callable by the owner. + */ + function _addExecutor( + address newExecutor + ) internal onlyRole(OWNER_ROLE) { + require(_executor == address(0), 'ExecutorRole: Executor already exists'); + if (newExecutor.isValidUserAddress()) { + grantRole(EXECUTOR_ROLE, newExecutor); + _executor = newExecutor; + emit ExecutorAdded(newExecutor); + } + } + + /** + * @notice Removes the current executor. + * @dev Only callable by the owner. + */ + function _removeExecutor() internal onlyRole(OWNER_ROLE) { + address oldExecutor = _executor; + if (oldExecutor.isValidUserAddress()) { + revokeRole(EXECUTOR_ROLE, oldExecutor); + _executor = address(0); + emit ExecutorRemoved(oldExecutor); + } + } + + /** + * @notice Updates the executor by replacing the old executor with a new one. + * @param newExecutor The address of the new executor. + * @dev Only callable by the owner. + */ + function _updateExecutor( + address newExecutor + ) internal onlyRole(OWNER_ROLE) { + address oldExecutor = _executor; + if (oldExecutor.isValidUserAddress() && newExecutor.isValidUserAddress()) { + // Revoke old executor role and assign to the new executor + revokeRole(EXECUTOR_ROLE, oldExecutor); + grantRole(EXECUTOR_ROLE, newExecutor); + + // Update the executor address + _executor = newExecutor; + + emit ExecutorUpdated(oldExecutor, newExecutor); + } + } +} diff --git a/src/components/roles/OwnerRole.sol b/src/components/roles/OwnerRole.sol new file mode 100644 index 0000000..3eb9f47 --- /dev/null +++ b/src/components/roles/OwnerRole.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import {AccessControl} from '@openzeppelin/contracts/access/AccessControl.sol'; +import {IOwnerRole} from '../../interfaces/roles/IOwnerRole.sol'; +import {OWNER_ROLE} from '../../utilities/VaultConstants.sol'; + +/** + * @title Owner Role Contract + * @author Emmanuel Joseph (JET) + * @dev Abstract contract providing the logic for the Owner role in the MultiSigVault system. + */ +abstract contract OwnerRole is AccessControl, IOwnerRole { + /// @dev Stores the address of the current owner. + address private _owner; + + /// @dev Stores the timelock for owner override functionality. + uint256 public ownerOverrideTimelock; + + /** + * @dev Initializes the Owner role and sets the initial owner override timelock. + * @param _ownerAddress The address of the initial owner. + * @param _initialOwnerOverrideLimit The initial timelock value for owner override. + */ + constructor(address _ownerAddress, uint256 _initialOwnerOverrideLimit) { + if (_initialOwnerOverrideLimit <= 0) { + revert InvalidOwnerOverrideLimitValue(_initialOwnerOverrideLimit); + } + + // Grant DEFAULT_ADMIN_ROLE to the owner + _grantRole(DEFAULT_ADMIN_ROLE, _ownerAddress); + + // Now change the admin role of DEFAULT_ADMIN_ROLE to OWNER_ROLE + _setRoleAdmin(DEFAULT_ADMIN_ROLE, OWNER_ROLE); + + // Grant OWNER_ROLE to the owner + _grantRole(OWNER_ROLE, _ownerAddress); + + ownerOverrideTimelock = _initialOwnerOverrideLimit; + _owner = _ownerAddress; + } + + /** + * @dev Modifier to restrict access to functions to only the owner. + * Reverts with `AccessControlUnauthorizedOwner` if the caller is not the owner. + */ + modifier onlyOwner() { + if (!hasRole(OWNER_ROLE, _msgSender())) { + revert AccessControlUnauthorizedOwner(_msgSender()); + } + _; + } + + /** + * @notice Returns the address of the current owner. + * @return The address of the owner. + */ + function owner() public view returns (address) { + return _owner; + } + + /** + * @notice Increases the owner override timelock to a new limit. + * Emits the `OwnerOverrideTimelockIncreased` event. + * + * @param newLimit The new timelock value for the owner override. + * @dev + * - `newLimit` must be higher than the current value. + */ + function increaseOwnerOverrideTimelockLimit( + uint256 newLimit + ) public onlyOwner { + require(newLimit > ownerOverrideTimelock, 'OwnerRole: New limit must be higher'); + ownerOverrideTimelock = newLimit; + emit OwnerOverrideTimelockIncreased(newLimit); + } + + /** + * @notice Decreases the owner override timelock to a new limit. + * Emits the `OwnerOverrideTimelockDecreased` event. + * + * @param newLimit The new timelock value for the owner override. + * @dev + * - `newLimit` must be lower than the current value. + */ + function decreaseOwnerOverrideTimelockLimit( + uint256 newLimit + ) public onlyOwner { + require(newLimit < ownerOverrideTimelock, 'OwnerRole: New limit must be lower'); + ownerOverrideTimelock = newLimit; + emit OwnerOverrideTimelockDecreased(newLimit); + } +} diff --git a/src/components/roles/SignerRole.sol b/src/components/roles/SignerRole.sol new file mode 100644 index 0000000..1736af0 --- /dev/null +++ b/src/components/roles/SignerRole.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import {AccessControl} from '@openzeppelin/contracts/access/AccessControl.sol'; +import {SIGNER_ROLE, OWNER_ROLE} from '../../utilities/VaultConstants.sol'; +import {ISignerRole} from '../../interfaces/roles/ISignerRole.sol'; +import {AddressUtils} from '../../libraries/AddressUtils.sol'; +import '../../libraries/Counters.sol'; + +/** + * @title Signer Role Contract + * @author Emmanuel Joseph (JET) + * @dev Abstract contract providing the logic for the Signer role in the MultiSigVault system. + */ +abstract contract SignerRole is AccessControl, ISignerRole { + using Counters for Counters.Counter; + using AddressUtils for address; + + /// @notice Using Counter library for total signers + Counters.Counter private _signerCount; + + /// @dev Stores store all signer addresses. + address[] private _signers; + + /** + * @dev Modifier to restrict access to functions to only the signer. + * Reverts with `AccessControlUnauthorizedSigner` if the caller is not the signer. + */ + modifier onlySigner() { + if (!isSigner(_msgSender())) { + revert AccessControlUnauthorizedSigner(_msgSender()); + } + _; + } + + /** + * @notice Checks if an address is a signer. + * @param signer The address to check. + * @return status True if the address is a signer, otherwise false. + */ + function isSigner( + address signer + ) public view returns (bool status) { + status = hasRole(SIGNER_ROLE, signer); + } + + /** + * @notice Returns the total number of signers. + * @return uint256 The number of signers in the system. + */ + function totalSigners() public view returns (uint256) { + return _signerCount.current(); + } + + /** + * @notice Returns an array of all current signers' addresses. + * @return address[] The list of signers' addresses. + */ + function getSigners() public view returns (address[] memory) { + return _signers; + } + + /** + * @notice Adds a new signer. + * @param newSigner The address of the new signer. + * @dev Only callable by the owner. + */ + function _addSigner( + address newSigner + ) internal onlyRole(OWNER_ROLE) { + require(!isSigner(newSigner), 'SignerRole: Signer already exists'); + if (newSigner.isValidUserAddress()) { + grantRole(SIGNER_ROLE, newSigner); + _signers.push(newSigner); + _signerCount.increment(); + emit SignerAdded(newSigner); + } + } + + /** + * @notice Removes the current signer. + * @param signer The address of the signer to be removed. + * @dev Only callable by the owner. + */ + function _removeSigner( + address signer + ) internal onlyRole(OWNER_ROLE) { + require(isSigner(signer), 'SignerRole: Signer does not exist'); + + /// Remove Signer ROle + revokeRole(SIGNER_ROLE, signer); + + // Remove signer from the _signers array + uint256 _totalSigners = totalSigners(); + for (uint256 i = 0; i < _totalSigners; i++) { + if (_signers[i] == signer) { + _signers[i] = _signers[_totalSigners - 1]; // Move the last element into the place of the removed signer + _signers.pop(); // Remove the last element + _signerCount.decrement(); + break; + } + } + + emit SignerRemoved(signer); + } +} diff --git a/src/interfaces/IMultiSigEnterpriseVault.sol b/src/interfaces/IMultiSigEnterpriseVault.sol new file mode 100644 index 0000000..432cead --- /dev/null +++ b/src/interfaces/IMultiSigEnterpriseVault.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +/** + * @title IMultiSigEnterpriseVault Interface + * @author Emmanuel Joseph (JET) + * @dev Interface defining events and external functions for the MultiSig Enterprise Vault contract. + */ +interface IMultiSigEnterpriseVault { + /** + * @dev Error thrown when an signers approval is required to perform an action. + */ + error SignersApprovalRequired(); + + /** + * @notice Emitted when the signatory threshold is updated. + * @param newThreshold The new threshold value for signatory approval. + */ + event ThresholdUpdated(uint256 newThreshold); + + /** + * @notice Returns the current signatory threshold for the vault. + * @return The current signatory threshold. + */ + function signatoryThreshold() external view returns (uint256); +} diff --git a/src/interfaces/IUser.sol b/src/interfaces/IUser.sol new file mode 100644 index 0000000..5366eba --- /dev/null +++ b/src/interfaces/IUser.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +/** + * @title IUser Interface + * @author Emmanuel Joseph (JET) + * @dev Interface defining errors related to the User role in the MultiSigVault system. + */ +interface IUser { + /** + * @dev Error thrown when an invalid user profile is encountered. + * @param user The address of the user with an invalid profile. + */ + error InvalidUserProfile(address user); +} diff --git a/src/interfaces/roles/IExecutorRole.sol b/src/interfaces/roles/IExecutorRole.sol new file mode 100644 index 0000000..32d5ad8 --- /dev/null +++ b/src/interfaces/roles/IExecutorRole.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +/** + * @title IExecutorRole Interface + * @author Emmanuel Joseph (JET) + * @dev Interface defining errors and events related to the Executor role in the MultiSigVault system. + */ +interface IExecutorRole { + /** + * @dev Error thrown when an unauthorized account attempts to perform an executor action. + * @param account The address of the unauthorized account. + */ + error AccessControlUnauthorizedExecutor(address account); + + /** + * @dev Event emitted when a new executor is added. + * @param executor The new executor address. + */ + event ExecutorAdded(address indexed executor); + + /** + * @dev Event emitted when an executor is removed. + * @param executor The removed executor address. + */ + event ExecutorRemoved(address indexed executor); + + /** + * @dev Event emitted when an executor is updated. + * @param oldExecutor The old executor address. + * @param newExecutor The new executor address. + */ + event ExecutorUpdated(address oldExecutor, address indexed newExecutor); +} diff --git a/src/interfaces/roles/IOwnerRole.sol b/src/interfaces/roles/IOwnerRole.sol new file mode 100644 index 0000000..d6c018c --- /dev/null +++ b/src/interfaces/roles/IOwnerRole.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +/** + * @title IOwnerRole Interface + * @author Emmanuel Joseph (JET) + * @dev Interface defining errors and events related to the Owner role in the MultiSigVault system. + */ +interface IOwnerRole { + /** + * @dev Error thrown when an invalid owner override limit is provided. + * @param limit The invalid limit value. + */ + error InvalidOwnerOverrideLimitValue(uint256 limit); + + /** + * @dev Error thrown when an unauthorized account attempts to perform an owner action. + * @param account The address of the unauthorized account. + */ + error AccessControlUnauthorizedOwner(address account); + + /** + * @dev Error thrown when an invalid default owner is set. + * @param defaultOwner The invalid owner address. + */ + error AccessControlInvalidDefaultOwner(address defaultOwner); + + /** + * @dev Event emitted when the owner override timelock is increased. + * @param newLimit The new timelock limit for owner override. + */ + event OwnerOverrideTimelockIncreased(uint256 newLimit); + + /** + * @dev Event emitted when the owner override timelock is decreased. + * @param newLimit The new timelock limit for owner override. + */ + event OwnerOverrideTimelockDecreased(uint256 newLimit); +} diff --git a/src/interfaces/roles/ISignerRole.sol b/src/interfaces/roles/ISignerRole.sol new file mode 100644 index 0000000..f1725dc --- /dev/null +++ b/src/interfaces/roles/ISignerRole.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +/** + * @title ISignerRole Interface + * @author Emmanuel Joseph (JET) + * @dev Interface defining errors and events related to the Signer role in the MultiSigVault system. + */ +interface ISignerRole { + /** + * @dev Error thrown when an unauthorized account attempts to perform an signer action. + * @param account The address of the unauthorized account. + */ + error AccessControlUnauthorizedSigner(address account); + + /** + * @dev Event emitted when a new signer is added. + * @param signer The new signer address. + */ + event SignerAdded(address indexed signer); + + /** + * @dev Event emitted when an signer is removed. + * @param signer The removed signer address. + */ + event SignerRemoved(address indexed signer); +} diff --git a/src/libraries/AddressUtils.sol b/src/libraries/AddressUtils.sol new file mode 100644 index 0000000..a48b7e0 --- /dev/null +++ b/src/libraries/AddressUtils.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import '@openzeppelin/contracts/utils/Address.sol'; + +/** + * @title AddressUtils Library + * @dev Extends OpenZeppelin's Address library to add custom utility functions. + */ +library AddressUtils { + using Address for address; + + /// @dev Error thrown when the provided user address is not valid (zero). + error InvalidUserAddress(address account); + + /** + * @notice Checks if the address is valid (i.e., not a zero address). + * @param account The address to validate. + * @return status Returns true if the address is valid, otherwise false. + */ + function isValidUserAddress( + address account + ) internal pure returns (bool status) { + status = account != address(0); + if (!status) { + revert InvalidUserAddress(account); + } + } +} diff --git a/src/libraries/Counters.sol b/src/libraries/Counters.sol new file mode 100644 index 0000000..4ca47ea --- /dev/null +++ b/src/libraries/Counters.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import './SafeMath.sol'; + +/** + * @title Counters Library + * @dev Provides counter functionality that can be incremented or decremented with overflow safety. + */ +library Counters { + using SafeMath for uint256; + + /** + * @notice This struct should be used to manage counters in contracts. + * @dev Struct that holds the counter value. + */ + struct Counter { + uint256 value; // Current value of the counter + } + + /** + * @notice Returns the current value of the counter. + * @param counter The counter to query + * @return The current value of the counter + */ + function current( + Counter storage counter + ) internal view returns (uint256) { + return counter.value; + } + + /** + * @notice Increments the counter by 1. + * @param counter The counter to increment + */ + function increment( + Counter storage counter + ) internal { + counter.value = counter.value.add(1); + } + + /** + * @notice Increments the counter by a specific quantity. + * @param counter The counter to increment + * @param quantity The amount to increment the counter by + */ + function batchIncrement(Counter storage counter, uint256 quantity) internal { + counter.value = counter.value.add(quantity); + } + + /** + * @notice Decrements the counter by 1. + * @param counter The counter to decrement + * @dev Reverts if the counter is already at 0. + */ + function decrement( + Counter storage counter + ) internal { + counter.value = counter.value.subtract(1); + } + + /** + * @notice Decrements the counter by a specific quantity. + * @param counter The counter to decrement + * @param quantity The amount to decrement the counter by + * @dev Reverts if the counter does not have enough value. + */ + function batchDecrement(Counter storage counter, uint256 quantity) internal { + counter.value = counter.value.subtract(quantity); + } +} diff --git a/src/libraries/SafeMath.sol b/src/libraries/SafeMath.sol new file mode 100644 index 0000000..5590801 --- /dev/null +++ b/src/libraries/SafeMath.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +/** + * @title SafeMath Library + * @dev Collection of functions related to uint256 type for performing arithmetic operations safely with overflow checks. + */ +library SafeMath { + /** + * @notice Adds two unsigned integers, reverts on overflow. + * + * @param x First unsigned integer + * @param y Second unsigned integer + * @return r The sum of the two unsigned integers + */ + function add(uint256 x, uint256 y) internal pure returns (uint256 r) { + r = x + y; + require(r >= x, 'SafeMath: addition overflow'); + } + + /** + * @notice Subtracts two unsigned integers, reverts on underflow (when the result is negative). + * + * @param x First unsigned integer + * @param y Second unsigned integer + * @return r The difference of the two unsigned integers + */ + function subtract(uint256 x, uint256 y) internal pure returns (uint256 r) { + require(y <= x, 'SafeMath: subtraction overflow'); + r = x - y; + } + + /** + * @notice Multiplies two unsigned integers, reverts on overflow. + * + * @param x First unsigned integer + * @param y Second unsigned integer + * @return r The product of the two unsigned integers + */ + function multiply(uint256 x, uint256 y) internal pure returns (uint256 r) { + if (x == 0) { + return 0; + } + r = x * y; + require(r / x == y, 'SafeMath: multiplication overflow'); + } + + /** + * @notice Divides two unsigned integers, reverts on division by zero. + * + * @param x First unsigned integer + * @param y Second unsigned integer (must be non-zero) + * @return r The quotient of the two unsigned integers + */ + function divide(uint256 x, uint256 y) internal pure returns (uint256 r) { + require(y > 0, 'SafeMath: division by zero'); + r = x / y; + } + + /** + * @notice Returns the remainder of dividing two unsigned integers. (unsigned integer modulo), + * reverts when dividing by zero. + * + * @param x First unsigned integer + * @param y Second unsigned integer (must be non-zero) + * @return r The remainder of the division + */ + function mod(uint256 x, uint256 y) internal pure returns (uint256 r) { + require(y != 0, 'SafeMath: modulo by zero'); + r = x % y; + } +} diff --git a/src/utilities/VaultConstants.sol b/src/utilities/VaultConstants.sol new file mode 100644 index 0000000..12e6082 --- /dev/null +++ b/src/utilities/VaultConstants.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +/// @dev Role identifier for the Owner role in bytes32 +bytes32 constant OWNER_ROLE = keccak256('RoleType.OWNER'); + +/// @dev Role identifier for the Signer role in bytes32 +bytes32 constant SIGNER_ROLE = keccak256('RoleType.SIGNER'); + +/// @dev Role identifier for the Executor role in bytes32 +bytes32 constant EXECUTOR_ROLE = keccak256('RoleType.EXECUTOR'); diff --git a/src/utilities/VaultEnums.sol b/src/utilities/VaultEnums.sol new file mode 100644 index 0000000..dbd7ebf --- /dev/null +++ b/src/utilities/VaultEnums.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +/** + * @title Vault RoleType Enum + * @author Emmanuel Joseph (JET) + * @notice RoleType helps differentiate between Owner, Executor, and Signer roles. + * + * @dev Enum representing the different roles in the MultiSig Vault contract. + * - OWNER 0: Represents the Owner role + * - EXECUTOR 1: Represents the Executor role + * - SIGNER 2: Represents the Signer role + */ +enum RoleType { + OWNER, + EXECUTOR, + SIGNER +} diff --git a/src/utilities/VaultStructs.sol b/src/utilities/VaultStructs.sol new file mode 100644 index 0000000..417307e --- /dev/null +++ b/src/utilities/VaultStructs.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import {RoleType} from './VaultEnums.sol'; + +/** + * @title UserProfile Struct + * @author Emmanuel Joseph (JET) + * @notice This struct stores the user profile details such as the user address, role, and the timestamp of when they joined. + * + * @dev + * - `user`: The address of the user. + * - `role`: The role assigned to the user (from RoleType enum). + * - `joinedAt`: The timestamp (in seconds) when the user was added to the system. + */ +struct UserProfile { + address user; + RoleType role; + uint256 joinedAt; +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 16b5015..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity ^0.8.27; - -import {Test, console} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/MultiSigEnterpriseVault.t.sol b/test/MultiSigEnterpriseVault.t.sol new file mode 100644 index 0000000..432df9c --- /dev/null +++ b/test/MultiSigEnterpriseVault.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import {Test, console} from 'forge-std/Test.sol'; +import {MultiSigEnterpriseVault} from '../src/MultiSigEnterpriseVault.sol'; + +contract MultiSigEnterpriseVaultTest is Test { + MultiSigEnterpriseVault internal vault; + + address internal vaultOwner; + address internal vaultAddress; + address internal vaultDeployer; + uint256 internal initialThreshold; + uint256 internal initialOwnerOverrideLimit; + + function setUp() public virtual { + initialThreshold = 3; + initialOwnerOverrideLimit = 3 days; + vaultOwner = makeAddr('vaultOwner'); + + vault = new MultiSigEnterpriseVault(vaultOwner, initialThreshold, initialOwnerOverrideLimit); + vaultAddress = address(vault); + vaultDeployer = msg.sender; + } +} diff --git a/test/components/BaseMultiSigTest.t.sol b/test/components/BaseMultiSigTest.t.sol new file mode 100644 index 0000000..8ce67b6 --- /dev/null +++ b/test/components/BaseMultiSigTest.t.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import '../MultiSigEnterpriseVault.t.sol'; + +contract BaseMultiSigTest is MultiSigEnterpriseVaultTest { + function testOwnerAddress() public view { + assertEq(vault.owner(), vaultOwner); + } + + function testTotalUsers() public { + vm.prank(vaultOwner); + assertEq(vault.totalUsers(), 1); + } + + function testInitialThreshold() public view { + assertEq(vault.signatoryThreshold(), initialThreshold); + } +} diff --git a/test/components/MultiSigFuzzTest.t.sol b/test/components/MultiSigFuzzTest.t.sol new file mode 100644 index 0000000..0677314 --- /dev/null +++ b/test/components/MultiSigFuzzTest.t.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import '../MultiSigEnterpriseVault.t.sol'; + +contract MultiSigFuzzTest is MultiSigEnterpriseVaultTest { + function testFuzzRemoveSigner( + address signer + ) public { + vm.assume(signer != address(0)); + + vm.prank(vaultOwner); + vault.addSigner(signer); + assertTrue(vault.isSigner(signer)); + + vm.prank(vaultOwner); + vault.removeSigner(signer); + assertFalse(vault.isSigner(signer)); + } + + function testFuzzUpdateExecutor( + address newExecutor + ) public { + vm.assume(newExecutor != address(0)); + + vm.prank(vaultOwner); + vault.addExecutor(newExecutor); + assertEq(vault.executor(), newExecutor); + + address anotherExecutor = makeAddr('randomExecutor'); + vm.prank(vaultOwner); + vault.updateExecutor(anotherExecutor); + assertEq(vault.executor(), anotherExecutor); + } + + function testFuzzIncreaseOwnerOverrideTimelock( + uint256 newLimit + ) public { + vm.assume(newLimit > vault.ownerOverrideTimelock()); + + vm.prank(vaultOwner); + vault.increaseOwnerOverrideTimelockLimit(newLimit); + assertEq(vault.ownerOverrideTimelock(), newLimit); + } + + function testFuzzUpdateThreshold( + uint256 newThreshold + ) public { + vm.assume(newThreshold > 0); + vm.prank(vaultOwner); + vault.ownerUpdateSignatoryThreshold(newThreshold); + assertEq(vault.signatoryThreshold(), newThreshold); + } +} diff --git a/test/components/roles/ExecutorRoleTest.t.sol b/test/components/roles/ExecutorRoleTest.t.sol new file mode 100644 index 0000000..c820bce --- /dev/null +++ b/test/components/roles/ExecutorRoleTest.t.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import '../../MultiSigEnterpriseVault.t.sol'; +import {UserProfile} from '../../../src/utilities/VaultStructs.sol'; + +contract ExecutorRoleTest is MultiSigEnterpriseVaultTest { + address internal vaultExecutor; + + function setUp() public override { + super.setUp(); + vaultExecutor = makeAddr('vaultExecutor'); + } + + function testOwnerCanAddExecutor() public { + vm.prank(vaultOwner); + vault.addExecutor(vaultExecutor); + + vm.prank(vaultOwner); + UserProfile memory executor = vault.getUserProfile(vaultExecutor); + + assertEq(vault.executor(), vaultExecutor); + assertEq(executor.user, vaultExecutor); + } + + function testAddExecutorWhenAlreadyExists() public { + vm.prank(vaultOwner); + vault.addExecutor(vaultExecutor); + + // Try to add a new executor when one already exists + vm.expectRevert(); + vault.addExecutor(makeAddr('anotherExecutor')); + } + + function testOwnerCanUpdateExecutor() public { + vm.prank(vaultOwner); + vault.addExecutor(vaultExecutor); + assertEq(vault.executor(), vaultExecutor); + + // Update the executor + vm.prank(vaultOwner); + address updatedExecutor = makeAddr('updatedExecutor'); + vault.updateExecutor(updatedExecutor); + + // Check if the executor is updated correctly + assertEq(vault.executor(), updatedExecutor); + } + + function testUpdateExecutorWhenNoneExists() public { + vm.prank(vaultOwner); + vm.expectRevert(); + vault.updateExecutor(makeAddr('updatedExecutor')); + } + + function testOwnerCanRemoveExecutor() public { + vm.prank(vaultOwner); + vault.addExecutor(vaultExecutor); + + // Remove the executor + vm.prank(vaultOwner); + vault.removeExecutor(); + + // Ensure executor is removed + assertEq(vault.executor(), address(0)); + } + + function testRemoveExecutorWhenNoneExists() public { + vm.prank(vaultOwner); + vm.expectRevert(); + vault.removeExecutor(); + } + + function testNonOwnerCannotAddExecutor() public { + vm.prank(address(0x5678)); + vm.expectRevert(); + vault.addExecutor(vaultExecutor); + } +} diff --git a/test/components/roles/OwnerRoleTest.t.sol b/test/components/roles/OwnerRoleTest.t.sol new file mode 100644 index 0000000..1bc9b54 --- /dev/null +++ b/test/components/roles/OwnerRoleTest.t.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import '../../MultiSigEnterpriseVault.t.sol'; +import {RoleType} from '../../../src/utilities/VaultEnums.sol'; +import {UserProfile} from '../../../src/utilities/VaultStructs.sol'; + +contract OwnerRoleTest is MultiSigEnterpriseVaultTest { + function testOwnerProfile() public { + vm.prank(vaultOwner); + UserProfile memory adminUser = vault.getUserProfile(vaultOwner); + assertEq(adminUser.user, vaultOwner); + } + + function testInvalidUserProfile() public { + vm.prank(vaultOwner); + vm.expectRevert(); + vault.getUserProfile(address(0x5678)); + } + + function testOnlyOwnerCanUpdateThreshold() public { + vm.prank(address(0x5678)); + vm.expectRevert(); + vault.ownerUpdateSignatoryThreshold(5); + } + + function testUpdateSignatoryThreshold() public { + vm.prank(vaultOwner); + uint256 newSignatoryThreshold = 5; + vault.ownerUpdateSignatoryThreshold(newSignatoryThreshold); + assertEq(vault.signatoryThreshold(), newSignatoryThreshold); + } + + function testUnauthorizedTimelockUpdate() public { + vm.prank(address(0x5678)); + vm.expectRevert(); + vault.increaseOwnerOverrideTimelockLimit(5 days); + } + + function testOwnerOverrideTimelock() public view { + assertEq(vault.ownerOverrideTimelock(), initialOwnerOverrideLimit); + } + + function testOwnerCanIncreaseOverrideTimelock() public { + vm.prank(vaultOwner); + vault.increaseOwnerOverrideTimelockLimit(5 days); + assertEq(vault.ownerOverrideTimelock(), 5 days); + } + + function testOwnerCanDecreaseOverrideTimelock() public { + vm.prank(vaultOwner); + vault.decreaseOwnerOverrideTimelockLimit(24 hours); + assertEq(vault.ownerOverrideTimelock(), 24 hours); + } +} diff --git a/test/components/roles/SignerRoleTest.t.sol b/test/components/roles/SignerRoleTest.t.sol new file mode 100644 index 0000000..1ad93f2 --- /dev/null +++ b/test/components/roles/SignerRoleTest.t.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.27; + +import '../../MultiSigEnterpriseVault.t.sol'; + +contract SignerRoleTest is MultiSigEnterpriseVaultTest { + address internal firstSigner; + address internal secondSigner; + + function setUp() public override { + super.setUp(); + firstSigner = makeAddr('firstSigner'); + secondSigner = makeAddr('secondSigner'); + } + + function testOwnerCanAddSigner() public { + vm.prank(vaultOwner); + vault.addSigner(firstSigner); + + // Check if the signer was added correctly via `_signers` + assertEq(vault.totalSigners(), 1); + address[] memory signers = vault.getSigners(); + assertEq(signers[0], firstSigner); + + // Check if the signer was added correctly via `_users` + vm.prank(vaultOwner); + address signerProfileAddress = vault.getUserProfile(firstSigner).user; + assertEq(signerProfileAddress, firstSigner); + } + + function testOwnerCannotAddInvalidSigner() public { + vm.prank(vaultOwner); + address invalidSigner = address(0); + vm.expectRevert(); + vault.addSigner(invalidSigner); + } + + function testOwnerCannotUpdateThresholdWithoutApproval() public { + vm.prank(vaultOwner); + vault.addSigner(firstSigner); + vm.prank(vaultOwner); + vault.addSigner(secondSigner); + vm.prank(vaultOwner); + vault.addSigner(vaultDeployer); + assertEq(vault.totalSigners(), 3); + + vm.prank(vaultOwner); + vm.expectRevert(); + vault.ownerUpdateSignatoryThreshold(5); + } + + function testSignerCannotAddAnotherSigner() public { + // Add signer as the owner + vm.prank(vaultOwner); + vault.addSigner(firstSigner); + + // Try to add another signer as a non-owner + vm.prank(firstSigner); + vm.expectRevert(); + vault.addSigner(secondSigner); + } + + function testRemoveSigner() public { + vm.prank(vaultOwner); + vault.addSigner(firstSigner); + + // Remove signer + vm.prank(vaultOwner); + vault.removeSigner(firstSigner); + + // Verify the signer was removed via `_signers` + assertEq(vault.totalSigners(), 0); + address[] memory signers = vault.getSigners(); + assertEq(signers.length, 0); + + // Verify the signer was removed via `_users` + vm.prank(vaultOwner); + vm.expectRevert(); + vault.getUserProfile(firstSigner); + } + + function testCannotRemoveNonexistentSigner() public { + vm.prank(vaultOwner); + vm.expectRevert(); + vault.removeSigner(firstSigner); + } + + function testCannotAddSameSignerTwice() public { + vm.prank(vaultOwner); + vault.addSigner(firstSigner); + + // Try adding the same signer again + vm.prank(vaultOwner); + vm.expectRevert(); + vault.addSigner(firstSigner); + } +}