From 67153172740433a7c90d3392f9e5023bea3a1d05 Mon Sep 17 00:00:00 2001 From: Thomas Clement Date: Wed, 5 Nov 2025 13:38:33 -0500 Subject: [PATCH 1/4] Add Minter module to frxUSD oft --- contracts/frxUsd/FrxUSDOFTUpgradeable.sol | 32 ++++- contracts/modules/MinterModule.sol | 111 ++++++++++++++++++ foundry.toml | 3 + .../frxUsd/FrxUSDOFTUpgradeableTest.t.sol | 52 ++++++++ 4 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 contracts/modules/MinterModule.sol diff --git a/contracts/frxUsd/FrxUSDOFTUpgradeable.sol b/contracts/frxUsd/FrxUSDOFTUpgradeable.sol index 9e391e1a..50f53c4e 100644 --- a/contracts/frxUsd/FrxUSDOFTUpgradeable.sol +++ b/contracts/frxUsd/FrxUSDOFTUpgradeable.sol @@ -8,14 +8,15 @@ import { EIP3009Module } from "contracts/modules/EIP3009Module.sol"; import { PermitModule } from "contracts/modules/PermitModule.sol"; import { SendParam } from "@fraxfinance/layerzero-v2-upgradeable/oapp/contracts/oft/interfaces/IOFT.sol"; import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import { MinterModule } from "contracts/modules/MinterModule.sol"; -contract FrxUSDOFTUpgradeable is OFTUpgradeable, EIP3009Module, PermitModule, FreezeThawModule, PauseModule { +contract FrxUSDOFTUpgradeable is OFTUpgradeable, EIP3009Module, PermitModule, FreezeThawModule, PauseModule, MinterModule { constructor(address _lzEndpoint) OFTUpgradeable(_lzEndpoint) { _disableInitializers(); } function version() public pure returns (string memory) { - return "1.1.0"; + return "1.2.0"; } /// @dev overrides state where previous OFT versions were named the legacy "FRAX" @@ -124,6 +125,28 @@ contract FrxUSDOFTUpgradeable is OFTUpgradeable, EIP3009Module, PermitModule, Fr _unpause(); } + /// @notice Used by minters to mint new tokens + /// @param m_address Address of the account to mint to + /// @param m_amount Amount of tokens to mint + /// @dev Added in v1.2.0 + function minter_mint(address m_address, uint256 m_amount) external onlyMinters { + _minter_mint(m_address, m_amount); + } + + /// @notice Adds a minter + /// @param minter_address Address of minter to add + /// @dev Added in v1.2.0 + function addMinter(address minter_address) external onlyOwner { + _addMinter(minter_address); + } + + /// @notice Removes a non-bridge minter + /// @param minter_address Address of minter to remove + /// @dev Added in v1.2.0 + function removeMinter(address minter_address) external onlyOwner { + _removeMinter(minter_address); + } + function _beforeTokenTransfer( address from, address to, @@ -177,6 +200,11 @@ contract FrxUSDOFTUpgradeable is OFTUpgradeable, EIP3009Module, PermitModule, Fr function _approve(address owner, address spender, uint256 amount) internal override(PermitModule, ERC20Upgradeable) { return ERC20Upgradeable._approve(owner, spender, amount); } + + /// @dev supports minter module + function _mint(address account, uint256 value) internal override(MinterModule, ERC20Upgradeable){ + ERC20Upgradeable._mint(account, value); + } /* ========== ERRORS ========== */ error ArrayMisMatch(); diff --git a/contracts/modules/MinterModule.sol b/contracts/modules/MinterModule.sol new file mode 100644 index 00000000..55045a47 --- /dev/null +++ b/contracts/modules/MinterModule.sol @@ -0,0 +1,111 @@ +pragma solidity ^0.8.0; + +abstract contract MinterModule { + + //============================================================================== + // Storage + //============================================================================== + + struct MinterModuleStorage { + address[] minters_array; + mapping(address => bool) minters; + } + + // keccak256(abi.encode(uint256(keccak256("frax.storage.MinterModulde")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant MinterModuleStorageLocation = 0x16de46d3f16cc00f05a39b90c9dbb3c2a12f55cc3d88865db990f6fa55fae300; + + function _getMinterModuleStorage() private pure returns(MinterModuleStorage storage $) { + assembly { + $.slot := MinterModuleStorageLocation + } + } + + /// @notice A modifier that only allows a minters to call + modifier onlyMinters() { + MinterModuleStorage storage $ = _getMinterModuleStorage(); + if (!$.minters[msg.sender]) revert OnlyMinter(); + _; + } + + + /// @notice Adds a minter + /// @param minter_address Address of minter to add + function _addMinter(address minter_address) internal { + MinterModuleStorage storage $ = _getMinterModuleStorage(); + if ($.minters[minter_address]) revert AlreadyExists(); + $.minters[minter_address] = true; + $.minters_array.push(minter_address); + emit MinterAdded(minter_address); + } + + /// @notice Removes a non-bridge minter + /// @param minter_address Address of minter to remove + function _removeMinter(address minter_address) internal { + MinterModuleStorage storage $ = _getMinterModuleStorage(); + if (!$.minters[minter_address]) revert AddressNonexistant(); + delete $.minters[minter_address]; + for (uint256 i = 0; i < $.minters_array.length; i++) { + if ($.minters_array[i] == minter_address) { + $.minters_array[i] = address(0); // This will leave a null in the array and keep the indices the same + break; + } + } + emit MinterRemoved(minter_address); + } + + /// @notice Used by minters to mint new tokens + /// @param m_address Address of the account to mint to + /// @param m_amount Amount of tokens to mint + function _minter_mint(address m_address, uint256 m_amount) internal { + _mint(m_address, m_amount); + emit TokenMinterMinted(msg.sender, m_address, m_amount); + } + + + //============================================================================== + // Overridden methods + //============================================================================== + + function _mint(address,uint256) internal virtual; + + //============================================================================== + // Views + //============================================================================== + + function minters(address _address) public view returns(bool) { + MinterModuleStorage storage $ = _getMinterModuleStorage(); + return $.minters[_address]; + } + + function minters_array(uint256 _idx) public view returns(address) { + MinterModuleStorage storage $ = _getMinterModuleStorage(); + return $.minters_array[_idx]; + } + + //============================================================================== + // Events + //============================================================================== + + /// @notice Emitted when a non-bridge minter is added + /// @param minter_address Address of the new minter + event MinterAdded(address minter_address); + + /// @notice Emitted when a non-bridge minter is removed + /// @param minter_address Address of the removed minter + event MinterRemoved(address minter_address); + + /// @notice Emitted when a non-bridge minter mints tokens + /// @param from The minter doing the minting + /// @param to The account that gets the newly minted tokens + /// @param amount Amount of tokens minted + event TokenMinterMinted(address indexed from, address indexed to, uint256 amount); + + //============================================================================== + // Errors + //============================================================================== + + error OnlyMinter(); + error ZeroAddress(); + error AlreadyExists(); + error AddressNonexistant(); +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index a6436b6a..fa8ea66c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -31,3 +31,6 @@ remappings = [ via_ir = false optimizer = true optimizer_runs = 200 + +[lint] +lint_on_build = false \ No newline at end of file diff --git a/test/foundry/contracts/frxUsd/FrxUSDOFTUpgradeableTest.t.sol b/test/foundry/contracts/frxUsd/FrxUSDOFTUpgradeableTest.t.sol index 24950ccf..98fe1a13 100644 --- a/test/foundry/contracts/frxUsd/FrxUSDOFTUpgradeableTest.t.sol +++ b/test/foundry/contracts/frxUsd/FrxUSDOFTUpgradeableTest.t.sol @@ -383,4 +383,56 @@ contract FrxUSDOFTUpgradeableTest is FraxTest { vm.expectRevert(); oft.burnMany(accounts, amounts); } + + + function test_onlyOwner_addMinter() external { + vm.expectRevert(bytes("Ownable: caller is not the owner")); + oft.addMinter(al); + } + + function test_onlyOwner_removeMinter() external { + vm.expectRevert(bytes("Ownable: caller is not the owner")); + oft.removeMinter(al); + } + + function test_canAddMinter() public { + vm.prank(oft.owner()); + oft.addMinter(al); + + assertEq({ + a: oft.minters(al), + b: true + }); + address _m = oft.minters_array(0); + assertEq({ + a: _m, + b: al + }); + } + + function test_minterCanMint() external { + test_canAddMinter(); + + uint tsBefore = oft.totalSupply(); + uint balAlBefore = oft.balanceOf(al); + vm.prank(al); + oft.minter_mint(address(0x39383928), 100e18); + uint tsAfter = oft.totalSupply(); + uint balAlAfter = oft.balanceOf(address(0x39383928)); + + assertEq({ + a: tsAfter - tsBefore, + b: 100e18 + }); + assertEq({ + a: balAlAfter - balAlBefore, + b: 100e18 + }); + } + + function test_onlyMinterCanMint() external { + vm.expectRevert(bytes4(keccak256("OnlyMinter()"))); + vm.prank(al); + oft.minter_mint(address(0x39383928), 100e18); + } } From 886d7c21d96f4acbef593a65f67d7a82872f38d8 Mon Sep 17 00:00:00 2001 From: Thomas Clement Date: Wed, 5 Nov 2025 13:47:09 -0500 Subject: [PATCH 2/4] Fix derived hash --- contracts/modules/MinterModule.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/modules/MinterModule.sol b/contracts/modules/MinterModule.sol index 55045a47..1a812878 100644 --- a/contracts/modules/MinterModule.sol +++ b/contracts/modules/MinterModule.sol @@ -11,8 +11,8 @@ abstract contract MinterModule { mapping(address => bool) minters; } - // keccak256(abi.encode(uint256(keccak256("frax.storage.MinterModulde")) - 1)) & ~bytes32(uint256(0xff)) - bytes32 private constant MinterModuleStorageLocation = 0x16de46d3f16cc00f05a39b90c9dbb3c2a12f55cc3d88865db990f6fa55fae300; + // keccak256(abi.encode(uint256(keccak256("frax.storage.MinterModule")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant MinterModuleStorageLocation = 0x7a20b3b4fafc14b62295555dcdd80cd62ae312ce9abdfc5568be6a1913cbf700; function _getMinterModuleStorage() private pure returns(MinterModuleStorage storage $) { assembly { From 0b1debdc4366e78298a2e10aab0c889f7aea88be Mon Sep 17 00:00:00 2001 From: Thomas Clement Date: Thu, 6 Nov 2025 10:44:26 -0500 Subject: [PATCH 3/4] minter_burn_from Add the minter_burn_from function as well as monotonic accounting variables --- contracts/frxUsd/FrxUSDOFTUpgradeable.sol | 15 ++++++ contracts/modules/MinterModule.sol | 44 ++++++++++++++++- .../frxUsd/FrxUSDOFTUpgradeableTest.t.sol | 47 +++++++++++++++++-- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/contracts/frxUsd/FrxUSDOFTUpgradeable.sol b/contracts/frxUsd/FrxUSDOFTUpgradeable.sol index 50f53c4e..4fbcb547 100644 --- a/contracts/frxUsd/FrxUSDOFTUpgradeable.sol +++ b/contracts/frxUsd/FrxUSDOFTUpgradeable.sol @@ -133,6 +133,13 @@ contract FrxUSDOFTUpgradeable is OFTUpgradeable, EIP3009Module, PermitModule, Fr _minter_mint(m_address, m_amount); } + /// @notice Used by minters to burn tokens + /// @param b_address Address of the account to burn from + /// @param b_amount Amount of tokens to burn + function minter_burn_from(address b_address, uint256 b_amount) external onlyMinters { + _minter_burn_from(b_address, b_amount); + } + /// @notice Adds a minter /// @param minter_address Address of minter to add /// @dev Added in v1.2.0 @@ -205,6 +212,14 @@ contract FrxUSDOFTUpgradeable is OFTUpgradeable, EIP3009Module, PermitModule, Fr function _mint(address account, uint256 value) internal override(MinterModule, ERC20Upgradeable){ ERC20Upgradeable._mint(account, value); } + + function _spendAllowance(address owner, address spender, uint256 amount) internal override(MinterModule, ERC20Upgradeable) { + ERC20Upgradeable._spendAllowance(owner, spender, amount); + } + + function _burn(address account, uint256 amount) internal override(MinterModule, ERC20Upgradeable) { + ERC20Upgradeable._burn(account, amount); + } /* ========== ERRORS ========== */ error ArrayMisMatch(); diff --git a/contracts/modules/MinterModule.sol b/contracts/modules/MinterModule.sol index 1a812878..188e60c0 100644 --- a/contracts/modules/MinterModule.sol +++ b/contracts/modules/MinterModule.sol @@ -9,6 +9,8 @@ abstract contract MinterModule { struct MinterModuleStorage { address[] minters_array; mapping(address => bool) minters; + uint256 totalMinted; + uint256 totalBurned; } // keccak256(abi.encode(uint256(keccak256("frax.storage.MinterModule")) - 1)) & ~bytes32(uint256(0xff)) @@ -27,7 +29,6 @@ abstract contract MinterModule { _; } - /// @notice Adds a minter /// @param minter_address Address of minter to add function _addMinter(address minter_address) internal { @@ -57,16 +58,41 @@ abstract contract MinterModule { /// @param m_address Address of the account to mint to /// @param m_amount Amount of tokens to mint function _minter_mint(address m_address, uint256 m_amount) internal { + MinterModuleStorage storage $ = _getMinterModuleStorage(); _mint(m_address, m_amount); + $.totalMinted += m_amount; emit TokenMinterMinted(msg.sender, m_address, m_amount); } + /// @notice Used by minters to burn tokens + /// @param b_address Address of the account to burn from + /// @param b_amount Amount of tokens to burn + function _minter_burn_from(address b_address, uint256 b_amount) internal { + MinterModuleStorage storage $ = _getMinterModuleStorage(); + _burnFrom(b_address, b_amount); + $.totalBurned += b_amount; + emit TokenMinterBurned(b_address, msg.sender, b_amount); + } + + /// @notice Destroys a `value` amount of tokens from `account`, deducting from + /// the caller's allowance. + /// @param account Account to burn tokens from + /// @param value the amount to burn from account caller must have allowance + function _burnFrom(address account, uint256 value) internal { + _spendAllowance(account, msg.sender, value); + _burn(account, value); + } + //============================================================================== // Overridden methods //============================================================================== function _mint(address,uint256) internal virtual; + + function _burn(address,uint256) internal virtual; + + function _spendAllowance(address,address,uint256) internal virtual; //============================================================================== // Views @@ -82,6 +108,16 @@ abstract contract MinterModule { return $.minters_array[_idx]; } + function totalMinted() public view returns(uint256) { + MinterModuleStorage storage $ = _getMinterModuleStorage(); + return $.totalMinted; + } + + function totalBurned() public view returns(uint256) { + MinterModuleStorage storage $ = _getMinterModuleStorage(); + return $.totalBurned; + } + //============================================================================== // Events //============================================================================== @@ -100,6 +136,12 @@ abstract contract MinterModule { /// @param amount Amount of tokens minted event TokenMinterMinted(address indexed from, address indexed to, uint256 amount); + /// @notice Emitted when a non-bridge minter burns tokens + /// @param from The account whose tokens are burned + /// @param to The minter doing the burning + /// @param amount Amount of tokens burned + event TokenMinterBurned(address indexed from, address indexed to, uint256 amount); + //============================================================================== // Errors //============================================================================== diff --git a/test/foundry/contracts/frxUsd/FrxUSDOFTUpgradeableTest.t.sol b/test/foundry/contracts/frxUsd/FrxUSDOFTUpgradeableTest.t.sol index 98fe1a13..16811665 100644 --- a/test/foundry/contracts/frxUsd/FrxUSDOFTUpgradeableTest.t.sol +++ b/test/foundry/contracts/frxUsd/FrxUSDOFTUpgradeableTest.t.sol @@ -410,22 +410,26 @@ contract FrxUSDOFTUpgradeableTest is FraxTest { }); } - function test_minterCanMint() external { + function test_minterCanMint() public { test_canAddMinter(); uint tsBefore = oft.totalSupply(); - uint balAlBefore = oft.balanceOf(al); + uint balUserBefore = oft.balanceOf(address(bob)); vm.prank(al); - oft.minter_mint(address(0x39383928), 100e18); + oft.minter_mint(address(bob), 100e18); uint tsAfter = oft.totalSupply(); - uint balAlAfter = oft.balanceOf(address(0x39383928)); + uint balUserAfter = oft.balanceOf(address(bob)); assertEq({ a: tsAfter - tsBefore, b: 100e18 }); assertEq({ - a: balAlAfter - balAlBefore, + a: balUserAfter - balUserBefore, + b: 100e18 + }); + assertEq({ + a: oft.totalMinted(), b: 100e18 }); } @@ -435,4 +439,37 @@ contract FrxUSDOFTUpgradeableTest is FraxTest { vm.prank(al); oft.minter_mint(address(0x39383928), 100e18); } + + function test_onlyMinterCanBurn() external { + vm.expectRevert(bytes4(keccak256("OnlyMinter()"))); + vm.prank(al); + oft.minter_burn_from(address(0x39383928), 100e18); + } + + function test_minterCanBurn() external { + test_minterCanMint(); + vm.prank(bob); + oft.approve(al, 100e18); + + + uint tsBefore = oft.totalSupply(); + uint balBobBefore = oft.balanceOf(bob); + vm.prank(al); + oft.minter_burn_from(bob, 100e18); + uint tsAfter = oft.totalSupply(); + uint balBobAfter = oft.balanceOf(bob); + + assertEq({ + a: tsBefore - tsAfter, + b: 100e18 + }); + assertEq({ + a: balBobBefore - balBobAfter, + b: 100e18 + }); + assertEq({ + a: oft.totalBurned(), + b: 100e18 + }); + } } From 705aea54c1072072b7b22e5cf777baf71a905f50 Mon Sep 17 00:00:00 2001 From: Thomas Clement Date: Wed, 12 Nov 2025 13:50:51 -0500 Subject: [PATCH 4/4] Update MinterModule.sol --- contracts/modules/MinterModule.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/modules/MinterModule.sol b/contracts/modules/MinterModule.sol index 188e60c0..1307b9a8 100644 --- a/contracts/modules/MinterModule.sol +++ b/contracts/modules/MinterModule.sol @@ -98,22 +98,22 @@ abstract contract MinterModule { // Views //============================================================================== - function minters(address _address) public view returns(bool) { + function minters(address _address) external view returns(bool) { MinterModuleStorage storage $ = _getMinterModuleStorage(); return $.minters[_address]; } - function minters_array(uint256 _idx) public view returns(address) { + function minters_array(uint256 _idx) external view returns(address) { MinterModuleStorage storage $ = _getMinterModuleStorage(); return $.minters_array[_idx]; } - function totalMinted() public view returns(uint256) { + function totalMinted() external view returns(uint256) { MinterModuleStorage storage $ = _getMinterModuleStorage(); return $.totalMinted; } - function totalBurned() public view returns(uint256) { + function totalBurned() external view returns(uint256) { MinterModuleStorage storage $ = _getMinterModuleStorage(); return $.totalBurned; }