Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
title: "NOT / Invert Policies"
description: "Bit 63 of a policy ID inverts isAuthorized at query time, letting one address list serve as either an allowlist or blocklist without duplicating state."
---

## Abstract

The Denim hardfork adds an invert flag to the Policy Registry's `uint64` policy ID. When bit 63 (`INVERTED_POLICY_BIT`) is set, `isAuthorized` resolves the base policy and returns the **opposite** of its result. No new storage is allocated; the flag is query-time only. Every existing policy type — `ALLOWLIST`, `BLOCKLIST`, `UNION`, and `INTERSECT` — can be inverted. Existing IDs are unaffected because bit 63 was previously unused.

## Motivation

The Policy Registry is increasingly used as a shared registry of addresses that other policies compose around. Without invert, expressing "NOT policy A" requires a second policy of the opposite type containing a copy of A's membership. Any membership change must then land on both policies; a lagging update admits invalid accounts or rejects valid ones. A composite that needs "A AND NOT X" cannot reuse X — it must point at a separately-maintained mirror.

Encoding inversion in the policy reference solves this. The same registry entry supports expressions such as `A OR B`, `A AND NOT B`, or `NOT A` without additional policies. One address list, managed once, represents either side of a rule depending on how it is referenced.

## What Changed

### New constant and helper

```solidity title="PolicyRegistryConstants"
uint64 internal constant INVERTED_POLICY_BIT = uint64(1) << 63;
```

```solidity title="IPolicyRegistry"
function invertedPolicyId(uint64 policyId) external view returns (uint64);
```

`invertedPolicyId` returns `policyId ^ INVERTED_POLICY_BIT`. It is pure (reads no state), never reverts, and is involutive: `invertedPolicyId(invertedPolicyId(id)) == id`.

### Updated selector table

| Symbol | Selector | Status | Notes |
|---|---|---|---|
| `invertedPolicyId(uint64)` | `0x6b468933` | NEW | Pure XOR of bit 63; never reverts, reads no state |
| `isAuthorized(uint64,address)` | unchanged | Extended | Inverted ID resolves the base and returns the negated result; fail-closed on unknown/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; child IDs returned 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 |

### Authorization behavior

`isAuthorized` gains a leading invert branch. All non-inverted paths are byte-identical to the previous behavior.

```text title="isAuthorized pseudocode"
isAuthorized(policyId, account):
if policyId has INVERTED_POLICY_BIT set:
base = policyId & ~INVERTED_POLICY_BIT
if not policyExists(base): // fail-closed guard
return false
return not isAuthorized(base, account)

... existing ALLOWLIST / BLOCKLIST / UNION / INTERSECT dispatch ...
```

An inverted ID over an unknown or malformed base returns `false` — it never becomes allow-everyone. This guards against a typo'd or garbage ID with bit 63 set from bypassing mint, transfer, or seize checks.

### Getter strip semantics

Read views strip bit 63 via `_basePolicyId(id) = id & ~INVERTED_POLICY_BIT` and load the base record. An inverted ID has no independent storage record; it mirrors the base's existence, admin, pending admin, and child set.

```mermaid
flowchart TD
Q["read view(policyId)"] --> S["_basePolicyId: clear bit 63"]
S --> B["Load the base policy record"]
B --> R["Return the base field"]
```

### Composite child invert

A child ID in `createCompositePolicy` or `updateComposite` may carry the invert bit. The registry validates the child against its base: an inverted simple child (`ALLOWLIST` or `BLOCKLIST`) is accepted; an inverted composite child is rejected with `InvalidChildPolicy` to preserve the flat-tree invariant. Across the whole child set, `PolicyNotFound` takes precedence over `InvalidChildPolicy`.

```mermaid
flowchart TD
C["createCompositePolicy / updateComposite child"] --> S["_basePolicyId: clear bit 63"]
S --> E{"policyExists(base)?"}
E -->|no| NF["revert PolicyNotFound"]
E -->|yes| T{"base is ALLOWLIST or BLOCKLIST?"}
T -->|no, composite| IC["revert InvalidChildPolicy"]
T -->|yes| OK["Accept child ID with invert bit kept"]
```

### Storage and gas

No new storage slots. Invert is query-time only — one boolean flip in memory. There is no extra `SLOAD`.

### Examples

Invert a sanctions blocklist so the policy reads "not sanctioned":

```solidity title="Standalone invert"
uint64 notSanctions = policyRegistry.invertedPolicyId(sanctionsId);
// notSanctions == sanctionsId ^ (uint64(1) << 63)
```

"Allowed to transfer = on KYC list AND not sanctioned" via an `INTERSECT` composite:

