diff --git a/README.md b/README.md index 2fa6ab4..8e287d0 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,115 @@ # EmGEMx Token -| Property | Value | -| ------------------------- | ------------------------------------------- | -| Name | EmGEMx Switzerland | -| Symbol | EmCH | -| Issuer | GemX AG, Zug, CH | -| Number of Tokens | Variable | -| Number of Decimals | 18 | -| Type | Crypto Asset (Asset Token) | -| Use Case | Tokenized Emeralds | -| Underlying Asset | Emeralds | -| Transferable | Yes | -| Transaction Fee | No | -| Burn Fee | No | -| Initial Price | Depends on ESU | -| Distribution | Proof-of-Reserve + Buy/DEX/CEX | -| Technical Base | ERC-20 on Avalanche | -| Public Tradeable | Yes | -| Governance Function | No | -| Allowlist | No | -| Blocklist | Yes | -| Mintable | Yes (redeem) | -| Burnable | Yes (redeem) | -| Pausable | Yes (all) | -| Roles | Owner, Minter, Redeem | -| Force Transfer (Clawback) | Yes/No (TBD) | -| Max Tokens per Address | No limit | -| Upgradeable | Yes | -| Cross-Chain | Yes (Ethereum, etc.) – CCIP | -| Other features | Emerald Standard Unit, Minting based on PoR | +## Token Properties + +| Property | Value | +| ------------------------- | ------------------------------------------------- | +| Name | EmGEMx Switzerland | +| Symbol | EmCH | +| Issuer | GemX AG, Zug, CH | +| Number of Tokens | Variable | +| Number of Decimals | 8 | +| Type | Crypto Asset (Asset Token) | +| Use Case | Tokenized Emeralds | +| Underlying Asset | Emeralds | +| Transferable | Yes | +| Transaction Fee | No | +| Burn Fee | No | +| Initial Price | Depends on ESU | +| Distribution | Proof-of-Reserve + Buy/DEX/CEX | +| Technical Base | ERC-20 on Avalanche | +| Public Tradeable | Yes | +| Governance Function | No | +| Allowlist | No | +| Blocklist | Yes | +| Mintable | Yes | +| Burnable | Yes (redeem) | +| Pausable | Yes (all) | +| Roles | Owner, Minter, ESU mod, Pause, Custodian, Limiter | +| Force Transfer (Clawback) | No | +| Max Tokens per Address | No limit | +| Upgradeable | Yes | +| Cross-Chain | Yes (Ethereum, etc.) – CCIP | +| Other features | Emerald Standard Unit, Minting based on PoR | + +Special Features +- Oracle writes Proof-of-Reserve to Blockchain (how many gemstones in ESU are in vault and can be minted) +- Mint function is limited to Proof-of-Reserve (amount of stones) and Emerald Standard Unit (ESU) +- Redeem must be transparent and results in burning of Token +- ESU changes about 0.1% per month (reduction, which results in more tokens to be allowed to be minted) + +Roles +- Admin: owner of the contract, allowed to upgrade the contract, change parameters and assign/revoke roles +- Minter: allowed to mint and burn tokens +- ESU per Token Modifier: allowed to update the ESU per token +- Pauser: allowed to pause/unpause tokens +- Freezer: allowed to freeze/unfreeze tokens +- Limiter: allowed to block/unblock users +- Redeemer: allowed to burn tokens from redeem address + +## Functional Requirements + +The token may be deployed to multiple blockchain networks, however the core logic of the token (e.g. max supply restriction via PoR oracle, redeem functionality) stays on the `parent chain` which in fact is the **Avalanche C-Chain**. + +### ESU + +The `ESU` (emerald standard unit) is a value defining how many gemstones are in vault and hence can be minted (based on the `esu_per_token` parameter). Its value is maintained & confirmed/attested by auditors and brought on-chain by an Chainlink PoR oracle data feed. + + - ESU value is written by chainlink + - Token has an esu_per_token value (set by emgemx) + - esu_per_token value is updated every month + - max_tokens = esu / esu_per_token + +Example calculation: + +1st of March: + +- ESU = 2521,13 +- esu_per_token = 0,01 +- max_tokens = 252.113 + +1st of April: + +- ESU = 2521,13 +- esu_per_token = 0.0099 +- max_tokens = 254.659,59 => hence ~2.546 new tokens are allowed to be minted compared to previous month + +1st of May: + +- ESU = 3871,13 (new stones delivered worth 1350 ESU) +- esu_per_token = 0,009801 +- max_tokens = 394.972,96 + +**Monthly amount adjustment** + +The following will be done once a month and influences the amount of tokens that are allowed to be minted: + +1. Redeem: All redeems are executed and those tokens are burned. + +2. ESU adjustment: ESU will be reduced bei 0.1%, allowing us to mint more tokens. + +### Redeeming tokens + +Users can redeem their tokens for the physical gemstones counterparts, which basically means that the gemstones are taken out of the safe/vault and delivered to the users in exchange for the tokens which eventually get burned. For this the user needs to transfer the tokens to a particular `redeemAddress` specified by emGEMx company and definable inside the token contract. On the parent chain tokens can only be burnt from that special address. + +### Burning tokens + +In general token burns should be strictly restricted, neither users should be able nor emGEMx on behalf of users should be able to burn tokens from the users (e.g. for a potential clawback scenario in case users lose access to the funds). + +However certain functionality requires token burning capabilities. Hence tokens should be burnable solely in the following cases: +- **on child chains:** only by the CCIP bridge to bring tokens from the child chain (burn) to the parent chain (release). Any tokens previously transfered from parent chain (lock) to child chain (mint) do not require burning for the transfer to settle. +- **on the parent chain:** only as part of the redemption process -> and only by the redeemer from the `redeemAddress`. + +### Cross Chain Support + +Cross-chain support will be enabled by leveraging Chainlink's CCIP via Token Manager which allows full cross-chain capabilities without changes in the token design/implementation. The only requirement from chainlink is to have a dedicated token owner (implemented via openzeppelin's `Ownable` contract); + +Cross-chain token transfers strategies used: + +- Source/Parent chain (Avalanche C-Chain): `lock & release` + +- All other destination/child chains (e.g. Ethereum Mainnet): `mint & burn` + ## Build, Test, Deploy diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index acd4ff7..ca7a4e3 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit acd4ff74de833399287ed6b31b4debf6b2b35527 +Subproject commit ca7a4e39de0860bbaadf95824207886e6de9fa64 diff --git a/script/DeployOracleMock.s.sol b/script/DeployOracleMock.s.sol new file mode 100644 index 0000000..fe4da75 --- /dev/null +++ b/script/DeployOracleMock.s.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.20; + +//import {Script, console} from "forge-std/Script.sol"; // not recognized by VS Code +import {Script, console} from "lib/forge-std/src/Script.sol"; +import {HelperConfig} from "./HelperConfig.s.sol"; +import {MockV3Aggregator} from "../test/mocks/MockV3Aggregator.sol"; + +/** + * @title Deployment script for the chainlink data feed oracle mock. + */ +contract DeployOracleMock is Script { + function run() public returns (MockV3Aggregator) { + HelperConfig helperConfig = new HelperConfig(); + uint256 mockValue = helperConfig.PROOF_OF_RESERVE_MOCK(); + + vm.startBroadcast(); + + MockV3Aggregator mock = deploy(mockValue); + + vm.stopBroadcast(); + + return mock; + } + + function deploy(uint256 mockValue) public returns (MockV3Aggregator) { + MockV3Aggregator mock = new MockV3Aggregator(int256(mockValue)); + console.log("Oracle mock deployed at: ", address(mock)); + return mock; + } +} diff --git a/script/DeployToken.s.sol b/script/DeployToken.s.sol index f917854..16f3ed4 100644 --- a/script/DeployToken.s.sol +++ b/script/DeployToken.s.sol @@ -4,9 +4,14 @@ pragma solidity 0.8.20; //import {Script, console} from "forge-std/Script.sol"; // not recognized by VS Code import {Script, console} from "lib/forge-std/src/Script.sol"; 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"; +/** + * @title Deployment script for the EmGEMx token contract + * @dev Upgradable ERC 20 contract that contains a chain switch due to the fact that certain functionality (e.g. token max supply) should be limited to the parent chain (Avalanche C-Chain). + */ contract DeployToken is Script { EmGEMxToken public token; @@ -15,10 +20,12 @@ contract DeployToken is Script { vm.startBroadcast(); - (address esuOracle) = helperConfig.activeNetworkConfig(); - if (esuOracle == address(0x0)) { + (address esuOracle, bool deployOracleMock) = helperConfig.activeNetworkConfig(); + if (esuOracle == address(0x0) && deployOracleMock) { uint256 mockValue = helperConfig.PROOF_OF_RESERVE_MOCK(); - MockV3Aggregator mock = createProofOrReserveMock(mockValue); + + DeployOracleMock deployMock = new DeployOracleMock(); + MockV3Aggregator mock = deployMock.deploy(mockValue); esuOracle = address(mock); } console.log("Oracle address:", esuOracle); @@ -35,10 +42,4 @@ contract DeployToken is Script { return token; } - - function createProofOrReserveMock(uint256 reserve) private returns (MockV3Aggregator) { - MockV3Aggregator mock = new MockV3Aggregator(int256(reserve)); - console.log("Oracle mock deployed at:", address(mock)); - return mock; - } } diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index 8a07ff5..94f2c5b 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -11,6 +11,7 @@ contract HelperConfig is Script { struct NetworkConfig { address esuOracle; + bool deployOracleMock; } constructor() { @@ -33,21 +34,23 @@ contract HelperConfig is Script { function getSepoliaEthConfig() public pure returns (NetworkConfig memory sepoliaNetworkConfig) { // no oracle on other chains than Avalanche - sepoliaNetworkConfig = NetworkConfig({esuOracle: 0x8D26D407ebed4D03dE7c18f5Db913155a4D587AE}); + sepoliaNetworkConfig = + NetworkConfig({esuOracle: 0x8D26D407ebed4D03dE7c18f5Db913155a4D587AE, deployOracleMock: false}); } function getFujiEthConfig() public pure returns (NetworkConfig memory fujiNetworkConfig) { - fujiNetworkConfig = NetworkConfig({esuOracle: 0x8F1C8888fBcd9Cc5D732df1e146d399a21899c22}); + fujiNetworkConfig = + NetworkConfig({esuOracle: 0x8F1C8888fBcd9Cc5D732df1e146d399a21899c22, deployOracleMock: false}); } function getAvalancheEthConfig() public pure returns (NetworkConfig memory avalancheNetworkConfig) { - revert("Feed address missing"); - avalancheNetworkConfig = NetworkConfig({esuOracle: address(0x0)}); + revert("Oracle feed address missing"); + avalancheNetworkConfig = NetworkConfig({esuOracle: address(0x0), deployOracleMock: false}); } function getMainnetEthConfig() public pure returns (NetworkConfig memory mainnetNetworkConfig) { // no oracle on other chains than Avalanche - mainnetNetworkConfig = NetworkConfig({esuOracle: address(0x0)}); + mainnetNetworkConfig = NetworkConfig({esuOracle: address(0x0), deployOracleMock: false}); } function getOrCreateAnvilEthConfig() public view returns (NetworkConfig memory anvilNetworkConfig) { @@ -59,6 +62,6 @@ contract HelperConfig is Script { //MockV3Aggregator mock = new MockV3Aggregator(PROOF_OF_RESERVE_MOCK); //console.log("Anvil oracle mock:", address(mock)); //anvilNetworkConfig = NetworkConfig({esuOracle: address(mock)}); - anvilNetworkConfig = NetworkConfig({esuOracle: address(0x0)}); + anvilNetworkConfig = NetworkConfig({esuOracle: address(0x0), deployOracleMock: true}); } } diff --git a/src/ERC20CustodianUpgradeable.sol b/src/ERC20FreezableUpgradeable.sol similarity index 84% rename from src/ERC20CustodianUpgradeable.sol rename to src/ERC20FreezableUpgradeable.sol index ed238e5..2fd44b4 100644 --- a/src/ERC20CustodianUpgradeable.sol +++ b/src/ERC20FreezableUpgradeable.sol @@ -16,8 +16,10 @@ import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ * The frozen balance is not available for transfers or approvals * to other entities to operate on its behalf if. The frozen balance * can be reduced by calling {freeze} again with a lower amount. + * + * Taken from https://docs.openzeppelin.com/community-contracts/0.0.1/api/token#ERC20Custodian */ -abstract contract ERC20CustodianUpgradeable is ERC20Upgradeable { +abstract contract ERC20FreezableUpgradeable is ERC20Upgradeable { /** * @dev The amount of tokens frozen by user address. */ @@ -48,15 +50,15 @@ abstract contract ERC20CustodianUpgradeable is ERC20Upgradeable { error ERC20InsufficientFrozenBalance(address user); /** - * @dev Error thrown when a non-custodian account attempts to perform a custodian-only operation. + * @dev Error thrown when a non-freezer account attempts to perform the freezer operation. */ - error ERC20NotCustodian(); + error ERC20NotFreezer(); /** - * @dev Modifier to restrict access to custodian accounts only. + * @dev Modifier to restrict access to freezer accounts only. */ - modifier onlyCustodian() { - if (!_isCustodian(_msgSender())) revert ERC20NotCustodian(); + modifier onlyFreezer() { + if (!_isFreezer(_msgSender())) revert ERC20NotFreezer(); _; } @@ -76,7 +78,7 @@ abstract contract ERC20CustodianUpgradeable is ERC20Upgradeable { * * - The user must have sufficient unfrozen balance. */ - function freeze(address user, uint256 amount) external virtual onlyCustodian { + function freeze(address user, uint256 amount) external virtual onlyFreezer { if (availableBalance(user) < amount) revert ERC20InsufficientUnfrozenBalance(user); _frozen[user] = amount; emit TokensFrozen(user, amount); @@ -92,11 +94,11 @@ abstract contract ERC20CustodianUpgradeable is ERC20Upgradeable { } /** - * @dev Checks if the user is a custodian. + * @dev Checks if the user is a freezer. * @param user The address of the user to check. * @return True if the user is authorized, false otherwise. */ - function _isCustodian(address user) internal view virtual returns (bool); + function _isFreezer(address user) internal view virtual returns (bool); function _update(address from, address to, uint256 value) internal virtual override { if (from != address(0) && availableBalance(from) < value) revert ERC20InsufficientUnfrozenBalance(from); diff --git a/src/EmGEMxToken.sol b/src/EmGEMxToken.sol index a7891b2..d17b043 100644 --- a/src/EmGEMxToken.sol +++ b/src/EmGEMxToken.sol @@ -11,11 +11,15 @@ import {ERC20PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol"; import {ERC20PermitUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; -import {ERC20CustodianUpgradeable} from "./ERC20CustodianUpgradeable.sol"; +import {ERC20FreezableUpgradeable} from "./ERC20FreezableUpgradeable.sol"; import {ERC20BlocklistUpgradeable} from "./ERC20BlocklistUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; +/** + * @title EmGEMx token contract + * @dev Upgradable ERC 20 contract that contains a chain switch due to the fact that certain functionality (e.g. token max supply) should be limited to the parent chain (Avalanche C-Chain). + */ contract EmGEMxToken is Initializable, ERC20Upgradeable, @@ -23,23 +27,41 @@ contract EmGEMxToken is ERC20PausableUpgradeable, AccessControlUpgradeable, OwnableUpgradeable, - ERC20CustodianUpgradeable, + ERC20FreezableUpgradeable, ERC20BlocklistUpgradeable, ERC20PermitUpgradeable { - error NotEnoughReserve(); + /////////////////// + // Errors + /////////////////// + error EmGEMxToken__NotEnoughReserve(); + error EmGEMxToken__InvalidAddress(address sender); + error EmGEMxToken__RedeemAddressNotSet(); + error EmGEMxToken__BurnOnParentChainNotAllowed(); + error EmGEMxToken__ParentChainOnly(); + /////////////////// + // Types + /////////////////// AggregatorV3Interface private oracle; + /////////////////// + // State Variables + /////////////////// bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); // token minting/burning bytes32 public constant ESU_PER_TOKEN_MODIFIER_ROLE = keccak256("ESU_PER_TOKEN_MODIFIER_ROLE"); // allowed to update esu-per-token parameter bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); // pause/unpause token - bytes32 public constant CUSTODIAN_ROLE = keccak256("CUSTODIAN_ROLE"); // freeze/unfreeze tokens + bytes32 public constant FREEZER_ROLE = keccak256("FREEZER_ROLE"); // freeze/unfreeze tokens bytes32 public constant LIMITER_ROLE = keccak256("LIMITER_ROLE"); // block/unblock user + bytes32 public constant REDEEMER_ROLE = keccak256("REDEEMER_ROLE"); // burn tokens from redeem address /// @dev Controls whether minting restriction is active (on parent chain) or not (on any other chain). uint256 public constant PARENT_CHAIN_ID = 43114; // Avalanche C-Chain + /// @notice Address where users send the funds to in case they want to redeem their tokens for gems. + /// @dev Tokens are burnt from this address as part of the redeem process which in large part takes place off-chain. + address private redeemAddress; + /* ESU Calculation: - ESU value is written by chainlink @@ -52,6 +74,29 @@ contract EmGEMxToken is uint256 private esuPerTokenValue = 1; uint256 private esuPerTokenPrecision = 100; + /////////////////// + // Events + /////////////////// + + /// @notice Emitted in case the EsuPerToken value was updated by the ESU_PER_TOKEN_MODIFIER_ROLE + event EsuPerTokenChanged(uint256 value, uint256 precision); + /// @notice Emitted in case the oracle address was updated by the token admin. + event OracleAddressChanged(address oldAddres, address newAddress); + /// @notice Emitted in case the redeem address was updated by the token admin. + event RedeemAddressChanged(address oldAddress, address newAddress); + + /// @dev Allows to restrict certain functions with core logic to be executed only on the parent chain + modifier onlyParentChain() { + if (block.chainid != PARENT_CHAIN_ID) { + revert EmGEMxToken__ParentChainOnly(); + } + _; + } + + /////////////////// + // Functions + /////////////////// + function initialize(address oracleAddres, string memory name, string memory symbol) public initializer { __ERC20_init(name, symbol); __ERC20Burnable_init(); @@ -62,57 +107,108 @@ contract EmGEMxToken is _setRoleAdmin(MINTER_ROLE, DEFAULT_ADMIN_ROLE); _setRoleAdmin(ESU_PER_TOKEN_MODIFIER_ROLE, DEFAULT_ADMIN_ROLE); _setRoleAdmin(PAUSER_ROLE, DEFAULT_ADMIN_ROLE); - _setRoleAdmin(CUSTODIAN_ROLE, DEFAULT_ADMIN_ROLE); + _setRoleAdmin(FREEZER_ROLE, DEFAULT_ADMIN_ROLE); _setRoleAdmin(LIMITER_ROLE, DEFAULT_ADMIN_ROLE); + _setRoleAdmin(REDEEMER_ROLE, DEFAULT_ADMIN_ROLE); oracle = AggregatorV3Interface(oracleAddres); } + /////////////////// + // External Functions + /////////////////// + + /// @notice Wrapps ERC20BurnableUpgradeable._mint function mint(address account, uint256 value) external onlyRole(MINTER_ROLE) { _mint(account, value); } + /// @notice Wrapps ERC20BurnableUpgradeable._burn function burn(address account, uint256 value) external onlyRole(MINTER_ROLE) { _burn(account, value); } + /// @notice Burns token from the redeemAddress. + /// @dev Only allowed to be called by a dedicated redeemer account. Supposed to be called only on parent chain. + function redeem(uint256 value) external onlyRole(REDEEMER_ROLE) onlyParentChain { + if (redeemAddress == address(0)) { + revert EmGEMxToken__RedeemAddressNotSet(); + } + + _burn(redeemAddress, value); + } + + /// @notice Wrapps ERC20PausableUpgradeable._pause function pause() external onlyRole(PAUSER_ROLE) { _pause(); } + /// @notice Wrapps ERC20PausableUpgradeable._unpause function unpause() external onlyRole(PAUSER_ROLE) { _unpause(); } + /// @notice Wrapps ERC20BlocklistUpgradeable._blockUser function blockUser(address user) external onlyRole(LIMITER_ROLE) { _blockUser(user); } + /// @notice Wrapps ERC20BlocklistUpgradeable._unblockUser function unblockUser(address user) external onlyRole(LIMITER_ROLE) { _unblockUser(user); } + /// @notice Returns the address of the chainlink PoR oracle for the ESU. + /// @return The address of the chainlink oracle contract. function getOracleAddress() external view returns (address) { return address(oracle); } - function setOracleAddress(address newAddress) external onlyRole(DEFAULT_ADMIN_ROLE) { + /// @notice Allows admin to update the chainlink oracle contract address. + function setOracleAddress(address newAddress) external onlyRole(DEFAULT_ADMIN_ROLE) onlyParentChain { + validateNotZeroAddress(newAddress); + + emit OracleAddressChanged(address(oracle), newAddress); oracle = AggregatorV3Interface(newAddress); } + /// @notice Returns the redeem address from where the tokens are burnt by the redeemer. + function getRedeemAddress() external view returns (address) { + return redeemAddress; + } + + /// @notice Allows admin to update the redeem address. + function setRedeemAddress(address newAddress) external onlyRole(DEFAULT_ADMIN_ROLE) onlyParentChain { + validateNotZeroAddress(newAddress); + + emit RedeemAddressChanged(redeemAddress, newAddress); + redeemAddress = newAddress; + } + + /// @notice Returns the current EsuPerToken value and precision. function getEsuPerToken() external view returns (uint256, uint256) { return (esuPerTokenValue, esuPerTokenPrecision); } - function setEsuPerToken(uint256 value, uint256 precision) external onlyRole(ESU_PER_TOKEN_MODIFIER_ROLE) { + /// @notice Allows the EsuPerToken modifier to update the value and precision. + function setEsuPerToken(uint256 value, uint256 precision) + external + onlyRole(ESU_PER_TOKEN_MODIFIER_ROLE) + onlyParentChain + { esuPerTokenValue = value; esuPerTokenPrecision = precision; + emit EsuPerTokenChanged(value, precision); } + /// @inheritdoc ERC20Upgradeable function decimals() public pure override returns (uint8) { return 8; } + /// @notice Returns the current possible token max supply based on the ESU from oracle and the esuPerToken value. + /// @dev The max supply is only restricted on the parent chain (Avalanche C-Chain). On all other chains there is no restriction -> the minting/burning is controlled cia CCIP messages. + /// @return The calculated max supply of the token. function getMaxSupply() public view returns (uint256) { // no max supply restriction on child chains if (block.chainid != PARENT_CHAIN_ID) { @@ -122,25 +218,34 @@ contract EmGEMxToken is return _getEsuFromOracle() * esuPerTokenPrecision / esuPerTokenValue; } - function _isCustodian(address user) internal view override returns (bool) { - return hasRole(CUSTODIAN_ROLE, user); - } + /////////////////// + // Internal Functions + /////////////////// + /// @notice Contains the custom logic for max token supply restriction and burn restricted to redeemAddress only. + /// @dev Both max token supply + burn restriction are only validated/active on parent chain. + /// @inheritdoc ERC20Upgradeable function _update(address from, address to, uint256 amount) internal - override(ERC20Upgradeable, ERC20PausableUpgradeable, ERC20CustodianUpgradeable, ERC20BlocklistUpgradeable) + override(ERC20Upgradeable, ERC20PausableUpgradeable, ERC20FreezableUpgradeable, ERC20BlocklistUpgradeable) { - // make sure it cannot be minted more than proof of reserve! Only to be checked on parent source chain - // Code is located here (and not in mint()) so that the logic it is always checked - even if _mint is called from any place)0 if (block.chainid == PARENT_CHAIN_ID) { + // make sure it cannot be minted more than proof of reserve! Only to be checked on parent source chain + // Code is located here (and not in mint()) so that the logic it is always checked - even if _mint is called from any place) if (from == address(0) && totalSupply() + amount > getMaxSupply()) { - revert NotEnoughReserve(); + revert EmGEMxToken__NotEnoughReserve(); + } + + // burn on parent chain should be only possible from redeemAddress + if (to == address(0) && from != redeemAddress) { + revert EmGEMxToken__BurnOnParentChainNotAllowed(); } } super._update(from, to, amount); } + /// @inheritdoc ERC20Upgradeable function _approve(address owner, address spender, uint256 value, bool emitEvent) internal override(ERC20Upgradeable, ERC20BlocklistUpgradeable) @@ -148,6 +253,13 @@ contract EmGEMxToken is super._approve(owner, spender, value, emitEvent); } + /////////////////// + // Private Functions + /////////////////// + + /// @notice Queries and returns the chainlink PoR oracle for the ESU value. + /// @dev This function can only be called on parent chain as on other chains no oracle contracts will be available. + /// @return The quried ESU value from the chainlink PoR oracle. function _getEsuFromOracle() private view returns (uint256) { ( /* uint80 roundID */ @@ -162,4 +274,19 @@ contract EmGEMxToken is return uint256(answer); } + + ////////////////////////////// + // Private & Internal View & Pure Functions + ////////////////////////////// + + /// @inheritdoc ERC20FreezableUpgradeable + function _isFreezer(address user) internal view override returns (bool) { + return hasRole(FREEZER_ROLE, user); + } + + function validateNotZeroAddress(address addressToVerify) private pure { + if (addressToVerify == address(0)) { + revert EmGEMxToken__InvalidAddress(addressToVerify); + } + } } diff --git a/test/unit/EmGEMxTokenTest.t.sol b/test/unit/EmGEMxTokenTest.t.sol index fe0cb70..02d47c2 100644 --- a/test/unit/EmGEMxTokenTest.t.sol +++ b/test/unit/EmGEMxTokenTest.t.sol @@ -5,7 +5,7 @@ import {Test, console} from "lib/forge-std/src/Test.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol"; -import {ERC20CustodianUpgradeable} from "../../src/ERC20CustodianUpgradeable.sol"; +import {ERC20FreezableUpgradeable} from "../../src/ERC20FreezableUpgradeable.sol"; import {ERC20BlocklistUpgradeable} from "../../src/ERC20BlocklistUpgradeable.sol"; import {EmGEMxToken} from "../../src/EmGEMxToken.sol"; import {DeployToken} from "../../script/DeployToken.s.sol"; @@ -18,13 +18,19 @@ contract EmGEMxTokenTest is Test { address admin = address(0x1); address minter = address(0x2); address pauser = address(0x3); - address custodian = address(0x4); + address freezer = address(0x4); address limiter = address(0x5); address esuPerTokenModifier = address(0x6); + address redeemer = address(0x7); + + address redeemAddress = makeAddr("redeemAddress"); address user = makeAddr("user"); address anon = makeAddr("anon"); event TokensFrozen(address indexed user, uint256 amount); + event OracleAddressChanged(string oldAddres, string newAddress); + event EsuPerTokenChanged(uint256 value, uint256 precision); + event RedeemAddressChanged(address oldAddress, address newAddress); function setUp() public { admin = makeAddr("Admin"); @@ -42,14 +48,13 @@ contract EmGEMxTokenTest is Test { token.grantRole(token.DEFAULT_ADMIN_ROLE(), admin); token.grantRole(token.MINTER_ROLE(), minter); token.grantRole(token.PAUSER_ROLE(), pauser); - token.grantRole(token.CUSTODIAN_ROLE(), custodian); + token.grantRole(token.FREEZER_ROLE(), freezer); token.grantRole(token.LIMITER_ROLE(), limiter); token.grantRole(token.ESU_PER_TOKEN_MODIFIER_ROLE(), esuPerTokenModifier); + token.grantRole(token.REDEEMER_ROLE(), redeemer); vm.stopPrank(); } - // TODO: set up invariant testing. Inveriant of the system: it should never be possible to mint more than allowed by ESU and PoR! - function testTokenProperties() public view { assertEq(token.name(), "EmGEMx Switzerland"); assertEq(token.symbol(), "EmCH"); @@ -81,6 +86,14 @@ contract EmGEMxTokenTest is Test { assertEq(token.getOracleAddress(), address(newOracle)); } + function testSetOracleAddress_CannotBeCalledOnNonParentChain() public { + vm.chainId(1); // non parent chain + + vm.prank(admin); + vm.expectRevert(EmGEMxToken.EmGEMxToken__ParentChainOnly.selector); + token.setOracleAddress(makeAddr("newOracleAddress")); + } + /*##################################################################################*/ /*###################################### ESU #######################################*/ /*##################################################################################*/ @@ -103,7 +116,7 @@ contract EmGEMxTokenTest is Test { assertEq(token.totalSupply(), 10_000 ether); // ACT - vm.expectRevert(EmGEMxToken.NotEnoughReserve.selector); + vm.expectRevert(EmGEMxToken.EmGEMxToken__NotEnoughReserve.selector); token.mint(user, 1); vm.stopPrank(); @@ -144,13 +157,14 @@ contract EmGEMxTokenTest is Test { vm.prank(user); token.setEsuPerToken(9, 10000); - // values should not have changed (esu, esuPrecision) = token.getEsuPerToken(); assertEq(esu, 1, "Esu value should not have changed"); assertEq(esuPrecision, 100, "Esu precision should not have changed"); // ACT vm.prank(esuPerTokenModifier); + vm.expectEmit(); + emit EsuPerTokenChanged(9, 10000); token.setEsuPerToken(9, 10000); (esu, esuPrecision) = token.getEsuPerToken(); @@ -158,6 +172,22 @@ contract EmGEMxTokenTest is Test { assertEq(esuPrecision, 10000); } + function testEsuPerToken_CannotBeUpdatedOnNonParentChain() public { + (uint256 esu, uint256 esuPrecision) = token.getEsuPerToken(); + assertEq(esu, 1); + assertEq(esuPrecision, 100); + + vm.chainId(1); // non parent chain + + vm.prank(esuPerTokenModifier); + vm.expectRevert(EmGEMxToken.EmGEMxToken__ParentChainOnly.selector); + token.setEsuPerToken(9, 10000); + + (esu, esuPrecision) = token.getEsuPerToken(); + assertEq(esu, 1, "Esu value should not have changed"); + assertEq(esuPrecision, 100, "Esu precision should not have changed"); + } + function testVerifyEsuCalculation() public { vm.chainId(token.PARENT_CHAIN_ID()); @@ -212,8 +242,9 @@ contract EmGEMxTokenTest is Test { assertEq(token.balanceOf(user), 1 ether); } - function testOnlyMinterCanBurn() public { + function testOnlyMinterCanBurnOnChildChain() public { _setEsu(1_000 ether); + vm.chainId(1); // burn restriction only in place on parent chain vm.prank(minter); token.mint(user, uint256(10 ether)); @@ -232,6 +263,104 @@ contract EmGEMxTokenTest is Test { assertEq(token.balanceOf(user), 9 ether); } + /*##################################################################################*/ + /*#################################### Redeem ######################################*/ + /*##################################################################################*/ + + function testOnlyAdminCanSetRedeemAddress() public { + assertEq(token.getRedeemAddress(), address(0)); + address newRedeemAddress = makeAddr("newRedeemAddress"); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.DEFAULT_ADMIN_ROLE() + ) + ); + vm.prank(user); + token.setRedeemAddress(newRedeemAddress); + assertEq(token.getRedeemAddress(), address(0)); + + vm.prank(admin); + vm.expectEmit(); + emit RedeemAddressChanged(address(0), newRedeemAddress); + token.setRedeemAddress(newRedeemAddress); + assertEq(token.getRedeemAddress(), newRedeemAddress); + } + + function testBurnOnParentChainOnlyAllowedForRedeemAddress() public { + _setEsu(1_000 ether); + vm.chainId(token.PARENT_CHAIN_ID()); // burn restriction only in place on parent chain + + vm.prank(minter); + token.mint(user, uint256(10 ether)); + + vm.expectRevert(EmGEMxToken.EmGEMxToken__BurnOnParentChainNotAllowed.selector); + vm.prank(minter); + token.burn(user, 1 ether); + + assertEq(token.balanceOf(user), 10 ether, "balance should not change"); + } + + function testRedeem_WhenRedeemAddressNotSet_Reverts() public { + _setEsu(1_000 ether); + assertEq(token.getRedeemAddress(), address(0)); + + vm.expectRevert(EmGEMxToken.EmGEMxToken__RedeemAddressNotSet.selector); + vm.prank(redeemer); + token.redeem(1 ether); + } + + function testOnlyRedeemerCanRedeem() public { + _setEsu(1_000 ether); + vm.prank(admin); + token.setRedeemAddress(redeemAddress); + vm.prank(minter); + token.mint(redeemAddress, 10 ether); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.REDEEMER_ROLE() + ) + ); + vm.prank(user); + token.redeem(1 ether); + assertEq(token.balanceOf(redeemAddress), 10 ether, "balance should not change"); + + vm.prank(redeemer); + token.redeem(1 ether); + assertEq(token.balanceOf(redeemAddress), 9 ether); + } + + function testZeroAddressAsRedeemAddressReverts() public { + assertEq(token.getRedeemAddress(), address(0)); + address newRedeemAddress = makeAddr("newRedeemAddress"); + + vm.prank(admin); + token.setRedeemAddress(newRedeemAddress); + assertEq(token.getRedeemAddress(), newRedeemAddress); + + vm.expectRevert(abi.encodeWithSelector(EmGEMxToken.EmGEMxToken__InvalidAddress.selector, address(0))); + vm.prank(admin); + token.setRedeemAddress(address(0)); + assertEq(token.getRedeemAddress(), newRedeemAddress, "Addres should not change"); + } + + function testRedeem_CannotBeCalledOnNonParentChain() public { + vm.chainId(1); // non parent chain + + vm.prank(redeemer); + vm.expectRevert(EmGEMxToken.EmGEMxToken__ParentChainOnly.selector); + token.redeem(1); + } + + function testSetRedeemAddress_CannotBeCalledOnNonParentChain() public { + vm.chainId(1); // non parent chain + + vm.prank(admin); + vm.expectRevert(EmGEMxToken.EmGEMxToken__ParentChainOnly.selector); + token.setRedeemAddress(makeAddr("newRedeemAddress")); + } + /*##################################################################################*/ /*################################# PAUSE/UNPAUSE ##################################*/ /*##################################################################################*/ @@ -301,14 +430,14 @@ contract EmGEMxTokenTest is Test { /*##################################################################################*/ // TODO: split into separate tests once modifier with test setup is implemented - function testOnlyCustodianCanFreezeAndUnfreeze() public { + function testOnlyfreezerCanFreezeAndUnfreeze() public { _setEsu(1_000 ether); vm.prank(minter); token.mint(user, uint256(10 ether)); // freeze not allowed - vm.expectRevert(ERC20CustodianUpgradeable.ERC20NotCustodian.selector); + vm.expectRevert(ERC20FreezableUpgradeable.ERC20NotFreezer.selector); vm.prank(anon); token.freeze(user, 1 ether); assertEq(token.frozen(user), 0); @@ -317,13 +446,13 @@ contract EmGEMxTokenTest is Test { // freeze allowed vm.expectEmit(); emit TokensFrozen(user, 1 ether); - vm.prank(custodian); + vm.prank(freezer); token.freeze(user, 1 ether); assertEq(token.frozen(user), 1 ether); assertEq(token.availableBalance(user), 9 ether); // unfreeze not allowed - vm.expectRevert(ERC20CustodianUpgradeable.ERC20NotCustodian.selector); + vm.expectRevert(ERC20FreezableUpgradeable.ERC20NotFreezer.selector); vm.prank(anon); token.freeze(user, 0 ether); assertEq(token.frozen(user), 1 ether); @@ -332,7 +461,7 @@ contract EmGEMxTokenTest is Test { // unfreeze allowed vm.expectEmit(); emit TokensFrozen(user, 0); - vm.prank(custodian); + vm.prank(freezer); token.freeze(user, 0 ether); assertEq(token.frozen(user), 0); assertEq(token.availableBalance(user), 10 ether); @@ -345,14 +474,14 @@ contract EmGEMxTokenTest is Test { token.mint(user, uint256(10 ether)); // freeze allowed - vm.prank(custodian); + vm.prank(freezer); token.freeze(user, 8 ether); assertEq(token.frozen(user), 8 ether); assertEq(token.availableBalance(user), 2 ether); // try to transfer with amount exceeding frozen balance vm.expectRevert( - abi.encodeWithSelector(ERC20CustodianUpgradeable.ERC20InsufficientUnfrozenBalance.selector, user) + abi.encodeWithSelector(ERC20FreezableUpgradeable.ERC20InsufficientUnfrozenBalance.selector, user) ); vm.prank(user); token.transfer(anon, 3 ether); @@ -373,9 +502,9 @@ contract EmGEMxTokenTest is Test { // try to freeze more than user has balance vm.expectRevert( - abi.encodeWithSelector(ERC20CustodianUpgradeable.ERC20InsufficientUnfrozenBalance.selector, user) + abi.encodeWithSelector(ERC20FreezableUpgradeable.ERC20InsufficientUnfrozenBalance.selector, user) ); - vm.prank(custodian); + vm.prank(freezer); token.freeze(user, 11 ether); assertEq(token.frozen(user), 0 ether); @@ -503,7 +632,7 @@ contract EmGEMxTokenTest is Test { token.grantRole(role, newMinter); assertTrue(token.hasRole(role, newMinter)); - role = token.CUSTODIAN_ROLE(); + role = token.FREEZER_ROLE(); vm.prank(admin); token.grantRole(role, newMinter); assertTrue(token.hasRole(role, newMinter)); @@ -517,6 +646,11 @@ contract EmGEMxTokenTest is Test { vm.prank(admin); token.grantRole(role, newMinter); assertTrue(token.hasRole(role, newMinter)); + + role = token.REDEEMER_ROLE(); + vm.prank(admin); + token.grantRole(role, newMinter); + assertTrue(token.hasRole(role, newMinter)); } function testAdminCanRevokeRoles() public { @@ -532,11 +666,11 @@ contract EmGEMxTokenTest is Test { token.revokeRole(pauserRole, pauser); assertFalse(token.hasRole(pauserRole, pauser)); - bytes32 custodianRole = token.CUSTODIAN_ROLE(); - assertTrue(token.hasRole(custodianRole, custodian)); + bytes32 freezerRole = token.FREEZER_ROLE(); + assertTrue(token.hasRole(freezerRole, freezer)); vm.prank(admin); - token.revokeRole(custodianRole, custodian); - assertFalse(token.hasRole(custodianRole, custodian)); + token.revokeRole(freezerRole, freezer); + assertFalse(token.hasRole(freezerRole, freezer)); bytes32 limiterRole = token.LIMITER_ROLE(); assertTrue(token.hasRole(limiterRole, limiter)); @@ -549,5 +683,11 @@ contract EmGEMxTokenTest is Test { vm.prank(admin); token.revokeRole(esuPerTokenModifierRole, esuPerTokenModifier); assertFalse(token.hasRole(esuPerTokenModifierRole, esuPerTokenModifier)); + + bytes32 redeemerRole = token.REDEEMER_ROLE(); + assertTrue(token.hasRole(redeemerRole, redeemer)); + vm.prank(admin); + token.revokeRole(redeemerRole, redeemer); + assertFalse(token.hasRole(redeemerRole, redeemer)); } }