diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index ee0c2c875..919bb44de 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -77,7 +77,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,upgrades/denim/200ms-blocks,upgrades/denim/migrate-from-flashblocks,base-chain/specs/reference/b20/changelog/03-denim-b20-token-receiver,base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement,base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy
|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/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize.mdx b/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize.mdx
index 4c632817f..5c12f9649 100644
--- a/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize.mdx
+++ b/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize.mdx
@@ -1,5 +1,5 @@
---
-title: "B20: Seize Surface and burnBlocked Deprecation"
+title: "B20: Seize Functionality"
description: "The B20 seize surface at Cobalt and the deprecation of burnBlocked. Migration notes for teams integrated against the Beryl seize path."
---
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..19b40c7a1 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
@@ -1,5 +1,5 @@
---
-title: "B20: Beryl to Cobalt Migration"
+title: "B20: ERC-8056 Conformant Multiplier"
description: "B20 Asset multiplier becomes ERC-8056 conformant at Cobalt, with a scheduled multiplier setter for corporate actions. Every Beryl selector stays dialable."
---
@@ -13,9 +13,11 @@ conformant and gains a scheduled multiplier setter for corporate actions. Nothin
breaks: every Beryl selector, event topic, and error keeps its exact 4-byte selector or topic0 and
stays dialable at Cobalt. The deprecations below are advisory, not enforced.
-To migrate, adopt the canonical ERC-8056 names (`uiMultiplier`, `toUIAmount`/`fromUIAmount`,
-`balanceOfUI`, `totalSupplyUI`), and move routine multiplier changes from the instant
-`updateMultiplier(uint256)` to the scheduled `updateUIMultiplier(uint256,uint256)`.
+To migrate, adopt the ERC-8056 names (`uiMultiplier`, `toUIAmount`/`fromUIAmount`, `balanceOfUI`,
+`totalSupplyUI`), and move routine multiplier changes from the instant `updateMultiplier(uint256)` to
+the scheduled `updateUIMultiplier(uint256,uint256)`. `multiplier()` and `scaledBalanceOf(address)`
+remain the canonical B20 names; `uiMultiplier()` and `balanceOfUI(address)` are their ERC-8056
+aliases.
Cobalt hasn't gone live yet. Until it activates, only the Beryl surface exists on-chain.
@@ -26,12 +28,12 @@ The selectors and topic0s below are the real values from the frozen ABIs: `abi/v
### Functions
-| Beryl symbol (selector) | Cobalt canonical (selector) | Status | Why |
+| Beryl symbol (selector) | Cobalt ERC-8056 symbol (selector) | Status | Why |
| --- | --- | --- | --- |
-| `multiplier()` `0x1b3ed722` | `uiMultiplier()` `0xa60bf13d` | deprecated-name-kept / new alias | ERC-8056 core naming. Both return the same effective multiplier. `multiplier()` stays. |
+| `multiplier()` `0x1b3ed722` | `uiMultiplier()` `0xa60bf13d` | unchanged (canonical name) / new alias | ERC-8056 core naming. Both return the same effective multiplier. |
| `toScaledBalance(uint256)` `0x04f04c99` | `toUIAmount(uint256)` `0x3248d4ff` | deprecated-dialable / new | ERC-8056 Conversion extension. Byte-identical behavior. |
| `toRawBalance(uint256)` `0x0ca06c44` | `fromUIAmount(uint256)` `0x65cd9b3c` | deprecated-dialable / new | ERC-8056 Conversion extension. Byte-identical behavior. |
-| `scaledBalanceOf(address)` `0x1da24f3e` | `balanceOfUI(address)` `0x437a9958` | deprecated-name-kept / new alias | ERC-8056 Balances extension. Alias, same value. |
+| `scaledBalanceOf(address)` `0x1da24f3e` | `balanceOfUI(address)` `0x437a9958` | unchanged (canonical name) / new alias | ERC-8056 Balances extension. Alias, same value. |
| `updateMultiplier(uint256)` `0x5ffe6146` | `updateUIMultiplier(uint256,uint256)` `0x628e600f` | deprecated-dialable / new (not 1:1) | The canonical path is now the scheduled setter. The instant setter remains as an emergency failsafe. |
| — | `newUIMultiplier()` `0xdc767007` | new | ERC-8056 pending-schedule read. |
| — | `effectiveAt()` `0x97a4064f` | new | ERC-8056 pending-schedule read (flip timestamp). |
diff --git a/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-token-receiver.mdx b/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-token-receiver.mdx
new file mode 100644
index 000000000..27dd2b6be
--- /dev/null
+++ b/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-token-receiver.mdx
@@ -0,0 +1,132 @@
+---
+title: "B20: Reject the Token Itself as a Credit Recipient"
+description: "Denim reverts InvalidReceiver when a transfer, mint, or seize credits the B20 token's own address, so a mistyped recipient fails instead of locking funds."
+---
+
+## Abstract
+
+[Denim](/upgrades/denim/overview) adds `address(this)` as a second trigger of the existing
+`InvalidReceiver(address receiver)` error. `transfer`, `transferFrom`, their memo variants, `mint`,
+`mintWithMemo`, `batchMint`, and `seizeWithMemo` revert `InvalidReceiver(to)` when `to` is the
+token's own address. The guard covers the `IB20` transfer, mint, and seize paths plus
+`IB20Asset.batchMint`, so it applies to both B20 Asset and B20 Stablecoin.
+
+A holder sending to themselves (`from == to`) still succeeds. An issuer can still recover tokens
+already credited to the token address: `seizeWithMemo` with `from == address(token)` succeeds.
+
+This change is breaking for any integration that sends to the token address today. It adds no new
+functions, events, errors, or selectors.
+
+## Motivation
+
+Users often paste the token address instead of the recipient's address. A B20 token is a precompile
+with no holder key, so it cannot call `transfer` on itself. After a credit lands at the token
+address, the sender cannot recover it; only the issuer can, through `seizeWithMemo`.
+
+There is no valid use case for a B20 token to hold its own tokens. Denim reverts on that destination
+so the mistaken send fails instead of locking the funds.
+
+## What Changed
+
+### Receiver Guard
+
+`InvalidReceiver` already fires for `address(0)` (ERC-6093). Denim extends the same guard, at the
+same position in the revert order:
+
+```solidity Before (Cobalt)
+if (to == address(0)) revert InvalidReceiver(to);
+```
+
+```solidity After (Denim)
+if (to == address(0) || to == address(this)) revert InvalidReceiver(to);
+```
+
+| Symbol | Selector | Status | Behavior |
+| --- | --- | --- | --- |
+| `InvalidReceiver(address)` | `0x9cfea583` | Extended | Now also fires when `to == address(this)`. |
+
+### Revert Order
+
+The check runs at the existing invalid-receiver step. Canonical order is otherwise unchanged:
+
+| Function | Check order |
+| --- | --- |
+| `transfer` / `transferWithMemo` | pause → **invalid-receiver** → zero-sender → executor policy → sender policy → receiver policy → balance |
+| `transferFrom` / `transferFromWithMemo` | pause → **invalid-receiver** → zero-sender → allowance → executor policy → sender policy → receiver policy → balance |
+| `mint` / `mintWithMemo` | pause → role → **invalid-receiver** → mint-receiver policy → supply cap |
+| `batchMint` | pause → role → length / empty → per-element **invalid-receiver** → `_mint` body |
+| `seizeWithMemo` | pause → role → **invalid-receiver** → zero-sender → self-seize (`from == to`) → seizable → seize-receiver policy → balance |
+
+The guard checks `to` only. `from` may equal `address(this)`, so a seize that drains the token
+address into a treasury still succeeds.
+
+## Examples
+
+A holder transfer to the token reverts:
+
+```solidity Transfer to Token Address
+vm.prank(alice);
+token.transfer({to: address(token), amount: amount}); // reverts InvalidReceiver(address(token))
+```
+
+Mint and seize to the token address revert the same way:
+
+```solidity Mint or Seize to Token Address lines expandable wrap
+token.mint({to: address(token), amount: amount}); // reverts InvalidReceiver(address(token))
+
+token.seizeWithMemo({
+ from: alice, to: address(token), amount: amount, memo: memo
+}); // reverts InvalidReceiver(address(token))
+```
+
+A self-send and a recovery seize both still succeed:
+
+```solidity Unaffected Paths lines expandable wrap
+vm.prank(alice);
+token.transfer({to: alice, amount: amount}); // succeeds; balance and totalSupply unchanged
+
+token.seizeWithMemo({
+ from: address(token), to: treasury, amount: amount, memo: memo
+}); // succeeds
+```
+
+## Design Decisions and Alternatives Considered
+
+Denim reuses `InvalidReceiver` and compares `to` against `address(this)` on every credit path. The
+destination is invalid for the same reason `address(0)` is: no holder can spend the credited units.
+Wallets that already treat `InvalidReceiver` as "do not send here" keep the same revert handling.
+
+### Reject Any B20-Prefix Address
+
+A prefix check cannot tell a B20 token from a user-controlled account in the same address space,
+such as a multisig. Rejecting the whole prefix would revert valid transfers, so Denim checks
+`address(this)` only. Sends to other B20 tokens still succeed.
+
+### Call `isB20Initialized(to)`
+
+This would reject only live tokens, but it adds a factory call on every credit path.
+
+### New `SelfSend(address)` Error
+
+A dedicated error would read more clearly in traces, but it adds ABI surface for a condition
+`InvalidReceiver` already describes.
+
+### Also Reject `from == address(this)`
+
+Blocking spends from the token address would close the only recovery path for balances already
+sitting there.
+
+## Migration
+
+1. Treat the token's own address as an invalid recipient in wallets, custodians, and indexers, the
+ same way you treat `address(0)`.
+2. After Denim activates, expect `InvalidReceiver` from any transfer, mint, or seize to
+ `address(token)` that succeeded before.
+3. Recover a balance credited to the token address before activation with
+ `seizeWithMemo(address(token), treasury, amount, memo)`. The caller must hold `SEIZE_ROLE`, and
+ the token must be seizable under `SEIZE_EXEMPT_POLICY`.
+4. No change is needed for self-transfers, approvals, burns, or sends to other B20 tokens.
+
+The [`seizeWithMemo`](/specifications/b20/reference/interfaces/ib20/seize-with-memo) and
+[`transfer`](/specifications/b20/reference/interfaces/ib20/transfer) reference pages describe
+behavior before Denim activates.
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..498869141
--- /dev/null
+++ b/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx
@@ -0,0 +1,142 @@
+---
+title: "B20: Transfer Executor Policy Enforcement"
+description: "Denim enforces TRANSFER_EXECUTOR_POLICY on msg.sender for every transfer path, closing the transfer and self-transferFrom bypasses."
+---
+
+## Abstract
+
+[Denim](/upgrades/denim/overview) applies `TRANSFER_EXECUTOR_POLICY` to every transfer path. The
+executor gate now checks `msg.sender` on `transfer`, `transferFrom`, `transferWithMemo`, and
+`transferFromWithMemo`, including when `msg.sender == from`. Before Denim, the check ran only on
+the `transferFrom` paths, and only when `msg.sender != from`.
+
+This change is breaking for a token that already set a restrictive `TRANSFER_EXECUTOR_POLICY`:
+holders who moved their own tokens with `transfer` or self-`transferFrom` must now be authorized as
+initiators. A token that never set the policy keeps the unset always-allow default and is
+unaffected. The change adds no new selectors, events, errors, or storage.
+
+## Motivation
+
+An issuer of a restricted security token may require every transfer to go through a registered
+transfer agent. Holders approve the agent, and only the agent calls `transferFrom`.
+`TRANSFER_EXECUTOR_POLICY` is the initiator allowlist for that pattern, but before Denim it had two
+gaps:
+
+1. **`transfer` never consulted the executor policy.** A holder could always move their own tokens
+ through `transfer`, regardless of the allowlist.
+2. **`transferFrom` skipped the check when `msg.sender == from`.** A holder could call
+ `transferFrom(self, to, amount)` to reach the same unchecked path.
+
+Both gaps let a non-allowlisted holder move tokens by picking a different entrypoint. Denim brings
+`TRANSFER_EXECUTOR_POLICY` to parity with `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY`,
+which already run on every transfer path.
+
+## What Changed
+
+### Transfer-Side Scopes
+
+All three transfer-side scopes now run inside the shared `_transfer` helper that backs `transfer`,
+`transferFrom`, and their memo variants:
+
+| Scope | Account checked | Before Denim | Denim |
+| --- | --- | --- | --- |
+| `TRANSFER_SENDER_POLICY` | `from` | Every transfer path | Every transfer path |
+| `TRANSFER_RECEIVER_POLICY` | `to` | Every transfer path | Every transfer path |
+| `TRANSFER_EXECUTOR_POLICY` | `msg.sender` | `transferFrom` paths, only when `msg.sender != from` | Every transfer path |
+
+All three scopes are still bypassed during the factory bootstrap window (`_isPrivileged()`), so a
+token's `initCalls` can move newly minted supply without pre-authorizing the factory.
+
+### Revert Order
+
+Pause, zero-actor, and allowance checks stay in the entrypoints. The executor check moves into
+`_transfer`, where it runs first, before the sender and receiver checks:
+
+| Function | Before Denim | Denim |
+| --- | --- | --- |
+| `transfer` / `transferWithMemo` | pause → zero-receiver → zero-sender → sender policy → receiver policy → balance | pause → invalid-receiver → zero-sender → **executor policy** → sender policy → receiver policy → balance |
+| `transferFrom` / `transferFromWithMemo` | pause → zero-receiver → zero-sender → allowance → executor policy (skipped if `msg.sender == from`) → sender policy → receiver policy → balance | pause → invalid-receiver → zero-sender → allowance → **executor policy** → sender policy → receiver policy → balance |
+
+At Denim, the invalid-receiver step also rejects the token's own address; see
+[Reject the Token Itself as a Credit Recipient](/base-chain/specs/reference/b20/changelog/03-denim-b20-token-receiver).
+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 one `SLOAD`.
+On `transferFrom`, this removes the second (warm) read the entrypoint used to make.
+
+On `transfer`, the executor lookup is new. When `TRANSFER_EXECUTOR_POLICY` equals
+`TRANSFER_SENDER_POLICY` and `from == msg.sender` (including both slots unset, `ALWAYS_ALLOW_ID`),
+`_transfer` reuses the executor result and skips the sender `isAuthorized` call. A default
+`transfer` therefore still makes two `isAuthorized` calls.
+
+## Examples
+
+A holder moving their own tokens is now gated by the executor policy:
+
+```solidity Holder Transfer Blocked by Executor Policy
+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 a transfer agent:
+
+```solidity Transfer Agent Allowlist lines expandable wrap highlight={6-7,9-10}
+address[] memory agents = new address[](1);
+agents[0] = transferAgent;
+uint64 executorAllowlist =
+ policyRegistry.createPolicyWithAccounts(admin, IPolicyRegistry.PolicyType.ALLOWLIST, agents);
+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. This sketch elides the `createB20` arguments:
+
+```solidity Bootstrap Bypass
+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 and Alternatives Considered
+
+Denim centralizes the executor check in `_transfer`, on `msg.sender`, with no `msg.sender == from`
+carve-out. It is the smallest change that closes both gaps, adds no interface surface, and matches
+how the sender and receiver scopes are already enforced.
+
+### Fold Pause, Zero-Actor, and Allowance Into `_transfer`
+
+Allowance is specific to `transferFrom`. Folding it in would need a consume-allowance flag, and
+moving the zero-actor checks after allowance would change revert order.
+
+### Add a Separate Check to `transfer`
+
+Adding a matching check to `transfer` while keeping the `transferFrom` carve-out leaves the
+self-`transferFrom` bypass open and duplicates the check across two entrypoints.
+
+## Migration
+
+No action is needed for a token that never set `TRANSFER_EXECUTOR_POLICY`. The unset slot stays
+always-allow, and the bootstrap bypass is unchanged.
+
+For a token with a restrictive `TRANSFER_EXECUTOR_POLICY`:
+
+1. **Holders using `transfer`:** they must be authorized under `TRANSFER_EXECUTOR_POLICY`, directly
+ or through a policy they belong to, to keep moving their own tokens.
+2. **Holders using self-`transferFrom`:** the same authorization now applies to that path.
+3. **To keep allowing holder-initiated transfers:** add those holders, or a policy covering them, to
+ the executor allowlist before Denim activates.
+
+The [`TRANSFER_EXECUTOR_POLICY`](/specifications/b20/reference/interfaces/ib20/transfer-executor-policy)
+reference page describes the scope as it behaves before Denim activates.
diff --git a/docs/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy.mdx b/docs/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy.mdx
new file mode 100644
index 000000000..1a967a355
--- /dev/null
+++ b/docs/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy.mdx
@@ -0,0 +1,167 @@
+---
+title: "PolicyRegistry: NOT / Invert Policies"
+description: "Denim reserves bit 63 of a policy ID as an invert flag, so isAuthorized returns the opposite of any policy's result without a second, mirrored policy."
+---
+
+## Abstract
+
+[Denim](/upgrades/denim/overview) lets any policy ID reference the opposite (NOT) of its result at
+query time. When bit 63 (`INVERTED_POLICY_BIT`) is set, `isAuthorized` resolves the base policy and
+returns the negation of that policy's decision. The flag applies to every policy type: `ALLOWLIST`,
+`BLOCKLIST`, `UNION`, and `INTERSECT`.
+
+Members stay on the base policy and are shared, not copied, so an update to the base also updates
+its inverse. Invert creates no new record and no new create path. The change is non-breaking:
+existing IDs have bit 63 unset and behave exactly as before.
+
+## Motivation
+
+The Policy Registry increasingly serves as a shared registry of address lists that other policies
+compose around. For example, an issuer may maintain one KYC list and need it to mean "only these
+addresses" in one scope and "exclude these addresses" in another.
+
+Before Denim, the opposite outcome required a second policy of the other type with a copy of the
+same addresses. Every membership change had to land on both; if one update lagged, valid accounts
+were rejected or invalid ones admitted. A composite that needed "NOT A" had to point at that mirror.
+
+Encoding inversion in the policy reference makes one registry entry reusable for `A OR B`,
+`A AND NOT B`, or `NOT A` without extra policies.
+
+## What Changed
+
+### Policy ID Layout
+
+A policy ID is a `uint64`. Bits `0–55` hold a unique counter; bits `56–63` hold the `PolicyType`.
+The four types in use (`0–3`) occupy bits `56–57`, leaving `58–63` unused. Denim reserves bit `63`
+as the invert bit.
+
+```text Policy ID Layout
+ 63 62 56 55 0
++---+-------------+-------------------------------+
+| I | PolicyType | unique counter |
++---+-------------+-------------------------------+
+```
+
+### Interface Changes
+
+```solidity IPolicyRegistry.sol
+// PolicyRegistryConstants
+uint64 internal constant INVERTED_POLICY_BIT = uint64(1) << 63;
+
+function invertedPolicyId(uint64 policyId) external view returns (uint64);
+```
+
+| Symbol | Selector | Status | Behavior |
+| --- | --- | --- | --- |
+| `invertedPolicyId(uint64)` | `0x6b468933` | New view | Toggles bit 63 (`policyId ^ INVERTED_POLICY_BIT`). Never reverts, reads no state, and is involutive. |
+| `isAuthorized(uint64,address)` | Unchanged | Extended | An inverted ID resolves the base and returns the negated result. Fail-closed on an unknown or malformed base. |
+| `policyExists(uint64)` | Unchanged | Extended | Strips to base: `policyExists(invertedPolicyId(id)) == policyExists(id)`. |
+| `policyAdmin(uint64)` | Unchanged | Extended | Strips to base. |
+| `pendingPolicyAdmin(uint64)` | Unchanged | Extended | Strips to base. |
+| `compositePolicyChildIds(uint64)` | Unchanged | Extended | Strips the queried composite's own flag; returns child IDs verbatim, including any per-child invert bit. |
+| `createCompositePolicy(address,uint8,uint64[])` | Unchanged | Extended | A child ID may carry the invert bit ("A AND NOT X"); validated against its base. |
+| `updateComposite(uint64,uint64[])` | Unchanged | Extended | Same per-child invert handling. |
+
+`invertedPolicyId` does not check existence. A missing or malformed base is denied later, at
+`isAuthorized`.
+
+### Behavioral Changes
+
+#### Authorization
+
+`isAuthorized` gains a leading invert branch. All non-inverted paths are unchanged.
+
+```text Authorization Evaluation lines expandable wrap highlight={2-6}
+isAuthorized(policyId, account):
+ if policyId has INVERTED_POLICY_BIT set:
+ base = policyId without the bit
+ if not policyExists(base): # fail-closed guard
+ return false
+ return not isAuthorized(base, account)
+
+ ... existing ALLOWLIST / BLOCKLIST / UNION / INTERSECT dispatch ...
+```
+
+Inverting a composite negates the composite's combined result. An inverted ID over a never-created
+base returns `false`; it never becomes allow-everyone.
+
+#### Getters Strip to Base
+
+Read views clear bit 63 with a shared `_basePolicyId(id) = id & ~INVERTED_POLICY_BIT` helper and read
+the base record. An inverted ID has no record of its own; it mirrors the base's existence, admin,
+pending admin, and child set. A token can store an inverted ID and re-validate it exactly as it would
+a plain one.
+
+#### Composite Children
+
+A composite child may carry the invert bit. The registry checks existence and type against the base:
+
+| Child | Result |
+| --- | --- |
+| Inverted `ALLOWLIST` or `BLOCKLIST` | Accepted; stored with the invert bit kept |
+| Inverted `UNION` or `INTERSECT` | Reverts `InvalidChildPolicy`, preserving the flat-tree invariant |
+| Base does not exist | Reverts `PolicyNotFound`, which still takes precedence across the whole child set |
+| Built-in sentinel (`ALWAYS_ALLOW_ID`, `ALWAYS_BLOCK_ID`), with or without the invert bit | Reverts `InvalidChildPolicy`, unchanged from Cobalt |
+
+### State and Gas
+
+Denim adds no storage slots. Invert is query-time only: storage keys, type decode, and existence
+resolve against the stripped ID. An inverted query runs the fail-closed existence guard on the base,
+then the existing dispatch, then one boolean flip in memory. Non-inverted queries are unchanged.
+
+## Examples
+
+Given a shared `ALLOWLIST` `sanctionedId` whose members are sanctioned addresses (authorized means
+on the list), the inverse authorizes every account that is not on the list:
+
+```solidity Standalone Invert
+uint64 notSanctioned = policyRegistry.invertedPolicyId(sanctionedId); // sanctionedId ^ (1 << 63)
+```
+
+Allow transfers only for accounts on `kycId` and not on `sanctionedId`, with an `INTERSECT`
+composite and an inverted child:
+
+```solidity Composite With an Inverted Child
+uint64[] memory children = new uint64[](2);
+children[0] = kycId;
+children[1] = policyRegistry.invertedPolicyId(sanctionedId); // not sanctioned
+policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, children);
+```
+
+Fail-closed: for any never-created base, `isAuthorized(base | INVERTED_POLICY_BIT, account)` returns
+`false`.
+
+Round-trip: `invertedPolicyId(invertedPolicyId(id)) == id`, and
+`policyExists(invertedPolicyId(id)) == policyExists(id)`.
+
+## Design Decisions and Alternatives Considered
+
+Encoding NOT in bit 63 adds no storage and no create path, and any policy, simple or composite, can
+be inverted on its own. The tradeoff: the flag occupies unused `PolicyType` bitspace, and every
+getter must strip it through `_basePolicyId`.
+
+### New `NOT` Policy Type
+
+`createNot(admin, base)` would allocate a record pointing at a base, with the clearest explorer
+legibility. Standalone NOT would cost ~3 `SLOAD`s vs 1 for a mirror blocklist, and "A AND NOT X"
+~6 vs 4. It also adds a create path and deepens hot-path recursion as a composite child.
+
+### Per-Child Invert Bitmask on the Composite
+
+A ≤4-bit mask packed into the children length word would flip individual children. It only works
+inside a composite, so a simple policy could not be inverted without wrapping it in a two-child
+composite. It could later compose on top of the invert bit.
+
+## Migration
+
+This change is non-breaking. All existing selectors, events, and errors are unchanged, and existing
+composites are unaffected. To adopt:
+
+1. Compute the inverse with `invertedPolicyId(policyId)`, or set bit 63 directly.
+2. Bind it to a B20 scope with `updatePolicy`, or pass it as a composite child. B20 needs no change;
+ it treats the ID as an opaque `uint64`.
+3. Consumers that store policy IDs must still validate `policyExists(policyId)` at write time. This
+ works for inverted IDs because existence resolves to the base.
+
+For the current interface, see the
+[IPolicyRegistry reference](/specifications/b20/reference/interfaces/i-policy-registry/index).
diff --git a/docs/docs.json b/docs/docs.json
index f495cce83..abaf9567c 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -465,7 +465,10 @@
"pages": [
"upgrades/denim/overview",
"upgrades/denim/200ms-blocks",
- "upgrades/denim/migrate-from-flashblocks"
+ "upgrades/denim/migrate-from-flashblocks",
+ "base-chain/specs/reference/b20/changelog/03-denim-b20-token-receiver",
+ "base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement",
+ "base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy"
]
},
{
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index 668c00d68..ec2459c41 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -510,21 +510,27 @@ const client = createPublicClient({ chain: base, transport: http() })
### Denim
-- [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.
+- [Overview](https://docs.base.org/upgrades/denim/overview): Denim introduces native blocks at a 200ms cadence, onchain millisecond time through BaseTime, millisecond-resolution RPC timestamps, and B20 transfer and policy improvements.
- [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.
+- [B20: Reject the Token Itself as a Credit Recipient](https://docs.base.org/base-chain/specs/reference/b20/changelog/03-denim-b20-token-receiver): Denim reverts InvalidReceiver when a transfer, mint, or seize credits the B20 token's own address, so a mistyped recipient fails instead of locking funds.
+
+- [B20: Transfer Executor Policy Enforcement](https://docs.base.org/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement): Denim enforces TRANSFER_EXECUTOR_POLICY on msg.sender for every transfer path, closing the transfer and self-transferFrom bypasses.
+
+- [PolicyRegistry: NOT / Invert Policies](https://docs.base.org/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy): Denim reserves bit 63 of a policy ID as an invert flag, so isAuthorized returns the opposite of any policy's result without a second, mirrored policy.
+
### Cobalt
- [Overview](https://docs.base.org/upgrades/cobalt/overview): Cobalt improves the B20 token standard, adds validity transactions, introduces dynamic node upgrades, and migrates TEE signer registration to onchain attestation verification.
- [Dynamic Upgrades](https://docs.base.org/upgrades/cobalt/dynamic-upgrades): An Ethereum smart contract stores upgrade timestamps for Base nodes, allowing forks to activate at the scheduled time with no client restart.
-- [B20: Beryl to Cobalt Migration](https://docs.base.org/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier): B20 Asset multiplier becomes ERC-8056 conformant at Cobalt, with a scheduled multiplier setter for corporate actions. Every Beryl selector stays dialable.
+- [B20: ERC-8056 Conformant Multiplier](https://docs.base.org/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier): B20 Asset multiplier becomes ERC-8056 conformant at Cobalt, with a scheduled multiplier setter for corporate actions. Every Beryl selector stays dialable.
-- [B20: Seize Surface and burnBlocked Deprecation](https://docs.base.org/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize): The B20 seize surface at Cobalt and the deprecation of burnBlocked. Migration notes for teams integrated against the Beryl seize path.
+- [B20: Seize Functionality](https://docs.base.org/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize): The B20 seize surface at Cobalt and the deprecation of burnBlocked. Migration notes for teams integrated against the Beryl seize path.
- [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.
diff --git a/docs/llms.txt b/docs/llms.txt
index 603e87a01..58720b776 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -444,21 +444,27 @@
### Denim
-- [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.
+- [Overview](https://docs.base.org/upgrades/denim/overview): Denim introduces native blocks at a 200ms cadence, onchain millisecond time through BaseTime, millisecond-resolution RPC timestamps, and B20 transfer and policy improvements.
- [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.
+- [B20: Reject the Token Itself as a Credit Recipient](https://docs.base.org/base-chain/specs/reference/b20/changelog/03-denim-b20-token-receiver): Denim reverts InvalidReceiver when a transfer, mint, or seize credits the B20 token's own address, so a mistyped recipient fails instead of locking funds.
+
+- [B20: Transfer Executor Policy Enforcement](https://docs.base.org/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement): Denim enforces TRANSFER_EXECUTOR_POLICY on msg.sender for every transfer path, closing the transfer and self-transferFrom bypasses.
+
+- [PolicyRegistry: NOT / Invert Policies](https://docs.base.org/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy): Denim reserves bit 63 of a policy ID as an invert flag, so isAuthorized returns the opposite of any policy's result without a second, mirrored policy.
+
### Cobalt
- [Overview](https://docs.base.org/upgrades/cobalt/overview): Cobalt improves the B20 token standard, adds validity transactions, introduces dynamic node upgrades, and migrates TEE signer registration to onchain attestation verification.
- [Dynamic Upgrades](https://docs.base.org/upgrades/cobalt/dynamic-upgrades): An Ethereum smart contract stores upgrade timestamps for Base nodes, allowing forks to activate at the scheduled time with no client restart.
-- [B20: Beryl to Cobalt Migration](https://docs.base.org/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier): B20 Asset multiplier becomes ERC-8056 conformant at Cobalt, with a scheduled multiplier setter for corporate actions. Every Beryl selector stays dialable.
+- [B20: ERC-8056 Conformant Multiplier](https://docs.base.org/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier): B20 Asset multiplier becomes ERC-8056 conformant at Cobalt, with a scheduled multiplier setter for corporate actions. Every Beryl selector stays dialable.
-- [B20: Seize Surface and burnBlocked Deprecation](https://docs.base.org/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize): The B20 seize surface at Cobalt and the deprecation of burnBlocked. Migration notes for teams integrated against the Beryl seize path.
+- [B20: Seize Functionality](https://docs.base.org/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize): The B20 seize surface at Cobalt and the deprecation of burnBlocked. Migration notes for teams integrated against the Beryl seize path.
- [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.
diff --git a/docs/specifications/b20/changelog.mdx b/docs/specifications/b20/changelog.mdx
index 8f97516ba..50dee4686 100644
--- a/docs/specifications/b20/changelog.mdx
+++ b/docs/specifications/b20/changelog.mdx
@@ -3,10 +3,10 @@ title: "Changelog"
description: "Per-hardfork, per-feature migration notes for the B20 token standard, newest first, including new methods, deprecations, and activation dates."
---
-Per-hardfork, per-feature migration notes for the [B20 token standard](/specifications/b20). Each Cobalt entry is a focused, code-forward changelog for one scoped feature change that crosses a hardfork boundary. Newest first.
+Per-hardfork, per-feature migration notes for the [B20 token standard](/specifications/b20). Each Cobalt and Denim entry is a focused, code-forward changelog for one scoped feature change that crosses a hardfork boundary. Newest first.
-B20 method and event signatures are part of the chain's consensus surface. Existing selectors and behavior do not change; the standard grows by addition.
+B20 method and event signatures are part of the chain's consensus surface. Existing selectors do not change, and new functionality ships by addition. Behavior can change at a hardfork boundary: two Denim entries below are breaking for some integrations.
## Hardfork Ordinals
@@ -15,8 +15,17 @@ B20 method and event signatures are part of the chain's consensus surface. Exist
|---------|----------|--------|
| `01` | Beryl | Live |
| `02` | Cobalt | Upcoming |
+| `03` | Denim | Upcoming |
-## [Cobalt](/upgrades/cobalt/overview) (Upcoming) — Ordinal 02
+## [Denim](/upgrades/denim/overview) (Upcoming), Ordinal 03
+
+| Product(s) | Change | Affected interfaces | Entry |
+|------------|--------|---------------------|-------|
+| B20 Asset, B20 Stablecoin | (Breaking) Reject the token itself as a credit recipient | `IB20` (shared surface), `IB20Asset` (`batchMint`) | [Token receiver](/base-chain/specs/reference/b20/changelog/03-denim-b20-token-receiver) |
+| B20 Asset, B20 Stablecoin | (Breaking) Transfer executor policy on every transfer path | `IB20` (shared surface) | [Transfer executor enforcement](/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement) |
+| PolicyRegistry | NOT / invert policies | `IPolicyRegistry` | [NOT policies](/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy) |
+
+## [Cobalt](/upgrades/cobalt/overview) (Upcoming), Ordinal 02
| Product(s) | Change | Affected interfaces | Entry |
|------------|--------|---------------------|-------|
@@ -24,7 +33,7 @@ B20 method and event signatures are part of the chain's consensus surface. Exist
| B20 Asset, B20 Stablecoin | Seize surface + `burnBlocked` deprecation | `IB20` (shared surface) | [Seize surface](/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize) |
| PolicyRegistry | Composite Policies (UNION / INTERSECT) | `IPolicyRegistry` | [Composite policies](/base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy) |
-## [Beryl](/upgrades/beryl/overview) — Initial Release
+## [Beryl](/upgrades/beryl/overview): Initial Release
| Network | Activated |
|---|---|
diff --git a/docs/upgrades/denim/overview.mdx b/docs/upgrades/denim/overview.mdx
index d3ec15a0b..275b0dde8 100644
--- a/docs/upgrades/denim/overview.mdx
+++ b/docs/upgrades/denim/overview.mdx
@@ -1,6 +1,6 @@
---
title: "Overview"
-description: "Denim introduces native blocks at a 200ms cadence, onchain millisecond time through BaseTime, and millisecond-resolution RPC timestamps."
+description: "Denim introduces native blocks at a 200ms cadence, onchain millisecond time through BaseTime, millisecond-resolution RPC timestamps, and B20 transfer and policy improvements."
---
| | Status | Date |
@@ -33,5 +33,13 @@ Denim adds onchain millisecond time through the BaseTime predeploy. A BaseTime m
Block, header, transaction, log, and receipt responses gain optional millisecond-resolution timestamp fields (`timestampMs`, `blockTimestampMs`), derived from authenticated BaseTime metadata. The existing seconds-based `timestamp` field is unchanged for backward compatibility.
+
+
+
+
+**Base** · Precompile
+
+Denim tightens B20 transfer rules and extends PolicyRegistry: transfers, mints, and seizes to the token's own address revert, `TRANSFER_EXECUTOR_POLICY` applies to every transfer path, and any policy can be inverted with a NOT flag. See [Token Receiver](/base-chain/specs/reference/b20/changelog/03-denim-b20-token-receiver), [Transfer Executor Enforcement](/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement), and [NOT / Invert Policies](/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy).
+