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