From eccdf00ff3914e47529da190e9a5199614c20f32 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:58:09 +0000 Subject: [PATCH 1/5] docs: sync from base-std@253bb15 --- .../02-cobalt-b20asset-multiplier.mdx | 9 + ...enim-b20-transfer-executor-enforcement.mdx | 158 ++++++++++++++++++ .../accept-payments/refund-a-payment.mdx | 4 + .../accept-payments/request-a-payment.mdx | 4 +- .../accept-payments/send-a-payout.mdx | 4 + .../accept-payments/split-a-payment.mdx | 2 + .../accept-payments/verify-a-payment.mdx | 4 + .../issue-rwa/announce-a-distribution.mdx | 4 + .../issue-rwa/pause-transfers.mdx | 10 ++ .../issue-stablecoins/pause-activity.mdx | 4 + .../reconcile-with-memos.mdx | 4 + .../restrict-who-can-hold.mdx | 6 +- docs/docs.json | 3 +- .../b20/reference/constants-addresses.mdx | 11 ++ .../ib20/transfer-executor-policy.mdx | 12 +- .../ib20/transfer-from-with-memo.mdx | 25 ++- .../interfaces/ib20/transfer-from.mdx | 8 +- .../reference/interfaces/ib20/transfer.mdx | 13 +- .../b20/reference/invariants-tests.mdx | 75 +++++---- .../b20/specification-overview.mdx | 4 +- 20 files changed, 302 insertions(+), 62 deletions(-) create mode 100644 docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx diff --git a/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier.mdx b/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier.mdx index 987ab3ca7..bbcea21b5 100644 --- a/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier.mdx +++ b/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier.mdx @@ -145,3 +145,12 @@ read the ceiling from `MAX_UI_MULTIPLIER()` without risking the revert. No. The multiplier is purely cosmetic: it rescales only the UI/scaled view. `balanceOf`, `transfer`, `totalSupply`, and `Transfer` stay raw, and no multiplier change, scheduled or instant, affects them. Only the `*UI` / scaled reads move. + +**Q: Does `TRANSFER_EXECUTOR_POLICY` apply to `transfer` and self-`transferFrom`?** +Yes, as of the Cobalt-era `IB20` update. `TRANSFER_EXECUTOR_POLICY` is checked against +`msg.sender` on every transfer path — `transfer`, `transferFrom`, `transferWithMemo`, and +`transferFromWithMemo` — including when `msg.sender == from`. There is no carve-out for the holder +initiating their own transfer. If you have configured a restrictive executor policy and rely on +holders moving their own tokens via `transfer` or self-`transferFrom`, those holders must now be +authorized as initiators. Tokens that have never configured `TRANSFER_EXECUTOR_POLICY` are +unaffected (the unset slot is always-allow). diff --git a/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx b/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx new file mode 100644 index 000000000..7f0bc8486 --- /dev/null +++ b/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx @@ -0,0 +1,158 @@ +--- +title: "Transfer Executor Policy Enforcement" +description: "TRANSFER_EXECUTOR_POLICY now gates every transfer path — including direct transfer and self-transferFrom — closing two initiator bypasses introduced in the Denim hardfork." +--- + +## Abstract + +The Denim hardfork extends `TRANSFER_EXECUTOR_POLICY` to cover 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 delegated `transferFrom` paths, and only when `msg.sender != from`. + +This is a purely behavioral change — no new selectors, events, errors, or storage. Tokens that never set `TRANSFER_EXECUTOR_POLICY` keep the always-allow default and are unaffected. Tokens that already set a restrictive executor policy and relied on either bypass must authorize affected holders before this change activates. + +## Motivation + +An issuer of a restricted security token may need every transfer to go through a registered transfer agent. Only the transfer agent's contract may initiate a move; a holder cannot call `transfer` themselves even to an already-eligible counterparty. The holder approves the transfer agent, and the transfer agent calls `transferFrom`. `TRANSFER_EXECUTOR_POLICY` is the initiator allowlist for that pattern. + +The previous scope could not enforce this consistently with `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY`, which already run on every transfer path. Two initiator-side gaps remained: + +1. **`transfer` was never gated.** The initiator of `transfer` is `msg.sender`, which is also `from`, but the executor 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 without being on the executor allowlist. + +Both gaps let a non-allowlisted holder move tokens by choosing a different entrypoint. 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. + +## What Changed + +### Check location and scope + +The executor check moves from the `transferFrom` and `transferFromWithMemo` bodies into `_transfer`, where it runs first — before the existing sender and receiver checks — under the same `_isPrivileged()` bootstrap bypass. The `msg.sender == from` carve-out that previously skipped the check is removed. Because `transfer` and `transferWithMemo` already route through `_transfer`, they gain the executor check with no entrypoint-specific code. + +Pause, zero-actor, and allowance checks remain in the entrypoints. Allowance is still consumed in `transferFrom` / `transferFromWithMemo` bodies before reaching `_transfer`, so revert order is unchanged for allowance failures. + +### Check order: before vs. after + +**Before** — 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 +``` + +**After** — shared `_transfer` helper handles all three policy checks: + +```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 +``` + +Canonical check order after this change: + +- `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. + +When more than one check would fail, the caller sees the first revert in that order. + +### Gas + +`_transfer` reads all three transfer-side policy IDs from the existing packed slot in a single `SLOAD`. On `transferFrom` and `transferFromWithMemo`, the previous implementation read the executor lane in the entrypoint body and then re-read the same packed slot in `_transfer` (warm); the helper now performs the only `SLOAD`. + +On `transfer` and `transferWithMemo`, the paths previously did not consult the executor policy. Those paths now check `msg.sender` under `TRANSFER_EXECUTOR_POLICY`. When `TRANSFER_EXECUTOR_POLICY` and `TRANSFER_SENDER_POLICY` share the same policy ID — including both slots unset (`ALWAYS_ALLOW_ID`) — `_transfer` reuses the executor result and skips the redundant `isAuthorized` call for the sender. An unset executor slot remains `ALWAYS_ALLOW_ID` (`0`), so a default `transfer` still makes two `isAuthorized` calls (one reused for executor + sender, one for receiver). + +### Examples + +A holder moving their own tokens is now gated by the executor policy even through direct `transfer`: + +```solidity Title="Executor policy blocks direct transfer" +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 (e.g., a transfer agent). A holder who is not allowlisted cannot bypass it by calling `transfer` or self-`transferFrom`: + +```solidity Title="Executor allowlist: approved vs. non-approved initiators" +uint64 executorAllowlist = policyRegistry.createPolicyWithAccounts(admin, ALLOWLIST, [transferAgent]); +token.updatePolicy(TRANSFER_EXECUTOR_POLICY, executorAllowlist); + +vm.prank(transferAgent); +token.transferFrom(alice, bob, amount); // succeeds: transferAgent 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 Title="Bootstrap window bypasses executor check" +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 +``` + +## Migration + +**Not breaking** for tokens that never configured `TRANSFER_EXECUTOR_POLICY`. The unset policy slot stays always-allow, and the factory bootstrap bypass is unchanged. + +**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` to keep doing so. +2. If holders were relying on `msg.sender == from` to skip the executor 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. + +## Alternatives Considered + +### Keep the check in `transferFrom` only; add it to `transfer` separately + +This would add a matching executor check to `transfer` while leaving the existing `transferFrom` check — including the `msg.sender != from` carve-out — in place. 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)`. It also duplicates the check across two entrypoints rather than centralizing it in `_transfer`. + +### Fold pause, zero-actor, and allowance into `_transfer` + +This option would move every remaining transfer-family check into the helper. Rejected because allowance is entrypoint-specific. Folding it in would require a consume-allowance flag, and moving zero-actor checks after allowance would change revert order. This change only relocates the executor policy check. + +## Test Cases + +Key scenarios pinned by the updated test suite: + +- **Executor sentinel on `transfer`** — `ALWAYS_BLOCK_ID` as executor policy reverts `transfer` with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ALWAYS_BLOCK_ID)`. +- **External allowlist on `transfer`** — only allowlisted `msg.sender` can call `transfer`; non-allowlisted holders revert. +- **Privileged bypass** — factory bootstrap window passes executor check regardless of policy. +- **Memo parity** — `transferWithMemo` and `transferFromWithMemo` behave identically to their non-memo counterparts under the executor policy. +- **Closed self-caller loophole** — `transferFrom(self, to, amount)` from a non-allowlisted `msg.sender` now reverts (`test_transferFrom_revert_selfCaller_executorPolicyForbids`). +- **Revert order** — all 21 pairs of check combinations across `transfer`, `transferFrom`, and memo variants confirm the canonical order: executor → sender → receiver → balance. diff --git a/docs/build-on-base/accept-payments/refund-a-payment.mdx b/docs/build-on-base/accept-payments/refund-a-payment.mdx index 5846c8eae..f3d8d2a60 100644 --- a/docs/build-on-base/accept-payments/refund-a-payment.mdx +++ b/docs/build-on-base/accept-payments/refund-a-payment.mdx @@ -67,6 +67,10 @@ For plain USDC, call `transfer` instead and record the original order ID, captur `reserveOnce` must atomically create a pending refund and reduce the available refundable balance before broadcasting. If the worker loses the receipt, reconcile that pending record from chain data instead of releasing it and risking a duplicate transfer. + +B20 tokens with a restrictive `TRANSFER_EXECUTOR_POLICY` now apply that policy to every transfer call — including direct `transfer` and `transferWithMemo` — not only delegated `transferFrom` paths. If your merchant account is not authorized as an executor on the token, the refund transfer will revert. Confirm your account is on the token's executor allowlist before broadcasting a refund. + + The refund transfer goes to the payer from the original receipt, and the durable refund ledger reduces the remaining refundable amount. diff --git a/docs/build-on-base/accept-payments/request-a-payment.mdx b/docs/build-on-base/accept-payments/request-a-payment.mdx index e78a34c7b..aee1e3ccd 100644 --- a/docs/build-on-base/accept-payments/request-a-payment.mdx +++ b/docs/build-on-base/accept-payments/request-a-payment.mdx @@ -1,7 +1,7 @@ --- title: "Request a Payment" keywords: ["request USDC payment", "wallet USDC checkout", "B20 payment memo", "onchain checkout Base"] -description: "Request an immediately settled USDC or memo-enabled B20 payment from a wallet on Base." +description: "Request and settle a USDC or memo-enabled B20 payment from a wallet on Base." --- import { PaymentsDemo } from "/snippets/PaymentsDemo.jsx" @@ -100,7 +100,7 @@ function pay(bytes32 orderId, uint256 amount) external { - If the Solidity checkout calls `transferFromWithMemo`, the payer must approve it and any `TRANSFER_EXECUTOR_POLICY` must authorize the checkout contract. The merchant must still verify the expected amount before fulfillment. + `TRANSFER_EXECUTOR_POLICY` now applies to every transfer entrypoint — including `transfer`, `transferFrom`, and their memo variants — even when `msg.sender == from`. If the token issuer has configured an executor allowlist, both direct wallet transfers and checkout-contract pulls must be authorized. Ensure your checkout contract address and any holder-initiated transfers are covered before deployment. diff --git a/docs/build-on-base/accept-payments/send-a-payout.mdx b/docs/build-on-base/accept-payments/send-a-payout.mdx index 31cb49cc4..3e67ace91 100644 --- a/docs/build-on-base/accept-payments/send-a-payout.mdx +++ b/docs/build-on-base/accept-payments/send-a-payout.mdx @@ -65,6 +65,10 @@ The transaction emits one `PayoutSent` event per recipient under the same `batch Cap batch size from measured gas usage and keep the contract's `MAX_RECIPIENTS` bound. A single failed token transfer reverts the entire batch. + +B20 tokens enforce `TRANSFER_EXECUTOR_POLICY` on every transfer path — including `transferFrom` calls where `msg.sender == from`. If the token has a restrictive executor policy, your payout contract's address must be authorized as an executor, or all transfers will revert with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)`. + + Do not approve the public `Multicall3` contract as an ERC-20 spender. Downstream token calls see Multicall3 as `msg.sender`, and any caller can ask that general-purpose contract to invoke `transferFrom` against an allowance you grant it. Use a purpose-built contract that fixes who can initiate payouts and how recipients are selected. ## See Also diff --git a/docs/build-on-base/accept-payments/split-a-payment.mdx b/docs/build-on-base/accept-payments/split-a-payment.mdx index c230c9e07..9d6693f8f 100644 --- a/docs/build-on-base/accept-payments/split-a-payment.mdx +++ b/docs/build-on-base/accept-payments/split-a-payment.mdx @@ -83,6 +83,8 @@ All shares sum to the input amount exactly, including the rounding remainder, an +If the B20 token used for splitting has a restrictive `TRANSFER_EXECUTOR_POLICY`, the contract calling `transferFrom` must be authorized as an executor — including when `msg.sender == from`. Configure the executor policy accordingly before routing splits through a payout contract. + Define who receives rounding remainder in your commercial terms. Also approve only the amount needed for the split and verify recipient addresses before submitting the transaction. diff --git a/docs/build-on-base/accept-payments/verify-a-payment.mdx b/docs/build-on-base/accept-payments/verify-a-payment.mdx index 9a5435a5d..592d8ddbf 100644 --- a/docs/build-on-base/accept-payments/verify-a-payment.mdx +++ b/docs/build-on-base/accept-payments/verify-a-payment.mdx @@ -73,6 +73,10 @@ Direct `transfer`, EIP-3009 capture, and `transferFrom` after a permit all emit Choose a confirmation depth that matches the value and reversibility of fulfillment. See [transaction finality](/specifications/transactions/transaction-finality) for Base's confirmation stages. + +B20 tokens may enforce a `TRANSFER_EXECUTOR_POLICY` that restricts which addresses may initiate transfers — including direct `transfer` calls and `transferFrom` calls where `msg.sender == from`. If your settlement contract initiates B20 transfers on behalf of payers, ensure it is authorized as an executor by the token issuer. + + ## See Also diff --git a/docs/build-on-base/issue-rwa/announce-a-distribution.mdx b/docs/build-on-base/issue-rwa/announce-a-distribution.mdx index 5e564b4dd..be1eb05da 100644 --- a/docs/build-on-base/issue-rwa/announce-a-distribution.mdx +++ b/docs/build-on-base/issue-rwa/announce-a-distribution.mdx @@ -152,6 +152,10 @@ IB20Asset(token).announce(new bytes[](0), id, description, uri); Typical inner causes of `InternalCallFailed`: missing `MINT_ROLE` or `BURN_ROLE`, paused `MINT` or `BURN`, `UIMultiplierUpdateExists`, `PolicyForbids`, `SupplyCapExceeded`, `InsufficientBalance`. A Solidity `Panic` (for example overflow) propagates raw and is not wrapped as `InternalCallFailed`. + +`TRANSFER_EXECUTOR_POLICY` now applies to every transfer path — including `transfer`, `transferFrom`, and their memo variants — even when `msg.sender == from`. If an inner call triggers a transfer and you have a restrictive executor policy configured, ensure the announcing operator is authorized as an executor. + + ## See Also diff --git a/docs/build-on-base/issue-rwa/pause-transfers.mdx b/docs/build-on-base/issue-rwa/pause-transfers.mdx index 4591c73c9..641d6b6b7 100644 --- a/docs/build-on-base/issue-rwa/pause-transfers.mdx +++ b/docs/build-on-base/issue-rwa/pause-transfers.mdx @@ -63,6 +63,16 @@ Transfer pause state changes without pausing mint or burn. `pause` requires `PAUSE_ROLE`. `unpause` requires `UNPAUSE_ROLE`. These are separate roles, so grant recovery authority more narrowly than emergency pause authority. +## Executor Policy and Transfer Initiation + +`TRANSFER_EXECUTOR_POLICY` now applies to **every** transfer path — `transfer`, `transferFrom`, and their memo variants — including when `msg.sender == from`. If your token has configured a restrictive executor policy, holders moving their own tokens (via direct `transfer` or self-`transferFrom`) must also be authorized as initiators. + + +If you have set a restrictive `TRANSFER_EXECUTOR_POLICY`, holders who are not on the executor allowlist can no longer initiate transfers themselves. This closes the previous bypasses where a direct `transfer` call or a self-`transferFrom` call skipped the executor check. Review your executor policy configuration before deploying tokens that rely on holder-initiated transfers. + + +Tokens that have never configured `TRANSFER_EXECUTOR_POLICY` are unaffected — an unset executor slot is always-allow. + ## See Also diff --git a/docs/build-on-base/issue-stablecoins/pause-activity.mdx b/docs/build-on-base/issue-stablecoins/pause-activity.mdx index 0695effab..94047e235 100644 --- a/docs/build-on-base/issue-stablecoins/pause-activity.mdx +++ b/docs/build-on-base/issue-stablecoins/pause-activity.mdx @@ -74,6 +74,10 @@ See the [B20 token standard](/specifications/b20/specification-overview) for the When a paused feature blocks a call, the transaction reverts `ContractPaused(feature)`. The error names only the one feature that blocked the call. + +Pausing `TRANSFER` stops all transfer entrypoints. Even with `TRANSFER` unpaused, the `TRANSFER_EXECUTOR_POLICY` now applies to **every** transfer path — including direct `transfer` calls and `transferFrom` when `msg.sender == from`. If you use an executor allowlist to restrict who may initiate transfers, holders are subject to that check regardless of which entrypoint they use. + + ## See Also diff --git a/docs/build-on-base/issue-stablecoins/reconcile-with-memos.mdx b/docs/build-on-base/issue-stablecoins/reconcile-with-memos.mdx index 739e39553..5c244b760 100644 --- a/docs/build-on-base/issue-stablecoins/reconcile-with-memos.mdx +++ b/docs/build-on-base/issue-stablecoins/reconcile-with-memos.mdx @@ -62,6 +62,10 @@ The receipt contains `Transfer` followed immediately by `Memo`, with `invoice-88 Join a memo to the preceding operation with `(transactionHash, logIndex - 1)`. Keep the offchain invoice ID unique. + +`TRANSFER_EXECUTOR_POLICY` now applies to `transferWithMemo` and `transferFromWithMemo` (and all other transfer entrypoints), including when `msg.sender` is the token holder. If your token has a restrictive executor policy set, callers must be authorized as initiators to use memo transfers. + + ## See Also diff --git a/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx b/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx index 9e1a67a3a..9052f1488 100644 --- a/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx +++ b/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx @@ -24,10 +24,14 @@ New to B20? See the [B20 Token Standard](/specifications/b20/specification-overv The Policy Registry is a singleton precompile that stores each member list once. A token stores only a `uint64` policy ID per scope. When a gated function runs, the token calls `isAuthorized(policyId, account)` on the registry. Many tokens can share one policy; an update to the policy is immediately visible to every token that references it. -Scopes gate specific functions. `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY` check the sender and recipient on every `transfer` and `transferFrom`. `MINT_RECEIVER_POLICY` checks the recipient on every `mint`. All three default to `ALWAYS_ALLOW` (`0`) until you bind a policy. +Scopes gate specific functions. `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY` check the sender and recipient on every `transfer` and `transferFrom`. `TRANSFER_EXECUTOR_POLICY` checks `msg.sender` (the initiator) on every `transfer`, `transferFrom`, and their memo variants — including when `msg.sender == from`. `MINT_RECEIVER_POLICY` checks the recipient on every `mint`. All four default to `ALWAYS_ALLOW` (`0`) until you bind a policy. An **allowlist** authorizes only accounts in the set. An empty allowlist authorizes nobody, so seed your intended holders before binding the policy. + +`TRANSFER_EXECUTOR_POLICY` now applies to all transfer entrypoints, including direct `transfer` calls where `msg.sender == from`. If you have set a restrictive executor policy, holders who are not on the allowlist can no longer move their own tokens via `transfer` or self-`transferFrom`. Add all authorized initiators to the policy before restricting this scope. + + ## Create and Bind a Holder Allowlist diff --git a/docs/docs.json b/docs/docs.json index 06fb01476..f2cd2ddc5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -435,7 +435,8 @@ "upgrades/denim/overview", "specifications/native-account-abstraction", "upgrades/denim/200ms-blocks", - "upgrades/denim/migrate-from-flashblocks" + "upgrades/denim/migrate-from-flashblocks", + "base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement" ] }, { diff --git a/docs/specifications/b20/reference/constants-addresses.mdx b/docs/specifications/b20/reference/constants-addresses.mdx index d3dc5c3c3..ddc30b0ee 100644 --- a/docs/specifications/b20/reference/constants-addresses.mdx +++ b/docs/specifications/b20/reference/constants-addresses.mdx @@ -62,6 +62,17 @@ Policy type bytes: | `UNION` | `0x02` | | `INTERSECT` | `0x03` | +## Policy Constants + +| Name | Value | Description | +|---|---|---| +| `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 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`. | + ## Variant Bytes | Variant | Byte | Address shape | diff --git a/docs/specifications/b20/reference/interfaces/ib20/transfer-executor-policy.mdx b/docs/specifications/b20/reference/interfaces/ib20/transfer-executor-policy.mdx index c705ecd3b..f55a5c0bc 100644 --- a/docs/specifications/b20/reference/interfaces/ib20/transfer-executor-policy.mdx +++ b/docs/specifications/b20/reference/interfaces/ib20/transfer-executor-policy.mdx @@ -1,6 +1,6 @@ --- title: "IB20.TRANSFER_EXECUTOR_POLICY" -description: "Generated B20 reference for TRANSFER_EXECUTOR_POLICY()." +description: "Returns the policy slot consulted against msg.sender on every transfer entrypoint in IB20." --- @@ -18,21 +18,25 @@ function TRANSFER_EXECUTOR_POLICY() external view returns (bytes32); ## Description -Policy slot consulted against `msg.sender` on `transferFrom` when distinct from `from`. Not consulted on `transfer`. +Policy slot consulted against `msg.sender` (the initiator) on every transfer, including when `msg.sender == from`. This applies to `transfer`, `transferFrom`, and their memo variants. Bypassed for factory-originated calls during the creation (bootstrap) window; see `IB20Factory.createB20`. + +`TRANSFER_EXECUTOR_POLICY` now applies to all transfer entrypoints — including `transfer` and self-`transferFrom` where `msg.sender == from`. Previously it applied only when `msg.sender != from` on the `transferFrom` path. Tokens with a restrictive executor policy will block holder-initiated transfers unless those holders are authorized as executors. + + ## Returns Policy scope constant. ## Access Control -Read-only or ERC-20-standard access rules unless the NatSpec states otherwise. +Read-only view function; no access restriction. ## Policy Interaction -No direct policy interaction. +The executor policy is checked against `msg.sender` on every transfer path (`transfer`, `transferFrom`, `transferWithMemo`, `transferFromWithMemo`). There is no exemption when `msg.sender == from`. An unset policy slot is always-allow, so tokens that never configured this policy are unaffected. ## Example diff --git a/docs/specifications/b20/reference/interfaces/ib20/transfer-from-with-memo.mdx b/docs/specifications/b20/reference/interfaces/ib20/transfer-from-with-memo.mdx index 450d08192..a529a26c2 100644 --- a/docs/specifications/b20/reference/interfaces/ib20/transfer-from-with-memo.mdx +++ b/docs/specifications/b20/reference/interfaces/ib20/transfer-from-with-memo.mdx @@ -1,10 +1,8 @@ --- title: "IB20.transferFromWithMemo" -description: "Generated B20 reference for transferFromWithMemo(address,address,uint256,bytes32)." +description: "Transfers tokens on behalf of another address and emits a Memo event, with executor, sender, and receiver policy enforcement." --- - - ## Signature ```solidity IB20.sol @@ -33,13 +31,26 @@ Same as `transferFrom`, plus emits `Memo` immediately after the standard `Transf Always `true` on success. -## Access Control +## Policy Interaction + +`TRANSFER_EXECUTOR_POLICY` is checked against `msg.sender` on every call, including when `msg.sender == from`. Sender (`from`) and receiver (`to`) policies are also enforced. All three checks are bypassed during the factory bootstrap window; see `IB20Factory.createB20`. -Read-only or ERC-20-standard access rules unless the NatSpec states otherwise. + +`TRANSFER_EXECUTOR_POLICY` now applies even when `msg.sender == from`. Tokens that have configured a restrictive executor policy will block holder-initiated transfers unless the holder is explicitly authorized as an executor. + -## Policy Interaction +## Revert Conditions -Checks `TRANSFER_EXECUTOR_POLICY` when `msg.sender != from`, plus sender and receiver transfer scopes. +| Revert | Condition | +|---|---| +| `ContractPaused(TRANSFER)` | The `TRANSFER` operation is paused. | +| `InvalidReceiver` | `to == address(0)`. | +| `InvalidSender` | `from == address(0)`. | +| `InsufficientAllowance` | The caller's allowance from `from` is below `amount`. | +| `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)` | `msg.sender` is not authorized by the executor policy. | +| `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` | `from` is not authorized by the sender policy. | +| `PolicyForbids(TRANSFER_RECEIVER_POLICY, ...)` | `to` is not authorized by the receiver policy. | +| `InsufficientBalance` | `from`'s balance is below `amount`. | ## Example diff --git a/docs/specifications/b20/reference/interfaces/ib20/transfer-from.mdx b/docs/specifications/b20/reference/interfaces/ib20/transfer-from.mdx index 4dc9d89cf..be6360c56 100644 --- a/docs/specifications/b20/reference/interfaces/ib20/transfer-from.mdx +++ b/docs/specifications/b20/reference/interfaces/ib20/transfer-from.mdx @@ -1,10 +1,8 @@ --- title: "IB20.transferFrom" -description: "Generated B20 reference for transferFrom(address,address,uint256)." +description: "Transfers tokens from a specified address using the caller's allowance, with executor, sender, and receiver policy enforcement." --- - - ## Signature ```solidity IB20.sol @@ -38,7 +36,7 @@ Always `true` on success. - `InvalidReceiver` when `to == address(0)`. - `InvalidSender` when `from == address(0)`. - `InsufficientAllowance` when the caller's allowance from `from` is below `amount`. -- `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)` when `msg.sender != from` and `msg.sender` is not authorized. +- `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)` when `msg.sender` is not authorized. - `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `from` is not authorized. - `PolicyForbids(TRANSFER_RECEIVER_POLICY, ...)` when `to` is not authorized. - `InsufficientBalance` when `from`'s balance is below `amount`. @@ -49,7 +47,7 @@ Read-only or ERC-20-standard access rules unless the NatSpec states otherwise. ## Policy Interaction -Checks `TRANSFER_EXECUTOR_POLICY` when `msg.sender != from`, plus sender and receiver transfer scopes. +Checks `TRANSFER_EXECUTOR_POLICY` against `msg.sender` on every call, including when `msg.sender == from`. An executor allowlist can therefore restrict holder-initiated transfers, with no self-transfer exemption. Also checks sender and receiver transfer scopes. All policy checks are bypassed during the factory bootstrap window. ## Example diff --git a/docs/specifications/b20/reference/interfaces/ib20/transfer.mdx b/docs/specifications/b20/reference/interfaces/ib20/transfer.mdx index 214930928..a7c0c8870 100644 --- a/docs/specifications/b20/reference/interfaces/ib20/transfer.mdx +++ b/docs/specifications/b20/reference/interfaces/ib20/transfer.mdx @@ -1,6 +1,6 @@ --- title: "IB20.transfer" -description: "Generated B20 reference for transfer(address,uint256)." +description: "Transfers tokens from msg.sender to a destination address, subject to executor, sender, and receiver policy checks." --- @@ -36,17 +36,22 @@ Always `true` on success. - `ContractPaused(TRANSFER)` when `TRANSFER` is paused. - `InvalidReceiver` when `to == address(0)`. - `InvalidSender` when `msg.sender == address(0)`. -- `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `msg.sender` is not authorized. +- `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)` when `msg.sender` is not authorized as an executor. +- `PolicyForbids(TRANSFER_SENDER_POLICY, ...)` when `msg.sender` is not authorized as a sender. - `PolicyForbids(TRANSFER_RECEIVER_POLICY, ...)` when `to` is not authorized. - `InsufficientBalance` when `msg.sender`'s balance is below `amount`. ## Access Control -Read-only or ERC-20-standard access rules unless the NatSpec states otherwise. +Standard ERC-20 caller rules apply. The factory bootstrap bypass suppresses all three policy checks during the creation window; see `IB20Factory.createB20`. ## Policy Interaction -Checks `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY`; `approve` and `permit` are not policy-gated. +Checks `TRANSFER_EXECUTOR_POLICY`, `TRANSFER_SENDER_POLICY`, and `TRANSFER_RECEIVER_POLICY` on every call. The executor check runs even when `msg.sender == from`, so an executor allowlist can restrict holder-initiated transfers with no self-transfer exemption. + + +`TRANSFER_EXECUTOR_POLICY` now applies to `transfer` (not only `transferFrom`). If you have configured a restrictive executor allowlist, holders who are not on that list can no longer initiate transfers directly — they must use an authorized executor. + ## Example diff --git a/docs/specifications/b20/reference/invariants-tests.mdx b/docs/specifications/b20/reference/invariants-tests.mdx index 0fafbfc47..e7552a0c6 100644 --- a/docs/specifications/b20/reference/invariants-tests.mdx +++ b/docs/specifications/b20/reference/invariants-tests.mdx @@ -26,7 +26,7 @@ These invariants and conformance test cases are the normative behavioral guarant ### Transfer Policies 10. `approve` is never policy-gated. -11. `TRANSFER_EXECUTOR_POLICY` is checked only on `transferFrom`, never on `transfer`. +11. `TRANSFER_EXECUTOR_POLICY` is checked on every transfer entrypoint (`transfer`, `transferFrom`, and memo'd variants), including when `msg.sender == from`. 12. `MINT_RECEIVER_POLICY` is always enforced, even during factory `initCalls`. 13. All three transfer-side scopes are bypassed during `initCalls`. 14. Every scope defaults to `ALWAYS_ALLOW` at token creation. @@ -104,74 +104,75 @@ These invariants and conformance test cases are the normative behavioral guarant | 15 | Sender on blocklist calls `transfer` | Reverts with `PolicyForbids` | | 16 | Sender on blocklist calls `approve` | Succeeds | | 17 | `transferFrom` where executor is on executor blocklist | Reverts | -| 18 | Direct `transfer` by sender on executor blocklist (not sender blocklist) | Succeeds | -| 19 | During `initCalls`, transfer from blocklisted sender | Succeeds — bypass | -| 20 | During `initCalls`, mint to receiver on mint-receiver blocklist | Reverts — never bypassed | +| 18 | Direct `transfer` by sender on executor blocklist (not sender blocklist) | Reverts with `PolicyForbids` | +| 19 | `transferFrom` where `msg.sender == from` and executor is on executor blocklist | Reverts with `PolicyForbids` | +| 20 | During `initCalls`, transfer from blocklisted sender | Succeeds — bypass | +| 21 | During `initCalls`, mint to receiver on mint-receiver blocklist | Reverts — never bypassed | ### Mint and Supply Cap | # | Scenario | Expected | |---|----------|----------| -| 21 | Mint that would push `totalSupply` above cap | Reverts with `SupplyCapExceeded` | -| 22 | Mint exactly to cap | Succeeds | -| 23 | `updateSupplyCap` below current `totalSupply` | Reverts with `InvalidSupplyCap` | -| 24 | Burn tokens, then mint up to cap | Succeeds | -| 25 | Mint while `MINT` is paused | Reverts | +| 22 | Mint that would push `totalSupply` above cap | Reverts with `SupplyCapExceeded` | +| 23 | Mint exactly to cap | Succeeds | +| 24 | `updateSupplyCap` below current `totalSupply` | Reverts with `InvalidSupplyCap` | +| 25 | Burn tokens, then mint up to cap | Succeeds | +| 26 | Mint while `MINT` is paused | Reverts | ### Burn and Seize | # | Scenario | Expected | |---|----------|----------| -| 26 | `burnBlocked` on frozen account | Succeeds | -| 27 | `burnBlocked` on non-frozen account | Reverts | -| 28 | `burnBlocked` by holder of `BURN_ROLE` (not `BURN_BLOCKED_ROLE`) | Reverts | -| 29 | Freeze, seize full balance, re-mint to recovery | Succeeds | -| 30 | Burn while `BURN` is paused | Reverts | +| 27 | `burnBlocked` on frozen account | Succeeds | +| 28 | `burnBlocked` on non-frozen account | Reverts | +| 29 | `burnBlocked` by holder of `BURN_ROLE` (not `BURN_BLOCKED_ROLE`) | Reverts | +| 30 | Freeze, seize full balance, re-mint to recovery | Succeeds | +| 31 | Burn while `BURN` is paused | Reverts | ### Pause | # | Scenario | Expected | |---|----------|----------| -| 31 | Pause `TRANSFER`, call `transfer` | Reverts | -| 32 | Pause `TRANSFER`, call `mint` | Succeeds | -| 33 | Pause `TRANSFER`, call `approve` | Succeeds | -| 34 | Pauser calls `unpause` without `UNPAUSE_ROLE` | Reverts | +| 32 | Pause `TRANSFER`, call `transfer` | Reverts | +| 33 | Pause `TRANSFER`, call `mint` | Succeeds | +| 34 | Pause `TRANSFER`, call `approve` | Succeeds | +| 35 | Pauser calls `unpause` without `UNPAUSE_ROLE` | Reverts | ### Memos | # | Scenario | Expected | |---|----------|----------| -| 35 | `transferWithMemo` | Emits `Transfer` then `Memo` at consecutive log indices | -| 36 | `transferFromWithMemo` | `Memo.caller` is `msg.sender`, not `from` | -| 37 | `transfer` (non-memo variant) | No `Memo` event | +| 36 | `transferWithMemo` | Emits `Transfer` then `Memo` at consecutive log indices | +| 37 | `transferFromWithMemo` | `Memo.caller` is `msg.sender`, not `from` | +| 38 | `transfer` (non-memo variant) | No `Memo` event | ### Permit | # | Scenario | Expected | |---|----------|----------| -| 38 | Valid permit with correct signature, nonce, deadline | Succeeds | -| 39 | Permit with expired deadline | Reverts | -| 40 | Replay used permit signature | Reverts | -| 41 | Permit signed before `updateName`, submitted after | Reverts | -| 42 | Contract wallet signature (ERC-1271) | Reverts | -| 43 | Permit while `TRANSFER` is paused | Succeeds | +| 39 | Valid permit with correct signature, nonce, deadline | Succeeds | +| 40 | Permit with expired deadline | Reverts | +| 41 | Replay used permit signature | Reverts | +| 42 | Permit signed before `updateName`, submitted after | Reverts | +| 43 | Contract wallet signature (ERC-1271) | Reverts | +| 44 | Permit while `TRANSFER` is paused | Succeeds | ### Factory | # | Scenario | Expected | |---|----------|----------| -| 44 | `getB20Address` then deploy with same params | Addresses match | -| 45 | Inspect byte 10 of deployed Asset address | Returns `0x00` | -| 46 | Deploy same `(deployer, variant, salt)` twice | Second reverts | -| 47 | Deploy when variant feature not activated | Reverts | -| 48 | `initCalls` that pause `TRANSFER`, then transfer in next initCall | Transfer reverts | +| 45 | `getB20Address` then deploy with same params | Addresses match | +| 46 | Inspect byte 10 of deployed Asset address | Returns `0x00` | +| 47 | Deploy same `(deployer, variant, salt)` twice | Second reverts | +| 48 | Deploy when variant feature not activated | Reverts | +| 49 | `initCalls` that pause `TRANSFER`, then transfer in next initCall | Transfer reverts | ### Variants | # | Scenario | Expected | |---|----------|----------| -| 49 | Deploy Asset with `decimals = 5` | Reverts | -| 50 | Update multiplier to `2e18`, check `balanceOf` for raw balance 100 | Returns 200 | -| 51 | Reuse announcement ID | Reverts with `DuplicateAnnouncementId` | -| 52 | `batchMint` where one recipient is not on allowlist | Reverts | -| 53 | Deploy Stablecoin with `currency = "usd"` | Reverts — `A`–`Z` only | +| 50 | Deploy Asset with `decimals = 5` | Reverts | +| 51 | Update multiplier to `2e18`, check `balanceOf` for raw balance 100 | Returns 200 | +| 52 | Reuse announcement ID | Reverts with `DuplicateAnnouncementId` | +| 53 | `batchMint` where one recipient is not on allowlist | Reverts | +| 54 | Deploy Stablecoin with `currency = "usd"` | Reverts — `A`–`Z` only | diff --git a/docs/specifications/b20/specification-overview.mdx b/docs/specifications/b20/specification-overview.mdx index 85c1a6f42..38eb8e3f1 100644 --- a/docs/specifications/b20/specification-overview.mdx +++ b/docs/specifications/b20/specification-overview.mdx @@ -95,13 +95,15 @@ B20 tokens store one `uint64 policyId` per supported policy scope. |---|---|---| | `TRANSFER_SENDER_POLICY` | `from` | `transfer`, `transferFrom`, and memo variants | | `TRANSFER_RECEIVER_POLICY` | `to` | `transfer`, `transferFrom`, and memo variants | -| `TRANSFER_EXECUTOR_POLICY` | `msg.sender` | `transferFrom` when `msg.sender != from` | +| `TRANSFER_EXECUTOR_POLICY` | `msg.sender` | `transfer`, `transferFrom`, and memo variants | | `MINT_RECEIVER_POLICY` | `to` | `mint`, `mintWithMemo` | | `SEIZE_EXEMPT_POLICY` | `from` | `seizeWithMemo`; an authorized holder is seize-exempt, so only unauthorized holders are seizable | | `SEIZE_RECEIVER_POLICY` | `to` | `seizeWithMemo`; destination must be authorized | All scopes default to `ALWAYS_ALLOW` at creation. `approve` and `permit` are not policy-gated. +`TRANSFER_EXECUTOR_POLICY` checks `msg.sender` on every transfer entrypoint — including `transfer` and self-`transferFrom` where `msg.sender == from`. An executor allowlist therefore gates who may initiate any transfer, not just delegated ones. Factory `initCalls` transfers bypass this check. + ## Mint `mint` and `mintWithMemo` are gated by `MINT_ROLE`, checked against `MINT_RECEIVER_POLICY`, and bounded by `supplyCap`. From 4e357ca57b466b0d0f529533db6ccd0285c77b7f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:58:09 +0000 Subject: [PATCH 2/5] chore: regenerate docs/AGENTS.md, docs/llms-full.txt, docs/llms.txt (post-commit of eccdf00f) --- docs/AGENTS.md | 4 ++-- docs/llms-full.txt | 10 ++++++---- docs/llms.txt | 10 ++++++---- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 000bd734e..36732c1ff 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -50,7 +50,7 @@ npx skills add base/base-skills |Build on Base/Accept Payments/Confirm and Reconcile:build-on-base/accept-payments/verify-a-payment,build-on-base/accept-payments/watch-for-payments,build-on-base/accept-payments/reconcile-payments |Build on Base/Accept Payments/Return and Pay Out:build-on-base/accept-payments/refund-a-payment,build-on-base/accept-payments/send-a-payout,build-on-base/accept-payments/split-a-payment |Build on Base/Accept Payments/Accept Agentic Payments:build-on-base/accept-payments/charge-for-an-api,build-on-base/accept-payments/settle-usage-based-payments,build-on-base/accept-payments/batch-high-frequency-payments,build-on-base/accept-payments/call-a-paid-service -|Specifications/Specifications:specifications/overview,specifications/native-account-abstraction,specifications/flashblocks +|Specifications/Specifications:specifications/overview,specifications/flashblocks |Specifications/Specifications/Base Protocol:specifications/base-protocol/overview,specifications/base-protocol/batcher,specifications/base-protocol/design-goals |Specifications/Specifications/Base Protocol/Consensus:specifications/base-protocol/consensus/specification,specifications/base-protocol/consensus/derivation,specifications/base-protocol/consensus/p2p,specifications/base-protocol/consensus/rpc |Specifications/Specifications/Base Protocol/Execution:specifications/base-protocol/execution/l2-execution-engine,specifications/base-protocol/execution/precompiles,specifications/base-protocol/execution/predeploys,specifications/base-protocol/execution/preinstalls @@ -73,7 +73,7 @@ npx skills add base/base-skills |SDKs & APIs/Base Verify API:sdks/base-verify/overview,sdks/base-verify/verify-social-accounts,sdks/base-verify/verify-users-onchain |SDKs & APIs/Migrated Documentation:sdks/migrated-products |Upgrades/Overview:upgrades/overview,base-chain/network-information/configuration-changelog -|Upgrades/Denim:upgrades/denim/overview,upgrades/denim/200ms-blocks,upgrades/denim/migrate-from-flashblocks +|Upgrades/Denim:upgrades/denim/overview,specifications/native-account-abstraction,upgrades/denim/200ms-blocks,upgrades/denim/migrate-from-flashblocks,base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement |Upgrades/Cobalt:upgrades/cobalt/overview,upgrades/cobalt/dynamic-upgrades,base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier,base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize,base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy,upgrades/cobalt/validity-transactions |Upgrades/Beryl:upgrades/beryl/overview,upgrades/beryl/reth-v2,upgrades/beryl/reducing-canonical-withdrawal-delay,upgrades/beryl/b20 |Upgrades/Azul:upgrades/azul/overview,upgrades/azul/node-upgrade,upgrades/azul/exec-engine,upgrades/azul/proofs diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 21f4fae66..4b2c039c4 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -174,7 +174,7 @@ const client = createPublicClient({ chain: base, transport: http() }) #### Take a Payment -- [Request a Payment](https://docs.base.org/build-on-base/accept-payments/request-a-payment): Request an immediately settled USDC or memo-enabled B20 payment from a wallet on Base. +- [Request a Payment](https://docs.base.org/build-on-base/accept-payments/request-a-payment): Request and settle a USDC or memo-enabled B20 payment from a wallet on Base. - [Authorize a Payment](https://docs.base.org/build-on-base/accept-payments/authorize-a-payment): Ask a buyer to sign a USDC authorization that your merchant backend can capture later on Base. @@ -300,8 +300,6 @@ const client = createPublicClient({ chain: base, transport: http() }) - [Changelog](https://docs.base.org/specifications/b20/changelog): Per-hardfork, per-feature migration notes for the B20 token standard, newest first, including new methods, deprecations, and activation dates. -- [Native Account Abstraction](https://docs.base.org/specifications/native-account-abstraction): EIP-8130 reference for Base: vibenet chain details, client setup, transaction structure, account configuration, authenticators, and payers. - #### Transactions - [Transaction Ordering](https://docs.base.org/specifications/transactions/transaction-ordering): Transactions are ordered based priority fee and arrival time, which determines which Flashblock they are included in. @@ -484,10 +482,14 @@ const client = createPublicClient({ chain: base, transport: http() }) - [Overview](https://docs.base.org/upgrades/denim/overview): Denim introduces native blocks at a 200ms cadence, onchain millisecond time through BaseTime, and millisecond-resolution RPC timestamps. +- [Native Account Abstraction](https://docs.base.org/specifications/native-account-abstraction): EIP-8130 reference for Base: vibenet chain details, client setup, transaction structure, account configuration, authenticators, and payers. + - [200ms Native Blocks](https://docs.base.org/upgrades/denim/200ms-blocks): Specification for Denim's canonical 200ms blocks, including BaseTime, derivation, validation, and RPC behavior. - [Migrate From Flashblocks](https://docs.base.org/upgrades/denim/migrate-from-flashblocks): Migrate your Flashblocks integration to 200ms blocks. +- [Transfer Executor Policy Enforcement](https://docs.base.org/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement): TRANSFER_EXECUTOR_POLICY now gates every transfer path — including direct transfer and self-transferFrom — closing two initiator bypasses introduced in the Denim hardfork. + ### Cobalt - [Overview](https://docs.base.org/upgrades/cobalt/overview): Cobalt improves the B20 token standard, adds validity transactions, introduces dynamic node upgrades, and migrates Base to a Trusted Execution Environment. @@ -500,7 +502,7 @@ const client = createPublicClient({ chain: base, transport: http() }) - [PolicyRegistry: Composite Policies (UNION / INTERSECT)](https://docs.base.org/base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy): Cobalt adds UNION and INTERSECT composite policies to PolicyRegistry so B20 integrations can combine simple authorization policies without flattening their member lists. -- [Validity Transactions](https://docs.base.org/upgrades/cobalt/validity-transactions): Validity transactions ship with the Cobalt upgrade. Read the full specification in the Specifications tab. +- [Validity Transactions](https://docs.base.org/upgrades/cobalt/validity-transactions): Validity transactions ship with the Cobalt upgrade. ### Beryl diff --git a/docs/llms.txt b/docs/llms.txt index d9a6f2e19..db7fac88c 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -108,7 +108,7 @@ #### Take a Payment -- [Request a Payment](https://docs.base.org/build-on-base/accept-payments/request-a-payment): Request an immediately settled USDC or memo-enabled B20 payment from a wallet on Base. +- [Request a Payment](https://docs.base.org/build-on-base/accept-payments/request-a-payment): Request and settle a USDC or memo-enabled B20 payment from a wallet on Base. - [Authorize a Payment](https://docs.base.org/build-on-base/accept-payments/authorize-a-payment): Ask a buyer to sign a USDC authorization that your merchant backend can capture later on Base. @@ -234,8 +234,6 @@ - [Changelog](https://docs.base.org/specifications/b20/changelog): Per-hardfork, per-feature migration notes for the B20 token standard, newest first, including new methods, deprecations, and activation dates. -- [Native Account Abstraction](https://docs.base.org/specifications/native-account-abstraction): EIP-8130 reference for Base: vibenet chain details, client setup, transaction structure, account configuration, authenticators, and payers. - #### Transactions - [Transaction Ordering](https://docs.base.org/specifications/transactions/transaction-ordering): Transactions are ordered based priority fee and arrival time, which determines which Flashblock they are included in. @@ -418,10 +416,14 @@ - [Overview](https://docs.base.org/upgrades/denim/overview): Denim introduces native blocks at a 200ms cadence, onchain millisecond time through BaseTime, and millisecond-resolution RPC timestamps. +- [Native Account Abstraction](https://docs.base.org/specifications/native-account-abstraction): EIP-8130 reference for Base: vibenet chain details, client setup, transaction structure, account configuration, authenticators, and payers. + - [200ms Native Blocks](https://docs.base.org/upgrades/denim/200ms-blocks): Specification for Denim's canonical 200ms blocks, including BaseTime, derivation, validation, and RPC behavior. - [Migrate From Flashblocks](https://docs.base.org/upgrades/denim/migrate-from-flashblocks): Migrate your Flashblocks integration to 200ms blocks. +- [Transfer Executor Policy Enforcement](https://docs.base.org/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement): TRANSFER_EXECUTOR_POLICY now gates every transfer path — including direct transfer and self-transferFrom — closing two initiator bypasses introduced in the Denim hardfork. + ### Cobalt - [Overview](https://docs.base.org/upgrades/cobalt/overview): Cobalt improves the B20 token standard, adds validity transactions, introduces dynamic node upgrades, and migrates Base to a Trusted Execution Environment. @@ -434,7 +436,7 @@ - [PolicyRegistry: Composite Policies (UNION / INTERSECT)](https://docs.base.org/base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy): Cobalt adds UNION and INTERSECT composite policies to PolicyRegistry so B20 integrations can combine simple authorization policies without flattening their member lists. -- [Validity Transactions](https://docs.base.org/upgrades/cobalt/validity-transactions): Validity transactions ship with the Cobalt upgrade. Read the full specification in the Specifications tab. +- [Validity Transactions](https://docs.base.org/upgrades/cobalt/validity-transactions): Validity transactions ship with the Cobalt upgrade. ### Beryl From c4a8685b309442f5f52f17244b57266f2d86dbf8 Mon Sep 17 00:00:00 2001 From: sohey Date: Wed, 16 Sep 2026 18:50:17 +0200 Subject: [PATCH 3/5] docs: remove Build on Base policy updates --- .../build-on-base/accept-payments/refund-a-payment.mdx | 4 ---- .../accept-payments/request-a-payment.mdx | 4 ++-- docs/build-on-base/accept-payments/send-a-payout.mdx | 4 ---- docs/build-on-base/accept-payments/split-a-payment.mdx | 2 -- .../build-on-base/accept-payments/verify-a-payment.mdx | 4 ---- .../issue-rwa/announce-a-distribution.mdx | 4 ---- docs/build-on-base/issue-rwa/pause-transfers.mdx | 10 ---------- .../build-on-base/issue-stablecoins/pause-activity.mdx | 4 ---- .../issue-stablecoins/reconcile-with-memos.mdx | 4 ---- .../issue-stablecoins/restrict-who-can-hold.mdx | 6 +----- 10 files changed, 3 insertions(+), 43 deletions(-) diff --git a/docs/build-on-base/accept-payments/refund-a-payment.mdx b/docs/build-on-base/accept-payments/refund-a-payment.mdx index f3d8d2a60..5846c8eae 100644 --- a/docs/build-on-base/accept-payments/refund-a-payment.mdx +++ b/docs/build-on-base/accept-payments/refund-a-payment.mdx @@ -67,10 +67,6 @@ For plain USDC, call `transfer` instead and record the original order ID, captur `reserveOnce` must atomically create a pending refund and reduce the available refundable balance before broadcasting. If the worker loses the receipt, reconcile that pending record from chain data instead of releasing it and risking a duplicate transfer. - -B20 tokens with a restrictive `TRANSFER_EXECUTOR_POLICY` now apply that policy to every transfer call — including direct `transfer` and `transferWithMemo` — not only delegated `transferFrom` paths. If your merchant account is not authorized as an executor on the token, the refund transfer will revert. Confirm your account is on the token's executor allowlist before broadcasting a refund. - - The refund transfer goes to the payer from the original receipt, and the durable refund ledger reduces the remaining refundable amount. diff --git a/docs/build-on-base/accept-payments/request-a-payment.mdx b/docs/build-on-base/accept-payments/request-a-payment.mdx index aee1e3ccd..e78a34c7b 100644 --- a/docs/build-on-base/accept-payments/request-a-payment.mdx +++ b/docs/build-on-base/accept-payments/request-a-payment.mdx @@ -1,7 +1,7 @@ --- title: "Request a Payment" keywords: ["request USDC payment", "wallet USDC checkout", "B20 payment memo", "onchain checkout Base"] -description: "Request and settle a USDC or memo-enabled B20 payment from a wallet on Base." +description: "Request an immediately settled USDC or memo-enabled B20 payment from a wallet on Base." --- import { PaymentsDemo } from "/snippets/PaymentsDemo.jsx" @@ -100,7 +100,7 @@ function pay(bytes32 orderId, uint256 amount) external {
- `TRANSFER_EXECUTOR_POLICY` now applies to every transfer entrypoint — including `transfer`, `transferFrom`, and their memo variants — even when `msg.sender == from`. If the token issuer has configured an executor allowlist, both direct wallet transfers and checkout-contract pulls must be authorized. Ensure your checkout contract address and any holder-initiated transfers are covered before deployment. + If the Solidity checkout calls `transferFromWithMemo`, the payer must approve it and any `TRANSFER_EXECUTOR_POLICY` must authorize the checkout contract. The merchant must still verify the expected amount before fulfillment. diff --git a/docs/build-on-base/accept-payments/send-a-payout.mdx b/docs/build-on-base/accept-payments/send-a-payout.mdx index 3e67ace91..31cb49cc4 100644 --- a/docs/build-on-base/accept-payments/send-a-payout.mdx +++ b/docs/build-on-base/accept-payments/send-a-payout.mdx @@ -65,10 +65,6 @@ The transaction emits one `PayoutSent` event per recipient under the same `batch Cap batch size from measured gas usage and keep the contract's `MAX_RECIPIENTS` bound. A single failed token transfer reverts the entire batch. - -B20 tokens enforce `TRANSFER_EXECUTOR_POLICY` on every transfer path — including `transferFrom` calls where `msg.sender == from`. If the token has a restrictive executor policy, your payout contract's address must be authorized as an executor, or all transfers will revert with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ...)`. - - Do not approve the public `Multicall3` contract as an ERC-20 spender. Downstream token calls see Multicall3 as `msg.sender`, and any caller can ask that general-purpose contract to invoke `transferFrom` against an allowance you grant it. Use a purpose-built contract that fixes who can initiate payouts and how recipients are selected. ## See Also diff --git a/docs/build-on-base/accept-payments/split-a-payment.mdx b/docs/build-on-base/accept-payments/split-a-payment.mdx index 9d6693f8f..c230c9e07 100644 --- a/docs/build-on-base/accept-payments/split-a-payment.mdx +++ b/docs/build-on-base/accept-payments/split-a-payment.mdx @@ -83,8 +83,6 @@ All shares sum to the input amount exactly, including the rounding remainder, an -If the B20 token used for splitting has a restrictive `TRANSFER_EXECUTOR_POLICY`, the contract calling `transferFrom` must be authorized as an executor — including when `msg.sender == from`. Configure the executor policy accordingly before routing splits through a payout contract. - Define who receives rounding remainder in your commercial terms. Also approve only the amount needed for the split and verify recipient addresses before submitting the transaction. diff --git a/docs/build-on-base/accept-payments/verify-a-payment.mdx b/docs/build-on-base/accept-payments/verify-a-payment.mdx index 592d8ddbf..9a5435a5d 100644 --- a/docs/build-on-base/accept-payments/verify-a-payment.mdx +++ b/docs/build-on-base/accept-payments/verify-a-payment.mdx @@ -73,10 +73,6 @@ Direct `transfer`, EIP-3009 capture, and `transferFrom` after a permit all emit Choose a confirmation depth that matches the value and reversibility of fulfillment. See [transaction finality](/specifications/transactions/transaction-finality) for Base's confirmation stages. - -B20 tokens may enforce a `TRANSFER_EXECUTOR_POLICY` that restricts which addresses may initiate transfers — including direct `transfer` calls and `transferFrom` calls where `msg.sender == from`. If your settlement contract initiates B20 transfers on behalf of payers, ensure it is authorized as an executor by the token issuer. - - ## See Also diff --git a/docs/build-on-base/issue-rwa/announce-a-distribution.mdx b/docs/build-on-base/issue-rwa/announce-a-distribution.mdx index be1eb05da..5e564b4dd 100644 --- a/docs/build-on-base/issue-rwa/announce-a-distribution.mdx +++ b/docs/build-on-base/issue-rwa/announce-a-distribution.mdx @@ -152,10 +152,6 @@ IB20Asset(token).announce(new bytes[](0), id, description, uri); Typical inner causes of `InternalCallFailed`: missing `MINT_ROLE` or `BURN_ROLE`, paused `MINT` or `BURN`, `UIMultiplierUpdateExists`, `PolicyForbids`, `SupplyCapExceeded`, `InsufficientBalance`. A Solidity `Panic` (for example overflow) propagates raw and is not wrapped as `InternalCallFailed`. - -`TRANSFER_EXECUTOR_POLICY` now applies to every transfer path — including `transfer`, `transferFrom`, and their memo variants — even when `msg.sender == from`. If an inner call triggers a transfer and you have a restrictive executor policy configured, ensure the announcing operator is authorized as an executor. - - ## See Also diff --git a/docs/build-on-base/issue-rwa/pause-transfers.mdx b/docs/build-on-base/issue-rwa/pause-transfers.mdx index 641d6b6b7..4591c73c9 100644 --- a/docs/build-on-base/issue-rwa/pause-transfers.mdx +++ b/docs/build-on-base/issue-rwa/pause-transfers.mdx @@ -63,16 +63,6 @@ Transfer pause state changes without pausing mint or burn. `pause` requires `PAUSE_ROLE`. `unpause` requires `UNPAUSE_ROLE`. These are separate roles, so grant recovery authority more narrowly than emergency pause authority. -## Executor Policy and Transfer Initiation - -`TRANSFER_EXECUTOR_POLICY` now applies to **every** transfer path — `transfer`, `transferFrom`, and their memo variants — including when `msg.sender == from`. If your token has configured a restrictive executor policy, holders moving their own tokens (via direct `transfer` or self-`transferFrom`) must also be authorized as initiators. - - -If you have set a restrictive `TRANSFER_EXECUTOR_POLICY`, holders who are not on the executor allowlist can no longer initiate transfers themselves. This closes the previous bypasses where a direct `transfer` call or a self-`transferFrom` call skipped the executor check. Review your executor policy configuration before deploying tokens that rely on holder-initiated transfers. - - -Tokens that have never configured `TRANSFER_EXECUTOR_POLICY` are unaffected — an unset executor slot is always-allow. - ## See Also diff --git a/docs/build-on-base/issue-stablecoins/pause-activity.mdx b/docs/build-on-base/issue-stablecoins/pause-activity.mdx index 94047e235..0695effab 100644 --- a/docs/build-on-base/issue-stablecoins/pause-activity.mdx +++ b/docs/build-on-base/issue-stablecoins/pause-activity.mdx @@ -74,10 +74,6 @@ See the [B20 token standard](/specifications/b20/specification-overview) for the When a paused feature blocks a call, the transaction reverts `ContractPaused(feature)`. The error names only the one feature that blocked the call. - -Pausing `TRANSFER` stops all transfer entrypoints. Even with `TRANSFER` unpaused, the `TRANSFER_EXECUTOR_POLICY` now applies to **every** transfer path — including direct `transfer` calls and `transferFrom` when `msg.sender == from`. If you use an executor allowlist to restrict who may initiate transfers, holders are subject to that check regardless of which entrypoint they use. - - ## See Also diff --git a/docs/build-on-base/issue-stablecoins/reconcile-with-memos.mdx b/docs/build-on-base/issue-stablecoins/reconcile-with-memos.mdx index 5c244b760..739e39553 100644 --- a/docs/build-on-base/issue-stablecoins/reconcile-with-memos.mdx +++ b/docs/build-on-base/issue-stablecoins/reconcile-with-memos.mdx @@ -62,10 +62,6 @@ The receipt contains `Transfer` followed immediately by `Memo`, with `invoice-88 Join a memo to the preceding operation with `(transactionHash, logIndex - 1)`. Keep the offchain invoice ID unique. - -`TRANSFER_EXECUTOR_POLICY` now applies to `transferWithMemo` and `transferFromWithMemo` (and all other transfer entrypoints), including when `msg.sender` is the token holder. If your token has a restrictive executor policy set, callers must be authorized as initiators to use memo transfers. - - ## See Also diff --git a/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx b/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx index 9052f1488..9e1a67a3a 100644 --- a/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx +++ b/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx @@ -24,14 +24,10 @@ New to B20? See the [B20 Token Standard](/specifications/b20/specification-overv The Policy Registry is a singleton precompile that stores each member list once. A token stores only a `uint64` policy ID per scope. When a gated function runs, the token calls `isAuthorized(policyId, account)` on the registry. Many tokens can share one policy; an update to the policy is immediately visible to every token that references it. -Scopes gate specific functions. `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY` check the sender and recipient on every `transfer` and `transferFrom`. `TRANSFER_EXECUTOR_POLICY` checks `msg.sender` (the initiator) on every `transfer`, `transferFrom`, and their memo variants — including when `msg.sender == from`. `MINT_RECEIVER_POLICY` checks the recipient on every `mint`. All four default to `ALWAYS_ALLOW` (`0`) until you bind a policy. +Scopes gate specific functions. `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY` check the sender and recipient on every `transfer` and `transferFrom`. `MINT_RECEIVER_POLICY` checks the recipient on every `mint`. All three default to `ALWAYS_ALLOW` (`0`) until you bind a policy. An **allowlist** authorizes only accounts in the set. An empty allowlist authorizes nobody, so seed your intended holders before binding the policy. - -`TRANSFER_EXECUTOR_POLICY` now applies to all transfer entrypoints, including direct `transfer` calls where `msg.sender == from`. If you have set a restrictive executor policy, holders who are not on the allowlist can no longer move their own tokens via `transfer` or self-`transferFrom`. Add all authorized initiators to the policy before restricting this scope. - - ## Create and Bind a Holder Allowlist From 7191900cfcbfb192d5de6804468ae07c039f5419 Mon Sep 17 00:00:00 2001 From: sohey Date: Wed, 16 Sep 2026 18:59:10 +0200 Subject: [PATCH 4/5] docs: remove transfer executor changelog --- .../02-cobalt-b20asset-multiplier.mdx | 9 - ...enim-b20-transfer-executor-enforcement.mdx | 158 ------------------ docs/docs.json | 3 +- 3 files changed, 1 insertion(+), 169 deletions(-) delete mode 100644 docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx diff --git a/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier.mdx b/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier.mdx index bbcea21b5..987ab3ca7 100644 --- a/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier.mdx +++ b/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier.mdx @@ -145,12 +145,3 @@ read the ceiling from `MAX_UI_MULTIPLIER()` without risking the revert. No. The multiplier is purely cosmetic: it rescales only the UI/scaled view. `balanceOf`, `transfer`, `totalSupply`, and `Transfer` stay raw, and no multiplier change, scheduled or instant, affects them. Only the `*UI` / scaled reads move. - -**Q: Does `TRANSFER_EXECUTOR_POLICY` apply to `transfer` and self-`transferFrom`?** -Yes, as of the Cobalt-era `IB20` update. `TRANSFER_EXECUTOR_POLICY` is checked against -`msg.sender` on every transfer path — `transfer`, `transferFrom`, `transferWithMemo`, and -`transferFromWithMemo` — including when `msg.sender == from`. There is no carve-out for the holder -initiating their own transfer. If you have configured a restrictive executor policy and rely on -holders moving their own tokens via `transfer` or self-`transferFrom`, those holders must now be -authorized as initiators. Tokens that have never configured `TRANSFER_EXECUTOR_POLICY` are -unaffected (the unset slot is always-allow). diff --git a/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx b/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx deleted file mode 100644 index 7f0bc8486..000000000 --- a/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: "Transfer Executor Policy Enforcement" -description: "TRANSFER_EXECUTOR_POLICY now gates every transfer path — including direct transfer and self-transferFrom — closing two initiator bypasses introduced in the Denim hardfork." ---- - -## Abstract - -The Denim hardfork extends `TRANSFER_EXECUTOR_POLICY` to cover 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 delegated `transferFrom` paths, and only when `msg.sender != from`. - -This is a purely behavioral change — no new selectors, events, errors, or storage. Tokens that never set `TRANSFER_EXECUTOR_POLICY` keep the always-allow default and are unaffected. Tokens that already set a restrictive executor policy and relied on either bypass must authorize affected holders before this change activates. - -## Motivation - -An issuer of a restricted security token may need every transfer to go through a registered transfer agent. Only the transfer agent's contract may initiate a move; a holder cannot call `transfer` themselves even to an already-eligible counterparty. The holder approves the transfer agent, and the transfer agent calls `transferFrom`. `TRANSFER_EXECUTOR_POLICY` is the initiator allowlist for that pattern. - -The previous scope could not enforce this consistently with `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY`, which already run on every transfer path. Two initiator-side gaps remained: - -1. **`transfer` was never gated.** The initiator of `transfer` is `msg.sender`, which is also `from`, but the executor 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 without being on the executor allowlist. - -Both gaps let a non-allowlisted holder move tokens by choosing a different entrypoint. 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. - -## What Changed - -### Check location and scope - -The executor check moves from the `transferFrom` and `transferFromWithMemo` bodies into `_transfer`, where it runs first — before the existing sender and receiver checks — under the same `_isPrivileged()` bootstrap bypass. The `msg.sender == from` carve-out that previously skipped the check is removed. Because `transfer` and `transferWithMemo` already route through `_transfer`, they gain the executor check with no entrypoint-specific code. - -Pause, zero-actor, and allowance checks remain in the entrypoints. Allowance is still consumed in `transferFrom` / `transferFromWithMemo` bodies before reaching `_transfer`, so revert order is unchanged for allowance failures. - -### Check order: before vs. after - -**Before** — 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 -``` - -**After** — shared `_transfer` helper handles all three policy checks: - -```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 -``` - -Canonical check order after this change: - -- `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. - -When more than one check would fail, the caller sees the first revert in that order. - -### Gas - -`_transfer` reads all three transfer-side policy IDs from the existing packed slot in a single `SLOAD`. On `transferFrom` and `transferFromWithMemo`, the previous implementation read the executor lane in the entrypoint body and then re-read the same packed slot in `_transfer` (warm); the helper now performs the only `SLOAD`. - -On `transfer` and `transferWithMemo`, the paths previously did not consult the executor policy. Those paths now check `msg.sender` under `TRANSFER_EXECUTOR_POLICY`. When `TRANSFER_EXECUTOR_POLICY` and `TRANSFER_SENDER_POLICY` share the same policy ID — including both slots unset (`ALWAYS_ALLOW_ID`) — `_transfer` reuses the executor result and skips the redundant `isAuthorized` call for the sender. An unset executor slot remains `ALWAYS_ALLOW_ID` (`0`), so a default `transfer` still makes two `isAuthorized` calls (one reused for executor + sender, one for receiver). - -### Examples - -A holder moving their own tokens is now gated by the executor policy even through direct `transfer`: - -```solidity Title="Executor policy blocks direct transfer" -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 (e.g., a transfer agent). A holder who is not allowlisted cannot bypass it by calling `transfer` or self-`transferFrom`: - -```solidity Title="Executor allowlist: approved vs. non-approved initiators" -uint64 executorAllowlist = policyRegistry.createPolicyWithAccounts(admin, ALLOWLIST, [transferAgent]); -token.updatePolicy(TRANSFER_EXECUTOR_POLICY, executorAllowlist); - -vm.prank(transferAgent); -token.transferFrom(alice, bob, amount); // succeeds: transferAgent 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 Title="Bootstrap window bypasses executor check" -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 -``` - -## Migration - -**Not breaking** for tokens that never configured `TRANSFER_EXECUTOR_POLICY`. The unset policy slot stays always-allow, and the factory bootstrap bypass is unchanged. - -**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` to keep doing so. -2. If holders were relying on `msg.sender == from` to skip the executor 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. - -## Alternatives Considered - -### Keep the check in `transferFrom` only; add it to `transfer` separately - -This would add a matching executor check to `transfer` while leaving the existing `transferFrom` check — including the `msg.sender != from` carve-out — in place. 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)`. It also duplicates the check across two entrypoints rather than centralizing it in `_transfer`. - -### Fold pause, zero-actor, and allowance into `_transfer` - -This option would move every remaining transfer-family check into the helper. Rejected because allowance is entrypoint-specific. Folding it in would require a consume-allowance flag, and moving zero-actor checks after allowance would change revert order. This change only relocates the executor policy check. - -## Test Cases - -Key scenarios pinned by the updated test suite: - -- **Executor sentinel on `transfer`** — `ALWAYS_BLOCK_ID` as executor policy reverts `transfer` with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, ALWAYS_BLOCK_ID)`. -- **External allowlist on `transfer`** — only allowlisted `msg.sender` can call `transfer`; non-allowlisted holders revert. -- **Privileged bypass** — factory bootstrap window passes executor check regardless of policy. -- **Memo parity** — `transferWithMemo` and `transferFromWithMemo` behave identically to their non-memo counterparts under the executor policy. -- **Closed self-caller loophole** — `transferFrom(self, to, amount)` from a non-allowlisted `msg.sender` now reverts (`test_transferFrom_revert_selfCaller_executorPolicyForbids`). -- **Revert order** — all 21 pairs of check combinations across `transfer`, `transferFrom`, and memo variants confirm the canonical order: executor → sender → receiver → balance. diff --git a/docs/docs.json b/docs/docs.json index f2cd2ddc5..06fb01476 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -435,8 +435,7 @@ "upgrades/denim/overview", "specifications/native-account-abstraction", "upgrades/denim/200ms-blocks", - "upgrades/denim/migrate-from-flashblocks", - "base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement" + "upgrades/denim/migrate-from-flashblocks" ] }, { From 768d94fc166ac7496c1a37388cc07c2615ed1c0b Mon Sep 17 00:00:00 2001 From: sohey Date: Wed, 16 Sep 2026 18:59:10 +0200 Subject: [PATCH 5/5] chore: regenerate docs/AGENTS.md, docs/llms-full.txt, docs/llms.txt (post-commit of 7191900c) --- docs/AGENTS.md | 2 +- docs/llms-full.txt | 4 +--- docs/llms.txt | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 36732c1ff..9a1a7a8f7 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -73,7 +73,7 @@ npx skills add base/base-skills |SDKs & APIs/Base Verify API:sdks/base-verify/overview,sdks/base-verify/verify-social-accounts,sdks/base-verify/verify-users-onchain |SDKs & APIs/Migrated Documentation:sdks/migrated-products |Upgrades/Overview:upgrades/overview,base-chain/network-information/configuration-changelog -|Upgrades/Denim:upgrades/denim/overview,specifications/native-account-abstraction,upgrades/denim/200ms-blocks,upgrades/denim/migrate-from-flashblocks,base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement +|Upgrades/Denim:upgrades/denim/overview,specifications/native-account-abstraction,upgrades/denim/200ms-blocks,upgrades/denim/migrate-from-flashblocks |Upgrades/Cobalt:upgrades/cobalt/overview,upgrades/cobalt/dynamic-upgrades,base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier,base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize,base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy,upgrades/cobalt/validity-transactions |Upgrades/Beryl:upgrades/beryl/overview,upgrades/beryl/reth-v2,upgrades/beryl/reducing-canonical-withdrawal-delay,upgrades/beryl/b20 |Upgrades/Azul:upgrades/azul/overview,upgrades/azul/node-upgrade,upgrades/azul/exec-engine,upgrades/azul/proofs diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 4b2c039c4..ffe83016d 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -174,7 +174,7 @@ const client = createPublicClient({ chain: base, transport: http() }) #### Take a Payment -- [Request a Payment](https://docs.base.org/build-on-base/accept-payments/request-a-payment): Request and settle a USDC or memo-enabled B20 payment from a wallet on Base. +- [Request a Payment](https://docs.base.org/build-on-base/accept-payments/request-a-payment): Request an immediately settled USDC or memo-enabled B20 payment from a wallet on Base. - [Authorize a Payment](https://docs.base.org/build-on-base/accept-payments/authorize-a-payment): Ask a buyer to sign a USDC authorization that your merchant backend can capture later on Base. @@ -488,8 +488,6 @@ const client = createPublicClient({ chain: base, transport: http() }) - [Migrate From Flashblocks](https://docs.base.org/upgrades/denim/migrate-from-flashblocks): Migrate your Flashblocks integration to 200ms blocks. -- [Transfer Executor Policy Enforcement](https://docs.base.org/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement): TRANSFER_EXECUTOR_POLICY now gates every transfer path — including direct transfer and self-transferFrom — closing two initiator bypasses introduced in the Denim hardfork. - ### Cobalt - [Overview](https://docs.base.org/upgrades/cobalt/overview): Cobalt improves the B20 token standard, adds validity transactions, introduces dynamic node upgrades, and migrates Base to a Trusted Execution Environment. diff --git a/docs/llms.txt b/docs/llms.txt index db7fac88c..ca4d49d96 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -108,7 +108,7 @@ #### Take a Payment -- [Request a Payment](https://docs.base.org/build-on-base/accept-payments/request-a-payment): Request and settle a USDC or memo-enabled B20 payment from a wallet on Base. +- [Request a Payment](https://docs.base.org/build-on-base/accept-payments/request-a-payment): Request an immediately settled USDC or memo-enabled B20 payment from a wallet on Base. - [Authorize a Payment](https://docs.base.org/build-on-base/accept-payments/authorize-a-payment): Ask a buyer to sign a USDC authorization that your merchant backend can capture later on Base. @@ -422,8 +422,6 @@ - [Migrate From Flashblocks](https://docs.base.org/upgrades/denim/migrate-from-flashblocks): Migrate your Flashblocks integration to 200ms blocks. -- [Transfer Executor Policy Enforcement](https://docs.base.org/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement): TRANSFER_EXECUTOR_POLICY now gates every transfer path — including direct transfer and self-transferFrom — closing two initiator bypasses introduced in the Denim hardfork. - ### Cobalt - [Overview](https://docs.base.org/upgrades/cobalt/overview): Cobalt improves the B20 token standard, adds validity transactions, introduces dynamic node upgrades, and migrates Base to a Trusted Execution Environment.