From ebab5536193542ec31750189d982247ef9a0737d Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 9 Jun 2026 15:27:59 -0700 Subject: [PATCH] docs(b20): document factory bootstrap policy asymmetry (MintReceiver always enforced) The `IB20Factory.createB20` natspec claimed the creation (bootstrap) window bypasses the token's "role / policy / pause" gates for factory-originated calls. That overstated the bypass on two counts (BOP-332 / PSRC #23): - `MINT_RECEIVER_POLICY` is always enforced, even for privileged mints, so new supply is never issued to a policy-denied recipient at creation. - Pause is never bypassed; it defaults to nothing-paused and must be opted into via initCalls. Only role gates and the transfer-side policies (sender / receiver / executor) are actually bypassed. base-std natspec is the source of truth, so pin the intent there rather than in implementation comments: - Rewrite the `createB20` bootstrap-window natspec to spell out the asymmetry. - Add cross-referencing notes to the IB20 policy-type getters. - Document the asymmetry in docs/B20/Factory.md. - Add a unit test pinning that a privileged bootstrap mint to a denied MINT_RECEIVER recipient reverts (the transfer-bypass side is already covered in transfer.t.sol). Generated with Claude Code Co-Authored-By: Claude --- docs/B20/Factory.md | 6 ++++++ src/interfaces/IB20.sol | 9 +++++++++ src/interfaces/IB20Factory.sol | 17 +++++++++++++---- test/unit/B20/supply/mint.t.sol | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/docs/B20/Factory.md b/docs/B20/Factory.md index 8c25b60..e4c17ca 100644 --- a/docs/B20/Factory.md +++ b/docs/B20/Factory.md @@ -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 diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index 0a49797..ba83167 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -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); diff --git a/src/interfaces/IB20Factory.sol b/src/interfaces/IB20Factory.sol index 3f4d2b1..5f2e897 100644 --- a/src/interfaces/IB20Factory.sol +++ b/src/interfaces/IB20Factory.sol @@ -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. diff --git a/test/unit/B20/supply/mint.t.sol b/test/unit/B20/supply/mint.t.sol index 0f9e5b9..07f3820 100644 --- a/test/unit/B20/supply/mint.t.sol +++ b/test/unit/B20/supply/mint.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 {B20Test} from "base-std-test/lib/B20Test.sol"; import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; @@ -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 {