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
13 changes: 8 additions & 5 deletions src/interfaces/IB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ interface IB20 {
/// @notice An empty array was passed to a function that requires at least one element.
error EmptyFeatureSet();

/// @notice The proposed supply cap is below the current `totalSupply`.
/// @notice The proposed supply cap is outside the permitted range: below the current
/// `totalSupply`, or above the maximum (`type(uint128).max`).
///
/// @param currentSupply Current `totalSupply`.
/// @param proposedCap Rejected proposed cap.
Expand Down Expand Up @@ -549,17 +550,19 @@ interface IB20 {
SUPPLY CAP
//////////////////////////////////////////////////////////////*/

/// @notice The maximum total supply enforced on `mint`. `type(uint256).max` indicates no cap.
/// @notice The maximum total supply enforced on `mint`. Capped at `type(uint128).max`, which
/// indicates no cap (the unbounded sentinel). `totalSupply` can therefore never exceed
/// `type(uint128).max`.
/// @return Current supply cap.
function supplyCap() external view returns (uint256);

/// @notice Sets a new supply cap. May be raised or lowered freely, but never below current
/// `totalSupply`. Emits `SupplyCapUpdated`.
/// `totalSupply` and never above `type(uint128).max`. Emits `SupplyCapUpdated`.
///
/// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `DEFAULT_ADMIN_ROLE`.
/// @dev Reverts with `InvalidSupplyCap` when `newSupplyCap < totalSupply()`.
/// @dev Reverts with `InvalidSupplyCap` when `newSupplyCap < totalSupply()` or `newSupplyCap > type(uint128).max`.
///
/// @param newSupplyCap New supply cap.
/// @param newSupplyCap New supply cap. Must not exceed `type(uint128).max`.
function updateSupplyCap(uint256 newSupplyCap) external;

/*//////////////////////////////////////////////////////////////
Expand Down
5 changes: 5 additions & 0 deletions src/lib/B20Constants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,9 @@ library B20Constants {
/// ERC-20 community ceiling — every common wallet and indexer renders up to
/// 18 decimals correctly; going higher risks integration breakage.
uint8 internal constant MAX_ASSET_DECIMALS = 18;

/// @notice Inclusive upper bound for the supply cap, and therefore for `totalSupply`.
/// `type(uint128).max` also serves as the unbounded ("no cap") sentinel: a cap
/// set to this value imposes no practical limit while keeping supply within `uint128`.
uint256 internal constant MAX_SUPPLY_CAP = type(uint128).max;
}
11 changes: 10 additions & 1 deletion test/lib/mocks/MockB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ abstract contract MockB20 is IB20 {
bytes32 public constant TRANSFER_EXECUTOR_POLICY = B20Constants.TRANSFER_EXECUTOR_POLICY;
bytes32 public constant MINT_RECEIVER_POLICY = B20Constants.MINT_RECEIVER_POLICY;

/// @notice Maximum value the supply cap may be set to. Because `mint`
/// rejects any `totalSupply` above the cap, this also bounds
/// `totalSupply` to `type(uint128).max`. The Rust precompile
/// enforces the same ceiling. Mirrors `B20Constants` so the
/// single source of truth lives in one library.
uint256 public constant MAX_SUPPLY_CAP = B20Constants.MAX_SUPPLY_CAP;

// ============================================================
// MODIFIERS
// ============================================================
Expand Down Expand Up @@ -510,7 +517,9 @@ abstract contract MockB20 is IB20 {

function updateSupplyCap(uint256 newSupplyCap) external onlyRole(DEFAULT_ADMIN_ROLE) {
uint256 currentSupply = MockB20Storage.layout().totalSupply;
if (newSupplyCap < currentSupply) revert InvalidSupplyCap(currentSupply, newSupplyCap);
if (newSupplyCap < currentSupply || newSupplyCap > MAX_SUPPLY_CAP) {
revert InvalidSupplyCap(currentSupply, newSupplyCap);
}
uint256 oldSupplyCap = MockB20Storage.layout().supplyCap;
MockB20Storage.layout().supplyCap = newSupplyCap;
emit SupplyCapUpdated(msg.sender, oldSupplyCap, newSupplyCap);
Expand Down
2 changes: 1 addition & 1 deletion test/lib/mocks/MockB20Factory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ contract MockB20Factory is IB20Factory {
function _writeBaseStorage(address token, string memory name_, string memory symbol_) internal {
_writeString(token, MockB20Storage.slotOf(MockB20Storage.NAME_OFFSET), name_);
_writeString(token, MockB20Storage.slotOf(MockB20Storage.SYMBOL_OFFSET), symbol_);
_writeUint(token, MockB20Storage.slotOf(MockB20Storage.SUPPLY_CAP_OFFSET), type(uint256).max);
_writeUint(token, MockB20Storage.slotOf(MockB20Storage.SUPPLY_CAP_OFFSET), B20Constants.MAX_SUPPLY_CAP);
// Everything else (totalSupply, balances, allowances, roles,
// roleAdmins, adminCount, transferPolicyIds, mintPolicyIds,
// pausedVectors, nonces, contractURI, initialized) defaults to
Expand Down
3 changes: 3 additions & 0 deletions test/unit/B20/erc20/balanceOf.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
pragma solidity ^0.8.20;

import {B20Test} from "base-std-test/lib/B20Test.sol";
import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol";

contract B20BalanceOfTest is B20Test {
/// @notice Verifies balanceOf returns zero for any account that has never received tokens
Expand All @@ -14,6 +15,7 @@ contract B20BalanceOfTest is B20Test {
/// @dev Mint readback; canonical mint test lives in mint.t.sol
function test_balanceOf_success_reflectsMint(address to, uint256 amount) public {
_assumeValidActor(to);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);
_mint(to, amount);
assertEq(token.balanceOf(to), amount, "balance must equal minted amount");
}
Expand All @@ -24,6 +26,7 @@ contract B20BalanceOfTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(from != to);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);

_mint(from, amount);
vm.prank(from);
Expand Down
3 changes: 2 additions & 1 deletion test/unit/B20/erc20/totalSupply.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ contract B20TotalSupplyTest is B20Test {
/// @dev Accounting invariant: totalSupply == sum of all balances == sum(mint) - sum(burn)
function test_totalSupply_success_tracksMintAndBurn(address to, uint256 mintAmount, uint256 burnAmount) public {
_assumeValidActor(to);
// Bound burnAmount to <= mintAmount so we don't underflow.
// Cap the mint at the supply ceiling, then bound burnAmount to <= mintAmount so we don't underflow.
mintAmount = bound(mintAmount, 0, B20Constants.MAX_SUPPLY_CAP);
burnAmount = bound(burnAmount, 0, mintAmount);

_mint(to, mintAmount);
Expand Down
14 changes: 9 additions & 5 deletions test/unit/B20/erc20/transfer.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ contract B20TransferTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(from != to);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);

_mint(from, amount);
uint256 before = token.balanceOf(from);
Expand All @@ -126,6 +127,7 @@ contract B20TransferTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(from != to);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);

_mint(from, amount);
uint256 before = token.balanceOf(to);
Expand All @@ -146,6 +148,7 @@ contract B20TransferTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);

amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);
_mint(from, amount);

vm.expectEmit(true, true, false, true, address(token));
Expand All @@ -160,6 +163,7 @@ contract B20TransferTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);

amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);
_mint(from, amount);

vm.prank(from);
Expand All @@ -172,7 +176,7 @@ contract B20TransferTest is B20Test {
/// leave both the balance and totalSupply exactly where they started.
function test_transfer_success_selfTransferNoInflation(address account, uint256 amount) public {
_assumeValidActor(account);
amount = bound(amount, 0, type(uint128).max);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);

_mint(account, amount);
uint256 balanceBefore = token.balanceOf(account);
Expand All @@ -195,7 +199,7 @@ contract B20TransferTest is B20Test {
/// identically against the live precompile under LIVE_PRECOMPILES.
function test_transfer_success_privilegedBypassesSenderPolicy(address to, uint256 amount) public {
_assumeValidActor(to);
amount = bound(amount, 0, type(uint128).max);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);

bytes32 salt = keccak256("privileged-sender-bypass");
// The fuzzed recipient must not collide with the to-be-created token's own address.
Expand All @@ -220,7 +224,7 @@ contract B20TransferTest is B20Test {
/// mirror, this drives the real factory bootstrap path and runs under LIVE_PRECOMPILES.
function test_transfer_success_privilegedBypassesReceiverPolicy(address to, uint256 amount) public {
_assumeValidActor(to);
amount = bound(amount, 0, type(uint128).max);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);

bytes32 salt = keccak256("privileged-receiver-bypass");
// The fuzzed recipient must not collide with the to-be-created token's own address.
Expand Down Expand Up @@ -248,7 +252,7 @@ contract B20TransferTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(from != to);
amount = bound(amount, 0, type(uint128).max);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);

uint64 id = _createAllowlist(from, true);
_setPolicy(B20Constants.TRANSFER_SENDER_POLICY, id);
Expand All @@ -266,7 +270,7 @@ contract B20TransferTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(from != to);
amount = bound(amount, 0, type(uint128).max);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);

uint64 id = _createAllowlist(to, true);
_setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, id);
Expand Down
29 changes: 16 additions & 13 deletions test/unit/B20/erc20/transferFrom.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(caller != from); // skip the consume-allowance bypass when caller == from
amount = bound(amount, 1, type(uint128).max);
amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP);

vm.prank(from);
token.approve(caller, amount);
Expand All @@ -40,7 +40,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(caller != from);
amount = bound(amount, 1, type(uint128).max);
amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP);

vm.prank(from);
token.approve(caller, amount);
Expand All @@ -66,7 +66,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(caller != from);
amount = bound(amount, 1, type(uint128).max);
amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP);

vm.prank(from);
token.approve(caller, amount);
Expand All @@ -92,7 +92,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(caller != from);
amount = bound(amount, 1, type(uint128).max);
amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP);

vm.prank(from);
token.approve(caller, amount);
Expand Down Expand Up @@ -135,7 +135,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(caller != from);
amount = bound(amount, 1, type(uint128).max);
amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP);
// from has zero balance, but allowance is set high enough to clear the allowance gate.

vm.prank(from);
Expand All @@ -156,6 +156,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(to);
vm.assume(caller != from);
vm.assume(from != to);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);

_mint(from, amount);
vm.prank(from);
Expand Down Expand Up @@ -194,7 +195,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(to);
vm.assume(caller != from);
vm.assume(from != to);
allowanceAmount = bound(allowanceAmount, 1, type(uint128).max);
allowanceAmount = bound(allowanceAmount, 1, B20Constants.MAX_SUPPLY_CAP);
// Cap below type(uint256).max so we exercise the consume path (not the infinite-allowance bypass).
vm.assume(allowanceAmount != type(uint256).max);
// spendAmount includes 0 so the assertion (allowance decreases by spendAmount) is
Expand Down Expand Up @@ -234,7 +235,7 @@ contract B20TransferFromTest is B20Test {
vm.assume(from != to);
// Include amount = 0: the infinite-allowance invariant must hold across the full
// valid input domain, including the no-op zero-transfer case.
amount = bound(amount, 0, type(uint128).max);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);

_mint(from, amount);
vm.prank(from);
Expand All @@ -258,6 +259,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(caller != from);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);

_mint(from, amount);
vm.prank(from);
Expand All @@ -276,6 +278,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(caller != from);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);

_mint(from, amount);
vm.prank(from);
Expand Down Expand Up @@ -322,7 +325,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(from != to);
allowanceAmount = bound(allowanceAmount, 1, type(uint128).max);
allowanceAmount = bound(allowanceAmount, 1, B20Constants.MAX_SUPPLY_CAP);
vm.assume(allowanceAmount != type(uint256).max);
spendAmount = bound(spendAmount, 1, allowanceAmount);

Expand Down Expand Up @@ -352,7 +355,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(from != to);
amount = bound(amount, 1, type(uint128).max);
amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP);

_mint(from, amount);
vm.prank(from);
Expand Down Expand Up @@ -403,8 +406,8 @@ contract B20TransferFromTest is B20Test {
vm.skip(vm.envOr("LIVE_PRECOMPILES", false));
_assumeValidActor(from);
_assumeValidActor(to);
allowanceAmount = bound(allowanceAmount, 0, type(uint128).max - 1);
spendAmount = bound(spendAmount, allowanceAmount + 1, type(uint128).max);
allowanceAmount = bound(allowanceAmount, 0, B20Constants.MAX_SUPPLY_CAP - 1);
spendAmount = bound(spendAmount, allowanceAmount + 1, B20Constants.MAX_SUPPLY_CAP);

_mint(from, spendAmount);
vm.prank(from);
Expand Down Expand Up @@ -437,7 +440,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(from != to);
allowanceAmount = bound(allowanceAmount, 1, type(uint128).max);
allowanceAmount = bound(allowanceAmount, 1, B20Constants.MAX_SUPPLY_CAP);
vm.assume(allowanceAmount != type(uint256).max);
spendAmount = bound(spendAmount, 0, allowanceAmount);

Expand Down Expand Up @@ -479,7 +482,7 @@ contract B20TransferFromTest is B20Test {
_assumeValidActor(from);
_assumeValidActor(to);
vm.assume(from != to);
amount = bound(amount, 1, type(uint128).max);
amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP);

_mint(from, amount);
vm.prank(from);
Expand Down
2 changes: 2 additions & 0 deletions test/unit/B20/memo/burnWithMemo.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ contract B20BurnWithMemoTest is B20Test {
/// @dev Accounting unchanged from burn; the memo does not alter accounting.
/// Paired slot assertions confirm balance and totalSupply slots reflect the burn.
function test_burnWithMemo_success_debitsAndDecreasesSupply(uint256 amount, bytes32 memo) public {
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);
_grantRole(B20Constants.BURN_ROLE, burner);
_mint(burner, amount);

Expand All @@ -49,6 +50,7 @@ contract B20BurnWithMemoTest is B20Test {
/// @notice Verifies burnWithMemo emits Transfer(caller, address(0), amount) then Memo(memo)
/// @dev Event ordering: Memo follows Transfer; canonical Memo test for the burn path
function test_burnWithMemo_success_emitsTransferThenMemo(uint256 amount, bytes32 memo) public {
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);
_grantRole(B20Constants.BURN_ROLE, burner);
_mint(burner, amount);

Expand Down
2 changes: 2 additions & 0 deletions test/unit/B20/memo/mintWithMemo.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ contract B20MintWithMemoTest is B20Test {
/// Paired slot assertions confirm balance and totalSupply slots reflect the mint.
function test_mintWithMemo_success_creditsAndUpdatesSupply(address to, uint256 amount, bytes32 memo) public {
_assumeValidActor(to);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);
_grantRole(B20Constants.MINT_ROLE, minter);

uint256 supplyBefore = token.totalSupply();
Expand All @@ -53,6 +54,7 @@ contract B20MintWithMemoTest is B20Test {
/// @dev Event ordering: Memo follows Transfer; canonical Memo test for the mint path
function test_mintWithMemo_success_emitsTransferThenMemo(address to, uint256 amount, bytes32 memo) public {
_assumeValidActor(to);
amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP);
_grantRole(B20Constants.MINT_ROLE, minter);

vm.expectEmit(true, true, false, true, address(token));
Expand Down
Loading
Loading