From a592258e2e1d99ddd3b371430249ded175868e78 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Wed, 5 Feb 2025 16:20:07 +0100 Subject: [PATCH 01/25] chore: forge init --- .env.sample | 7 ++ .github/workflows/test.yml | 43 +++++++++++ .gitignore | 15 ++++ .gitmodules | 9 +++ README.md | 86 ++++++++++++++++----- foundry.toml | 12 +++ remappings.txt | 3 + script/Counter.s.sol | 19 +++++ script/GEMxTokenDeployer.s.sol | 21 ++++++ src/Counter.sol | 14 ++++ src/GEMxToken.sol | 50 +++++++++++++ test/AdditionalGEMxTokenTests.t.sol | 13 ++++ test/Counter.t.sol | 24 ++++++ test/GEMxToken.t.sol | 112 ++++++++++++++++++++++++++++ test/mocks/MockV3Aggregator.sol | 72 ++++++++++++++++++ 15 files changed, 480 insertions(+), 20 deletions(-) create mode 100644 .env.sample create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 foundry.toml create mode 100644 remappings.txt create mode 100644 script/Counter.s.sol create mode 100644 script/GEMxTokenDeployer.s.sol create mode 100644 src/Counter.sol create mode 100644 src/GEMxToken.sol create mode 100644 test/AdditionalGEMxTokenTests.t.sol create mode 100644 test/Counter.t.sol create mode 100644 test/GEMxToken.t.sol create mode 100644 test/mocks/MockV3Aggregator.sol diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..1cad7b2 --- /dev/null +++ b/.env.sample @@ -0,0 +1,7 @@ +PRIVATE_KEY=XXXXXXXXX +DEFAULT_ANVIL_KEY=XXXXXXXXX +RPC_URL=http://0.0.0.0:8545 +ETHERSCAN_API_KEY=XXXX +SEPOLIA_RPC_URL= +AVALANCHE_FUJI_RPC_URL=https://avalanche-fuji-c-chain-rpc.publicnode.com +AVALANCHE_MAINNET_RPC_URL=https://api.avax.network/ext/bc/C/rpc \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..34a4a52 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + strategy: + fail-fast: true + + name: Foundry project + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Show Forge version + run: | + forge --version + + - name: Run Forge fmt + run: | + forge fmt --check + id: fmt + + - name: Run Forge build + run: | + forge build --sizes + id: build + + - name: Run Forge tests + run: | + forge test -vvv + id: test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f0df3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env +/coverage diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..5ec0382 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-foundry-upgrades"] + path = lib/openzeppelin-foundry-upgrades +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts \ No newline at end of file diff --git a/README.md b/README.md index 0ca446a..9265b45 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,66 @@ -# Introduction -TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project. - -# Getting Started -TODO: Guide users through getting your code up and running on their own system. In this section you can talk about: -1. Installation process -2. Software dependencies -3. Latest releases -4. API references - -# Build and Test -TODO: Describe and show how to build your code and run the tests. - -# Contribute -TODO: Explain how other users and developers can contribute to make your code better. - -If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files: -- [ASP.NET Core](https://github.com/aspnet/Home) -- [Visual Studio Code](https://github.com/Microsoft/vscode) -- [Chakra Core](https://github.com/Microsoft/ChakraCore) \ No newline at end of file +## Foundry + +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** + +Foundry consists of: + +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. +- **Chisel**: Fast, utilitarian, and verbose solidity REPL. + +## Documentation + +https://book.getfoundry.sh/ + +## Usage + +### Build + +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Gas Snapshots + +```shell +$ forge snapshot +``` + +### Anvil + +```shell +$ anvil +``` + +### Deploy + +```shell +$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ anvil --help +$ cast --help +``` diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..bc09100 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,12 @@ +[profile.default] +src = "src" +test = "test" +out = "out" +libs = ["lib"] +remappings = [] +ffi = true +ast = true +build_info = true +extra_output = ["storageLayout"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..2047311 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,3 @@ +forge-std/=lib/forge-std/src/ +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ +@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ \ No newline at end of file diff --git a/script/Counter.s.sol b/script/Counter.s.sol new file mode 100644 index 0000000..cdc1fe9 --- /dev/null +++ b/script/Counter.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script, console} from "forge-std/Script.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterScript is Script { + Counter public counter; + + function setUp() public {} + + function run() public { + vm.startBroadcast(); + + counter = new Counter(); + + vm.stopBroadcast(); + } +} diff --git a/script/GEMxTokenDeployer.s.sol b/script/GEMxTokenDeployer.s.sol new file mode 100644 index 0000000..19d8418 --- /dev/null +++ b/script/GEMxTokenDeployer.s.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +//import {Script, console} from "forge-std/Script.sol"; // not recognized by VS Code +import {Script, console} from "lib/forge-std/src/Script.sol"; +import {GEMxToken} from "../src/GEMxToken.sol"; + +contract GEMxTokenDeployer is Script { + GEMxToken public token; + + function run(address oracleAddress) public returns (GEMxToken) { + vm.startBroadcast(); + + token = new GEMxToken(); + token.initialize(oracleAddress); + + vm.stopBroadcast(); + + return token; + } +} diff --git a/src/Counter.sol b/src/Counter.sol new file mode 100644 index 0000000..aded799 --- /dev/null +++ b/src/Counter.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol new file mode 100644 index 0000000..ed18524 --- /dev/null +++ b/src/GEMxToken.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { + ERC20Upgradeable, + ERC20BurnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {ISolvencyOracle, SolvencyOracleMock} from "./SolvencyOracleMock.sol"; + +contract GEMxToken is ERC20BurnableUpgradeable, AccessControlUpgradeable { + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + ISolvencyOracle solvencyOracle; + + event ProofOfSolvencyChanged(uint256 amount); + + error NotEnoughReserve(); + + function initialize(address proofOfSolvencyOracleAddress) public initializer { + __ERC20_init("GEMxToken", "GEMX"); + __AccessControl_init(); + _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); + _setRoleAdmin(MINTER_ROLE, DEFAULT_ADMIN_ROLE); + + solvencyOracle = SolvencyOracleMock(proofOfSolvencyOracleAddress); + } + + function getProofOfSolvency() external view returns (uint256) { + return solvencyOracle.getProofOfSolvency(); + } + + function mint(address account, uint256 value) external onlyRole(MINTER_ROLE) { + _mint(account, value); + } + + function burn(address account, uint256 value) external onlyRole(MINTER_ROLE) { + _burn(account, value); + } + + function _update(address from, address to, uint256 amount) internal override(ERC20Upgradeable) { + // make sure it cannot be minted more than proof of reserve! + if (from == address(0) && totalSupply() + amount > this.getProofOfSolvency()) { + revert NotEnoughReserve(); + } + + super._update(from, to, amount); + } +} diff --git a/test/AdditionalGEMxTokenTests.t.sol b/test/AdditionalGEMxTokenTests.t.sol new file mode 100644 index 0000000..dc7eb82 --- /dev/null +++ b/test/AdditionalGEMxTokenTests.t.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +//import {Test} from "forge-std/Test.sol"; +import {Test, console} from "lib/forge-std/src/Test.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {SolvencyOracleMock} from "../src/SolvencyOracleMock.sol"; +import {GEMxToken} from "../src/GEMxToken.sol"; +import {GEMxTokenDeployer} from "../script/GEMxTokenDeployer.s.sol"; + +contract AdditionalGEMxTokenTests is Test { + +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol new file mode 100644 index 0000000..54b724f --- /dev/null +++ b/test/Counter.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } +} diff --git a/test/GEMxToken.t.sol b/test/GEMxToken.t.sol new file mode 100644 index 0000000..cbb92ec --- /dev/null +++ b/test/GEMxToken.t.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +//import {Test} from "forge-std/Test.sol"; +import {Test, console} from "lib/forge-std/src/Test.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {GEMxToken} from "../src/GEMxToken.sol"; +import {GEMxTokenDeployer} from "../script/GEMxTokenDeployer.s.sol"; +import {SolvencyOracleMock} from "../src/SolvencyOracleMock.sol"; +import {SolvencyOracleMockDeployer} from "../script/SolvencyOracleMockDeployer.s.sol"; + +contract GEMxTokenTest is Test { + GEMxToken private token; + SolvencyOracleMock private oracle; + address admin = address(0x1); + address minter = address(0x2); + address user = address(0x3); + + function setUp() public { + admin = makeAddr("Admin"); + + //vm.startPrank(admin); + SolvencyOracleMockDeployer oracleDeployer = new SolvencyOracleMockDeployer(); + oracle = oracleDeployer.run(); + + GEMxTokenDeployer deployer = new GEMxTokenDeployer(); + token = deployer.run(address(oracle)); + + // Grant roles + token.grantRole(token.DEFAULT_ADMIN_ROLE(), admin); + token.grantRole(token.MINTER_ROLE(), minter); + //vm.stopPrank(); + } + + function testMintRespectsProofOfSolvency() public { + uint256 proof = 1_000 ether; + _setProofOfSolvency(proof); + + vm.prank(minter); + token.mint(admin, 500 ether); + assertEq(token.totalSupply(), 500 ether); + + vm.prank(minter); + token.mint(admin, 500 ether); + assertEq(token.totalSupply(), 1_000 ether); + + vm.expectRevert(GEMxToken.NotEnoughReserve.selector); + vm.prank(minter); + token.mint(admin, 1); + } + + function testBurn() public { + uint256 proof = 1_000 ether; + _setProofOfSolvency(proof); + + vm.prank(minter); + token.mint(user, proof); + + vm.prank(user); + token.burn(500 ether); + + assertEq(token.balanceOf(user), 500 ether); + assertEq(token.totalSupply(), 500 ether); + } + + function testOnlyMinterCanMint() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.MINTER_ROLE()) + ); + vm.prank(user); + token.mint(user, 1_000 ether); + } + + function testOnlyMinterCanBurn() public { + uint256 proof = 1_000 ether; + _setProofOfSolvency(proof); + + vm.prank(minter); + token.mint(user, proof); + + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.MINTER_ROLE()) + ); + vm.prank(user); + token.burn(user, 500 ether); + } + + function testAdminCanGrantRoles() public { + address newMinter = address(0x5); + + bytes32 role = token.MINTER_ROLE(); + vm.prank(admin); + token.grantRole(role, newMinter); + + assertTrue(token.hasRole(token.MINTER_ROLE(), newMinter)); + } + + function testRevokeRoles() public { + assertTrue(token.hasRole(token.MINTER_ROLE(), minter)); + + bytes32 role = token.MINTER_ROLE(); + vm.prank(admin); + token.revokeRole(role, minter); + + assertFalse(token.hasRole(token.MINTER_ROLE(), minter)); + } + + function _setProofOfSolvency(uint256 proof) private { + oracle.setProofOfSolvency(proof); + assertEq(oracle.getProofOfSolvency(), proof); + } +} diff --git a/test/mocks/MockV3Aggregator.sol b/test/mocks/MockV3Aggregator.sol new file mode 100644 index 0000000..d975b46 --- /dev/null +++ b/test/mocks/MockV3Aggregator.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title MockV3Aggregator + * @notice Based on the FluxAggregator contract + * @notice Use this contract when you need to test + * other contract's ability to read data from an + * aggregator contract, but how the aggregator got + * its answer is unimportant + */ +contract MockV3Aggregator { + uint256 public constant version = 0; + + uint8 public decimals; + int256 public latestAnswer; + uint256 public latestTimestamp; + uint256 public latestRound; + + mapping(uint256 => int256) public getAnswer; + mapping(uint256 => uint256) public getTimestamp; + mapping(uint256 => uint256) private getStartedAt; + + constructor(uint8 _decimals, int256 _initialAnswer) { + decimals = _decimals; + updateAnswer(_initialAnswer); + } + + function updateAnswer(int256 _answer) public { + latestAnswer = _answer; + latestTimestamp = block.timestamp; + latestRound++; + getAnswer[latestRound] = _answer; + getTimestamp[latestRound] = block.timestamp; + getStartedAt[latestRound] = block.timestamp; + } + + function updateRoundData(uint80 _roundId, int256 _answer, uint256 _timestamp, uint256 _startedAt) public { + latestRound = _roundId; + latestAnswer = _answer; + latestTimestamp = _timestamp; + getAnswer[latestRound] = _answer; + getTimestamp[latestRound] = _timestamp; + getStartedAt[latestRound] = _startedAt; + } + + function getRoundData(uint80 _roundId) + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + return (_roundId, getAnswer[_roundId], getStartedAt[_roundId], getTimestamp[_roundId], _roundId); + } + + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + return ( + uint80(latestRound), + getAnswer[latestRound], + getStartedAt[latestRound], + getTimestamp[latestRound], + uint80(latestRound) + ); + } + + function description() external pure returns (string memory) { + return "v0.6/tests/MockV3Aggregator.sol"; + } +} \ No newline at end of file From a68eadb4fa00d22e59ad54c5f9181f172a44b230 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Wed, 5 Feb 2025 16:20:17 +0100 Subject: [PATCH 02/25] forge install: forge-std v1.9.6 --- .gitmodules | 4 ++-- lib/forge-std | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 160000 lib/forge-std diff --git a/.gitmodules b/.gitmodules index 5ec0382..51311da 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "lib/forge-std"] - path = lib/forge-std - url = https://github.com/foundry-rs/forge-std + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std [submodule "lib/openzeppelin-foundry-upgrades"] path = lib/openzeppelin-foundry-upgrades [submodule "lib/openzeppelin-contracts-upgradeable"] diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..3b20d60 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 3b20d60d14b343ee4f908cb8079495c07f5e8981 From 16af2439168e7499a2a1d58ee6b4bdb6c3cef0ec Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Wed, 5 Feb 2025 17:23:39 +0100 Subject: [PATCH 03/25] First version, WIP. --- .gitmodules | 16 ++++++---- lib/chainlink-brownie-contracts | 1 + lib/openzeppelin-contracts | 1 + lib/openzeppelin-contracts-upgradeable | 1 + lib/openzeppelin-foundry-upgrades | 1 + remappings.txt | 3 +- src/GEMxToken.sol | 41 +++++++++++++++++++------- test/AdditionalGEMxTokenTests.t.sol | 13 -------- test/Counter.t.sol | 24 --------------- test/mocks/MockV3Aggregator.sol | 4 ++- test/{ => unit}/GEMxToken.t.sol | 14 +++------ 11 files changed, 55 insertions(+), 64 deletions(-) create mode 160000 lib/chainlink-brownie-contracts create mode 160000 lib/openzeppelin-contracts create mode 160000 lib/openzeppelin-contracts-upgradeable create mode 160000 lib/openzeppelin-foundry-upgrades delete mode 100644 test/AdditionalGEMxTokenTests.t.sol delete mode 100644 test/Counter.t.sol rename test/{ => unit}/GEMxToken.t.sol (86%) diff --git a/.gitmodules b/.gitmodules index 51311da..beb7a17 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,8 +2,14 @@ path = lib/forge-std url = https://github.com/foundry-rs/forge-std [submodule "lib/openzeppelin-foundry-upgrades"] - path = lib/openzeppelin-foundry-upgrades -[submodule "lib/openzeppelin-contracts-upgradeable"] - path = lib/openzeppelin-contracts-upgradeable -[submodule "lib/openzeppelin-contracts"] - path = lib/openzeppelin-contracts \ No newline at end of file + path = lib/openzeppelin-foundry-upgrades + url = https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "lib/chainlink-brownie-contracts"] + path = lib/chainlink-brownie-contracts + url = https://github.com/smartcontractkit/chainlink-brownie-contracts diff --git a/lib/chainlink-brownie-contracts b/lib/chainlink-brownie-contracts new file mode 160000 index 0000000..5cb41fb --- /dev/null +++ b/lib/chainlink-brownie-contracts @@ -0,0 +1 @@ +Subproject commit 5cb41fbc9b525338b6098da5ea7dd0b7e92f89e4 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..acd4ff7 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit acd4ff74de833399287ed6b31b4debf6b2b35527 diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 0000000..3d5fa5c --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 3d5fa5c24c411112bab47bec25cfa9ad0af0e6e8 diff --git a/lib/openzeppelin-foundry-upgrades b/lib/openzeppelin-foundry-upgrades new file mode 160000 index 0000000..cbce1e0 --- /dev/null +++ b/lib/openzeppelin-foundry-upgrades @@ -0,0 +1 @@ +Subproject commit cbce1e00305e943aa1661d43f41e5ac72c662b07 diff --git a/remappings.txt b/remappings.txt index 2047311..6a90951 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,3 +1,4 @@ forge-std/=lib/forge-std/src/ @openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ -@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ \ No newline at end of file +@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ +@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/src/ \ No newline at end of file diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index ed18524..06cfa20 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -1,4 +1,27 @@ -// SPDX-License-Identifier: UNLICENSED +// Layout of Contract: +// version +// imports +// errors +// interfaces, libraries, contracts +// Type declarations +// State variables +// Events +// Modifiers +// Functions + +// Layout of Functions: +// constructor +// receive function (if exists) +// fallback function (if exists) +// external +// public +// internal +// private +// internal & private view & pure functions +// external & public view & pure functions + +// SPDX-License-Identifier: MIT + pragma solidity ^0.8.13; import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; @@ -7,28 +30,26 @@ import { ERC20BurnableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {ISolvencyOracle, SolvencyOracleMock} from "./SolvencyOracleMock.sol"; +import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; contract GEMxToken is ERC20BurnableUpgradeable, AccessControlUpgradeable { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); - ISolvencyOracle solvencyOracle; - - event ProofOfSolvencyChanged(uint256 amount); + AggregatorV3Interface oracle; error NotEnoughReserve(); - function initialize(address proofOfSolvencyOracleAddress) public initializer { + function initialize(address oracleAddres) public initializer { __ERC20_init("GEMxToken", "GEMX"); __AccessControl_init(); _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); _setRoleAdmin(MINTER_ROLE, DEFAULT_ADMIN_ROLE); - solvencyOracle = SolvencyOracleMock(proofOfSolvencyOracleAddress); + oracle = AggregatorV3Interface(oracleAddres); } - function getProofOfSolvency() external view returns (uint256) { - return solvencyOracle.getProofOfSolvency(); + function getProofOfReserve() external view returns (uint256) { + return oracle.latestRoundData().answer; } function mint(address account, uint256 value) external onlyRole(MINTER_ROLE) { @@ -41,7 +62,7 @@ contract GEMxToken is ERC20BurnableUpgradeable, AccessControlUpgradeable { function _update(address from, address to, uint256 amount) internal override(ERC20Upgradeable) { // make sure it cannot be minted more than proof of reserve! - if (from == address(0) && totalSupply() + amount > this.getProofOfSolvency()) { + if (from == address(0) && totalSupply() + amount > this.getProofOfReserve()) { revert NotEnoughReserve(); } diff --git a/test/AdditionalGEMxTokenTests.t.sol b/test/AdditionalGEMxTokenTests.t.sol deleted file mode 100644 index dc7eb82..0000000 --- a/test/AdditionalGEMxTokenTests.t.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -//import {Test} from "forge-std/Test.sol"; -import {Test, console} from "lib/forge-std/src/Test.sol"; -import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; -import {SolvencyOracleMock} from "../src/SolvencyOracleMock.sol"; -import {GEMxToken} from "../src/GEMxToken.sol"; -import {GEMxTokenDeployer} from "../script/GEMxTokenDeployer.s.sol"; - -contract AdditionalGEMxTokenTests is Test { - -} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 54b724f..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/mocks/MockV3Aggregator.sol b/test/mocks/MockV3Aggregator.sol index d975b46..eadf652 100644 --- a/test/mocks/MockV3Aggregator.sol +++ b/test/mocks/MockV3Aggregator.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; +import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; + /** * @title MockV3Aggregator * @notice Based on the FluxAggregator contract @@ -9,7 +11,7 @@ pragma solidity ^0.8.0; * aggregator contract, but how the aggregator got * its answer is unimportant */ -contract MockV3Aggregator { +contract MockV3Aggregator is AggregatorV3Interface { uint256 public constant version = 0; uint8 public decimals; diff --git a/test/GEMxToken.t.sol b/test/unit/GEMxToken.t.sol similarity index 86% rename from test/GEMxToken.t.sol rename to test/unit/GEMxToken.t.sol index cbb92ec..d111379 100644 --- a/test/GEMxToken.t.sol +++ b/test/unit/GEMxToken.t.sol @@ -4,14 +4,13 @@ pragma solidity ^0.8.13; //import {Test} from "forge-std/Test.sol"; import {Test, console} from "lib/forge-std/src/Test.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; -import {GEMxToken} from "../src/GEMxToken.sol"; -import {GEMxTokenDeployer} from "../script/GEMxTokenDeployer.s.sol"; -import {SolvencyOracleMock} from "../src/SolvencyOracleMock.sol"; -import {SolvencyOracleMockDeployer} from "../script/SolvencyOracleMockDeployer.s.sol"; +import {GEMxToken} from "../../src/GEMxToken.sol"; +import {GEMxTokenDeployer} from "../../script/GEMxTokenDeployer.s.sol"; +import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; contract GEMxTokenTest is Test { GEMxToken private token; - SolvencyOracleMock private oracle; + AggregatorV3Interface private oracle; address admin = address(0x1); address minter = address(0x2); address user = address(0x3); @@ -104,9 +103,4 @@ contract GEMxTokenTest is Test { assertFalse(token.hasRole(token.MINTER_ROLE(), minter)); } - - function _setProofOfSolvency(uint256 proof) private { - oracle.setProofOfSolvency(proof); - assertEq(oracle.getProofOfSolvency(), proof); - } } From e419c5ecb646144fefad5eea28c3727f24aa1210 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Thu, 6 Feb 2025 13:32:32 +0100 Subject: [PATCH 04/25] Add makefile and fix build errors. --- Makefile | 46 ++++++++++ script/Counter.s.sol | 19 ----- script/DeployToken.s.sol | 27 ++++++ script/GEMxTokenDeployer.s.sol | 21 ----- script/HelperConfig.s.sol | 52 +++++++++++ src/Counter.sol | 14 --- src/GEMxToken.sol | 4 +- test/mocks/MockV3Aggregator.sol | 147 ++++++++++++++++---------------- test/unit/GEMxToken.t.sol | 38 +++++---- 9 files changed, 222 insertions(+), 146 deletions(-) create mode 100644 Makefile delete mode 100644 script/Counter.s.sol create mode 100644 script/DeployToken.s.sol delete mode 100644 script/GEMxTokenDeployer.s.sol create mode 100644 script/HelperConfig.s.sol delete mode 100644 src/Counter.sol diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f48001 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +# include .env file and export its env vars +# (-include to ignore error if it does not exist) +-include .env + +.PHONY: all test clean deploy fund help install snapshot format anvil + +DEFAULT_ANVIL_KEY := 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +# Update dependencies +setup :; make update-libs ; make install-deps +update-libs :; git submodule update --init --recursive +install-deps :; yarn install --frozen-lockfile + +help: + @echo "Usage:" + @echo " make deploy [ARGS=...]\n example: make deploy ARGS=\"--network sepolia\"" + @echo "" + @echo " make fund [ARGS=...]\n example: make deploy ARGS=\"--network sepolia\"" + +all: clean remove install update build + +build :; forge build +clean :; forge clean +# Remove modules +remove :; rm -rf .gitmodules && rm -rf .git/modules/* && rm -rf lib && touch .gitmodules && git add . +install :; forge install OpenZeppelin/openzeppelin-contracts --no-commit && OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit && forge install OpenZeppelin/openzeppelin-foundry-upgrades --no-commit && forge install foundry-rs/forge-std --no-commit +lint :; yarn run lint +test :; forge test +test-vvvv :; forge test -vvvv +test-gasreport :; forge test --gas-report +test-fork :; forge test --fork-url ${ETH_RPC_URL} -vvv +coverage :; forge coverage --report debug > coverage-report.txt +snapshot :; forge snapshot +format :; forge fmt +anvil :; anvil -m 'test test test test test test test test test test test junk' --steps-tracing --block-time 1 +fork :; anvil --fork-url ${FORK_ETH_RPC_URL} --fork-block-number ${FORK_BLOCK_NUMBER} +watch :; forge test --watch src/ + +NETWORK_ARGS := --rpc-url http://localhost:8545 --private-key $(DEFAULT_ANVIL_KEY) --broadcast + +ifeq ($(findstring --network sepolia,$(ARGS)),--network sepolia) + NETWORK_ARGS := --rpc-url $(SEPOLIA_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv +endif + +deploy: + @forge script script/DeployToken.s.sol:DeployToken $(NETWORK_ARGS) \ No newline at end of file diff --git a/script/Counter.s.sol b/script/Counter.s.sol deleted file mode 100644 index cdc1fe9..0000000 --- a/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script, console} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/script/DeployToken.s.sol b/script/DeployToken.s.sol new file mode 100644 index 0000000..32554d5 --- /dev/null +++ b/script/DeployToken.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +//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, NetworkConfig} from "./HelperConfig.s.sol"; +import {GEMxToken} from "../src/GEMxToken.sol"; + +contract DeployToken is Script { + GEMxToken public token; + + function run() public returns (GEMxToken) { + HelperConfig helperConfig = new HelperConfig(); // This comes with our mocks! + + vm.startBroadcast(); + + token = new GEMxToken(); + //NetworkConfig memory config = helperConfig.activeNetworkConfig(); + (address proofOfReserveOracle,) = helperConfig.activeNetworkConfig(); + //address oracleAddress = config.proofOfReserveOracle; + token.initialize(proofOfReserveOracle); + + vm.stopBroadcast(); + + return token; + } +} diff --git a/script/GEMxTokenDeployer.s.sol b/script/GEMxTokenDeployer.s.sol deleted file mode 100644 index 19d8418..0000000 --- a/script/GEMxTokenDeployer.s.sol +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -//import {Script, console} from "forge-std/Script.sol"; // not recognized by VS Code -import {Script, console} from "lib/forge-std/src/Script.sol"; -import {GEMxToken} from "../src/GEMxToken.sol"; - -contract GEMxTokenDeployer is Script { - GEMxToken public token; - - function run(address oracleAddress) public returns (GEMxToken) { - vm.startBroadcast(); - - token = new GEMxToken(); - token.initialize(oracleAddress); - - vm.stopBroadcast(); - - return token; - } -} diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol new file mode 100644 index 0000000..b0caf17 --- /dev/null +++ b/script/HelperConfig.s.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {MockV3Aggregator} from "../test/mocks/MockV3Aggregator.sol"; +import {Script} from "forge-std/Script.sol"; + +struct NetworkConfig { + address proofOfReserveOracle; + uint256 deployerKey; +} + +contract HelperConfig is Script { + NetworkConfig public activeNetworkConfig; + + uint8 public constant DECIMALS = 18; + int256 public constant PROOF_OF_RESERVE = 100_000; + + uint256 public DEFAULT_ANVIL_PRIVATE_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + + constructor() { + if (block.chainid == 11_155_111) { + activeNetworkConfig = getSepoliaEthConfig(); + } else if (block.chainid == 43113) { + activeNetworkConfig = getFujiEthConfig(); + } else { + activeNetworkConfig = getOrCreateAnvilEthConfig(); + } + } + + function getSepoliaEthConfig() public view returns (NetworkConfig memory sepoliaNetworkConfig) { + sepoliaNetworkConfig = + NetworkConfig({proofOfReserveOracle: address(0x0), deployerKey: vm.envUint("PRIVATE_KEY")}); + } + + function getFujiEthConfig() public view returns (NetworkConfig memory fujiNetworkConfig) { + fujiNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0), deployerKey: vm.envUint("PRIVATE_KEY")}); + } + + function getOrCreateAnvilEthConfig() public returns (NetworkConfig memory anvilNetworkConfig) { + // Check to see if we set an active network config + if (activeNetworkConfig.proofOfReserveOracle != address(0)) { + return activeNetworkConfig; + } + + vm.startBroadcast(); + MockV3Aggregator proofOfReserveFeed = new MockV3Aggregator(PROOF_OF_RESERVE); + vm.stopBroadcast(); + + anvilNetworkConfig = + NetworkConfig({proofOfReserveOracle: address(proofOfReserveFeed), deployerKey: DEFAULT_ANVIL_PRIVATE_KEY}); + } +} diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index aded799..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index 06cfa20..e9895d9 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -49,7 +49,9 @@ contract GEMxToken is ERC20BurnableUpgradeable, AccessControlUpgradeable { } function getProofOfReserve() external view returns (uint256) { - return oracle.latestRoundData().answer; + (, int256 answer,,,) = oracle.latestRoundData(); + + return uint256(answer); } function mint(address account, uint256 value) external onlyRole(MINTER_ROLE) { diff --git a/test/mocks/MockV3Aggregator.sol b/test/mocks/MockV3Aggregator.sol index eadf652..ad6caec 100644 --- a/test/mocks/MockV3Aggregator.sol +++ b/test/mocks/MockV3Aggregator.sol @@ -1,74 +1,73 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; - -/** - * @title MockV3Aggregator - * @notice Based on the FluxAggregator contract - * @notice Use this contract when you need to test - * other contract's ability to read data from an - * aggregator contract, but how the aggregator got - * its answer is unimportant - */ -contract MockV3Aggregator is AggregatorV3Interface { - uint256 public constant version = 0; - - uint8 public decimals; - int256 public latestAnswer; - uint256 public latestTimestamp; - uint256 public latestRound; - - mapping(uint256 => int256) public getAnswer; - mapping(uint256 => uint256) public getTimestamp; - mapping(uint256 => uint256) private getStartedAt; - - constructor(uint8 _decimals, int256 _initialAnswer) { - decimals = _decimals; - updateAnswer(_initialAnswer); - } - - function updateAnswer(int256 _answer) public { - latestAnswer = _answer; - latestTimestamp = block.timestamp; - latestRound++; - getAnswer[latestRound] = _answer; - getTimestamp[latestRound] = block.timestamp; - getStartedAt[latestRound] = block.timestamp; - } - - function updateRoundData(uint80 _roundId, int256 _answer, uint256 _timestamp, uint256 _startedAt) public { - latestRound = _roundId; - latestAnswer = _answer; - latestTimestamp = _timestamp; - getAnswer[latestRound] = _answer; - getTimestamp[latestRound] = _timestamp; - getStartedAt[latestRound] = _startedAt; - } - - function getRoundData(uint80 _roundId) - external - view - returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) - { - return (_roundId, getAnswer[_roundId], getStartedAt[_roundId], getTimestamp[_roundId], _roundId); - } - - function latestRoundData() - external - view - returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) - { - return ( - uint80(latestRound), - getAnswer[latestRound], - getStartedAt[latestRound], - getTimestamp[latestRound], - uint80(latestRound) - ); - } - - function description() external pure returns (string memory) { - return "v0.6/tests/MockV3Aggregator.sol"; - } -} \ No newline at end of file +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; + +/** + * @title MockV3Aggregator + * @notice Based on the FluxAggregator contract + * @notice Use this contract when you need to test + * other contract's ability to read data from an + * aggregator contract, but how the aggregator got + * its answer is unimportant + */ +contract MockV3Aggregator is AggregatorV3Interface { + uint256 public constant version = 0; + + uint8 public decimals; + int256 public latestAnswer; + uint256 public latestTimestamp; + uint256 public latestRound; + + mapping(uint256 => int256) public getAnswer; + mapping(uint256 => uint256) public getTimestamp; + mapping(uint256 => uint256) private getStartedAt; + + constructor(int256 _initialAnswer) { + updateAnswer(_initialAnswer); + } + + function updateAnswer(int256 _answer) public { + latestAnswer = _answer; + latestTimestamp = block.timestamp; + latestRound++; + getAnswer[latestRound] = _answer; + getTimestamp[latestRound] = block.timestamp; + getStartedAt[latestRound] = block.timestamp; + } + + function updateRoundData(uint80 _roundId, int256 _answer, uint256 _timestamp, uint256 _startedAt) public { + latestRound = _roundId; + latestAnswer = _answer; + latestTimestamp = _timestamp; + getAnswer[latestRound] = _answer; + getTimestamp[latestRound] = _timestamp; + getStartedAt[latestRound] = _startedAt; + } + + function getRoundData(uint80 _roundId) + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + return (_roundId, getAnswer[_roundId], getStartedAt[_roundId], getTimestamp[_roundId], _roundId); + } + + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + return ( + uint80(latestRound), + getAnswer[latestRound], + getStartedAt[latestRound], + getTimestamp[latestRound], + uint80(latestRound) + ); + } + + function description() external pure returns (string memory) { + return "v0.6/tests/MockV3Aggregator.sol"; + } +} diff --git a/test/unit/GEMxToken.t.sol b/test/unit/GEMxToken.t.sol index d111379..ca5b0dd 100644 --- a/test/unit/GEMxToken.t.sol +++ b/test/unit/GEMxToken.t.sol @@ -5,12 +5,13 @@ pragma solidity ^0.8.13; import {Test, console} from "lib/forge-std/src/Test.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {GEMxToken} from "../../src/GEMxToken.sol"; -import {GEMxTokenDeployer} from "../../script/GEMxTokenDeployer.s.sol"; +import {DeployToken} from "../../script/DeployToken.s.sol"; import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; +import {MockV3Aggregator} from "../mocks/MockV3Aggregator.sol"; contract GEMxTokenTest is Test { GEMxToken private token; - AggregatorV3Interface private oracle; + MockV3Aggregator private oracle; address admin = address(0x1); address minter = address(0x2); address user = address(0x3); @@ -19,21 +20,24 @@ contract GEMxTokenTest is Test { admin = makeAddr("Admin"); //vm.startPrank(admin); - SolvencyOracleMockDeployer oracleDeployer = new SolvencyOracleMockDeployer(); - oracle = oracleDeployer.run(); - - GEMxTokenDeployer deployer = new GEMxTokenDeployer(); - token = deployer.run(address(oracle)); + DeployToken deployer = new DeployToken(); + token = deployer.run(); // Grant roles token.grantRole(token.DEFAULT_ADMIN_ROLE(), admin); + + vm.startPrank(admin); token.grantRole(token.MINTER_ROLE(), minter); - //vm.stopPrank(); + vm.stopPrank(); + } + + function _setProofOfReserve(int256 value) private { + oracle.updateAnswer(value); } - function testMintRespectsProofOfSolvency() public { - uint256 proof = 1_000 ether; - _setProofOfSolvency(proof); + function testMintRespectsProofOfReserve() public { + int256 proof = 1_000 ether; + _setProofOfReserve(proof); vm.prank(minter); token.mint(admin, 500 ether); @@ -49,11 +53,11 @@ contract GEMxTokenTest is Test { } function testBurn() public { - uint256 proof = 1_000 ether; - _setProofOfSolvency(proof); + int256 proof = 1_000 ether; + _setProofOfReserve(proof); vm.prank(minter); - token.mint(user, proof); + token.mint(user, uint256(proof)); vm.prank(user); token.burn(500 ether); @@ -71,11 +75,11 @@ contract GEMxTokenTest is Test { } function testOnlyMinterCanBurn() public { - uint256 proof = 1_000 ether; - _setProofOfSolvency(proof); + int256 proof = 1_000 ether; + _setProofOfReserve(proof); vm.prank(minter); - token.mint(user, proof); + token.mint(user, uint256(proof)); vm.expectRevert( abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.MINTER_ROLE()) From 06d2926cb91b65970184fd455aec517e054c6a28 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Thu, 6 Feb 2025 17:11:54 +0100 Subject: [PATCH 05/25] First version of tests --- Makefile | 10 +--- script/DeployToken.s.sol | 4 +- script/HelperConfig.s.sol | 26 +++++---- src/GEMxToken.sol | 19 ++++--- .../{GEMxToken.t.sol => GEMxTokenTest.t.sol} | 57 ++++++++++--------- 5 files changed, 60 insertions(+), 56 deletions(-) rename test/unit/{GEMxToken.t.sol => GEMxTokenTest.t.sol} (74%) diff --git a/Makefile b/Makefile index 7f48001..c41b3f2 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,6 @@ DEFAULT_ANVIL_KEY := 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -# Update dependencies -setup :; make update-libs ; make install-deps -update-libs :; git submodule update --init --recursive -install-deps :; yarn install --frozen-lockfile - help: @echo "Usage:" @echo " make deploy [ARGS=...]\n example: make deploy ARGS=\"--network sepolia\"" @@ -24,12 +19,11 @@ clean :; forge clean # Remove modules remove :; rm -rf .gitmodules && rm -rf .git/modules/* && rm -rf lib && touch .gitmodules && git add . install :; forge install OpenZeppelin/openzeppelin-contracts --no-commit && OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit && forge install OpenZeppelin/openzeppelin-foundry-upgrades --no-commit && forge install foundry-rs/forge-std --no-commit -lint :; yarn run lint test :; forge test -test-vvvv :; forge test -vvvv +test-vvv :; forge test -vvv test-gasreport :; forge test --gas-report test-fork :; forge test --fork-url ${ETH_RPC_URL} -vvv -coverage :; forge coverage --report debug > coverage-report.txt +coverage :; mkdir -p ./coverage && forge coverage --report lcov --report-file coverage/lcov.info && genhtml coverage/lcov.info -o coverage --branch-coverage snapshot :; forge snapshot format :; forge fmt anvil :; anvil -m 'test test test test test test test test test test test junk' --steps-tracing --block-time 1 diff --git a/script/DeployToken.s.sol b/script/DeployToken.s.sol index 32554d5..4f74466 100644 --- a/script/DeployToken.s.sol +++ b/script/DeployToken.s.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.13; //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, NetworkConfig} from "./HelperConfig.s.sol"; +import {HelperConfig} from "./HelperConfig.s.sol"; import {GEMxToken} from "../src/GEMxToken.sol"; contract DeployToken is Script { @@ -16,7 +16,7 @@ contract DeployToken is Script { token = new GEMxToken(); //NetworkConfig memory config = helperConfig.activeNetworkConfig(); - (address proofOfReserveOracle,) = helperConfig.activeNetworkConfig(); + (address proofOfReserveOracle) = helperConfig.activeNetworkConfig(); //address oracleAddress = config.proofOfReserveOracle; token.initialize(proofOfReserveOracle); diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index b0caf17..97e8990 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -4,11 +4,6 @@ pragma solidity ^0.8.19; import {MockV3Aggregator} from "../test/mocks/MockV3Aggregator.sol"; import {Script} from "forge-std/Script.sol"; -struct NetworkConfig { - address proofOfReserveOracle; - uint256 deployerKey; -} - contract HelperConfig is Script { NetworkConfig public activeNetworkConfig; @@ -17,8 +12,13 @@ contract HelperConfig is Script { uint256 public DEFAULT_ANVIL_PRIVATE_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + struct NetworkConfig { + address proofOfReserveOracle; + } + //uint256 deployerKey; + constructor() { - if (block.chainid == 11_155_111) { + if (block.chainid == 11155111) { activeNetworkConfig = getSepoliaEthConfig(); } else if (block.chainid == 43113) { activeNetworkConfig = getFujiEthConfig(); @@ -28,12 +28,15 @@ contract HelperConfig is Script { } function getSepoliaEthConfig() public view returns (NetworkConfig memory sepoliaNetworkConfig) { - sepoliaNetworkConfig = - NetworkConfig({proofOfReserveOracle: address(0x0), deployerKey: vm.envUint("PRIVATE_KEY")}); + sepoliaNetworkConfig = NetworkConfig({ + proofOfReserveOracle: address(0x0) //, deployerKey: vm.envUint("PRIVATE_KEY") + }); } function getFujiEthConfig() public view returns (NetworkConfig memory fujiNetworkConfig) { - fujiNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0), deployerKey: vm.envUint("PRIVATE_KEY")}); + fujiNetworkConfig = NetworkConfig({ + proofOfReserveOracle: address(0x0) //, deployerKey: vm.envUint("PRIVATE_KEY") + }); } function getOrCreateAnvilEthConfig() public returns (NetworkConfig memory anvilNetworkConfig) { @@ -46,7 +49,8 @@ contract HelperConfig is Script { MockV3Aggregator proofOfReserveFeed = new MockV3Aggregator(PROOF_OF_RESERVE); vm.stopBroadcast(); - anvilNetworkConfig = - NetworkConfig({proofOfReserveOracle: address(proofOfReserveFeed), deployerKey: DEFAULT_ANVIL_PRIVATE_KEY}); + anvilNetworkConfig = NetworkConfig({ + proofOfReserveOracle: address(proofOfReserveFeed) //, deployerKey: DEFAULT_ANVIL_PRIVATE_KEY + }); } } diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index e9895d9..51ebd52 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -41,6 +41,7 @@ contract GEMxToken is ERC20BurnableUpgradeable, AccessControlUpgradeable { function initialize(address oracleAddres) public initializer { __ERC20_init("GEMxToken", "GEMX"); + __ERC20Burnable_init(); __AccessControl_init(); _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); _setRoleAdmin(MINTER_ROLE, DEFAULT_ADMIN_ROLE); @@ -48,12 +49,6 @@ contract GEMxToken is ERC20BurnableUpgradeable, AccessControlUpgradeable { oracle = AggregatorV3Interface(oracleAddres); } - function getProofOfReserve() external view returns (uint256) { - (, int256 answer,,,) = oracle.latestRoundData(); - - return uint256(answer); - } - function mint(address account, uint256 value) external onlyRole(MINTER_ROLE) { _mint(account, value); } @@ -62,12 +57,22 @@ contract GEMxToken is ERC20BurnableUpgradeable, AccessControlUpgradeable { _burn(account, value); } + function getOracleAddress() public returns (address) { + return address(oracle); + } + function _update(address from, address to, uint256 amount) internal override(ERC20Upgradeable) { // make sure it cannot be minted more than proof of reserve! - if (from == address(0) && totalSupply() + amount > this.getProofOfReserve()) { + if (from == address(0) && totalSupply() + amount > _getProofOfReserve()) { revert NotEnoughReserve(); } super._update(from, to, amount); } + + function _getProofOfReserve() private view returns (uint256) { + (, int256 answer,,,) = oracle.latestRoundData(); + + return uint256(answer); + } } diff --git a/test/unit/GEMxToken.t.sol b/test/unit/GEMxTokenTest.t.sol similarity index 74% rename from test/unit/GEMxToken.t.sol rename to test/unit/GEMxTokenTest.t.sol index ca5b0dd..6c38981 100644 --- a/test/unit/GEMxToken.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -15,18 +15,18 @@ contract GEMxTokenTest is Test { address admin = address(0x1); address minter = address(0x2); address user = address(0x3); + address anon = makeAddr("anon"); function setUp() public { admin = makeAddr("Admin"); - //vm.startPrank(admin); DeployToken deployer = new DeployToken(); token = deployer.run(); + oracle = MockV3Aggregator(token.getOracleAddress()); // Grant roles + vm.startPrank(DEFAULT_SENDER); token.grantRole(token.DEFAULT_ADMIN_ROLE(), admin); - - vm.startPrank(admin); token.grantRole(token.MINTER_ROLE(), minter); vm.stopPrank(); } @@ -36,56 +36,57 @@ contract GEMxTokenTest is Test { } function testMintRespectsProofOfReserve() public { - int256 proof = 1_000 ether; - _setProofOfReserve(proof); + int256 reserve = 1_000 ether; + _setProofOfReserve(reserve); - vm.prank(minter); - token.mint(admin, 500 ether); + vm.startPrank(minter); + token.mint(user, 500 ether); assertEq(token.totalSupply(), 500 ether); - vm.prank(minter); - token.mint(admin, 500 ether); + // vm.prank(minter); + token.mint(user, 500 ether); assertEq(token.totalSupply(), 1_000 ether); vm.expectRevert(GEMxToken.NotEnoughReserve.selector); - vm.prank(minter); - token.mint(admin, 1); - } - - function testBurn() public { - int256 proof = 1_000 ether; - _setProofOfReserve(proof); - - vm.prank(minter); - token.mint(user, uint256(proof)); - - vm.prank(user); - token.burn(500 ether); + // vm.prank(minter); + token.mint(user, 1); + vm.stopPrank(); - assertEq(token.balanceOf(user), 500 ether); - assertEq(token.totalSupply(), 500 ether); + assertEq(token.balanceOf(user), 1_000 ether); } function testOnlyMinterCanMint() public { + _setProofOfReserve(1_000 ether); + vm.expectRevert( abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.MINTER_ROLE()) ); vm.prank(user); token.mint(user, 1_000 ether); + + vm.prank(minter); + token.mint(user, 1 ether); + assertEq(token.balanceOf(user), 1 ether); } function testOnlyMinterCanBurn() public { - int256 proof = 1_000 ether; - _setProofOfReserve(proof); + _setProofOfReserve(1_000 ether); vm.prank(minter); - token.mint(user, uint256(proof)); + token.mint(user, uint256(10 ether)); vm.expectRevert( abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.MINTER_ROLE()) ); vm.prank(user); - token.burn(user, 500 ether); + token.burn(user, 1 ether); + + assertEq(token.balanceOf(user), 10 ether); + + vm.prank(minter); + token.burn(user, 1 ether); + + assertEq(token.balanceOf(user), 9 ether); } function testAdminCanGrantRoles() public { From 2e78b94cb75ada31d7abebd1142ed309c3c14e47 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Thu, 6 Feb 2025 17:37:44 +0100 Subject: [PATCH 06/25] Fix install target in Makefile. --- .gitmodules | 8 +++--- Makefile | 82 ++++++++++++++++++++++++++++------------------------- 2 files changed, 47 insertions(+), 43 deletions(-) diff --git a/.gitmodules b/.gitmodules index beb7a17..8d1b421 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,15 +1,15 @@ -[submodule "lib/forge-std"] +[submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std -[submodule "lib/openzeppelin-foundry-upgrades"] - path = lib/openzeppelin-foundry-upgrades - url = https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts [submodule "lib/openzeppelin-contracts-upgradeable"] path = lib/openzeppelin-contracts-upgradeable url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "lib/openzeppelin-foundry-upgrades"] + path = lib/openzeppelin-foundry-upgrades + url = https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades [submodule "lib/chainlink-brownie-contracts"] path = lib/chainlink-brownie-contracts url = https://github.com/smartcontractkit/chainlink-brownie-contracts diff --git a/Makefile b/Makefile index c41b3f2..5368b85 100644 --- a/Makefile +++ b/Makefile @@ -1,40 +1,44 @@ -# include .env file and export its env vars -# (-include to ignore error if it does not exist) --include .env - -.PHONY: all test clean deploy fund help install snapshot format anvil - -DEFAULT_ANVIL_KEY := 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - -help: - @echo "Usage:" - @echo " make deploy [ARGS=...]\n example: make deploy ARGS=\"--network sepolia\"" - @echo "" - @echo " make fund [ARGS=...]\n example: make deploy ARGS=\"--network sepolia\"" - -all: clean remove install update build - -build :; forge build -clean :; forge clean -# Remove modules -remove :; rm -rf .gitmodules && rm -rf .git/modules/* && rm -rf lib && touch .gitmodules && git add . -install :; forge install OpenZeppelin/openzeppelin-contracts --no-commit && OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit && forge install OpenZeppelin/openzeppelin-foundry-upgrades --no-commit && forge install foundry-rs/forge-std --no-commit -test :; forge test -test-vvv :; forge test -vvv -test-gasreport :; forge test --gas-report -test-fork :; forge test --fork-url ${ETH_RPC_URL} -vvv -coverage :; mkdir -p ./coverage && forge coverage --report lcov --report-file coverage/lcov.info && genhtml coverage/lcov.info -o coverage --branch-coverage -snapshot :; forge snapshot -format :; forge fmt -anvil :; anvil -m 'test test test test test test test test test test test junk' --steps-tracing --block-time 1 -fork :; anvil --fork-url ${FORK_ETH_RPC_URL} --fork-block-number ${FORK_BLOCK_NUMBER} -watch :; forge test --watch src/ - -NETWORK_ARGS := --rpc-url http://localhost:8545 --private-key $(DEFAULT_ANVIL_KEY) --broadcast - -ifeq ($(findstring --network sepolia,$(ARGS)),--network sepolia) - NETWORK_ARGS := --rpc-url $(SEPOLIA_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv -endif - -deploy: +# include .env file and export its env vars +# (-include to ignore error if it does not exist) +-include .env + +.PHONY: all test clean deploy fund help install snapshot format anvil + +DEFAULT_ANVIL_KEY := 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +help: + @echo "Usage:" + @echo " make deploy [ARGS=...]\n example: make deploy ARGS=\"--network sepolia\"" + @echo "" + @echo " make fund [ARGS=...]\n example: make deploy ARGS=\"--network sepolia\"" + +all: clean remove install update build + +build :; forge build +clean :; forge clean +remove :; rm -rf .gitmodules && rm -rf .git/modules/* && rm -rf lib && touch .gitmodules && git add . +install :; forge install foundry-rs/forge-std --no-commit && \ + forge install OpenZeppelin/openzeppelin-contracts --no-commit && \ + forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit && \ + forge install OpenZeppelin/openzeppelin-foundry-upgrades --no-commit && \ + forge install smartcontractkit/chainlink-brownie-contracts --no-commit + +test :; forge test +test-vvv :; forge test -vvv +test-gasreport :; forge test --gas-report +test-fork :; forge test --fork-url ${ETH_RPC_URL} -vvv +coverage :; mkdir -p ./coverage && forge coverage --report lcov --report-file coverage/lcov.info && genhtml coverage/lcov.info -o coverage --branch-coverage +snapshot :; forge snapshot +format :; forge fmt +anvil :; anvil -m 'test test test test test test test test test test test junk' --steps-tracing --block-time 1 +fork :; anvil --fork-url ${FORK_ETH_RPC_URL} --fork-block-number ${FORK_BLOCK_NUMBER} +watch :; forge test --watch src/ + +NETWORK_ARGS := --rpc-url http://localhost:8545 --private-key $(DEFAULT_ANVIL_KEY) --broadcast + +ifeq ($(findstring --network sepolia,$(ARGS)),--network sepolia) + NETWORK_ARGS := --rpc-url $(SEPOLIA_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv +endif + +deploy: @forge script script/DeployToken.s.sol:DeployToken $(NETWORK_ARGS) \ No newline at end of file From b8e3fea78604f2fe04dc9cbbe016fd0c26adc99a Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Fri, 7 Feb 2025 11:34:35 +0100 Subject: [PATCH 07/25] Cleanup --- .env.sample | 1 + Makefile | 3 +-- script/DeployToken.s.sol | 4 +--- script/HelperConfig.s.sol | 11 +++++------ test/unit/GEMxTokenTest.t.sol | 3 +-- 5 files changed, 9 insertions(+), 13 deletions(-) diff --git a/.env.sample b/.env.sample index 1cad7b2..1090b7e 100644 --- a/.env.sample +++ b/.env.sample @@ -3,5 +3,6 @@ DEFAULT_ANVIL_KEY=XXXXXXXXX RPC_URL=http://0.0.0.0:8545 ETHERSCAN_API_KEY=XXXX SEPOLIA_RPC_URL= +MAINNET_RPC_URL= AVALANCHE_FUJI_RPC_URL=https://avalanche-fuji-c-chain-rpc.publicnode.com AVALANCHE_MAINNET_RPC_URL=https://api.avax.network/ext/bc/C/rpc \ No newline at end of file diff --git a/Makefile b/Makefile index 5368b85..43be91c 100644 --- a/Makefile +++ b/Makefile @@ -22,9 +22,8 @@ install :; forge install foundry-rs/forge-std --no-commit && \ forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit && \ forge install OpenZeppelin/openzeppelin-foundry-upgrades --no-commit && \ forge install smartcontractkit/chainlink-brownie-contracts --no-commit - test :; forge test -test-vvv :; forge test -vvv +test-vvv :; forge test -vvv test-gasreport :; forge test --gas-report test-fork :; forge test --fork-url ${ETH_RPC_URL} -vvv coverage :; mkdir -p ./coverage && forge coverage --report lcov --report-file coverage/lcov.info && genhtml coverage/lcov.info -o coverage --branch-coverage diff --git a/script/DeployToken.s.sol b/script/DeployToken.s.sol index 4f74466..b28b745 100644 --- a/script/DeployToken.s.sol +++ b/script/DeployToken.s.sol @@ -10,14 +10,12 @@ contract DeployToken is Script { GEMxToken public token; function run() public returns (GEMxToken) { - HelperConfig helperConfig = new HelperConfig(); // This comes with our mocks! + HelperConfig helperConfig = new HelperConfig(); vm.startBroadcast(); token = new GEMxToken(); - //NetworkConfig memory config = helperConfig.activeNetworkConfig(); (address proofOfReserveOracle) = helperConfig.activeNetworkConfig(); - //address oracleAddress = config.proofOfReserveOracle; token.initialize(proofOfReserveOracle); vm.stopBroadcast(); diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index 97e8990..abddcd0 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -8,14 +8,13 @@ contract HelperConfig is Script { NetworkConfig public activeNetworkConfig; uint8 public constant DECIMALS = 18; - int256 public constant PROOF_OF_RESERVE = 100_000; + int256 public constant PROOF_OF_RESERVE_MOCK = 100_000; uint256 public DEFAULT_ANVIL_PRIVATE_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; struct NetworkConfig { address proofOfReserveOracle; } - //uint256 deployerKey; constructor() { if (block.chainid == 11155111) { @@ -29,13 +28,13 @@ contract HelperConfig is Script { function getSepoliaEthConfig() public view returns (NetworkConfig memory sepoliaNetworkConfig) { sepoliaNetworkConfig = NetworkConfig({ - proofOfReserveOracle: address(0x0) //, deployerKey: vm.envUint("PRIVATE_KEY") + proofOfReserveOracle: address(0x0) }); } function getFujiEthConfig() public view returns (NetworkConfig memory fujiNetworkConfig) { fujiNetworkConfig = NetworkConfig({ - proofOfReserveOracle: address(0x0) //, deployerKey: vm.envUint("PRIVATE_KEY") + proofOfReserveOracle: address(0x0) }); } @@ -46,11 +45,11 @@ contract HelperConfig is Script { } vm.startBroadcast(); - MockV3Aggregator proofOfReserveFeed = new MockV3Aggregator(PROOF_OF_RESERVE); + MockV3Aggregator proofOfReserveFeed = new MockV3Aggregator(PROOF_OF_RESERVE_MOCK); vm.stopBroadcast(); anvilNetworkConfig = NetworkConfig({ - proofOfReserveOracle: address(proofOfReserveFeed) //, deployerKey: DEFAULT_ANVIL_PRIVATE_KEY + proofOfReserveOracle: address(proofOfReserveFeed) }); } } diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index 6c38981..2dfd274 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -43,12 +43,10 @@ contract GEMxTokenTest is Test { token.mint(user, 500 ether); assertEq(token.totalSupply(), 500 ether); - // vm.prank(minter); token.mint(user, 500 ether); assertEq(token.totalSupply(), 1_000 ether); vm.expectRevert(GEMxToken.NotEnoughReserve.selector); - // vm.prank(minter); token.mint(user, 1); vm.stopPrank(); @@ -63,6 +61,7 @@ contract GEMxTokenTest is Test { ); vm.prank(user); token.mint(user, 1_000 ether); + assertEq(token.balanceOf(user), 0 ether); vm.prank(minter); token.mint(user, 1 ether); From f88026de80008e1bddd4a758c49f53b5acd8f6cf Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Fri, 7 Feb 2025 13:21:32 +0100 Subject: [PATCH 08/25] Pausing --- Makefile | 2 +- script/HelperConfig.s.sol | 12 ++----- src/GEMxToken.sol | 32 ++++++++++++++---- test/mocks/MockV3Aggregator.sol | 2 +- test/unit/GEMxTokenTest.t.sol | 58 +++++++++++++++++++++++++++++++-- 5 files changed, 85 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index 43be91c..96329a0 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # (-include to ignore error if it does not exist) -include .env -.PHONY: all test clean deploy fund help install snapshot format anvil +.PHONY: all test clean deploy fund help install coverage snapshot format anvil DEFAULT_ANVIL_KEY := 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index abddcd0..681515c 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -27,15 +27,11 @@ contract HelperConfig is Script { } function getSepoliaEthConfig() public view returns (NetworkConfig memory sepoliaNetworkConfig) { - sepoliaNetworkConfig = NetworkConfig({ - proofOfReserveOracle: address(0x0) - }); + sepoliaNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); } function getFujiEthConfig() public view returns (NetworkConfig memory fujiNetworkConfig) { - fujiNetworkConfig = NetworkConfig({ - proofOfReserveOracle: address(0x0) - }); + fujiNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); } function getOrCreateAnvilEthConfig() public returns (NetworkConfig memory anvilNetworkConfig) { @@ -48,8 +44,6 @@ contract HelperConfig is Script { MockV3Aggregator proofOfReserveFeed = new MockV3Aggregator(PROOF_OF_RESERVE_MOCK); vm.stopBroadcast(); - anvilNetworkConfig = NetworkConfig({ - proofOfReserveOracle: address(proofOfReserveFeed) - }); + anvilNetworkConfig = NetworkConfig({proofOfReserveOracle: address(proofOfReserveFeed)}); } } diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index 51ebd52..86c3dc2 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -22,18 +22,26 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.13; +pragma solidity ^0.8.22; import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; -import { - ERC20Upgradeable, - ERC20BurnableUpgradeable -} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {ERC20BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import {ERC20PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol"; +import {ERC20PermitUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; -contract GEMxToken is ERC20BurnableUpgradeable, AccessControlUpgradeable { +contract GEMxToken is + ERC20Upgradeable, ERC20BurnableUpgradeable, ERC20PausableUpgradeable, AccessControlUpgradeable +{ + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + /// @custom:oz-upgrades-unsafe-allow constructor + // constructor() { + // _disableInitializers(); + // } AggregatorV3Interface oracle; @@ -42,13 +50,23 @@ contract GEMxToken is ERC20BurnableUpgradeable, AccessControlUpgradeable { function initialize(address oracleAddres) public initializer { __ERC20_init("GEMxToken", "GEMX"); __ERC20Burnable_init(); + __ERC20Pausable_init(); __AccessControl_init(); _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); _setRoleAdmin(MINTER_ROLE, DEFAULT_ADMIN_ROLE); + _setRoleAdmin(PAUSER_ROLE, DEFAULT_ADMIN_ROLE); oracle = AggregatorV3Interface(oracleAddres); } + function pause() public onlyRole(PAUSER_ROLE) { + _pause(); + } + + function unpause() public onlyRole(PAUSER_ROLE) { + _unpause(); + } + function mint(address account, uint256 value) external onlyRole(MINTER_ROLE) { _mint(account, value); } @@ -61,7 +79,7 @@ contract GEMxToken is ERC20BurnableUpgradeable, AccessControlUpgradeable { return address(oracle); } - function _update(address from, address to, uint256 amount) internal override(ERC20Upgradeable) { + function _update(address from, address to, uint256 amount) internal override(ERC20Upgradeable, ERC20PausableUpgradeable) { // make sure it cannot be minted more than proof of reserve! if (from == address(0) && totalSupply() + amount > _getProofOfReserve()) { revert NotEnoughReserve(); diff --git a/test/mocks/MockV3Aggregator.sol b/test/mocks/MockV3Aggregator.sol index ad6caec..4d27ce0 100644 --- a/test/mocks/MockV3Aggregator.sol +++ b/test/mocks/MockV3Aggregator.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; +pragma solidity ^0.8.22; import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index 2dfd274..d251746 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -1,9 +1,10 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; +pragma solidity ^0.8.22; //import {Test} from "forge-std/Test.sol"; 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 {GEMxToken} from "../../src/GEMxToken.sol"; import {DeployToken} from "../../script/DeployToken.s.sol"; import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; @@ -14,7 +15,8 @@ contract GEMxTokenTest is Test { MockV3Aggregator private oracle; address admin = address(0x1); address minter = address(0x2); - address user = address(0x3); + address pauser = address(0x3); + address user = address(0x4); address anon = makeAddr("anon"); function setUp() public { @@ -28,6 +30,7 @@ contract GEMxTokenTest is Test { vm.startPrank(DEFAULT_SENDER); token.grantRole(token.DEFAULT_ADMIN_ROLE(), admin); token.grantRole(token.MINTER_ROLE(), minter); + token.grantRole(token.PAUSER_ROLE(), pauser); vm.stopPrank(); } @@ -88,6 +91,19 @@ contract GEMxTokenTest is Test { assertEq(token.balanceOf(user), 9 ether); } + function testOnlyPauserCanPause() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.PAUSER_ROLE()) + ); + vm.prank(user); + token.pause(); + assertEq(token.paused(), false); + + vm.prank(pauser); + token.pause(); + assertEq(token.paused(), true); + } + function testAdminCanGrantRoles() public { address newMinter = address(0x5); @@ -96,6 +112,12 @@ contract GEMxTokenTest is Test { token.grantRole(role, newMinter); assertTrue(token.hasRole(token.MINTER_ROLE(), newMinter)); + + role = token.PAUSER_ROLE(); + vm.prank(admin); + token.grantRole(role, newMinter); + + assertTrue(token.hasRole(token.PAUSER_ROLE(), newMinter)); } function testRevokeRoles() public { @@ -107,4 +129,34 @@ contract GEMxTokenTest is Test { assertFalse(token.hasRole(token.MINTER_ROLE(), minter)); } -} + + function testPause() public { + _setProofOfReserve(1_000 ether); + + vm.prank(minter); + token.mint(user, uint256(10 ether)); + + address receiver = makeAddr("receiver"); + vm.prank(user); + token.transfer(receiver, 1 ether); + assertEq(token.balanceOf(receiver), 1 ether); + + vm.prank(pauser); + token.pause(); + assertEq(token.paused(), true); + + vm.expectRevert( + abi.encodeWithSelector(PausableUpgradeable.EnforcedPause.selector) + ); + token.transfer(receiver, 1 ether); + assertEq(token.balanceOf(receiver), 1 ether); + + vm.prank(pauser); + token.unpause(); + assertEq(token.paused(), false); + + vm.prank(user); + token.transfer(receiver, 1 ether); + assertEq(token.balanceOf(receiver), 2 ether); + } +} \ No newline at end of file From 85ea9d2da627da43541d2f05609403a27fbad2f9 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Fri, 7 Feb 2025 22:41:03 +0100 Subject: [PATCH 09/25] Implement Token freezing --- .gitmodules | 3 + Makefile | 3 +- lib/openzeppelin-community-contracts | 1 + remappings.txt | 3 +- src/ERC20CustodianUpgradeable.sol | 105 +++++++++++++++++++++++++++ src/GEMxToken.sol | 31 ++++++-- test/unit/GEMxTokenTest.t.sol | 71 +++++++++++++++--- 7 files changed, 196 insertions(+), 21 deletions(-) create mode 160000 lib/openzeppelin-community-contracts create mode 100644 src/ERC20CustodianUpgradeable.sol diff --git a/.gitmodules b/.gitmodules index 8d1b421..5624f9f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "lib/chainlink-brownie-contracts"] path = lib/chainlink-brownie-contracts url = https://github.com/smartcontractkit/chainlink-brownie-contracts +[submodule "lib/openzeppelin-community-contracts"] + path = lib/openzeppelin-community-contracts + url = https://github.com/OpenZeppelin/openzeppelin-community-contracts diff --git a/Makefile b/Makefile index 96329a0..fb4fbd2 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,8 @@ install :; forge install foundry-rs/forge-std --no-commit && \ forge install OpenZeppelin/openzeppelin-contracts --no-commit && \ forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit && \ forge install OpenZeppelin/openzeppelin-foundry-upgrades --no-commit && \ - forge install smartcontractkit/chainlink-brownie-contracts --no-commit + forge install smartcontractkit/chainlink-brownie-contracts --no-commit && \ + forge install OpenZeppelin/openzeppelin-community-contracts --no-commit test :; forge test test-vvv :; forge test -vvv test-gasreport :; forge test --gas-report diff --git a/lib/openzeppelin-community-contracts b/lib/openzeppelin-community-contracts new file mode 160000 index 0000000..abc2ff6 --- /dev/null +++ b/lib/openzeppelin-community-contracts @@ -0,0 +1 @@ +Subproject commit abc2ff6ebdd891f39038c82a2e8443296242c582 diff --git a/remappings.txt b/remappings.txt index 6a90951..77295c3 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,4 +1,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/ \ No newline at end of file +@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/src/ +@openzeppelin/community-contracts/=lib/openzeppelin-community-contracts/contracts/ \ No newline at end of file diff --git a/src/ERC20CustodianUpgradeable.sol b/src/ERC20CustodianUpgradeable.sol new file mode 100644 index 0000000..1630a3e --- /dev/null +++ b/src/ERC20CustodianUpgradeable.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; + +/** + * @dev Extension of {ERC20Upgradeable} that allows to implement a custodian + * mechanism that can be managed by an authorized account with the + * {freeze} function. + * + * This mechanism allows a custodian (e.g. a DAO or a + * well-configured multisig) to freeze and unfreeze the balance + * of a user. + * + * 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. + */ +abstract contract ERC20CustodianUpgradeable is ERC20Upgradeable { + /** + * @dev The amount of tokens frozen by user address. + */ + mapping(address user => uint256 amount) private _frozen; + + /** + * @dev Emitted when tokens are frozen for a user. + * @param user The address of the user whose tokens were frozen. + * @param amount The amount of tokens that were frozen. + */ + event TokensFrozen(address indexed user, uint256 amount); + + /** + * @dev Emitted when tokens are unfrozen for a user. + * @param user The address of the user whose tokens were unfrozen. + * @param amount The amount of tokens that were unfrozen. + */ + event TokensUnfrozen(address indexed user, uint256 amount); + + /** + * @dev The operation failed because the user has insufficient unfrozen balance. + */ + error ERC20InsufficientUnfrozenBalance(address user); + + /** + * @dev The operation failed because the user has insufficient frozen balance. + */ + error ERC20InsufficientFrozenBalance(address user); + + /** + * @dev Error thrown when a non-custodian account attempts to perform a custodian-only operation. + */ + error ERC20NotCustodian(); + + /** + * @dev Modifier to restrict access to custodian accounts only. + */ + modifier onlyCustodian() { + if (!_isCustodian(_msgSender())) revert ERC20NotCustodian(); + _; + } + + /** + * @dev Returns the amount of tokens frozen for a user. + */ + function frozen(address user) public view virtual returns (uint256) { + return _frozen[user]; + } + + /** + * @dev Adjusts the amount of tokens frozen for a user. + * @param user The address of the user whose tokens to freeze. + * @param amount The amount of tokens frozen. + * + * Requirements: + * + * - The user must have sufficient unfrozen balance. + */ + function freeze(address user, uint256 amount) external virtual onlyCustodian { + if (availableBalance(user) < amount) revert ERC20InsufficientUnfrozenBalance(user); + _frozen[user] = amount; + emit TokensFrozen(user, amount); + } + + /** + * @dev Returns the available (unfrozen) balance of an account. + * @param account The address to query the available balance of. + * @return available The amount of tokens available for transfer. + */ + function availableBalance(address account) public view returns (uint256 available) { + available = balanceOf(account) - frozen(account); + } + + /** + * @dev Checks if the user is a custodian. + * @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 _update(address from, address to, uint256 value) internal virtual override { + if (from != address(0) && availableBalance(from) < value) revert ERC20InsufficientUnfrozenBalance(from); + super._update(from, to, value); + } +} diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index 86c3dc2..77a10a5 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -26,18 +26,28 @@ pragma solidity ^0.8.22; import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; -import {ERC20BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; -import {ERC20PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol"; -import {ERC20PermitUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; +import {ERC20BurnableUpgradeable} from + "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import {ERC20PausableUpgradeable} from + "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol"; +import {ERC20PermitUpgradeable} from + "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; +//import {ERC20Custodian} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC20Custodian.sol"; +import {ERC20CustodianUpgradeable} from "./ERC20CustodianUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; -contract GEMxToken is - ERC20Upgradeable, ERC20BurnableUpgradeable, ERC20PausableUpgradeable, AccessControlUpgradeable +contract GEMxToken is + ERC20Upgradeable, + ERC20BurnableUpgradeable, + ERC20PausableUpgradeable, + AccessControlUpgradeable, + ERC20CustodianUpgradeable { bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); - + bytes32 public constant CUSTODIAN_ROLE = keccak256("CUSTODIAN_ROLE"); + /// @custom:oz-upgrades-unsafe-allow constructor // constructor() { // _disableInitializers(); @@ -67,6 +77,10 @@ contract GEMxToken is _unpause(); } + function _isCustodian(address user) internal view override returns (bool) { + return hasRole(CUSTODIAN_ROLE, user); + } + function mint(address account, uint256 value) external onlyRole(MINTER_ROLE) { _mint(account, value); } @@ -79,7 +93,10 @@ contract GEMxToken is return address(oracle); } - function _update(address from, address to, uint256 amount) internal override(ERC20Upgradeable, ERC20PausableUpgradeable) { + function _update(address from, address to, uint256 amount) + internal + override(ERC20Upgradeable, ERC20PausableUpgradeable, ERC20CustodianUpgradeable) + { // make sure it cannot be minted more than proof of reserve! if (from == address(0) && totalSupply() + amount > _getProofOfReserve()) { revert NotEnoughReserve(); diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index d251746..3a5af01 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -4,7 +4,9 @@ pragma solidity ^0.8.22; //import {Test} from "forge-std/Test.sol"; 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 {PausableUpgradeable} from + "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol"; +import {ERC20CustodianUpgradeable} from "../../src/ERC20CustodianUpgradeable.sol"; import {GEMxToken} from "../../src/GEMxToken.sol"; import {DeployToken} from "../../script/DeployToken.s.sol"; import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; @@ -16,7 +18,8 @@ contract GEMxTokenTest is Test { address admin = address(0x1); address minter = address(0x2); address pauser = address(0x3); - address user = address(0x4); + address custodian = address(0x4); + address user = address(0x5); address anon = makeAddr("anon"); function setUp() public { @@ -31,9 +34,12 @@ contract GEMxTokenTest 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); 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 _setProofOfReserve(int256 value) private { oracle.updateAnswer(value); } @@ -104,6 +110,44 @@ contract GEMxTokenTest is Test { assertEq(token.paused(), true); } + // TODO: split into separate tests once modifier with test setup is implemented + function testOnlyCustodianCanFreezeAndUnfreeze() public { + _setProofOfReserve(1_000 ether); + + vm.prank(minter); + token.mint(user, uint256(10 ether)); + + // freeze not allowed + vm.expectRevert(ERC20CustodianUpgradeable.ERC20NotCustodian.selector); + vm.prank(anon); + token.freeze(user, 1 ether); + assertEq(token.frozen(user), 0); + assertEq(token.availableBalance(user), 10 ether); + + // freeze allowed + vm.expectEmit(); + emit ERC20CustodianUpgradeable.TokensFrozen(user, 1 ether); + vm.prank(custodian); + 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.prank(anon); + token.freeze(user, 0 ether); + assertEq(token.frozen(user), 1 ether); + assertEq(token.availableBalance(user), 9 ether); + + // unfreeze allowed + vm.expectEmit(); + emit ERC20CustodianUpgradeable.TokensFrozen(user, 0); + vm.prank(custodian); + token.freeze(user, 0 ether); + assertEq(token.frozen(user), 0); + assertEq(token.availableBalance(user), 10 ether); + } + function testAdminCanGrantRoles() public { address newMinter = address(0x5); @@ -121,13 +165,17 @@ contract GEMxTokenTest is Test { } function testRevokeRoles() public { - assertTrue(token.hasRole(token.MINTER_ROLE(), minter)); - - bytes32 role = token.MINTER_ROLE(); + bytes32 minteRole = token.MINTER_ROLE(); + assertTrue(token.hasRole(minteRole, minter)); vm.prank(admin); - token.revokeRole(role, minter); + token.revokeRole(minteRole, minter); + assertFalse(token.hasRole(minteRole, minter)); - assertFalse(token.hasRole(token.MINTER_ROLE(), minter)); + bytes32 pauserRole = token.PAUSER_ROLE(); + assertTrue(token.hasRole(pauserRole, pauser)); + vm.prank(admin); + token.revokeRole(pauserRole, pauser); + assertFalse(token.hasRole(pauserRole, pauser)); } function testPause() public { @@ -145,18 +193,17 @@ contract GEMxTokenTest is Test { token.pause(); assertEq(token.paused(), true); - vm.expectRevert( - abi.encodeWithSelector(PausableUpgradeable.EnforcedPause.selector) - ); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + vm.prank(user); token.transfer(receiver, 1 ether); assertEq(token.balanceOf(receiver), 1 ether); vm.prank(pauser); token.unpause(); assertEq(token.paused(), false); - + vm.prank(user); token.transfer(receiver, 1 ether); assertEq(token.balanceOf(receiver), 2 ether); } -} \ No newline at end of file +} From 57d9a2dfe06f936e554d9046ddc935b68d6de273 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Fri, 7 Feb 2025 23:38:45 +0100 Subject: [PATCH 10/25] User blocking --- src/ERC20BlocklistUpgradeable.sol | 87 +++++++++++++ src/GEMxToken.sol | 43 +++++-- test/unit/GEMxTokenTest.t.sol | 203 +++++++++++++++++++++++++----- 3 files changed, 289 insertions(+), 44 deletions(-) create mode 100644 src/ERC20BlocklistUpgradeable.sol diff --git a/src/ERC20BlocklistUpgradeable.sol b/src/ERC20BlocklistUpgradeable.sol new file mode 100644 index 0000000..c5f0488 --- /dev/null +++ b/src/ERC20BlocklistUpgradeable.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; + +/** + * @dev Extension of {ERC20Upgradeable} that allows to implement a blocklist + * mechanism that can be managed by an authorized account with the + * {_blockUser} and {_unblockUser} functions. + * + * The blocklist provides the guarantee to the contract owner + * (e.g. a DAO or a well-configured multisig) that any account won't be + * able to execute transfers or approvals to other entities to operate + * on its behalf if {_blockUser} was not called with such account as an + * argument. Similarly, the account will be unblocked again if + * {_unblockUser} is called. + */ +abstract contract ERC20BlocklistUpgradeable is ERC20Upgradeable { + /** + * @dev Blocked status of addresses. True if blocked, False otherwise. + */ + mapping(address user => bool) private _blocked; + + /** + * @dev Emitted when a user is blocked. + */ + event UserBlocked(address indexed user); + + /** + * @dev Emitted when a user is unblocked. + */ + event UserUnblocked(address indexed user); + + /** + * @dev The operation failed because the user is blocked. + */ + error ERC20Blocked(address user); + + /** + * @dev Returns the blocked status of an account. + */ + function blocked(address account) public virtual returns (bool) { + return _blocked[account]; + } + + /** + * @dev Blocks a user from receiving and transferring tokens, including minting and burning. + */ + function _blockUser(address user) internal virtual returns (bool) { + bool isBlocked = blocked(user); + if (!isBlocked) { + _blocked[user] = true; + emit UserBlocked(user); + } + return isBlocked; + } + + /** + * @dev Unblocks a user from receiving and transferring tokens, including minting and burning. + */ + function _unblockUser(address user) internal virtual returns (bool) { + bool isBlocked = blocked(user); + if (isBlocked) { + _blocked[user] = false; + emit UserUnblocked(user); + } + return isBlocked; + } + + /** + * @dev See {ERC20-_update}. + */ + function _update(address from, address to, uint256 value) internal virtual override { + if (blocked(from)) revert ERC20Blocked(from); + if (blocked(to)) revert ERC20Blocked(to); + super._update(from, to, value); + } + + /** + * @dev See {ERC20-_approve}. + */ + function _approve(address owner, address spender, uint256 value, bool emitEvent) internal virtual override { + if (blocked(owner)) revert ERC20Blocked(owner); + super._approve(owner, spender, value, emitEvent); + } +} \ No newline at end of file diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index 77a10a5..729128b 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -32,8 +32,8 @@ import {ERC20PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol"; import {ERC20PermitUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; -//import {ERC20Custodian} from "@openzeppelin/community-contracts/token/ERC20/extensions/ERC20Custodian.sol"; import {ERC20CustodianUpgradeable} from "./ERC20CustodianUpgradeable.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"; @@ -42,11 +42,13 @@ contract GEMxToken is ERC20BurnableUpgradeable, ERC20PausableUpgradeable, AccessControlUpgradeable, - ERC20CustodianUpgradeable + ERC20CustodianUpgradeable, + ERC20BlocklistUpgradeable { - bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); - bytes32 public constant CUSTODIAN_ROLE = keccak256("CUSTODIAN_ROLE"); + 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 LIMITER_ROLE = keccak256("LIMITER_ROLE"); // block/unblock user /// @custom:oz-upgrades-unsafe-allow constructor // constructor() { @@ -69,6 +71,14 @@ contract GEMxToken is oracle = AggregatorV3Interface(oracleAddres); } + function mint(address account, uint256 value) external onlyRole(MINTER_ROLE) { + _mint(account, value); + } + + function burn(address account, uint256 value) external onlyRole(MINTER_ROLE) { + _burn(account, value); + } + function pause() public onlyRole(PAUSER_ROLE) { _pause(); } @@ -77,25 +87,25 @@ contract GEMxToken is _unpause(); } - function _isCustodian(address user) internal view override returns (bool) { - return hasRole(CUSTODIAN_ROLE, user); - } - - function mint(address account, uint256 value) external onlyRole(MINTER_ROLE) { - _mint(account, value); + function blockUser(address user) public onlyRole(LIMITER_ROLE) { + _blockUser(user); } - function burn(address account, uint256 value) external onlyRole(MINTER_ROLE) { - _burn(account, value); + function unblockUser(address user) public onlyRole(LIMITER_ROLE) { + _unblockUser(user); } function getOracleAddress() public returns (address) { return address(oracle); } + function _isCustodian(address user) internal view override returns (bool) { + return hasRole(CUSTODIAN_ROLE, user); + } + function _update(address from, address to, uint256 amount) internal - override(ERC20Upgradeable, ERC20PausableUpgradeable, ERC20CustodianUpgradeable) + override(ERC20Upgradeable, ERC20PausableUpgradeable, ERC20CustodianUpgradeable, ERC20BlocklistUpgradeable) { // make sure it cannot be minted more than proof of reserve! if (from == address(0) && totalSupply() + amount > _getProofOfReserve()) { @@ -105,6 +115,13 @@ contract GEMxToken is super._update(from, to, amount); } + function _approve(address owner, address spender, uint256 value, bool emitEvent) + internal + override(ERC20Upgradeable, ERC20BlocklistUpgradeable) + { + super._approve(owner, spender, value, emitEvent); + } + function _getProofOfReserve() private view returns (uint256) { (, int256 answer,,,) = oracle.latestRoundData(); diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index 3a5af01..cb421ef 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -7,6 +7,7 @@ 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 {ERC20BlocklistUpgradeable} from "../../src/ERC20BlocklistUpgradeable.sol"; import {GEMxToken} from "../../src/GEMxToken.sol"; import {DeployToken} from "../../script/DeployToken.s.sol"; import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; @@ -19,7 +20,8 @@ contract GEMxTokenTest is Test { address minter = address(0x2); address pauser = address(0x3); address custodian = address(0x4); - address user = address(0x5); + address limiter = address(0x5); + address user = address(0x6); address anon = makeAddr("anon"); function setUp() public { @@ -35,6 +37,7 @@ contract GEMxTokenTest is Test { token.grantRole(token.MINTER_ROLE(), minter); token.grantRole(token.PAUSER_ROLE(), pauser); token.grantRole(token.CUSTODIAN_ROLE(), custodian); + token.grantRole(token.LIMITER_ROLE(), limiter); vm.stopPrank(); } @@ -62,6 +65,10 @@ contract GEMxTokenTest is Test { assertEq(token.balanceOf(user), 1_000 ether); } + /**********************************************************************************/ + /********************************** MINT/BURN ***********************************/ + /**********************************************************************************/ + function testOnlyMinterCanMint() public { _setProofOfReserve(1_000 ether); @@ -97,6 +104,10 @@ contract GEMxTokenTest is Test { assertEq(token.balanceOf(user), 9 ether); } + /**********************************************************************************/ + /******************************** PAUSE/UNPAUSE *********************************/ + /**********************************************************************************/ + function testOnlyPauserCanPause() public { vm.expectRevert( abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.PAUSER_ROLE()) @@ -110,6 +121,57 @@ contract GEMxTokenTest is Test { assertEq(token.paused(), true); } + function testOnlyPauserCanUnpause() public { + vm.prank(pauser); + token.pause(); + assertEq(token.paused(), true); + + // ACT + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.PAUSER_ROLE()) + ); + vm.prank(user); + token.pause(); + assertEq(token.paused(), true); + + vm.prank(pauser); + token.unpause(); + assertEq(token.paused(), false); + } + + function testTransferWhenPauseUnpause() public { + _setProofOfReserve(1_000 ether); + + vm.prank(minter); + token.mint(user, uint256(10 ether)); + + address receiver = makeAddr("receiver"); + vm.prank(user); + token.transfer(receiver, 1 ether); + assertEq(token.balanceOf(receiver), 1 ether); + + vm.prank(pauser); + token.pause(); + assertEq(token.paused(), true); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + vm.prank(user); + token.transfer(receiver, 1 ether); + assertEq(token.balanceOf(receiver), 1 ether); + + vm.prank(pauser); + token.unpause(); + assertEq(token.paused(), false); + + vm.prank(user); + token.transfer(receiver, 1 ether); + assertEq(token.balanceOf(receiver), 2 ether); + } + + /**********************************************************************************/ + /******************************* FREEZE/UNFREEZE ********************************/ + /**********************************************************************************/ + // TODO: split into separate tests once modifier with test setup is implemented function testOnlyCustodianCanFreezeAndUnfreeze() public { _setProofOfReserve(1_000 ether); @@ -148,23 +210,119 @@ contract GEMxTokenTest is Test { assertEq(token.availableBalance(user), 10 ether); } + /**********************************************************************************/ + /******************************** BLOCK/UNBLOCK *********************************/ + /**********************************************************************************/ + + function testOnlyLimiterCanBlockUser() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.LIMITER_ROLE()) + ); + vm.prank(user); + token.blockUser(anon); + assertEq(token.blocked(anon), false); + + vm.prank(limiter); + token.blockUser(anon); + assertEq(token.blocked(anon), true); + } + + function testOnlyPauserCanUnblockUser() public { + vm.prank(limiter); + token.blockUser(anon); + assertEq(token.blocked(anon), true); + + // ACT + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.LIMITER_ROLE()) + ); + vm.prank(user); + token.unblockUser(anon); + assertEq(token.blocked(anon), true); + + vm.prank(limiter); + token.unblockUser(anon); + assertEq(token.blocked(anon), false); + } + + function testTransferWhenUserBlocked() public { + _setProofOfReserve(1_000 ether); + + vm.prank(minter); + token.mint(user, uint256(10 ether)); + + // 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); + + vm.prank(limiter); + token.blockUser(receiver); + assertEq(token.blocked(receiver), true); + + // neither sending nor receiving should work, basically receiver should keep 1 as initially sent! + + // 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"); + + // 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"); + + vm.prank(limiter); + token.unblockUser(receiver); + assertEq(token.blocked(receiver), false); + + // receiving should work again + vm.prank(user); + token.transfer(receiver, 1 ether); + assertEq(token.balanceOf(receiver), 2 ether); + + // sending should work again + vm.prank(receiver); + token.transfer(user, 1 ether); + assertEq(token.balanceOf(receiver), 1 ether); + } + + /**********************************************************************************/ + /************************************* RBAC *************************************/ + /**********************************************************************************/ + function testAdminCanGrantRoles() public { address newMinter = address(0x5); bytes32 role = token.MINTER_ROLE(); vm.prank(admin); token.grantRole(role, newMinter); - assertTrue(token.hasRole(token.MINTER_ROLE(), newMinter)); role = token.PAUSER_ROLE(); vm.prank(admin); token.grantRole(role, newMinter); - assertTrue(token.hasRole(token.PAUSER_ROLE(), newMinter)); + + role = token.CUSTODIAN_ROLE(); + vm.prank(admin); + token.grantRole(role, newMinter); + assertTrue(token.hasRole(token.CUSTODIAN_ROLE(), newMinter)); + + role = token.LIMITER_ROLE(); + vm.prank(admin); + token.grantRole(role, newMinter); + assertTrue(token.hasRole(token.LIMITER_ROLE(), newMinter)); } - function testRevokeRoles() public { + function testAdminCanRevoketRoles() public { bytes32 minteRole = token.MINTER_ROLE(); assertTrue(token.hasRole(minteRole, minter)); vm.prank(admin); @@ -176,34 +334,17 @@ contract GEMxTokenTest is Test { vm.prank(admin); token.revokeRole(pauserRole, pauser); assertFalse(token.hasRole(pauserRole, pauser)); - } - - function testPause() public { - _setProofOfReserve(1_000 ether); - - vm.prank(minter); - token.mint(user, uint256(10 ether)); - - address receiver = makeAddr("receiver"); - vm.prank(user); - token.transfer(receiver, 1 ether); - assertEq(token.balanceOf(receiver), 1 ether); - vm.prank(pauser); - token.pause(); - assertEq(token.paused(), true); - - vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - vm.prank(user); - token.transfer(receiver, 1 ether); - assertEq(token.balanceOf(receiver), 1 ether); - - vm.prank(pauser); - token.unpause(); - assertEq(token.paused(), false); + bytes32 custodianRole = token.CUSTODIAN_ROLE(); + assertTrue(token.hasRole(custodianRole, custodian)); + vm.prank(admin); + token.revokeRole(custodianRole, custodian); + assertFalse(token.hasRole(custodianRole, custodian)); - vm.prank(user); - token.transfer(receiver, 1 ether); - assertEq(token.balanceOf(receiver), 2 ether); + bytes32 limiterRole = token.LIMITER_ROLE(); + assertTrue(token.hasRole(limiterRole, limiter)); + vm.prank(admin); + token.revokeRole(limiterRole, limiter); + assertFalse(token.hasRole(limiterRole, limiter)); } } From 1f2b52bc0c777031e4ae7473f0958ade82ef42af Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Fri, 7 Feb 2025 23:54:00 +0100 Subject: [PATCH 11/25] Exlude script and test sol files from coverage --- Makefile | 2 +- test/unit/GEMxTokenTest.t.sol | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index fb4fbd2..5a27113 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ test :; forge test test-vvv :; forge test -vvv test-gasreport :; forge test --gas-report test-fork :; forge test --fork-url ${ETH_RPC_URL} -vvv -coverage :; mkdir -p ./coverage && forge coverage --report lcov --report-file coverage/lcov.info && genhtml coverage/lcov.info -o coverage --branch-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 format :; forge fmt anvil :; anvil -m 'test test test test test test test test test test test junk' --steps-tracing --block-time 1 diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index cb421ef..18e8f73 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -294,6 +294,33 @@ contract GEMxTokenTest is Test { assertEq(token.balanceOf(receiver), 1 ether); } + function testErc20ApproveWhenUserBlocked() public { + _setProofOfReserve(1_000 ether); + + vm.prank(minter); + token.mint(user, uint256(10 ether)); + + vm.prank(limiter); + token.blockUser(user); + assertEq(token.blocked(user), true); + + // 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); + assertEq(token.allowance(user, anon), 0); + + vm.prank(limiter); + token.unblockUser(user); + assertEq(token.blocked(user), false); + + vm.prank(user); + token.approve(anon, 1 ether); + assertEq(token.allowance(user, anon), 1 ether); + } + /**********************************************************************************/ /************************************* RBAC *************************************/ /**********************************************************************************/ From 5b339d2dcef8ccb3b47f75a1d77ac369c15839c8 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Mon, 10 Feb 2025 11:26:22 +0100 Subject: [PATCH 12/25] Enable solhint & slither --- .gitignore | 1 + .solhint.json | 8 ++++++++ src/GEMxToken.sol | 4 ++-- 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 .solhint.json diff --git a/.gitignore b/.gitignore index 4f0df3a..3eb67c3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ docs/ # Dotenv file .env /coverage +src/artifacts/* diff --git a/.solhint.json b/.solhint.json new file mode 100644 index 0000000..001bdb5 --- /dev/null +++ b/.solhint.json @@ -0,0 +1,8 @@ +{ + "extends": "solhint:recommended", + "plugins": [], + "rules": { + "avoid-suicide": "error", + "compiler-version": ["error", "^0.8.22"] + } + } \ No newline at end of file diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index 729128b..d7e9c95 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -55,7 +55,7 @@ contract GEMxToken is // _disableInitializers(); // } - AggregatorV3Interface oracle; + AggregatorV3Interface private oracle; error NotEnoughReserve(); @@ -95,7 +95,7 @@ contract GEMxToken is _unblockUser(user); } - function getOracleAddress() public returns (address) { + function getOracleAddress() public view returns (address) { return address(oracle); } From c6ff25625fbe9a2392b5ad5490311a0b63f22fbe Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Mon, 10 Feb 2025 16:11:03 +0100 Subject: [PATCH 13/25] slither integration --- .solhint.json | 2 +- Makefile | 5 ++-- foundry.toml | 1 + script/DeployToken.s.sol | 2 +- script/HelperConfig.s.sol | 6 ++--- slither.config.json | 16 +++++++++++++ src/GEMxToken.sol | 4 ++-- test/mocks/MockV3Aggregator.sol | 2 +- test/unit/GEMxTokenTest.t.sol | 42 ++++++++++++++++++++++++++------- 9 files changed, 62 insertions(+), 18 deletions(-) create mode 100644 slither.config.json diff --git a/.solhint.json b/.solhint.json index 001bdb5..43f914b 100644 --- a/.solhint.json +++ b/.solhint.json @@ -3,6 +3,6 @@ "plugins": [], "rules": { "avoid-suicide": "error", - "compiler-version": ["error", "^0.8.22"] + "compiler-version": ["error", "0.8.20"] } } \ No newline at end of file diff --git a/Makefile b/Makefile index 5a27113..49ee0bc 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # (-include to ignore error if it does not exist) -include .env -.PHONY: all test clean deploy fund help install coverage snapshot format anvil +.PHONY: all test clean deploy fund help install coverage snapshot format anvil slither DEFAULT_ANVIL_KEY := 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 @@ -32,7 +32,8 @@ snapshot :; forge snapshot format :; forge fmt anvil :; anvil -m 'test test test test test test test test test test test junk' --steps-tracing --block-time 1 fork :; anvil --fork-url ${FORK_ETH_RPC_URL} --fork-block-number ${FORK_BLOCK_NUMBER} -watch :; forge test --watch src/ +watch :; forge test --watch src/ +slither :; slither src/GEMxToken.sol --triage-mode NETWORK_ARGS := --rpc-url http://localhost:8545 --private-key $(DEFAULT_ANVIL_KEY) --broadcast diff --git a/foundry.toml b/foundry.toml index bc09100..6fa30fe 100644 --- a/foundry.toml +++ b/foundry.toml @@ -8,5 +8,6 @@ ffi = true ast = true build_info = true extra_output = ["storageLayout"] +evm_version = "paris" # required for slither to work # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/script/DeployToken.s.sol b/script/DeployToken.s.sol index b28b745..b3b1c00 100644 --- a/script/DeployToken.s.sol +++ b/script/DeployToken.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; +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"; diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index 681515c..c4f1a26 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.19; +pragma solidity 0.8.20; import {MockV3Aggregator} from "../test/mocks/MockV3Aggregator.sol"; import {Script} from "forge-std/Script.sol"; @@ -26,11 +26,11 @@ contract HelperConfig is Script { } } - function getSepoliaEthConfig() public view returns (NetworkConfig memory sepoliaNetworkConfig) { + function getSepoliaEthConfig() public pure returns (NetworkConfig memory sepoliaNetworkConfig) { sepoliaNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); } - function getFujiEthConfig() public view returns (NetworkConfig memory fujiNetworkConfig) { + function getFujiEthConfig() public pure returns (NetworkConfig memory fujiNetworkConfig) { fujiNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); } diff --git a/slither.config.json b/slither.config.json new file mode 100644 index 0000000..85503aa --- /dev/null +++ b/slither.config.json @@ -0,0 +1,16 @@ +{ + "detectors_to_run": "all", + "include_paths": "src", + "filter_paths": "src", + "exclude_dependencies": true, + "exclude_informational": false, + "exclude_optimization": false, + "exclude_low": false, + "exclude_medium": false, + "exclude_high": false, + "sarif": true, + "disable_color": false, + "skip_assembly": false, + "show_ignored_findings": false, + "triage_database": "slither.db.json" +} \ No newline at end of file diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index d7e9c95..2810f93 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -22,7 +22,7 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.22; +pragma solidity ^0.8.20; import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; @@ -123,7 +123,7 @@ contract GEMxToken is } function _getProofOfReserve() private view returns (uint256) { - (, int256 answer,,,) = oracle.latestRoundData(); + (, int256 answer, , ,) = oracle.latestRoundData(); return uint256(answer); } diff --git a/test/mocks/MockV3Aggregator.sol b/test/mocks/MockV3Aggregator.sol index 4d27ce0..4a4fbd3 100644 --- a/test/mocks/MockV3Aggregator.sol +++ b/test/mocks/MockV3Aggregator.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.22; +pragma solidity 0.8.20; import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index 18e8f73..d194b02 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -1,7 +1,6 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.22; +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; -//import {Test} from "forge-std/Test.sol"; import {Test, console} from "lib/forge-std/src/Test.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {PausableUpgradeable} from @@ -187,8 +186,8 @@ contract GEMxTokenTest is Test { assertEq(token.availableBalance(user), 10 ether); // freeze allowed - vm.expectEmit(); - emit ERC20CustodianUpgradeable.TokensFrozen(user, 1 ether); + //vm.expectEmit(); + //emit ERC20CustodianUpgradeable.TokensFrozen(user, 1 ether); vm.prank(custodian); token.freeze(user, 1 ether); assertEq(token.frozen(user), 1 ether); @@ -202,14 +201,41 @@ contract GEMxTokenTest is Test { assertEq(token.availableBalance(user), 9 ether); // unfreeze allowed - vm.expectEmit(); - emit ERC20CustodianUpgradeable.TokensFrozen(user, 0); + //vm.expectEmit(); + //emit ERC20CustodianUpgradeable.TokensFrozen(user, 0); vm.prank(custodian); token.freeze(user, 0 ether); assertEq(token.frozen(user), 0); assertEq(token.availableBalance(user), 10 ether); } + function testTransferWhenAmountFrozen() public { + _setProofOfReserve(1_000 ether); + + vm.prank(minter); + token.mint(user, uint256(10 ether)); + + // freeze allowed + vm.prank(custodian); + 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) + ); + vm.prank(user); + token.transfer(anon, 3 ether); + + // try to transfer with available balance left -> should work + vm.prank(user); + token.transfer(anon, 2 ether); + + assertEq(token.availableBalance(user), 0 ether); + assertEq(token.availableBalance(anon), 2 ether); + } + /**********************************************************************************/ /******************************** BLOCK/UNBLOCK *********************************/ /**********************************************************************************/ @@ -227,7 +253,7 @@ contract GEMxTokenTest is Test { assertEq(token.blocked(anon), true); } - function testOnlyPauserCanUnblockUser() public { + function testOnlyLimiterCanUnblockUser() public { vm.prank(limiter); token.blockUser(anon); assertEq(token.blocked(anon), true); From 7e9a710ef2637fbe1207daedd35cc3ec92d142d6 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Mon, 10 Feb 2025 16:11:56 +0100 Subject: [PATCH 14/25] .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 3eb67c3..0995f09 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,8 @@ docs/ # Dotenv file .env + +# foundry and other code analysis artefacts /coverage src/artifacts/* +remix-slither-report.json From f7c443a7215631c752d9032c3d1e69a8df667a3d Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Wed, 12 Feb 2025 15:49:20 +0100 Subject: [PATCH 15/25] formatting --- src/ERC20BlocklistUpgradeable.sol | 2 +- src/GEMxToken.sol | 8 +++--- test/unit/GEMxTokenTest.t.sol | 46 ++++++++++++++----------------- 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/src/ERC20BlocklistUpgradeable.sol b/src/ERC20BlocklistUpgradeable.sol index c5f0488..145bc6e 100644 --- a/src/ERC20BlocklistUpgradeable.sol +++ b/src/ERC20BlocklistUpgradeable.sol @@ -84,4 +84,4 @@ abstract contract ERC20BlocklistUpgradeable is ERC20Upgradeable { if (blocked(owner)) revert ERC20Blocked(owner); super._approve(owner, spender, value, emitEvent); } -} \ No newline at end of file +} diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index 2810f93..1d69d49 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -46,9 +46,9 @@ contract GEMxToken is ERC20BlocklistUpgradeable { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); - 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 LIMITER_ROLE = keccak256("LIMITER_ROLE"); // block/unblock user + 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 LIMITER_ROLE = keccak256("LIMITER_ROLE"); // block/unblock user /// @custom:oz-upgrades-unsafe-allow constructor // constructor() { @@ -123,7 +123,7 @@ contract GEMxToken is } function _getProofOfReserve() private view returns (uint256) { - (, int256 answer, , ,) = oracle.latestRoundData(); + (, int256 answer,,,) = oracle.latestRoundData(); return uint256(answer); } diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index d194b02..77a4de9 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -64,9 +64,9 @@ contract GEMxTokenTest is Test { assertEq(token.balanceOf(user), 1_000 ether); } - /**********************************************************************************/ - /********************************** MINT/BURN ***********************************/ - /**********************************************************************************/ + /*##################################################################################*/ + /*################################### MINT/BURN ####################################*/ + /*##################################################################################*/ function testOnlyMinterCanMint() public { _setProofOfReserve(1_000 ether); @@ -103,9 +103,9 @@ contract GEMxTokenTest is Test { assertEq(token.balanceOf(user), 9 ether); } - /**********************************************************************************/ - /******************************** PAUSE/UNPAUSE *********************************/ - /**********************************************************************************/ + /*##################################################################################*/ + /*################################# PAUSE/UNPAUSE ##################################*/ + /*##################################################################################*/ function testOnlyPauserCanPause() public { vm.expectRevert( @@ -124,7 +124,7 @@ contract GEMxTokenTest is Test { vm.prank(pauser); token.pause(); assertEq(token.paused(), true); - + // ACT vm.expectRevert( abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.PAUSER_ROLE()) @@ -167,9 +167,9 @@ contract GEMxTokenTest is Test { assertEq(token.balanceOf(receiver), 2 ether); } - /**********************************************************************************/ - /******************************* FREEZE/UNFREEZE ********************************/ - /**********************************************************************************/ + /*##################################################################################*/ + /*################################ FREEZE/UNFREEZE #################################*/ + /*##################################################################################*/ // TODO: split into separate tests once modifier with test setup is implemented function testOnlyCustodianCanFreezeAndUnfreeze() public { @@ -236,9 +236,9 @@ contract GEMxTokenTest is Test { assertEq(token.availableBalance(anon), 2 ether); } - /**********************************************************************************/ - /******************************** BLOCK/UNBLOCK *********************************/ - /**********************************************************************************/ + /*##################################################################################*/ + /*################################# BLOCK/UNBLOCK ##################################*/ + /*##################################################################################*/ function testOnlyLimiterCanBlockUser() public { vm.expectRevert( @@ -257,7 +257,7 @@ contract GEMxTokenTest is Test { vm.prank(limiter); token.blockUser(anon); assertEq(token.blocked(anon), true); - + // ACT vm.expectRevert( abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.LIMITER_ROLE()) @@ -290,17 +290,13 @@ contract GEMxTokenTest is Test { // neither sending nor receiving should work, basically receiver should keep 1 as initially sent! // receiving - vm.expectRevert( - abi.encodeWithSelector(ERC20BlocklistUpgradeable.ERC20Blocked.selector, receiver) - ); + 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"); // sending - vm.expectRevert( - abi.encodeWithSelector(ERC20BlocklistUpgradeable.ERC20Blocked.selector, receiver) - ); + 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"); @@ -331,9 +327,7 @@ contract GEMxTokenTest is Test { assertEq(token.blocked(user), true); // user should not be able approve others in case he is blocked - vm.expectRevert( - abi.encodeWithSelector(ERC20BlocklistUpgradeable.ERC20Blocked.selector, user) - ); + vm.expectRevert(abi.encodeWithSelector(ERC20BlocklistUpgradeable.ERC20Blocked.selector, user)); vm.prank(user); token.approve(anon, 1 ether); assertEq(token.allowance(user, anon), 0); @@ -347,9 +341,9 @@ contract GEMxTokenTest is Test { assertEq(token.allowance(user, anon), 1 ether); } - /**********************************************************************************/ - /************************************* RBAC *************************************/ - /**********************************************************************************/ + /*##################################################################################*/ + /*##################################### RBAC #######################################*/ + /*##################################################################################*/ function testAdminCanGrantRoles() public { address newMinter = address(0x5); From ea496035541a35501389c916f1a6b097cb6f1511 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Wed, 12 Feb 2025 15:57:45 +0100 Subject: [PATCH 16/25] token properties test --- src/GEMxToken.sol | 2 +- test/unit/GEMxTokenTest.t.sol | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index 1d69d49..0d19433 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -60,7 +60,7 @@ contract GEMxToken is error NotEnoughReserve(); function initialize(address oracleAddres) public initializer { - __ERC20_init("GEMxToken", "GEMX"); + __ERC20_init("EmGemX Switzerland", "EmCH"); __ERC20Burnable_init(); __ERC20Pausable_init(); __AccessControl_init(); diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index 77a4de9..f91829a 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -46,6 +46,12 @@ contract GEMxTokenTest is Test { oracle.updateAnswer(value); } + function testTokenProperties() public { + assertEq(token.name(), "EmGemX Switzerland"); + assertEq(token.symbol(), "EmCH"); + assertEq(token.decimals(), 18); + } + function testMintRespectsProofOfReserve() public { int256 reserve = 1_000 ether; _setProofOfReserve(reserve); From b2a1e6dd5f2223e7d4290846739885ef6b69b537 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Wed, 12 Feb 2025 17:22:59 +0100 Subject: [PATCH 17/25] implement ESU draft --- src/GEMxToken.sol | 22 +++++++++++--- test/unit/GEMxTokenTest.t.sol | 57 +++++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index 0d19433..bfe6853 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -46,6 +46,7 @@ contract GEMxToken is ERC20BlocklistUpgradeable { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 public constant ESU_ROLE = keccak256("ESU_ROLE"); // allowed to update esu value 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 LIMITER_ROLE = keccak256("LIMITER_ROLE"); // block/unblock user @@ -55,6 +56,8 @@ contract GEMxToken is // _disableInitializers(); // } + uint256 private esuValue = 1; + uint256 private esuPrecision = 100; AggregatorV3Interface private oracle; error NotEnoughReserve(); @@ -79,26 +82,35 @@ contract GEMxToken is _burn(account, value); } - function pause() public onlyRole(PAUSER_ROLE) { + function pause() external onlyRole(PAUSER_ROLE) { _pause(); } - function unpause() public onlyRole(PAUSER_ROLE) { + function unpause() external onlyRole(PAUSER_ROLE) { _unpause(); } - function blockUser(address user) public onlyRole(LIMITER_ROLE) { + function blockUser(address user) external onlyRole(LIMITER_ROLE) { _blockUser(user); } - function unblockUser(address user) public onlyRole(LIMITER_ROLE) { + function unblockUser(address user) external onlyRole(LIMITER_ROLE) { _unblockUser(user); } - function getOracleAddress() public view returns (address) { + function getOracleAddress() external view returns (address) { return address(oracle); } + function getEsu() external view returns (uint256, uint256) { + return (esuValue, esuPrecision); + } + + function setEsu(uint256 esu, uint256 precision) external onlyRole(ESU_ROLE) { + esuValue = esu; + esuPrecision = precision; + } + function _isCustodian(address user) internal view override returns (bool) { return hasRole(CUSTODIAN_ROLE, user); } diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index f91829a..a14ffbb 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -20,7 +20,8 @@ contract GEMxTokenTest is Test { address pauser = address(0x3); address custodian = address(0x4); address limiter = address(0x5); - address user = address(0x6); + address esuUpdater = address(0x6); + address user = makeAddr("user"); address anon = makeAddr("anon"); function setUp() public { @@ -37,6 +38,7 @@ contract GEMxTokenTest is Test { token.grantRole(token.PAUSER_ROLE(), pauser); token.grantRole(token.CUSTODIAN_ROLE(), custodian); token.grantRole(token.LIMITER_ROLE(), limiter); + token.grantRole(token.ESU_ROLE(), esuUpdater); vm.stopPrank(); } @@ -46,10 +48,13 @@ contract GEMxTokenTest is Test { oracle.updateAnswer(value); } - function testTokenProperties() public { + function testTokenProperties() public view { assertEq(token.name(), "EmGemX Switzerland"); assertEq(token.symbol(), "EmCH"); assertEq(token.decimals(), 18); + (uint256 esu, uint256 esuPrecision) = token.getEsu(); + assertEq(esu, 1); + assertEq(esuPrecision, 100); } function testMintRespectsProofOfReserve() public { @@ -70,6 +75,35 @@ contract GEMxTokenTest is Test { assertEq(token.balanceOf(user), 1_000 ether); } + /*##################################################################################*/ + /*###################################### ESU #######################################*/ + /*##################################################################################*/ + + function testOnlyEsuUpdaterCanUpdateEsuValue() public { + (uint256 esu, uint256 esuPrecision) = token.getEsu(); + assertEq(esu, 1); + assertEq(esuPrecision, 100); + + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.ESU_ROLE()) + ); + vm.prank(user); + token.setEsu(9, 1000); + + // values should not have changed + (esu, esuPrecision) = token.getEsu(); + assertEq(esu, 1); + assertEq(esuPrecision, 100); + + // ACT + vm.prank(esuUpdater); + token.setEsu(9, 1000); + + (esu, esuPrecision) = token.getEsu(); + assertEq(esu, 9); + assertEq(esuPrecision, 1000); + } + /*##################################################################################*/ /*################################### MINT/BURN ####################################*/ /*##################################################################################*/ @@ -357,22 +391,27 @@ contract GEMxTokenTest is Test { bytes32 role = token.MINTER_ROLE(); vm.prank(admin); token.grantRole(role, newMinter); - assertTrue(token.hasRole(token.MINTER_ROLE(), newMinter)); + assertTrue(token.hasRole(role, newMinter)); role = token.PAUSER_ROLE(); vm.prank(admin); token.grantRole(role, newMinter); - assertTrue(token.hasRole(token.PAUSER_ROLE(), newMinter)); + assertTrue(token.hasRole(role, newMinter)); role = token.CUSTODIAN_ROLE(); vm.prank(admin); token.grantRole(role, newMinter); - assertTrue(token.hasRole(token.CUSTODIAN_ROLE(), newMinter)); + assertTrue(token.hasRole(role, newMinter)); role = token.LIMITER_ROLE(); vm.prank(admin); token.grantRole(role, newMinter); - assertTrue(token.hasRole(token.LIMITER_ROLE(), newMinter)); + assertTrue(token.hasRole(role, newMinter)); + + role = token.ESU_ROLE(); + vm.prank(admin); + token.grantRole(role, newMinter); + assertTrue(token.hasRole(role, newMinter)); } function testAdminCanRevoketRoles() public { @@ -399,5 +438,11 @@ contract GEMxTokenTest is Test { vm.prank(admin); token.revokeRole(limiterRole, limiter); assertFalse(token.hasRole(limiterRole, limiter)); + + bytes32 esuUpdateRole = token.ESU_ROLE(); + assertTrue(token.hasRole(esuUpdateRole, esuUpdater)); + vm.prank(admin); + token.revokeRole(esuUpdateRole, esuUpdater); + assertFalse(token.hasRole(esuUpdateRole, esuUpdater)); } } From 5304ff0aa4f16e50fddf98ea5b515b3fc8939138 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Mon, 17 Feb 2025 14:56:40 +0100 Subject: [PATCH 18/25] formatting --- src/GEMxToken.sol | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index bfe6853..899c055 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -135,7 +135,13 @@ contract GEMxToken is } function _getProofOfReserve() private view returns (uint256) { - (, int256 answer,,,) = oracle.latestRoundData(); + ( + /* uint80 roundID */, + int answer, + /*uint startedAt*/, + /*uint timeStamp*/, + /*uint80 answeredInRound*/ + ) = oracle.latestRoundData(); return uint256(answer); } From 8cca95faa932095aa3a9a1278ce067d7c654985f Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Tue, 18 Feb 2025 16:12:32 +0100 Subject: [PATCH 19/25] fix typo --- test/unit/GEMxTokenTest.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index a14ffbb..ded8f44 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -414,7 +414,7 @@ contract GEMxTokenTest is Test { assertTrue(token.hasRole(role, newMinter)); } - function testAdminCanRevoketRoles() public { + function testAdminCanRevokeRoles() public { bytes32 minteRole = token.MINTER_ROLE(); assertTrue(token.hasRole(minteRole, minter)); vm.prank(admin); From c77c1d5552d6183cab1e5e2d06cfcd878937adf9 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Thu, 27 Feb 2025 14:08:43 +0100 Subject: [PATCH 20/25] Refactor & prepare for upload --- .env.sample | 11 +++-- Makefile | 2 +- README.md | 91 +++++++++++++++++------------------ lib/chainlink-local | 1 + script/DeployToken.s.sol | 6 ++- script/HelperConfig.s.sol | 12 +++++ src/GEMxToken.sol | 45 +++++++++-------- test/unit/GEMxTokenTest.t.sol | 14 +++--- 8 files changed, 105 insertions(+), 77 deletions(-) create mode 160000 lib/chainlink-local diff --git a/.env.sample b/.env.sample index 1090b7e..1641b96 100644 --- a/.env.sample +++ b/.env.sample @@ -1,8 +1,13 @@ -PRIVATE_KEY=XXXXXXXXX + +TOKEN_NAME="EmGemX Switzerland"; +TOKEN_SYMBOL="EmCH"; +#TOKEN_NAME="EmGemX Singapur"; +#TOKEN_SYMBOL="EmSP"; + DEFAULT_ANVIL_KEY=XXXXXXXXX RPC_URL=http://0.0.0.0:8545 ETHERSCAN_API_KEY=XXXX -SEPOLIA_RPC_URL= -MAINNET_RPC_URL= +SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com +MAINNET_RPC_URL=https://ethereum-rpc.publicnode.com AVALANCHE_FUJI_RPC_URL=https://avalanche-fuji-c-chain-rpc.publicnode.com AVALANCHE_MAINNET_RPC_URL=https://api.avax.network/ext/bc/C/rpc \ No newline at end of file diff --git a/Makefile b/Makefile index 49ee0bc..902f782 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ slither :; slither src/GEMxToken.sol --triage-mode NETWORK_ARGS := --rpc-url http://localhost:8545 --private-key $(DEFAULT_ANVIL_KEY) --broadcast ifeq ($(findstring --network sepolia,$(ARGS)),--network sepolia) - NETWORK_ARGS := --rpc-url $(SEPOLIA_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv + NETWORK_ARGS := --rpc-url $(SEPOLIA_RPC_URL) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv endif deploy: diff --git a/README.md b/README.md index 9265b45..b2e5666 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,63 @@ -## Foundry - -**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** - -Foundry consists of: - -- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). -- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. -- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. -- **Chisel**: Fast, utilitarian, and verbose solidity REPL. - -## Documentation - -https://book.getfoundry.sh/ - -## Usage +# 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 | + +## Build, Test, Deploy + +### Install + +```shell +$ make install +``` ### Build ```shell -$ forge build +$ make build ``` ### Test ```shell -$ forge test -``` - -### Format - -```shell -$ forge fmt +$ make test ``` -### Gas Snapshots +### Coverage ```shell -$ forge snapshot -``` - -### Anvil - -```shell -$ anvil +$ make coverage ``` ### Deploy ```shell -$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key -``` - -### Cast - -```shell -$ cast -``` - -### Help - -```shell -$ forge --help -$ anvil --help -$ cast --help +$ forge script script/DeployToken.s.sol:DeployToken ``` diff --git a/lib/chainlink-local b/lib/chainlink-local new file mode 160000 index 0000000..7d8b2f8 --- /dev/null +++ b/lib/chainlink-local @@ -0,0 +1 @@ +Subproject commit 7d8b2f888e1f10c8841ccd9e0f4af0f5baf11dab diff --git a/script/DeployToken.s.sol b/script/DeployToken.s.sol index b3b1c00..4cb5ea0 100644 --- a/script/DeployToken.s.sol +++ b/script/DeployToken.s.sol @@ -16,7 +16,11 @@ contract DeployToken is Script { token = new GEMxToken(); (address proofOfReserveOracle) = helperConfig.activeNetworkConfig(); - token.initialize(proofOfReserveOracle); + + string memory tokenName = vm.envString("TOKEN_NAME"); // "EmGemX Switzerland" + string memory tokenSymbol = vm.envString("TOKEN_SYMBOL"); // "EmCH" + + token.initialize(proofOfReserveOracle, tokenName, tokenSymbol); vm.stopBroadcast(); diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index c4f1a26..3a8aea5 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -21,6 +21,10 @@ contract HelperConfig is Script { activeNetworkConfig = getSepoliaEthConfig(); } else if (block.chainid == 43113) { activeNetworkConfig = getFujiEthConfig(); + } else if (block.chainid == 43114) { + activeNetworkConfig = getAvalancheEthConfig(); + } else if (block.chainid == 1) { + activeNetworkConfig = getMainnetEthConfig(); } else { activeNetworkConfig = getOrCreateAnvilEthConfig(); } @@ -34,6 +38,14 @@ contract HelperConfig is Script { fujiNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); } + function getAvalancheEthConfig() public pure returns (NetworkConfig memory avalancheNetworkConfig) { + avalancheNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); + } + + function getMainnetEthConfig() public pure returns (NetworkConfig memory mainnetNetworkConfig) { + mainnetNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); + } + function getOrCreateAnvilEthConfig() public returns (NetworkConfig memory anvilNetworkConfig) { // Check to see if we set an active network config if (activeNetworkConfig.proofOfReserveOracle != address(0)) { diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index 899c055..18cd532 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -45,25 +45,28 @@ contract GEMxToken is ERC20CustodianUpgradeable, ERC20BlocklistUpgradeable { + error NotEnoughReserve(); + + AggregatorV3Interface private oracle; + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant ESU_ROLE = keccak256("ESU_ROLE"); // allowed to update esu value 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 LIMITER_ROLE = keccak256("LIMITER_ROLE"); // block/unblock user - /// @custom:oz-upgrades-unsafe-allow constructor - // constructor() { - // _disableInitializers(); - // } + /* + ESU Calculation: TODO: this needs to be confirmed! + - ESU value is written by chainlink + - Token has an esu_per_token value + - max_tokens = esu * esu_per_token + */ - uint256 private esuValue = 1; - uint256 private esuPrecision = 100; - AggregatorV3Interface private oracle; - - error NotEnoughReserve(); + uint256 private esuPerTokenValue = 1; + uint256 private esuPerTokenPrecision = 1000; - function initialize(address oracleAddres) public initializer { - __ERC20_init("EmGemX Switzerland", "EmCH"); + function initialize(address oracleAddres, string memory name, string memory symbol) public initializer { + __ERC20_init(name, symbol); __ERC20Burnable_init(); __ERC20Pausable_init(); __AccessControl_init(); @@ -102,13 +105,14 @@ contract GEMxToken is return address(oracle); } + // TODO: ESU and PoR logic still be confirmed! function getEsu() external view returns (uint256, uint256) { - return (esuValue, esuPrecision); + return (esuPerTokenValue, esuPerTokenPrecision); } - function setEsu(uint256 esu, uint256 precision) external onlyRole(ESU_ROLE) { - esuValue = esu; - esuPrecision = precision; + function setEsuValue(uint256 esu, uint256 precision) external onlyRole(ESU_ROLE) { + esuPerTokenValue = esu; + esuPerTokenPrecision = precision; } function _isCustodian(address user) internal view override returns (bool) { @@ -136,10 +140,13 @@ contract GEMxToken is function _getProofOfReserve() private view returns (uint256) { ( - /* uint80 roundID */, - int answer, - /*uint startedAt*/, - /*uint timeStamp*/, + /* uint80 roundID */ + , + int256 answer, + /*uint startedAt*/ + , + /*uint timeStamp*/ + , /*uint80 answeredInRound*/ ) = oracle.latestRoundData(); diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index ded8f44..956e7fb 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -27,6 +27,8 @@ contract GEMxTokenTest is Test { function setUp() public { admin = makeAddr("Admin"); + vm.setEnv("TOKEN_NAME", "EmGemX Switzerland"); + vm.setEnv("TOKEN_SYMBOL", "EmCH"); DeployToken deployer = new DeployToken(); token = deployer.run(); oracle = MockV3Aggregator(token.getOracleAddress()); @@ -54,7 +56,7 @@ contract GEMxTokenTest is Test { assertEq(token.decimals(), 18); (uint256 esu, uint256 esuPrecision) = token.getEsu(); assertEq(esu, 1); - assertEq(esuPrecision, 100); + assertEq(esuPrecision, 1000); } function testMintRespectsProofOfReserve() public { @@ -82,26 +84,26 @@ contract GEMxTokenTest is Test { function testOnlyEsuUpdaterCanUpdateEsuValue() public { (uint256 esu, uint256 esuPrecision) = token.getEsu(); assertEq(esu, 1); - assertEq(esuPrecision, 100); + assertEq(esuPrecision, 1000); vm.expectRevert( abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.ESU_ROLE()) ); vm.prank(user); - token.setEsu(9, 1000); + token.setEsuValue(9, 10000); // values should not have changed (esu, esuPrecision) = token.getEsu(); assertEq(esu, 1); - assertEq(esuPrecision, 100); + assertEq(esuPrecision, 1000); // ACT vm.prank(esuUpdater); - token.setEsu(9, 1000); + token.setEsuValue(9, 10000); (esu, esuPrecision) = token.getEsu(); assertEq(esu, 9); - assertEq(esuPrecision, 1000); + assertEq(esuPrecision, 10000); } /*##################################################################################*/ From 87ab790bf28466335cd2121088d8ce667f4aaa91 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Mon, 10 Mar 2025 11:56:58 +0100 Subject: [PATCH 21/25] testnet deployment --- .env.sample | 10 ++++++---- Makefile | 13 +++++++++++-- script/DeployToken.s.sol | 14 +++++++++++++- script/HelperConfig.s.sol | 19 +++++++++++-------- src/GEMxToken.sol | 6 ++++++ 5 files changed, 47 insertions(+), 15 deletions(-) diff --git a/.env.sample b/.env.sample index 1641b96..7668bb9 100644 --- a/.env.sample +++ b/.env.sample @@ -1,8 +1,10 @@ +TOKEN_NAME="GemTestCCIP" +TOKEN_SYMBOL="GTCCIP1" -TOKEN_NAME="EmGemX Switzerland"; -TOKEN_SYMBOL="EmCH"; -#TOKEN_NAME="EmGemX Singapur"; -#TOKEN_SYMBOL="EmSP"; +#TOKEN_NAME="EmGemX Switzerland" +#TOKEN_SYMBOL="EmCH" +#TOKEN_NAME="EmGemX Singapur" +#TOKEN_SYMBOL="EmSP" DEFAULT_ANVIL_KEY=XXXXXXXXX RPC_URL=http://0.0.0.0:8545 diff --git a/Makefile b/Makefile index 902f782..1611e34 100644 --- a/Makefile +++ b/Makefile @@ -38,8 +38,17 @@ slither :; slither src/GEMxToken.sol --triage-mode NETWORK_ARGS := --rpc-url http://localhost:8545 --private-key $(DEFAULT_ANVIL_KEY) --broadcast ifeq ($(findstring --network sepolia,$(ARGS)),--network sepolia) - NETWORK_ARGS := --rpc-url $(SEPOLIA_RPC_URL) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv + NETWORK_ARGS := --rpc-url $(SEPOLIA_RPC_URL) --broadcast --slow --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv +else ifeq ($(findstring --network fuji,$(ARGS)),--network fuji) + NETWORK_ARGS := --rpc-url $(AVALANCHE_FUJI_RPC_URL) --broadcast --slow --verify --etherscan-api-key "verifyContract" --verifier-url 'https://api.routescan.io/v2/network/testnet/evm/43113/etherscan' -vvvv endif deploy: - @forge script script/DeployToken.s.sol:DeployToken $(NETWORK_ARGS) \ No newline at end of file + @forge script script/DeployToken.s.sol:DeployToken $(NETWORK_ARGS) + +# mkdir -p keystores/emgemx && cast wallet new keystores/emgemx +# cast wallet import -k keystores emgemx_deployer -- interactive +# cast wallet list --dir keystores + +# make deploy ARGS="--network sepolia" +# make deploy ARGS="--network fuji" \ No newline at end of file diff --git a/script/DeployToken.s.sol b/script/DeployToken.s.sol index 4cb5ea0..4cbcc2d 100644 --- a/script/DeployToken.s.sol +++ b/script/DeployToken.s.sol @@ -5,6 +5,7 @@ pragma solidity 0.8.20; import {Script, console} from "lib/forge-std/src/Script.sol"; import {HelperConfig} from "./HelperConfig.s.sol"; import {GEMxToken} from "../src/GEMxToken.sol"; +import {MockV3Aggregator} from "../test/mocks/MockV3Aggregator.sol"; contract DeployToken is Script { GEMxToken public token; @@ -14,9 +15,14 @@ contract DeployToken is Script { vm.startBroadcast(); - token = new GEMxToken(); (address proofOfReserveOracle) = helperConfig.activeNetworkConfig(); + if (proofOfReserveOracle == address(0x0)) { + MockV3Aggregator mock = new MockV3Aggregator(helperConfig.PROOF_OF_RESERVE_MOCK()); + proofOfReserveOracle = address(mock); + } + token = new GEMxToken(); + string memory tokenName = vm.envString("TOKEN_NAME"); // "EmGemX Switzerland" string memory tokenSymbol = vm.envString("TOKEN_SYMBOL"); // "EmCH" @@ -26,4 +32,10 @@ contract DeployToken is Script { return token; } + + function createProofOrReserveMock(uint256 reserve) private returns (MockV3Aggregator) { + MockV3Aggregator mock = new MockV3Aggregator(int256(reserve)); + console.log("Mock deployed under:", address(mock)); + return mock; + } } diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index 3a8aea5..81edbe1 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -2,7 +2,7 @@ pragma solidity 0.8.20; import {MockV3Aggregator} from "../test/mocks/MockV3Aggregator.sol"; -import {Script} from "forge-std/Script.sol"; +import {Script, console} from "forge-std/Script.sol"; contract HelperConfig is Script { NetworkConfig public activeNetworkConfig; @@ -18,31 +18,37 @@ contract HelperConfig is Script { constructor() { if (block.chainid == 11155111) { + // Ethereum Sepolia activeNetworkConfig = getSepoliaEthConfig(); } else if (block.chainid == 43113) { + // Avalanche Fuji Testnet activeNetworkConfig = getFujiEthConfig(); } else if (block.chainid == 43114) { + // Avalanche C-Chain Mainnet activeNetworkConfig = getAvalancheEthConfig(); } else if (block.chainid == 1) { + // Ethereum Mainnet activeNetworkConfig = getMainnetEthConfig(); } else { activeNetworkConfig = getOrCreateAnvilEthConfig(); } } - function getSepoliaEthConfig() public pure returns (NetworkConfig memory sepoliaNetworkConfig) { + function getSepoliaEthConfig() public /*pure*/ returns (NetworkConfig memory sepoliaNetworkConfig) { sepoliaNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); } - function getFujiEthConfig() public pure returns (NetworkConfig memory fujiNetworkConfig) { + function getFujiEthConfig() public /*pure*/ returns (NetworkConfig memory fujiNetworkConfig) { fujiNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); } function getAvalancheEthConfig() public pure returns (NetworkConfig memory avalancheNetworkConfig) { + revert("Feed address missing"); avalancheNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); } function getMainnetEthConfig() public pure returns (NetworkConfig memory mainnetNetworkConfig) { + revert("Feed address missing"); mainnetNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); } @@ -52,10 +58,7 @@ contract HelperConfig is Script { return activeNetworkConfig; } - vm.startBroadcast(); - MockV3Aggregator proofOfReserveFeed = new MockV3Aggregator(PROOF_OF_RESERVE_MOCK); - vm.stopBroadcast(); - - anvilNetworkConfig = NetworkConfig({proofOfReserveOracle: address(proofOfReserveFeed)}); + MockV3Aggregator mock = new MockV3Aggregator(PROOF_OF_RESERVE_MOCK); + anvilNetworkConfig = NetworkConfig({proofOfReserveOracle: address(mock)}); } } diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index 18cd532..c9c4e46 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -25,6 +25,7 @@ pragma solidity ^0.8.20; import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import {ERC20BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; @@ -42,6 +43,7 @@ contract GEMxToken is ERC20BurnableUpgradeable, ERC20PausableUpgradeable, AccessControlUpgradeable, + OwnableUpgradeable, ERC20CustodianUpgradeable, ERC20BlocklistUpgradeable { @@ -70,9 +72,13 @@ contract GEMxToken is __ERC20Burnable_init(); __ERC20Pausable_init(); __AccessControl_init(); + __Ownable_init(_msgSender()); _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); _setRoleAdmin(MINTER_ROLE, DEFAULT_ADMIN_ROLE); + _setRoleAdmin(ESU_ROLE, DEFAULT_ADMIN_ROLE); _setRoleAdmin(PAUSER_ROLE, DEFAULT_ADMIN_ROLE); + _setRoleAdmin(CUSTODIAN_ROLE, DEFAULT_ADMIN_ROLE); + _setRoleAdmin(LIMITER_ROLE, DEFAULT_ADMIN_ROLE); oracle = AggregatorV3Interface(oracleAddres); } From 68c104e822ad8722a1d475b9879306a8469c68e7 Mon Sep 17 00:00:00 2001 From: Siegfried Skalla Date: Fri, 21 Mar 2025 09:01:53 +0000 Subject: [PATCH 22/25] Merged PR 5920: ESU finalization --- .env.sample | 9 +- Makefile | 31 +++--- README.md | 2 +- foundry.toml | 2 +- lib/chainlink-local | 1 - script/DeployToken.s.sol | 11 ++- script/GrantMinterRole.s.sol | 23 +++++ script/HelperConfig.s.sol | 22 +++-- script/Mint.s.sol | 22 +++++ src/GEMxToken.sol | 72 +++++++------- test/unit/GEMxTokenTest.t.sol | 181 ++++++++++++++++++++++++---------- 11 files changed, 248 insertions(+), 128 deletions(-) delete mode 160000 lib/chainlink-local create mode 100644 script/GrantMinterRole.s.sol create mode 100644 script/Mint.s.sol diff --git a/.env.sample b/.env.sample index 7668bb9..450d13d 100644 --- a/.env.sample +++ b/.env.sample @@ -1,9 +1,6 @@ -TOKEN_NAME="GemTestCCIP" -TOKEN_SYMBOL="GTCCIP1" - -#TOKEN_NAME="EmGemX Switzerland" -#TOKEN_SYMBOL="EmCH" -#TOKEN_NAME="EmGemX Singapur" +TOKEN_NAME="EmGEMx Switzerland" +TOKEN_SYMBOL="EmCH" +#TOKEN_NAME="EmGEMx Singapur" #TOKEN_SYMBOL="EmSP" DEFAULT_ANVIL_KEY=XXXXXXXXX diff --git a/Makefile b/Makefile index 1611e34..6082906 100644 --- a/Makefile +++ b/Makefile @@ -2,15 +2,11 @@ # (-include to ignore error if it does not exist) -include .env -.PHONY: all test clean deploy fund help install coverage snapshot format anvil slither - DEFAULT_ANVIL_KEY := 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 help: @echo "Usage:" @echo " make deploy [ARGS=...]\n example: make deploy ARGS=\"--network sepolia\"" - @echo "" - @echo " make fund [ARGS=...]\n example: make deploy ARGS=\"--network sepolia\"" all: clean remove install update build @@ -20,35 +16,34 @@ remove :; rm -rf .gitmodules && rm -rf .git/modules/* && rm -rf lib && install :; forge install foundry-rs/forge-std --no-commit && \ forge install OpenZeppelin/openzeppelin-contracts --no-commit && \ forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit && \ - forge install OpenZeppelin/openzeppelin-foundry-upgrades --no-commit && \ - forge install smartcontractkit/chainlink-brownie-contracts --no-commit && \ - forge install OpenZeppelin/openzeppelin-community-contracts --no-commit + forge install OpenZeppelin/openzeppelin-foundry-upgrades --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 +.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 format :; forge fmt -anvil :; anvil -m 'test test test test test test test test test test test junk' --steps-tracing --block-time 1 +anvil :; anvil -m 'test test test test test test test test test test test junk' --steps-tracing #--block-time 1 fork :; anvil --fork-url ${FORK_ETH_RPC_URL} --fork-block-number ${FORK_BLOCK_NUMBER} watch :; forge test --watch src/ slither :; slither src/GEMxToken.sol --triage-mode NETWORK_ARGS := --rpc-url http://localhost:8545 --private-key $(DEFAULT_ANVIL_KEY) --broadcast +COMMON_DEPLOY_ARGS := --keystore keystores/emgemx_deployer --broadcast --slow --verify -vvvv + ifeq ($(findstring --network sepolia,$(ARGS)),--network sepolia) - NETWORK_ARGS := --rpc-url $(SEPOLIA_RPC_URL) --broadcast --slow --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv + NETWORK_ARGS := $(COMMON_DEPLOY_ARGS) --rpc-url $(SEPOLIA_RPC_URL) --etherscan-api-key $(ETHERSCAN_API_KEY) else ifeq ($(findstring --network fuji,$(ARGS)),--network fuji) - NETWORK_ARGS := --rpc-url $(AVALANCHE_FUJI_RPC_URL) --broadcast --slow --verify --etherscan-api-key "verifyContract" --verifier-url 'https://api.routescan.io/v2/network/testnet/evm/43113/etherscan' -vvvv + NETWORK_ARGS := $(COMMON_DEPLOY_ARGS) --rpc-url $(AVALANCHE_FUJI_RPC_URL) --etherscan-api-key "verifyContract" --verifier-url 'https://api.routescan.io/v2/network/testnet/evm/43113/etherscan' +else ifeq ($(findstring --network avalanche_mainnet,$(ARGS)),--network avalanche_mainnet) + NETWORK_ARGS := $(COMMON_DEPLOY_ARGS) --rpc-url $(AVALANCHE_MAINNET_RPC_URL) --etherscan-api-key "verifyContract" --verifier-url 'https://api.routescan.io/v2/network/mainnet/evm/43114/etherscan' endif deploy: - @forge script script/DeployToken.s.sol:DeployToken $(NETWORK_ARGS) - -# mkdir -p keystores/emgemx && cast wallet new keystores/emgemx -# cast wallet import -k keystores emgemx_deployer -- interactive -# cast wallet list --dir keystores - -# make deploy ARGS="--network sepolia" -# make deploy ARGS="--network fuji" \ No newline at end of file + @forge script script/DeployToken.s.sol:DeployToken $(NETWORK_ARGS) \ No newline at end of file diff --git a/README.md b/README.md index b2e5666..2fa6ab4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ | Property | Value | | ------------------------- | ------------------------------------------- | -| Name | EmGemX Switzerland | +| Name | EmGEMx Switzerland | | Symbol | EmCH | | Issuer | GemX AG, Zug, CH | | Number of Tokens | Variable | diff --git a/foundry.toml b/foundry.toml index 6fa30fe..1e0b160 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/lib/chainlink-local b/lib/chainlink-local deleted file mode 160000 index 7d8b2f8..0000000 --- a/lib/chainlink-local +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7d8b2f888e1f10c8841ccd9e0f4af0f5baf11dab diff --git a/script/DeployToken.s.sol b/script/DeployToken.s.sol index 4cbcc2d..1cc51da 100644 --- a/script/DeployToken.s.sol +++ b/script/DeployToken.s.sol @@ -17,13 +17,16 @@ contract DeployToken is Script { (address proofOfReserveOracle) = helperConfig.activeNetworkConfig(); if (proofOfReserveOracle == address(0x0)) { - MockV3Aggregator mock = new MockV3Aggregator(helperConfig.PROOF_OF_RESERVE_MOCK()); + uint256 mockValue = helperConfig.PROOF_OF_RESERVE_MOCK(); + MockV3Aggregator mock = createProofOrReserveMock(mockValue); proofOfReserveOracle = address(mock); } + console.log("Oracle address:", proofOfReserveOracle); token = new GEMxToken(); - - string memory tokenName = vm.envString("TOKEN_NAME"); // "EmGemX Switzerland" + 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(proofOfReserveOracle, tokenName, tokenSymbol); @@ -35,7 +38,7 @@ contract DeployToken is Script { function createProofOrReserveMock(uint256 reserve) private returns (MockV3Aggregator) { MockV3Aggregator mock = new MockV3Aggregator(int256(reserve)); - console.log("Mock deployed under:", address(mock)); + console.log("Oracle mock deployed at:", address(mock)); return mock; } } diff --git a/script/GrantMinterRole.s.sol b/script/GrantMinterRole.s.sol new file mode 100644 index 0000000..4ef9d02 --- /dev/null +++ b/script/GrantMinterRole.s.sol @@ -0,0 +1,23 @@ +// 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 {GEMxToken} from "../src/GEMxToken.sol"; + +contract GrantMinterRole is Script { + function run(address tokenAddress, address newMinter) public { + vm.startBroadcast(); + + GEMxToken token = GEMxToken(tokenAddress); + bytes32 role = token.MINTER_ROLE(); + if (token.hasRole(role, newMinter)) { + console.log("Address is already minter"); + } else { + token.grantRole(token.MINTER_ROLE(), newMinter); + } + + vm.stopBroadcast(); + } +} diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index 81edbe1..c4488fb 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -7,8 +7,7 @@ import {Script, console} from "forge-std/Script.sol"; contract HelperConfig is Script { NetworkConfig public activeNetworkConfig; - uint8 public constant DECIMALS = 18; - int256 public constant PROOF_OF_RESERVE_MOCK = 100_000; + uint256 public constant PROOF_OF_RESERVE_MOCK = 10_000; uint256 public DEFAULT_ANVIL_PRIVATE_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; @@ -34,12 +33,13 @@ contract HelperConfig is Script { } } - function getSepoliaEthConfig() public /*pure*/ returns (NetworkConfig memory sepoliaNetworkConfig) { - sepoliaNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); + function getSepoliaEthConfig() public pure returns (NetworkConfig memory sepoliaNetworkConfig) { + // no oracle on other chains than Avalanche + sepoliaNetworkConfig = NetworkConfig({proofOfReserveOracle: 0x8D26D407ebed4D03dE7c18f5Db913155a4D587AE}); } - function getFujiEthConfig() public /*pure*/ returns (NetworkConfig memory fujiNetworkConfig) { - fujiNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); + function getFujiEthConfig() public pure returns (NetworkConfig memory fujiNetworkConfig) { + fujiNetworkConfig = NetworkConfig({proofOfReserveOracle: 0x8F1C8888fBcd9Cc5D732df1e146d399a21899c22}); } function getAvalancheEthConfig() public pure returns (NetworkConfig memory avalancheNetworkConfig) { @@ -48,17 +48,19 @@ contract HelperConfig is Script { } function getMainnetEthConfig() public pure returns (NetworkConfig memory mainnetNetworkConfig) { - revert("Feed address missing"); + // no oracle on other chains than Avalanche mainnetNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); } - function getOrCreateAnvilEthConfig() public returns (NetworkConfig memory anvilNetworkConfig) { + function getOrCreateAnvilEthConfig() public view returns (NetworkConfig memory anvilNetworkConfig) { // Check to see if we set an active network config if (activeNetworkConfig.proofOfReserveOracle != address(0)) { return activeNetworkConfig; } - MockV3Aggregator mock = new MockV3Aggregator(PROOF_OF_RESERVE_MOCK); - anvilNetworkConfig = NetworkConfig({proofOfReserveOracle: address(mock)}); + //MockV3Aggregator mock = new MockV3Aggregator(PROOF_OF_RESERVE_MOCK); + //console.log("Anvil oracle mock:", address(mock)); + //anvilNetworkConfig = NetworkConfig({proofOfReserveOracle: address(mock)}); + anvilNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); } } diff --git a/script/Mint.s.sol b/script/Mint.s.sol new file mode 100644 index 0000000..fab0af7 --- /dev/null +++ b/script/Mint.s.sol @@ -0,0 +1,22 @@ +// 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 {GEMxToken} from "../src/GEMxToken.sol"; + +contract Mint is Script { + function run(address tokenAddress, address account, uint256 amount) public { + vm.startBroadcast(); + + GEMxToken token = GEMxToken(tokenAddress); + bytes32 role = token.MINTER_ROLE(); + console.log("Sender:", msg.sender); + require(token.hasRole(role, msg.sender), "Sender is not minter"); + + token.mint(account, amount); + + vm.stopBroadcast(); + } +} diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index c9c4e46..e08a2e2 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -1,25 +1,3 @@ -// Layout of Contract: -// version -// imports -// errors -// interfaces, libraries, contracts -// Type declarations -// State variables -// Events -// Modifiers -// Functions - -// Layout of Functions: -// constructor -// receive function (if exists) -// fallback function (if exists) -// external -// public -// internal -// private -// internal & private view & pure functions -// external & public view & pure functions - // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; @@ -51,21 +29,26 @@ contract GEMxToken is AggregatorV3Interface private oracle; - bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); - bytes32 public constant ESU_ROLE = keccak256("ESU_ROLE"); // allowed to update esu value + 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 LIMITER_ROLE = keccak256("LIMITER_ROLE"); // block/unblock user + /// @dev Controls whether minting restriction is active (on parent chain) or not (on any other chain). + uint256 public constant parentChainId = 43114; // Avalanche C-Chain + /* - ESU Calculation: TODO: this needs to be confirmed! + ESU Calculation: - ESU value is written by chainlink - - Token has an esu_per_token value - - max_tokens = esu * esu_per_token + - Token has an esu_per_token value (set by emgemx) + - esu_per_token value is updated every month + - max_tokens = esu / esu_per_token */ + // initial esuPerToken: 0.01 uint256 private esuPerTokenValue = 1; - uint256 private esuPerTokenPrecision = 1000; + uint256 private esuPerTokenPrecision = 100; function initialize(address oracleAddres, string memory name, string memory symbol) public initializer { __ERC20_init(name, symbol); @@ -75,7 +58,7 @@ contract GEMxToken is __Ownable_init(_msgSender()); _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); _setRoleAdmin(MINTER_ROLE, DEFAULT_ADMIN_ROLE); - _setRoleAdmin(ESU_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(LIMITER_ROLE, DEFAULT_ADMIN_ROLE); @@ -111,16 +94,28 @@ contract GEMxToken is return address(oracle); } - // TODO: ESU and PoR logic still be confirmed! - function getEsu() external view returns (uint256, uint256) { + function getEsuPerToken() external view returns (uint256, uint256) { return (esuPerTokenValue, esuPerTokenPrecision); } - function setEsuValue(uint256 esu, uint256 precision) external onlyRole(ESU_ROLE) { - esuPerTokenValue = esu; + function setEsuPerToken(uint256 value, uint256 precision) external onlyRole(ESU_PER_TOKEN_MODIFIER_ROLE) { + esuPerTokenValue = value; esuPerTokenPrecision = precision; } + function decimals() public pure override returns (uint8) { + return 8; + } + + function getMaxSupply() public view returns (uint256) { + // no max supply restriction on child chains + if (block.chainid != parentChainId) { + return type(uint256).max; + } + + return _getEsuFromOracle() * esuPerTokenPrecision / esuPerTokenValue; + } + function _isCustodian(address user) internal view override returns (bool) { return hasRole(CUSTODIAN_ROLE, user); } @@ -129,9 +124,12 @@ contract GEMxToken is internal override(ERC20Upgradeable, ERC20PausableUpgradeable, ERC20CustodianUpgradeable, ERC20BlocklistUpgradeable) { - // make sure it cannot be minted more than proof of reserve! - if (from == address(0) && totalSupply() + amount > _getProofOfReserve()) { - revert NotEnoughReserve(); + // 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 == parentChainId) { + if (from == address(0) && totalSupply() + amount > getMaxSupply()) { + revert NotEnoughReserve(); + } } super._update(from, to, amount); @@ -144,7 +142,7 @@ contract GEMxToken is super._approve(owner, spender, value, emitEvent); } - function _getProofOfReserve() private view returns (uint256) { + function _getEsuFromOracle() private view returns (uint256) { ( /* uint80 roundID */ , diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index 956e7fb..41f5667 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -20,19 +20,23 @@ contract GEMxTokenTest is Test { address pauser = address(0x3); address custodian = address(0x4); address limiter = address(0x5); - address esuUpdater = address(0x6); + address esuPerTokenModifier = address(0x6); address user = makeAddr("user"); address anon = makeAddr("anon"); + event TokensFrozen(address indexed user, uint256 amount); + function setUp() public { admin = makeAddr("Admin"); - vm.setEnv("TOKEN_NAME", "EmGemX Switzerland"); + vm.setEnv("TOKEN_NAME", "EmGEMx Switzerland"); vm.setEnv("TOKEN_SYMBOL", "EmCH"); DeployToken deployer = new DeployToken(); token = deployer.run(); oracle = MockV3Aggregator(token.getOracleAddress()); + vm.chainId(token.parentChainId()); // set chain token's parent chain to enable full feature set with minting restrictio + // Grant roles vm.startPrank(DEFAULT_SENDER); token.grantRole(token.DEFAULT_ADMIN_ROLE(), admin); @@ -40,78 +44,139 @@ contract GEMxTokenTest is Test { token.grantRole(token.PAUSER_ROLE(), pauser); token.grantRole(token.CUSTODIAN_ROLE(), custodian); token.grantRole(token.LIMITER_ROLE(), limiter); - token.grantRole(token.ESU_ROLE(), esuUpdater); + token.grantRole(token.ESU_PER_TOKEN_MODIFIER_ROLE(), esuPerTokenModifier); 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 _setProofOfReserve(int256 value) private { - oracle.updateAnswer(value); - } - function testTokenProperties() public view { - assertEq(token.name(), "EmGemX Switzerland"); + assertEq(token.name(), "EmGEMx Switzerland"); assertEq(token.symbol(), "EmCH"); - assertEq(token.decimals(), 18); - (uint256 esu, uint256 esuPrecision) = token.getEsu(); - assertEq(esu, 1); - assertEq(esuPrecision, 1000); + assertEq(token.decimals(), 8); + (uint256 esu, uint256 esuPrecision) = token.getEsuPerToken(); + assertEq(esu, 1, "Initial EsuPerToken is 0.01"); + assertEq(esuPrecision, 100, "Initial EsuPerToken is 0.01"); } - function testMintRespectsProofOfReserve() public { - int256 reserve = 1_000 ether; - _setProofOfReserve(reserve); + /*##################################################################################*/ + /*###################################### ESU #######################################*/ + /*##################################################################################*/ + + function testMintOnAvalancheParentChainRespectsEsuOracle_And_EsuPerTokenSetting() public { + vm.chainId(token.parentChainId()); + + int256 esu = 100 ether; + _setEsu(esu); + + uint256 maxSupply = token.getMaxSupply(); + console.log("Allowed MaxSupply:", maxSupply); + assertEq(maxSupply, 10_000 ether, "Parameters changed - Arrange needs to be adjusted"); vm.startPrank(minter); - token.mint(user, 500 ether); - assertEq(token.totalSupply(), 500 ether); + token.mint(user, 5000 ether); + assertEq(token.totalSupply(), 5000 ether); - token.mint(user, 500 ether); - assertEq(token.totalSupply(), 1_000 ether); + token.mint(user, 5000 ether); + assertEq(token.totalSupply(), 10_000 ether); + // ACT vm.expectRevert(GEMxToken.NotEnoughReserve.selector); token.mint(user, 1); vm.stopPrank(); - assertEq(token.balanceOf(user), 1_000 ether); + assertEq(token.balanceOf(user), 10_000 ether); } - /*##################################################################################*/ - /*###################################### ESU #######################################*/ - /*##################################################################################*/ + function testMintOnChildChainHasNoRestriction() public { + vm.chainId(1); // e.g. ethereum mainnet + + int256 esu = 100 ether; + _setEsu(esu); + + 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); + + assertEq(token.balanceOf(user), 1_000_000 ether); + } - function testOnlyEsuUpdaterCanUpdateEsuValue() public { - (uint256 esu, uint256 esuPrecision) = token.getEsu(); + function _setEsu(int256 value) private { + oracle.updateAnswer(value); + } + + function testOnlyEsuPerTokenModifierCanUpdateEsuPerTokenValue() public { + (uint256 esu, uint256 esuPrecision) = token.getEsuPerToken(); assertEq(esu, 1); - assertEq(esuPrecision, 1000); + assertEq(esuPrecision, 100); vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.ESU_ROLE()) + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.ESU_PER_TOKEN_MODIFIER_ROLE() + ) ); vm.prank(user); - token.setEsuValue(9, 10000); + token.setEsuPerToken(9, 10000); // values should not have changed - (esu, esuPrecision) = token.getEsu(); - assertEq(esu, 1); - assertEq(esuPrecision, 1000); + (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(esuUpdater); - token.setEsuValue(9, 10000); + vm.prank(esuPerTokenModifier); + token.setEsuPerToken(9, 10000); - (esu, esuPrecision) = token.getEsu(); + (esu, esuPrecision) = token.getEsuPerToken(); assertEq(esu, 9); assertEq(esuPrecision, 10000); } + function testVerifyEsuCalculation() public { + vm.chainId(token.parentChainId()); + + (uint256 esu, uint256 esuPrecision) = token.getEsuPerToken(); + assertEq(esu, 1); + assertEq(esuPrecision, 100); // 0.01 ether + + _setEsu(2521130000000000000000); // 2521.13 + uint256 maxSupplyWei = token.getMaxSupply(); + assertEq(maxSupplyWei, 252_113 ether); + + _setEsu(2521130000000000000000); // 2521.13 + vm.prank(esuPerTokenModifier); + token.setEsuPerToken(99, 10000); // 0.0099 + maxSupplyWei = token.getMaxSupply(); + assertEq(roundTwoDecimals(maxSupplyWei), 254_659_60 ether / 100); // 254659.60 + + _setEsu(3871130000000000000000); // 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 + } + + function roundTwoDecimals(uint256 value) private pure returns (uint256) { + // Define the rounding factor for 0.01 ether (10^16 wei) + uint256 roundingFactor = 10 ** 16; + + // Add half of the rounding factor to the value for proper rounding + uint256 roundedValue = (value + (roundingFactor / 2)) / roundingFactor; + + // Multiply back to get the rounded wei value + return roundedValue * roundingFactor; + } + /*##################################################################################*/ /*################################### MINT/BURN ####################################*/ /*##################################################################################*/ function testOnlyMinterCanMint() public { - _setProofOfReserve(1_000 ether); + _setEsu(1_000 ether); vm.expectRevert( abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.MINTER_ROLE()) @@ -126,7 +191,7 @@ contract GEMxTokenTest is Test { } function testOnlyMinterCanBurn() public { - _setProofOfReserve(1_000 ether); + _setEsu(1_000 ether); vm.prank(minter); token.mint(user, uint256(10 ether)); @@ -181,7 +246,7 @@ contract GEMxTokenTest is Test { } function testTransferWhenPauseUnpause() public { - _setProofOfReserve(1_000 ether); + _setEsu(1_000 ether); vm.prank(minter); token.mint(user, uint256(10 ether)); @@ -215,7 +280,7 @@ contract GEMxTokenTest is Test { // TODO: split into separate tests once modifier with test setup is implemented function testOnlyCustodianCanFreezeAndUnfreeze() public { - _setProofOfReserve(1_000 ether); + _setEsu(1_000 ether); vm.prank(minter); token.mint(user, uint256(10 ether)); @@ -228,8 +293,8 @@ contract GEMxTokenTest is Test { assertEq(token.availableBalance(user), 10 ether); // freeze allowed - //vm.expectEmit(); - //emit ERC20CustodianUpgradeable.TokensFrozen(user, 1 ether); + vm.expectEmit(); + emit TokensFrozen(user, 1 ether); vm.prank(custodian); token.freeze(user, 1 ether); assertEq(token.frozen(user), 1 ether); @@ -243,8 +308,8 @@ contract GEMxTokenTest is Test { assertEq(token.availableBalance(user), 9 ether); // unfreeze allowed - //vm.expectEmit(); - //emit ERC20CustodianUpgradeable.TokensFrozen(user, 0); + vm.expectEmit(); + emit TokensFrozen(user, 0); vm.prank(custodian); token.freeze(user, 0 ether); assertEq(token.frozen(user), 0); @@ -252,7 +317,7 @@ contract GEMxTokenTest is Test { } function testTransferWhenAmountFrozen() public { - _setProofOfReserve(1_000 ether); + _setEsu(1_000 ether); vm.prank(minter); token.mint(user, uint256(10 ether)); @@ -278,6 +343,22 @@ contract GEMxTokenTest is Test { assertEq(token.availableBalance(anon), 2 ether); } + function testCannotFreezeMoreThanAvailable() public { + _setEsu(1_000 ether); + + vm.prank(minter); + token.mint(user, uint256(10 ether)); + + // try to freeze more than user has balance + vm.expectRevert( + abi.encodeWithSelector(ERC20CustodianUpgradeable.ERC20InsufficientUnfrozenBalance.selector, user) + ); + vm.prank(custodian); + token.freeze(user, 11 ether); + + assertEq(token.frozen(user), 0 ether); + } + /*##################################################################################*/ /*################################# BLOCK/UNBLOCK ##################################*/ /*##################################################################################*/ @@ -314,7 +395,7 @@ contract GEMxTokenTest is Test { } function testTransferWhenUserBlocked() public { - _setProofOfReserve(1_000 ether); + _setEsu(1_000 ether); vm.prank(minter); token.mint(user, uint256(10 ether)); @@ -359,7 +440,7 @@ contract GEMxTokenTest is Test { } function testErc20ApproveWhenUserBlocked() public { - _setProofOfReserve(1_000 ether); + _setEsu(1_000 ether); vm.prank(minter); token.mint(user, uint256(10 ether)); @@ -410,7 +491,7 @@ contract GEMxTokenTest is Test { token.grantRole(role, newMinter); assertTrue(token.hasRole(role, newMinter)); - role = token.ESU_ROLE(); + role = token.ESU_PER_TOKEN_MODIFIER_ROLE(); vm.prank(admin); token.grantRole(role, newMinter); assertTrue(token.hasRole(role, newMinter)); @@ -441,10 +522,10 @@ contract GEMxTokenTest is Test { token.revokeRole(limiterRole, limiter); assertFalse(token.hasRole(limiterRole, limiter)); - bytes32 esuUpdateRole = token.ESU_ROLE(); - assertTrue(token.hasRole(esuUpdateRole, esuUpdater)); + bytes32 esuPerTokenModifierRole = token.ESU_PER_TOKEN_MODIFIER_ROLE(); + assertTrue(token.hasRole(esuPerTokenModifierRole, esuPerTokenModifier)); vm.prank(admin); - token.revokeRole(esuUpdateRole, esuUpdater); - assertFalse(token.hasRole(esuUpdateRole, esuUpdater)); + token.revokeRole(esuPerTokenModifierRole, esuPerTokenModifier); + assertFalse(token.hasRole(esuPerTokenModifierRole, esuPerTokenModifier)); } } From f4eafcc1e7042dee9423fa4206b90762a717d6f3 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Fri, 21 Mar 2025 10:18:06 +0100 Subject: [PATCH 23/25] linting. cleanup --- script/DeployToken.s.sol | 10 +++++----- script/HelperConfig.s.sol | 20 +++++++++----------- src/GEMxToken.sol | 10 ++++++---- test/unit/GEMxTokenTest.t.sol | 6 +++--- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/script/DeployToken.s.sol b/script/DeployToken.s.sol index 1cc51da..dbd71b2 100644 --- a/script/DeployToken.s.sol +++ b/script/DeployToken.s.sol @@ -15,13 +15,13 @@ contract DeployToken is Script { vm.startBroadcast(); - (address proofOfReserveOracle) = helperConfig.activeNetworkConfig(); - if (proofOfReserveOracle == address(0x0)) { + (address esuOracle) = helperConfig.activeNetworkConfig(); + if (esuOracle == address(0x0)) { uint256 mockValue = helperConfig.PROOF_OF_RESERVE_MOCK(); MockV3Aggregator mock = createProofOrReserveMock(mockValue); - proofOfReserveOracle = address(mock); + esuOracle = address(mock); } - console.log("Oracle address:", proofOfReserveOracle); + console.log("Oracle address:", esuOracle); token = new GEMxToken(); console.log("Token address:", address(token)); @@ -29,7 +29,7 @@ contract DeployToken is Script { string memory tokenName = vm.envString("TOKEN_NAME"); // "EmGEMx Switzerland" string memory tokenSymbol = vm.envString("TOKEN_SYMBOL"); // "EmCH" - token.initialize(proofOfReserveOracle, tokenName, tokenSymbol); + token.initialize(esuOracle, tokenName, tokenSymbol); vm.stopBroadcast(); diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index c4488fb..7d2d77b 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -7,12 +7,10 @@ import {Script, console} from "forge-std/Script.sol"; contract HelperConfig is Script { NetworkConfig public activeNetworkConfig; - uint256 public constant PROOF_OF_RESERVE_MOCK = 10_000; - - uint256 public DEFAULT_ANVIL_PRIVATE_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + uint256 public constant PROOF_OF_RESERVE_MOCK = 10000 * 100000000; //10.000 in token decimals struct NetworkConfig { - address proofOfReserveOracle; + address esuOracle; } constructor() { @@ -35,32 +33,32 @@ contract HelperConfig is Script { function getSepoliaEthConfig() public pure returns (NetworkConfig memory sepoliaNetworkConfig) { // no oracle on other chains than Avalanche - sepoliaNetworkConfig = NetworkConfig({proofOfReserveOracle: 0x8D26D407ebed4D03dE7c18f5Db913155a4D587AE}); + sepoliaNetworkConfig = NetworkConfig({esuOracle: 0x8D26D407ebed4D03dE7c18f5Db913155a4D587AE}); } function getFujiEthConfig() public pure returns (NetworkConfig memory fujiNetworkConfig) { - fujiNetworkConfig = NetworkConfig({proofOfReserveOracle: 0x8F1C8888fBcd9Cc5D732df1e146d399a21899c22}); + fujiNetworkConfig = NetworkConfig({esuOracle: 0x8F1C8888fBcd9Cc5D732df1e146d399a21899c22}); } function getAvalancheEthConfig() public pure returns (NetworkConfig memory avalancheNetworkConfig) { revert("Feed address missing"); - avalancheNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); + avalancheNetworkConfig = NetworkConfig({esuOracle: address(0x0)}); } function getMainnetEthConfig() public pure returns (NetworkConfig memory mainnetNetworkConfig) { // no oracle on other chains than Avalanche - mainnetNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); + mainnetNetworkConfig = NetworkConfig({esuOracle: address(0x0)}); } function getOrCreateAnvilEthConfig() public view returns (NetworkConfig memory anvilNetworkConfig) { // Check to see if we set an active network config - if (activeNetworkConfig.proofOfReserveOracle != address(0)) { + if (activeNetworkConfig.esuOracle != address(0)) { return activeNetworkConfig; } //MockV3Aggregator mock = new MockV3Aggregator(PROOF_OF_RESERVE_MOCK); //console.log("Anvil oracle mock:", address(mock)); - //anvilNetworkConfig = NetworkConfig({proofOfReserveOracle: address(mock)}); - anvilNetworkConfig = NetworkConfig({proofOfReserveOracle: address(0x0)}); + //anvilNetworkConfig = NetworkConfig({esuOracle: address(mock)}); + anvilNetworkConfig = NetworkConfig({esuOracle: address(0x0)}); } } diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index e08a2e2..2568a18 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -17,13 +17,15 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; contract GEMxToken is + Initializable, ERC20Upgradeable, ERC20BurnableUpgradeable, ERC20PausableUpgradeable, AccessControlUpgradeable, OwnableUpgradeable, ERC20CustodianUpgradeable, - ERC20BlocklistUpgradeable + ERC20BlocklistUpgradeable, + ERC20PermitUpgradeable { error NotEnoughReserve(); @@ -36,7 +38,7 @@ contract GEMxToken is bytes32 public constant LIMITER_ROLE = keccak256("LIMITER_ROLE"); // block/unblock user /// @dev Controls whether minting restriction is active (on parent chain) or not (on any other chain). - uint256 public constant parentChainId = 43114; // Avalanche C-Chain + uint256 public constant PARENT_CHAIN_ID = 43114; // Avalanche C-Chain /* ESU Calculation: @@ -109,7 +111,7 @@ contract GEMxToken is function getMaxSupply() public view returns (uint256) { // no max supply restriction on child chains - if (block.chainid != parentChainId) { + if (block.chainid != PARENT_CHAIN_ID) { return type(uint256).max; } @@ -126,7 +128,7 @@ contract GEMxToken is { // 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 == parentChainId) { + if (block.chainid == PARENT_CHAIN_ID) { if (from == address(0) && totalSupply() + amount > getMaxSupply()) { revert NotEnoughReserve(); } diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index 41f5667..8af6bae 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -35,7 +35,7 @@ contract GEMxTokenTest is Test { token = deployer.run(); oracle = MockV3Aggregator(token.getOracleAddress()); - vm.chainId(token.parentChainId()); // set chain token's parent chain to enable full feature set with minting restrictio + vm.chainId(token.PARENT_CHAIN_ID()); // set chain token's parent chain to enable full feature set with minting restrictio // Grant roles vm.startPrank(DEFAULT_SENDER); @@ -64,7 +64,7 @@ contract GEMxTokenTest is Test { /*##################################################################################*/ function testMintOnAvalancheParentChainRespectsEsuOracle_And_EsuPerTokenSetting() public { - vm.chainId(token.parentChainId()); + vm.chainId(token.PARENT_CHAIN_ID()); int256 esu = 100 ether; _setEsu(esu); @@ -137,7 +137,7 @@ contract GEMxTokenTest is Test { } function testVerifyEsuCalculation() public { - vm.chainId(token.parentChainId()); + vm.chainId(token.PARENT_CHAIN_ID()); (uint256 esu, uint256 esuPrecision) = token.getEsuPerToken(); assertEq(esu, 1); From 3aa7ad9ac66398320a6621f2c0d6c1a17e6d1cdb Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Fri, 21 Mar 2025 10:21:10 +0100 Subject: [PATCH 24/25] align license --- script/HelperConfig.s.sol | 2 +- src/ERC20BlocklistUpgradeable.sol | 2 +- src/ERC20CustodianUpgradeable.sol | 2 +- src/GEMxToken.sol | 2 +- test/mocks/MockV3Aggregator.sol | 2 +- test/unit/GEMxTokenTest.t.sol | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/script/HelperConfig.s.sol b/script/HelperConfig.s.sol index 7d2d77b..8a07ff5 100644 --- a/script/HelperConfig.s.sol +++ b/script/HelperConfig.s.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.20; import {MockV3Aggregator} from "../test/mocks/MockV3Aggregator.sol"; diff --git a/src/ERC20BlocklistUpgradeable.sol b/src/ERC20BlocklistUpgradeable.sol index 145bc6e..9492a15 100644 --- a/src/ERC20BlocklistUpgradeable.sol +++ b/src/ERC20BlocklistUpgradeable.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.20; diff --git a/src/ERC20CustodianUpgradeable.sol b/src/ERC20CustodianUpgradeable.sol index 1630a3e..ed238e5 100644 --- a/src/ERC20CustodianUpgradeable.sol +++ b/src/ERC20CustodianUpgradeable.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.20; diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index 2568a18..12b2dd3 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.20; diff --git a/test/mocks/MockV3Aggregator.sol b/test/mocks/MockV3Aggregator.sol index 4a4fbd3..4f1feb0 100644 --- a/test/mocks/MockV3Aggregator.sol +++ b/test/mocks/MockV3Aggregator.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.20; import {AggregatorV3Interface} from "@chainlink/contracts/v0.8/shared/interfaces/AggregatorV3Interface.sol"; diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index 8af6bae..63b00cc 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.20; import {Test, console} from "lib/forge-std/src/Test.sol"; From 7a8838aa4e9147443b0c3cba29e7c19f3a0a37e5 Mon Sep 17 00:00:00 2001 From: Siegi Skalla Date: Fri, 21 Mar 2025 11:30:55 +0100 Subject: [PATCH 25/25] Allow update of oracle address by admin --- src/GEMxToken.sol | 4 ++++ test/unit/GEMxTokenTest.t.sol | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/GEMxToken.sol b/src/GEMxToken.sol index 12b2dd3..250d3d9 100644 --- a/src/GEMxToken.sol +++ b/src/GEMxToken.sol @@ -96,6 +96,10 @@ contract GEMxToken is return address(oracle); } + function setOracleAddress(address newAddress) external onlyRole(DEFAULT_ADMIN_ROLE) { + oracle = AggregatorV3Interface(newAddress); + } + function getEsuPerToken() external view returns (uint256, uint256) { return (esuPerTokenValue, esuPerTokenPrecision); } diff --git a/test/unit/GEMxTokenTest.t.sol b/test/unit/GEMxTokenTest.t.sol index 63b00cc..4e740a3 100644 --- a/test/unit/GEMxTokenTest.t.sol +++ b/test/unit/GEMxTokenTest.t.sol @@ -59,6 +59,26 @@ contract GEMxTokenTest is Test { assertEq(esuPrecision, 100, "Initial EsuPerToken is 0.01"); } + /*##################################################################################*/ + /*################################# Oracle Update ##################################*/ + /*##################################################################################*/ + + function testOnlyAdminCanUpdateOracle() public { + address currentOracleAddress = token.getOracleAddress(); + MockV3Aggregator newOracle = new MockV3Aggregator(1000); + + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, user, token.DEFAULT_ADMIN_ROLE()) + ); + vm.prank(user); + token.setOracleAddress(address(newOracle)); + assertEq(token.getOracleAddress(), currentOracleAddress); + + vm.prank(admin); + token.setOracleAddress(address(newOracle)); + assertEq(token.getOracleAddress(), address(newOracle)); + } + /*##################################################################################*/ /*###################################### ESU #######################################*/ /*##################################################################################*/