diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index b8c2457..414dd39 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -78,7 +78,8 @@ interface IB20 { /// @notice An empty array was passed to a function that requires at least one element. error EmptyFeatureSet(); - /// @notice The proposed supply cap is below the current `totalSupply`. + /// @notice The proposed supply cap is outside the permitted range: below the current + /// `totalSupply`, or above the maximum (`type(uint128).max`). /// /// @param currentSupply Current `totalSupply`. /// @param proposedCap Rejected proposed cap. @@ -549,17 +550,19 @@ interface IB20 { SUPPLY CAP //////////////////////////////////////////////////////////////*/ - /// @notice The maximum total supply enforced on `mint`. `type(uint256).max` indicates no cap. + /// @notice The maximum total supply enforced on `mint`. Capped at `type(uint128).max`, which + /// indicates no cap (the unbounded sentinel). `totalSupply` can therefore never exceed + /// `type(uint128).max`. /// @return Current supply cap. function supplyCap() external view returns (uint256); /// @notice Sets a new supply cap. May be raised or lowered freely, but never below current - /// `totalSupply`. Emits `SupplyCapUpdated`. + /// `totalSupply` and never above `type(uint128).max`. Emits `SupplyCapUpdated`. /// /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `DEFAULT_ADMIN_ROLE`. - /// @dev Reverts with `InvalidSupplyCap` when `newSupplyCap < totalSupply()`. + /// @dev Reverts with `InvalidSupplyCap` when `newSupplyCap < totalSupply()` or `newSupplyCap > type(uint128).max`. /// - /// @param newSupplyCap New supply cap. + /// @param newSupplyCap New supply cap. Must not exceed `type(uint128).max`. function updateSupplyCap(uint256 newSupplyCap) external; /*////////////////////////////////////////////////////////////// diff --git a/src/lib/B20Constants.sol b/src/lib/B20Constants.sol index 39a2dfc..9986783 100644 --- a/src/lib/B20Constants.sol +++ b/src/lib/B20Constants.sol @@ -30,4 +30,9 @@ library B20Constants { /// ERC-20 community ceiling — every common wallet and indexer renders up to /// 18 decimals correctly; going higher risks integration breakage. uint8 internal constant MAX_ASSET_DECIMALS = 18; + + /// @notice Inclusive upper bound for the supply cap, and therefore for `totalSupply`. + /// `type(uint128).max` also serves as the unbounded ("no cap") sentinel: a cap + /// set to this value imposes no practical limit while keeping supply within `uint128`. + uint256 internal constant MAX_SUPPLY_CAP = type(uint128).max; } diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index da3e456..4f4e656 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -95,6 +95,13 @@ abstract contract MockB20 is IB20 { bytes32 public constant TRANSFER_EXECUTOR_POLICY = B20Constants.TRANSFER_EXECUTOR_POLICY; bytes32 public constant MINT_RECEIVER_POLICY = B20Constants.MINT_RECEIVER_POLICY; + /// @notice Maximum value the supply cap may be set to. Because `mint` + /// rejects any `totalSupply` above the cap, this also bounds + /// `totalSupply` to `type(uint128).max`. The Rust precompile + /// enforces the same ceiling. Mirrors `B20Constants` so the + /// single source of truth lives in one library. + uint256 public constant MAX_SUPPLY_CAP = B20Constants.MAX_SUPPLY_CAP; + // ============================================================ // MODIFIERS // ============================================================ @@ -510,7 +517,9 @@ abstract contract MockB20 is IB20 { function updateSupplyCap(uint256 newSupplyCap) external onlyRole(DEFAULT_ADMIN_ROLE) { uint256 currentSupply = MockB20Storage.layout().totalSupply; - if (newSupplyCap < currentSupply) revert InvalidSupplyCap(currentSupply, newSupplyCap); + if (newSupplyCap < currentSupply || newSupplyCap > MAX_SUPPLY_CAP) { + revert InvalidSupplyCap(currentSupply, newSupplyCap); + } uint256 oldSupplyCap = MockB20Storage.layout().supplyCap; MockB20Storage.layout().supplyCap = newSupplyCap; emit SupplyCapUpdated(msg.sender, oldSupplyCap, newSupplyCap); diff --git a/test/lib/mocks/MockB20Factory.sol b/test/lib/mocks/MockB20Factory.sol index 967dc90..f8000a7 100644 --- a/test/lib/mocks/MockB20Factory.sol +++ b/test/lib/mocks/MockB20Factory.sol @@ -319,7 +319,7 @@ contract MockB20Factory is IB20Factory { function _writeBaseStorage(address token, string memory name_, string memory symbol_) internal { _writeString(token, MockB20Storage.slotOf(MockB20Storage.NAME_OFFSET), name_); _writeString(token, MockB20Storage.slotOf(MockB20Storage.SYMBOL_OFFSET), symbol_); - _writeUint(token, MockB20Storage.slotOf(MockB20Storage.SUPPLY_CAP_OFFSET), type(uint256).max); + _writeUint(token, MockB20Storage.slotOf(MockB20Storage.SUPPLY_CAP_OFFSET), B20Constants.MAX_SUPPLY_CAP); // Everything else (totalSupply, balances, allowances, roles, // roleAdmins, adminCount, transferPolicyIds, mintPolicyIds, // pausedVectors, nonces, contractURI, initialized) defaults to diff --git a/test/unit/B20/erc20/balanceOf.t.sol b/test/unit/B20/erc20/balanceOf.t.sol index 6fbdfc2..9ee53b6 100644 --- a/test/unit/B20/erc20/balanceOf.t.sol +++ b/test/unit/B20/erc20/balanceOf.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.20; import {B20Test} from "base-std-test/lib/B20Test.sol"; +import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; contract B20BalanceOfTest is B20Test { /// @notice Verifies balanceOf returns zero for any account that has never received tokens @@ -14,6 +15,7 @@ contract B20BalanceOfTest is B20Test { /// @dev Mint readback; canonical mint test lives in mint.t.sol function test_balanceOf_success_reflectsMint(address to, uint256 amount) public { _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(to, amount); assertEq(token.balanceOf(to), amount, "balance must equal minted amount"); } @@ -24,6 +26,7 @@ contract B20BalanceOfTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); diff --git a/test/unit/B20/erc20/totalSupply.t.sol b/test/unit/B20/erc20/totalSupply.t.sol index 31ace9b..739bbd6 100644 --- a/test/unit/B20/erc20/totalSupply.t.sol +++ b/test/unit/B20/erc20/totalSupply.t.sol @@ -9,7 +9,8 @@ contract B20TotalSupplyTest is B20Test { /// @dev Accounting invariant: totalSupply == sum of all balances == sum(mint) - sum(burn) function test_totalSupply_success_tracksMintAndBurn(address to, uint256 mintAmount, uint256 burnAmount) public { _assumeValidActor(to); - // Bound burnAmount to <= mintAmount so we don't underflow. + // Cap the mint at the supply ceiling, then bound burnAmount to <= mintAmount so we don't underflow. + mintAmount = bound(mintAmount, 0, B20Constants.MAX_SUPPLY_CAP); burnAmount = bound(burnAmount, 0, mintAmount); _mint(to, mintAmount); diff --git a/test/unit/B20/erc20/transfer.t.sol b/test/unit/B20/erc20/transfer.t.sol index 5a3d13b..cb9f91d 100644 --- a/test/unit/B20/erc20/transfer.t.sol +++ b/test/unit/B20/erc20/transfer.t.sol @@ -105,6 +105,7 @@ contract B20TransferTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); uint256 before = token.balanceOf(from); @@ -126,6 +127,7 @@ contract B20TransferTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); uint256 before = token.balanceOf(to); @@ -146,6 +148,7 @@ contract B20TransferTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.expectEmit(true, true, false, true, address(token)); @@ -160,6 +163,7 @@ contract B20TransferTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); @@ -172,7 +176,7 @@ contract B20TransferTest is B20Test { /// leave both the balance and totalSupply exactly where they started. function test_transfer_success_selfTransferNoInflation(address account, uint256 amount) public { _assumeValidActor(account); - amount = bound(amount, 0, type(uint128).max); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(account, amount); uint256 balanceBefore = token.balanceOf(account); @@ -195,7 +199,7 @@ contract B20TransferTest is B20Test { /// identically against the live precompile under LIVE_PRECOMPILES. function test_transfer_success_privilegedBypassesSenderPolicy(address to, uint256 amount) public { _assumeValidActor(to); - amount = bound(amount, 0, type(uint128).max); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); bytes32 salt = keccak256("privileged-sender-bypass"); // The fuzzed recipient must not collide with the to-be-created token's own address. @@ -220,7 +224,7 @@ contract B20TransferTest is B20Test { /// mirror, this drives the real factory bootstrap path and runs under LIVE_PRECOMPILES. function test_transfer_success_privilegedBypassesReceiverPolicy(address to, uint256 amount) public { _assumeValidActor(to); - amount = bound(amount, 0, type(uint128).max); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); bytes32 salt = keccak256("privileged-receiver-bypass"); // The fuzzed recipient must not collide with the to-be-created token's own address. @@ -248,7 +252,7 @@ contract B20TransferTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); - amount = bound(amount, 0, type(uint128).max); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); uint64 id = _createAllowlist(from, true); _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, id); @@ -266,7 +270,7 @@ contract B20TransferTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); - amount = bound(amount, 0, type(uint128).max); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); uint64 id = _createAllowlist(to, true); _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, id); diff --git a/test/unit/B20/erc20/transferFrom.t.sol b/test/unit/B20/erc20/transferFrom.t.sol index ff3046d..949f861 100644 --- a/test/unit/B20/erc20/transferFrom.t.sol +++ b/test/unit/B20/erc20/transferFrom.t.sol @@ -20,7 +20,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); // skip the consume-allowance bypass when caller == from - amount = bound(amount, 1, type(uint128).max); + amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP); vm.prank(from); token.approve(caller, amount); @@ -40,7 +40,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); - amount = bound(amount, 1, type(uint128).max); + amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP); vm.prank(from); token.approve(caller, amount); @@ -66,7 +66,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); - amount = bound(amount, 1, type(uint128).max); + amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP); vm.prank(from); token.approve(caller, amount); @@ -92,7 +92,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); - amount = bound(amount, 1, type(uint128).max); + amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP); vm.prank(from); token.approve(caller, amount); @@ -135,7 +135,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); - amount = bound(amount, 1, type(uint128).max); + amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP); // from has zero balance, but allowance is set high enough to clear the allowance gate. vm.prank(from); @@ -156,6 +156,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(to); vm.assume(caller != from); vm.assume(from != to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); @@ -194,7 +195,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(to); vm.assume(caller != from); vm.assume(from != to); - allowanceAmount = bound(allowanceAmount, 1, type(uint128).max); + allowanceAmount = bound(allowanceAmount, 1, B20Constants.MAX_SUPPLY_CAP); // Cap below type(uint256).max so we exercise the consume path (not the infinite-allowance bypass). vm.assume(allowanceAmount != type(uint256).max); // spendAmount includes 0 so the assertion (allowance decreases by spendAmount) is @@ -234,7 +235,7 @@ contract B20TransferFromTest is B20Test { vm.assume(from != to); // Include amount = 0: the infinite-allowance invariant must hold across the full // valid input domain, including the no-op zero-transfer case. - amount = bound(amount, 0, type(uint128).max); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); @@ -258,6 +259,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); @@ -276,6 +278,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); @@ -322,7 +325,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); - allowanceAmount = bound(allowanceAmount, 1, type(uint128).max); + allowanceAmount = bound(allowanceAmount, 1, B20Constants.MAX_SUPPLY_CAP); vm.assume(allowanceAmount != type(uint256).max); spendAmount = bound(spendAmount, 1, allowanceAmount); @@ -352,7 +355,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); - amount = bound(amount, 1, type(uint128).max); + amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); @@ -403,8 +406,8 @@ contract B20TransferFromTest is B20Test { vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); _assumeValidActor(from); _assumeValidActor(to); - allowanceAmount = bound(allowanceAmount, 0, type(uint128).max - 1); - spendAmount = bound(spendAmount, allowanceAmount + 1, type(uint128).max); + allowanceAmount = bound(allowanceAmount, 0, B20Constants.MAX_SUPPLY_CAP - 1); + spendAmount = bound(spendAmount, allowanceAmount + 1, B20Constants.MAX_SUPPLY_CAP); _mint(from, spendAmount); vm.prank(from); @@ -437,7 +440,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); - allowanceAmount = bound(allowanceAmount, 1, type(uint128).max); + allowanceAmount = bound(allowanceAmount, 1, B20Constants.MAX_SUPPLY_CAP); vm.assume(allowanceAmount != type(uint256).max); spendAmount = bound(spendAmount, 0, allowanceAmount); @@ -479,7 +482,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); - amount = bound(amount, 1, type(uint128).max); + amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); diff --git a/test/unit/B20/memo/burnWithMemo.t.sol b/test/unit/B20/memo/burnWithMemo.t.sol index ee47aa0..bb06a78 100644 --- a/test/unit/B20/memo/burnWithMemo.t.sol +++ b/test/unit/B20/memo/burnWithMemo.t.sol @@ -24,6 +24,7 @@ contract B20BurnWithMemoTest is B20Test { /// @dev Accounting unchanged from burn; the memo does not alter accounting. /// Paired slot assertions confirm balance and totalSupply slots reflect the burn. function test_burnWithMemo_success_debitsAndDecreasesSupply(uint256 amount, bytes32 memo) public { + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _grantRole(B20Constants.BURN_ROLE, burner); _mint(burner, amount); @@ -49,6 +50,7 @@ contract B20BurnWithMemoTest is B20Test { /// @notice Verifies burnWithMemo emits Transfer(caller, address(0), amount) then Memo(memo) /// @dev Event ordering: Memo follows Transfer; canonical Memo test for the burn path function test_burnWithMemo_success_emitsTransferThenMemo(uint256 amount, bytes32 memo) public { + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _grantRole(B20Constants.BURN_ROLE, burner); _mint(burner, amount); diff --git a/test/unit/B20/memo/mintWithMemo.t.sol b/test/unit/B20/memo/mintWithMemo.t.sol index bf73cda..2cca5df 100644 --- a/test/unit/B20/memo/mintWithMemo.t.sol +++ b/test/unit/B20/memo/mintWithMemo.t.sol @@ -27,6 +27,7 @@ contract B20MintWithMemoTest is B20Test { /// Paired slot assertions confirm balance and totalSupply slots reflect the mint. function test_mintWithMemo_success_creditsAndUpdatesSupply(address to, uint256 amount, bytes32 memo) public { _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _grantRole(B20Constants.MINT_ROLE, minter); uint256 supplyBefore = token.totalSupply(); @@ -53,6 +54,7 @@ contract B20MintWithMemoTest is B20Test { /// @dev Event ordering: Memo follows Transfer; canonical Memo test for the mint path function test_mintWithMemo_success_emitsTransferThenMemo(address to, uint256 amount, bytes32 memo) public { _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _grantRole(B20Constants.MINT_ROLE, minter); vm.expectEmit(true, true, false, true, address(token)); diff --git a/test/unit/B20/memo/transferFromWithMemo.t.sol b/test/unit/B20/memo/transferFromWithMemo.t.sol index f6de74f..86f6247 100644 --- a/test/unit/B20/memo/transferFromWithMemo.t.sol +++ b/test/unit/B20/memo/transferFromWithMemo.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; import {IB20} from "base-std/interfaces/IB20.sol"; import {B20Test} from "base-std-test/lib/B20Test.sol"; +import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; import {MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; contract B20TransferFromWithMemoTest is B20Test { @@ -47,7 +48,7 @@ contract B20TransferFromWithMemoTest is B20Test { vm.assume(from != to); // Include amount = 0: the balance/allowance invariants must hold across the full // valid input domain, including the no-op zero-transfer case. - amount = bound(amount, 0, type(uint128).max); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); @@ -89,6 +90,7 @@ contract B20TransferFromWithMemoTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); @@ -115,6 +117,7 @@ contract B20TransferFromWithMemoTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); @@ -160,7 +163,7 @@ contract B20TransferFromWithMemoTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); - amount = bound(amount, 1, type(uint128).max); + amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); @@ -209,8 +212,8 @@ contract B20TransferFromWithMemoTest is B20Test { vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); _assumeValidActor(from); _assumeValidActor(to); - allowanceAmount = bound(allowanceAmount, 0, type(uint128).max - 1); - spendAmount = bound(spendAmount, allowanceAmount + 1, type(uint128).max); + allowanceAmount = bound(allowanceAmount, 0, B20Constants.MAX_SUPPLY_CAP - 1); + spendAmount = bound(spendAmount, allowanceAmount + 1, B20Constants.MAX_SUPPLY_CAP); _mint(from, spendAmount); vm.prank(from); @@ -244,7 +247,7 @@ contract B20TransferFromWithMemoTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); - allowanceAmount = bound(allowanceAmount, 1, type(uint128).max); + allowanceAmount = bound(allowanceAmount, 1, B20Constants.MAX_SUPPLY_CAP); vm.assume(allowanceAmount != type(uint256).max); spendAmount = bound(spendAmount, 0, allowanceAmount); diff --git a/test/unit/B20/memo/transferWithMemo.t.sol b/test/unit/B20/memo/transferWithMemo.t.sol index 92eb28a..3af4bb7 100644 --- a/test/unit/B20/memo/transferWithMemo.t.sol +++ b/test/unit/B20/memo/transferWithMemo.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; import {IB20} from "base-std/interfaces/IB20.sol"; import {B20Test} from "base-std-test/lib/B20Test.sol"; +import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; import {MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; contract B20TransferWithMemoTest is B20Test { @@ -30,6 +31,7 @@ contract B20TransferWithMemoTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); @@ -56,6 +58,7 @@ contract B20TransferWithMemoTest is B20Test { { _assumeValidActor(from); _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); @@ -72,6 +75,7 @@ contract B20TransferWithMemoTest is B20Test { function test_transferWithMemo_success_returnsTrue(address from, address to, uint256 amount, bytes32 memo) public { _assumeValidActor(from); _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); vm.prank(from); diff --git a/test/unit/B20/supply/burn.t.sol b/test/unit/B20/supply/burn.t.sol index 1ade631..b844c81 100644 --- a/test/unit/B20/supply/burn.t.sol +++ b/test/unit/B20/supply/burn.t.sol @@ -48,6 +48,7 @@ contract B20BurnTest is B20Test { /// @dev Accounting: balanceOf(caller) decreases by exactly amount. /// Paired slot assertion verifies `balances[burner]` slot reflects the debit. function test_burn_success_debitsCaller(uint256 amount) public { + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _grantRole(B20Constants.BURN_ROLE, burner); _mint(burner, amount); @@ -65,6 +66,7 @@ contract B20BurnTest is B20Test { /// @dev Accounting: totalSupply tracks cumulative minted-burned. /// Paired slot assertion verifies `totalSupply` slot reflects the decrease. function test_burn_success_decreasesTotalSupply(uint256 amount) public { + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _grantRole(B20Constants.BURN_ROLE, burner); _mint(burner, amount); uint256 before = token.totalSupply(); @@ -82,6 +84,7 @@ contract B20BurnTest is B20Test { /// @notice Verifies burn emits Transfer(caller, address(0), amount) /// @dev Event integrity for the burn path; burn represented as transfer to the zero address function test_burn_success_emitsTransferToZero(uint256 amount) public { + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _grantRole(B20Constants.BURN_ROLE, burner); _mint(burner, amount); diff --git a/test/unit/B20/supply/burnBlocked.t.sol b/test/unit/B20/supply/burnBlocked.t.sol index 2ed8282..d797134 100644 --- a/test/unit/B20/supply/burnBlocked.t.sol +++ b/test/unit/B20/supply/burnBlocked.t.sol @@ -71,6 +71,7 @@ contract B20BurnBlockedTest is B20Test { /// Paired slot assertion verifies `balances[from]` slot reflects the seizure. function test_burnBlocked_success_debitsTarget(address from, uint256 amount) public { _assumeValidActor(from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); // Mint while no policy is set so the mint isn't blocked. _mint(from, amount); // Now block from via TRANSFER_SENDER_POLICY policy, then seize. @@ -92,6 +93,7 @@ contract B20BurnBlockedTest is B20Test { /// Paired slot assertion verifies `totalSupply` slot reflects the decrease. function test_burnBlocked_success_decreasesTotalSupply(address from, uint256 amount) public { _assumeValidActor(from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); @@ -111,6 +113,7 @@ contract B20BurnBlockedTest is B20Test { /// @dev Dual-event integrity: Transfer for accounting, BurnedBlocked for seizure audit trail function test_burnBlocked_success_emitsTransferAndBurnedBlocked(address from, uint256 amount) public { _assumeValidActor(from); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); diff --git a/test/unit/B20/supply/mint.t.sol b/test/unit/B20/supply/mint.t.sol index 07f3820..c3fbf3f 100644 --- a/test/unit/B20/supply/mint.t.sol +++ b/test/unit/B20/supply/mint.t.sol @@ -47,7 +47,7 @@ contract B20MintTest is B20Test { /// We set cap low and request more than it allows. function test_mint_revert_supplyCapExceeded(address to, uint256 cap, uint256 amount) public { _assumeValidActor(to); - cap = bound(cap, 0, type(uint128).max); + cap = bound(cap, 0, B20Constants.MAX_SUPPLY_CAP); amount = bound(amount, cap + 1, type(uint256).max - cap); vm.prank(admin); @@ -86,7 +86,7 @@ contract B20MintTest is B20Test { /// cheat, so it runs identically against the live precompile under LIVE_PRECOMPILES. function test_mint_revert_privilegedStillEnforcesReceiverPolicy(address to, uint256 amount) public { _assumeValidActor(to); - amount = bound(amount, 1, type(uint128).max); + amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP); bytes32 salt = keccak256("privileged-mint-receiver-enforced"); // The fuzzed recipient must not collide with the to-be-created token's own address. @@ -121,6 +121,7 @@ contract B20MintTest is B20Test { /// Paired slot assertion verifies `balances[to]` slot reflects the credit. function test_mint_success_creditsRecipient(address to, uint256 amount) public { _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); uint256 before = token.balanceOf(to); _mint(to, amount); @@ -137,6 +138,7 @@ contract B20MintTest is B20Test { /// Paired slot assertion verifies `totalSupply` slot reflects the increase. function test_mint_success_increasesTotalSupply(address to, uint256 amount) public { _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); uint256 before = token.totalSupply(); _mint(to, amount); @@ -152,6 +154,7 @@ contract B20MintTest is B20Test { /// @dev Event integrity for the mint path; mint represented as transfer from the zero address function test_mint_success_emitsTransferFromZero(address to, uint256 amount) public { _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _grantRole(B20Constants.MINT_ROLE, minter); vm.expectEmit(true, true, false, true, address(token)); @@ -166,7 +169,7 @@ contract B20MintTest is B20Test { /// leaves totalSupply == cap. function test_mint_success_atSupplyCapBoundary(address to, uint256 cap) public { _assumeValidActor(to); - cap = bound(cap, 1, type(uint128).max); + cap = bound(cap, 1, B20Constants.MAX_SUPPLY_CAP); vm.prank(admin); token.updateSupplyCap(cap); @@ -182,8 +185,10 @@ contract B20MintTest is B20Test { /// balance and totalSupply by the sum of both amounts. function test_mint_success_accumulatesAcrossCalls(address to, uint256 first, uint256 second) public { _assumeValidActor(to); - first = bound(first, 0, type(uint128).max); - second = bound(second, 0, type(uint128).max); + // Cumulative supply must stay within the uint128.max cap, so the two + // amounts together cannot exceed the ceiling. + first = bound(first, 0, B20Constants.MAX_SUPPLY_CAP); + second = bound(second, 0, B20Constants.MAX_SUPPLY_CAP - first); _mint(to, first); _mint(to, second); diff --git a/test/unit/B20/supply/mintWithMemo_revertOrder.t.sol b/test/unit/B20/supply/mintWithMemo_revertOrder.t.sol index 529aa00..a1d0982 100644 --- a/test/unit/B20/supply/mintWithMemo_revertOrder.t.sol +++ b/test/unit/B20/supply/mintWithMemo_revertOrder.t.sol @@ -27,7 +27,7 @@ contract B20MintWithMemoRevertOrderTest is B20Test { _assumeValidCaller(caller); _assumeValidActor(to); vm.assume(caller != admin); - amount = bound(amount, 1, type(uint128).max); + amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP); // Activate all five violations simultaneously: MINT paused, caller has no // MINT_ROLE, address(0) receiver, receiver policy blocks, supply cap = 0. @@ -72,7 +72,7 @@ contract B20MintWithMemoRevertOrderTest is B20Test { vm.expectRevert(abi.encodeWithSelector(IB20.SupplyCapExceeded.selector, 0, amount)); token.mintWithMemo(to, amount, memo); vm.prank(admin); - token.updateSupplyCap(type(uint256).max); + token.updateSupplyCap(B20Constants.MAX_SUPPLY_CAP); // Success — all conditions satisfied. vm.prank(caller); diff --git a/test/unit/B20/supply/supplyCap.t.sol b/test/unit/B20/supply/supplyCap.t.sol index e539b5f..605eda2 100644 --- a/test/unit/B20/supply/supplyCap.t.sol +++ b/test/unit/B20/supply/supplyCap.t.sol @@ -2,18 +2,21 @@ pragma solidity ^0.8.20; import {B20Test} from "base-std-test/lib/B20Test.sol"; +import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; contract B20SupplyCapTest is B20Test { /// @notice Verifies supplyCap returns the value set at token creation - /// @dev Constructor-stored value readback. The factory writes type(uint256).max - /// at bootstrap, so a fresh default token starts uncapped. + /// @dev Constructor-stored value readback. The factory writes B20Constants.MAX_SUPPLY_CAP + /// at bootstrap, so a fresh default token starts uncapped (uint128.max is the + /// unbounded sentinel and the maximum the cap may ever hold). function test_supplyCap_success_returnsCreationCap() public view { - assertEq(token.supplyCap(), type(uint256).max, "fresh token must start with unbounded cap"); + assertEq(token.supplyCap(), B20Constants.MAX_SUPPLY_CAP, "fresh token must start with unbounded cap"); } /// @notice Verifies supplyCap reflects updates made via updateSupplyCap /// @dev Mutable cap readback; canonical setter test lives in updateSupplyCap.t.sol function test_supplyCap_success_reflectsSetSupplyCap(uint256 newCap) public { + newCap = bound(newCap, 0, B20Constants.MAX_SUPPLY_CAP); vm.prank(admin); token.updateSupplyCap(newCap); assertEq(token.supplyCap(), newCap, "supplyCap must reflect updateSupplyCap"); diff --git a/test/unit/B20/supply/updateSupplyCap.t.sol b/test/unit/B20/supply/updateSupplyCap.t.sol index 9919872..40d8aac 100644 --- a/test/unit/B20/supply/updateSupplyCap.t.sol +++ b/test/unit/B20/supply/updateSupplyCap.t.sol @@ -26,7 +26,7 @@ contract B20UpdateSupplyCapTest is B20Test { /// @notice Verifies updateSupplyCap reverts when newCap is below the current totalSupply /// @dev Invariant: never invalidate already-issued supply; checks InvalidSupplyCap(currentSupply, proposedCap) function test_updateSupplyCap_revert_belowCurrentSupply(uint256 mintedAmount, uint256 newCap) public { - mintedAmount = bound(mintedAmount, 2, type(uint128).max); + mintedAmount = bound(mintedAmount, 2, B20Constants.MAX_SUPPLY_CAP); newCap = bound(newCap, 0, mintedAmount - 1); _mint(alice, mintedAmount); @@ -36,11 +36,23 @@ contract B20UpdateSupplyCapTest is B20Test { token.updateSupplyCap(newCap); } + /// @notice Verifies updateSupplyCap reverts when newCap exceeds the uint128.max ceiling + /// @dev Upper bound: the cap (and therefore totalSupply) can never exceed B20Constants.MAX_SUPPLY_CAP. + /// Reuses InvalidSupplyCap(currentSupply, proposedCap); a fresh token has currentSupply == 0. + function test_updateSupplyCap_revert_aboveMaximum(uint256 newCap) public { + newCap = bound(newCap, B20Constants.MAX_SUPPLY_CAP + 1, type(uint256).max); + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(IB20.InvalidSupplyCap.selector, 0, newCap)); + token.updateSupplyCap(newCap); + } + /// @notice Verifies updateSupplyCap raises the cap to a value above the current totalSupply /// @dev Read-after-write: supplyCap returns newCap. Fresh token has totalSupply == 0, - /// so any cap is valid. Paired slot assertion verifies + /// so any cap within the uint128.max ceiling is valid. Paired slot assertion verifies /// `supplyCap` slot reflects the write. function test_updateSupplyCap_success_raisesCap(uint256 newCap) public { + newCap = bound(newCap, 0, B20Constants.MAX_SUPPLY_CAP); vm.prank(admin); token.updateSupplyCap(newCap); assertEq(token.supplyCap(), newCap, "supplyCap must equal newCap"); @@ -51,13 +63,22 @@ contract B20UpdateSupplyCapTest is B20Test { ); } + /// @notice Verifies updateSupplyCap accepts the maximum permitted cap (B20Constants.MAX_SUPPLY_CAP) + /// @dev Boundary: the uint128.max ceiling is inclusive — setting the cap to exactly the + /// maximum is the unbounded ("no cap") configuration and must succeed. + function test_updateSupplyCap_success_atMaximum() public { + vm.prank(admin); + token.updateSupplyCap(B20Constants.MAX_SUPPLY_CAP); + assertEq(token.supplyCap(), B20Constants.MAX_SUPPLY_CAP, "supplyCap must equal the maximum"); + } + /// @notice Verifies updateSupplyCap lowers the cap to a value at or above the current totalSupply /// @dev Cap may be lowered as long as totalSupply <= newCap. /// Paired slot assertion verifies `supplyCap` slot reflects the lower. function test_updateSupplyCap_success_lowersCap(uint256 newCap) public { - // Mint some supply, then bound newCap >= mintedAmount. + // Mint some supply, then bound newCap into [mintedAmount, uint128.max]. uint256 mintedAmount = 1000; - newCap = bound(newCap, mintedAmount, type(uint256).max); + newCap = bound(newCap, mintedAmount, B20Constants.MAX_SUPPLY_CAP); _mint(alice, mintedAmount); @@ -74,6 +95,7 @@ contract B20UpdateSupplyCapTest is B20Test { /// @notice Verifies updateSupplyCap emits SupplyCapUpdated(updater, oldCap, newCap) /// @dev Event integrity; canonical SupplyCapUpdated emission test function test_updateSupplyCap_success_emitsSupplyCapUpdated(uint256 newCap) public { + newCap = bound(newCap, 0, B20Constants.MAX_SUPPLY_CAP); uint256 oldCap = token.supplyCap(); vm.expectEmit(true, false, false, true, address(token)); diff --git a/test/unit/B20/supply/updateSupplyCap_revertOrder.t.sol b/test/unit/B20/supply/updateSupplyCap_revertOrder.t.sol index c4cd839..09afee9 100644 --- a/test/unit/B20/supply/updateSupplyCap_revertOrder.t.sol +++ b/test/unit/B20/supply/updateSupplyCap_revertOrder.t.sol @@ -10,7 +10,8 @@ import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; /// /// @notice **Canonical order (Solidity reference):** /// 1. ROLE (`onlyRole(DEFAULT_ADMIN_ROLE)` modifier) → `AccessControlUnauthorizedAccount` -/// 2. INVALID-SUPPLY-CAP (`newSupplyCap < currentSupply`) → `InvalidSupplyCap` +/// 2. INVALID-SUPPLY-CAP (`newSupplyCap < currentSupply` or `newSupplyCap > B20Constants.MAX_SUPPLY_CAP`) +/// → `InvalidSupplyCap` /// /// C(2, 2) = 1 pair. contract B20UpdateSupplyCapRevertOrderTest is B20Test { @@ -20,7 +21,7 @@ contract B20UpdateSupplyCapRevertOrderTest is B20Test { function test_updateSupplyCap_revertOrder_role_beats_invalidCap(address caller, uint256 mintedAmount) public { _assumeValidCaller(caller); vm.assume(caller != admin); - mintedAmount = bound(mintedAmount, 1, type(uint128).max); + mintedAmount = bound(mintedAmount, 1, B20Constants.MAX_SUPPLY_CAP); _mint(alice, mintedAmount); vm.prank(caller); diff --git a/test/unit/B20Factory/createToken.t.sol b/test/unit/B20Factory/createToken.t.sol index fe638b8..e990638 100644 --- a/test/unit/B20Factory/createToken.t.sol +++ b/test/unit/B20Factory/createToken.t.sol @@ -298,6 +298,23 @@ contract B20FactoryCreateB20Test is B20FactoryTest { ); } + /// @notice Verifies asset createToken initializes the supply cap to the maximum (uint128.max). + /// @dev The factory writes `B20Constants.MAX_SUPPLY_CAP` at bootstrap — the unbounded + /// ("no cap") sentinel and the highest value the cap may ever hold. Pinning this on the + /// factory-creation path (fuzzed caller/salt) means fork mode catches a precompile that + /// fails to seed the cap at creation. Paired surface + slot assertions. + function test_createB20_success_assetSupplyCapDefaultsToMax(address caller, bytes32 salt) public { + _assumeValidCaller(caller); + address token = _createAsset(caller, salt, _assetParams(), new bytes[](0)); + + assertEq(IB20(token).supplyCap(), B20Constants.MAX_SUPPLY_CAP, "fresh token supply cap must be MAX_SUPPLY_CAP"); + assertEq( + uint256(vm.load(token, MockB20Storage.supplyCapSlot())), + B20Constants.MAX_SUPPLY_CAP, + "supplyCap slot must hold MAX_SUPPLY_CAP at creation" + ); + } + /// @notice Verifies asset createToken executes with admin == address(0) /// @dev Same zero-admin success behavior on the asset variant. Paired slot assertions /// cross-check the base namespace (adminCount=0, initialized=true). diff --git a/test/unit/storage/MockB20SlotHelpers.t.sol b/test/unit/storage/MockB20SlotHelpers.t.sol index 1246601..ba8dcee 100644 --- a/test/unit/storage/MockB20SlotHelpers.t.sol +++ b/test/unit/storage/MockB20SlotHelpers.t.sol @@ -29,6 +29,7 @@ contract MockB20SlotHelpersTest is B20Test { /// assert `vm.load(token, balanceSlot(account)) == amount`. function test_balanceSlot_success_locatesBalance(address account, uint256 amount) public { _assumeValidActor(account); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(account, amount); @@ -48,9 +49,10 @@ contract MockB20SlotHelpersTest is B20Test { _assumeValidActor(a); _assumeValidActor(b); vm.assume(a != b); - // Bound to avoid SupplyCapExceeded interactions. - amountA = bound(amountA, 0, type(uint128).max); - amountB = bound(amountB, 0, type(uint128).max); + // Bound the cumulative mint to the uint128.max supply ceiling so the + // second mint doesn't trip SupplyCapExceeded. + amountA = bound(amountA, 0, B20Constants.MAX_SUPPLY_CAP); + amountB = bound(amountB, 0, B20Constants.MAX_SUPPLY_CAP - amountA); _mint(a, amountA); _mint(b, amountB); @@ -148,7 +150,7 @@ contract MockB20SlotHelpersTest is B20Test { /// @notice Verifies `totalSupplySlot()` returns the slot `_mint` updates. /// @dev Read-after-write: mint to alice, then `vm.load` the helper. function test_totalSupplySlot_success_locatesTotalSupply(uint256 amount) public { - amount = bound(amount, 0, type(uint128).max); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(alice, amount); @@ -170,12 +172,13 @@ contract MockB20SlotHelpersTest is B20Test { } /// @notice Verifies `supplyCapSlot()` returns the slot updateSupplyCap writes to. - /// @dev Default-token bootstrap sets supplyCap = type(uint256).max; we lower it + /// @dev Default-token bootstrap sets supplyCap = B20Constants.MAX_SUPPLY_CAP; we lower it /// and re-read both via surface and slot. function test_supplyCapSlot_success_locatesSupplyCap(uint256 cap) public { // Lower the cap to a value that won't violate the - // "cannot lower below totalSupply" invariant (totalSupply == 0). - cap = bound(cap, 0, type(uint256).max - 1); + // "cannot lower below totalSupply" invariant (totalSupply == 0) + // and stays within the uint128.max ceiling. + cap = bound(cap, 0, B20Constants.MAX_SUPPLY_CAP - 1); _grantRole(B20Constants.MINT_ROLE, admin); vm.prank(admin);