diff --git a/Makefile b/Makefile index 54e1182..b8b2d96 100644 --- a/Makefile +++ b/Makefile @@ -20,10 +20,13 @@ install :; forge install foundry-rs/forge-std --no-commit && \ forge install smartcontractkit/chainlink-brownie-contracts --no-commit && \ forge install OpenZeppelin/openzeppelin-community-contracts --no-commit .PHONY: test -test :; forge test -test-vvv :; forge test -vvv -test-gasreport :; forge test --gas-report -test-fork :; forge test --fork-url ${ETH_RPC_URL} -vvv +test : clean + forge test +test-vvv : clean + forge test -vvv +test-gasreport : clean + forge test --gas-report +test-fork :; forge test --fork-url ${ETH_RPC_URL} -vvv .PHONY: coverage coverage :; mkdir -p ./coverage && forge coverage --no-match-coverage "script|test" --report lcov --report-file coverage/lcov.info && genhtml coverage/lcov.info -o coverage --branch-coverage snapshot :; forge snapshot diff --git a/foundry.toml b/foundry.toml index 1e0b160..6fa30fe 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,7 +4,7 @@ test = "test" out = "out" libs = ["lib"] remappings = [] -#ffi = true +ffi = true ast = true build_info = true extra_output = ["storageLayout"] diff --git a/remappings.txt b/remappings.txt index 77295c3..2aa94bf 100644 --- a/remappings.txt +++ b/remappings.txt @@ -2,4 +2,5 @@ forge-std/=lib/forge-std/src/ @openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ @openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ @chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/src/ -@openzeppelin/community-contracts/=lib/openzeppelin-community-contracts/contracts/ \ No newline at end of file +@openzeppelin/community-contracts/=lib/openzeppelin-community-contracts/contracts/ +@openzeppelin-foundry-upgrades/=lib/openzeppelin-foundry-upgrades/src/ \ No newline at end of file diff --git a/script/DeployOracleMock.s.sol b/script/DeployOracleMock.s.sol index fe4da75..8be81d8 100644 --- a/script/DeployOracleMock.s.sol +++ b/script/DeployOracleMock.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.20; +pragma solidity 0.8.22; //import {Script, console} from "forge-std/Script.sol"; // not recognized by VS Code import {Script, console} from "lib/forge-std/src/Script.sol"; diff --git a/script/DeployToken.s.sol b/script/DeployToken.s.sol index 16f3ed4..84b3a1a 100644 --- a/script/DeployToken.s.sol +++ b/script/DeployToken.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.20; +pragma solidity 0.8.22; //import {Script, console} from "forge-std/Script.sol"; // not recognized by VS Code import {Script, console} from "lib/forge-std/src/Script.sol"; @@ -7,6 +7,7 @@ import {HelperConfig} from "./HelperConfig.s.sol"; import {DeployOracleMock} from "./DeployOracleMock.s.sol"; import {EmGEMxToken} from "../src/EmGEMxToken.sol"; import {MockV3Aggregator} from "../test/mocks/MockV3Aggregator.sol"; +import {Upgrades} from "@openzeppelin-foundry-upgrades/Upgrades.sol"; /** * @title Deployment script for the EmGEMx token contract @@ -30,13 +31,16 @@ contract DeployToken is Script { } console.log("Oracle address:", esuOracle); - token = new EmGEMxToken(); - console.log("Token address:", address(token)); - string memory tokenName = vm.envString("TOKEN_NAME"); // "EmGEMx Switzerland" string memory tokenSymbol = vm.envString("TOKEN_SYMBOL"); // "EmCH" - token.initialize(esuOracle, tokenName, tokenSymbol); + address proxyAddress = Upgrades.deployTransparentProxy( + "EmGEMxToken.sol", msg.sender, abi.encodeCall(EmGEMxToken.initialize, (esuOracle, tokenName, tokenSymbol)) + ); + token = EmGEMxToken(proxyAddress); + console.log("Token address:", address(token)); + address implementationAddress = Upgrades.getImplementationAddress(proxyAddress); + console.log("Implementation address:", implementationAddress); vm.stopBroadcast(); diff --git a/script/GrantMinterRole.s.sol b/script/GrantMinterRole.s.sol index f978bf8..41b6a47 100644 --- a/script/GrantMinterRole.s.sol +++ b/script/GrantMinterRole.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.20; +pragma solidity 0.8.22; //import {Script, console} from "forge-std/Script.sol"; // not recognized by VS Code import {Script, console} from "lib/forge-std/src/Script.sol"; diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index 94f2c5b..924a1c6 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.20; +pragma solidity 0.8.22; import {MockV3Aggregator} from "../test/mocks/MockV3Aggregator.sol"; import {Script, console} from "forge-std/Script.sol"; diff --git a/script/Mint.s.sol b/script/Mint.s.sol index b6196b4..0536e98 100644 --- a/script/Mint.s.sol +++ b/script/Mint.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.20; +pragma solidity 0.8.22; //import {Script, console} from "forge-std/Script.sol"; // not recognized by VS Code import {Script, console} from "lib/forge-std/src/Script.sol"; diff --git a/src/ERC20BlocklistUpgradeable.sol b/src/ERC20BlocklistUpgradeable.sol index 9492a15..48f3419 100644 --- a/src/ERC20BlocklistUpgradeable.sol +++ b/src/ERC20BlocklistUpgradeable.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.20; +pragma solidity 0.8.22; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; @@ -17,10 +17,23 @@ import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ * {_unblockUser} is called. */ abstract contract ERC20BlocklistUpgradeable is ERC20Upgradeable { - /** - * @dev Blocked status of addresses. True if blocked, False otherwise. - */ - mapping(address user => bool) private _blocked; + /// @custom:storage-location erc7201:ERC20BlocklistUpgradeable.storage + struct ERC20BlocklistUpgradeableStorage { + /** + * @dev Blocked status of addresses. True if blocked, False otherwise. + */ + mapping(address user => bool) _blocked; + } + + // keccak256(abi.encode(uint256(keccak256("ERC20BlocklistUpgradeable.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ERC20BlocklistUpgradeableStorageLocation = + 0x943d6f0aae33a11bdac5b1e1fa272eeefbfbb201792466fc6e6bfcdac78e2f00; + + function _getERC20BlocklistStorage() private pure returns (ERC20BlocklistUpgradeableStorage storage $) { + assembly { + $.slot := ERC20BlocklistUpgradeableStorageLocation + } + } /** * @dev Emitted when a user is blocked. @@ -40,8 +53,9 @@ abstract contract ERC20BlocklistUpgradeable is ERC20Upgradeable { /** * @dev Returns the blocked status of an account. */ - function blocked(address account) public virtual returns (bool) { - return _blocked[account]; + function blocked(address account) public view virtual returns (bool) { + ERC20BlocklistUpgradeableStorage storage $ = _getERC20BlocklistStorage(); + return $._blocked[account]; } /** @@ -50,7 +64,8 @@ abstract contract ERC20BlocklistUpgradeable is ERC20Upgradeable { function _blockUser(address user) internal virtual returns (bool) { bool isBlocked = blocked(user); if (!isBlocked) { - _blocked[user] = true; + ERC20BlocklistUpgradeableStorage storage $ = _getERC20BlocklistStorage(); + $._blocked[user] = true; emit UserBlocked(user); } return isBlocked; @@ -62,7 +77,8 @@ abstract contract ERC20BlocklistUpgradeable is ERC20Upgradeable { function _unblockUser(address user) internal virtual returns (bool) { bool isBlocked = blocked(user); if (isBlocked) { - _blocked[user] = false; + ERC20BlocklistUpgradeableStorage storage $ = _getERC20BlocklistStorage(); + $._blocked[user] = false; emit UserUnblocked(user); } return isBlocked; diff --git a/src/ERC20FreezableUpgradeable.sol b/src/ERC20FreezableUpgradeable.sol index 2fd44b4..58fa696 100644 --- a/src/ERC20FreezableUpgradeable.sol +++ b/src/ERC20FreezableUpgradeable.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.20; +pragma solidity 0.8.22; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; @@ -20,10 +20,23 @@ import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ * Taken from https://docs.openzeppelin.com/community-contracts/0.0.1/api/token#ERC20Custodian */ abstract contract ERC20FreezableUpgradeable is ERC20Upgradeable { - /** - * @dev The amount of tokens frozen by user address. - */ - mapping(address user => uint256 amount) private _frozen; + /// @custom:storage-location erc7201:ERC20FreezableUpgradeable.storage + struct ERC20FreezableUpgradeableStorage { + /** + * @dev The amount of tokens frozen by user address. + */ + mapping(address user => uint256 amount) _frozen; + } + + // keccak256(abi.encode(uint256(keccak256("ERC20FreezableUpgradeable.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ERC20FreezableUpgradeableStorageLocation = + 0x3fa3c15e60cd35140c36fb837aa364f49ac6961def966d04c318a640c7984100; + + function _getERC20FreezableStorage() private pure returns (ERC20FreezableUpgradeableStorage storage $) { + assembly { + $.slot := ERC20FreezableUpgradeableStorageLocation + } + } /** * @dev Emitted when tokens are frozen for a user. @@ -66,7 +79,8 @@ abstract contract ERC20FreezableUpgradeable is ERC20Upgradeable { * @dev Returns the amount of tokens frozen for a user. */ function frozen(address user) public view virtual returns (uint256) { - return _frozen[user]; + ERC20FreezableUpgradeableStorage storage $ = _getERC20FreezableStorage(); + return $._frozen[user]; } /** @@ -80,7 +94,8 @@ abstract contract ERC20FreezableUpgradeable is ERC20Upgradeable { */ function freeze(address user, uint256 amount) external virtual onlyFreezer { if (availableBalance(user) < amount) revert ERC20InsufficientUnfrozenBalance(user); - _frozen[user] = amount; + ERC20FreezableUpgradeableStorage storage $ = _getERC20FreezableStorage(); + $._frozen[user] = amount; emit TokensFrozen(user, amount); } diff --git a/src/EmGEMxToken.sol b/src/EmGEMxToken.sol index d17b043..78b45b9 100644 --- a/src/EmGEMxToken.sol +++ b/src/EmGEMxToken.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.20; +pragma solidity 0.8.22; import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; @@ -70,9 +70,8 @@ contract EmGEMxToken is - max_tokens = esu / esu_per_token */ - // initial esuPerToken: 0.01 - uint256 private esuPerTokenValue = 1; - uint256 private esuPerTokenPrecision = 100; + uint256 private esuPerTokenValue; + uint256 private esuPerTokenPrecision; /////////////////// // Events @@ -93,16 +92,24 @@ contract EmGEMxToken is _; } + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + /////////////////// // Functions /////////////////// - function initialize(address oracleAddres, string memory name, string memory symbol) public initializer { + function initialize(address oracleAddress, string memory name, string memory symbol) public initializer { + validateNotZeroAddress(oracleAddress); + __ERC20_init(name, symbol); __ERC20Burnable_init(); __ERC20Pausable_init(); __AccessControl_init(); __Ownable_init(_msgSender()); + __ERC20Permit_init(name); _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); _setRoleAdmin(MINTER_ROLE, DEFAULT_ADMIN_ROLE); _setRoleAdmin(ESU_PER_TOKEN_MODIFIER_ROLE, DEFAULT_ADMIN_ROLE); @@ -111,7 +118,11 @@ contract EmGEMxToken is _setRoleAdmin(LIMITER_ROLE, DEFAULT_ADMIN_ROLE); _setRoleAdmin(REDEEMER_ROLE, DEFAULT_ADMIN_ROLE); - oracle = AggregatorV3Interface(oracleAddres); + oracle = AggregatorV3Interface(oracleAddress); + + // initial esuPerToken: 0.01 + esuPerTokenValue = 1; + esuPerTokenPrecision = 100; } /////////////////// @@ -124,8 +135,13 @@ contract EmGEMxToken is } /// @notice Wrapps ERC20BurnableUpgradeable._burn - function burn(address account, uint256 value) external onlyRole(MINTER_ROLE) { - _burn(account, value); + function burn(uint256 value) public override onlyRole(MINTER_ROLE) { + super.burn(value); + } + + /// @notice Wrapps ERC20BurnableUpgradeable._burnFrom + function burnFrom(address account, uint256 value) public override onlyRole(MINTER_ROLE) { + super.burnFrom(account, value); } /// @notice Burns token from the redeemAddress. @@ -215,6 +231,7 @@ contract EmGEMxToken is return type(uint256).max; } + // as returned oracle value has 8 decimals (same as token decimals) we can directly take it for token supply calculation return _getEsuFromOracle() * esuPerTokenPrecision / esuPerTokenValue; } @@ -262,16 +279,19 @@ contract EmGEMxToken is /// @return The quried ESU value from the chainlink PoR oracle. function _getEsuFromOracle() private view returns (uint256) { ( - /* uint80 roundID */ - , + uint80 roundID, int256 answer, /*uint startedAt*/ , - /*uint timeStamp*/ - , - /*uint80 answeredInRound*/ + uint256 updatedAt, + uint80 answeredInRound ) = oracle.latestRoundData(); + require(answer > 0, "PoR: answer is zero"); + require(updatedAt > 0, "PoR: updatedAt is zero"); + require(answeredInRound >= roundID, "PoR: data is stale"); + + // according to docs answer has always 8 decimals! return uint256(answer); } diff --git a/test/mocks/MockV3Aggregator.sol b/test/mocks/MockV3Aggregator.sol index 4f1feb0..ade1f16 100644 --- a/test/mocks/MockV3Aggregator.sol +++ b/test/mocks/MockV3Aggregator.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.20; +pragma solidity 0.8.22; import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; @@ -18,6 +18,7 @@ contract MockV3Aggregator is AggregatorV3Interface { int256 public latestAnswer; uint256 public latestTimestamp; uint256 public latestRound; + bool public isStale; mapping(uint256 => int256) public getAnswer; mapping(uint256 => uint256) public getTimestamp; @@ -34,6 +35,7 @@ contract MockV3Aggregator is AggregatorV3Interface { getAnswer[latestRound] = _answer; getTimestamp[latestRound] = block.timestamp; getStartedAt[latestRound] = block.timestamp; + isStale = false; } function updateRoundData(uint80 _roundId, int256 _answer, uint256 _timestamp, uint256 _startedAt) public { @@ -43,6 +45,7 @@ contract MockV3Aggregator is AggregatorV3Interface { getAnswer[latestRound] = _answer; getTimestamp[latestRound] = _timestamp; getStartedAt[latestRound] = _startedAt; + isStale = false; } function getRoundData(uint80 _roundId) @@ -50,7 +53,7 @@ contract MockV3Aggregator is AggregatorV3Interface { view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) { - return (_roundId, getAnswer[_roundId], getStartedAt[_roundId], getTimestamp[_roundId], _roundId); + return (_roundId, getAnswer[_roundId], getStartedAt[_roundId], getTimestamp[_roundId], isStale ? 0 : _roundId); } function latestRoundData() @@ -63,10 +66,14 @@ contract MockV3Aggregator is AggregatorV3Interface { getAnswer[latestRound], getStartedAt[latestRound], getTimestamp[latestRound], - uint80(latestRound) + isStale ? 0 : uint80(latestRound) ); } + function setStale() external { + isStale = true; + } + function description() external pure returns (string memory) { return "v0.6/tests/MockV3Aggregator.sol"; } diff --git a/test/unit/EmGEMxTokenTest.t.sol b/test/unit/EmGEMxTokenTest.t.sol index 02d47c2..98e4007 100644 --- a/test/unit/EmGEMxTokenTest.t.sol +++ b/test/unit/EmGEMxTokenTest.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.20; +pragma solidity 0.8.22; import {Test, console} from "lib/forge-std/src/Test.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; @@ -27,6 +27,8 @@ contract EmGEMxTokenTest is Test { address user = makeAddr("user"); address anon = makeAddr("anon"); + uint256 private constant ONE_TOKEN = 100000000; // 8 decimals -> 1234000000 = 12.34 + event TokensFrozen(address indexed user, uint256 amount); event OracleAddressChanged(string oldAddres, string newAddress); event EsuPerTokenChanged(uint256 value, uint256 precision); @@ -101,47 +103,45 @@ contract EmGEMxTokenTest is Test { function testMintOnAvalancheParentChainRespectsEsuOracle_And_EsuPerTokenSetting() public { vm.chainId(token.PARENT_CHAIN_ID()); - int256 esu = 100 ether; - _setEsu(esu); + _setEsu(100 * ONE_TOKEN); // 100 uint256 maxSupply = token.getMaxSupply(); console.log("Allowed MaxSupply:", maxSupply); - assertEq(maxSupply, 10_000 ether, "Parameters changed - Arrange needs to be adjusted"); + assertEq(maxSupply, 10_000 * ONE_TOKEN, "Parameters changed - Arrange needs to be adjusted"); vm.startPrank(minter); - token.mint(user, 5000 ether); - assertEq(token.totalSupply(), 5000 ether); + token.mint(user, 5000 * ONE_TOKEN); + assertEq(token.totalSupply(), 5000 * ONE_TOKEN); - token.mint(user, 5000 ether); - assertEq(token.totalSupply(), 10_000 ether); + token.mint(user, 5000 * ONE_TOKEN); + assertEq(token.totalSupply(), 10_000 * ONE_TOKEN); // ACT vm.expectRevert(EmGEMxToken.EmGEMxToken__NotEnoughReserve.selector); token.mint(user, 1); vm.stopPrank(); - assertEq(token.balanceOf(user), 10_000 ether); + assertEq(token.balanceOf(user), 10_000 * ONE_TOKEN); } function testMintOnChildChainHasNoRestriction() public { vm.chainId(1); // e.g. ethereum mainnet - int256 esu = 100 ether; - _setEsu(esu); + _setEsu(100 * ONE_TOKEN); // 100 uint256 maxSupply = token.getMaxSupply(); assertEq(maxSupply, type(uint256).max); // ACT vm.startPrank(minter); - token.mint(user, 1_000_000 ether); - assertEq(token.totalSupply(), 1_000_000 ether); + token.mint(user, 1_000_000 * ONE_TOKEN); + assertEq(token.totalSupply(), 1_000_000 * ONE_TOKEN); - assertEq(token.balanceOf(user), 1_000_000 ether); + assertEq(token.balanceOf(user), 1_000_000 * ONE_TOKEN); } - function _setEsu(int256 value) private { - oracle.updateAnswer(value); + function _setEsu(uint256 value) private { + oracle.updateAnswer(int256(value)); } function testOnlyEsuPerTokenModifierCanUpdateEsuPerTokenValue() public { @@ -193,28 +193,51 @@ contract EmGEMxTokenTest is Test { (uint256 esu, uint256 esuPrecision) = token.getEsuPerToken(); assertEq(esu, 1); - assertEq(esuPrecision, 100); // 0.01 ether + assertEq(esuPrecision, 100); // 0.01 + + uint256 decimals = token.decimals(); - _setEsu(2521130000000000000000); // 2521.13 + _setEsu(252113 * ONE_TOKEN / 100); // 2521.13 uint256 maxSupplyWei = token.getMaxSupply(); - assertEq(maxSupplyWei, 252_113 ether); + assertEq(maxSupplyWei, 252_113 * 10 ** decimals); - _setEsu(2521130000000000000000); // 2521.13 + _setEsu(252113 * ONE_TOKEN / 100); // 2521.13 vm.prank(esuPerTokenModifier); token.setEsuPerToken(99, 10000); // 0.0099 maxSupplyWei = token.getMaxSupply(); - assertEq(roundTwoDecimals(maxSupplyWei), 254_659_60 ether / 100); // 254659.60 + assertEq(roundTwoDecimals(maxSupplyWei), 254_659_60 * ONE_TOKEN / 100); // 254659.60 - _setEsu(3871130000000000000000); // 3871.13 + _setEsu(387113 * ONE_TOKEN / 100); // 3871.13 vm.prank(esuPerTokenModifier); token.setEsuPerToken(9801, 1_000_000); // 0.009801 maxSupplyWei = token.getMaxSupply(); - assertEq(roundTwoDecimals(maxSupplyWei), 394_972_96 ether / 100); // 394972.96 + assertEq(roundTwoDecimals(maxSupplyWei), 394_972_96 * ONE_TOKEN / 100); // 394972.96 + } + + function testStaleOracleReverts() public { + vm.chainId(token.PARENT_CHAIN_ID()); + + _setEsu(1000 * ONE_TOKEN); // 1000 + (uint80 roundId, int256 answer, /*uint256 startedAt*/, uint256 updatedAt, uint80 answeredInRound) = + oracle.getRoundData(1); + + oracle.updateRoundData(roundId, 0, updatedAt, answeredInRound); + vm.expectRevert(bytes("PoR: answer is zero")); + uint256 maxSupplyWei = token.getMaxSupply(); + + oracle.updateRoundData(roundId, answer, 0, answeredInRound); + vm.expectRevert(bytes("PoR: updatedAt is zero")); + maxSupplyWei = token.getMaxSupply(); + + oracle.updateRoundData(roundId, answer, updatedAt, answeredInRound); + oracle.setStale(); + vm.expectRevert(bytes("PoR: data is stale")); + maxSupplyWei = token.getMaxSupply(); } function roundTwoDecimals(uint256 value) private pure returns (uint256) { - // Define the rounding factor for 0.01 ether (10^16 wei) - uint256 roundingFactor = 10 ** 16; + // Define the rounding factor for 0.01 token (10^6 wei as 8 token decimals) + uint256 roundingFactor = 10 ** 6; // Add half of the rounding factor to the value for proper rounding uint256 roundedValue = (value + (roundingFactor / 2)) / roundingFactor; @@ -228,39 +251,101 @@ contract EmGEMxTokenTest is Test { /*##################################################################################*/ function testOnlyMinterCanMint() public { - _setEsu(1_000 ether); + _setEsu(1000 * ONE_TOKEN); // 1000 vm.expectRevert( abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.MINTER_ROLE()) ); vm.prank(user); - token.mint(user, 1_000 ether); - assertEq(token.balanceOf(user), 0 ether); + token.mint(user, 1_000 * ONE_TOKEN); + assertEq(token.balanceOf(user), 0); vm.prank(minter); - token.mint(user, 1 ether); - assertEq(token.balanceOf(user), 1 ether); + token.mint(user, 1 * ONE_TOKEN); + assertEq(token.balanceOf(user), 1 * ONE_TOKEN); } function testOnlyMinterCanBurnOnChildChain() public { - _setEsu(1_000 ether); + _setEsu(100000 * ONE_TOKEN / 100); // 1000 vm.chainId(1); // burn restriction only in place on parent chain vm.prank(minter); - token.mint(user, uint256(10 ether)); + token.mint(user, uint256(10 * ONE_TOKEN)); vm.expectRevert( abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.MINTER_ROLE()) ); vm.prank(user); - token.burn(user, 1 ether); + token.burn(1 * ONE_TOKEN); - assertEq(token.balanceOf(user), 10 ether); + assertEq(token.balanceOf(user), 10 * ONE_TOKEN); + // add minter role to user -> now burning his tokens should be possible + bytes32 role = token.MINTER_ROLE(); + vm.prank(admin); + token.grantRole(role, user); + assertTrue(token.hasRole(role, user)); + + vm.prank(user); + token.burn(1 * ONE_TOKEN); + + assertEq(token.balanceOf(user), 9 * ONE_TOKEN); + } + + function testRegularUsersWihtoutMinterRoleCannotBurnOnChildChain() public { + // Set to a child chain (not the parent chain) + vm.chainId(1); // Use a different chain ID than PARENT_CHAIN_ID (43114) + + address regularUser = makeAddr("regularUser"); + address otherUser = makeAddr("otherUser"); + + // Verify users don't have minter role + assertFalse(token.hasRole(token.MINTER_ROLE(), regularUser)); + assertFalse(token.hasRole(token.MINTER_ROLE(), otherUser)); + + // Mint tokens to users on child chain (using account with minter role) + vm.prank(minter); + token.mint(regularUser, 100 * ONE_TOKEN); vm.prank(minter); - token.burn(user, 1 ether); + token.mint(otherUser, 100 * ONE_TOKEN); - assertEq(token.balanceOf(user), 9 ether); + // Regular user burns their own tokens despite not having minter role + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, regularUser, token.MINTER_ROLE() + ) + ); + vm.prank(regularUser); + token.burn(30 * ONE_TOKEN); + + // Verify burn did not work + assertEq(token.balanceOf(regularUser), 100 * ONE_TOKEN); + + // Set up for burnFrom - otherUser approves regularUser + vm.prank(otherUser); + token.approve(regularUser, 50 * ONE_TOKEN); + + // Regular user burns tokens from other user using burnFrom + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, regularUser, token.MINTER_ROLE() + ) + ); + vm.prank(regularUser); + token.burnFrom(otherUser, 40 * ONE_TOKEN); + + // Verify burnFrom did not work as minter role missing + assertEq(token.balanceOf(otherUser), 100 * ONE_TOKEN); + assertEq(token.allowance(otherUser, regularUser), 50 * ONE_TOKEN); + + // Explicitly assert that users without minter role cannot burn tokens on child chain + assertTrue( + token.balanceOf(regularUser) == 100 * ONE_TOKEN, "Regular user failed to burn tokens without minter role" + ); + assertTrue( + token.balanceOf(otherUser) == 100 * ONE_TOKEN, + "Regular user unsuccessfully used burnFrom without minter role" + ); } /*##################################################################################*/ @@ -288,34 +373,52 @@ contract EmGEMxTokenTest is Test { } function testBurnOnParentChainOnlyAllowedForRedeemAddress() public { - _setEsu(1_000 ether); + _setEsu(100000 * ONE_TOKEN / 100); // 1000 vm.chainId(token.PARENT_CHAIN_ID()); // burn restriction only in place on parent chain vm.prank(minter); - token.mint(user, uint256(10 ether)); + token.mint(user, uint256(10 * ONE_TOKEN)); + + // grant user minter rights so burn(amount) can be called + bytes32 role = token.MINTER_ROLE(); + vm.prank(admin); + token.grantRole(role, user); + assertTrue(token.hasRole(role, user)); vm.expectRevert(EmGEMxToken.EmGEMxToken__BurnOnParentChainNotAllowed.selector); + vm.prank(user); + token.burn(1 * ONE_TOKEN); + assertEq(token.balanceOf(user), 10 * ONE_TOKEN, "balance should not change"); + + // verify that also burnFrom is not possible + address otherUser = makeAddr("otherUser"); vm.prank(minter); - token.burn(user, 1 ether); + token.mint(otherUser, 10 * ONE_TOKEN); + // Set up for burnFrom - otherUser approves user + vm.prank(otherUser); + token.approve(user, 5 * ONE_TOKEN); - assertEq(token.balanceOf(user), 10 ether, "balance should not change"); + vm.expectRevert(EmGEMxToken.EmGEMxToken__BurnOnParentChainNotAllowed.selector); + vm.prank(user); + token.burnFrom(otherUser, 4 * ONE_TOKEN); + assertEq(token.balanceOf(otherUser), 10 * ONE_TOKEN, "balance should not change"); } function testRedeem_WhenRedeemAddressNotSet_Reverts() public { - _setEsu(1_000 ether); + _setEsu(100000 * ONE_TOKEN / 100); // 1000 assertEq(token.getRedeemAddress(), address(0)); vm.expectRevert(EmGEMxToken.EmGEMxToken__RedeemAddressNotSet.selector); vm.prank(redeemer); - token.redeem(1 ether); + token.redeem(1 * ONE_TOKEN); } function testOnlyRedeemerCanRedeem() public { - _setEsu(1_000 ether); + _setEsu(100000 * ONE_TOKEN / 100); // 1000 vm.prank(admin); token.setRedeemAddress(redeemAddress); vm.prank(minter); - token.mint(redeemAddress, 10 ether); + token.mint(redeemAddress, 10 * ONE_TOKEN); vm.expectRevert( abi.encodeWithSelector( @@ -323,12 +426,12 @@ contract EmGEMxTokenTest is Test { ) ); vm.prank(user); - token.redeem(1 ether); - assertEq(token.balanceOf(redeemAddress), 10 ether, "balance should not change"); + token.redeem(1 * ONE_TOKEN); + assertEq(token.balanceOf(redeemAddress), 10 * ONE_TOKEN, "balance should not change"); vm.prank(redeemer); - token.redeem(1 ether); - assertEq(token.balanceOf(redeemAddress), 9 ether); + token.redeem(1 * ONE_TOKEN); + assertEq(token.balanceOf(redeemAddress), 9 * ONE_TOKEN); } function testZeroAddressAsRedeemAddressReverts() public { @@ -397,15 +500,15 @@ contract EmGEMxTokenTest is Test { } function testTransferWhenPauseUnpause() public { - _setEsu(1_000 ether); + _setEsu(100000 * ONE_TOKEN / 100); // 1000 vm.prank(minter); - token.mint(user, uint256(10 ether)); + token.mint(user, uint256(10 * ONE_TOKEN)); address receiver = makeAddr("receiver"); vm.prank(user); - token.transfer(receiver, 1 ether); - assertEq(token.balanceOf(receiver), 1 ether); + token.transfer(receiver, 1 * ONE_TOKEN); + assertEq(token.balanceOf(receiver), 1 * ONE_TOKEN); vm.prank(pauser); token.pause(); @@ -413,16 +516,16 @@ contract EmGEMxTokenTest is Test { vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); vm.prank(user); - token.transfer(receiver, 1 ether); - assertEq(token.balanceOf(receiver), 1 ether); + token.transfer(receiver, 1 * ONE_TOKEN); + assertEq(token.balanceOf(receiver), 1 * ONE_TOKEN); vm.prank(pauser); token.unpause(); assertEq(token.paused(), false); vm.prank(user); - token.transfer(receiver, 1 ether); - assertEq(token.balanceOf(receiver), 2 ether); + token.transfer(receiver, 1 * ONE_TOKEN); + assertEq(token.balanceOf(receiver), 2 * ONE_TOKEN); } /*##################################################################################*/ @@ -431,83 +534,83 @@ contract EmGEMxTokenTest is Test { // TODO: split into separate tests once modifier with test setup is implemented function testOnlyfreezerCanFreezeAndUnfreeze() public { - _setEsu(1_000 ether); + _setEsu(100000 * ONE_TOKEN / 100); // 1000 vm.prank(minter); - token.mint(user, uint256(10 ether)); + token.mint(user, uint256(10 * ONE_TOKEN)); // freeze not allowed vm.expectRevert(ERC20FreezableUpgradeable.ERC20NotFreezer.selector); vm.prank(anon); - token.freeze(user, 1 ether); + token.freeze(user, 1 * ONE_TOKEN); assertEq(token.frozen(user), 0); - assertEq(token.availableBalance(user), 10 ether); + assertEq(token.availableBalance(user), 10 * ONE_TOKEN); // freeze allowed vm.expectEmit(); - emit TokensFrozen(user, 1 ether); + emit TokensFrozen(user, 1 * ONE_TOKEN); vm.prank(freezer); - token.freeze(user, 1 ether); - assertEq(token.frozen(user), 1 ether); - assertEq(token.availableBalance(user), 9 ether); + token.freeze(user, 1 * ONE_TOKEN); + assertEq(token.frozen(user), 1 * ONE_TOKEN); + assertEq(token.availableBalance(user), 9 * ONE_TOKEN); // unfreeze not allowed vm.expectRevert(ERC20FreezableUpgradeable.ERC20NotFreezer.selector); vm.prank(anon); - token.freeze(user, 0 ether); - assertEq(token.frozen(user), 1 ether); - assertEq(token.availableBalance(user), 9 ether); + token.freeze(user, 0 * ONE_TOKEN); + assertEq(token.frozen(user), 1 * ONE_TOKEN); + assertEq(token.availableBalance(user), 9 * ONE_TOKEN); // unfreeze allowed vm.expectEmit(); emit TokensFrozen(user, 0); vm.prank(freezer); - token.freeze(user, 0 ether); + token.freeze(user, 0 * ONE_TOKEN); assertEq(token.frozen(user), 0); - assertEq(token.availableBalance(user), 10 ether); + assertEq(token.availableBalance(user), 10 * ONE_TOKEN); } function testTransferWhenAmountFrozen() public { - _setEsu(1_000 ether); + _setEsu(100000 * ONE_TOKEN / 100); // 1000 vm.prank(minter); - token.mint(user, uint256(10 ether)); + token.mint(user, uint256(10 * ONE_TOKEN)); // freeze allowed vm.prank(freezer); - token.freeze(user, 8 ether); - assertEq(token.frozen(user), 8 ether); - assertEq(token.availableBalance(user), 2 ether); + token.freeze(user, 8 * ONE_TOKEN); + assertEq(token.frozen(user), 8 * ONE_TOKEN); + assertEq(token.availableBalance(user), 2 * ONE_TOKEN); // try to transfer with amount exceeding frozen balance vm.expectRevert( abi.encodeWithSelector(ERC20FreezableUpgradeable.ERC20InsufficientUnfrozenBalance.selector, user) ); vm.prank(user); - token.transfer(anon, 3 ether); + token.transfer(anon, 3 * ONE_TOKEN); // try to transfer with available balance left -> should work vm.prank(user); - token.transfer(anon, 2 ether); + token.transfer(anon, 2 * ONE_TOKEN); - assertEq(token.availableBalance(user), 0 ether); - assertEq(token.availableBalance(anon), 2 ether); + assertEq(token.availableBalance(user), 0 * ONE_TOKEN); + assertEq(token.availableBalance(anon), 2 * ONE_TOKEN); } function testCannotFreezeMoreThanAvailable() public { - _setEsu(1_000 ether); + _setEsu(100000 * ONE_TOKEN / 100); // 1000 vm.prank(minter); - token.mint(user, uint256(10 ether)); + token.mint(user, uint256(10 * ONE_TOKEN)); // try to freeze more than user has balance vm.expectRevert( abi.encodeWithSelector(ERC20FreezableUpgradeable.ERC20InsufficientUnfrozenBalance.selector, user) ); vm.prank(freezer); - token.freeze(user, 11 ether); + token.freeze(user, 11 * ONE_TOKEN); - assertEq(token.frozen(user), 0 ether); + assertEq(token.frozen(user), 0 * ONE_TOKEN); } /*##################################################################################*/ @@ -546,16 +649,16 @@ contract EmGEMxTokenTest is Test { } function testTransferWhenUserBlocked() public { - _setEsu(1_000 ether); + _setEsu(100000 * ONE_TOKEN / 100); // 1000 vm.prank(minter); - token.mint(user, uint256(10 ether)); + token.mint(user, uint256(10 * ONE_TOKEN)); // send some tokens so sending can be tested when user gets blocked address receiver = makeAddr("receiver"); vm.prank(user); - token.transfer(receiver, 1 ether); - assertEq(token.balanceOf(receiver), 1 ether); + token.transfer(receiver, 1 * ONE_TOKEN); + assertEq(token.balanceOf(receiver), 1 * ONE_TOKEN); vm.prank(limiter); token.blockUser(receiver); @@ -566,14 +669,14 @@ contract EmGEMxTokenTest is Test { // receiving vm.expectRevert(abi.encodeWithSelector(ERC20BlocklistUpgradeable.ERC20Blocked.selector, receiver)); vm.prank(user); - token.transfer(receiver, 1 ether); - assertEq(token.balanceOf(receiver), 1 ether, "Tokens should not be received"); + token.transfer(receiver, 1 * ONE_TOKEN); + assertEq(token.balanceOf(receiver), 1 * ONE_TOKEN, "Tokens should not be received"); // sending vm.expectRevert(abi.encodeWithSelector(ERC20BlocklistUpgradeable.ERC20Blocked.selector, receiver)); vm.prank(receiver); - token.transfer(user, 1 ether); - assertEq(token.balanceOf(receiver), 1 ether, "Tokens should not be moved"); + token.transfer(user, 1 * ONE_TOKEN); + assertEq(token.balanceOf(receiver), 1 * ONE_TOKEN, "Tokens should not be moved"); vm.prank(limiter); token.unblockUser(receiver); @@ -581,20 +684,20 @@ contract EmGEMxTokenTest is Test { // receiving should work again vm.prank(user); - token.transfer(receiver, 1 ether); - assertEq(token.balanceOf(receiver), 2 ether); + token.transfer(receiver, 1 * ONE_TOKEN); + assertEq(token.balanceOf(receiver), 2 * ONE_TOKEN); // sending should work again vm.prank(receiver); - token.transfer(user, 1 ether); - assertEq(token.balanceOf(receiver), 1 ether); + token.transfer(user, 1 * ONE_TOKEN); + assertEq(token.balanceOf(receiver), 1 * ONE_TOKEN); } function testErc20ApproveWhenUserBlocked() public { - _setEsu(1_000 ether); + _setEsu(100000 * ONE_TOKEN / 100); // 1000 vm.prank(minter); - token.mint(user, uint256(10 ether)); + token.mint(user, uint256(10 * ONE_TOKEN)); vm.prank(limiter); token.blockUser(user); @@ -603,7 +706,7 @@ contract EmGEMxTokenTest is Test { // user should not be able approve others in case he is blocked vm.expectRevert(abi.encodeWithSelector(ERC20BlocklistUpgradeable.ERC20Blocked.selector, user)); vm.prank(user); - token.approve(anon, 1 ether); + token.approve(anon, 1 * ONE_TOKEN); assertEq(token.allowance(user, anon), 0); vm.prank(limiter); @@ -611,8 +714,8 @@ contract EmGEMxTokenTest is Test { assertEq(token.blocked(user), false); vm.prank(user); - token.approve(anon, 1 ether); - assertEq(token.allowance(user, anon), 1 ether); + token.approve(anon, 1 * ONE_TOKEN); + assertEq(token.allowance(user, anon), 1 * ONE_TOKEN); } /*##################################################################################*/ diff --git a/test/unit/EmGEMxTokenV2.sol b/test/unit/EmGEMxTokenV2.sol new file mode 100644 index 0000000..2492629 --- /dev/null +++ b/test/unit/EmGEMxTokenV2.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +import {EmGEMxToken} from "../../src/EmGEMxToken.sol"; +import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; + +/** + * @dev Just for testing contract upgrade functionality. + */ +/// @custom:oz-upgrades-from src/EmGEMxToken.sol:EmGEMxToken +contract EmGEMxTokenV2 is EmGEMxToken { + uint256 private addedVariable; + + function initializeV2(address oracleAddress, string memory name, string memory symbol) public initializer { + super.initialize(oracleAddress, name, symbol); + } + + function name() public pure override returns (string memory) { + return "emGEMxV2"; + } + + function getAddedVariableValue() public view returns (uint256) { + return addedVariable; + } + + function setAddedVariableValue(uint256 _newValue) public { + addedVariable = _newValue; + } +} diff --git a/test/unit/Upgrades.t.sol b/test/unit/Upgrades.t.sol new file mode 100644 index 0000000..5d894d4 --- /dev/null +++ b/test/unit/Upgrades.t.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.22; + +import {Test, console} from "lib/forge-std/src/Test.sol"; +import {Upgrades} from "@openzeppelin-foundry-upgrades/Upgrades.sol"; + +import {EmGEMxToken} from "../../src/EmGEMxToken.sol"; +import {EmGEMxTokenV2} from "./EmGEMxTokenV2.sol"; +import {MockV3Aggregator} from "../mocks/MockV3Aggregator.sol"; + +contract UpgradesTest is Test { + function testUpgrade() public { + MockV3Aggregator newOracle = new MockV3Aggregator(1000); + + // Deploy a transparent proxy with ContractA as the implementation and initialize it with 10 + address proxy = Upgrades.deployTransparentProxy( + "EmGEMxToken.sol", + msg.sender, + abi.encodeCall(EmGEMxToken.initialize, (address(newOracle), "emGEMx", "emCH")) + ); + + // Get the instance of the contract + EmGEMxToken instance = EmGEMxToken(proxy); + + // Get the implementation address of the proxy + address implAddrV1 = Upgrades.getImplementationAddress(proxy); + console.log("Implementation V1:", implAddrV1); + + // Get the admin address of the proxy + address adminAddr = Upgrades.getAdminAddress(proxy); + console.log("Admin:", adminAddr); + + // Ensure the admin address is valid + assertFalse(adminAddr == address(0)); + + // Log the initial value + console.log("----------------------------------"); + console.log("Value before upgrade --> ", instance.name()); + console.log("----------------------------------"); + + // Verify initial value is as expected + assertEq(instance.name(), "emGEMx"); + + // Upgrade the proxy + Upgrades.upgradeProxy(proxy, "EmGEMxTokenV2.sol", "", msg.sender); + + // Get the new implementation address after upgrade + address implAddrV2 = Upgrades.getImplementationAddress(proxy); + console.log("Implementation V2:", implAddrV2); + + // Verify admin address remains unchanged + assertEq(Upgrades.getAdminAddress(proxy), adminAddr); + + // Verify implementation address has changed + assertFalse(implAddrV1 == implAddrV2); + + // Log and verify the updated value + console.log("----------------------------------"); + console.log("Value after upgrade --> ", instance.name()); + console.log("----------------------------------"); + assertEq(instance.name(), "emGEMxV2"); + + // set newly added variable and verify value. + EmGEMxTokenV2 instanceV2 = EmGEMxTokenV2(address(instance)); + instanceV2.setAddedVariableValue(123); + console.log("----------------------------------"); + console.log("Value of added variable after calling new functionality --> ", instanceV2.getAddedVariableValue()); + console.log("----------------------------------"); + assertEq(instanceV2.getAddedVariableValue(), 123); + } +}