From f6e692c586d13da255e5c0f530cda46a54e3d39e Mon Sep 17 00:00:00 2001 From: stubbsta Date: Thu, 18 Sep 2025 12:28:09 +0200 Subject: [PATCH] Replace manual maxSupply with OZ ERC20CappedUpgradeable --- script/DeployTokenWithProxy.s.sol | 14 +++--- test/README.md | 6 +-- test/TestStableToken.sol | 69 +++++++++++++++++++--------- test/TestStableToken.t.sol | 75 +++++++++++++------------------ 4 files changed, 87 insertions(+), 77 deletions(-) diff --git a/script/DeployTokenWithProxy.s.sol b/script/DeployTokenWithProxy.s.sol index 4725005..3b45930 100644 --- a/script/DeployTokenWithProxy.s.sol +++ b/script/DeployTokenWithProxy.s.sol @@ -11,14 +11,14 @@ contract DeployTokenWithProxy is BaseScript { } function deploy() public returns (ERC1967Proxy) { - // Read desired max supply from env or use default - uint256 defaultMaxSupply = vm.envOr({ name: "MAX_SUPPLY", defaultValue: uint256(1_000_000 * 10 ** 18) }); + // Read desired token cap from env or use default + uint256 defaultCap = vm.envOr({ name: "TOKEN_CAP", defaultValue: uint256(1_000_000 * 10 ** 18) }); // Deploy the initial implementation address implementation = address(new TestStableToken()); - // Encode the initialize call (maxSupply) - bytes memory initData = abi.encodeCall(TestStableToken.initialize, (defaultMaxSupply)); + // Encode the initialize call (cap) + bytes memory initData = abi.encodeCall(TestStableToken.initialize, (defaultCap)); // Deploy the proxy with initialization data ERC1967Proxy proxy = new ERC1967Proxy(implementation, initData); @@ -27,9 +27,9 @@ contract DeployTokenWithProxy is BaseScript { // These revert the script if validation fails. address proxyAddr = address(proxy); - // Check maxSupply set - uint256 actualMax = TestStableToken(proxyAddr).maxSupply(); - if (actualMax != defaultMaxSupply) revert("Proxy maxSupply mismatch after initialization"); + // Check cap set + uint256 actualMax = TestStableToken(proxyAddr).cap(); + if (actualMax != defaultCap) revert("Proxy token cap mismatch after initialization"); return proxy; } diff --git a/test/README.md b/test/README.md index b8dc5d2..d7d16ff 100644 --- a/test/README.md +++ b/test/README.md @@ -21,7 +21,7 @@ token distribution while mimicking DAI's behaviour. ## Usage -Add environment variable `MAX_SUPPLY` to set the maximum supply of the token, otherwise it defaults to 10 million +Add environment variable `TOKEN_CAP` to set the maximum supply of the token, otherwise it defaults to 1 million tokens. ### Deploy new TestStableToken with proxy contract @@ -54,7 +54,7 @@ When upgrading a UUPS/ERC1967 proxy you should perform the upgrade and initializ leaving the proxy in an uninitialized state. Use `upgradeToAndCall(address,bytes)` with the initializer calldata. ```bash -# Encode the initializer calldata (example: set MAX_SUPPLY to 1_000_000 * 10**18) +# Encode the initializer calldata (example: set TOKEN_CAP to 1_000_000 * 10**18) DATA=$(cast abi-encode "initialize(uint256)" 1000000000000000000000000) # Perform upgrade and call initializer atomically @@ -63,7 +63,7 @@ cast send $TOKEN_PROXY_ADDRESS "upgradeToAndCall(address,bytes)" $NEW_IMPLEMENTA If you must call `upgradeTo` separately (not recommended), follow up immediately with an `initialize(...)` call in the same transaction or as the next transaction from the owner/multisig. However, prefer `upgradeToAndCall` to eliminate the -time window where the proxy points to a new implementation but its storage (e.g., `maxSupply`) is uninitialized. +time window where the proxy points to a new implementation but its storage (e.g., `cap`) is uninitialized. ### Add account to the allowlist to enable minting diff --git a/test/TestStableToken.sol b/test/TestStableToken.sol index 8197434..27e7d44 100644 --- a/test/TestStableToken.sol +++ b/test/TestStableToken.sol @@ -5,6 +5,8 @@ import { BaseScript } from "../script/Base.s.sol"; import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import { ERC20PermitUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; +import { ERC20CappedUpgradeable } from + "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20CappedUpgradeable.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; @@ -14,23 +16,24 @@ error AccountNotMinter(); error AccountAlreadyMinter(); error AccountNotInMinterList(); error InsufficientETH(); -error ExceedsMaxSupply(); -error InvalidMaxSupply(uint256 supplied); +error ExceedsCap(); contract TestStableToken is Initializable, - ERC20Upgradeable, + ERC20CappedUpgradeable, ERC20PermitUpgradeable, OwnableUpgradeable, UUPSUpgradeable { - mapping(address => bool) public isMinter; - uint256 public maxSupply; + mapping(address account => bool allowed) public isMinter; + + // mutable cap storage ( override cap() from ERC20CappedUpgradeable to return this) + uint256 private _mutableCap; event MinterAdded(address indexed account); event MinterRemoved(address indexed account); event ETHBurned(uint256 amount, address indexed minter, address indexed to, uint256 tokensMinted); - event MaxSupplySet(uint256 oldMaxSupply, uint256 newMaxSupply); + event CapSet(uint256 oldCap, uint256 newCap); modifier onlyOwnerOrMinter() { if (msg.sender != owner() && !isMinter[msg.sender]) revert("AccountNotMinter"); @@ -41,14 +44,16 @@ contract TestStableToken is _disableInitializers(); } - function initialize(uint256 _maxSupply) public initializer { + function initialize(uint256 initialCap) public initializer { __ERC20_init("TestStableToken", "TST"); __ERC20Permit_init("TestStableToken"); __Ownable_init(); __UUPSUpgradeable_init(); - if (_maxSupply == 0) revert InvalidMaxSupply(_maxSupply); + // initialize capped supply (parent init does internal checks) + __ERC20Capped_init(initialCap); - maxSupply = _maxSupply; + // our mutable cap storage (used by the overridden cap()) + _mutableCap = initialCap; } function _authorizeUpgrade(address newImplementation) internal override onlyOwner { } @@ -66,13 +71,15 @@ contract TestStableToken is } function mint(address to, uint256 amount) external onlyOwnerOrMinter { - if (totalSupply() + amount > maxSupply) revert ExceedsMaxSupply(); + // pre-check so we use custom error + if (totalSupply() + amount > cap()) revert ExceedsCap(); + // ERC20CappedUpgradeable::_mint will still enforce the cap as a safety _mint(to, amount); } function mintWithETH(address to) external payable { if (msg.value == 0) revert InsufficientETH(); - if (totalSupply() + msg.value > maxSupply) revert ExceedsMaxSupply(); + if (totalSupply() + msg.value > cap()) revert ExceedsCap(); // Burn ETH by sending to zero address payable(address(0)).transfer(msg.value); @@ -82,30 +89,48 @@ contract TestStableToken is emit ETHBurned(msg.value, msg.sender, to, msg.value); } - function setMaxSupply(uint256 _maxSupply) external onlyOwner { - if (_maxSupply < totalSupply()) revert ExceedsMaxSupply(); + // Returns the configured cap - override to use a mutable storage slot. + function cap() public view virtual override returns (uint256) { + return _mutableCap; + } - uint256 oldMaxSupply = maxSupply; - maxSupply = _maxSupply; + function setCap(uint256 newCap) external onlyOwner { + if (newCap < totalSupply()) revert ExceedsCap(); + uint256 old = _mutableCap; + _mutableCap = newCap; + emit CapSet(old, newCap); + } - emit MaxSupplySet(oldMaxSupply, _maxSupply); + // Solidity requires an explicit override when multiple base classes in the + // linearized inheritance chain declare the same function signature. Here + // both ERC20Upgradeable (base) and ERC20CappedUpgradeable (overrider) are + // present in the chain, so provide the override that forwards to super. + function _mint( + address account, + uint256 amount + ) + internal + virtual + override(ERC20CappedUpgradeable, ERC20Upgradeable) + { + super._mint(account, amount); } } contract TestStableTokenFactory is BaseScript { /// @notice Deploys the implementation and an ERC1967 proxy, initializing the proxy atomically. - /// @dev Reads `MAX_SUPPLY` from environment (wei). Defaults to 1_000_000 * 10**18. + /// @dev Reads `TOKEN_CAP` from environment (wei). Defaults to 1_000_000 * 10**18. function run() public broadcast returns (address) { - // Read desired max supply from env or use default - uint256 defaultMaxSupply = vm.envOr({ name: "MAX_SUPPLY", defaultValue: uint256(1_000_000 * 10 ** 18) }); + // Read desired token cap from env or use default + uint256 defaultCap = vm.envOr({ name: "TOKEN_CAP", defaultValue: uint256(1_000_000 * 10 ** 18) }); // Deploy the implementation address implementation = address(new TestStableToken()); - // Encode initializer calldata to run in proxy context (maxSupply) - bytes memory initData = abi.encodeCall(TestStableToken.initialize, (defaultMaxSupply)); + // Encode initializer calldata to run in proxy context (cap) + bytes memory initData = abi.encodeCall(TestStableToken.initialize, (defaultCap)); - // Deploy ERC1967Proxy with initialization data so storage (owner, maxSupply) is set atomically + // Deploy ERC1967Proxy with initialization data so storage (owner, cap) is set atomically ERC1967Proxy proxy = new ERC1967Proxy(implementation, initData); return address(proxy); diff --git a/test/TestStableToken.t.sol b/test/TestStableToken.t.sol index 82f20db..6b1abb2 100644 --- a/test/TestStableToken.t.sol +++ b/test/TestStableToken.t.sol @@ -8,8 +8,7 @@ import { AccountAlreadyMinter, AccountNotInMinterList, InsufficientETH, - ExceedsMaxSupply, - InvalidMaxSupply + ExceedsCap } from "./TestStableToken.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import { DeployTokenWithProxy } from "../script/DeployTokenWithProxy.s.sol"; @@ -25,7 +24,7 @@ contract TestStableTokenTest is Test { event MinterAdded(address indexed account); event MinterRemoved(address indexed account); event ETHBurned(uint256 amount, address indexed minter, address indexed to, uint256 tokensMinted); - event MaxSupplySet(uint256 oldMaxSupply, uint256 newMaxSupply); + event CapSet(uint256 oldCap, uint256 newCap); function setUp() public { // Deploy using the deployment script @@ -278,77 +277,63 @@ contract TestStableTokenTest is Test { token.mintWithETH{ value: 0 }(user2); } - function test__MaxSupplyIsSetCorrectly() external { - // maxSupply should be set to 1000000 * 10^18 by deployment script - uint256 expectedMaxSupply = 1_000_000 * 10 ** 18; - assertEq(token.maxSupply(), expectedMaxSupply); + function test__CapIsSetCorrectly() external { + // Cap should be set to 1000000 * 10^18 by deployment script + uint256 expectedCap = 1_000_000 * 10 ** 18; + assertEq(token.cap(), expectedCap); } - function test__CannotMintExceedingMaxSupply() external { - uint256 currentMaxSupply = token.maxSupply(); + function test__CannotMintExceedingCap() external { + uint256 currentCap = token.cap(); - // Try to mint more than maxSupply + // Try to mint more than cap vm.prank(owner); - vm.expectRevert(abi.encodeWithSelector(ExceedsMaxSupply.selector)); - token.mint(user1, currentMaxSupply + 1); + vm.expectRevert(abi.encodeWithSelector(ExceedsCap.selector)); + token.mint(user1, currentCap + 1); } - function test__CannotMintWithETHExceedingMaxSupply() external { - uint256 currentMaxSupply = token.maxSupply(); - // Send an amount of ETH that would exceed maxSupply when minted as tokens - uint256 ethAmount = currentMaxSupply + 1; + function test__CannotMintWithETHExceedingCap() external { + uint256 currentCap = token.cap(); + // Send an amount of ETH that would exceed cap when minted as tokens + uint256 ethAmount = currentCap + 1; - // Try to mint more than maxSupply with ETH + // Try to mint more than cap with ETH vm.deal(owner, ethAmount); vm.prank(owner); - vm.expectRevert(abi.encodeWithSelector(ExceedsMaxSupply.selector)); + vm.expectRevert(abi.encodeWithSelector(ExceedsCap.selector)); token.mintWithETH{ value: ethAmount }(user1); } - function test__OwnerCanSetMaxSupply() external { - uint256 newMaxSupply = 2_000_000 * 10 ** 18; - uint256 oldMaxSupply = token.maxSupply(); + function test__OwnerCanSetCap() external { + uint256 newCap = 2_000_000 * 10 ** 18; + uint256 oldCap = token.cap(); vm.expectEmit(true, true, false, false); - emit MaxSupplySet(oldMaxSupply, newMaxSupply); + emit CapSet(oldCap, newCap); vm.prank(owner); - token.setMaxSupply(newMaxSupply); + token.setCap(newCap); - assertEq(token.maxSupply(), newMaxSupply); + assertEq(token.cap(), newCap); } - function test__CannotSetMaxSupplyBelowTotalSupply() external { + function test__CannotSetCapBelowTotalSupply() external { // First mint some tokens uint256 mintAmount = 1000 ether; vm.prank(owner); token.mint(user1, mintAmount); - // Try to set maxSupply below current totalSupply + // Try to set cap below current totalSupply vm.prank(owner); - vm.expectRevert(abi.encodeWithSelector(ExceedsMaxSupply.selector)); - token.setMaxSupply(mintAmount - 1); + vm.expectRevert(abi.encodeWithSelector(ExceedsCap.selector)); + token.setCap(mintAmount - 1); } - function test__NonOwnerCannotSetMaxSupply() external { - uint256 newMaxSupply = 2_000_000 * 10 ** 18; + function test__NonOwnerCannotSetCap() external { + uint256 newCap = 2_000_000 * 10 ** 18; vm.prank(user1); vm.expectRevert("Ownable: caller is not the owner"); - token.setMaxSupply(newMaxSupply); - } - - function test__InitializeZeroReverts() external { - // Deploy implementation directly - TestStableToken implementation = new TestStableToken(); - - // Build initializer calldata with zero - bytes memory initData = abi.encodeCall(TestStableToken.initialize, (uint256(0))); - - // Expect the InvalidMaxSupply reversion including the supplied value - vm.expectRevert(abi.encodeWithSelector(InvalidMaxSupply.selector, uint256(0))); - - // Attempt to deploy proxy with initData - should revert - new ERC1967Proxy(address(implementation), initData); + token.setCap(newCap); } }