```solidity title="Composite with inverted child"
uint64 notSanctions = policyRegistry.invertedPolicyId(sanctionsId);
policyRegistry.createCompositePolicy(admin, INTERSECT, [kycId, notSanctions]);
```

Fail-closed guarantee: for any never-created base ID, `isAuthorized(base | INVERTED_POLICY_BIT, account)` returns `false`.

## Migration

This change is not breaking. All existing selectors, events, and errors are unchanged. Existing IDs have bit 63 unset, so all existing behavior is identical.

<Steps>
<Step title="Compute the inverted ID">
Call `invertedPolicyId(policyId)` on the registry, or set bit 63 directly with `policyId | (uint64(1) << 63)`.
</Step>
<Step title="Bind it to a B20 scope">
Pass the inverted ID to `updatePolicy` for a standalone scope, or include it as a child in `createCompositePolicy` / `updateComposite`. B20 treats the ID as an opaque `uint64` and requires no changes.
</Step>
<Step title="Validate at write time">
Consumers that store policy IDs must still call `policyExists(policyId)` at write time. This works for inverted IDs because existence resolves to the base.
</Step>
</Steps>

## Alternatives Considered

### Alternative 1 — New `NOT` policy type

`createNot(admin, base)` allocates a fresh record pointing at a base. A first-class NOT node wraps any policy and offers the clearest explorer legibility. Rejected: standalone NOT costs about 3 `SLOAD`s versus 1 for a mirror blocklist; "A AND NOT X" costs about 6 versus the chosen approach's 4. It also adds a new create path and deepens hot-path recursion as a composite child.

### Alternative 2 — Per-child invert bitmask on the composite

A bitmask packed into the children length word flips individual children. `mask = 0` reproduces today's behavior with no migration. Rejected: the flag only works inside a composite — a simple policy cannot be inverted without wrapping it in a composite with a minimum of two children. There is no standalone referenceable inverse of an arbitrary policy.

## Test Cases

The new suite (`test/unit/PolicyRegistry/isAuthorizedInvert.t.sol`, 16 cases) covers:

- Fail-closed invariants: inverted unknown base returns `false`
- Simple/built-in truth tables for `ALLOWLIST` and `BLOCKLIST`
- `INTERSECT[A, ~X]` composite evaluation
- Child validation: inverted simple child accepted, inverted composite child rejected
- Getter strip semantics: `policyExists`, `policyAdmin`, `pendingPolicyAdmin`, `compositePolicyChildIds`
- `invertedPolicyId` round-trip involution
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ Scopes gate specific functions. `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_

An **allowlist** authorizes only accounts in the set. An empty allowlist authorizes nobody, so seed your intended holders before binding the policy.

### Inverted policy IDs

Bit 63 of a `uint64` policy ID is the invert (NOT) flag. When that bit is set, `isAuthorized` resolves the base policy and returns the opposite result. If the base does not exist, the result is `false` — a mistyped inverted ID never becomes allow-everyone. Call `invertedPolicyId(policyId)` on the registry to toggle the bit. The inverted ID shares the base's member set; updating the base updates the inverse immediately.

You can pass an inverted simple policy ID as a child of an `INTERSECT` or `UNION` composite to express "A AND NOT X" without maintaining a mirror list.

## Create and Bind a Holder Allowlist

<CodeGroup>
Expand Down Expand Up @@ -93,7 +99,7 @@ An allowlist denies every account not in the policy. Seed intended holders befor

After creation, only the policy admin can add or remove accounts. Call `updateAllowlist(policyId, true, accounts)` to add and `updateAllowlist(policyId, false, accounts)` to remove. The change is visible to every token that references the policy on the next call. No second `updatePolicy` is needed on the token.

To combine a KYC allowlist with a sanctions blocklist, create an `INTERSECT` composite policy referencing both simple policies, then bind the composite ID to the token's scopes. See the [B20 token standard](/specifications/b20/specification-overview) for the full policy type reference.
To combine a KYC allowlist with a sanctions blocklist, create an `INTERSECT` composite policy referencing both simple policies, then bind the composite ID to the token's scopes. You can also pass an inverted policy ID as a composite child — for example, `[kycId, invertedPolicyId(exclusionId)]` — to express "KYC'd and not on the exclusion list" without duplicating members. See the [B20 token standard](/specifications/b20/specification-overview) for the full policy type reference.

## See Also

Expand Down
3 changes: 2 additions & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,8 @@
"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-policyregistry-not-policy"
]
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Creates a composite policy that combines two to four existing simple policies (`

The registry stores references to the children, not a snapshot of their members. Every call to `isAuthorized` reads each child's current member set, so updates to a child policy are immediately visible through the composite.

