diff --git a/contracts/interfaces/IRoles.sol b/contracts/interfaces/IRoles.sol new file mode 100644 index 00000000..97a71e7b --- /dev/null +++ b/contracts/interfaces/IRoles.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import { IRolesBase } from './IRolesBase.sol'; + +/** + * @title IRoles Interface + * @notice IRoles is an interface that abstracts the implementation of a + * contract with role control features. It's commonly included for the functionality to + * get current role, transfer role, and propose and accept role. + */ +interface IRoles is IRolesBase { + error InvalidProposedAccount(address account); + + /** + * @notice Checks if an account has all the roles. + * @param account The address to check + * @param roles The roles to check + * @return True if the account has all the roles, false otherwise + */ + function hasAllTheRoles(address account, uint8[] memory roles) external view returns (bool); + + /** + * @notice Checks if an account has any of the roles. + * @param account The address to check + * @param roles The roles to check + * @return True if the account has any of the roles, false otherwise + */ + function hasAnyOfRoles(address account, uint8[] memory roles) external view returns (bool); + + /** + * @notice Returns the roles of an account. + * @param account The address to get the roles for + * @return accountRoles The roles of the account in uint256 format + */ + function getAccountRoles(address account) external view returns (uint256 accountRoles); + + /** + * @notice Returns the pending role of the contract. + * @param fromAccount The address with the current roles + * @param toAccount The address with the pending roles + * @return proposedRoles_ The pending role of the contract in uint256 format + */ + function getProposedRoles(address fromAccount, address toAccount) external view returns (uint256 proposedRoles_); + + /** + * @notice Transfers roles of the contract to a new account. + * @dev Can only be called by the account with all the roles. + * @dev Emits RolesRemoved and RolesAdded events. + * @param toAccount The address to transfer role to + * @param roles The roles to transfer + */ + function transferRoles(address toAccount, uint8[] memory roles) external; + + /** + * @notice Propose to transfer roles of message sender to a new account. + * @dev Can only be called by the account with all the proposed roles. + * @dev emits a RolesProposed event. + * @dev Roles are not transferred until the new role accepts the role transfer. + * @param toAccount The address to transfer role to + * @param roles The roles to transfer + */ + function proposeRoles(address toAccount, uint8[] memory roles) external; + + /** + * @notice Accepts roles transferred from another account. + * @dev Can only be called by the pending account with all the proposed roles. + * @dev Emits RolesRemoved and RolesAdded events. + * @param fromAccount The address of the current role + * @param roles The roles to accept + */ + function acceptRoles(address fromAccount, uint8[] memory roles) external; +} diff --git a/contracts/interfaces/IRolesBase.sol b/contracts/interfaces/IRolesBase.sol new file mode 100644 index 00000000..659f5234 --- /dev/null +++ b/contracts/interfaces/IRolesBase.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @title IRolesBase Interface + * @notice IRolesBase is an interface that abstracts the implementation of a + * contract with role control internal functions. + */ +interface IRolesBase { + error MissingRole(address account, uint8 role); + error MissingAllRoles(address account, uint8[] roles); + error MissingAnyOfRoles(address account, uint8[] roles); + + error InvalidProposedRoles(address fromAccount, address toAccount, uint8[] roles); + + event RolesProposed(address indexed fromAccount, address indexed toAccount, uint8[] roles); + event RolesAdded(address indexed account, uint8[] roles); + event RolesRemoved(address indexed account, uint8[] roles); + + /** + * @notice Checks if an account has a role. + * @param account The address to check + * @param role The role to check + * @return True if the account has the role, false otherwise + */ + function hasRole(address account, uint8 role) external view returns (bool); +} diff --git a/contracts/test/utils/TestRoles.sol b/contracts/test/utils/TestRoles.sol new file mode 100644 index 00000000..a5f32361 --- /dev/null +++ b/contracts/test/utils/TestRoles.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import { Roles } from '../../utils/Roles.sol'; + +contract TestRoles is Roles { + error InvalidRolesLength(); + + event NumSet(uint256 _num); + + uint256 public num; + + constructor(address[] memory accounts, uint8[][] memory roleSets) { + uint256 length = accounts.length; + if (length != roleSets.length) revert InvalidRolesLength(); + + for (uint256 i = 0; i < length; ++i) { + _addRoles(accounts[i], roleSets[i]); + } + } + + function setNum(uint256 _num, uint8 role) external onlyRole(role) { + num = _num; + emit NumSet(_num); + } + + function setNumWithAllRoles(uint256 _num, uint8[] calldata roles) external withEveryRole(roles) { + num = _num; + emit NumSet(_num); + } + + function setNumWithAnyRoles(uint256 _num, uint8[] calldata roles) external withAnyRole(roles) { + num = _num; + emit NumSet(_num); + } + + function addRole(address account, uint8 role) external { + _addRole(account, role); + } + + function removeRole(address account, uint8 role) external { + _removeRole(account, role); + } +} diff --git a/contracts/utils/Roles.sol b/contracts/utils/Roles.sol new file mode 100644 index 00000000..54fdd2d7 --- /dev/null +++ b/contracts/utils/Roles.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import { IRoles } from '../interfaces/IRoles.sol'; +import { RolesBase } from './RolesBase.sol'; + +/** + * @title Roles + * @notice A contract module which provides set of external functions providing basic role transferring functionality. + * + * @notice The role account is set through role transfer. This module makes + * it possible to transfer the role of the contract to a new account in one + * step, as well as to an interim pending role. In the second flow the role does not + * change until the pending role accepts the role transfer. + */ +contract Roles is RolesBase, IRoles { + /** + * @notice Checks if an account has all the roles. + * @param account The address to check + * @param roles The roles to check + * @return True if the account has all the roles, false otherwise + */ + function hasAllTheRoles(address account, uint8[] memory roles) public view returns (bool) { + return _hasAllTheRoles(_getRoles(account), roles); + } + + /** + * @notice Checks if an account has any of the roles. + * @param account The address to check + * @param roles The roles to check + * @return True if the account has any of the roles, false otherwise + */ + function hasAnyOfRoles(address account, uint8[] memory roles) public view returns (bool) { + return _hasAnyOfRoles(_getRoles(account), roles); + } + + /** + * @notice Returns the roles of an account. + * @param account The address to get the roles for + * @return accountRoles The roles of the account in uint256 format + */ + function getAccountRoles(address account) public view returns (uint256 accountRoles) { + accountRoles = _getRoles(account); + } + + /** + * @notice Returns the pending role of the contract. + * @param fromAccount The address with the current roles + * @param toAccount The address with the pending roles + * @return proposedRoles_ The pending role of the contract in uint256 format + */ + function getProposedRoles(address fromAccount, address toAccount) public view returns (uint256 proposedRoles_) { + proposedRoles_ = _getProposedRoles(fromAccount, toAccount); + } + + /** + * @notice Propose to transfer roles of message sender to a new account. + * @dev Can only be called by the account with all the proposed roles. + * @dev emits a RolesProposed event. + * @dev Roles are not transferred until the new role accepts the role transfer. + * @param toAccount The address to transfer role to + * @param roles The roles to transfer + */ + function proposeRoles(address toAccount, uint8[] memory roles) external virtual { + if (toAccount == address(0) || toAccount == msg.sender) revert InvalidProposedAccount(toAccount); + + _proposeRoles(msg.sender, toAccount, roles); + } + + /** + * @notice Accepts roles transferred from another account. + * @dev Can only be called by the pending account with all the proposed roles. + * @dev Emits RolesRemoved and RolesAdded events. + * @param fromAccount The address of the current role + * @param roles The roles to accept + */ + function acceptRoles(address fromAccount, uint8[] memory roles) external virtual { + _acceptRoles(fromAccount, msg.sender, roles); + } + + /** + * @notice Transfers roles of the contract to a new account. + * @dev Can only be called by the account with all the roles. + * @dev Emits RolesRemoved and RolesAdded events. + * @param toAccount The address to transfer role to + * @param roles The roles to transfer + */ + function transferRoles(address toAccount, uint8[] memory roles) external virtual { + if (toAccount == address(0) || toAccount == msg.sender) revert InvalidProposedAccount(toAccount); + + _transferRoles(msg.sender, toAccount, roles); + } +} diff --git a/contracts/utils/RolesBase.sol b/contracts/utils/RolesBase.sol new file mode 100644 index 00000000..3909bfc8 --- /dev/null +++ b/contracts/utils/RolesBase.sol @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import { IRolesBase } from '../interfaces/IRolesBase.sol'; + +/** + * @title RolesBase + * @notice A contract module which provides a set if internal functions + * for implementing role control features. + */ +contract RolesBase is IRolesBase { + bytes32 internal constant ROLES_PREFIX = keccak256('roles'); + bytes32 internal constant PROPOSE_ROLES_PREFIX = keccak256('propose-roles'); + + /** + * @notice Modifier that throws an error if called by any account missing the role. + */ + modifier onlyRole(uint8 role) { + if (!_hasRole(_getRoles(msg.sender), role)) revert MissingRole(msg.sender, role); + + _; + } + + /** + * @notice Modifier that throws an error if called by an account without all the roles. + */ + modifier withEveryRole(uint8[] memory roles) { + if (!_hasAllTheRoles(_getRoles(msg.sender), roles)) revert MissingAllRoles(msg.sender, roles); + + _; + } + + /** + * @notice Modifier that throws an error if called by an account without any of the roles. + */ + modifier withAnyRole(uint8[] memory roles) { + if (!_hasAnyOfRoles(_getRoles(msg.sender), roles)) revert MissingAnyOfRoles(msg.sender, roles); + + _; + } + + /** + * @notice Checks if an account has a role. + * @param account The address to check + * @param role The role to check + * @return True if the account has the role, false otherwise + */ + function hasRole(address account, uint8 role) public view returns (bool) { + return _hasRole(_getRoles(account), role); + } + + /** + * @notice Internal function to convert an array of roles to a uint256. + * @param roles The roles to convert + * @return accountRoles The roles in uint256 format + */ + function _toAccountRoles(uint8[] memory roles) internal pure returns (uint256) { + uint256 length = roles.length; + uint256 accountRoles; + + for (uint256 i = 0; i < length; ++i) { + accountRoles |= (1 << roles[i]); + } + + return accountRoles; + } + + /** + * @notice Internal function to get the key of the roles mapping. + * @param account The address to get the key for + * @return key The key of the roles mapping + */ + function _rolesKey(address account) internal view virtual returns (bytes32 key) { + return keccak256(abi.encodePacked(ROLES_PREFIX, account)); + } + + /** + * @notice Internal function to get the roles of an account. + * @param account The address to get the roles for + * @return accountRoles The roles of the account in uint256 format + */ + function _getRoles(address account) internal view returns (uint256 accountRoles) { + bytes32 key = _rolesKey(account); + assembly { + accountRoles := sload(key) + } + } + + /** + * @notice Internal function to set the roles of an account. + * @param account The address to set the roles for + * @param accountRoles The roles to set + */ + function _setRoles(address account, uint256 accountRoles) private { + bytes32 key = _rolesKey(account); + assembly { + sstore(key, accountRoles) + } + } + + /** + * @notice Internal function to get the key of the proposed roles mapping. + * @param fromAccount The address of the current role + * @param toAccount The address of the pending role + * @return key The key of the proposed roles mapping + */ + function _proposalKey(address fromAccount, address toAccount) internal view virtual returns (bytes32 key) { + return keccak256(abi.encodePacked(PROPOSE_ROLES_PREFIX, fromAccount, toAccount)); + } + + /** + * @notice Internal function to get the proposed roles of an account. + * @param fromAccount The address of the current role + * @param toAccount The address of the pending role + * @return proposedRoles_ The proposed roles of the account in uint256 format + */ + function _getProposedRoles(address fromAccount, address toAccount) internal view returns (uint256 proposedRoles_) { + bytes32 key = _proposalKey(fromAccount, toAccount); + assembly { + proposedRoles_ := sload(key) + } + } + + /** + * @notice Internal function to set the proposed roles of an account. + * @param fromAccount The address of the current role + * @param toAccount The address of the pending role + * @param proposedRoles_ The proposed roles to set in uint256 format + */ + function _setProposedRoles( + address fromAccount, + address toAccount, + uint256 proposedRoles_ + ) private { + bytes32 key = _proposalKey(fromAccount, toAccount); + assembly { + sstore(key, proposedRoles_) + } + } + + /** + * @notice Internal function to add a role to an account. + * @dev emits a RolesAdded event. + * @param account The address to add the role to + * @param role The role to add + */ + function _addRole(address account, uint8 role) internal { + uint8[] memory roles = new uint8[](1); + roles[0] = role; + _addRoles(account, roles); + } + + /** + * @notice Internal function to add roles to an account. + * @dev emits a RolesAdded event. + * @dev Called in the constructor to set the initial roles. + * @param account The address to add roles to + * @param roles The roles to add + */ + function _addRoles(address account, uint8[] memory roles) internal { + uint256 accountRoles = _getRoles(account); + uint256 length = roles.length; + + for (uint256 i = 0; i < length; ++i) { + accountRoles |= (1 << roles[i]); + } + + _setRoles(account, accountRoles); + + emit RolesAdded(account, roles); + } + + /** + * @notice Internal function to remove a role from an account. + * @dev emits a RolesRemoved event. + * @param account The address to remove the role from + * @param role The role to remove + */ + function _removeRole(address account, uint8 role) internal { + uint8[] memory roles = new uint8[](1); + roles[0] = role; + _removeRoles(account, roles); + } + + /** + * @notice Internal function to remove roles from an account. + * @dev emits a RolesRemoved event. + * @param account The address to remove roles from + * @param roles The roles to remove + */ + function _removeRoles(address account, uint8[] memory roles) internal { + uint256 accountRoles = _getRoles(account); + uint256 length = roles.length; + + for (uint256 i = 0; i < length; ++i) { + accountRoles &= ~(1 << roles[i]); + } + + _setRoles(account, accountRoles); + + emit RolesRemoved(account, roles); + } + + /** + * @notice Internal function to check if an account has a role. + * @param accountRoles The roles of the account in uint256 format + * @param role The role to check + * @return True if the account has the role, false otherwise + */ + function _hasRole(uint256 accountRoles, uint8 role) internal pure returns (bool) { + return accountRoles & (1 << role) != 0; + } + + /** + * @notice Internal function to check if an account has all the roles. + * @param accountRoles The roles of the account in uint256 format + * @param roles The roles to check + * @return True if the account has all the roles, false otherwise + */ + function _hasAllTheRoles(uint256 accountRoles, uint8[] memory roles) internal pure returns (bool) { + uint256 length = roles.length; + + for (uint256 i = 0; i < length; ++i) { + if (accountRoles & (1 << roles[i]) == 0) { + return false; + } + } + + return true; + } + + /** + * @notice Internal function to check if an account has any of the roles. + * @param accountRoles The roles of the account in uint256 format + * @param roles The roles to check + * @return True if the account has any of the roles, false otherwise + */ + function _hasAnyOfRoles(uint256 accountRoles, uint8[] memory roles) internal pure returns (bool) { + uint256 length = roles.length; + + for (uint256 i = 0; i < length; ++i) { + if (accountRoles & (1 << roles[i]) != 0) { + return true; + } + } + + return false; + } + + /** + * @notice Internal function to propose to transfer roles of message sender to a new account. + * @dev Original account must have all the proposed roles. + * @dev Emits a RolesProposed event. + * @dev Roles are not transferred until the new role accepts the role transfer. + * @param fromAccount The address of the current roles + * @param toAccount The address to transfer roles to + * @param roles The roles to transfer + */ + function _proposeRoles( + address fromAccount, + address toAccount, + uint8[] memory roles + ) internal { + if (!_hasAllTheRoles(_getRoles(fromAccount), roles)) revert MissingAllRoles(fromAccount, roles); + + _setProposedRoles(fromAccount, toAccount, _toAccountRoles(roles)); + + emit RolesProposed(fromAccount, toAccount, roles); + } + + /** + * @notice Internal function to accept roles transferred from another account. + * @dev Pending account needs to pass all the proposed roles. + * @dev Emits RolesRemoved and RolesAdded events. + * @param fromAccount The address of the current role + * @param roles The roles to accept + */ + function _acceptRoles( + address fromAccount, + address toAccount, + uint8[] memory roles + ) internal virtual { + if (_getProposedRoles(fromAccount, toAccount) != _toAccountRoles(roles)) { + revert InvalidProposedRoles(fromAccount, toAccount, roles); + } + + _setProposedRoles(fromAccount, toAccount, 0); + _transferRoles(fromAccount, toAccount, roles); + } + + /** + * @notice Internal function to transfer roles from one account to another. + * @dev Original account must have all the proposed roles. + * @param fromAccount The address of the current role + * @param toAccount The address to transfer role to + * @param roles The roles to transfer + */ + function _transferRoles( + address fromAccount, + address toAccount, + uint8[] memory roles + ) internal { + if (!_hasAllTheRoles(_getRoles(fromAccount), roles)) revert MissingAllRoles(fromAccount, roles); + + _removeRoles(fromAccount, roles); + _addRoles(toAccount, roles); + } +} diff --git a/package-lock.json b/package-lock.json index aeeedc43..f2780d55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@axelar-network/axelar-gmp-sdk-solidity", - "version": "5.4.0", + "version": "5.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@axelar-network/axelar-gmp-sdk-solidity", - "version": "5.4.0", + "version": "5.5.0", "license": "MIT", "devDependencies": { "@axelar-network/axelar-chains-config": "^0.1.2", diff --git a/package.json b/package.json index ec299509..7ef0fd63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axelar-network/axelar-gmp-sdk-solidity", - "version": "5.4.0", + "version": "5.5.0", "description": "Solidity GMP SDK and utilities provided by Axelar for cross-chain development", "main": "index.js", "scripts": { diff --git a/scripts/create2Deployer.js b/scripts/create2Deployer.js index c3397991..a1b67a17 100644 --- a/scripts/create2Deployer.js +++ b/scripts/create2Deployer.js @@ -96,8 +96,7 @@ const create2DeployAndInitContract = async ( const contract = new Contract(address, contractJson.abi, wallet); const initData = (await contract.populateTransaction.init(...initArgs)).data; - const tx = await deployer - .deployAndInit(bytecode, salt, initData, txOptions); + const tx = await deployer.deployAndInit(bytecode, salt, initData, txOptions); await tx.wait(confirmations); return contract; diff --git a/scripts/create3Deployer.js b/scripts/create3Deployer.js index a7b6d293..6e6e5797 100644 --- a/scripts/create3Deployer.js +++ b/scripts/create3Deployer.js @@ -85,8 +85,7 @@ const create3DeployAndInitContract = async ( const contract = new Contract(address, contractJson.abi, wallet); const initData = (await contract.populateTransaction.init(...initArgs)).data; - const tx = await deployer - .deployAndInit(bytecode, salt, initData, txOptions); + const tx = await deployer.deployAndInit(bytecode, salt, initData, txOptions); await tx.wait(confirmations); return contract; diff --git a/test/utils.js b/test/utils.js index aff211be..46d4b322 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,6 +1,7 @@ 'use strict'; const { config, ethers, network } = require('hardhat'); +const { expect } = require('chai'); const { utils: { defaultAbiCoder, id, arrayify, keccak256 }, } = ethers; @@ -62,6 +63,18 @@ const waitFor = async (timeDelay) => { } }; +const expectRevert = async (txFunc, contract, error, args) => { + if (network.config.skipRevertTests) { + await expect(txFunc(getGasOptions())).to.be.reverted; + } else { + await expect(txFunc(null)).to.be.revertedWithCustomError( + contract, + error, + args, + ); + } +}; + module.exports = { bigNumberToNumber: (bigNumber) => bigNumber.toNumber(), @@ -97,4 +110,6 @@ module.exports = { getPayloadAndProposalHash, waitFor, + + expectRevert, }; diff --git a/test/utils/Roles.js b/test/utils/Roles.js new file mode 100644 index 00000000..9fde9de4 --- /dev/null +++ b/test/utils/Roles.js @@ -0,0 +1,411 @@ +'use strict'; + +const chai = require('chai'); +const { expect } = chai; +const { ethers } = require('hardhat'); +const { + constants: { AddressZero }, +} = require('ethers'); +const { expectRevert } = require('../utils'); + +describe('Roles', () => { + let testRolesFactory; + let testRoles; + + let ownerWallet; + let userWallet; + + let accounts; + let roleSets; + + before(async () => { + [ownerWallet, userWallet] = await ethers.getSigners(); + + testRolesFactory = await ethers.getContractFactory( + 'TestRoles', + ownerWallet, + ); + }); + + describe('negative tests', () => { + beforeEach(async () => { + accounts = [ownerWallet.address]; + roleSets = [[1, 2, 3]]; + + testRoles = await testRolesFactory + .deploy(accounts, roleSets) + .then((d) => d.deployed()); + }); + + it('should revert on deployment with invalid roles length', async () => { + accounts.push(userWallet.address); + + await expectRevert( + (gasOptions) => testRolesFactory.deploy(accounts, roleSets, gasOptions), + testRoles, + 'InvalidRolesLength', + ); + + accounts.pop(); + }); + + it('should revert when non-role account calls onlyRole function', async () => { + const num = 5; + const role = 1; + + await expectRevert( + (gasOptions) => + testRoles.connect(userWallet).setNum(num, role, gasOptions), + testRoles, + 'MissingRole', + { + account: userWallet.address, + role, + }, + ); + }); + + it('should not revert when role account calls onlyRole function', async () => { + const num = 5; + const role = 1; + + await expect(testRoles.connect(ownerWallet).setNum(num, role)) + .to.emit(testRoles, 'NumSet') + .withArgs(num); + }); + + it('should revert when non-role account calls withAllTheRoles function', async () => { + const num = 5; + const roles = [1, 2, 3]; + + await expectRevert( + (gasOptions) => + testRoles + .connect(userWallet) + .setNumWithAllRoles(num, roles, gasOptions), + testRoles, + 'MissingAllRoles', + { + account: userWallet.address, + roles, + }, + ); + }); + + it('should not revert when role account calls withAllTheRoles function', async () => { + const num = 5; + const roles = [1, 2, 3]; + + await expect( + testRoles.connect(ownerWallet).setNumWithAllRoles(num, roles), + ) + .to.emit(testRoles, 'NumSet') + .withArgs(num); + }); + + it('should revert when non-role account calls withAnyOfRoles function', async () => { + const num = 5; + const roles = [1, 2, 3]; + + await expectRevert( + (gasOptions) => + testRoles + .connect(userWallet) + .setNumWithAnyRoles(num, roles, gasOptions), + testRoles, + 'MissingAnyOfRoles', + { + account: userWallet.address, + roles, + }, + ); + }); + + it('should not revert when role account calls withAnyOfRoles function', async () => { + const num = 5; + const roles = [1, 2, 3]; + + await expect( + testRoles.connect(ownerWallet).setNumWithAnyRoles(num, roles), + ) + .to.emit(testRoles, 'NumSet') + .withArgs(num); + }); + }); + + describe('negative tests for role transfers', () => { + beforeEach(async () => { + const accounts = [ownerWallet.address]; + const roleSets = [[1, 2, 3]]; + + testRoles = await testRolesFactory + .deploy(accounts, roleSets) + .then((d) => d.deployed()); + }); + + it('should revert on acceptRoles if called by an account without all the proposed roles', async () => { + const roles = [1, 2, 3]; + + await testRoles + .proposeRoles(userWallet.address, roles) + .then((tx) => tx.wait()); + + await expectRevert( + (gasOptions) => + testRoles + .connect(ownerWallet) + .acceptRoles(ownerWallet.address, roles, gasOptions), + testRoles, + 'InvalidProposedRoles', + { + fromAccount: ownerWallet.address, + toAccount: ownerWallet.address, + roles, + }, + ); + }); + + it('should revert on transferRoles if called by an account without all the proposed roles', async () => { + const roles = [1, 2, 3]; + + await testRoles + .transferRoles(userWallet.address, [3]) + .then((tx) => tx.wait()); + + await expectRevert( + (gasOptions) => + testRoles + .connect(ownerWallet) + .transferRoles(userWallet.address, roles, gasOptions), + testRoles, + 'MissingAllRoles', + { + account: ownerWallet.address, + roles, + }, + ); + }); + + it('should revert on transferRoles if transferred to an invalid account', async () => { + const roles = [1, 2, 3]; + + await expectRevert( + (gasOptions) => testRoles.transferRoles(AddressZero, roles, gasOptions), + testRoles, + 'InvalidProposedAccount', + { + account: AddressZero, + }, + ); + + await expectRevert( + (gasOptions) => + testRoles.transferRoles(ownerWallet.address, roles, gasOptions), + testRoles, + 'InvalidProposedAccount', + { + account: ownerWallet.address, + }, + ); + }); + + it('should revert on proposeRoles if called by an account without all the proposed roles', async () => { + const roles = [1, 2, 3]; + + await testRoles + .transferRoles(userWallet.address, [3]) + .then((tx) => tx.wait()); + + await expectRevert( + (gasOptions) => + testRoles + .connect(ownerWallet) + .proposeRoles(userWallet.address, roles, gasOptions), + testRoles, + 'MissingAllRoles', + { + account: ownerWallet.address, + roles, + }, + ); + }); + + it('should revert on proposeRoles if proposed to an invalid account', async () => { + const roles = [1, 2, 3]; + + await expectRevert( + (gasOptions) => + testRoles + .connect(ownerWallet) + .proposeRoles(AddressZero, roles, gasOptions), + testRoles, + 'InvalidProposedAccount', + { + account: AddressZero, + }, + ); + + await expectRevert( + (gasOptions) => + testRoles + .connect(ownerWallet) + .proposeRoles(ownerWallet.address, roles, gasOptions), + testRoles, + 'InvalidProposedAccount', + { + account: ownerWallet.address, + }, + ); + }); + + it('should revert on acceptRoles if called with incorrect proposed roles', async () => { + const roles = [1, 2, 3]; + const incorrectRoles = [1, 2]; + + await testRoles + .proposeRoles(userWallet.address, roles) + .then((tx) => tx.wait()); + + await expectRevert( + (gasOptions) => + testRoles + .connect(userWallet) + .acceptRoles(ownerWallet.address, incorrectRoles, gasOptions), + testRoles, + 'InvalidProposedRoles', + { + fromAccount: ownerWallet.address, + toAccount: userWallet.address, + roles: incorrectRoles, + }, + ); + }); + }); + + describe('positive tests', () => { + let roleSets; + + beforeEach(async () => { + const accounts = [ownerWallet.address]; + roleSets = [[1, 2, 3]]; + + testRoles = await testRolesFactory + .deploy(accounts, roleSets) + .then((d) => d.deployed()); + }); + + it('should set the initial roles and return the current roles', async () => { + const currentRoles = await testRoles.getAccountRoles(ownerWallet.address); + + expect(currentRoles).to.equal(14); // 14 is the binary representation of roles [1, 2, 3] + }); + + it('should return if an account has a specific role', async () => { + const hasRole = await testRoles.hasRole( + ownerWallet.address, + roleSets[0][0], + ); + + expect(hasRole).to.be.true; + + const hasRoleNegative = await testRoles.hasRole( + userWallet.address, + roleSets[0][0], + ); + + expect(hasRoleNegative).to.be.false; + }); + + it('should return if an account has all the roles', async () => { + const hasAllTheRoles = await testRoles.hasAllTheRoles( + ownerWallet.address, + roleSets[0], + ); + + expect(hasAllTheRoles).to.be.true; + + const hasAllTheRolesNegative = await testRoles.hasAllTheRoles( + userWallet.address, + roleSets[0], + ); + + expect(hasAllTheRolesNegative).to.be.false; + }); + + it('should return if an account has any of the roles', async () => { + const hasAnyRoles = await testRoles.hasAnyOfRoles( + ownerWallet.address, + roleSets[0], + ); + + expect(hasAnyRoles).to.be.true; + + const hasAnyRolesNegative = await testRoles.hasAnyOfRoles( + userWallet.address, + roleSets[0], + ); + + expect(hasAnyRolesNegative).to.be.false; + }); + + it('should transfer roles in one step', async () => { + const roles = [1, 2]; + + await expect(testRoles.transferRoles(userWallet.address, roles)) + .to.emit(testRoles, 'RolesRemoved') + .withArgs(ownerWallet.address, roles) + .to.emit(testRoles, 'RolesAdded') + .withArgs(userWallet.address, roles); + + expect(await testRoles.getAccountRoles(userWallet.address)).to.equal(6); // 6 is the binary representation of roles [1, 2] + expect(await testRoles.getAccountRoles(ownerWallet.address)).to.equal(8); // 8 is a binary representation of role 3, other roles transferred + }); + + it('should propose new roles and accept roles', async () => { + const roles = [2, 3]; + + await expect(testRoles.proposeRoles(userWallet.address, roles)) + .to.emit(testRoles, 'RolesProposed') + .withArgs(ownerWallet.address, userWallet.address, roles); + + expect( + await testRoles.getProposedRoles( + ownerWallet.address, + userWallet.address, + ), + ).to.equal(12); // 12 is the binary representation of roles [2, 3] + + expect(await testRoles.getAccountRoles(userWallet.address)).to.equal(0); + + await testRoles + .connect(userWallet) + .acceptRoles(ownerWallet.address, roles) + .then((tx) => tx.wait()); + + expect( + await testRoles.getProposedRoles( + ownerWallet.address, + userWallet.address, + ), + ).to.equal(0); // cleared proposed roles + expect(await testRoles.getAccountRoles(userWallet.address)).to.equal(12); // 12 is the binary representation of roles [2, 3] + expect(await testRoles.getAccountRoles(ownerWallet.address)).to.equal(2); // 2 is a binary representation of role 1, other roles transferred + }); + + it('should add and remove single role', async () => { + const role = 2; + + await expect(testRoles.addRole(userWallet.address, role)) + .to.emit(testRoles, 'RolesAdded') + .withArgs(userWallet.address, [role]); + + expect(await testRoles.getAccountRoles(userWallet.address)).to.equal(4); // 4 is the binary representation of roles [2] + + await expect(testRoles.removeRole(userWallet.address, role)) + .to.emit(testRoles, 'RolesRemoved') + .withArgs(userWallet.address, [role]); + + expect(await testRoles.getAccountRoles(userWallet.address)).to.equal(0); + }); + }); +});