diff --git a/test/unit/B20/erc20/transfer.t.sol b/test/unit/B20/erc20/transfer.t.sol index ed81854..5a3d13b 100644 --- a/test/unit/B20/erc20/transfer.t.sol +++ b/test/unit/B20/erc20/transfer.t.sol @@ -2,6 +2,9 @@ pragma solidity ^0.8.20; import {IB20} from "base-std/interfaces/IB20.sol"; +import {IB20Factory} from "base-std/interfaces/IB20Factory.sol"; +import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; +import {StdPrecompiles} from "base-std/StdPrecompiles.sol"; import {B20Test} from "base-std-test/lib/B20Test.sol"; import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; @@ -162,4 +165,147 @@ contract B20TransferTest is B20Test { vm.prank(from); assertTrue(token.transfer(to, amount), "transfer must return true"); } + + /// @notice Verifies a self-transfer never inflates balance or totalSupply + /// @dev Regression guard against a dual-write bug where `balances[from] -= amount` followed by + /// `balances[to] += amount` with from == to could net non-zero. A self-transfer must + /// 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); + + _mint(account, amount); + uint256 balanceBefore = token.balanceOf(account); + uint256 supplyBefore = token.totalSupply(); + + vm.prank(account); + token.transfer(account, amount); + + assertEq(token.balanceOf(account), balanceBefore, "self-transfer must not change balance"); + assertEq(token.totalSupply(), supplyBefore, "self-transfer must not change totalSupply"); + } + + /// @notice Verifies a privileged (factory bootstrap) transfer bypasses the TRANSFER_SENDER_POLICY + /// @dev During the bootstrap window the factory caller is privileged and the sender policy is not + /// consulted. Privilege is reached through a genuine bootstrap: the token is created with + /// initCalls that (1) mint to the factory, (2) set the sender policy to ALWAYS_BLOCK, then + /// (3) transfer from the factory. A non-privileged transfer would revert PolicyForbids, so + /// the init-call transfer succeeding (createB20 not bubbling InitCallFailed) proves the + /// bypass. This drives the real factory-as-caller path with no vm.store cheat, so it runs + /// 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); + + bytes32 salt = keccak256("privileged-sender-bypass"); + // The fuzzed recipient must not collide with the to-be-created token's own address. + vm.assume(to != factory.getB20Address(IB20Factory.B20Variant.ASSET, alice, salt)); + + bytes[] memory initCalls = new bytes[](3); + initCalls[0] = abi.encodeWithSelector(IB20.mint.selector, address(factory), amount); + initCalls[1] = abi.encodeWithSelector( + IB20.updatePolicy.selector, B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID + ); + initCalls[2] = abi.encodeWithSelector(IB20.transfer.selector, to, amount); + + address newToken = _createAsset(alice, salt, _assetParams(), initCalls); + + assertEq(IB20(newToken).balanceOf(to), amount, "privileged transfer must succeed despite blocked sender policy"); + } + + /// @notice Verifies a privileged (factory bootstrap) transfer bypasses the TRANSFER_RECEIVER_POLICY + /// @dev Receiver-side mirror of the sender bypass: the bootstrap initCalls set the receiver policy + /// to ALWAYS_BLOCK and transfer to the blocked recipient. A non-privileged transfer would + /// revert PolicyForbids; the privileged init-call transfer must succeed. Like the sender + /// 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); + + bytes32 salt = keccak256("privileged-receiver-bypass"); + // The fuzzed recipient must not collide with the to-be-created token's own address. + vm.assume(to != factory.getB20Address(IB20Factory.B20Variant.ASSET, alice, salt)); + + bytes[] memory initCalls = new bytes[](3); + initCalls[0] = abi.encodeWithSelector(IB20.mint.selector, address(factory), amount); + initCalls[1] = abi.encodeWithSelector( + IB20.updatePolicy.selector, B20Constants.TRANSFER_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID + ); + initCalls[2] = abi.encodeWithSelector(IB20.transfer.selector, to, amount); + + address newToken = _createAsset(alice, salt, _assetParams(), initCalls); + + assertEq( + IB20(newToken).balanceOf(to), amount, "privileged transfer must succeed despite blocked receiver policy" + ); + } + + /// @notice Verifies transfer succeeds when the sender is a member of a custom ALLOWLIST policy + /// @dev Exercises the external-registry authorization path (a real custom policy id, not the + /// ALWAYS_ALLOW / ALWAYS_BLOCK sentinels): isAuthorized resolves to a membership SLOAD. + /// The sentinel-only tests cannot catch a divergence in custom-allowlist evaluation. + function test_transfer_success_externalSenderPolicyAllows(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + amount = bound(amount, 0, type(uint128).max); + + uint64 id = _createAllowlist(from, true); + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, id); + _mint(from, amount); + + vm.prank(from); + token.transfer(to, amount); + + assertEq(token.balanceOf(to), amount, "transfer must succeed when sender is allowlisted"); + } + + /// @notice Verifies transfer succeeds when the receiver is a member of a custom ALLOWLIST policy + /// @dev Receiver-side mirror of the external sender allow path. + function test_transfer_success_externalReceiverPolicyAllows(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + amount = bound(amount, 0, type(uint128).max); + + uint64 id = _createAllowlist(to, true); + _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, id); + _mint(from, amount); + + vm.prank(from); + token.transfer(to, amount); + + assertEq(token.balanceOf(to), amount, "transfer must succeed when receiver is allowlisted"); + } + + /// @notice Verifies transfer reverts when the sender is NOT a member of a custom ALLOWLIST policy + /// @dev Negative external-registry path: an allowlist with no membership for `from` resolves + /// isAuthorized to false, so the sender guard reverts PolicyForbids with the custom id. + /// No balance needed — the policy check fires before the balance check. + function test_transfer_revert_externalSenderPolicyDenies(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + + uint64 id = _createAllowlist(from, false); // create the allowlist but do NOT add `from` + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, id); + + vm.prank(from); + vm.expectRevert(abi.encodeWithSelector(IB20.PolicyForbids.selector, B20Constants.TRANSFER_SENDER_POLICY, id)); + token.transfer(to, amount); + } + + /// @notice Creates a custom ALLOWLIST policy administered by `admin`, optionally seeding + /// `member`, and returns its id. Drives the external-registry authorization path + /// (custom policy id) beyond the ALWAYS_ALLOW / ALWAYS_BLOCK sentinels. + function _createAllowlist(address member, bool addMember) private returns (uint64 id) { + vm.prank(admin); + id = StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + if (addMember) { + address[] memory accounts = new address[](1); + accounts[0] = member; + vm.prank(admin); + StdPrecompiles.POLICY_REGISTRY.updateAllowlist(id, true, accounts); + } + } } diff --git a/test/unit/B20/erc20/transferFrom.t.sol b/test/unit/B20/erc20/transferFrom.t.sol index bf431ec..ff3046d 100644 --- a/test/unit/B20/erc20/transferFrom.t.sol +++ b/test/unit/B20/erc20/transferFrom.t.sol @@ -393,6 +393,14 @@ contract B20TransferFromTest is B20Test { uint256 allowanceAmount, uint256 spendAmount ) public { + // Mock-only by necessity. This pins a privileged (factory bootstrap) transferFrom that + // consumes a pre-existing third-party allowance (allowance[from][factory], from != factory). + // Such an allowance can only be set by `from` calling approve, which requires the token to + // already exist with the bootstrap window CLOSED, yet the privileged path requires the + // window OPEN (during which the factory is the only caller). The two states are mutually + // exclusive in any real sequence, so there is no fork-reachable construction. The mock + // observes it only by reopening the window via vm.store, which has no live-precompile analog. + vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); _assumeValidActor(from); _assumeValidActor(to); allowanceAmount = bound(allowanceAmount, 0, type(uint128).max - 1); @@ -422,6 +430,10 @@ contract B20TransferFromTest is B20Test { uint256 allowanceAmount, uint256 spendAmount ) public { + // Mock-only by necessity: see test_transferFrom_revert_privileged_insufficientAllowance. + // The privileged path needs a pre-existing allowance[from][factory] that cannot be + // established inside the atomic bootstrap window, so there is no fork-reachable construction. + vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); @@ -451,4 +463,36 @@ contract B20TransferFromTest is B20Test { "allowances[from][factory] slot must reflect the consumed amount" ); } + + /// @notice Verifies a privileged transferFrom bypasses the executor policy while still consuming allowance + /// @dev Companion to test_transferFrom_success_privileged_decrementsAllowance (which pins allowance + /// accounting): this isolates the executor-policy bypass. With TRANSFER_EXECUTOR_POLICY set to + /// ALWAYS_BLOCK a non-privileged transferFrom reverts PolicyForbids; the privileged (factory + /// bootstrap) path must succeed and still burn the allowance. Only the executor-policy check + /// honors the privileged bypass — the allowance is consumed unconditionally (BOP-230 / L-04). + function test_transferFrom_success_privileged_skipsExecutorPolicy(address from, address to, uint256 amount) public { + // Mock-only by necessity: like test_transferFrom_revert_privileged_insufficientAllowance, + // the privileged path needs a pre-existing allowance[from][factory] (from != factory) that + // cannot be set inside the atomic bootstrap window (the factory is the only in-window + // caller). No fork-reachable construction exists; the mock reaches it via vm.store. + vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + amount = bound(amount, 1, type(uint128).max); + + _mint(from, amount); + vm.prank(from); + token.approve(address(factory), amount); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + // Reopen the factory bootstrap window so the factory caller is privileged. + vm.store(address(token), MockB20Storage.initializedSlot(), bytes32(0)); + + vm.prank(address(factory)); + token.transferFrom(from, to, amount); + + assertEq(token.balanceOf(to), amount, "privileged transferFrom must succeed despite blocked executor policy"); + assertEq(token.allowance(from, address(factory)), 0, "allowance must still be consumed under privilege"); + } } diff --git a/test/unit/B20/memo/transferFromWithMemo.t.sol b/test/unit/B20/memo/transferFromWithMemo.t.sol index af6489f..f6de74f 100644 --- a/test/unit/B20/memo/transferFromWithMemo.t.sol +++ b/test/unit/B20/memo/transferFromWithMemo.t.sol @@ -199,6 +199,14 @@ contract B20TransferFromWithMemoTest is B20Test { uint256 spendAmount, bytes32 memo ) public { + // Mock-only by necessity. This pins a privileged (factory bootstrap) transferFromWithMemo + // that consumes a pre-existing third-party allowance (allowance[from][factory], from != + // factory). Such an allowance can only be set by `from` calling approve, which requires the + // token to already exist with the bootstrap window CLOSED, yet the privileged path requires + // the window OPEN (during which the factory is the only caller). The two states are mutually + // exclusive in any real sequence, so there is no fork-reachable construction. The mock + // observes it only by reopening the window via vm.store, which has no live-precompile analog. + vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); _assumeValidActor(from); _assumeValidActor(to); allowanceAmount = bound(allowanceAmount, 0, type(uint128).max - 1); @@ -229,6 +237,10 @@ contract B20TransferFromWithMemoTest is B20Test { uint256 spendAmount, bytes32 memo ) public { + // Mock-only by necessity: see test_transferFromWithMemo_revert_privileged_insufficientAllowance. + // The privileged path needs a pre-existing allowance[from][factory] that cannot be + // established inside the atomic bootstrap window, so there is no fork-reachable construction. + vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); diff --git a/test/unit/B20/supply/mint.t.sol b/test/unit/B20/supply/mint.t.sol index 8d6e361..0f9e5b9 100644 --- a/test/unit/B20/supply/mint.t.sol +++ b/test/unit/B20/supply/mint.t.sol @@ -127,4 +127,36 @@ contract B20MintTest is B20Test { vm.prank(minter); token.mint(to, amount); } + + /// @notice Verifies mint succeeds when totalSupply + amount equals supplyCap exactly + /// @dev Boundary companion to test_mint_revert_supplyCapExceeded (which only exercises + /// cap + 1). The `> supplyCap` guard must admit the exact-cap case; minting to the cap + /// leaves totalSupply == cap. + function test_mint_success_atSupplyCapBoundary(address to, uint256 cap) public { + _assumeValidActor(to); + cap = bound(cap, 1, type(uint128).max); + + vm.prank(admin); + token.updateSupplyCap(cap); + + _mint(to, cap); + + assertEq(token.totalSupply(), cap, "totalSupply must reach exactly the cap"); + assertEq(token.balanceOf(to), cap, "recipient must hold exactly the cap"); + } + + /// @notice Verifies sequential mints to the same recipient accumulate additively + /// @dev Pins that mint is additive rather than last-write-wins: two mints credit the running + /// 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); + + _mint(to, first); + _mint(to, second); + + assertEq(token.balanceOf(to), first + second, "balance must equal the sum of both mints"); + assertEq(token.totalSupply(), first + second, "totalSupply must equal the sum of both mints"); + } } diff --git a/test/unit/B20Asset/multiplier/toScaledBalance.t.sol b/test/unit/B20Asset/multiplier/toScaledBalance.t.sol index 1b8dac4..d0f8eea 100644 --- a/test/unit/B20Asset/multiplier/toScaledBalance.t.sol +++ b/test/unit/B20Asset/multiplier/toScaledBalance.t.sol @@ -49,4 +49,19 @@ contract B20AssetToScaledBalanceTest is B20AssetTest { "stored zero multiplier must produce identity (WAD fallback)" ); } + + /// @notice Verifies toScaledBalance reverts when rawBalance * multiplier overflows uint256 + /// @dev The Rust precompile uses checked multiplication and reverts on overflow; the Solidity + /// reference relies on 0.8.x checked arithmetic (Panic 0x11). The success tests bound inputs + /// to avoid the overflow, leaving the boundary itself untested. A generic expectRevert keeps + /// the assertion robust across the mock (Panic) and the live precompile's overflow error. + function test_toScaledBalance_revert_arithmeticOverflow(uint256 rawBalance, uint256 newMultiplier) public { + newMultiplier = bound(newMultiplier, 2, type(uint256).max); + // Force rawBalance * multiplier strictly above type(uint256).max. + rawBalance = bound(rawBalance, type(uint256).max / newMultiplier + 1, type(uint256).max); + _updateMultiplier(newMultiplier); + + vm.expectRevert(); + asset().toScaledBalance(rawBalance); + } } diff --git a/test/unit/B20Factory/createToken.t.sol b/test/unit/B20Factory/createToken.t.sol index 33bf92b..fe638b8 100644 --- a/test/unit/B20Factory/createToken.t.sol +++ b/test/unit/B20Factory/createToken.t.sol @@ -45,6 +45,23 @@ contract B20FactoryCreateB20Test is B20FactoryTest { ok; // silence unused warning; the revert is asserted via vm.expectRevert. } + /// @notice Verifies createB20 reverts when the params bytes are not valid ABI-encoded create params + /// @dev The factory ABI-decodes `params` into the variant's create-params struct after the + /// activation gate; a malformed blob fails to decode. The Rust precompile surfaces this as + /// AbiDecodeFailed; the Solidity reference reverts at the decoder. A generic expectRevert + /// keeps the assertion robust across both. Activation is active by default in setUp, so the + /// decode step is reached before any variant-body validation. + function test_createB20_revert_invalidParamsEncoding(address caller, bytes32 salt) public { + _assumeValidCaller(caller); + // Four bytes is far too short to decode as B20AssetCreateParams (which begins with + // string-field offset words), so abi.decode reverts. + bytes memory badParams = hex"deadbeef"; + + vm.prank(caller); + vm.expectRevert(); + factory.createB20(IB20Factory.B20Variant.ASSET, salt, badParams, new bytes[](0)); + } + /// @notice Verifies createToken reverts for any unsupported version byte on the STABLECOIN variant /// @dev Each variant arm has its own version check; this exercises the stablecoin arm's check. function test_createB20_revert_unsupportedVersion_stablecoin(address caller, uint8 badVersion, bytes32 salt)