A child policy ID may carry the invert flag (bit 63, set via `invertedPolicyId`). The registry validates and stores the base policy ID while preserving the invert flag. At authorization time, the inverted child returns the opposite of its base's result. An inverted composite child is not valid and reverts `InvalidChildPolicy`. Across the whole child set, `PolicyNotFound` takes precedence over `InvalidChildPolicy`.

Creation is permissionless. The `admin` you supply is the only address that can later call `updateComposite`, `stageUpdateAdmin`, or `renounceAdmin` on this policy.

On success, emits `PolicyCreated(newPolicyId, creator, policyType)`, `PolicyAdminUpdated(newPolicyId, address(0), admin)`, and `CompositePolicyUpdated(newPolicyId, creator, childPolicyIds)`.
Expand All @@ -35,7 +37,7 @@ On success, emits `PolicyCreated(newPolicyId, creator, policyType)`, `PolicyAdmi
|---|---|---|
| `admin` | `address` | Initial admin authorized to update child policies and transfer or renounce administration. Cannot be `address(0)`. |
| `policyType` | `PolicyType` (`uint8`) | Must be `UNION` or `INTERSECT`. |
| `childPolicyIds` | `uint64[]` | IDs of existing simple policies to combine. Count must be in `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (2–4). |
| `childPolicyIds` | `uint64[]` | IDs of existing simple policies to combine. Count must be in `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (2–4). A child ID may have its invert flag (bit 63) set; the registry validates and stores the base while preserving the flag. |

## Returns

Expand All @@ -50,8 +52,8 @@ On success, emits `PolicyCreated(newPolicyId, creator, policyType)`, `PolicyAdmi
| `ZeroAddress()` | `admin` is `address(0)` |
| `IncompatiblePolicyType()` | `policyType` is not `UNION` or `INTERSECT` |
| `ChildPoliciesOutsideOfRange()` | `childPolicyIds.length` is outside `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (2–4) |
| `PolicyNotFound()` | Any child policy ID does not exist in the registry |
| `InvalidChildPolicy(childPolicyId)` | Any child is not an existing simple policy (composites and built-in sentinels are not valid children) |
| `PolicyNotFound()` | Any child policy ID's base does not exist in the registry (checked first, across the whole set) |
| `InvalidChildPolicy(childPolicyId)` | Any child's base is not a simple policy (composites, inverted composites, and built-in sentinels are not valid children) |
| Panic `0x11` | The policy ID counter has reached its maximum value |

## Access Control
Expand All @@ -78,6 +80,23 @@ token.updatePolicy(B20Constants.TRANSFER_RECEIVER_POLICY, gateId);
token.updatePolicy(B20Constants.MINT_RECEIVER_POLICY, gateId);
```

```solidity KYC and not on an exclusion list lines wrap expandable highlight={9,11}
// 1. Create a KYC allowlist and an exclusion allowlist.
uint64 kycId = registry.createPolicy(admin, PolicyType.ALLOWLIST);
uint64 exclusionId = registry.createPolicy(admin, PolicyType.ALLOWLIST);

// 2. Invert the exclusion ID so the child evaluates as "not on the list".
uint64 notExcluded = registry.invertedPolicyId(exclusionId);

// 3. INTERSECT: KYC'd AND not on the exclusion list.
uint64[] memory children = new uint64[](2);
children[0] = kycId;
children[1] = notExcluded;

uint64 gateId = registry.createCompositePolicy(admin, PolicyType.INTERSECT, children);
token.updatePolicy(B20Constants.TRANSFER_RECEIVER_POLICY, gateId);
```

<Note>
Updating a child policy's membership (via `updateAllowlist` or `updateBlocklist`) takes effect on the next `isAuthorized` call through any composite that references it. No second `updateComposite` is needed on the token.
Updating a child policy's membership (via `updateAllowlist` or `updateBlocklist`) takes effect on the next `isAuthorized` call through any composite that references it. No second `updateComposite` is needed on the token. This applies to inverted children too — updating the base updates the inverse.
</Note>
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
---
title: "IPolicyRegistry.createPolicy"
description: "Generated B20 reference for createPolicy(address,uint8)."
description: "Creates a new simple policy with no initial members and returns its assigned policy ID."
---



## Signature

