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
6 changes: 6 additions & 0 deletions docs/B20/Factory.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ Variant-specific creation arguments, ABI-encoded as a versioned struct (one stru

An optional array of ABI-encoded calls dispatched on the new token immediately after creation. These let you configure anything beyond the variant's defined `params` — role grants, mint operations, policy scopes, contract URI, and so on. They execute on the new token as if the factory were the admin, so admin-gated operations are permitted within this window. The factory itself receives no official roles and has no persisted access to the token.

The bootstrap bypass is deliberately **not total**. During the window, factory-originated calls skip the token's role gates and its transfer-side policy gates (`TRANSFER_SENDER_POLICY`, `TRANSFER_RECEIVER_POLICY`, `TRANSFER_EXECUTOR_POLICY`), but:

- **`MINT_RECEIVER_POLICY` is always enforced**, even for factory-originated mints — new supply is never issued to a policy-denied recipient, even at creation. If your `initCalls` set a restrictive `MINT_RECEIVER_POLICY` and then mint to a non-authorized account in the same bundle, the mint reverts `PolicyForbids` and the whole `createB20` reverts. Sequence the mint before the restrictive policy, or mint to an authorized recipient.
- **Pause is never bypassed.** It defaults to nothing-paused at creation, so a start-paused configuration must sequence its `pause(...)` call last.
- **Token invariants** (supply-cap math, balance accounting) are never bypassed.

Build the array with [`B20FactoryLib`](../../src/lib/B20FactoryLib.sol) helpers (or encode manually):

```solidity
Expand Down
9 changes: 9 additions & 0 deletions src/interfaces/IB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -213,19 +213,28 @@ interface IB20 {
//////////////////////////////////////////////////////////////*/

/// @notice Policy slot consulted against `from` on every transfer (including `transferFrom`).
/// @dev Bypassed for factory-originated calls during the creation (bootstrap) window; see
/// `IB20Factory.createB20`.
/// @return Policy scope constant.
function TRANSFER_SENDER_POLICY() external view returns (bytes32);

/// @notice Policy slot consulted against `to` on every transfer.
/// @dev Bypassed for factory-originated calls during the creation (bootstrap) window; see
/// `IB20Factory.createB20`.
/// @return Policy scope constant.
function TRANSFER_RECEIVER_POLICY() external view returns (bytes32);

/// @notice Policy slot consulted against `msg.sender` on `transferFrom` when distinct from `from`.
/// Not consulted on `transfer`.
/// @dev Bypassed for factory-originated calls during the creation (bootstrap) window; see
/// `IB20Factory.createB20`.
/// @return Policy scope constant.
function TRANSFER_EXECUTOR_POLICY() external view returns (bytes32);

/// @notice Policy slot consulted against `to` on every mint.
/// @dev Unlike the transfer-side policies, this slot is ALWAYS enforced — including for
/// factory-originated mints during the creation (bootstrap) window — so new supply is never
/// issued to a policy-denied recipient even at creation. See `IB20Factory.createB20`.
/// @return Policy scope constant.
function MINT_RECEIVER_POLICY() external view returns (bytes32);

Expand Down
17 changes: 13 additions & 4 deletions src/interfaces/IB20Factory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,19 @@ interface IB20Factory {
/// @dev Reverts with `InvalidDecimals` when an asset `decimals` is outside `[B20Constants.MIN_ASSET_DECIMALS, B20Constants.MAX_ASSET_DECIMALS]`.
/// @dev Reverts with `TokenAlreadyExists` when a token already exists at the derived address.
/// @dev Reverts with `InitCallFailed` (or the bubbled inner reason) when any entry in `initCalls` reverts.
/// @dev Each `initCall` executes on the new token within the creation (bootstrap) window, during which the
/// token's role / policy / pause authorization gates are bypassed for factory-originated calls — so
/// admin-gated setup (e.g. `grantRole`, `updatePolicy`, `updateSupplyCap`) succeeds without the factory
/// holding any role. The window closes when `createB20` returns; the factory retains no persisted access.
/// @dev Each `initCall` executes on the new token within the creation (bootstrap) window, during which
/// factory-originated calls bypass the token's role gates and its transfer-side policy gates
/// (`TRANSFER_SENDER_POLICY`, `TRANSFER_RECEIVER_POLICY`, `TRANSFER_EXECUTOR_POLICY`) — so admin-gated
/// setup (e.g. `grantRole`, `updatePolicy`, `updateSupplyCap`) and bootstrap transfers succeed without
/// the factory holding any role. The bypass is deliberately NOT total:
/// - `MINT_RECEIVER_POLICY` is ALWAYS enforced, including for factory-originated mints, so new supply is
/// never issued to a policy-denied recipient even at creation. An `initCalls` bundle that sets a
/// restrictive `MINT_RECEIVER_POLICY` and then mints to a non-authorized account reverts
/// `PolicyForbids(MINT_RECEIVER_POLICY, ...)` (bubbled out of `createB20`).
/// - Pause is never bypassed. It defaults to nothing-paused at creation, so a start-paused
/// configuration must sequence its `pause(...)` call last among the `initCalls`.
/// - Token invariants (supply-cap math, balance accounting) are never bypassed.
/// The window closes when `createB20` returns; the factory retains no persisted access.
///
/// @param variant Which variant struct `params` decodes as.
/// @param salt Caller-chosen salt for deterministic address derivation.
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 @@ -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 {B20Test} from "base-std-test/lib/B20Test.sol";
import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol";
Expand Down Expand Up @@ -74,6 +75,37 @@ contract B20MintTest is B20Test {
token.mint(to, amount);
}

/// @notice Verifies MINT_RECEIVER_POLICY is enforced even for a privileged (factory bootstrap) mint
/// @dev Mint-side counterpart to the transfer privileged-bypass tests (BOP-332): the bootstrap window
/// bypasses the transfer-side policies but ALWAYS enforces MINT_RECEIVER_POLICY, so new supply is
/// never issued to a policy-denied recipient even at creation. Privilege is reached through a
/// genuine bootstrap: the token is created with initCalls that (1) set the mint-receiver policy to
/// ALWAYS_BLOCK, then (2) mint to the blocked recipient. A privileged mint that bypassed the policy
/// would succeed; instead the init-call mint reverts PolicyForbids, which the factory bubbles out of
/// createB20 — proving the asymmetry. 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_mint_revert_privilegedStillEnforcesReceiverPolicy(address to, uint256 amount) public {
_assumeValidActor(to);
amount = bound(amount, 1, type(uint128).max);

bytes32 salt = keccak256("privileged-mint-receiver-enforced");
// 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[](2);
initCalls[0] = abi.encodeWithSelector(
IB20.updatePolicy.selector, B20Constants.MINT_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID
);
initCalls[1] = abi.encodeWithSelector(IB20.mint.selector, to, amount);

vm.expectRevert(
abi.encodeWithSelector(
IB20.PolicyForbids.selector, B20Constants.MINT_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID
)
);
_createAsset(alice, salt, _assetParams(), initCalls);
}

/// @notice Verifies mint reverts for the zero recipient address
/// @dev OZ ERC-6093 invariant; checks InvalidReceiver(address(0)) error
function test_mint_revert_zeroRecipient(uint256 amount) public {
Expand Down
Loading