From 16107e93c21d7723be4976c4bb3e8fe5a65f22b0 Mon Sep 17 00:00:00 2001 From: Amie Corso Date: Sun, 7 Jun 2026 07:00:42 -0700 Subject: [PATCH 1/3] test(b20): add Rust-parity gap coverage + guard mock-only privileged tests Adds base-std unit tests for externally-observable behaviors the Rust precompile suite covers but base-std did not exercise (surfaced by a Rust -> base-std test-parity audit of the B20 precompiles): - supply: zero-amount mint / burn / burnBlocked no-ops, exact supply-cap boundary, multi-call mint accumulation - erc20: self-transfer no-inflation, privileged sender/receiver policy bypass, external-allowlist sender/receiver allow + sender deny, privileged transferFrom executor-policy bypass - B20Asset: toScaledBalance arithmetic-overflow revert - B20Factory: createB20 invalid-params decode revert Also guards the pre-existing privileged transferFrom / transferFromWithMemo tests under LIVE_PRECOMPILES. Those tests reopen the factory bootstrap window via vm.store on the mock-only `initialized` slot, which the live precompile omits from its namespaced layout; the guard matches the convention already used by the other mock-only tests. Validated in both modes: mock (256 fuzz runs) and fork against the live Rust precompile at base/base main (non-privileged tests pass; privileged tests skip by construction). --- test/unit/B20/erc20/transfer.t.sol | 139 ++++++++++++++++++ test/unit/B20/erc20/transferFrom.t.sol | 41 ++++++ test/unit/B20/memo/transferFromWithMemo.t.sol | 8 + test/unit/B20/supply/burn.t.sol | 15 ++ test/unit/B20/supply/burnBlocked.t.sol | 17 +++ test/unit/B20/supply/mint.t.sol | 49 ++++++ .../B20Asset/multiplier/toScaledBalance.t.sol | 15 ++ test/unit/B20Factory/createToken.t.sol | 17 +++ 8 files changed, 301 insertions(+) diff --git a/test/unit/B20/erc20/transfer.t.sol b/test/unit/B20/erc20/transfer.t.sol index ed81854..c251f54 100644 --- a/test/unit/B20/erc20/transfer.t.sol +++ b/test/unit/B20/erc20/transfer.t.sol @@ -2,6 +2,8 @@ pragma solidity ^0.8.20; import {IB20} from "base-std/interfaces/IB20.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 +164,141 @@ contract B20TransferTest is B20Test { vm.prank(from); assertTrue(token.transfer(to, amount), "transfer must return true"); } + + /// @notice Verifies repeated self-transfers never inflate 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. Five self-transfers 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(); + + for (uint256 i = 0; i < 5; i++) { + 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. With the sender policy set to ALWAYS_BLOCK a non-privileged transfer would + /// revert PolicyForbids; the privileged path must succeed. Window reopened via vm.store on + /// the initialized slot, mirroring the privileged transferFrom regression tests. + function test_transfer_success_privilegedBypassesSenderPolicy(address to, uint256 amount) public { + // Mock-only: the privileged path is reached by reopening the bootstrap window via + // vm.store on the mock's initialized slot. The live precompile derives privilege from a + // real factory bootstrap call, not a storable flag, so this mechanism doesn't apply. + vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); + _assumeValidActor(to); + amount = bound(amount, 0, type(uint128).max); + + _mint(address(factory), amount); + _setPolicy(B20Constants.TRANSFER_SENDER_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.transfer(to, amount); + + assertEq(token.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: with the receiver policy set to ALWAYS_BLOCK a + /// non-privileged transfer would revert PolicyForbids, but the privileged path must succeed. + function test_transfer_success_privilegedBypassesReceiverPolicy(address to, uint256 amount) public { + // Mock-only: see test_transfer_success_privilegedBypassesSenderPolicy. The vm.store + // bootstrap-window reopen has no effect on the live precompile. + vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); + _assumeValidActor(to); + amount = bound(amount, 0, type(uint128).max); + + _mint(address(factory), amount); + _setPolicy(B20Constants.TRANSFER_RECEIVER_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.transfer(to, amount); + + assertEq(token.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..dacd8d9 100644 --- a/test/unit/B20/erc20/transferFrom.t.sol +++ b/test/unit/B20/erc20/transferFrom.t.sol @@ -393,6 +393,11 @@ contract B20TransferFromTest is B20Test { uint256 allowanceAmount, uint256 spendAmount ) public { + // Mock-only: the privileged path is reached by reopening the bootstrap window via + // vm.store on the mock's initialized slot, which the live precompile omits from its + // namespaced layout (it derives init-state from code presence, not slot 14). Matches the + // guard on test/unit/storage/MockB20SlotHelpers.t.sol:test_initializedSlot_*. + vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); _assumeValidActor(from); _assumeValidActor(to); allowanceAmount = bound(allowanceAmount, 0, type(uint128).max - 1); @@ -422,6 +427,9 @@ contract B20TransferFromTest is B20Test { uint256 allowanceAmount, uint256 spendAmount ) public { + // Mock-only: see test_transferFrom_revert_privileged_insufficientAllowance. The vm.store + // bootstrap-window reopen has no effect on the live precompile. + vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); @@ -451,4 +459,37 @@ 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: privilege is reached by reopening the bootstrap window via vm.store on the + // mock's initialized slot, which has no effect on the live precompile (it derives privilege + // from a real factory bootstrap call). Matches the guard the other vm.store-based tests use. + 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..1238b2b 100644 --- a/test/unit/B20/memo/transferFromWithMemo.t.sol +++ b/test/unit/B20/memo/transferFromWithMemo.t.sol @@ -199,6 +199,11 @@ contract B20TransferFromWithMemoTest is B20Test { uint256 spendAmount, bytes32 memo ) public { + // Mock-only: the privileged path is reached by reopening the bootstrap window via + // vm.store on the mock's initialized slot, which the live precompile omits from its + // namespaced layout (it derives init-state from code presence, not slot 14). Matches the + // guard on test/unit/storage/MockB20SlotHelpers.t.sol:test_initializedSlot_*. + vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); _assumeValidActor(from); _assumeValidActor(to); allowanceAmount = bound(allowanceAmount, 0, type(uint128).max - 1); @@ -229,6 +234,9 @@ contract B20TransferFromWithMemoTest is B20Test { uint256 spendAmount, bytes32 memo ) public { + // Mock-only: see test_transferFromWithMemo_revert_privileged_insufficientAllowance. The + // vm.store bootstrap-window reopen has no effect on the live precompile. + vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); diff --git a/test/unit/B20/supply/burn.t.sol b/test/unit/B20/supply/burn.t.sol index 1ade631..8ea178c 100644 --- a/test/unit/B20/supply/burn.t.sol +++ b/test/unit/B20/supply/burn.t.sol @@ -90,4 +90,19 @@ contract B20BurnTest is B20Test { vm.prank(burner); token.burn(amount); } + + /// @notice Verifies burn with a zero amount succeeds as a no-op + /// @dev amount == 0 is not rejected (the `InvalidAmount` selector is unused on the burn path). + /// The InsufficientBalance check (`balance < amount` == `0 < 0` == false) passes even with + /// a zero balance, so a zero burn leaves balance and totalSupply unchanged. + function test_burn_success_zeroAmount() public { + _grantRole(B20Constants.BURN_ROLE, burner); + uint256 supplyBefore = token.totalSupply(); + + vm.prank(burner); + token.burn(0); + + assertEq(token.balanceOf(burner), 0, "zero burn must leave balance unchanged"); + assertEq(token.totalSupply(), supplyBefore, "zero burn must leave totalSupply unchanged"); + } } diff --git a/test/unit/B20/supply/burnBlocked.t.sol b/test/unit/B20/supply/burnBlocked.t.sol index 2ed8282..2138c43 100644 --- a/test/unit/B20/supply/burnBlocked.t.sol +++ b/test/unit/B20/supply/burnBlocked.t.sol @@ -122,4 +122,21 @@ contract B20BurnBlockedTest is B20Test { vm.prank(burnBlocker); token.burnBlocked(from, amount); } + + /// @notice Verifies burnBlocked with a zero amount succeeds as a no-op against a blocked account + /// @dev amount == 0 is not rejected; with `from` policy-blocked under TRANSFER_SENDER_POLICY the + /// AccountNotBlocked guard passes, and the InsufficientBalance check (`0 < 0` == false) + /// passes against a zero balance, so the seizure is a no-op leaving state unchanged. + function test_burnBlocked_success_zeroAmount(address from) public { + _assumeValidActor(from); + _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + uint256 supplyBefore = token.totalSupply(); + + vm.prank(burnBlocker); + token.burnBlocked(from, 0); + + assertEq(token.balanceOf(from), 0, "zero seizure must leave balance unchanged"); + assertEq(token.totalSupply(), supplyBefore, "zero seizure must leave totalSupply unchanged"); + } } diff --git a/test/unit/B20/supply/mint.t.sol b/test/unit/B20/supply/mint.t.sol index 8d6e361..de6b216 100644 --- a/test/unit/B20/supply/mint.t.sol +++ b/test/unit/B20/supply/mint.t.sol @@ -127,4 +127,53 @@ contract B20MintTest is B20Test { vm.prank(minter); token.mint(to, amount); } + + /// @notice Verifies mint with a zero amount succeeds as a no-op + /// @dev Neither the Rust precompile nor the spec rejects amount == 0: the `InvalidAmount` + /// selector exists in the ABI but is unused on the mint path, so a zero mint leaves + /// balance and totalSupply unchanged. Pins the agreed zero-as-no-op behavior so a future + /// divergence (one side re-adding an InvalidAmount guard) is caught. The canonical + /// Transfer event test already fuzzes amount over a domain that includes 0. + function test_mint_success_zeroAmount(address to) public { + _assumeValidActor(to); + uint256 balanceBefore = token.balanceOf(to); + uint256 supplyBefore = token.totalSupply(); + + _mint(to, 0); + + assertEq(token.balanceOf(to), balanceBefore, "zero mint must leave balance unchanged"); + assertEq(token.totalSupply(), supplyBefore, "zero mint must leave totalSupply unchanged"); + } + + /// @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) From b854e1051581fa02299d8a37dc2fd1992f818eda Mon Sep 17 00:00:00 2001 From: Amie Corso Date: Sun, 7 Jun 2026 07:16:12 -0700 Subject: [PATCH 2/3] forge format --- test/unit/B20/erc20/transferFrom.t.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/unit/B20/erc20/transferFrom.t.sol b/test/unit/B20/erc20/transferFrom.t.sol index dacd8d9..503fdb2 100644 --- a/test/unit/B20/erc20/transferFrom.t.sol +++ b/test/unit/B20/erc20/transferFrom.t.sol @@ -466,9 +466,7 @@ contract B20TransferFromTest is B20Test { /// 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 - { + function test_transferFrom_success_privileged_skipsExecutorPolicy(address from, address to, uint256 amount) public { // Mock-only: privilege is reached by reopening the bootstrap window via vm.store on the // mock's initialized slot, which has no effect on the live precompile (it derives privilege // from a real factory bootstrap call). Matches the guard the other vm.store-based tests use. From 551152be3cbf467a5e5b17f33185ed6719fa650f Mon Sep 17 00:00:00 2001 From: Amie Corso Date: Sun, 7 Jun 2026 10:08:06 -0700 Subject: [PATCH 3/3] test(b20): tighten parity-gap tests per audit - drop redundant zero-amount mint/burn/burnBlocked tests (already exercised by the unbounded-fuzz happy-path tests, whose domain includes 0) - simplify the self-transfer no-inflation test to a single transfer - rewrite the privileged transfer sender/receiver-policy bypass tests to reach privilege via a genuine factory bootstrap initCall chain (mint -> updatePolicy -> transfer) instead of a vm.store window reopen, so they run against the live precompile under LIVE_PRECOMPILES - correct the skip rationale on the privileged transferFrom/memo allowance tests: a pre-existing third-party allowance cannot coexist with an open bootstrap window, so there is no fork-reachable construction (the prior slot-layout explanation was misleading) Validation: mock 271 pass / 0 fail / 0 skip; fork 266 pass / 0 fail / 5 skip (the 5 privileged allowance tests) via run-fork-tests.sh against base-anvil. --- test/unit/B20/erc20/transfer.t.sol | 71 ++++++++++--------- test/unit/B20/erc20/transferFrom.t.sol | 23 +++--- test/unit/B20/memo/transferFromWithMemo.t.sol | 16 +++-- test/unit/B20/supply/burn.t.sol | 15 ---- test/unit/B20/supply/burnBlocked.t.sol | 17 ----- test/unit/B20/supply/mint.t.sol | 17 ----- 6 files changed, 63 insertions(+), 96 deletions(-) diff --git a/test/unit/B20/erc20/transfer.t.sol b/test/unit/B20/erc20/transfer.t.sol index c251f54..5a3d13b 100644 --- a/test/unit/B20/erc20/transfer.t.sol +++ b/test/unit/B20/erc20/transfer.t.sol @@ -2,6 +2,7 @@ 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"; @@ -165,9 +166,9 @@ contract B20TransferTest is B20Test { assertTrue(token.transfer(to, amount), "transfer must return true"); } - /// @notice Verifies repeated self-transfers never inflate balance or totalSupply + /// @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. Five self-transfers must + /// `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); @@ -177,10 +178,8 @@ contract B20TransferTest is B20Test { uint256 balanceBefore = token.balanceOf(account); uint256 supplyBefore = token.totalSupply(); - for (uint256 i = 0; i < 5; i++) { - vm.prank(account); - token.transfer(account, amount); - } + 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"); @@ -188,49 +187,57 @@ contract B20TransferTest is B20Test { /// @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. With the sender policy set to ALWAYS_BLOCK a non-privileged transfer would - /// revert PolicyForbids; the privileged path must succeed. Window reopened via vm.store on - /// the initialized slot, mirroring the privileged transferFrom regression tests. + /// 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 { - // Mock-only: the privileged path is reached by reopening the bootstrap window via - // vm.store on the mock's initialized slot. The live precompile derives privilege from a - // real factory bootstrap call, not a storable flag, so this mechanism doesn't apply. - vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); _assumeValidActor(to); amount = bound(amount, 0, type(uint128).max); - _mint(address(factory), amount); - _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + 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)); - // Reopen the factory bootstrap window so the factory caller is privileged. - vm.store(address(token), MockB20Storage.initializedSlot(), bytes32(0)); + 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); - vm.prank(address(factory)); - token.transfer(to, amount); + address newToken = _createAsset(alice, salt, _assetParams(), initCalls); - assertEq(token.balanceOf(to), amount, "privileged transfer must succeed despite blocked sender policy"); + 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: with the receiver policy set to ALWAYS_BLOCK a - /// non-privileged transfer would revert PolicyForbids, but the privileged path must succeed. + /// @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 { - // Mock-only: see test_transfer_success_privilegedBypassesSenderPolicy. The vm.store - // bootstrap-window reopen has no effect on the live precompile. - vm.skip(vm.envOr("LIVE_PRECOMPILES", false)); _assumeValidActor(to); amount = bound(amount, 0, type(uint128).max); - _mint(address(factory), amount); - _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + 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)); - // Reopen the factory bootstrap window so the factory caller is privileged. - vm.store(address(token), MockB20Storage.initializedSlot(), bytes32(0)); + 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); - vm.prank(address(factory)); - token.transfer(to, amount); + address newToken = _createAsset(alice, salt, _assetParams(), initCalls); - assertEq(token.balanceOf(to), amount, "privileged transfer must succeed despite blocked receiver policy"); + 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 diff --git a/test/unit/B20/erc20/transferFrom.t.sol b/test/unit/B20/erc20/transferFrom.t.sol index 503fdb2..ff3046d 100644 --- a/test/unit/B20/erc20/transferFrom.t.sol +++ b/test/unit/B20/erc20/transferFrom.t.sol @@ -393,10 +393,13 @@ contract B20TransferFromTest is B20Test { uint256 allowanceAmount, uint256 spendAmount ) public { - // Mock-only: the privileged path is reached by reopening the bootstrap window via - // vm.store on the mock's initialized slot, which the live precompile omits from its - // namespaced layout (it derives init-state from code presence, not slot 14). Matches the - // guard on test/unit/storage/MockB20SlotHelpers.t.sol:test_initializedSlot_*. + // 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); @@ -427,8 +430,9 @@ contract B20TransferFromTest is B20Test { uint256 allowanceAmount, uint256 spendAmount ) public { - // Mock-only: see test_transferFrom_revert_privileged_insufficientAllowance. The vm.store - // bootstrap-window reopen has no effect on the live precompile. + // 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); @@ -467,9 +471,10 @@ contract B20TransferFromTest is B20Test { /// 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: privilege is reached by reopening the bootstrap window via vm.store on the - // mock's initialized slot, which has no effect on the live precompile (it derives privilege - // from a real factory bootstrap call). Matches the guard the other vm.store-based tests use. + // 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); diff --git a/test/unit/B20/memo/transferFromWithMemo.t.sol b/test/unit/B20/memo/transferFromWithMemo.t.sol index 1238b2b..f6de74f 100644 --- a/test/unit/B20/memo/transferFromWithMemo.t.sol +++ b/test/unit/B20/memo/transferFromWithMemo.t.sol @@ -199,10 +199,13 @@ contract B20TransferFromWithMemoTest is B20Test { uint256 spendAmount, bytes32 memo ) public { - // Mock-only: the privileged path is reached by reopening the bootstrap window via - // vm.store on the mock's initialized slot, which the live precompile omits from its - // namespaced layout (it derives init-state from code presence, not slot 14). Matches the - // guard on test/unit/storage/MockB20SlotHelpers.t.sol:test_initializedSlot_*. + // 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); @@ -234,8 +237,9 @@ contract B20TransferFromWithMemoTest is B20Test { uint256 spendAmount, bytes32 memo ) public { - // Mock-only: see test_transferFromWithMemo_revert_privileged_insufficientAllowance. The - // vm.store bootstrap-window reopen has no effect on the live precompile. + // 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); diff --git a/test/unit/B20/supply/burn.t.sol b/test/unit/B20/supply/burn.t.sol index 8ea178c..1ade631 100644 --- a/test/unit/B20/supply/burn.t.sol +++ b/test/unit/B20/supply/burn.t.sol @@ -90,19 +90,4 @@ contract B20BurnTest is B20Test { vm.prank(burner); token.burn(amount); } - - /// @notice Verifies burn with a zero amount succeeds as a no-op - /// @dev amount == 0 is not rejected (the `InvalidAmount` selector is unused on the burn path). - /// The InsufficientBalance check (`balance < amount` == `0 < 0` == false) passes even with - /// a zero balance, so a zero burn leaves balance and totalSupply unchanged. - function test_burn_success_zeroAmount() public { - _grantRole(B20Constants.BURN_ROLE, burner); - uint256 supplyBefore = token.totalSupply(); - - vm.prank(burner); - token.burn(0); - - assertEq(token.balanceOf(burner), 0, "zero burn must leave balance unchanged"); - assertEq(token.totalSupply(), supplyBefore, "zero burn must leave totalSupply unchanged"); - } } diff --git a/test/unit/B20/supply/burnBlocked.t.sol b/test/unit/B20/supply/burnBlocked.t.sol index 2138c43..2ed8282 100644 --- a/test/unit/B20/supply/burnBlocked.t.sol +++ b/test/unit/B20/supply/burnBlocked.t.sol @@ -122,21 +122,4 @@ contract B20BurnBlockedTest is B20Test { vm.prank(burnBlocker); token.burnBlocked(from, amount); } - - /// @notice Verifies burnBlocked with a zero amount succeeds as a no-op against a blocked account - /// @dev amount == 0 is not rejected; with `from` policy-blocked under TRANSFER_SENDER_POLICY the - /// AccountNotBlocked guard passes, and the InsufficientBalance check (`0 < 0` == false) - /// passes against a zero balance, so the seizure is a no-op leaving state unchanged. - function test_burnBlocked_success_zeroAmount(address from) public { - _assumeValidActor(from); - _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - uint256 supplyBefore = token.totalSupply(); - - vm.prank(burnBlocker); - token.burnBlocked(from, 0); - - assertEq(token.balanceOf(from), 0, "zero seizure must leave balance unchanged"); - assertEq(token.totalSupply(), supplyBefore, "zero seizure must leave totalSupply unchanged"); - } } diff --git a/test/unit/B20/supply/mint.t.sol b/test/unit/B20/supply/mint.t.sol index de6b216..0f9e5b9 100644 --- a/test/unit/B20/supply/mint.t.sol +++ b/test/unit/B20/supply/mint.t.sol @@ -128,23 +128,6 @@ contract B20MintTest is B20Test { token.mint(to, amount); } - /// @notice Verifies mint with a zero amount succeeds as a no-op - /// @dev Neither the Rust precompile nor the spec rejects amount == 0: the `InvalidAmount` - /// selector exists in the ABI but is unused on the mint path, so a zero mint leaves - /// balance and totalSupply unchanged. Pins the agreed zero-as-no-op behavior so a future - /// divergence (one side re-adding an InvalidAmount guard) is caught. The canonical - /// Transfer event test already fuzzes amount over a domain that includes 0. - function test_mint_success_zeroAmount(address to) public { - _assumeValidActor(to); - uint256 balanceBefore = token.balanceOf(to); - uint256 supplyBefore = token.totalSupply(); - - _mint(to, 0); - - assertEq(token.balanceOf(to), balanceBefore, "zero mint must leave balance unchanged"); - assertEq(token.totalSupply(), supplyBefore, "zero mint must leave totalSupply unchanged"); - } - /// @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