diff --git a/contracts/token/common/HubOwner.sol b/contracts/token/common/HubOwner.sol new file mode 100644 index 00000000..8221516a --- /dev/null +++ b/contracts/token/common/HubOwner.sol @@ -0,0 +1,76 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {AccessControlEnumerable, AccessControl, IAccessControl} from "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; + +/** + * @notice Adds the concept of a hub owner. + * @dev This contract has the concept of a hubOwner, called _hubOwner in the constructor. + * This account has no rights to execute any administrative actions within the contract, + * with the exception of renouncing their ownership. + * The Immutable Hub uses this function to help associate the ERC 20 contract + * with a specific Immutable Hub account. + */ +abstract contract HubOwner is AccessControlEnumerable { + // Report an error if renounceRole is called for the last DEFAULT_ADMIN_ROLE or + // OWNER_ROLE. + error RenounceLastNotAllowed(); + + /// @notice Role to indicate owner for Immutable Hub and other applications. + bytes32 public constant HUB_OWNER_ROLE = bytes32("HUB_OWNER_ROLE"); + + /** + * @param _roleAdmin The account that administers other roles and other + * accounts with DEFAULT_ADMIN_ROLE. + * @param _hubOwner The account associated with Immutable Hub and other applications that need an "owner". + */ + constructor(address _roleAdmin, address _hubOwner) { + _grantRole(DEFAULT_ADMIN_ROLE, _roleAdmin); + _grantRole(HUB_OWNER_ROLE, _hubOwner); + } + + /** + * @dev Renounces the role `role` from the calling account. Prevents the last hub owner and admin from + * renouncing their role. + * @param role The role to renounce. + * @param account The account to renounce the role from. + */ + function renounceRole(bytes32 role, address account) public virtual override(AccessControl, IAccessControl) { + if ((role == HUB_OWNER_ROLE || role == DEFAULT_ADMIN_ROLE) && (getRoleMemberCount(role) == 1)) { + revert RenounceLastNotAllowed(); + } + super.renounceRole(role, account); + } + + /** + * @notice Returns the addresses which have a certain role. + * @dev In the unlikely event that there are many accounts with a certain role, + * this function might cause out of memory issues, and fail. + * @param _role Role to return array of admins for. + * @return admins The array of admins with the requested role. + */ + function getAdmins(bytes32 _role) public view returns (address[] memory admins) { + uint256 adminCount = getRoleMemberCount(_role); + admins = new address[](adminCount); + for (uint256 i; i < adminCount; i++) { + admins[i] = getRoleMember(_role, i); + } + return admins; + } + + /** + * @notice Return the first account that has OWNER_ROLE. + * @dev Some applications assume there is only one owner and it is returned by the owner function. + * @return address The "owner" of the contract. + */ + function owner() external view returns (address) { + bytes32 role = HUB_OWNER_ROLE; + if (getRoleMemberCount(role) == 0) { + // The only way that there could be no owners is if an account with DEFAULT_ADMIN_ROLE + // revoked all of the accounts with OWNER_ROLE. + return address(0); + } + return getRoleMember(role, 0); + } +} diff --git a/contracts/token/common/MintingHubOwner.sol b/contracts/token/common/MintingHubOwner.sol new file mode 100644 index 00000000..971a8360 --- /dev/null +++ b/contracts/token/common/MintingHubOwner.sol @@ -0,0 +1,21 @@ +// Copyright Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity 0.8.19; + +// solhint-disable no-unused-import +import {HubOwner, AccessControlEnumerable, AccessControl, IAccessControl} from "./HubOwner.sol"; + +abstract contract MintingHubOwner is HubOwner { + /// @notice Role to mint tokens + bytes32 public constant MINTER_ROLE = bytes32("MINTER_ROLE"); + + /** + * @param _roleAdmin The account that administers other roles and other + * accounts with DEFAULT_ADMIN_ROLE. + * @param _hubOwner The account associated with Immutable Hub. + * @param _minterAdmin An account with minter role. + */ + constructor(address _roleAdmin, address _hubOwner, address _minterAdmin) HubOwner(_roleAdmin, _hubOwner) { + _grantRole(MINTER_ROLE, _minterAdmin); + } +} diff --git a/contracts/token/erc20/preset/Errors.sol b/contracts/token/erc20/preset/Errors.sol deleted file mode 100644 index 198f3f81..00000000 --- a/contracts/token/erc20/preset/Errors.sol +++ /dev/null @@ -1,6 +0,0 @@ -//SPDX-License-Identifier: Apache 2.0 -pragma solidity 0.8.19; - -interface IImmutableERC20Errors { - error RenounceOwnershipNotAllowed(); -} diff --git a/contracts/token/erc20/preset/ImmutableERC20FixedSupplyNoBurn.sol b/contracts/token/erc20/preset/ImmutableERC20FixedSupplyNoBurn.sol index 8595c0a4..3bc8ed97 100644 --- a/contracts/token/erc20/preset/ImmutableERC20FixedSupplyNoBurn.sol +++ b/contracts/token/erc20/preset/ImmutableERC20FixedSupplyNoBurn.sol @@ -4,7 +4,6 @@ pragma solidity 0.8.19; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import {IImmutableERC20Errors} from "./Errors.sol"; /** * @notice ERC 20 contract that mints a fixed total supply of tokens when the contract @@ -16,6 +15,9 @@ import {IImmutableERC20Errors} from "./Errors.sol"; * with a specific Immutable Hub account. */ contract ImmutableERC20FixedSupplyNoBurn is Ownable, ERC20 { + // Report an error on attempts to renounce ownership. + error RenounceOwnershipNotAllowed(); + /** * @dev Mints `_totalSupply` number of token and transfers them to `_owner`. * @@ -40,6 +42,6 @@ contract ImmutableERC20FixedSupplyNoBurn is Ownable, ERC20 { * @notice Prevent calls to renounce ownership. */ function renounceOwnership() public pure override { - revert IImmutableERC20Errors.RenounceOwnershipNotAllowed(); + revert RenounceOwnershipNotAllowed(); } } diff --git a/contracts/token/erc20/preset/ImmutableERC20FixedSupplyNoBurnV2.sol b/contracts/token/erc20/preset/ImmutableERC20FixedSupplyNoBurnV2.sol new file mode 100644 index 00000000..b66fe582 --- /dev/null +++ b/contracts/token/erc20/preset/ImmutableERC20FixedSupplyNoBurnV2.sol @@ -0,0 +1,38 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {HubOwner} from "../../common/HubOwner.sol"; + +/** + * @notice ERC 20 contract that mints a fixed total supply of tokens when the contract + * is deployed. + * @dev This contract has the concept of a hubOwner, called _hubOwner in the constructor. + * This account has no rights to execute any administrative actions within the contract, + * with the exception of renouncing their ownership. + * The Immutable Hub uses this function to help associate the ERC 20 contract + * with a specific Immutable Hub account. + */ + +contract ImmutableERC20FixedSupplyNoBurnV2 is HubOwner, ERC20 { + /** + * @dev Mints `_totalSupply` number of token and transfers them to `_hubOwner`. + * @param _roleAdmin The account that has the DEFAULT_ADMIN_ROLE. + * @param _treasurer Initial owner of entire supply of all tokens. + * @param _hubOwner The account associated with Immutable Hub. + * @param _name Name of the token. + * @param _symbol Token symbol. + * @param _totalSupply The fixed supply to be minted. + */ + constructor( + address _roleAdmin, + address _treasurer, + address _hubOwner, + string memory _name, + string memory _symbol, + uint256 _totalSupply + ) HubOwner(_roleAdmin, _hubOwner) ERC20(_name, _symbol) { + _mint(_treasurer, _totalSupply); + } +} diff --git a/contracts/token/erc20/preset/ImmutableERC20MinterBurnerPermit.sol b/contracts/token/erc20/preset/ImmutableERC20MinterBurnerPermit.sol index e9ed7948..b50c25c2 100644 --- a/contracts/token/erc20/preset/ImmutableERC20MinterBurnerPermit.sol +++ b/contracts/token/erc20/preset/ImmutableERC20MinterBurnerPermit.sol @@ -6,9 +6,10 @@ import {ERC20Permit, ERC20} from "@openzeppelin/contracts/token/ERC20/extensions import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; import {ERC20Capped} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol"; import {MintingAccessControl, AccessControl, IAccessControl} from "../../../access/MintingAccessControl.sol"; -import {IImmutableERC20Errors} from "./Errors.sol"; /** + * @notice This contract is now deprecated in favour of ImmutableERC20MinterBurnerPermitV2.sol. + * * @notice ERC 20 contract that wraps Open Zeppelin's ERC 20 contract. * This contract has the concept of a hubOwner, called _hubOwner in the constructor. * This account has no rights to execute any administrative actions within the contract, @@ -17,6 +18,9 @@ import {IImmutableERC20Errors} from "./Errors.sol"; * with a specific Immutable Hub account. */ contract ImmutableERC20MinterBurnerPermit is ERC20Capped, ERC20Burnable, ERC20Permit, MintingAccessControl { + // Report an error if renounceOwnership is called. + error RenounceOwnershipNotAllowed(); + /// @notice Role to mint tokens bytes32 public constant HUB_OWNER_ROLE = bytes32("HUB_OWNER_ROLE"); @@ -59,7 +63,7 @@ contract ImmutableERC20MinterBurnerPermit is ERC20Capped, ERC20Burnable, ERC20Pe */ function renounceRole(bytes32 role, address account) public override(AccessControl, IAccessControl) { if (getRoleMemberCount(role) == 1 && (role == HUB_OWNER_ROLE || role == DEFAULT_ADMIN_ROLE)) { - revert IImmutableERC20Errors.RenounceOwnershipNotAllowed(); + revert RenounceOwnershipNotAllowed(); } super.renounceRole(role, account); } diff --git a/contracts/token/erc20/preset/ImmutableERC20MinterBurnerPermitV2.sol b/contracts/token/erc20/preset/ImmutableERC20MinterBurnerPermitV2.sol new file mode 100644 index 00000000..b870da0a --- /dev/null +++ b/contracts/token/erc20/preset/ImmutableERC20MinterBurnerPermitV2.sol @@ -0,0 +1,57 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {ERC20Permit, ERC20} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import {ERC20Capped} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol"; +import {MintingHubOwner} from "../../common/MintingHubOwner.sol"; + +/** + * @notice ERC 20 contract that wraps Open Zeppelin's ERC 20 Permit contract. + * @dev This contract has the concept of a hubOwner, called _hubOwner in the constructor. + * This account has no rights to execute any administrative actions within the contract, + * with the exception of renouncing their ownership. + * The Immutable Hub uses this function to help associate the ERC 20 contract + * with a specific Immutable Hub account. + */ +contract ImmutableERC20MinterBurnerPermitV2 is MintingHubOwner, ERC20Capped, ERC20Burnable, ERC20Permit { + /** + * @dev Delegate to Open Zeppelin's contract. + * @param _roleAdmin The account that has the DEFAULT_ADMIN_ROLE. + * @param _minterAdmin The account that has the MINTER_ROLE. + * @param _hubOwner The account that owns the contract and is associated with Immutable Hub. + * @param _name Name of the token. + * @param _symbol Token symbol. + * @param _maxTokenSupply The maximum supply of the token. + */ + constructor( + address _roleAdmin, + address _minterAdmin, + address _hubOwner, + string memory _name, + string memory _symbol, + uint256 _maxTokenSupply + ) + MintingHubOwner(_roleAdmin, _hubOwner, _minterAdmin) + ERC20(_name, _symbol) + ERC20Permit(_name) + ERC20Capped(_maxTokenSupply) + {} + + /** + * @dev Mints `amount` number of token and transfers them to the `to` address. + * @param to the address to mint the tokens to. + * @param amount The amount of tokens to mint. + */ + function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) { + _mint(to, amount); + } + + /** + * @dev Delegate to Open Zeppelin's ERC20Capped contract. + */ + function _mint(address account, uint256 amount) internal override(ERC20, ERC20Capped) { + ERC20Capped._mint(account, amount); + } +} diff --git a/test/deployer/create2/Create2Utils.sol b/test/deployer/create2/Create2Utils.sol index d3f335ef..a971da9e 100644 --- a/test/deployer/create2/Create2Utils.sol +++ b/test/deployer/create2/Create2Utils.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.19; import "forge-std/Test.sol"; -contract Create2Utils is Test { +abstract contract Create2Utils is Test { function predictCreate2Address(bytes memory _bytecode, address _deployer, address _sender, bytes32 _salt) public pure diff --git a/test/deployer/create3/Create3Utils.sol b/test/deployer/create3/Create3Utils.sol index cd27a524..ddf259b5 100644 --- a/test/deployer/create3/Create3Utils.sol +++ b/test/deployer/create3/Create3Utils.sol @@ -5,7 +5,7 @@ pragma solidity 0.8.19; import "forge-std/Test.sol"; import {IDeployer} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IDeployer.sol"; -contract Create3Utils is Test { +abstract contract Create3Utils is Test { function predictCreate3Address(IDeployer _deployer, address _sender, bytes32 _salt) public view returns (address) { return _deployer.deployedAddress("", _sender, _salt); } diff --git a/test/token/common/HubOwner.t.sol b/test/token/common/HubOwner.t.sol new file mode 100644 index 00000000..f5632fb7 --- /dev/null +++ b/test/token/common/HubOwner.t.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import "forge-std/Test.sol"; + +import {HubOwner} from "contracts/token/common/HubOwner.sol"; + + +// Contract that isn't abstract, and hence allows the contract to be instantiated +contract HubOwnerImpl is HubOwner { + constructor(address _roleAdmin, address _hubOwner) HubOwner(_roleAdmin, _hubOwner) { + } +} + +contract HubOwnerTest is Test { + HubOwner public tokenContract; + + address public hubOwner; + address public admin; + + function setUp() public virtual { + admin = makeAddr("admin"); + hubOwner = makeAddr("hubOwner"); + + tokenContract = new HubOwnerImpl(admin, hubOwner); + } + + function testInit() public { + assertEq(tokenContract.owner(), hubOwner, "owner"); + assertTrue(tokenContract.hasRole(tokenContract.DEFAULT_ADMIN_ROLE(), admin)); + assertEq(tokenContract.getRoleMemberCount(tokenContract.DEFAULT_ADMIN_ROLE()), 1, "one admin"); + assertTrue(tokenContract.hasRole(tokenContract.HUB_OWNER_ROLE(), hubOwner), "hub owner"); + assertEq(tokenContract.getRoleMemberCount(tokenContract.HUB_OWNER_ROLE()), 1, "one hub owner"); + + address[] memory admins = tokenContract.getAdmins(tokenContract.DEFAULT_ADMIN_ROLE()); + assertEq(admins.length, 1, "admins length"); + assertEq(admins[0], admin, "admins[0]"); + + address[] memory hubOwners = tokenContract.getAdmins(tokenContract.HUB_OWNER_ROLE()); + assertEq(hubOwners.length, 1, "hub owners length"); + assertEq(hubOwners[0], hubOwner, "hub owners[0]"); + } + + function testRenounceAdmin() public { + address secondAdmin = makeAddr("secondAdmin"); + vm.startPrank(admin); + tokenContract.grantRole(tokenContract.DEFAULT_ADMIN_ROLE(), secondAdmin); + assertTrue(tokenContract.hasRole(tokenContract.DEFAULT_ADMIN_ROLE(), secondAdmin)); + + tokenContract.renounceRole(tokenContract.DEFAULT_ADMIN_ROLE(), admin); + assertFalse(tokenContract.hasRole(tokenContract.DEFAULT_ADMIN_ROLE(), admin)); + vm.stopPrank(); + } + + function testRenounceLastAdminBlocked() public { + bytes32 defaultAdminRole = tokenContract.DEFAULT_ADMIN_ROLE(); + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(HubOwner.RenounceLastNotAllowed.selector)); + tokenContract.renounceRole(defaultAdminRole, admin); + } + + function testRenounceHubOwner() public { + address secondHubOwner = makeAddr("secondHubOwner"); + vm.startPrank(admin); + tokenContract.grantRole(tokenContract.HUB_OWNER_ROLE(), secondHubOwner); + assertTrue(tokenContract.hasRole(tokenContract.HUB_OWNER_ROLE(), secondHubOwner)); + vm.stopPrank(); + + vm.startPrank(hubOwner); + tokenContract.renounceRole(tokenContract.HUB_OWNER_ROLE(), hubOwner); + assertFalse(tokenContract.hasRole(tokenContract.HUB_OWNER_ROLE(), hubOwner)); + vm.stopPrank(); + } + + function testRenounceLastHubOwnerBlocked() public { + bytes32 hubOwnerRole = tokenContract.HUB_OWNER_ROLE(); + vm.prank(hubOwner); + vm.expectRevert(abi.encodeWithSelector(HubOwner.RenounceLastNotAllowed.selector)); + tokenContract.renounceRole(hubOwnerRole, hubOwner); + } + + // Check what happens when owner() is called when there is no hub owner. + function testOwnerWhenNoHubOwner() public { + bytes32 hubOwnerRole = tokenContract.HUB_OWNER_ROLE(); + vm.prank(admin); + tokenContract.revokeRole(hubOwnerRole, hubOwner); + + // Check the revoke worked. + assertEq(tokenContract.getRoleMemberCount(hubOwnerRole), 0, "no hub owner"); + + // Check getAdmins worked in this situation too. + address[] memory hubOwners = tokenContract.getAdmins(hubOwnerRole); + assertEq(hubOwners.length, 0, "hub owners length"); + + address theOwner = tokenContract.owner(); + assertEq(theOwner, address(0), "owner when there are now owners"); + } +} diff --git a/test/token/common/README.md b/test/token/common/README.md new file mode 100644 index 00000000..44893a2b --- /dev/null +++ b/test/token/common/README.md @@ -0,0 +1,14 @@ +# Test Plan for Common Token contracts + +## HubOwner.sol +This section defines tests for contracts/token/common/HubOwner.sol. +All of these tests are in test/token/common/HubOwner.t.sol. + +| Test name |Description | Happy Case | Implemented | +|---------------------------------| --------------------------------------------------|------------|-------------| +| testInit | Check that deployment work. | Yes | Yes | +| testRenounceAdmin | Check that default admins can call renounce. | Yes | Yes | +| testRenounceLastAdminBlocked | Check that the last admin can not call renounce. | No | Yes | +| testRenounceHubOwner | Check that hub owners can call renounce. | Yes | Yes | +| testRenounceLastHubOwnerBlocked | Check that the last hub owner can not call renounce. | No | Yes | +| testOwnerWhenNoHubOwner | Check operation when there are no hub owners. | No | Yes | diff --git a/test/token/erc20/preset/ERC20MinterBurnerPermitCommon.t.sol b/test/token/erc20/preset/ERC20MinterBurnerPermitCommon.t.sol new file mode 100644 index 00000000..4156585f --- /dev/null +++ b/test/token/erc20/preset/ERC20MinterBurnerPermitCommon.t.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {IERC20Metadata, ERC20TestCommon} from "./ERC20TestCommon.t.sol"; +import {ImmutableERC20MinterBurnerPermit} from "contracts/token/erc20/preset/ImmutableERC20MinterBurnerPermit.sol"; + +abstract contract ERC20MinterBurnerPermitCommonTest is ERC20TestCommon { + + ImmutableERC20MinterBurnerPermit public erc20; + + address public minter; + address public tokenReceiver; + + function setUp() public virtual override { + super.setUp(); + minter = makeAddr("minterRole"); + tokenReceiver = makeAddr("tokenReceiver"); + } + + function testInitExtended() public { + bytes32 minterRole = erc20.MINTER_ROLE(); + assertTrue(erc20.hasRole(minterRole, minter)); + bytes32 adminRole = erc20.DEFAULT_ADMIN_ROLE(); + assertTrue(erc20.hasRole(adminRole, admin)); + assertEq(erc20.cap(), supply, "total supply"); + assertTrue(erc20.hasRole(erc20.HUB_OWNER_ROLE(), hubOwner), "hub owner"); + } + + function testMint() public { + address to = makeAddr("to"); + uint256 amount = 100; + vm.prank(minter); + erc20.mint(to, amount); + assertEq(erc20.balanceOf(to), amount); + } + + function testOnlyMinterCanMint() public { + address to = makeAddr("to"); + uint256 amount = 100; + vm.prank(hubOwner); + vm.expectRevert("AccessControl: account 0xa268ae5516b47694c3f15805a560258dbcdefd08 is missing role 0x4d494e5445525f524f4c45000000000000000000000000000000000000000000"); + erc20.mint(to, amount); + } + + function testCanOnlyMintUpToMaxSupply() public { + address to = makeAddr("to"); + uint256 amount = supply; + vm.startPrank(minter); + erc20.mint(to, amount); + assertEq(erc20.balanceOf(to), amount); + vm.expectRevert("ERC20Capped: cap exceeded"); + erc20.mint(to, 1); + vm.stopPrank(); + } + + function testBurn() public { + uint256 amount = 100; + vm.prank(minter); + erc20.mint(tokenReceiver, amount); + assertEq(erc20.balanceOf(tokenReceiver), 100); + vm.prank(tokenReceiver); + erc20.burn(amount); + assertEq(erc20.balanceOf(tokenReceiver), 0); + } + + function testBurnFrom() public { + uint256 amount = 100; + address operator = makeAddr("operator"); + vm.prank(minter); + erc20.mint(tokenReceiver, amount); + assertEq(erc20.balanceOf(tokenReceiver), 100); + vm.prank(tokenReceiver); + erc20.increaseAllowance(operator, amount); + vm.prank(operator); + erc20.burnFrom(tokenReceiver, amount); + assertEq(erc20.balanceOf(tokenReceiver), 0); + } + + function testPermit() public { + uint256 ownerPrivateKey = 1; + uint256 spenderPrivateKey = 2; + address owner = vm.addr(ownerPrivateKey); + address spender = vm.addr(spenderPrivateKey); + + bytes32 PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; + + uint256 value = 1e18; + + uint256 deadline = block.timestamp + 1 days; + uint256 nonce = erc20.nonces(owner); + bytes32 structHash = keccak256( + abi.encode( + PERMIT_TYPEHASH, + owner, + spender, + value, + nonce, + deadline + ) + ); + bytes32 hash = erc20.DOMAIN_SEPARATOR(); + hash = keccak256(abi.encodePacked("\x19\x01", hash, structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + vm.startPrank(owner); + erc20.permit(owner, spender, value, deadline, v, r, s); + vm.stopPrank(); + + assertEq(erc20.allowance(owner, spender), value); + } + + +} diff --git a/test/token/erc20/preset/ERC20TestCommon.t.sol b/test/token/erc20/preset/ERC20TestCommon.t.sol new file mode 100644 index 00000000..7ee93cd2 --- /dev/null +++ b/test/token/erc20/preset/ERC20TestCommon.t.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; +import "forge-std/Test.sol"; + +abstract contract ERC20TestCommon is Test { + address public admin; + address public treasurer; + address public hubOwner; + string public name; + string public symbol; + uint256 public supply; + + IERC20Metadata basicERC20; + + function setUp() public virtual { + admin = makeAddr("admin"); + hubOwner = makeAddr("hubOwner"); + treasurer = makeAddr("treasurer"); + name = "HappyToken"; + symbol = "HPY"; + supply = 1000000; + } + + function testInit() public { + assertEq(basicERC20.name(), name, "name"); + assertEq(basicERC20.symbol(), symbol, "symbol"); + assertEq(basicERC20.balanceOf(hubOwner), 0, "initial hub owner balance"); + } +} diff --git a/test/token/erc20/preset/ImmutableERC20FixedSupplyNoBurn.t.sol b/test/token/erc20/preset/ImmutableERC20FixedSupplyNoBurn.t.sol index db1065ff..7a1dc8be 100644 --- a/test/token/erc20/preset/ImmutableERC20FixedSupplyNoBurn.t.sol +++ b/test/token/erc20/preset/ImmutableERC20FixedSupplyNoBurn.t.sol @@ -1,36 +1,21 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.19; -import "forge-std/Test.sol"; - +import {IERC20Metadata, ERC20TestCommon} from "./ERC20TestCommon.t.sol"; import {ImmutableERC20FixedSupplyNoBurn} from "contracts/token/erc20/preset/ImmutableERC20FixedSupplyNoBurn.sol"; -import {IImmutableERC20Errors} from "contracts/token/erc20/preset/Errors.sol"; -contract ImmutableERC20FixedSupplyNoBurnTest is Test { +contract ImmutableERC20FixedSupplyNoBurnTest is ERC20TestCommon { ImmutableERC20FixedSupplyNoBurn public erc20; - address public treasurer; - address public hubOwner; - string name; - string symbol; - uint256 supply; - - function setUp() public virtual { - hubOwner = makeAddr("hubOwner"); - treasurer = makeAddr("treasurer"); - name = "HappyToken"; - symbol = "HPY"; - supply = 1000000; - + function setUp() public virtual override { + super.setUp(); erc20 = new ImmutableERC20FixedSupplyNoBurn(name, symbol, supply, treasurer, hubOwner); + basicERC20 = IERC20Metadata(address(erc20)); } - function testInit() public { - assertEq(erc20.name(), name, "name"); - assertEq(erc20.symbol(), symbol, "symbol"); - assertEq(erc20.totalSupply(), supply, "supply"); - assertEq(erc20.balanceOf(treasurer), supply, "initial treasurer balance"); - assertEq(erc20.balanceOf(hubOwner), 0, "initial hub owner balance"); + function testInitExtended() public { + assertEq(basicERC20.totalSupply(), supply, "supply"); + assertEq(basicERC20.balanceOf(treasurer), supply, "initial treasurer balance"); assertEq(erc20.owner(), hubOwner, "Hub owner"); } @@ -43,7 +28,7 @@ contract ImmutableERC20FixedSupplyNoBurnTest is Test { function testRenounceOwnerBlocked() public { vm.prank(hubOwner); - vm.expectRevert(abi.encodeWithSelector(IImmutableERC20Errors.RenounceOwnershipNotAllowed.selector)); + vm.expectRevert(abi.encodeWithSelector(ImmutableERC20FixedSupplyNoBurn.RenounceOwnershipNotAllowed.selector)); erc20.renounceOwnership(); - } + } } diff --git a/test/token/erc20/preset/ImmutableERC20FixedSupplyNoBurnV2.t.sol b/test/token/erc20/preset/ImmutableERC20FixedSupplyNoBurnV2.t.sol new file mode 100644 index 00000000..ef88a3d0 --- /dev/null +++ b/test/token/erc20/preset/ImmutableERC20FixedSupplyNoBurnV2.t.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {IERC20Metadata, ERC20TestCommon} from "./ERC20TestCommon.t.sol"; +import {ImmutableERC20FixedSupplyNoBurnV2} from "contracts/token/erc20/preset/ImmutableERC20FixedSupplyNoBurnV2.sol"; + +contract ImmutableERC20FixedSupplyNoBurnV2Test is ERC20TestCommon { + + ImmutableERC20FixedSupplyNoBurnV2 public erc20; + + function setUp() public virtual override { + super.setUp(); + erc20 = new ImmutableERC20FixedSupplyNoBurnV2(admin, treasurer, hubOwner, name, symbol, supply); + basicERC20 = IERC20Metadata(address(erc20)); + } + + function testInitExtended() public { + assertEq(basicERC20.totalSupply(), supply, "supply"); + assertEq(basicERC20.balanceOf(treasurer), supply, "initial treasurer balance"); + assertEq(erc20.owner(), hubOwner, "Hub owner"); + + assertTrue(erc20.hasRole(erc20.HUB_OWNER_ROLE(), hubOwner), "Hub owner"); + assertEq(erc20.getRoleMemberCount(erc20.HUB_OWNER_ROLE()), 1, "one hub owner"); + assertTrue(erc20.hasRole(erc20.DEFAULT_ADMIN_ROLE(), admin), "admin"); + assertEq(erc20.getRoleMemberCount(erc20.DEFAULT_ADMIN_ROLE()), 1, "one admin"); + } +} diff --git a/test/token/erc20/preset/ImmutableERC20MinterBurnerPermit.t.sol b/test/token/erc20/preset/ImmutableERC20MinterBurnerPermit.t.sol index b601a189..8ebd875f 100644 --- a/test/token/erc20/preset/ImmutableERC20MinterBurnerPermit.t.sol +++ b/test/token/erc20/preset/ImmutableERC20MinterBurnerPermit.t.sol @@ -1,57 +1,22 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.19; -import "forge-std/Test.sol"; +import {IERC20Metadata, ImmutableERC20MinterBurnerPermit, ERC20MinterBurnerPermitCommonTest} from "./ERC20MinterBurnerPermitCommon.t.sol"; -import {ImmutableERC20MinterBurnerPermit} from "contracts/token/erc20/preset/ImmutableERC20MinterBurnerPermit.sol"; -import {IImmutableERC20Errors} from "contracts/token/erc20/preset/Errors.sol"; +/** + * Test ImmutableERC20MinterBurnerPermit. + * Most of the tests are inherited. + * The renounce ownership tests are similar but different to those in HubOwner.t.sol + * Importantly, the error returned on last owner revocation is different, and hence the tests + * are different. + */ -contract ImmutableERC20MinterBurnerPermitTest is Test { - ImmutableERC20MinterBurnerPermit public erc20; +contract ImmutableERC20MinterBurnerPermitTest is ERC20MinterBurnerPermitCommonTest { - address public minter; - address public hubOwner; - address public tokenReceiver; - address public admin; - string name; - string symbol; - uint256 maxSupply; - - function setUp() public virtual { - hubOwner = makeAddr("hubOwner"); - minter = makeAddr("minterRole"); - tokenReceiver = makeAddr("tokenReceiver"); - admin = makeAddr("admin"); - name = "HappyToken"; - symbol = "HPY"; - maxSupply = 1000; - - erc20 = new ImmutableERC20MinterBurnerPermit(admin, minter, hubOwner, name, symbol, maxSupply); - } - - function testInit() public { - assertEq(erc20.name(), name, "name"); - assertEq(erc20.symbol(), symbol, "symbol"); - bytes32 minterRole = erc20.MINTER_ROLE(); - assertTrue(erc20.hasRole(minterRole, minter)); - bytes32 adminRole = erc20.DEFAULT_ADMIN_ROLE(); - assertTrue(erc20.hasRole(adminRole, admin)); - assertEq(erc20.cap(), maxSupply, "total supply"); - assertTrue(erc20.hasRole(erc20.HUB_OWNER_ROLE(), hubOwner), "hub owner"); - } - - function testRenounceLastHubOwnerBlocked() public { - vm.prank(hubOwner); - bytes32 hubRole = erc20.HUB_OWNER_ROLE(); - vm.expectRevert(abi.encodeWithSelector(IImmutableERC20Errors.RenounceOwnershipNotAllowed.selector)); - erc20.renounceRole(hubRole, hubOwner); - } - - function testRenounceLastAdminBlocked() public { - vm.prank(admin); - bytes32 adminRole = erc20.DEFAULT_ADMIN_ROLE(); - vm.expectRevert(abi.encodeWithSelector(IImmutableERC20Errors.RenounceOwnershipNotAllowed.selector)); - erc20.renounceRole(adminRole, admin); + function setUp() public virtual override { + super.setUp(); + erc20 = new ImmutableERC20MinterBurnerPermit(admin, minter, hubOwner, name, symbol, supply); + basicERC20 = IERC20Metadata(address(erc20)); } function testRenounceAdmin() public { @@ -59,14 +24,19 @@ contract ImmutableERC20MinterBurnerPermitTest is Test { vm.startPrank(admin); erc20.grantRole(erc20.DEFAULT_ADMIN_ROLE(), secondAdmin); assertTrue(erc20.hasRole(erc20.DEFAULT_ADMIN_ROLE(), secondAdmin)); - vm.stopPrank(); - vm.startPrank(admin); erc20.renounceRole(erc20.DEFAULT_ADMIN_ROLE(), admin); assertFalse(erc20.hasRole(erc20.DEFAULT_ADMIN_ROLE(), admin)); vm.stopPrank(); } + function testRenounceLastAdminBlocked() public { + bytes32 defaultAdminRole = erc20.DEFAULT_ADMIN_ROLE(); + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(ImmutableERC20MinterBurnerPermit.RenounceOwnershipNotAllowed.selector)); + erc20.renounceRole(defaultAdminRole, admin); + } + function testRenounceHubOwner() public { address secondHubOwner = makeAddr("secondHubOwner"); vm.startPrank(admin); @@ -80,79 +50,10 @@ contract ImmutableERC20MinterBurnerPermitTest is Test { vm.stopPrank(); } - function testOnlyMinterCanMint() public { - address to = makeAddr("to"); - uint256 amount = 100; + function testRenounceLastHubOwnerBlocked() public { + bytes32 hubOwnerRole = erc20.HUB_OWNER_ROLE(); vm.prank(hubOwner); - vm.expectRevert( - "AccessControl: account 0xa268ae5516b47694c3f15805a560258dbcdefd08 is missing role 0x4d494e5445525f524f4c45000000000000000000000000000000000000000000" - ); - erc20.mint(to, amount); - } - - function testMint() public { - address to = makeAddr("to"); - uint256 amount = 100; - vm.prank(minter); - erc20.mint(to, amount); - assertEq(erc20.balanceOf(to), amount); - } - - function testBurn() public { - uint256 amount = 100; - vm.prank(minter); - erc20.mint(tokenReceiver, amount); - assertEq(erc20.balanceOf(tokenReceiver), 100); - vm.prank(tokenReceiver); - erc20.burn(amount); - assertEq(erc20.balanceOf(tokenReceiver), 0); - } - - function testCanOnlyMintUpToMaxSupply() public { - address to = makeAddr("to"); - uint256 amount = 1000; - vm.startPrank(minter); - erc20.mint(to, amount); - assertEq(erc20.balanceOf(to), amount); - vm.expectRevert("ERC20Capped: cap exceeded"); - erc20.mint(to, 1); - vm.stopPrank(); - } - - function testBurnFrom() public { - uint256 amount = 100; - address operator = makeAddr("operator"); - vm.prank(minter); - erc20.mint(tokenReceiver, amount); - assertEq(erc20.balanceOf(tokenReceiver), 100); - vm.prank(tokenReceiver); - erc20.increaseAllowance(operator, amount); - vm.prank(operator); - erc20.burnFrom(tokenReceiver, amount); - assertEq(erc20.balanceOf(tokenReceiver), 0); - } - - function testPermit() public { - uint256 ownerPrivateKey = 1; - uint256 spenderPrivateKey = 2; - address owner = vm.addr(ownerPrivateKey); - address spender = vm.addr(spenderPrivateKey); - - bytes32 PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; - - uint256 value = 1e18; - - uint256 deadline = block.timestamp + 1 days; - uint256 nonce = erc20.nonces(owner); - bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonce, deadline)); - bytes32 hash = erc20.DOMAIN_SEPARATOR(); - hash = keccak256(abi.encodePacked("\x19\x01", hash, structHash)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); - - vm.startPrank(owner); - erc20.permit(owner, spender, value, deadline, v, r, s); - vm.stopPrank(); - - assertEq(erc20.allowance(owner, spender), value); + vm.expectRevert(abi.encodeWithSelector(ImmutableERC20MinterBurnerPermit.RenounceOwnershipNotAllowed.selector)); + erc20.renounceRole(hubOwnerRole, hubOwner); } } diff --git a/test/token/erc20/preset/ImmutableERC20MinterBurnerPermitV2.t.sol b/test/token/erc20/preset/ImmutableERC20MinterBurnerPermitV2.t.sol new file mode 100644 index 00000000..a6713700 --- /dev/null +++ b/test/token/erc20/preset/ImmutableERC20MinterBurnerPermitV2.t.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import {IERC20Metadata, ImmutableERC20MinterBurnerPermit, ERC20MinterBurnerPermitCommonTest} from "./ERC20MinterBurnerPermitCommon.t.sol"; +import {ImmutableERC20MinterBurnerPermitV2} from "contracts/token/erc20/preset/ImmutableERC20MinterBurnerPermitV2.sol"; + +contract ImmutableERC20MinterBurnerPermitV2Test is ERC20MinterBurnerPermitCommonTest { + ImmutableERC20MinterBurnerPermitV2 public erc20V2; + + function setUp() public virtual override { + super.setUp(); + erc20V2 = new ImmutableERC20MinterBurnerPermitV2(admin, minter, hubOwner, name, symbol, supply); + erc20 = ImmutableERC20MinterBurnerPermit(address(erc20V2)); + basicERC20 = IERC20Metadata(address(erc20V2)); + } + + function testCheckOwner() public { + assertEq(erc20V2.owner(), hubOwner, "Hub owner"); + } +} diff --git a/test/token/erc20/preset/README.md b/test/token/erc20/preset/README.md index 2f5c7b57..1d4c9e18 100644 --- a/test/token/erc20/preset/README.md +++ b/test/token/erc20/preset/README.md @@ -1,37 +1,73 @@ # Test Plan for Immutable ERC20 Preset contracts -## ImmutableERC20FixedSupplyNoBurn.sol -This section defines tests for contracts/erc20/preset/ImmutableERC20FixedSupplyNoBurn.sol. Note -that this contract extends Open Zeppelin's ERC 20 contract which is extensively tested here: +The ERC 20 contracts test the additional features supplied over and above the Open Zeppelin contracts. +These base contracts are extensively tested here: https://github.com/OpenZeppelin/openzeppelin-contracts/tree/release-v4.9/test/token/ERC20 . + +## Common Tests +ERC20TestCommon.t.sol provides a test that is common to all ERC20 contracts: checking initialisation. + +ERC20MinternBurnerPermitCommon.t.sol provides tests used by both ImmutableERC20MinterBurnerPermit.t.sol +and ImmutableERC20MinterBurnerPermitV2.t.sol. The ERC20MinternBurnerPermitCommon.t.sol tests are shown below: + +| Test name |Description | Happy Case | Implemented | +|---------------------------------| --------------------------------------------------|------------|-------------| +| testInitExtended | Check initialisation. | Yes | Yes | +| testMint | Ensure successful minting by minter | Yes | Yes | +| testOnlyMinterCanMint | Ensure Only minter role can mint reverts. | No | Yes | +| testCanOnlyMintUpToMaxSupply | Ensure can only mint up to max supply | No | Yes | +| testBurn | Ensure allowance is required to burn | Yes | Yes | +| testBurnFrom | Ensure allowance is required to burnFrom | Yes | Yes | +| testPermit | Ensure Permit works | Yes | Yes | + + +## ImmutableERC20FixedSupplyNoBurn.sol +This section defines tests for contracts/erc20/preset/ImmutableERC20FixedSupplyNoBurn.sol. All of the tests defined in the table below are in test/erc20/preset/ImmutableERC20FixedSupplyNoBurn.t.sol. | Test name |Description | Happy Case | Implemented | |---------------------------------| --------------------------------------------------|------------|-------------| -| testInit | Check constructor. | Yes | Yes | +| testInitExtended | Check constructor. | Yes | Yes | | testChangeOwner | Check change ownership. | Yes | Yes | | testRenounceOwnershipBlocked | Ensure renounceOwnership reverts. | No | Yes | +## ImmutableERC20FixedSupplyNoBurnV2.sol +This section defines tests for contracts/erc20/preset/ImmutableERC20FixedSupplyNoBurnV2.sol. +All of the tests defined in the table below are in test/erc20/preset/ImmutableERC20FixedSupplyNoBurnV2.t.sol. +Note that ImmutableERC20FixedSupplyNoBurnV2 extends HubOwner.sol. The ownership features reside in +HubOwner.sol, and hence are tested in HubOwner.t.sol. -## ImmutableERC20MinterBurnerPermit.sol -This section defines tests for contracts/erc20/preset/ImmutableERC20MinterBurnerPermit.sol. Note -that this contract extends Open Zeppelin's ERC 20 contract which is extensively tested here: -https://github.com/OpenZeppelin/openzeppelin-contracts/tree/release-v4.9/test/token/ERC20 . +| Test name |Description | Happy Case | Implemented | +|---------------------------------| --------------------------------------------------|------------|-------------| +| testInitExtended | Check constructor. | Yes | Yes | + +## ImmutableERC20MinterBurnerPermit.sol +This section defines tests for contracts/erc20/preset/ImmutableERC20MinterBurnerPermit.sol. All of the tests defined in the table below are in test/erc20/preset/ImmutableERC20MinterBurnerPermit.t.sol. +Minter, Burner and Permit features are tested in ERC20MinternBurnerPermitCommon.t.sol, described above. | Test name |Description | Happy Case | Implemented | |---------------------------------| --------------------------------------------------|------------|-------------| -| testInit | Check constructor. | Yes | Yes | -| testChangeOwner | Check change ownership. | Yes | Yes | -| testRenounceOwnershipBlocked | Ensure renounceOwnership reverts. | No | Yes | -| testOnlyMinterCanMunt | Ensure Only minter role can mint reverts. | No | Yes | -| testMint | Ensure successful minting by minter | No | Yes | -| testCanOnlyMintUpToMaxSupply | Ensure can only mint up to max supply | No | Yes | -| testRenounceLastHubOwnerBlocked | Ensure the last hub owner cannot be renounced | No | Yes | -| testRenounceLastAdminBlocked | Ensure the last default admin cannot be renounced | No | Yes | -| testRenounceAdmin | Ensure admin role can be renounced | No | Yes | -| testRenounceHubOwner | Ensure hub owner role can be renounced | No | Yes | -| testBurnFrom | Ensure allowance is required to burnFrom | Yes | Yes | -| testPermit | Ensure Permit works | Yes | Yes | +| testInitExtended | Check constructor. | Yes | Yes | +| testRenounceAdmin | Check that default admins can call renounce. | Yes | Yes | +| testRenounceLastAdminBlocked | Check that the last admin can not call renounce. | No | Yes | +| testRenounceHubOwner | Check that hub owners can call renounce. | Yes | Yes | +| testRenounceLastHubOwnerBlocked | Check that the last hub owner can not call renounce. | No | Yes | + +## ImmutableERC20MinterBurnerPermitV2.sol +This section defines tests for contracts/erc20/preset/ImmutableERC20MinterBurnerPermitV2.sol. +All of the tests defined in the table below are in test/erc20/preset/ImmutableERC20MinterBurnerPermitV2.t.sol. +Note that ImmutableERC20MinterBurnerPermitV2 extends HubOwner.sol. The ownership features reside in +HubOwner.sol, and hence are tested in HubOwner.t.sol. Minter, Burner and Permit features are +tested in ERC20MinternBurnerPermitCommon.t.sol, described above. + + +| Test name |Description | Happy Case | Implemented | +|---------------------------------| --------------------------------------------------|------------|-------------| +| testInitExtended | Check constructor. | Yes | Yes | +| testRenounceAdmin | Check that default admins can call renounce. | Yes | Yes | +| testRenounceLastAdminBlocked | Check that the last admin can not call renounce. | No | Yes | +| testRenounceHubOwner | Check that hub owners can call renounce. | Yes | Yes | +| testRenounceLastHubOwnerBlocked | Check that the last hub owner can not call renounce. | No | Yes |