diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index b8c2457..35f0487 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -75,6 +75,13 @@ interface IB20 { /// @notice An amount argument was zero where a non-zero value is required. Not used for ERC-20 amount arguments. error InvalidAmount(); + /// @notice The operation would push `account`'s balance above the maximum per-account balance. + /// + /// @param account Account whose balance would exceed the maximum. + /// @param maxBalance Maximum allowed balance for a single account. + /// @param attemptedBalance Balance the account would have after the operation. + error MaxBalanceExceeded(address account, uint256 maxBalance, uint256 attemptedBalance); + /// @notice An empty array was passed to a function that requires at least one element. error EmptyFeatureSet(); @@ -287,6 +294,7 @@ interface IB20 { /// @dev Reverts with `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `msg.sender` is not authorized. /// @dev Reverts with `PolicyForbids(TRANSFER_RECEIVER_POLICY, ...)` when `to` is not authorized. /// @dev Reverts with `InsufficientBalance` when `msg.sender`'s balance is below `amount`. + /// @dev Reverts with `MaxBalanceExceeded` when `to`'s resulting balance would exceed the per-account maximum. /// /// @param to Destination address. /// @param amount Amount to transfer. @@ -304,6 +312,7 @@ interface IB20 { /// @dev Reverts with `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `from` is not authorized. /// @dev Reverts with `PolicyForbids(TRANSFER_RECEIVER_POLICY, ...)` when `to` is not authorized. /// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`. + /// @dev Reverts with `MaxBalanceExceeded` when `to`'s resulting balance would exceed the per-account maximum. /// /// @param from Source address. /// @param to Destination address. @@ -377,6 +386,7 @@ interface IB20 { /// @dev Reverts with `InvalidReceiver` when `to == address(0)`. /// @dev Reverts with `PolicyForbids(MINT_RECEIVER_POLICY, ...)` when `to` is not authorized. /// @dev Reverts with `SupplyCapExceeded` when `totalSupply + amount > supplyCap`. + /// @dev Reverts with `MaxBalanceExceeded` when `to`'s resulting balance would exceed the per-account maximum. /// /// @param to Mint recipient. /// @param amount Amount to mint. diff --git a/test/lib/B20Test.sol b/test/lib/B20Test.sol index 7ef63d3..3b5b7c1 100644 --- a/test/lib/B20Test.sol +++ b/test/lib/B20Test.sol @@ -43,6 +43,9 @@ contract B20Test is B20FactoryTest { /// @notice Asset-variant `IB20` token deployed in `setUp`. IB20 internal token; + /// @notice Maximum balance a single B-20 account can hold. + uint256 internal constant MAX_BALANCE = type(uint128).max; + // -- Setup -- function setUp() public virtual override { super.setUp(); @@ -117,6 +120,11 @@ contract B20Test is B20FactoryTest { token.mint(to, amount); } + /// @notice Bounds fuzzed values to the allowed per-account balance range. + function _boundBalanceAmount(uint256 amount) internal pure returns (uint256) { + return bound(amount, 0, MAX_BALANCE); + } + /// @notice Sets a policy slot on the token as the admin actor. /// @dev Use `ALWAYS_ALLOW` (0) or `ALWAYS_REJECT` (type(uint64).max); /// these are the only two policy IDs `MockPolicyRegistry` diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index da3e456..ef35142 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -95,6 +95,9 @@ 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 balance a single account can hold. + uint256 public constant MAX_BALANCE = type(uint128).max; + // ============================================================ // MODIFIERS // ============================================================ @@ -731,9 +734,15 @@ abstract contract MockB20 is IB20 { MockB20Storage.Layout storage $ = MockB20Storage.layout(); uint256 fromBalance = $.balances[from]; if (fromBalance < amount) revert InsufficientBalance(from, fromBalance, amount); + if (from == to) { + emit Transfer(from, to, amount); + return; + } + uint256 toBalance = $.balances[to]; + uint256 newToBalance = _checkedAddBalance(to, toBalance, amount); unchecked { $.balances[from] = fromBalance - amount; - $.balances[to] += amount; + $.balances[to] = newToBalance; } emit Transfer(from, to, amount); } @@ -762,11 +771,21 @@ abstract contract MockB20 is IB20 { MockB20Storage.Layout storage $ = MockB20Storage.layout(); uint256 newSupply = $.totalSupply + amount; if (newSupply > $.supplyCap) revert SupplyCapExceeded($.supplyCap, newSupply); + uint256 newBalance = _checkedAddBalance(to, $.balances[to], amount); $.totalSupply = newSupply; + $.balances[to] = newBalance; + emit Transfer(address(0), to, amount); + } + + /// @dev Mirrors the Rust precompile's saturating attempted-balance calculation. + function _checkedAddBalance(address account, uint256 balance, uint256 amount) internal pure returns (uint256) { + uint256 attempted; unchecked { - $.balances[to] += amount; + attempted = balance + amount; } - emit Transfer(address(0), to, amount); + if (attempted < balance) attempted = type(uint256).max; + if (attempted > MAX_BALANCE) revert MaxBalanceExceeded(account, MAX_BALANCE, attempted); + return attempted; } /// @dev Pure mechanics: balance + effects. Pause and role gates are diff --git a/test/unit/B20/erc20/balanceOf.t.sol b/test/unit/B20/erc20/balanceOf.t.sol index 6fbdfc2..2111ba2 100644 --- a/test/unit/B20/erc20/balanceOf.t.sol +++ b/test/unit/B20/erc20/balanceOf.t.sol @@ -14,6 +14,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 = _boundBalanceAmount(amount); _mint(to, amount); assertEq(token.balanceOf(to), amount, "balance must equal minted amount"); } @@ -24,6 +25,7 @@ contract B20BalanceOfTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); + amount = _boundBalanceAmount(amount); _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..70fece4 100644 --- a/test/unit/B20/erc20/totalSupply.t.sol +++ b/test/unit/B20/erc20/totalSupply.t.sol @@ -9,6 +9,7 @@ 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); + mintAmount = _boundBalanceAmount(mintAmount); // Bound burnAmount to <= mintAmount so we don't underflow. burnAmount = bound(burnAmount, 0, mintAmount); diff --git a/test/unit/B20/erc20/transfer.t.sol b/test/unit/B20/erc20/transfer.t.sol index 5a3d13b..1919f33 100644 --- a/test/unit/B20/erc20/transfer.t.sol +++ b/test/unit/B20/erc20/transfer.t.sol @@ -96,6 +96,21 @@ contract B20TransferTest is B20Test { token.transfer(to, amount); } + /// @notice Verifies transfer reverts when the recipient balance would exceed the per-account maximum + /// @dev The sender can afford the transfer, but the receiver is already at MAX_BALANCE. + function test_transfer_revert_maxBalanceExceeded(address from, address to) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + + _mint(from, 1); + _mint(to, MAX_BALANCE); + + vm.prank(from); + vm.expectRevert(abi.encodeWithSelector(IB20.MaxBalanceExceeded.selector, to, MAX_BALANCE, MAX_BALANCE + 1)); + token.transfer(to, 1); + } + /// @notice Verifies transfer debits the sender balance by amount /// @dev Accounting half: balanceOf(from) decreases by exactly amount. /// Paired slot assertion: `balances[from]` slot reflects the @@ -105,6 +120,7 @@ contract B20TransferTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); + amount = _boundBalanceAmount(amount); _mint(from, amount); uint256 before = token.balanceOf(from); @@ -126,6 +142,7 @@ contract B20TransferTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); + amount = _boundBalanceAmount(amount); _mint(from, amount); uint256 before = token.balanceOf(to); @@ -145,6 +162,7 @@ contract B20TransferTest is B20Test { function test_transfer_success_emitsTransfer(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); + amount = _boundBalanceAmount(amount); _mint(from, amount); @@ -159,6 +177,7 @@ contract B20TransferTest is B20Test { function test_transfer_success_returnsTrue(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); + amount = _boundBalanceAmount(amount); _mint(from, amount); diff --git a/test/unit/B20/erc20/transferFrom.t.sol b/test/unit/B20/erc20/transferFrom.t.sol index ff3046d..b9592ee 100644 --- a/test/unit/B20/erc20/transferFrom.t.sol +++ b/test/unit/B20/erc20/transferFrom.t.sol @@ -156,6 +156,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(to); vm.assume(caller != from); vm.assume(from != to); + amount = _boundBalanceAmount(amount); _mint(from, amount); vm.prank(from); @@ -258,6 +259,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); + amount = _boundBalanceAmount(amount); _mint(from, amount); vm.prank(from); @@ -276,6 +278,7 @@ contract B20TransferFromTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); + amount = _boundBalanceAmount(amount); _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..15654f5 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 = _boundBalanceAmount(amount); _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 = _boundBalanceAmount(amount); _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..b3e2541 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 = _boundBalanceAmount(amount); _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 = _boundBalanceAmount(amount); _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..5162f64 100644 --- a/test/unit/B20/memo/transferFromWithMemo.t.sol +++ b/test/unit/B20/memo/transferFromWithMemo.t.sol @@ -89,6 +89,7 @@ contract B20TransferFromWithMemoTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); + amount = _boundBalanceAmount(amount); _mint(from, amount); vm.prank(from); @@ -115,6 +116,7 @@ contract B20TransferFromWithMemoTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(caller != from); + amount = _boundBalanceAmount(amount); _mint(from, amount); vm.prank(from); diff --git a/test/unit/B20/memo/transferWithMemo.t.sol b/test/unit/B20/memo/transferWithMemo.t.sol index 92eb28a..34f5131 100644 --- a/test/unit/B20/memo/transferWithMemo.t.sol +++ b/test/unit/B20/memo/transferWithMemo.t.sol @@ -30,6 +30,7 @@ contract B20TransferWithMemoTest is B20Test { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); + amount = _boundBalanceAmount(amount); _mint(from, amount); vm.prank(from); @@ -56,6 +57,7 @@ contract B20TransferWithMemoTest is B20Test { { _assumeValidActor(from); _assumeValidActor(to); + amount = _boundBalanceAmount(amount); _mint(from, amount); @@ -72,6 +74,7 @@ contract B20TransferWithMemoTest is B20Test { function test_transferWithMemo_success_returnsTrue(address from, address to, uint256 amount, bytes32 memo) public { _assumeValidActor(from); _assumeValidActor(to); + amount = _boundBalanceAmount(amount); _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..3b40257 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 = _boundBalanceAmount(amount); _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 = _boundBalanceAmount(amount); _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 = _boundBalanceAmount(amount); _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..6c8cb62 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 = _boundBalanceAmount(amount); // 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 = _boundBalanceAmount(amount); _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 = _boundBalanceAmount(amount); _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..65bfe1e 100644 --- a/test/unit/B20/supply/mint.t.sol +++ b/test/unit/B20/supply/mint.t.sol @@ -75,6 +75,19 @@ contract B20MintTest is B20Test { token.mint(to, amount); } + /// @notice Verifies mint reverts when the recipient balance would exceed the per-account maximum + /// @dev Per-account balance cap: total supply may still fit under supplyCap, but the recipient + /// cannot hold more than MAX_BALANCE. + function test_mint_revert_maxBalanceExceeded(address to, uint256 amount) public { + _assumeValidActor(to); + amount = bound(amount, MAX_BALANCE + 1, type(uint256).max); + _grantRole(B20Constants.MINT_ROLE, minter); + + vm.prank(minter); + vm.expectRevert(abi.encodeWithSelector(IB20.MaxBalanceExceeded.selector, to, MAX_BALANCE, amount)); + token.mint(to, amount); + } + /// @notice Verifies MINT_RECEIVER_POLICY is enforced even for a privileged (factory bootstrap) mint /// @dev Mint-side counterpart to the transfer privileged-bypass tests (BOP-332): the bootstrap window /// bypasses the transfer-side policies but ALWAYS enforces MINT_RECEIVER_POLICY, so new supply is @@ -121,6 +134,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 = _boundBalanceAmount(amount); uint256 before = token.balanceOf(to); _mint(to, amount); @@ -137,6 +151,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 = _boundBalanceAmount(amount); uint256 before = token.totalSupply(); _mint(to, amount); @@ -152,6 +167,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 = _boundBalanceAmount(amount); _grantRole(B20Constants.MINT_ROLE, minter); vm.expectEmit(true, true, false, true, address(token)); @@ -182,8 +198,8 @@ 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); + first = _boundBalanceAmount(first); + second = bound(second, 0, MAX_BALANCE - first); _mint(to, first); _mint(to, second); diff --git a/test/unit/storage/MockB20SlotHelpers.t.sol b/test/unit/storage/MockB20SlotHelpers.t.sol index 1246601..3886aef 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 = _boundBalanceAmount(amount); _mint(account, amount);