diff --git a/l2-contracts/src/ZkCappedMinterV2.sol b/l2-contracts/src/ZkCappedMinterV2.sol index 1dd0d13..b9a11b8 100644 --- a/l2-contracts/src/ZkCappedMinterV2.sol +++ b/l2-contracts/src/ZkCappedMinterV2.sol @@ -22,9 +22,15 @@ contract ZkCappedMinterV2 is AccessControl, Pausable { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + /// @notice Whether the contract has been permanently closed. + bool public closed; + /// @notice Error for when the cap is exceeded. error ZkCappedMinterV2__CapExceeded(address minter, uint256 amount); + /// @notice Thrown when a mint action is taken while the contract is closed. + error ZkCappedMinterV2__ContractClosed(); + /// @notice Constructor for a new ZkCappedMinterV2 contract /// @param _token The token contract where tokens will be minted. /// @param _admin The address that will be granted the admin role. @@ -53,6 +59,7 @@ contract ZkCappedMinterV2 is AccessControl, Pausable { /// @param _to The address that will receive the new tokens. /// @param _amount The quantity of tokens, in raw decimals, that will be created. function mint(address _to, uint256 _amount) external { + _revertIfClosed(); _requireNotPaused(); _checkRole(MINTER_ROLE, msg.sender); _revertIfCapExceeded(_amount); @@ -67,4 +74,19 @@ contract ZkCappedMinterV2 is AccessControl, Pausable { revert ZkCappedMinterV2__CapExceeded(msg.sender, _amount); } } + + /// @notice Reverts if the contract is closed. + function _revertIfClosed() internal view { + if (closed) { + revert ZkCappedMinterV2__ContractClosed(); + } + } + + /// @notice Permanently closes the contract, preventing any future minting. + /// @dev Once closed, the contract cannot be reopened and all minting operations will be permanently blocked. + /// @dev Only callable by the admin. + function close() external { + _checkRole(DEFAULT_ADMIN_ROLE, msg.sender); + closed = true; + } } diff --git a/l2-contracts/test/ZkCappedMinterV2.t.sol b/l2-contracts/test/ZkCappedMinterV2.t.sol index 0a2e695..ee8860a 100644 --- a/l2-contracts/test/ZkCappedMinterV2.t.sol +++ b/l2-contracts/test/ZkCappedMinterV2.t.sol @@ -36,7 +36,7 @@ contract ZkCappedMinterV2Test is ZkTokenTest { "AccessControl: account ", Strings.toHexString(uint160(account), 20), " is missing role ", - Strings.toHexString(uint256(role)) + Strings.toHexString(uint256(role), 32) ) ); } @@ -56,7 +56,6 @@ contract Constructor is ZkCappedMinterV2Test { contract Mint is ZkCappedMinterV2Test { function testFuzz_MintsNewTokensWhenTheAmountRequestedIsBelowTheCap( - address _cappedMinterAdmin, address _minter, address _receiver, uint256 _amount @@ -66,13 +65,14 @@ contract Mint is ZkCappedMinterV2Test { _grantMinterRole(cappedMinter, cappedMinterAdmin, _minter); + uint256 balanceBefore = token.balanceOf(_receiver); + vm.prank(_minter); cappedMinter.mint(_receiver, _amount); - assertEq(token.balanceOf(_receiver), _amount); + assertEq(token.balanceOf(_receiver), balanceBefore + _amount); } function testFuzz_MintsNewTokensInSuccessionToDifferentAccountsWhileRemainingBelowCap( - address _cappedMinterAdmin, address _minter, address _receiver1, address _receiver2, @@ -87,13 +87,16 @@ contract Mint is ZkCappedMinterV2Test { _grantMinterRole(cappedMinter, cappedMinterAdmin, _minter); + uint256 balanceBefore1 = token.balanceOf(_receiver1); + uint256 balanceBefore2 = token.balanceOf(_receiver2); + vm.startPrank(_minter); cappedMinter.mint(_receiver1, _amount1); cappedMinter.mint(_receiver2, _amount2); vm.stopPrank(); - assertEq(token.balanceOf(_receiver1), _amount1); - assertEq(token.balanceOf(_receiver2), _amount2); + assertEq(token.balanceOf(_receiver1), balanceBefore1 + _amount1); + assertEq(token.balanceOf(_receiver2), balanceBefore2 + _amount2); } function testFuzz_RevertIf_MintAttemptedByNonMinter(address _nonMinter, uint256 _amount) public { @@ -123,6 +126,21 @@ contract Mint is ZkCappedMinterV2Test { vm.prank(cappedMinterAdmin); cappedMinter.mint(_receiver, _amount); } + + function testFuzz_CorrectlyPermanentlyBlocksMinting(address _minter, address _receiver, uint256 _amount) public { + _amount = bound(_amount, 1, DEFAULT_CAP); + vm.assume(_receiver != address(0)); + + vm.prank(cappedMinterAdmin); + cappedMinter.grantRole(MINTER_ROLE, _minter); + + vm.prank(cappedMinterAdmin); + cappedMinter.close(); + + vm.expectRevert(ZkCappedMinterV2.ZkCappedMinterV2__ContractClosed.selector); + vm.prank(_minter); + cappedMinter.mint(_receiver, _amount); + } } contract Pause is ZkCappedMinterV2Test { @@ -133,9 +151,11 @@ contract Pause is ZkCappedMinterV2Test { // Grant minter role and verify minting works _grantMinterRole(cappedMinter, cappedMinterAdmin, _minter); + uint256 balanceBefore = token.balanceOf(_receiver); + vm.prank(_minter); cappedMinter.mint(_receiver, _amount); - assertEq(token.balanceOf(_receiver), _amount); + assertEq(token.balanceOf(_receiver), balanceBefore + _amount); // Pause and verify minting fails vm.prank(cappedMinterAdmin); @@ -209,3 +229,20 @@ contract Unpause is ZkCappedMinterV2Test { cappedMinter.unpause(); } } + +contract Close is ZkCappedMinterV2Test { + function test_CorrectlyChangesClosedVarWhenCalledByAdmin() public { + assertEq(cappedMinter.closed(), false); + + vm.prank(cappedMinterAdmin); + cappedMinter.close(); + assertEq(cappedMinter.closed(), true); + } + + function testFuzz_RevertIf_NotAdminCloses(address _nonAdmin) public { + vm.assume(_nonAdmin != cappedMinterAdmin); + vm.expectRevert(_formatAccessControlError(_nonAdmin, DEFAULT_ADMIN_ROLE)); + vm.prank(_nonAdmin); + cappedMinter.close(); + } +}