From 71191ce151c8eca36f313a538a679948f417ecf5 Mon Sep 17 00:00:00 2001 From: Tanya S <120410716+stubbsta@users.noreply.github.com> Date: Thu, 11 Sep 2025 10:45:43 +0200 Subject: [PATCH] chore: Add mint function that requires ETH to burn (#33) * remove ownable to clear compiler error * Add mintWithEth function to TST to burn Eth * Update test/README.md with mintWithETH usage * remove unnecessary 'revert ETHTransferFailed' in mintWithETH * Move emit functions to top of TestStabletoken.t.sol script * Add max token supply mechanism for TST * Linting fix * Update max eth used in WakuRlnv2 test Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Use 1 to 1 eth burn per token ratio --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- script/DeployTokenWithProxy.s.sol | 2 +- src/LinearPriceCalculator.sol | 2 +- test/README.md | 12 ++ test/TestStableToken.sol | 31 ++++- test/TestStableToken.t.sol | 195 +++++++++++++++++++++++++----- test/WakuRlnV2.t.sol | 46 ++++++- 6 files changed, 253 insertions(+), 35 deletions(-) diff --git a/script/DeployTokenWithProxy.s.sol b/script/DeployTokenWithProxy.s.sol index 660eb71..c61561f 100644 --- a/script/DeployTokenWithProxy.s.sol +++ b/script/DeployTokenWithProxy.s.sol @@ -15,7 +15,7 @@ contract DeployTokenWithProxy is BaseScript { address implementation = address(new TestStableToken()); // Encode the initialize call - bytes memory data = abi.encodeCall(TestStableToken.initialize, ()); + bytes memory data = abi.encodeCall(TestStableToken.initialize, (1_000_000 * 10 ** 18)); // Deploy the proxy with initialization data return new ERC1967Proxy(implementation, data); diff --git a/src/LinearPriceCalculator.sol b/src/LinearPriceCalculator.sol index 70d0664..500fdd8 100644 --- a/src/LinearPriceCalculator.sol +++ b/src/LinearPriceCalculator.sol @@ -15,7 +15,7 @@ contract LinearPriceCalculator is IPriceCalculator, Ownable { /// @notice The price per message per epoch uint256 public pricePerMessagePerEpoch; - constructor(address _token, uint256 _pricePerMessagePerEpoch) Ownable() { + constructor(address _token, uint256 _pricePerMessagePerEpoch) { _setTokenAndPrice(_token, _pricePerMessagePerEpoch); } diff --git a/test/README.md b/test/README.md index 1fe358f..34ac908 100644 --- a/test/README.md +++ b/test/README.md @@ -60,10 +60,22 @@ cast send $TOKEN_PROXY_ADDRESS "addMinter(address)" $ACCOUNT_ADDRESS --rpc-url $ ### Mint tokens to the account +#### Option 1: Restricted minting (requires minter privileges) + ```bash cast send $TOKEN_PROXY_ADDRESS "mint(address,uint256)" --rpc-url $RPC_URL --private-key $MINTER_ACCOUNT_PRIVATE_KEY ``` +#### Option 2: Public minting by burning ETH (no privileges required) + +```bash +cast send $TOKEN_PROXY_ADDRESS "mintWithETH(address,uint256)" --value --rpc-url $RPC_URL --private-key $MINTING_ACCOUNT_PRIVATE_KEY --from $MINTING_ACCOUNT_ADDRESS +``` + +**Note**: The `mintWithETH` function is public and can be called by anyone. It requires sending ETH with the transaction +(using `--value`), which gets burned (sent to address(0)) as an economic cost for minting tokens. This provides a +permissionless way to obtain tokens for testing without requiring minter privileges. + ### Approve the token for the waku-rlnv2-contract to use ```bash diff --git a/test/TestStableToken.sol b/test/TestStableToken.sol index 13fd482..1125ebf 100644 --- a/test/TestStableToken.sol +++ b/test/TestStableToken.sol @@ -12,6 +12,8 @@ import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils error AccountNotMinter(); error AccountAlreadyMinter(); error AccountNotInMinterList(); +error InsufficientETH(); +error ExceedsMaxSupply(); contract TestStableToken is Initializable, @@ -21,9 +23,12 @@ contract TestStableToken is UUPSUpgradeable { mapping(address => bool) public isMinter; + uint256 public maxSupply; 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); modifier onlyOwnerOrMinter() { if (msg.sender != owner() && !isMinter[msg.sender]) revert AccountNotMinter(); @@ -34,11 +39,13 @@ contract TestStableToken is _disableInitializers(); } - function initialize() public initializer { + function initialize(uint256 _maxSupply) public initializer { __ERC20_init("TestStableToken", "TST"); __ERC20Permit_init("TestStableToken"); __Ownable_init(); __UUPSUpgradeable_init(); + + maxSupply = _maxSupply; } function _authorizeUpgrade(address newImplementation) internal override onlyOwner { } @@ -56,8 +63,30 @@ contract TestStableToken is } function mint(address to, uint256 amount) external onlyOwnerOrMinter { + if (totalSupply() + amount > maxSupply) revert ExceedsMaxSupply(); _mint(to, amount); } + + function mintWithETH(address to) external payable { + if (msg.value == 0) revert InsufficientETH(); + if (totalSupply() + msg.value > maxSupply) revert ExceedsMaxSupply(); + + // Burn ETH by sending to zero address + payable(address(0)).transfer(msg.value); + + _mint(to, msg.value); + + emit ETHBurned(msg.value, msg.sender, to, msg.value); + } + + function setMaxSupply(uint256 _maxSupply) external onlyOwner { + if (_maxSupply < totalSupply()) revert ExceedsMaxSupply(); + + uint256 oldMaxSupply = maxSupply; + maxSupply = _maxSupply; + + emit MaxSupplySet(oldMaxSupply, _maxSupply); + } } contract TestStableTokenFactory is BaseScript { diff --git a/test/TestStableToken.t.sol b/test/TestStableToken.t.sol index eff78c1..7bf8f0b 100644 --- a/test/TestStableToken.t.sol +++ b/test/TestStableToken.t.sol @@ -2,7 +2,14 @@ pragma solidity >=0.8.19 <0.9.0; import { Test } from "forge-std/Test.sol"; -import { TestStableToken, AccountNotMinter, AccountAlreadyMinter, AccountNotInMinterList } from "./TestStableToken.sol"; +import { + TestStableToken, + AccountNotMinter, + AccountAlreadyMinter, + AccountNotInMinterList, + InsufficientETH, + ExceedsMaxSupply +} from "./TestStableToken.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import { DeployTokenWithProxy } from "../script/DeployTokenWithProxy.s.sol"; @@ -14,6 +21,11 @@ contract TestStableTokenTest is Test { address internal user2; address internal nonMinter; + 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); + function setUp() public { // Deploy using the deployment script deployer = new DeployTokenWithProxy(); @@ -51,6 +63,9 @@ contract TestStableTokenTest is Test { function test__OwnerCanMintWithoutMinterRole() external { uint256 mintAmount = 1000 ether; + // Owner is not in minter role but should still be able to mint + assertFalse(token.isMinter(owner)); + vm.prank(owner); token.mint(user1, mintAmount); @@ -134,16 +149,6 @@ contract TestStableTokenTest is Test { token.mint(user2, mintAmount); } - function test__OwnerCanAlwaysMintEvenWithoutMinterRole() external { - uint256 mintAmount = 500 ether; - - // Owner is not in minter role but should still be able to mint - assertFalse(token.isMinter(owner)); - vm.prank(owner); - token.mint(user1, mintAmount); - assertEq(token.balanceOf(user1), mintAmount); - } - function test__CheckMinterRoleMapping() external { assertFalse(token.isMinter(user1)); assertFalse(token.isMinter(user2)); @@ -164,24 +169,6 @@ contract TestStableTokenTest is Test { assertTrue(token.isMinter(user2)); } - function test__ERC20BasicFunctionality() external { - vm.prank(owner); - token.addMinter(user1); - uint256 mintAmount = 1000 ether; - - vm.prank(user1); - token.mint(user2, mintAmount); - - assertEq(token.balanceOf(user2), mintAmount); - assertEq(token.totalSupply(), mintAmount); - - vm.prank(user2); - assertTrue(token.transfer(owner, 200 ether)); - - assertEq(token.balanceOf(user2), 800 ether); - assertEq(token.balanceOf(owner), 200 ether); - } - function test__MinterAddedEventEmitted() external { vm.expectEmit(true, true, false, false); emit MinterAdded(user1); @@ -201,6 +188,152 @@ contract TestStableTokenTest is Test { token.removeMinter(user1); } - event MinterAdded(address indexed account); - event MinterRemoved(address indexed account); + function test__MintRequiresETH() external { + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(InsufficientETH.selector)); + token.mintWithETH(user1); + } + + function test__ERC20BasicFunctionality() external { + uint256 ethAmount = 0.1 ether; + + vm.deal(user1, ethAmount); + vm.prank(user1); + token.mintWithETH{ value: ethAmount }(user2); + + assertEq(token.balanceOf(user2), ethAmount); + assertEq(token.totalSupply(), ethAmount); + + vm.prank(user2); + assertTrue(token.transfer(owner, 0.05 ether)); + + assertEq(token.balanceOf(user2), 0.05 ether); + assertEq(token.balanceOf(owner), 0.05 ether); + } + + function test__ETHBurnedEventEmitted() external { + uint256 ethAmount = 0.1 ether; + + vm.deal(owner, ethAmount); + + vm.expectEmit(true, true, true, true); + emit ETHBurned(ethAmount, owner, user1, ethAmount); + + vm.prank(owner); + token.mintWithETH{ value: ethAmount }(user1); + } + + function test__ETHIsBurnedToZeroAddress() external { + uint256 ethAmount = 0.1 ether; + address zeroAddress = address(0); + + uint256 zeroBalanceBefore = zeroAddress.balance; + + vm.deal(owner, ethAmount); + vm.prank(owner); + token.mintWithETH{ value: ethAmount }(user1); + + // ETH should be burned to zero address + assertEq(zeroAddress.balance, zeroBalanceBefore + ethAmount); + } + + function test__ContractDoesNotHoldETHAfterMint() external { + uint256 ethAmount = 0.1 ether; + + uint256 contractBalanceBefore = address(token).balance; + + vm.deal(owner, ethAmount); + vm.prank(owner); + token.mintWithETH{ value: ethAmount }(user1); + + // Contract should not hold any ETH after mint + assertEq(address(token).balance, contractBalanceBefore); + } + + function test__MintWithDifferentETHAmounts() external { + uint256[] memory ethAmounts = new uint256[](3); + ethAmounts[0] = 0.01 ether; + ethAmounts[1] = 1 ether; + ethAmounts[2] = 10 ether; + + for (uint256 i = 0; i < ethAmounts.length; i++) { + address user = vm.addr(i + 10); + vm.deal(owner, ethAmounts[i]); + + vm.expectEmit(true, true, true, true); + emit ETHBurned(ethAmounts[i], owner, user, ethAmounts[i]); + + vm.prank(owner); + token.mintWithETH{ value: ethAmounts[i] }(user); + + assertEq(token.balanceOf(user), ethAmounts[i]); + } + } + + function test__CannotMintWithZeroETH() external { + // Anyone can call mintWithETH (public function), but it requires ETH + vm.prank(user1); + vm.expectRevert(abi.encodeWithSelector(InsufficientETH.selector)); + 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__CannotMintExceedingMaxSupply() external { + uint256 currentMaxSupply = token.maxSupply(); + + // Try to mint more than maxSupply + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(ExceedsMaxSupply.selector)); + token.mint(user1, currentMaxSupply + 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; + + // Try to mint more than maxSupply with ETH + vm.deal(owner, ethAmount); + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(ExceedsMaxSupply.selector)); + token.mintWithETH{ value: ethAmount }(user1); + } + + function test__OwnerCanSetMaxSupply() external { + uint256 newMaxSupply = 2_000_000 * 10 ** 18; + uint256 oldMaxSupply = token.maxSupply(); + + vm.expectEmit(true, true, false, false); + emit MaxSupplySet(oldMaxSupply, newMaxSupply); + + vm.prank(owner); + token.setMaxSupply(newMaxSupply); + + assertEq(token.maxSupply(), newMaxSupply); + } + + function test__CannotSetMaxSupplyBelowTotalSupply() external { + // First mint some tokens + uint256 mintAmount = 1000 ether; + vm.prank(owner); + token.mint(user1, mintAmount); + + // Try to set maxSupply below current totalSupply + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(ExceedsMaxSupply.selector)); + token.setMaxSupply(mintAmount - 1); + } + + function test__NonOwnerCannotSetMaxSupply() external { + uint256 newMaxSupply = 2_000_000 * 10 ** 18; + + vm.prank(user1); + vm.expectRevert("Ownable: caller is not the owner"); + token.setMaxSupply(newMaxSupply); + } } diff --git a/test/WakuRlnV2.t.sol b/test/WakuRlnV2.t.sol index b9f9d25..3fe5ac1 100644 --- a/test/WakuRlnV2.t.sol +++ b/test/WakuRlnV2.t.sol @@ -38,8 +38,9 @@ contract WakuRlnV2Test is Test { // Minting a large number of tokens to not have to worry about // Not having enough balance + // 900_000 ether is chosen to be well above any test requirements and is within the new max supply constraints. vm.prank(address(tokenDeployer)); - token.mint(address(this), 100_000_000 ether); + token.mint(address(this), 900_000 ether); } function test__ValidRegistration__kats() external { @@ -634,6 +635,49 @@ contract WakuRlnV2Test is Test { } } + function test__NonMinterCanMintWithETHAndRegister() external { + uint256 idCommitment = 123; + uint32 membershipRateLimit = w.minMembershipRateLimit(); + address nonMinter = vm.addr(999); + + // Calculate required token amount for membership + (, uint256 price) = w.priceCalculator().calculate(membershipRateLimit); + uint256 ethAmount = price; // Use same amount of ETH as token price needed + + // Verify nonMinter is not a minter + assertFalse(token.isMinter(nonMinter)); + + // Non-minter uses mintWithETH to get tokens needed for membership + // Need to send enough ETH to mint the required tokens (1:1 ratio) + vm.deal(nonMinter, price); + vm.prank(nonMinter); + token.mintWithETH{ value: price }(nonMinter); + + // Verify tokens were minted + assertEq(token.balanceOf(nonMinter), price); + + // Non-minter approves and registers for membership + vm.startPrank(nonMinter); + token.approve(address(w), price); + w.register(idCommitment, membershipRateLimit, noIdCommitmentsToErase); + vm.stopPrank(); + + // Verify successful registration + assertTrue(w.isInMembershipSet(idCommitment)); + (uint32 fetchedRateLimit, uint32 index, uint256 rateCommitment) = w.getMembershipInfo(idCommitment); + assertEq(fetchedRateLimit, membershipRateLimit); + assertEq(index, 0); + assertNotEq(rateCommitment, 0); + + // Verify membership holder is the non-minter + (,,,,,, address holder,) = w.memberships(idCommitment); + assertEq(holder, nonMinter); + + // Verify tokens were transferred to membership contract + assertEq(token.balanceOf(address(w)), price); + assertEq(token.balanceOf(nonMinter), 0); + } + function test__WithdrawToken(uint32 membershipRateLimit) external { vm.pauseGasMetering(); uint256 idCommitment = 2;