Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions test/unit/B20/erc20/transfer.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
}
}
44 changes: 44 additions & 0 deletions test/unit/B20/erc20/transferFrom.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
}
}
12 changes: 12 additions & 0 deletions test/unit/B20/memo/transferFromWithMemo.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions test/unit/B20/supply/mint.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
15 changes: 15 additions & 0 deletions test/unit/B20Asset/multiplier/toScaledBalance.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
17 changes: 17 additions & 0 deletions test/unit/B20Factory/createToken.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading