From 6a55bf89aca40a1d0e90dec50d59ab636f0b54a3 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 10 Sep 2026 15:12:51 -0400 Subject: [PATCH 1/3] feat(policy): enforce TRANSFER_EXECUTOR_POLICY on every transfer path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralize the executor gate in `_transfer` so TRANSFER_EXECUTOR_POLICY is checked against `msg.sender` on all four transfer entrypoints (`transfer`, `transferFrom`, `transferWithMemo`, `transferFromWithMemo`), including when `msg.sender == from`. Previously it ran only on the delegated `transferFrom` paths and only when `msg.sender != from`. This closes two bypasses that made an executor allowlist unenforceable: direct `transfer` was never gated, and a self-`transferFrom` skipped the check. The executor scope is now a true "who may initiate a transfer" gate. Behavioral only — no new selectors, events, errors, or storage. An unset executor slot stays always-allow, so tokens that never configured the policy are unaffected. The factory bootstrap bypass and allowance accounting are unchanged. - MockB20: move executor check into `_transfer` (first, before sender/receiver); drop the duplicated body checks and the `msg.sender == from` carve-out - tests: executor cases on transfer + memo, EXECUTOR woven into the revert-order suites, and the old self-caller skip test inverted to pin the closed loophole - interface/comments: IB20 natspec, mock/storage comments Co-Authored-By: Claude --- src/interfaces/IB20.sol | 7 +- test/lib/mocks/MockB20.sol | 52 ++++------ test/lib/mocks/MockB20Storage.sol | 6 +- test/unit/B20/erc20/transfer.t.sol | 90 +++++++++++++++++ test/unit/B20/erc20/transferFrom.t.sol | 23 +++-- .../B20/erc20/transferFrom_revertOrder.t.sol | 35 ++++--- .../erc20/transferWithMemo_revertOrder.t.sol | 40 +++++--- .../unit/B20/erc20/transfer_revertOrder.t.sol | 98 ++++++++++++++++++- test/unit/B20/memo/transferWithMemo.t.sol | 23 +++++ 9 files changed, 294 insertions(+), 80 deletions(-) diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index 60d96619..ac908285 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -248,8 +248,8 @@ interface IB20 { /// @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`. + /// @notice Policy slot consulted against `msg.sender` (the initiator) on every transfer, + /// including when `msg.sender == from`. /// @dev Bypassed for factory-originated calls during the creation (bootstrap) window; see /// `IB20Factory.createB20`. /// @return Policy scope constant. @@ -317,6 +317,7 @@ interface IB20 { /// @dev Reverts with `ContractPaused(TRANSFER)` when `TRANSFER` is paused. /// @dev Reverts with `InvalidReceiver` when `to == address(0)`. /// @dev Reverts with `InvalidSender` when `msg.sender == address(0)`. + /// @dev Reverts with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)` when `msg.sender` is not authorized. /// @dev Reverts with `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `msg.sender` is not authorized. /// @dev Reverts with `PolicyForbids(TRANSFER_RECEIVER_POLICY, ...)` when `to` is not authorized. /// @dev Reverts with `InsufficientBalance` when `msg.sender`'s balance is below `amount`. @@ -333,7 +334,7 @@ interface IB20 { /// @dev Reverts with `InvalidReceiver` when `to == address(0)`. /// @dev Reverts with `InvalidSender` when `from == address(0)`. /// @dev Reverts with `InsufficientAllowance` when the caller's allowance from `from` is below `amount`. - /// @dev Reverts with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)` when `msg.sender != from` and `msg.sender` is not authorized. + /// @dev Reverts with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)` when `msg.sender` is not authorized. /// @dev Reverts with `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `from` is not authorized. /// @dev Reverts with `PolicyForbids(TRANSFER_RECEIVER_POLICY, ...)` when `to` is not authorized. /// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`. diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 05142a2d..45f99252 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -198,21 +198,11 @@ abstract contract MockB20 is IB20 { _requireNonZeroActors(from, to); // Allowance is consumed unconditionally — including during the factory // bootstrap window (`_isPrivileged()`). Matches the Rust precompile, - // which carves no `privileged` exception for allowance accounting; - // only the executor-policy check below is bypassed - // for a privileged caller. An infinite allowance is still not - // decremented (handled inside `_consumeAllowance`). + // which carves no `privileged` exception for allowance accounting. An + // infinite allowance is still not decremented (handled inside + // `_consumeAllowance`). The executor policy is enforced centrally in + // `_transfer` (on `msg.sender`), which honors the bootstrap bypass. _consumeAllowance(from, msg.sender, amount); - if (!_isPrivileged() && msg.sender != from) { - // Read the executor policy ID out of the transfer-side packed - // slot. Cold here; warm by the time _transfer reads the same - // slot for sender + receiver. Skipped when the caller is the - // owner — sender-policy already covers `from` inside _transfer. - uint64 executorPolicyId = MockB20Storage.layout().transferPolicyIds.executor; - if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(executorPolicyId, msg.sender)) { - revert PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorPolicyId); - } - } _transfer(from, to, amount); return true; } @@ -247,16 +237,10 @@ abstract contract MockB20 is IB20 { { _requireNonZeroActors(from, to); // Allowance is consumed unconditionally — including during the factory - // bootstrap window — matching the Rust precompile. - // Only the executor-policy check below is bypassed for a privileged - // caller; infinite allowance is still not decremented. + // bootstrap window — matching the Rust precompile. Infinite allowance + // is still not decremented. The executor policy is enforced centrally + // in `_transfer` (on `msg.sender`), which honors the bootstrap bypass. _consumeAllowance(from, msg.sender, amount); - if (!_isPrivileged() && msg.sender != from) { - uint64 executorPolicyId = MockB20Storage.layout().transferPolicyIds.executor; - if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(executorPolicyId, msg.sender)) { - revert PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorPolicyId); - } - } _transfer(from, to, amount); emit Memo(msg.sender, memo); return true; @@ -754,18 +738,22 @@ abstract contract MockB20 is IB20 { /// this helper. `transferFrom` / `transferFromWithMemo` /// additionally consume the allowance (unconditionally — /// including in the bootstrap window, matching the Rust - /// precompile) and check the executor - /// policy in their bodies before calling here; only the - /// executor-policy check honors the bootstrap bypass, - /// consistent with the sender/receiver policy bypass below. + /// precompile) before calling here. + /// + /// Enforces the executor (`msg.sender`), sender (`from`), and receiver + /// (`to`) policies. Gating the executor here — not just on delegated + /// `transferFrom` — lets an executor allowlist restrict who may + /// initiate any transfer, including a holder moving their own tokens. + /// All honor the bootstrap bypass; an unset lane is always-allow. function _transfer(address from, address to, uint256 amount) internal { if (!_isPrivileged()) { - // One SLOAD pulls both policy IDs we need for the transfer - // check (and was already warmed if we came in via transferFrom, - // which reads the executor lane of the same slot first). - // Solidity emits a single SLOAD for the struct read + masked - // extracts for the named fields. + // One SLOAD pulls all three policy IDs we need for the transfer + // check. Solidity emits a single SLOAD for the struct read + + // masked extracts for the named fields. MockB20Storage.TransferPolicyIds memory packed = MockB20Storage.layout().transferPolicyIds; + if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(packed.executor, msg.sender)) { + revert PolicyForbids(TRANSFER_EXECUTOR_POLICY, packed.executor); + } if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(packed.sender, from)) { revert PolicyForbids(TRANSFER_SENDER_POLICY, packed.sender); } diff --git a/test/lib/mocks/MockB20Storage.sol b/test/lib/mocks/MockB20Storage.sol index 393ecadc..6c8f9eed 100644 --- a/test/lib/mocks/MockB20Storage.sol +++ b/test/lib/mocks/MockB20Storage.sol @@ -49,7 +49,7 @@ library MockB20Storage { // not declared as a field is simply uninitialized (zero) and the // struct cannot accidentally write to it. - /// @notice Transfer-side policy IDs (read by `_transfer` and `transferFrom*`). + /// @notice Transfer-side policy IDs (all three lanes read by `_transfer`). /// @dev Bit layout (Solidity LSB-first): /// bits 0.. 63 : sender /// bits 64..127 : receiver @@ -119,8 +119,8 @@ library MockB20Storage { // access (`$.transferPolicyIds.sender = id;`) instead of inline // shifts and mask operations. // - // Transfer-side policies (read by `_transfer`, `transferFrom*`, - // and the blocked check in the deprecated `burnBlocked`). + // Transfer-side policies (read by `_transfer`, and the sender lane + // by the blocked check in the deprecated `burnBlocked`). TransferPolicyIds transferPolicyIds; // Mint-side policies (read by `_mint`). Only `MINT_RECEIVER_POLICY` // is defined today; future granular mint-side policy types (e.g. diff --git a/test/unit/B20/erc20/transfer.t.sol b/test/unit/B20/erc20/transfer.t.sol index cb9f91dc..3cb52994 100644 --- a/test/unit/B20/erc20/transfer.t.sol +++ b/test/unit/B20/erc20/transfer.t.sol @@ -60,6 +60,28 @@ contract B20TransferTest is B20Test { token.transfer(to, amount); } + /// @notice Verifies transfer reverts when the executor (msg.sender) is not authorized under + /// TRANSFER_EXECUTOR_POLICY + /// @dev On the direct `transfer` path the executor is `msg.sender` (== `from`). The executor + /// gate is enforced in `_transfer` before the sender/receiver gates, so a blocked executor + /// reverts PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...) even for a holder moving their own + /// tokens. No balance needed — the policy check fires first. + function test_transfer_revert_executorPolicyForbids(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(from); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_EXECUTOR_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transfer(to, amount); + } + /// @notice Verifies transfer reverts when sender balance is insufficient /// @dev Balance precondition; checks InsufficientBalance(sender, balance, amount) error function test_transfer_revert_insufficientBalance(address from, address to, uint256 amount) public { @@ -299,6 +321,74 @@ contract B20TransferTest is B20Test { token.transfer(to, amount); } + /// @notice Verifies transfer succeeds when the executor (msg.sender) is a member of a custom + /// ALLOWLIST policy + /// @dev Exercises the external-registry authorization path for the executor scope: only an + /// allowlisted initiator can move tokens. Here the holder `from` is on the allowlist, so + /// their own `transfer` clears the executor gate. + function test_transfer_success_externalExecutorPolicyAllows(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + uint64 id = _createAllowlist(from, true); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, id); + _mint(from, amount); + + vm.prank(from); + token.transfer(to, amount); + + assertEq(token.balanceOf(to), amount, "transfer must succeed when executor is allowlisted"); + } + + /// @notice Verifies transfer reverts when the executor (msg.sender) is NOT a member of a custom + /// ALLOWLIST policy + /// @dev Negative external-registry path for the executor scope: an allowlist without membership + /// for `from` resolves isAuthorized to false, so the executor gate reverts PolicyForbids + /// with the custom id. This is the case an issuer uses to restrict transfers to specific + /// initiators (e.g. a settlement contract). No balance needed — the policy check fires first. + function test_transfer_revert_externalExecutorPolicyDenies(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_EXECUTOR_POLICY, id); + + vm.prank(from); + vm.expectRevert(abi.encodeWithSelector(IB20.PolicyForbids.selector, B20Constants.TRANSFER_EXECUTOR_POLICY, id)); + token.transfer(to, amount); + } + + /// @notice Verifies a privileged (factory bootstrap) transfer bypasses the TRANSFER_EXECUTOR_POLICY + /// @dev Executor mirror of the sender/receiver bootstrap bypasses: the initCalls set the executor + /// policy to ALWAYS_BLOCK and transfer from the factory. A non-privileged transfer would + /// revert PolicyForbids(EXECUTOR, ...); the privileged init-call transfer must succeed, + /// proving the executor gate honors the bootstrap bypass on the direct transfer path. Runs + /// the real factory bootstrap path with no vm.store cheat, so it holds under LIVE_PRECOMPILES. + function test_transfer_success_privilegedBypassesExecutorPolicy(address to, uint256 amount) public { + _assumeValidActor(to); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + + bytes32 salt = keccak256("privileged-executor-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_EXECUTOR_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 executor policy" + ); + } + /// @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. diff --git a/test/unit/B20/erc20/transferFrom.t.sol b/test/unit/B20/erc20/transferFrom.t.sol index 0b3197a9..9c96beef 100644 --- a/test/unit/B20/erc20/transferFrom.t.sol +++ b/test/unit/B20/erc20/transferFrom.t.sol @@ -347,11 +347,15 @@ contract B20TransferFromTest is B20Test { assertEq(token.balanceOf(to), spendAmount, "to must receive the spent amount"); } - /// @notice Verifies transferFrom with self-caller skips the executor policy check - /// @dev Self-caller is not an executor distinct from `from`; sender-policy already - /// covers `from` inside _transfer. Executor policy MUST NOT fire — pins the - /// one carve-out we intentionally keep around `msg.sender == from`. - function test_transferFrom_success_selfCaller_skipsExecutorPolicy(address from, address to, uint256 amount) public { + /// @notice Verifies transferFrom with a self-caller is still gated by the executor policy + /// @dev Executor enforcement is centralized in `_transfer` on `msg.sender`, so the old + /// `msg.sender == from` carve-out is gone: a holder moving their own tokens via + /// transferFrom must also clear TRANSFER_EXECUTOR_POLICY. This closes the bypass where + /// an executor allowlist could be sidestepped by routing a self-transferFrom. Allowance + /// is self-approved so the executor check — not the allowance gate — is what fires. + function test_transferFrom_revert_selfCaller_executorPolicyForbids(address from, address to, uint256 amount) + public + { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); @@ -363,9 +367,14 @@ contract B20TransferFromTest is B20Test { _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(from); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_EXECUTOR_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); token.transferFrom(from, to, amount); - - assertEq(token.balanceOf(to), amount, "transfer must succeed despite blocked executor policy"); } // ============================================================ diff --git a/test/unit/B20/erc20/transferFrom_revertOrder.t.sol b/test/unit/B20/erc20/transferFrom_revertOrder.t.sol index ba84c27a..3f2ec855 100644 --- a/test/unit/B20/erc20/transferFrom_revertOrder.t.sol +++ b/test/unit/B20/erc20/transferFrom_revertOrder.t.sol @@ -9,28 +9,28 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// @title Differential check-order tests for `transferFrom`. /// -/// @notice `transferFrom` layers two body-level preconditions -/// (ALLOWANCE and EXECUTOR-POLICY) on top of `_transfer`'s -/// policy / balance checks. The PAUSE / ZERO-RECEIVER / -/// ZERO-SENDER guards run before the allowance / executor-policy -/// work in the entrypoint body. +/// @notice `transferFrom` consumes the allowance in the entrypoint body, then +/// defers to `_transfer` for the policy / balance checks. The PAUSE / +/// ZERO-RECEIVER / ZERO-SENDER guards run before the allowance work in +/// the entrypoint body; the EXECUTOR / SENDER / RECEIVER / BALANCE +/// checks all run inside `_transfer`, with EXECUTOR first. /// -/// **Canonical order (Solidity reference, when -/// `msg.sender != from`):** +/// **Canonical order (Solidity reference):** /// 1. PAUSE (`whenNotPaused(TRANSFER)` modifier) → `ContractPaused` /// 2. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` /// 3. ZERO-SENDER (`from == address(0)`) → `InvalidSender` /// 4. ALLOWANCE (`_consumeAllowance`) → `InsufficientAllowance` -/// 5. EXECUTOR-POLICY (`isAuthorized(executorPolicyId, msg.sender)`) +/// 5. EXECUTOR-POLICY (`_transfer` body: `isAuthorized(executor, msg.sender)`) /// → `PolicyForbids(EXECUTOR, ...)` -/// 6..N. All `_transfer` body checks — see `transfer_revertOrder.t.sol` +/// 6..N. Remaining `_transfer` body checks — see `transfer_revertOrder.t.sol` /// (SENDER-POLICY → RECEIVER-POLICY → BALANCE). /// -/// The full pair matrix between body-level ALLOWANCE/EXECUTOR-POLICY -/// and the PAUSE/ZERO-RECEIVER/ZERO-SENDER guards is pinned below; -/// one test against a representative `_transfer` body check -/// (SENDER-POLICY) proves ALLOWANCE and EXECUTOR-POLICY both -/// fire before `_transfer` is entered. +/// The executor gate is enforced on every transfer path, not only when +/// `msg.sender != from`; this suite exercises the delegated path with a +/// distinct caller (the self-caller case is pinned in `transferFrom.t.sol`). +/// The pair matrix between the body-level ALLOWANCE guard, the +/// PAUSE/ZERO-RECEIVER/ZERO-SENDER guards, and the leading `_transfer` +/// EXECUTOR-POLICY check is pinned below. contract B20TransferFromRevertOrderTest is B20Test { // --- Pairs where PAUSE wins (PAUSE is canonical first) --- @@ -186,10 +186,9 @@ contract B20TransferFromRevertOrderTest is B20Test { // --- Pair where EXECUTOR-POLICY wins (everything earlier satisfied) --- - /// @notice EXECUTOR-POLICY beats anything in `_transfer` (representative: SENDER-POLICY). - /// @dev Allowance is set high enough to pass the allowance check, so the - /// executor-policy check runs next and fires before `_transfer` is - /// entered. + /// @notice EXECUTOR-POLICY beats the other `_transfer` checks (representative: SENDER-POLICY). + /// @dev Allowance is set high enough to pass the allowance check, so `_transfer` is entered; + /// the executor gate is checked first inside `_transfer` and fires before SENDER-POLICY. function test_transferFrom_revertOrder_executorPolicy_beats_transferBody( address caller, address from, diff --git a/test/unit/B20/erc20/transferWithMemo_revertOrder.t.sol b/test/unit/B20/erc20/transferWithMemo_revertOrder.t.sol index 2b331e18..50dadbf7 100644 --- a/test/unit/B20/erc20/transferWithMemo_revertOrder.t.sol +++ b/test/unit/B20/erc20/transferWithMemo_revertOrder.t.sol @@ -16,24 +16,27 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// 1. PAUSE (`whenNotPaused(TRANSFER)` modifier) → `ContractPaused` /// 2. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` /// 3. ZERO-SENDER (`from == address(0)`) → `InvalidSender` -/// 4. SENDER-POLICY (`_transfer` body) → `PolicyForbids(SENDER, ...)` -/// 5. RECEIVER-POLICY (`_transfer` body) → `PolicyForbids(RECEIVER, ...)` -/// 6. BALANCE (`_transfer` body) → `InsufficientBalance` +/// 4. EXECUTOR-POLICY (`_transfer` body) → `PolicyForbids(EXECUTOR, ...)` +/// 5. SENDER-POLICY (`_transfer` body) → `PolicyForbids(SENDER, ...)` +/// 6. RECEIVER-POLICY (`_transfer` body) → `PolicyForbids(RECEIVER, ...)` +/// 7. BALANCE (`_transfer` body) → `InsufficientBalance` /// /// The public `transferWithMemo(to, amount, memo)` entry sets -/// `from = msg.sender`. The single test below activates all six -/// violations simultaneously, then fixes them one at a time in -/// canonical order, asserting that the next-priority revert fires -/// at each step. +/// `from = msg.sender`, so the executor is `msg.sender` (== `from`). +/// The single test below activates all seven violations +/// simultaneously, then fixes them one at a time in canonical order, +/// asserting that the next-priority revert fires at each step. contract B20TransferWithMemoRevertOrderTest is B20Test { function test_transferWithMemo_revertOrder(address from, address to, uint256 amount, bytes32 memo) public { _assumeValidActor(from); _assumeValidActor(to); amount = bound(amount, 1, type(uint128).max); - // Activate all six violations: TRANSFER paused, from=address(0) (via prank), - // to=address(0), sender policy blocks, receiver policy blocks, from has zero balance. + // Activate all seven violations: TRANSFER paused, from=address(0) (via prank), + // to=address(0), executor policy blocks, sender policy blocks, receiver policy blocks, + // from has zero balance. _pause(IB20.PausableFeature.TRANSFER); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); @@ -56,7 +59,20 @@ contract B20TransferWithMemoRevertOrderTest is B20Test { vm.expectRevert(abi.encodeWithSelector(IB20.InvalidSender.selector, address(0))); token.transferWithMemo(to, amount, memo); - // 4. SENDER-POLICY fires (all earlier cleared; sender policy blocks, receiver also blocks). + // 4. EXECUTOR-POLICY fires (all earlier cleared; executor == msg.sender == from is + // blocked; sender/receiver also block, but executor is checked first in _transfer). + vm.prank(from); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_EXECUTOR_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transferWithMemo(to, amount, memo); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_ALLOW_ID); + + // 5. SENDER-POLICY fires (all earlier cleared; sender policy blocks, receiver also blocks). vm.prank(from); vm.expectRevert( abi.encodeWithSelector( @@ -68,7 +84,7 @@ contract B20TransferWithMemoRevertOrderTest is B20Test { token.transferWithMemo(to, amount, memo); _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_ALLOW_ID); - // 5. RECEIVER-POLICY fires (all earlier cleared; receiver policy still blocks). + // 6. RECEIVER-POLICY fires (all earlier cleared; receiver policy still blocks). vm.prank(from); vm.expectRevert( abi.encodeWithSelector( @@ -80,7 +96,7 @@ contract B20TransferWithMemoRevertOrderTest is B20Test { token.transferWithMemo(to, amount, memo); _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_ALLOW_ID); - // 6. BALANCE fires (all earlier cleared; from has zero balance, amount>0). + // 7. BALANCE fires (all earlier cleared; from has zero balance, amount>0). vm.prank(from); vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientBalance.selector, from, 0, amount)); token.transferWithMemo(to, amount, memo); diff --git a/test/unit/B20/erc20/transfer_revertOrder.t.sol b/test/unit/B20/erc20/transfer_revertOrder.t.sol index 839e8ee4..1d398584 100644 --- a/test/unit/B20/erc20/transfer_revertOrder.t.sol +++ b/test/unit/B20/erc20/transfer_revertOrder.t.sol @@ -13,12 +13,14 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// 1. PAUSE (`whenNotPaused(TRANSFER)` modifier) → `ContractPaused` /// 2. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` /// 3. ZERO-SENDER (`from == address(0)`) → `InvalidSender` -/// 4. SENDER-POLICY (`_transfer` body) → `PolicyForbids(SENDER, ...)` -/// 5. RECEIVER-POLICY (`_transfer` body) → `PolicyForbids(RECEIVER, ...)` -/// 6. BALANCE (`_transfer` body) → `InsufficientBalance` +/// 4. EXECUTOR-POLICY (`_transfer` body) → `PolicyForbids(EXECUTOR, ...)` +/// 5. SENDER-POLICY (`_transfer` body) → `PolicyForbids(SENDER, ...)` +/// 6. RECEIVER-POLICY (`_transfer` body) → `PolicyForbids(RECEIVER, ...)` +/// 7. BALANCE (`_transfer` body) → `InsufficientBalance` /// -/// The public `transfer(to, amount)` entry sets `from = msg.sender`, so -/// pairs involving ZERO-SENDER require pranking `address(0)`. C(6, 2) = 15 pairs. +/// The public `transfer(to, amount)` entry sets `from = msg.sender`, so the executor +/// is `msg.sender` (== `from`): a blocked EXECUTOR policy reverts even on this direct +/// path. Pairs involving ZERO-SENDER require pranking `address(0)`. C(7, 2) = 21 pairs. contract B20TransferRevertOrderTest is B20Test { // --- Pairs where PAUSE wins (PAUSE is canonical first) --- @@ -49,6 +51,15 @@ contract B20TransferRevertOrderTest is B20Test { token.transfer(address(0), amount); } + function test_transfer_revertOrder_zeroReceiver_beats_executorPolicy(address from, uint256 amount) public { + _assumeValidActor(from); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(from); + vm.expectRevert(abi.encodeWithSelector(IB20.InvalidReceiver.selector, address(0))); + token.transfer(address(0), amount); + } + function test_transfer_revertOrder_zeroReceiver_beats_senderPolicy(address from, uint256 amount) public { _assumeValidActor(from); _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); @@ -79,6 +90,15 @@ contract B20TransferRevertOrderTest is B20Test { // --- Pairs where ZERO-SENDER wins (PAUSE not violated; requires pranking address(0)) --- + function test_transfer_revertOrder_zeroSender_beats_executorPolicy(address to, uint256 amount) public { + _assumeValidActor(to); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(address(0)); + vm.expectRevert(abi.encodeWithSelector(IB20.InvalidSender.selector, address(0))); + token.transfer(to, amount); + } + function test_transfer_revertOrder_zeroSender_beats_senderPolicy(address to, uint256 amount) public { _assumeValidActor(to); _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); @@ -119,6 +139,17 @@ contract B20TransferRevertOrderTest is B20Test { token.transfer(to, amount); } + function test_transfer_revertOrder_pause_beats_executorPolicy(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + _pause(IB20.PausableFeature.TRANSFER); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(from); + vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.TRANSFER)); + token.transfer(to, amount); + } + function test_transfer_revertOrder_pause_beats_receiverPolicy(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); @@ -141,6 +172,63 @@ contract B20TransferRevertOrderTest is B20Test { token.transfer(to, amount); } + // --- Pairs where EXECUTOR-POLICY wins (executor == msg.sender == from) --- + + function test_transfer_revertOrder_executorPolicy_beats_senderPolicy(address from, address to, uint256 amount) + public + { + _assumeValidActor(from); + _assumeValidActor(to); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(from); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_EXECUTOR_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transfer(to, amount); + } + + function test_transfer_revertOrder_executorPolicy_beats_receiverPolicy(address from, address to, uint256 amount) + public + { + _assumeValidActor(from); + _assumeValidActor(to); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(from); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_EXECUTOR_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transfer(to, amount); + } + + function test_transfer_revertOrder_executorPolicy_beats_balance(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + amount = bound(amount, 1, type(uint128).max); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(from); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_EXECUTOR_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transfer(to, amount); + } + // --- Pairs where SENDER-POLICY wins --- function test_transfer_revertOrder_senderPolicy_beats_receiverPolicy(address from, address to, uint256 amount) diff --git a/test/unit/B20/memo/transferWithMemo.t.sol b/test/unit/B20/memo/transferWithMemo.t.sol index 3af4bb7d..3c95b92e 100644 --- a/test/unit/B20/memo/transferWithMemo.t.sol +++ b/test/unit/B20/memo/transferWithMemo.t.sol @@ -6,6 +6,7 @@ import {IB20} from "base-std/interfaces/IB20.sol"; import {B20Test} from "base-std-test/lib/B20Test.sol"; import {B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; import {MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; +import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; contract B20TransferWithMemoTest is B20Test { /// @notice Verifies transferWithMemo applies the same pause / policy / balance checks as transfer @@ -24,6 +25,28 @@ contract B20TransferWithMemoTest is B20Test { token.transferWithMemo(to, amount, memo); } + /// @notice Verifies transferWithMemo enforces TRANSFER_EXECUTOR_POLICY like transfer + /// @dev The memo variant routes through the same `_transfer`, so a blocked executor + /// (msg.sender == from) must revert PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...). + /// Concrete executor-scope tests live in transfer.t.sol; this pins parity for the memo path. + function test_transferWithMemo_revert_executorPolicyForbids(address from, address to, uint256 amount, bytes32 memo) + public + { + _assumeValidActor(from); + _assumeValidActor(to); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(from); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, + B20Constants.TRANSFER_EXECUTOR_POLICY, + PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.transferWithMemo(to, amount, memo); + } + /// @notice Verifies transferWithMemo performs the same balance movement as transfer /// @dev Same accounting effect as transfer; the memo does not alter accounting. /// Paired slot assertions confirm both balance slots reflect the move. From e7d16c6c5e5ea74fdd4ae9ffd84d356c1aa5120a Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Fri, 11 Sep 2026 11:16:01 -0400 Subject: [PATCH 2/3] docs(changelog): add Denim transfer executor enforcement spec Record that TRANSFER_EXECUTOR_POLICY now gates every transfer path, including memo variants, so integrators can see the new revert order and coverage. Co-authored-by: Cursor --- ...Denim_B20_transfer_executor_enforcement.md | 163 ++++++++++++++++++ changelog/README.md | 10 ++ docs/concepts/policies.md | 2 +- docs/reference/constants.md | 2 +- 4 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 changelog/03_Denim_B20_transfer_executor_enforcement.md diff --git a/changelog/03_Denim_B20_transfer_executor_enforcement.md b/changelog/03_Denim_B20_transfer_executor_enforcement.md new file mode 100644 index 00000000..f1bb248b --- /dev/null +++ b/changelog/03_Denim_B20_transfer_executor_enforcement.md @@ -0,0 +1,163 @@ +# Transfer Executor Policy Enforcement + +- **Feature Name**: transfer_executor_enforcement +- **Start Date**: 2026-09-10 +- **Authors**: Rayyan Alam +- **Title**: (Breaking) Transfer Executor Policy Enforcement + +## Summary + +This change makes `TRANSFER_EXECUTOR_POLICY` apply to every transfer path. The executor gate now checks `msg.sender` on `transfer`, `transferFrom`, `transferWithMemo`, and `transferFromWithMemo`, including when `msg.sender == from`. Previously the check ran only on the delegated `transferFrom` paths, and only when `msg.sender != from`. + +The change is purely behavioral. It adds no new selectors, events, errors, or storage. A token that never sets `TRANSFER_EXECUTOR_POLICY` keeps the unset always-allow default, so it is unaffected. + +## Motivation + +An issuer may want an executor allowlist: only specific, approved contracts or accounts may initiate a transfer, for example a settlement contract that moves tokens on a holder's behalf. `TRANSFER_EXECUTOR_POLICY` exists for this, but the previous scope could not enforce it consistently with `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY`, which already run on every transfer path. + +The previous scope left two initiator-side gaps: + +1. `transfer` never consulted the executor policy. The initiator of `transfer` is `msg.sender`, which is also `from`, but the check lived only inside `transferFrom`. A holder could always move their own tokens through `transfer`, regardless of the executor allowlist. +2. `transferFrom` skipped the check when `msg.sender == from`. A holder could route a self-`transferFrom(from, to, amount)` call to reach the same unchecked path, even if they were not on the executor allowlist. + +Both gaps let a non-allowlisted holder move tokens by choosing a different entrypoint, so the executor scope could not express "only these initiators may move tokens" for any holder. Centralizing the check on `msg.sender` and removing the `msg.sender == from` carve-out closes both gaps and brings `TRANSFER_EXECUTOR_POLICY` to parity with the sender and receiver scopes. + +## Background + +### Policy Registry and transfer-side scopes + +The Policy Registry is a singleton precompile that B20 tokens call for pre-operation compliance checks on an address. A B20 token stores a `uint64` policy ID per scope and calls `isAuthorized(policyId, account)` before a gated operation. `isAuthorized` never reverts; a malformed or unknown ID returns `false` (deny). See [Policies](../docs/concepts/policies.md) for the full model. + +B20 has three transfer-side scopes, all checked inside the shared `_transfer` function that backs `transfer`, `transferFrom`, and their memo variants: + +| Scope | Account checked | +| --- | --- | +| `TRANSFER_SENDER_POLICY` | `from` | +| `TRANSFER_RECEIVER_POLICY` | `to` | +| `TRANSFER_EXECUTOR_POLICY` | `msg.sender` | + +All three scopes are bypassed during the factory bootstrap window (`_isPrivileged()`), so a token's `initCalls` can move newly minted supply without pre-authorizing itself under any of the three policies. See [`IB20Factory.createB20`](../src/interfaces/IB20Factory.sol). + +`transferFrom` and `transferFromWithMemo` additionally consume the caller's allowance from `from` before reaching `_transfer`. Allowance accounting is unconditional, including during the bootstrap window, and is unaffected by this change. + +## Specs + +### Interface Changes + +This change adds no new functions, events, errors, or selectors, changes are behavioural to the underlying Transfer Functions. + +### Behavioural Changes + +The executor check moves from the `transferFrom` and `transferFromWithMemo` bodies into `_transfer`, where it runs first, before the existing sender and receiver checks, and under the same `_isPrivileged()` bootstrap bypass. The `msg.sender == from` carve-out that previously skipped the check is removed. `transfer` and `transferWithMemo` route through the same `_transfer` function, so they gain the check with no entrypoint-specific code. + +The previous order, by entrypoint: + +```mermaid +flowchart TD + subgraph beforeTransfer ["Before: transfer / transferWithMemo"] + BT1[pause] --> BT2[zero-receiver] + BT2 --> BT3[zero-sender] + BT3 --> BT4[sender policy] + BT4 --> BT5[receiver policy] + BT5 --> BT6[balance] + end + + subgraph beforeTransferFrom ["Before: transferFrom / transferFromWithMemo"] + BF1[pause] --> BF2[zero-receiver] + BF2 --> BF3[zero-sender] + BF3 --> BF4[allowance] + BF4 --> BF5{"msg.sender != from?"} + BF5 -->|yes| BF6[executor policy] + BF5 -->|no: skip| BF7[sender policy] + BF6 --> BF7 + BF7 --> BF8[receiver policy] + BF8 --> BF9[balance] + end +``` + +This reorders the checks a caller can hit. Both paths now enter `_transfer` for the three transfer-side policies. The canonical order is now: + +- `transfer` / `transferWithMemo`: pause → zero-receiver → zero-sender → **executor policy** → sender policy → receiver policy → balance. +- `transferFrom` / `transferFromWithMemo`: pause → zero-receiver → zero-sender → allowance → **executor policy** → sender policy → receiver policy → balance. + +```mermaid +flowchart TD + AT["transfer / transferWithMemo"] --> AT1[pause] + AT1 --> AT2[zero-receiver] + AT2 --> AT3[zero-sender] + AT3 --> XE + + AF["transferFrom / transferFromWithMemo"] --> AF1[pause] + AF1 --> AF2[zero-receiver] + AF2 --> AF3[zero-sender] + AF3 --> AF4[allowance] + AF4 --> XE + + subgraph xfer ["_transfer"] + XE[executor policy] --> XS[sender policy] + XS --> XR[receiver policy] + XR --> XB[balance] + end +``` + +When more than one check would fail, the caller sees the first revert in that order: + +There are no new storage slots. The `TRANSFER_EXECUTOR_POLICY` policy ID is read from the same packed slot as before; `_transfer` now reads all three transfer-side policy IDs from that slot in one `SLOAD` instead of the executor lane being pre-warmed by a separate read in `transferFrom`'s body. + +### Examples + +A holder moving their own tokens is now gated by the executor policy, even through direct `transfer`: + +```solidity +token.updatePolicy(TRANSFER_EXECUTOR_POLICY, ALWAYS_BLOCK_ID); + +vm.prank(alice); +token.transfer(bob, amount); // reverts PolicyForbids(TRANSFER_EXECUTOR_POLICY, ALWAYS_BLOCK_ID) +``` + +An executor allowlist restricts initiation to approved accounts. A holder who is a member can move their own tokens; one who is not, cannot: + +```solidity +uint64 executorAllowlist = policyRegistry.createPolicyWithAccounts(admin, ALLOWLIST, [settlementContract]); +token.updatePolicy(TRANSFER_EXECUTOR_POLICY, executorAllowlist); + +vm.prank(settlementContract); +token.transferFrom(alice, bob, amount); // succeeds: settlementContract is allowlisted + +vm.prank(alice); +token.transfer(bob, amount); // reverts PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...): alice is not allowlisted +``` + +The factory bootstrap bypass still applies. A token's `initCalls` can mint and transfer even when the freshly configured executor policy would otherwise block the factory: + +```solidity +initCalls = [ + abi.encodeCall(IB20.mint, (address(factory), amount)), + abi.encodeCall(IB20.updatePolicy, (TRANSFER_EXECUTOR_POLICY, ALWAYS_BLOCK_ID)), + abi.encodeCall(IB20.transfer, (to, amount)) +]; +factory.createB20(..., initCalls); // succeeds: bootstrap window bypasses the executor check +``` + +## Design Decisions & Alternatives Considered + +### Chosen: centralize the check in `_transfer`, on `msg.sender` + +The executor check moves into the shared `_transfer` helper. `transfer`, `transferFrom`, `transferWithMemo`, and `transferFromWithMemo` already call `_transfer`, so they all run the same executor check on `msg.sender`. `transferWithMemo` and `transferFromWithMemo` therefore get the same coverage as the non-memo paths, with no entrypoint-specific code. The check has no `msg.sender == from` carve-out and still honors the existing `_isPrivileged()` bypass. + +This approach was chosen because it is the smallest change that closes both gaps described in Motivation, adds no new interface surface, and brings `TRANSFER_EXECUTOR_POLICY` in line with how `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY` are already enforced: once, in `_transfer`, on every path. + +### Alternative — keep the check in `transferFrom` only, add it to `transfer` separately + +This option would add a matching check to `transfer` while leaving the existing `transferFrom` check, including its `msg.sender != from` carve-out, in place. It was rejected because it does not close the self-`transferFrom` bypass: a holder could still route around an executor allowlist by calling `transferFrom(self, to, amount)` instead of `transfer`. It also keeps the check duplicated across two entrypoints instead of centralized in `_transfer`. + +## Migration Steps + +This change is not breaking for a token that never configured `TRANSFER_EXECUTOR_POLICY`. The unset policy slot stays always-allow, and the factory bootstrap bypass is unchanged, so existing deployments and initialization flows are unaffected. + +This change is breaking for a token that has already set a restrictive `TRANSFER_EXECUTOR_POLICY` and relied on either of the closed bypasses: + +1. If holders were moving their own tokens with `transfer`, they must now be authorized under `TRANSFER_EXECUTOR_POLICY` (directly, or through a policy they belong to) to keep doing so. +2. If holders were relying on `msg.sender == from` to skip the check in `transferFrom`, the same authorization requirement now applies to that self-call path. + +An issuer who wants to keep allowing holders to self-initiate transfers should add those holders, or a policy covering them, to the executor allowlist before this change activates. diff --git a/changelog/README.md b/changelog/README.md index 703cd9d7..0f173ce1 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -16,11 +16,21 @@ See [AGENTS.md](AGENTS.md) for how to name and write a new entry. | --- | --- | --- | | `01` | Beryl | Live | | `02` | Cobalt | Upcoming | +| `03` | Denim | Upcoming | ## Index Grouped by hardfork, one collapsible section per hardfork, newest first. +
+Denim (upcoming) — ordinal 03 + +| Product(s) | Change | Affected interfaces | Entry | +| --- | --- | --- | --- | +| B20 | Transfer executor policy on every transfer path | `src/interfaces/IB20.sol` | [03_Denim_B20_transfer_executor_enforcement](03_Denim_B20_transfer_executor_enforcement.md) | + +
+
Cobalt (upcoming) — ordinal 02 diff --git a/docs/concepts/policies.md b/docs/concepts/policies.md index 1459b6e3..b69b7c81 100644 --- a/docs/concepts/policies.md +++ b/docs/concepts/policies.md @@ -199,7 +199,7 @@ Most scopes deny when `isAuthorized` is `false` and revert `PolicyForbids`. `SEI | -------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------- | -------------------- | | `TRANSFER_SENDER_POLICY` | `transfer`, `transferFrom`, and memo'd variants. Skipped on factory `initCalls` transfers. | `from` (`msg.sender` on `transfer`) | `false` | `PolicyForbids` | | `TRANSFER_RECEIVER_POLICY` | `transfer`, `transferFrom`, and memo'd variants. Skipped on factory `initCalls` transfers. | `to` | `false` | `PolicyForbids` | -| `TRANSFER_EXECUTOR_POLICY` | `transferFrom` and `transferFromWithMemo` when `msg.sender != from`. Not on `transfer`. Skipped on factory `initCalls`. | `msg.sender` | `false` | `PolicyForbids` | +| `TRANSFER_EXECUTOR_POLICY` | `transfer`, `transferFrom`, and memo'd variants. Skipped on factory `initCalls` transfers. | `msg.sender` | `false` | `PolicyForbids` | | `MINT_RECEIVER_POLICY` | `mint`, `mintWithMemo`, and Asset `batchMint`. Always checked, including factory `initCalls` mints. | `to` | `false` | `PolicyForbids` | | `SEIZE_HOLDER_POLICY` | `seizeWithMemo`. Unset (`ALWAYS_ALLOW`) means no account is seizable. | `from` | `true` | `AccountNotSeizable` | | `SEIZE_RECEIVER_POLICY` | `seizeWithMemo`. Unset (`ALWAYS_ALLOW`) means seize may send to any destination. | `to` | `false` | `PolicyForbids` | diff --git a/docs/reference/constants.md b/docs/reference/constants.md index 23f40a8d..44f45e6a 100644 --- a/docs/reference/constants.md +++ b/docs/reference/constants.md @@ -36,7 +36,7 @@ |---|---|---| | `TRANSFER_SENDER_POLICY` | `keccak256("TRANSFER_SENDER_POLICY")`
`0xb81736c875ab819dd97f59f2a6542cfb731ad52b4ae15a6f24df2fb02b0327f5` | Consulted for `from` on `transfer` and `transferFrom`. | | `TRANSFER_RECEIVER_POLICY` | `keccak256("TRANSFER_RECEIVER_POLICY")`
`0x8a4b3fa2d8b921852bc0089c6ef0958aa6961897be36fd731330fe2cd23f8363` | Consulted for `to` on `transfer` and `transferFrom`. | -| `TRANSFER_EXECUTOR_POLICY` | `keccak256("TRANSFER_EXECUTOR_POLICY")`
`0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8` | Consulted for `msg.sender` on `transferFrom` only. | +| `TRANSFER_EXECUTOR_POLICY` | `keccak256("TRANSFER_EXECUTOR_POLICY")`
`0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8` | Consulted for `msg.sender` on every transfer entrypoint (`transfer`, `transferFrom`, and memo'd variants). | | `MINT_RECEIVER_POLICY` | `keccak256("MINT_RECEIVER_POLICY")`
`0xa0d5ae037e66a09119acf080a1d807abb9b6d03b6b9130eb19f7c1e6bdb8ffc8` | Consulted for `to` on `mint`. | | `SEIZE_HOLDER_POLICY` | `keccak256("SEIZE_HOLDER_POLICY")`
`0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2` | Consulted for `from` on `seizeWithMemo`; `from` is seizable when unauthorized under this policy. | | `SEIZE_RECEIVER_POLICY` | `keccak256("SEIZE_RECEIVER_POLICY")`
`0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d` | Consulted for `to` on `seizeWithMemo`. | From d8bc983a336e92cc6bc35e43d62b615b11be4caf Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Fri, 11 Sep 2026 11:35:23 -0400 Subject: [PATCH 3/3] docs(guides): add restrict-transfer-initiators guide Add a task-oriented guide for gating TRANSFER_EXECUTOR_POLICY with a transfer-agent example, and index it in docs/README.md. Co-Authored-By: Claude --- docs/README.md | 1 + .../guides/restricting-transfer-initiators.md | 236 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 docs/guides/restricting-transfer-initiators.md diff --git a/docs/README.md b/docs/README.md index 925c602f..e2d3311d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ Building something? - [Seize a holder's B20 balance](guides/seizeing-assets.md) - [Schedule a stock split](guides/scheduling-stock-splits.md) - [Announce a corporate action](guides/announcing-corporate-actions.md) +- [Restrict who can initiate transfers](guides/restricting-transfer-initiators.md) Looking for exact technical details? diff --git a/docs/guides/restricting-transfer-initiators.md b/docs/guides/restricting-transfer-initiators.md new file mode 100644 index 00000000..e294925d --- /dev/null +++ b/docs/guides/restricting-transfer-initiators.md @@ -0,0 +1,236 @@ +# Restrict who can initiate transfers + +## Goal + +Restrict which account may act as the initiator of a transfer, separate from who may send or receive. `TRANSFER_EXECUTOR_POLICY` gates `msg.sender` on every transfer path — `transfer`, `transferFrom`, and their memo variants — including when the initiator is also the holder (`msg.sender == from`). + +A restricted security token needs this control. Regulation can require every transfer to go through a registered transfer agent, so a holder cannot self-initiate a `transfer` even to an already-eligible counterparty. Only the transfer agent's contract may move tokens, using its own `transferFrom` call against an allowance the holder grants in advance. + +```mermaid +flowchart LR + A[Holder] -->|"transfer (direct)"| X[Reverts: not an authorized executor] + A -->|"approve"| T[Transfer agent] + T -->|"transferFrom"| B[Recipient] +``` + +This surface exists on both B20 Asset and B20 Stablecoin. The rest of this guide uses "the token" for either variant. + +## Before You Start + +You need all of the following: + +- A B20 token you administer. +- `DEFAULT_ADMIN_ROLE` on that token, so you can call `updatePolicy`. +- A policy admin able to create and manage a policy in the Policy Registry. Token admin and policy admin are separate roles; the same account can hold both. +- The address of the sole intended initiator (the transfer agent contract, or any account you want to allow). +- `TRANSFER` not paused. + +### Which account is checked + +Transfer has three independent policy scopes. All three are enforced inside the same shared transfer path, so they apply the same way to `transfer`, `transferFrom`, and their memo variants: + +| Scope | Account checked | Default when unset (`0`) | +| --- | --- | --- | +| `TRANSFER_SENDER_POLICY` | `from` | Always allow | +| `TRANSFER_RECEIVER_POLICY` | `to` | Always allow | +| `TRANSFER_EXECUTOR_POLICY` | `msg.sender` | Always allow | + +`TRANSFER_EXECUTOR_POLICY` checks the initiator, not the holder. On `transfer`, the initiator is also `from` — the same account. On `transferFrom`, the initiator is the caller, which may be a different account than `from`. Both paths run the same check against `msg.sender`. There is no carve-out for a holder acting on their own behalf: once you attach a restrictive executor policy, a holder must be authorized under it to call `transfer`, or to call `transferFrom` with themselves as `from`. + +### Allowance is a separate gate + +`transferFrom` still requires the caller to hold an ERC-20 allowance from `from`. The executor policy and the allowance answer different questions: the allowance says "this caller may spend up to this amount," and the executor policy says "this caller may initiate a transfer at all." A transfer agent needs both — an allowance from each holder it moves tokens for, and a place on the executor allowlist. Granting one does not grant the other. + +## Steps + +Configure the executor allowlist, then confirm both the denied and allowed paths. + +1. Create an `ALLOWLIST` policy for authorized initiators. +2. Add the transfer agent to that allowlist. +3. Attach the allowlist to `TRANSFER_EXECUTOR_POLICY`. +4. Have the holder approve the transfer agent for the amount it will move. +5. Confirm a direct transfer from the holder is denied. +6. Confirm the transfer agent's `transferFrom` is allowed. + +### 1. Create an executor allowlist + +```solidity +uint64 executorId = POLICY_REGISTRY.createPolicy(policyAdmin, IPolicyRegistry.PolicyType.ALLOWLIST); +``` + +The member set starts empty. Every account is still authorized until you attach this ID — creating the policy alone changes nothing. + +### 2. Add the transfer agent + +Only the policy admin can change membership. + +```solidity +address[] memory initiators = new address[](1); +initiators[0] = transferAgent; +POLICY_REGISTRY.updateAllowlist(executorId, true, initiators); +``` + +`createPolicyWithAccounts` can create the policy and seed the first member in one call. Batches are capped at 64 accounts. + +### 3. Attach the allowlist to `TRANSFER_EXECUTOR_POLICY` + +```solidity +token.updatePolicy(token.TRANSFER_EXECUTOR_POLICY(), executorId); +``` + +From this call forward, every `transfer` and `transferFrom` on this token checks `msg.sender` against `executorId`. A holder who is not on the allowlist can no longer initiate a transfer, including one of their own tokens. + +### 4. Approve the transfer agent + +The executor allowlist controls who may initiate. It does not grant spending rights. Each holder still approves the transfer agent for the amount it will move on their behalf: + +```solidity +vm.prank(alice); +token.approve(transferAgent, amount); +``` + +### 5. Confirm the direct path is denied + +```solidity +vm.prank(alice); +token.transfer(bob, amount); // reverts PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorId) +``` + +Alice holds a balance and, after step 4, an allowance for the transfer agent — but she is not on the executor allowlist, so the call reverts before balance is checked. + +### 6. Confirm the transfer agent's path succeeds + +```solidity +vm.prank(transferAgent); +token.transferFrom(alice, bob, amount); +``` + +The transfer agent is on the executor allowlist and holds an allowance from Alice, so both gates pass and the transfer completes. + +## Example + +A transfer agent is the only account allowed to move tokens on this asset. Alice holds a balance and has approved the transfer agent. Her own direct `transfer` reverts; the transfer agent's `transferFrom` succeeds. + +```mermaid +sequenceDiagram + participant Admin + participant Registry as Policy Registry + participant Token as B20 token + participant Alice + participant TransferAgent as Transfer agent + participant Bob + + Admin->>Registry: createPolicy(policyAdmin, ALLOWLIST) + Registry-->>Admin: executorId + Admin->>Registry: updateAllowlist(executorId, true, [TransferAgent]) + Admin->>Token: updatePolicy(TRANSFER_EXECUTOR_POLICY, executorId) + + Alice->>Token: approve(TransferAgent, amount) + + Alice->>Token: transfer(Bob, amount) + Token->>Registry: isAuthorized(executorId, Alice) + Registry-->>Token: false + Token-->>Alice: revert PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorId) + + TransferAgent->>Token: transferFrom(Alice, Bob, amount) + Token->>Registry: isAuthorized(executorId, TransferAgent) + Registry-->>Token: true + Token-->>TransferAgent: Transfer(Alice, Bob, amount) + Note over Alice: loses amount + Note over Bob: gains amount +``` + +```solidity +import {IB20} from "base-std/interfaces/IB20.sol"; +import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; +import {StdPrecompiles} from "base-std/StdPrecompiles.sol"; + +IB20 token = IB20(tokenAddr); +IPolicyRegistry registry = StdPrecompiles.POLICY_REGISTRY; + +uint64 executorId = registry.createPolicy(policyAdmin, IPolicyRegistry.PolicyType.ALLOWLIST); +address[] memory initiators = new address[](1); +initiators[0] = transferAgent; +registry.updateAllowlist(executorId, true, initiators); +token.updatePolicy(token.TRANSFER_EXECUTOR_POLICY(), executorId); + +// Alice approves the transfer agent, but is not herself an authorized initiator. +vm.prank(alice); +token.approve(transferAgent, amount); + +vm.prank(alice); +token.transfer(bob, amount); // reverts PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorId) + +vm.prank(transferAgent); +token.transferFrom(alice, bob, amount); // succeeds +``` + +## Verify + +Look for `Transfer(from, to, amount)` on the transfer agent's `transferFrom` call. That event is the success signal. + +A revert on the holder's own `transfer` or self-`transferFrom` with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorId)` confirms the restriction is active — it means the holder is not on the executor allowlist, not that anything is misconfigured. + +To confirm the allowlist itself, call `isAuthorized(executorId, account)` on the Policy Registry for both the transfer agent (`true`) and the holder (`false`). + +## Common Errors + +These errors follow the order the shared transfer path checks them. + +| Error | Why it happened | What to do | +| --- | --- | --- | +| `PolicyForbids(TRANSFER_EXECUTOR_POLICY, policyId)` | The caller is not authorized under the attached executor policy. This fires for `transfer`, for `transferFrom`, and for a holder's self-`transferFrom` — there is no exemption for `msg.sender == from`. | Add the caller to the executor allowlist, or route the call through an already-authorized initiator such as the transfer agent. | +| `InsufficientAllowance(spender, allowance, needed)` | `transferFrom` ran with an allowance below `needed`. Passing the executor check does not grant spending rights. | Have `from` call `approve(spender, amount)` for at least `amount`. | +| `PolicyForbids(TRANSFER_SENDER_POLICY, policyId)` | `from` is not authorized under the sender policy. Independent of the executor check. | Add `from` to the sender allowlist, or clear the sender policy back to `0`. | +| `PolicyForbids(TRANSFER_RECEIVER_POLICY, policyId)` | `to` is not authorized under the receiver policy. Independent of the executor check. | Add `to` to the receiver allowlist, or clear the receiver policy back to `0`. | +| `InsufficientBalance(sender, balance, needed)` | `sender`'s balance is less than `needed`. | Transfer `balanceOf(from)` or less. | +| `PolicyNotFound(uint64 policyId)` | `updatePolicy` received an ID that is not a sentinel and does not exist in the registry. | Create the policy first, then attach the returned ID. | +| `Unauthorized()` | A non-admin called `updateAllowlist` or `updateBlocklist` on the executor policy. | Call as the policy's `policyAdmin`. | + +```mermaid +flowchart TD + Fail[Call reverted] --> E{Error} + E -->|PolicyForbids TRANSFER_EXECUTOR_POLICY| F1[Allowlist the caller, or route through an authorized initiator] + E -->|InsufficientAllowance| F2[Approve the spender for at least amount] + E -->|PolicyForbids TRANSFER_SENDER_POLICY or TRANSFER_RECEIVER_POLICY| F3[Allowlist from or to, or clear that scope] + E -->|InsufficientBalance| F4[Lower amount] +``` + +## Related Concepts + +- [Policies](../concepts/policies.md) +- [Roles and Pause](../concepts/roles-and-pause.md) + +## Reference + +```solidity +function TRANSFER_EXECUTOR_POLICY() external view returns (bytes32); +function TRANSFER_SENDER_POLICY() external view returns (bytes32); +function TRANSFER_RECEIVER_POLICY() external view returns (bytes32); + +function transfer(address to, uint256 amount) external returns (bool); +function transferFrom(address from, address to, uint256 amount) external returns (bool); +function approve(address spender, uint256 amount) external returns (bool); +function allowance(address owner, address spender) external view returns (uint256); + +function updatePolicy(bytes32 policyScope, uint64 newPolicyId) external; +function policyId(bytes32 policyScope) external view returns (uint64); + +// Policy Registry +function createPolicy(address admin, PolicyType policyType) external returns (uint64 newPolicyId); +function createPolicyWithAccounts(address admin, PolicyType policyType, address[] calldata accounts) external returns (uint64 newPolicyId); +function updateAllowlist(uint64 policyId, bool allowed, address[] calldata accounts) external; +function isAuthorized(uint64 policyId, address account) external view returns (bool); +``` + +`transfer(address,uint256)` selector: `0xa9059cbb`. + +`transferFrom(address,address,uint256)` selector: `0x23b872dd`. + +`approve(address,uint256)` selector: `0x095ea7b3`. + +`updatePolicy(bytes32,uint64)` selector: `0xadf9c4ea`. + +`PolicyForbids(bytes32,uint64)` selector: `0xa43fec12`. + +`TRANSFER_EXECUTOR_POLICY` value: `keccak256("TRANSFER_EXECUTOR_POLICY")` = `0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8`.