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.