```solidity IPolicyRegistry.sol
Expand All @@ -18,20 +16,39 @@ function createPolicy(address admin, PolicyType policyType) external returns (ui

## Description

Creates a new simple policy with no initial members. Permissionless.
Dev: Reverts with `ZeroAddress` when `admin` is `address(0)`.
Dev: Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate.
Param: admin Initial admin authorized to modify membership and transfer or renounce administration.
Param: policyType BLOCKLIST or ALLOWLIST.
Return: newPolicyId The newly assigned policy ID.
Creates a new simple policy with no initial members. Permissionless. The registry assigns a new policy ID and returns it. The member set starts empty.

## Parameters

| Name | Type | Description |
|---|---|---|
| `admin` | `address` | Initial admin authorized to modify membership and transfer or renounce administration. Cannot be `address(0)`. |
| `policyType` | `PolicyType` | `ALLOWLIST` or `BLOCKLIST`. Composite types are not valid here. |

## Returns

| Name | Type | Description |
|---|---|---|
| `newPolicyId` | `uint64` | The newly assigned policy ID. |

## Revert Conditions

| Error | Condition |
|---|---|
| `ZeroAddress` | `admin` is `address(0)`. |
| `IncompatiblePolicyType` | `policyType` is a composite gate (`UNION` or `INTERSECT`). |

## Access Control

Permissionless creation, but state-changing registry calls require the feature to be active.
Permissionless. Any caller may create a policy.

## Behavior

Emits `PolicyCreated` and `PolicyAdminUpdated(newPolicyId, address(0), admin)`. After the call, `policyAdmin(newPolicyId)` returns `admin` and `policyExists(newPolicyId)` returns `true`.

## Policy Interaction
To seed members in the same call, use `createPolicyWithAccounts`. To create a composite policy, use `createCompositePolicy`.

This is part of the singleton PolicyRegistry surface used by B20 policy scopes.
A policy ID returned here is a plain (non-inverted) ID. To obtain the inverted form, call `invertedPolicyId(newPolicyId)`, which toggles bit 63. The inverted ID shares the same member set and does not require a separate creation step.

## Example

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
---
title: "IPolicyRegistry.isAuthorized"
description: "Generated B20 reference for isAuthorized(uint64,address)."
description: "Returns whether an account is authorized under a policy ID, including inverted (NOT) policy semantics."
---



## Signature

```solidity IPolicyRegistry.sol
Expand All @@ -18,17 +16,46 @@ function isAuthorized(uint64 policyId, address account) external view returns (b

## Description

Returns whether `account` is authorized under `policyId`. Never reverts; unknown
or malformed IDs collapse to empty-member-set semantics (ALLOWLIST -&gt; false,
BLOCKLIST -&gt; true).
Dev: Callers that store policy IDs MUST validate `policyExists(policyId)` at write time.
Param: policyId Policy to query.
Param: account Account to check.
Return: Whether `account` is authorized.
Returns whether `account` is authorized under `policyId`. Never reverts.

Unknown or malformed IDs collapse to empty-member-set semantics (`ALLOWLIST` → `false`, `BLOCKLIST` → `true`).

Callers that store policy IDs MUST validate `policyExists(policyId)` at write time.

### Invert (NOT) semantics

When bit 63 of `policyId` is set (the invert flag), `isAuthorized` resolves the base policy (`policyId & ~INVERT_BIT`) and returns the **opposite** of its result:

- If the base policy does not exist, returns `false` (fail-closed). A missing or malformed base is never treated as allow-everyone.
- If the base policy exists, returns `!isAuthorized(base, account)`.

This applies to every policy type: `ALLOWLIST`, `BLOCKLIST`, `UNION`, and `INTERSECT`.

Use `invertedPolicyId(policyId)` to set or clear bit 63. The inverted ID has no record of its own; members live on the base.

```mermaid
flowchart TD
Q["isAuthorized(policyId, account)"] --> Inv{"bit 63 set?"}
Inv -->|no| T[Dispatch on policy type]
Inv -->|yes| E{"policyExists(base)?"}
E -->|no| F[false]
E -->|yes| N["not isAuthorized(base, account)"]
```

## Parameters

| Name | Type | Description |
|---|---|---|
| `policyId` | `uint64` | Policy to query. Bit 63 may be set to invert the result. |
| `account` | `address` | Account to check. |

## Returns

`bool` — `true` if `account` is authorized under `policyId`, `false` otherwise.

## Access Control

Read-only or ERC-20-standard access rules unless the NatSpec states otherwise.
View function. No role or admin restriction.

## Policy Interaction

Expand All @@ -37,5 +64,10 @@ This is part of the singleton PolicyRegistry surface used by B20 policy scopes.
## Example

```solidity Usage Example
// Plain policy
bool ok = StdPrecompiles.POLICY_REGISTRY.isAuthorized(policyId, account);

// Inverted policy: authorized if account is NOT in the base policy's member set
uint64 notExcluded = StdPrecompiles.POLICY_REGISTRY.invertedPolicyId(exclusionId);
bool notOnExclusionList = StdPrecompiles.POLICY_REGISTRY.isAuthorized(notExcluded, account);
```
Loading