diff --git a/.gas-snapshot b/.gas-snapshot index e3a6297..9e67cb6 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -41,4 +41,15 @@ FiatTokenV1Test:testUnpause() (gas: 35210) FiatTokenV1Test:testUnpauseUnauthorized() (gas: 52752) FiatTokenV1Test:testUpgradeToAndCall() (gas: 2336296) FiatTokenV1Test:testVersion() (gas: 11934) +FiatTokenV2Test:testBlacklistBurner() (gas: 120114) +FiatTokenV2Test:testBurnByBurner() (gas: 68598) +FiatTokenV2Test:testBurnByBurnerMustBeLessThanBalance() (gas: 86736) +FiatTokenV2Test:testBurnByBurnerUnauthorized() (gas: 88757) +FiatTokenV2Test:testBurnerPause() (gas: 55480) +FiatTokenV2Test:testGrantBurnerRole() (gas: 52173) +FiatTokenV2Test:testRenounceBurnerRole() (gas: 41264) +FiatTokenV2Test:testRevokeBurnerRole() (gas: 41667) +FiatTokenV2Test:testUnblacklistBurner() (gas: 95862) +FiatTokenV2Test:testUpgradeToAndCall() (gas: 5104270) +FiatTokenV2Test:testVersion() (gas: 11978) RescuableV1Test:testRescue() (gas: 42236) \ No newline at end of file diff --git a/package.json b/package.json index 20a9bc1..dd2f463 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "@trillion-x/trillion-contracts", + "name": "trillion-contracts", "version": "1.0.0", "license": "MIT", - "repository": "git@github.com:royal-markets/royal-contracts.git", + "repository": "git@github.com:trillion-network/trillion-contracts.git", "files": [ "out", "ts-types" diff --git a/script/UpgradeFiatToken.s.sol b/script/UpgradeFiatToken.s.sol index bb1e470..d07a99a 100644 --- a/script/UpgradeFiatToken.s.sol +++ b/script/UpgradeFiatToken.s.sol @@ -5,9 +5,9 @@ pragma solidity ^0.8.20; import "forge-std/console2.sol"; import {Script} from "forge-std/Script.sol"; import {Upgrades, Options} from "openzeppelin-foundry-upgrades/Upgrades.sol"; -import {FiatTokenV1} from "../src/v1/FiatTokenV1.sol"; +import {FiatTokenV2} from "../src/v2/FiatTokenV2.sol"; -contract DeployFiatToken is Script { +contract UpgradeFiatToken is Script { function run() public { vm.startBroadcast(); address fiatTokenProxyAddress = vm.envAddress("FIAT_TOKEN_PROXY_ADDRESS"); @@ -16,7 +16,7 @@ contract DeployFiatToken is Script { opts.referenceContract = "FiatTokenV1.sol"; // upgrade contract Upgrades.upgradeProxy(fiatTokenProxyAddress, "FiatTokenV2.sol", "", opts); - console2.log("FiatTokenV1 upgraded to version 2"); + console2.log("FiatToken upgrade successful"); vm.stopBroadcast(); } } diff --git a/src/v2/FiatTokenV2.sol b/src/v2/FiatTokenV2.sol new file mode 100644 index 0000000..7aaf04d --- /dev/null +++ b/src/v2/FiatTokenV2.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import "../v1/FiatTokenV1.sol"; + +/// @custom:security-contact snggeng@gmail.com +contract FiatTokenV2 is FiatTokenV1 { + bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); + + // burnByBurnerOnly is a token burn operation that can only be called by BURNER_ROLE + // this is to separate the operation from MINTER_ROLE that can call mint and burn function + function burnByBurnerOnly(uint256 value) public virtual onlyRole(BURNER_ROLE) { + _burn(_msgSender(), value); + } + + function version() public pure virtual override(FiatTokenV1) returns (string memory) { + return "2"; + } +} diff --git a/test/v1/FiatTokenV1.t.sol b/test/v1/FiatTokenV1.t.sol index 064c38e..1440cac 100644 --- a/test/v1/FiatTokenV1.t.sol +++ b/test/v1/FiatTokenV1.t.sol @@ -12,11 +12,10 @@ import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.so import {ERC20CappedUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20CappedUpgradeable.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; -import {Upgrades, Options} from "openzeppelin-foundry-upgrades/Upgrades.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {CallerBlacklisted} from "../../src/v1/BlacklistableV1.sol"; import {Ramen} from "../../src/mocks/Ramen.sol"; -// import {FiatTokenV99} from "../../src/mocks/FiatTokenV99.sol"; // mock contract to test upgrades contract FiatTokenV99 is FiatTokenV1 { diff --git a/test/v2/FiatTokenV2.t.sol b/test/v2/FiatTokenV2.t.sol new file mode 100644 index 0000000..bc8cf5e --- /dev/null +++ b/test/v2/FiatTokenV2.t.sol @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; + +import {Test, console2} from "forge-std/Test.sol"; +import {FiatTokenV1} from "../../src/v1/FiatTokenV1.sol"; +import {FiatTokenV2} from "../../src/v2/FiatTokenV2.sol"; + +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {CallerBlacklisted} from "../../src/v1/BlacklistableV1.sol"; + +contract FiatTokenV2Test is Test { + FiatTokenV2 public fiatTokenV2; + ERC1967Proxy public proxy; + address public owner; + address public defaultAdmin; + address public pauser; + address public minter; + address public upgrader; + address public rescuer; + address public blacklister; + address public unauthorized; + address public burner; + address public trustedAddress; + string public tokenName = "FiatTokenV2"; + string public tokenSymbol = "FIAT"; + + // events + event Blacklisted(address indexed account); + event UnBlacklisted(address indexed account); + + function setUp() public { + owner = address(this); + defaultAdmin = vm.addr(1); + pauser = vm.addr(2); + minter = vm.addr(3); + upgrader = vm.addr(4); + rescuer = vm.addr(5); + blacklister = vm.addr(6); + unauthorized = vm.addr(7); + burner = vm.addr(8); + trustedAddress = address(0x66787300CCc33F17643a02635ca96d54301aE2a8); + + // Deploy the token implementation + fiatTokenV2 = new FiatTokenV2(); + + // Deploy the proxy and initialize the contract through the proxy + vm.prank(trustedAddress); + proxy = new ERC1967Proxy( + address(fiatTokenV2), + abi.encodeCall( + fiatTokenV2.initialize, + (defaultAdmin, pauser, minter, upgrader, rescuer, blacklister, tokenName, tokenSymbol) + ) + ); + + // Attach the FiatTokenV2 interface to the deployed proxy + fiatTokenV2 = FiatTokenV2(address(proxy)); + + // Assign BURNER_ROLE to burner + bytes32 burnerRole = fiatTokenV2.BURNER_ROLE(); + vm.prank(defaultAdmin); + fiatTokenV2.grantRole(burnerRole, burner); + } + + // ERC 20 behavior + + function testVersion() public { + assertEq(fiatTokenV2.version(), "2"); + } + + function testBurnByBurner() public { + assertEq(fiatTokenV2.totalSupply(), 0); + vm.prank(minter); + fiatTokenV2.mint(burner, 100); + assertEq(fiatTokenV2.totalSupply(), 100); + assertEq(fiatTokenV2.balanceOf(burner), 100); + vm.prank(burner); + fiatTokenV2.burnByBurnerOnly(100); + assertEq(fiatTokenV2.totalSupply(), 0); + assertEq(fiatTokenV2.balanceOf(burner), 0); + } + + function testBurnByBurnerMustBeLessThanBalance() public { + assertEq(fiatTokenV2.totalSupply(), 0); + vm.prank(minter); + fiatTokenV2.mint(burner, 100); + assertEq(fiatTokenV2.totalSupply(), 100); + assertEq(fiatTokenV2.balanceOf(burner), 100); + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientBalance.selector, + burner, // from + 100, // fromBalance + 101 // value + ) + ); + vm.prank(burner); + fiatTokenV2.burnByBurnerOnly(101); + } + + function testBurnByBurnerUnauthorized() public { + assertEq(fiatTokenV2.totalSupply(), 0); + vm.prank(minter); + fiatTokenV2.mint(owner, 100); + assertEq(fiatTokenV2.totalSupply(), 100); + assertEq(fiatTokenV2.balanceOf(owner), 100); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + unauthorized, // address + fiatTokenV2.BURNER_ROLE() // role + ) + ); + vm.prank(unauthorized); + fiatTokenV2.burnByBurnerOnly(100); + } + + function testBurnerPause() public { + assertEq(fiatTokenV2.paused(), false); + vm.prank(pauser); + fiatTokenV2.pause(); + assertEq(fiatTokenV2.paused(), true); + // when contract is paused, not allowed to burn + vm.expectRevert(Pausable.EnforcedPause.selector); + vm.prank(burner); + fiatTokenV2.burnByBurnerOnly(100); + } + + function testBlacklistBurner() public { + // mint tokens to burner account + // for simplicity, we blacklist the burner account since it has permissions to burn + vm.prank(minter); + fiatTokenV2.mint(burner, 100); + assertEq(fiatTokenV2.balanceOf(burner), 100); + + // blacklist minter account + assertEq(fiatTokenV2.isBlacklisted(burner), false); + vm.expectEmit(); + emit Blacklisted(burner); + vm.prank(blacklister); + fiatTokenV2.blacklist(burner); + assertEq(fiatTokenV2.isBlacklisted(burner), true); + // once blacklisted, not allowed to transfer + vm.expectRevert(abi.encodeWithSelector(CallerBlacklisted.selector, burner)); + vm.prank(burner); + fiatTokenV2.transfer(burner, 100); + // not allowed to burn + vm.expectRevert(abi.encodeWithSelector(CallerBlacklisted.selector, burner)); + vm.prank(burner); + fiatTokenV2.burnByBurnerOnly(100); + } + + function testUnblacklistBurner() public { + // mint 100 for to burner first + vm.prank(minter); + fiatTokenV2.mint(burner, 100); + // blacklist burner account + assertEq(fiatTokenV2.isBlacklisted(burner), false); + vm.expectEmit(); + emit Blacklisted(burner); + vm.prank(blacklister); + fiatTokenV2.blacklist(burner); + assertEq(fiatTokenV2.isBlacklisted(burner), true); + // unblacklist burner account + vm.expectEmit(); + emit UnBlacklisted(burner); + vm.prank(blacklister); + fiatTokenV2.unBlacklist(burner); + assertEq(fiatTokenV2.isBlacklisted(burner), false); + // once unblacklisted, allowed to burn + vm.prank(burner); + fiatTokenV2.burnByBurnerOnly(100); + // no balance left after transferring and burning + assertEq(fiatTokenV2.balanceOf(burner), 0); + } + + // Access control + + function testGrantBurnerRole() public { + bytes32 burnerRole = fiatTokenV2.BURNER_ROLE(); + assertEq(fiatTokenV2.hasRole(burnerRole, unauthorized), false); + vm.prank(defaultAdmin); + fiatTokenV2.grantRole(burnerRole, unauthorized); + assertEq(fiatTokenV2.hasRole(burnerRole, unauthorized), true); + } + + function testRevokeBurnerRole() public { + bytes32 burnerRole = fiatTokenV2.BURNER_ROLE(); + vm.prank(defaultAdmin); + fiatTokenV2.grantRole(burnerRole, unauthorized); + assertEq(fiatTokenV2.hasRole(burnerRole, unauthorized), true); + + vm.prank(defaultAdmin); + fiatTokenV2.revokeRole(burnerRole, unauthorized); + assertEq(fiatTokenV2.hasRole(burnerRole, unauthorized), false); + } + + function testRenounceBurnerRole() public { + bytes32 burnerRole = fiatTokenV2.BURNER_ROLE(); + vm.prank(defaultAdmin); + fiatTokenV2.grantRole(burnerRole, unauthorized); + assertEq(fiatTokenV2.hasRole(burnerRole, unauthorized), true); + + vm.prank(unauthorized); // caller needs to be the one renouncing their own role + fiatTokenV2.renounceRole(burnerRole, unauthorized); + assertEq(fiatTokenV2.hasRole(burnerRole, unauthorized), false); + } + + // Upgradeability + + function testUpgradeToAndCall() public { + // new implementation contract + FiatTokenV1 fiatTokenV1 = new FiatTokenV1(); + vm.prank(trustedAddress); + ERC1967Proxy proxyV1 = new ERC1967Proxy( + address(fiatTokenV1), + abi.encodeCall( + fiatTokenV1.initialize, + (defaultAdmin, pauser, minter, upgrader, rescuer, blacklister, tokenName, tokenSymbol) + ) + ); + + // Attach the FiatTokenV1 interface to the deployed proxy + fiatTokenV1 = FiatTokenV1(address(proxyV1)); + + FiatTokenV2 fiatTokenV2New = new FiatTokenV2(); + address newImplementationAddress = address(fiatTokenV2New); + assertEq(fiatTokenV1.version(), "1"); + assertEq(fiatTokenV2New.version(), "2"); + + // upgrade contract + vm.prank(upgrader); + fiatTokenV1.upgradeToAndCall(newImplementationAddress, ""); + address updatedImplementationAddress = Upgrades.getImplementationAddress(address(proxyV1)); + // verify implementation address is updated + assertEq(newImplementationAddress, updatedImplementationAddress); + // verify version() function implementation is updated + assertEq(fiatTokenV1.version(), "2"); + } +}