From c134904a5dc56a76c7da3079c8678966ae9a65cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:14:21 +0000 Subject: [PATCH] docs: sync from base-std@91427ab --- .../03-denim-policyregistry-not-policy.mdx | 142 +++++++++++++++ .../restrict-who-can-hold.mdx | 8 +- docs/docs.json | 3 +- .../create-composite-policy.mdx | 27 ++- .../i-policy-registry/create-policy.mdx | 41 +++-- .../i-policy-registry/is-authorized.mdx | 54 ++++-- .../max-composite-child-policies.mdx | 2 + .../min-composite-child-policies.mdx | 2 + .../pending-policy-admin.mdx | 6 +- .../i-policy-registry/policy-admin.mdx | 10 +- .../i-policy-registry/update-allowlist.mdx | 2 + .../i-policy-registry/update-composite.mdx | 18 +- .../reference/interfaces/ib20/policy-id.mdx | 7 +- .../interfaces/ib20/update-policy.mdx | 17 +- .../b20/reference/invariants-tests.mdx | 169 ++++++++++-------- .../b20/specification-overview.mdx | 34 +++- 16 files changed, 416 insertions(+), 126 deletions(-) create mode 100644 docs/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy.mdx 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..39b9d1ca6 --- /dev/null +++ b/docs/base-chain/specs/reference/b20/changelog/03-denim-policyregistry-not-policy.mdx @@ -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. + + + + Call `invertedPolicyId(policyId)` on the registry, or set bit 63 directly with `policyId | (uint64(1) << 63)`. + + + 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. + + + Consumers that store policy IDs must still call `policyExists(policyId)` at write time. This works for inverted IDs because existence resolves to the base. + + + +## 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 diff --git a/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx b/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx index 9e1a67a3a..e64ded027 100644 --- a/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx +++ b/docs/build-on-base/issue-stablecoins/restrict-who-can-hold.mdx @@ -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 @@ -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 diff --git a/docs/docs.json b/docs/docs.json index 49521c202..0e9f18c48 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" ] }, { diff --git a/docs/specifications/b20/reference/interfaces/i-policy-registry/create-composite-policy.mdx b/docs/specifications/b20/reference/interfaces/i-policy-registry/create-composite-policy.mdx index d54cd7a0c..ec63b018d 100644 --- a/docs/specifications/b20/reference/interfaces/i-policy-registry/create-composite-policy.mdx +++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/create-composite-policy.mdx @@ -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)`. @@ -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 @@ -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 @@ -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); +``` + -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. diff --git a/docs/specifications/b20/reference/interfaces/i-policy-registry/create-policy.mdx b/docs/specifications/b20/reference/interfaces/i-policy-registry/create-policy.mdx index bd455b956..e84097fd6 100644 --- a/docs/specifications/b20/reference/interfaces/i-policy-registry/create-policy.mdx +++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/create-policy.mdx @@ -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 @@ -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 diff --git a/docs/specifications/b20/reference/interfaces/i-policy-registry/is-authorized.mdx b/docs/specifications/b20/reference/interfaces/i-policy-registry/is-authorized.mdx index 57eeebf25..9377c476f 100644 --- a/docs/specifications/b20/reference/interfaces/i-policy-registry/is-authorized.mdx +++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/is-authorized.mdx @@ -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 @@ -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 -> false, -BLOCKLIST -> 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 @@ -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); ``` diff --git a/docs/specifications/b20/reference/interfaces/i-policy-registry/max-composite-child-policies.mdx b/docs/specifications/b20/reference/interfaces/i-policy-registry/max-composite-child-policies.mdx index a3cd43b6b..4b8f07be2 100644 --- a/docs/specifications/b20/reference/interfaces/i-policy-registry/max-composite-child-policies.mdx +++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/max-composite-child-policies.mdx @@ -26,6 +26,8 @@ Read-only. No role required. Used by `createCompositePolicy` and `updateComposite` to validate that the supplied child array length does not exceed this bound. Exceeding it reverts `ChildPoliciesOutsideOfRange`. +Child IDs passed to `createCompositePolicy` and `updateComposite` may carry the invert flag (bit 63). The registry strips the flag to locate the base policy for existence and type validation, then stores the child ID with the flag preserved. The child count limit applies to the number of child entries regardless of whether any carry the invert flag. + ## Example ```solidity Usage Example diff --git a/docs/specifications/b20/reference/interfaces/i-policy-registry/min-composite-child-policies.mdx b/docs/specifications/b20/reference/interfaces/i-policy-registry/min-composite-child-policies.mdx index 60a18fbd8..e717ab2d2 100644 --- a/docs/specifications/b20/reference/interfaces/i-policy-registry/min-composite-child-policies.mdx +++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/min-composite-child-policies.mdx @@ -20,6 +20,8 @@ Returns the minimum number of child policies a composite policy must reference, A composite created with fewer than `MIN_COMPOSITE_CHILD_POLICIES` children reverts `ChildPoliciesOutsideOfRange`. The upper bound is enforced by `MAX_COMPOSITE_CHILD_POLICIES` (`4`). +Child IDs passed to `createCompositePolicy` may carry the invert flag (bit 63). The registry validates the base policy's existence and type. An inverted simple child is valid; an inverted composite child reverts `InvalidChildPolicy`. + ## Returns | Name | Type | Description | diff --git a/docs/specifications/b20/reference/interfaces/i-policy-registry/pending-policy-admin.mdx b/docs/specifications/b20/reference/interfaces/i-policy-registry/pending-policy-admin.mdx index 0439e33f9..9de647f9e 100644 --- a/docs/specifications/b20/reference/interfaces/i-policy-registry/pending-policy-admin.mdx +++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/pending-policy-admin.mdx @@ -18,13 +18,15 @@ function pendingPolicyAdmin(uint64 policyId) external view returns (address); Returns the currently-staged pending admin for `policyId`, or `address(0)` when no transfer is in flight or for built-in sentinels, unknown IDs, and malformed IDs. Never reverts. -The pending admin is set by `stageUpdateAdmin(policyId, newAdmin)` and cleared when `finalizeUpdateAdmin(policyId)` succeeds or when the current admin calls `stageUpdateAdmin` with `address(0)`. Until `finalizeUpdateAdmin` is called, `policyAdmin` still returns the current admin, the pending admin has no privileges yet. +If `policyId` has the invert flag set (bit 63), the function strips that bit and returns the base policy's pending admin. An inverted ID has no record of its own. + +The pending admin is set by `stageUpdateAdmin(policyId, newAdmin)` and cleared when `finalizeUpdateAdmin(policyId)` succeeds or when the current admin calls `stageUpdateAdmin` with `address(0)`. Until `finalizeUpdateAdmin` is called, `policyAdmin` still returns the current admin; the pending admin has no privileges yet. ## Parameters | Name | Type | Description | |---|---|---| -| `policyId` | `uint64` | Policy to query. | +| `policyId` | `uint64` | Policy to query. Pass the plain or inverted ID; bit 63 is stripped before the lookup. | ## Returns diff --git a/docs/specifications/b20/reference/interfaces/i-policy-registry/policy-admin.mdx b/docs/specifications/b20/reference/interfaces/i-policy-registry/policy-admin.mdx index 28ef9b180..70c16e733 100644 --- a/docs/specifications/b20/reference/interfaces/i-policy-registry/policy-admin.mdx +++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/policy-admin.mdx @@ -18,6 +18,8 @@ function policyAdmin(uint64 policyId) external view returns (address); Never reverts. Returns `address(0)` for any ID that has no admin, including sentinels and IDs that have never been created. +**Inverted IDs:** When bit 63 (the invert flag) is set, `policyAdmin` strips the flag and returns the base policy's admin. An inverted ID has no record of its own — `policyAdmin(invertedPolicyId(id)) == policyAdmin(id)`. + After `renounceAdmin`, no address can be assigned as admin again. `policyAdmin` returns `address(0)` permanently for that policy. During a pending admin transfer, `policyAdmin` still returns the **current** admin. The staged nominee is readable via `pendingPolicyAdmin`. Administration transfers only when the nominee calls `finalizeUpdateAdmin`. @@ -26,11 +28,11 @@ During a pending admin transfer, `policyAdmin` still returns the **current** adm | Name | Type | Description | |---|---|---| -| `policyId` | `uint64` | The policy to query. | +| `policyId` | `uint64` | The policy to query. Bit 63 (invert flag) is stripped before lookup. | ## Returns -The current admin address, or `address(0)` for built-in sentinels (`ALWAYS_ALLOW`, `ALWAYS_BLOCK`), renounced policies, unknown IDs, and malformed IDs. +The current admin address, or `address(0)` for built-in sentinels (`ALWAYS_ALLOW`, `ALWAYS_BLOCK`), renounced policies, unknown IDs, and malformed IDs. An inverted ID returns the base policy's admin. ## Access Control @@ -40,4 +42,8 @@ View, no role required. ```solidity Usage Example address admin = IPolicyRegistry(registry).policyAdmin(policyId); + +// Inverted IDs resolve to the base policy's admin. +uint64 inverted = IPolicyRegistry(registry).invertedPolicyId(policyId); +address sameAdmin = IPolicyRegistry(registry).policyAdmin(inverted); // == admin ``` diff --git a/docs/specifications/b20/reference/interfaces/i-policy-registry/update-allowlist.mdx b/docs/specifications/b20/reference/interfaces/i-policy-registry/update-allowlist.mdx index 11cf1e270..20b891341 100644 --- a/docs/specifications/b20/reference/interfaces/i-policy-registry/update-allowlist.mdx +++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/update-allowlist.mdx @@ -20,6 +20,8 @@ Sets each address in `accounts` to `allowed` (add or remove) in an `ALLOWLIST` p Membership batches are capped by the registry, currently at 64 accounts. A larger batch reverts `BatchSizeTooLarge(maxBatchSize)`, which carries the limit. +If a composite policy references `policyId` as an inverted child, the inverted evaluation also reflects the update immediately. No second write is needed on the composite or the token. + ## Parameters | Name | Type | Description | diff --git a/docs/specifications/b20/reference/interfaces/i-policy-registry/update-composite.mdx b/docs/specifications/b20/reference/interfaces/i-policy-registry/update-composite.mdx index 398976834..afa936d4f 100644 --- a/docs/specifications/b20/reference/interfaces/i-policy-registry/update-composite.mdx +++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/update-composite.mdx @@ -16,7 +16,7 @@ function updateComposite(uint64 policyId, uint64[] calldata childPolicyIds) exte ## Description -Replaces a composite policy's child-policy set in full with `childPolicyIds`. There is no partial edit, the entire child set is replaced in one call. +Replaces a composite policy's child-policy set in full with `childPolicyIds`. There is no partial edit — the entire child set is replaced in one call. The write takes effect on the next `isAuthorized` call that references `policyId`. Every token that stores this policy ID sees the new result without a second `updatePolicy` call on the token. @@ -25,17 +25,27 @@ The write takes effect on the next `isAuthorized` call that references `policyId | Name | Type | Description | |---|---|---| | `policyId` | `uint64` | Composite policy to update. Must exist and be a `UNION` or `INTERSECT` policy. | -| `childPolicyIds` | `uint64[]` | Complete new set of existing simple policy IDs. Count must be in `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (2–4). | +| `childPolicyIds` | `uint64[]` | Complete new set of child policy IDs. Count must be in `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (2–4). Each entry may be a plain or inverted simple policy ID. | + +## Child Policy Inversion + +A child ID may carry the invert flag (bit 63). The registry validates the base policy (the ID with bit 63 cleared) and stores the child ID with the invert bit set. At authorization time, the composite evaluates the inverted child as the logical NOT of its base result. + +Validity rules applied to each child: + +- The base (bit 63 cleared) must exist — `PolicyNotFound` if it does not. +- The base must be a simple `ALLOWLIST` or `BLOCKLIST`, not a composite or built-in sentinel — `InvalidChildPolicy` if it is not. +- `PolicyNotFound` takes precedence over `InvalidChildPolicy` across the whole child set (two-pass validation). ## Reverts | Error | Condition | |---|---| | `Unauthorized()` | Caller is not the current policy admin. | -| `PolicyNotFound()` | `policyId` does not exist, or any entry in `childPolicyIds` does not exist. | +| `PolicyNotFound()` | `policyId` does not exist, or the base of any entry in `childPolicyIds` does not exist. | | `IncompatiblePolicyType()` | `policyId` is not a composite (`UNION` or `INTERSECT`). | | `ChildPoliciesOutsideOfRange()` | `childPolicyIds.length` is outside `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (2–4). | -| `InvalidChildPolicy(childPolicyId)` | A child is a composite or a built-in sentinel (`ALWAYS_ALLOW`, `ALWAYS_BLOCK`) rather than a simple `ALLOWLIST` or `BLOCKLIST` policy. | +| `InvalidChildPolicy(childPolicyId)` | The base of a child is a composite or a built-in sentinel (`ALWAYS_ALLOW`, `ALWAYS_BLOCK`) rather than a simple `ALLOWLIST` or `BLOCKLIST` policy. The full `childPolicyId` (including the invert bit) is passed to the error. | ## Events diff --git a/docs/specifications/b20/reference/interfaces/ib20/policy-id.mdx b/docs/specifications/b20/reference/interfaces/ib20/policy-id.mdx index 9849a2036..00cddbe47 100644 --- a/docs/specifications/b20/reference/interfaces/ib20/policy-id.mdx +++ b/docs/specifications/b20/reference/interfaces/ib20/policy-id.mdx @@ -18,6 +18,8 @@ function policyId(bytes32 policyScope) external view returns (uint64); Returns the policy ID stored for `policyScope`. If the scope has never been assigned, this returns `0`, which is the `ALWAYS_ALLOW` built-in sentinel, every address is authorized. +An inverted policy ID (bit 63 set) is a valid binding. The token treats the stored value as an opaque `uint64` and passes it to the policy registry unchanged. The registry strips bit 63 when checking existence and evaluates the base policy before inverting the result. + ## Parameters | Parameter | Description | @@ -26,7 +28,7 @@ Returns the policy ID stored for `policyScope`. If the scope has never been assi ## Returns -The `uint64` policy ID currently bound to the scope. `0` means `ALWAYS_ALLOW`. +The `uint64` policy ID currently bound to the scope. `0` means `ALWAYS_ALLOW`. Bit 63 may be set, indicating an inverted policy. ## Reverts @@ -49,11 +51,12 @@ The following scopes are recognized by a B20 token. Pass one of these as `policy | `SEIZE_EXEMPT_POLICY` | `seizeWithMemo` | `from` | | `SEIZE_RECEIVER_POLICY` | `seizeWithMemo` | `to` | -An unset scope reads as `0` (`ALWAYS_ALLOW`). To bind a different policy, the token admin calls `updatePolicy(policyScope, newPolicyId)`. +An unset scope reads as `0` (`ALWAYS_ALLOW`). To bind a different policy, the token admin calls `updatePolicy(policyScope, newPolicyId)`. An inverted ID is valid when its base policy exists; `policyExists` strips bit 63, so the registry resolves it correctly. ## Example ```solidity Usage Example uint64 id = IB20(token).policyId(B20Constants.MINT_RECEIVER_POLICY); // id == 0 means ALWAYS_ALLOW (no mint restriction) +// id with bit 63 set means an inverted policy is bound ``` diff --git a/docs/specifications/b20/reference/interfaces/ib20/update-policy.mdx b/docs/specifications/b20/reference/interfaces/ib20/update-policy.mdx index 545b356d4..2084c9c1a 100644 --- a/docs/specifications/b20/reference/interfaces/ib20/update-policy.mdx +++ b/docs/specifications/b20/reference/interfaces/ib20/update-policy.mdx @@ -25,7 +25,7 @@ Until a scope is updated it holds `0` (`ALWAYS_ALLOW`), so the check passes for | Parameter | Description | |---|---| | `policyScope` | The scope slot to update. Must be a scope this token recognizes. | -| `newPolicyId` | The policy ID to assign. Must be a built-in sentinel (`ALWAYS_ALLOW`, `ALWAYS_BLOCK`) or an existing registry policy. | +| `newPolicyId` | The policy ID to assign. Must be a built-in sentinel (`ALWAYS_ALLOW`, `ALWAYS_BLOCK`), an existing registry policy, or an inverted ID whose base policy exists. The token treats the value as an opaque `uint64`. | ## Reverts @@ -33,7 +33,7 @@ Until a scope is updated it holds `0` (`ALWAYS_ALLOW`), so the check passes for |---|---| | `AccessControlUnauthorizedAccount` | Caller does not hold `DEFAULT_ADMIN_ROLE`. | | `UnsupportedPolicyType(policyScope)` | `policyScope` is not a slot this token supports. | -| `PolicyNotFound(newPolicyId)` | `newPolicyId` is not a built-in sentinel and does not exist in the registry. | +| `PolicyNotFound(newPolicyId)` | `newPolicyId` is not a built-in sentinel and does not exist in the registry. An inverted ID is valid when its base exists, because `policyExists` strips bit 63 before checking. | ## Access Control @@ -54,10 +54,21 @@ The recognized scopes and the accounts they check are: Transfer scopes are skipped on factory `initCalls` transfers. `MINT_RECEIVER_POLICY` is always checked, including factory `initCalls` mints. `SEIZE_EXEMPT_POLICY` unset (`ALWAYS_ALLOW`) means no account is seizable. +## Inverted Policy IDs + +Bit 63 of a `uint64` policy ID is the invert (NOT) flag. Passing an inverted ID to `updatePolicy` is valid as long as the base policy (the ID with bit 63 cleared) exists in the registry. The token stores and passes the full `uint64` — including the invert bit — to `isAuthorized`, which returns the opposite of the base policy's decision. If the base does not exist at evaluation time, `isAuthorized` returns `false` (fail-closed). + +Use `invertedPolicyId(policyId)` on the Policy Registry to toggle the bit rather than computing it manually. + ## Example ```solidity Usage Example IB20(token).updatePolicy(B20Constants.TRANSFER_RECEIVER_POLICY, policyId); ``` -The same policy ID can be bound to more than one scope and to more than one token. Updating membership in the registry policy is visible immediately on every scope and token that references it, no second `updatePolicy` call is needed. +```solidity Inverted Policy Example +uint64 notExcluded = IPolicyRegistry(registry).invertedPolicyId(exclusionId); +IB20(token).updatePolicy(B20Constants.TRANSFER_RECEIVER_POLICY, notExcluded); +``` + +The same policy ID can be bound to more than one scope and to more than one token. Updating membership in the registry policy is visible immediately on every scope and token that references it; no second `updatePolicy` call is needed. diff --git a/docs/specifications/b20/reference/invariants-tests.mdx b/docs/specifications/b20/reference/invariants-tests.mdx index 0fafbfc47..ed356f9aa 100644 --- a/docs/specifications/b20/reference/invariants-tests.mdx +++ b/docs/specifications/b20/reference/invariants-tests.mdx @@ -15,61 +15,65 @@ These invariants and conformance test cases are the normative behavioral guarant 3. After `renounceAdmin(policyId)`, all membership-mutating calls on that policy revert permanently. 4. `ALWAYS_ALLOW` (ID `0`) authorizes every account. `ALWAYS_BLOCK` denies every account. Neither can be created, modified, or renounced. 5. Policy IDs are globally unique and monotonically increasing within each `PolicyType` prefix. +6. An inverted policy ID (bit 63 set) is an extension of its base — `policyExists`, `policyAdmin`, and `pendingPolicyAdmin` resolve to the base's values. +7. `isAuthorized` on an inverted ID returns the negated result of the base. If the base does not exist, it returns `false` (fail-closed). +8. `invertedPolicyId(invertedPolicyId(id)) == id` — inversion is involutive. +9. An inverted composite child `id` evaluates the base's members and returns the opposite result; an inverted **composite** child is rejected. ### Roles -6. The last `DEFAULT_ADMIN_ROLE` holder cannot be removed via `renounceRole` or `revokeRole` — only `renounceLastAdmin()`. -7. After `renounceLastAdmin()`, no address can ever hold `DEFAULT_ADMIN_ROLE` again. -8. Roles granted before admin renunciation continue to function. -9. Custom roles have no built-in effect on any B20 operation. +10. The last `DEFAULT_ADMIN_ROLE` holder cannot be removed via `renounceRole` or `revokeRole` — only `renounceLastAdmin()`. +11. After `renounceLastAdmin()`, no address can ever hold `DEFAULT_ADMIN_ROLE` again. +12. Roles granted before admin renunciation continue to function. +13. Custom roles have no built-in effect on any B20 operation. ### Transfer Policies -10. `approve` is never policy-gated. -11. `TRANSFER_EXECUTOR_POLICY` is checked only on `transferFrom`, never on `transfer`. -12. `MINT_RECEIVER_POLICY` is always enforced, even during factory `initCalls`. -13. All three transfer-side scopes are bypassed during `initCalls`. -14. Every scope defaults to `ALWAYS_ALLOW` at token creation. +14. `approve` is never policy-gated. +15. `TRANSFER_EXECUTOR_POLICY` is checked only on `transferFrom`, never on `transfer`. +16. `MINT_RECEIVER_POLICY` is always enforced, even during factory `initCalls`. +17. All three transfer-side scopes are bypassed during `initCalls`. +18. Every scope defaults to `ALWAYS_ALLOW` at token creation. ### Supply -15. `totalSupply` can never exceed the supply cap. -16. The supply cap can never be set below the current `totalSupply`. -17. Burns reduce `totalSupply` and create headroom under the cap. +19. `totalSupply` can never exceed the supply cap. +20. The supply cap can never be set below the current `totalSupply`. +21. Burns reduce `totalSupply` and create headroom under the cap. ### Pause -18. Each `PausableFeature` is independent — pausing one does not affect the others. -19. `approve` and `permit` are never affected by any pause state. -20. Pause state is never bypassed during factory `initCalls`. +22. Each `PausableFeature` is independent — pausing one does not affect the others. +23. `approve` and `permit` are never affected by any pause state. +24. Pause state is never bypassed during factory `initCalls`. ### Memos -21. The `Memo` event is always emitted at exactly `logIndex + 1` relative to its parent `Transfer` event. -22. Memo methods are functionally identical to their non-memo counterparts in all respects except event emission. +25. The `Memo` event is always emitted at exactly `logIndex + 1` relative to its parent `Transfer` event. +26. Memo methods are functionally identical to their non-memo counterparts in all respects except event emission. ### Permit -23. `permit` only accepts ECDSA signatures. ERC-1271 contract signatures always fail. -24. Each successful `permit` increments the owner's nonce by exactly 1. -25. Permits signed before `updateName` fail after the name change. +27. `permit` only accepts ECDSA signatures. ERC-1271 contract signatures always fail. +28. Each successful `permit` increments the owner's nonce by exactly 1. +29. Permits signed before `updateName` fail after the name change. ### Variants -26. Asset decimals are set at creation and immutable. Valid range is 6–18. -27. Stablecoin decimals are always `6`. -28. `OPERATOR_ROLE` exists only on Asset tokens. -29. Announcement IDs are unique across a token's lifetime. -30. The currency code on a Stablecoin is immutable and contains only `A`–`Z` characters. -31. Multiplier updates affect all holders simultaneously. -32. `batchMint` enforces `MINT_RECEIVER_POLICY` for each recipient individually. +30. Asset decimals are set at creation and immutable. Valid range is 6–18. +31. Stablecoin decimals are always `6`. +32. `OPERATOR_ROLE` exists only on Asset tokens. +33. Announcement IDs are unique across a token's lifetime. +34. The currency code on a Stablecoin is immutable and contains only `A`–`Z` characters. +35. Multiplier updates affect all holders simultaneously. +36. `batchMint` enforces `MINT_RECEIVER_POLICY` for each recipient individually. ### Factory -33. B20 addresses are deterministic: same inputs always produce the same address. -34. The variant byte at address position 10 always matches the deployed variant. -35. Each `(deployer, variant, salt)` tuple produces exactly one address. -36. `initCalls` execute in array order. A revert in any initCall reverts the entire deployment. +37. B20 addresses are deterministic: same inputs always produce the same address. +38. The variant byte at address position 10 always matches the deployed variant. +39. Each `(deployer, variant, salt)` tuple produces exactly one address. +40. `initCalls` execute in array order. A revert in any initCall reverts the entire deployment. ## Test Cases @@ -85,93 +89,106 @@ These invariants and conformance test cases are the normative behavioral guarant | 6 | `isAuthorized` with non-existent allowlist-prefixed ID | Returns `false` | | 7 | `renounceAdmin`, then `updateBlocklist` | Reverts | | 8 | `finalizeUpdateAdmin` from non-pending address | Reverts | +| 9 | `isAuthorized(invertedPolicyId(id), account)` where base authorizes account | Returns `false` | +| 10 | `isAuthorized(invertedPolicyId(id), account)` where base denies account | Returns `true` | +| 11 | `isAuthorized(invertedPolicyId(id), account)` where base does not exist | Returns `false` (fail-closed) | +| 12 | `policyExists(invertedPolicyId(id))` where base exists | Returns `true` | +| 13 | `policyExists(invertedPolicyId(id))` where base does not exist | Returns `false` | +| 14 | `policyAdmin(invertedPolicyId(id))` | Returns same address as `policyAdmin(id)` | +| 15 | `pendingPolicyAdmin(invertedPolicyId(id))` | Returns same address as `pendingPolicyAdmin(id)` | +| 16 | `compositePolicyChildIds(invertedPolicyId(id))` where base is composite | Returns same children as base, preserving per-child invert flags | +| 17 | `invertedPolicyId(invertedPolicyId(id))` | Returns `id` | +| 18 | `createCompositePolicy` with an inverted simple-policy child | Succeeds | +| 19 | `createCompositePolicy` with an inverted composite child | Reverts with `InvalidChildPolicy` | +| 20 | `INTERSECT` composite of policy A and `invertedPolicyId(policyB)` — account in A but in B | `isAuthorized` returns `false` | +| 21 | `INTERSECT` composite of policy A and `invertedPolicyId(policyB)` — account in A but not in B | `isAuthorized` returns `true` | ### Roles | # | Scenario | Expected | |---|----------|----------| -| 9 | Grant `MINT_ROLE`, call `mint` | Succeeds | -| 10 | Call `mint` without `MINT_ROLE` | Reverts | -| 11 | One admin remains, call `revokeRole(DEFAULT_ADMIN_ROLE)` | Reverts with `LastAdminCannotRenounce` | -| 12 | Call `renounceLastAdmin()` | Succeeds — token becomes admin-less | -| 13 | After `renounceLastAdmin`, `MINT_ROLE` holder calls `mint` | Succeeds — non-admin roles still work | -| 14 | Deploy with `initialAdmin == address(0)`, call `grantRole` | Reverts | +| 22 | Grant `MINT_ROLE`, call `mint` | Succeeds | +| 23 | Call `mint` without `MINT_ROLE` | Reverts | +| 24 | One admin remains, call `revokeRole(DEFAULT_ADMIN_ROLE)` | Reverts with `LastAdminCannotRenounce` | +| 25 | Call `renounceLastAdmin()` | Succeeds — token becomes admin-less | +| 26 | After `renounceLastAdmin`, `MINT_ROLE` holder calls `mint` | Succeeds — non-admin roles still work | +| 27 | Deploy with `initialAdmin == address(0)`, call `grantRole` | Reverts | ### Transfer Policies | # | Scenario | Expected | |---|----------|----------| -| 15 | Sender on blocklist calls `transfer` | Reverts with `PolicyForbids` | -| 16 | Sender on blocklist calls `approve` | Succeeds | -| 17 | `transferFrom` where executor is on executor blocklist | Reverts | -| 18 | Direct `transfer` by sender on executor blocklist (not sender blocklist) | Succeeds | -| 19 | During `initCalls`, transfer from blocklisted sender | Succeeds — bypass | -| 20 | During `initCalls`, mint to receiver on mint-receiver blocklist | Reverts — never bypassed | +| 28 | Sender on blocklist calls `transfer` | Reverts with `PolicyForbids` | +| 29 | Sender on blocklist calls `approve` | Succeeds | +| 30 | `transferFrom` where executor is on executor blocklist | Reverts | +| 31 | Direct `transfer` by sender on executor blocklist (not sender blocklist) | Succeeds | +| 32 | During `initCalls`, transfer from blocklisted sender | Succeeds — bypass | +| 33 | During `initCalls`, mint to receiver on mint-receiver blocklist | Reverts — never bypassed | ### Mint and Supply Cap | # | Scenario | Expected | |---|----------|----------| -| 21 | Mint that would push `totalSupply` above cap | Reverts with `SupplyCapExceeded` | -| 22 | Mint exactly to cap | Succeeds | -| 23 | `updateSupplyCap` below current `totalSupply` | Reverts with `InvalidSupplyCap` | -| 24 | Burn tokens, then mint up to cap | Succeeds | -| 25 | Mint while `MINT` is paused | Reverts | +| 34 | Mint that would push `totalSupply` above cap | Reverts with `SupplyCapExceeded` | +| 35 | Mint exactly to cap | Succeeds | +| 36 | `updateSupplyCap` below current `totalSupply` | Reverts with `InvalidSupplyCap` | +| 37 | Burn tokens, then mint up to cap | Succeeds | +| 38 | Mint while `MINT` is paused | Reverts | ### Burn and Seize | # | Scenario | Expected | |---|----------|----------| -| 26 | `burnBlocked` on frozen account | Succeeds | -| 27 | `burnBlocked` on non-frozen account | Reverts | -| 28 | `burnBlocked` by holder of `BURN_ROLE` (not `BURN_BLOCKED_ROLE`) | Reverts | -| 29 | Freeze, seize full balance, re-mint to recovery | Succeeds | -| 30 | Burn while `BURN` is paused | Reverts | +| 39 | `burnBlocked` on frozen account | Succeeds | +| 40 | `burnBlocked` on non-frozen account | Reverts | +| 41 | `burnBlocked` by holder of `BURN_ROLE` (not `BURN_BLOCKED_ROLE`) | Reverts | +| 42 | Freeze, seize full balance, re-mint to recovery | Succeeds | +| 43 | Burn while `BURN` is paused | Reverts | ### Pause | # | Scenario | Expected | |---|----------|----------| -| 31 | Pause `TRANSFER`, call `transfer` | Reverts | -| 32 | Pause `TRANSFER`, call `mint` | Succeeds | -| 33 | Pause `TRANSFER`, call `approve` | Succeeds | -| 34 | Pauser calls `unpause` without `UNPAUSE_ROLE` | Reverts | +| 44 | Pause `TRANSFER`, call `transfer` | Reverts | +| 45 | Pause `TRANSFER`, call `mint` | Succeeds | +| 46 | Pause `TRANSFER`, call `approve` | Succeeds | +| 47 | Pauser calls `unpause` without `UNPAUSE_ROLE` | Reverts | ### Memos | # | Scenario | Expected | |---|----------|----------| -| 35 | `transferWithMemo` | Emits `Transfer` then `Memo` at consecutive log indices | -| 36 | `transferFromWithMemo` | `Memo.caller` is `msg.sender`, not `from` | -| 37 | `transfer` (non-memo variant) | No `Memo` event | +| 48 | `transferWithMemo` | Emits `Transfer` then `Memo` at consecutive log indices | +| 49 | `transferFromWithMemo` | `Memo.caller` is `msg.sender`, not `from` | +| 50 | `transfer` (non-memo variant) | No `Memo` event | ### Permit | # | Scenario | Expected | |---|----------|----------| -| 38 | Valid permit with correct signature, nonce, deadline | Succeeds | -| 39 | Permit with expired deadline | Reverts | -| 40 | Replay used permit signature | Reverts | -| 41 | Permit signed before `updateName`, submitted after | Reverts | -| 42 | Contract wallet signature (ERC-1271) | Reverts | -| 43 | Permit while `TRANSFER` is paused | Succeeds | +| 51 | Valid permit with correct signature, nonce, deadline | Succeeds | +| 52 | Permit with expired deadline | Reverts | +| 53 | Replay used permit signature | Reverts | +| 54 | Permit signed before `updateName`, submitted after | Reverts | +| 55 | Contract wallet signature (ERC-1271) | Reverts | +| 56 | Permit while `TRANSFER` is paused | Succeeds | ### Factory | # | Scenario | Expected | |---|----------|----------| -| 44 | `getB20Address` then deploy with same params | Addresses match | -| 45 | Inspect byte 10 of deployed Asset address | Returns `0x00` | -| 46 | Deploy same `(deployer, variant, salt)` twice | Second reverts | -| 47 | Deploy when variant feature not activated | Reverts | -| 48 | `initCalls` that pause `TRANSFER`, then transfer in next initCall | Transfer reverts | +| 57 | `getB20Address` then deploy with same params | Addresses match | +| 58 | Inspect byte 10 of deployed Asset address | Returns `0x00` | +| 59 | Deploy same `(deployer, variant, salt)` twice | Second reverts | +| 60 | Deploy when variant feature not activated | Reverts | +| 61 | `initCalls` that pause `TRANSFER`, then transfer in next initCall | Transfer reverts | ### Variants | # | Scenario | Expected | |---|----------|----------| -| 49 | Deploy Asset with `decimals = 5` | Reverts | -| 50 | Update multiplier to `2e18`, check `balanceOf` for raw balance 100 | Returns 200 | -| 51 | Reuse announcement ID | Reverts with `DuplicateAnnouncementId` | -| 52 | `batchMint` where one recipient is not on allowlist | Reverts | -| 53 | Deploy Stablecoin with `currency = "usd"` | Reverts — `A`–`Z` only | +| 62 | Deploy Asset with `decimals = 5` | Reverts | +| 63 | Update multiplier to `2e18`, check `balanceOf` for raw balance 100 | Returns 200 | +| 64 | Reuse announcement ID | Reverts with `DuplicateAnnouncementId` | +| 65 | `batchMint` where one recipient is not on allowlist | Reverts | +| 66 | Deploy Stablecoin with `currency = "usd"` | Reverts — `A`–`Z` only | diff --git a/docs/specifications/b20/specification-overview.mdx b/docs/specifications/b20/specification-overview.mdx index 85c1a6f42..60954bc03 100644 --- a/docs/specifications/b20/specification-overview.mdx +++ b/docs/specifications/b20/specification-overview.mdx @@ -52,14 +52,14 @@ State-changing PolicyRegistry calls are ActivationRegistry-gated. Read functions | `UNION` | Composite: account is authorized if any child simple policy authorizes it. | | `INTERSECT` | Composite: account is authorized only if every child simple policy authorizes it. | -Composite policies reference existing simple `ALLOWLIST` or `BLOCKLIST` child policies. They cannot reference composites or built-ins as children. +Composite policies reference existing simple `ALLOWLIST` or `BLOCKLIST` child policies. They cannot reference composites or built-ins as children. A child policy ID may carry the invert flag (bit 63); the registry validates and stores the base while preserving the flag. ### Policy IDs Policy IDs are laid out as: ```text Policy ID Layout -[top 8 bits: PolicyType][low 56 bits: counter] +[bit 63: invert flag][bits 62-56: PolicyType][low 56 bits: counter] ``` Counters `0` and `1` are reserved for built-ins: @@ -67,10 +67,27 @@ Counters `0` and `1` are reserved for built-ins: | Built-in | Value | Behavior | |---|---:|---| | `ALWAYS_ALLOW` | `0` | Authorizes every account. | -| `ALWAYS_BLOCK` | `(uint64(ALLOWLIST) << 56) \| 1` | Denies every account. | +| `ALWAYS_BLOCK` | `(uint64(ALLOWLIST) << 56) \| 1` | Denies every account. | Custom policy creation starts at counter `2`. +#### Invert Flag (Bit 63) + +Bit 63 of a policy ID is the invert (NOT) flag. Setting it does not create a new policy — membership stays on the base ID. `isAuthorized` on an inverted ID returns the opposite of the base's result. If the base does not exist, the result is `false` (fail-closed). A mistyped inverted ID never becomes allow-everyone. + +Call `invertedPolicyId(policyId)` to toggle the flag. Inversion is involutive: `invertedPolicyId(invertedPolicyId(id)) == id`. You can also set bit 63 directly. + +Read views strip bit 63 and load the base: + +| View | Inverted-ID behavior | +|---|---| +| `policyExists(policyId)` | Returns existence of the base. | +| `policyAdmin(policyId)` | Returns the base's admin. | +| `pendingPolicyAdmin(policyId)` | Returns the base's pending admin. | +| `compositePolicyChildIds(policyId)` | Returns the base composite's children, with per-child invert flags preserved. | + +An inverted ID is valid in a token scope when its base exists, because `policyExists` strips bit 63. + ### Admin Model Each policy has one admin. Admin transfer is two-step: `stageUpdateAdmin(policyId, newAdmin)` followed by `finalizeUpdateAdmin(policyId)` from the pending admin. `renounceAdmin(policyId)` permanently freezes membership or child-policy updates for that policy. @@ -79,11 +96,12 @@ Each policy has one admin. Admin transfer is two-step: `stageUpdateAdmin(policyI | Method | Description | |---|---| -| `isAuthorized(policyId, account)` | Returns authorization and never reverts for uncreated IDs. | -| `policyExists(policyId)` | Returns whether a policy exists. | -| `policyAdmin(policyId)` | Returns the current admin or zero. | -| `pendingPolicyAdmin(policyId)` | Returns the staged admin or zero. | -| `compositePolicyChildIds(policyId)` | Returns child policy IDs for composite policies. | +| `isAuthorized(policyId, account)` | Returns authorization and never reverts for uncreated IDs. If bit 63 is set, returns the negated result of the base; fail-closed if the base does not exist. | +| `policyExists(policyId)` | Returns whether a policy exists; strips bit 63 and checks the base. | +| `policyAdmin(policyId)` | Returns the current admin or zero; strips bit 63. | +| `pendingPolicyAdmin(policyId)` | Returns the staged admin or zero; strips bit 63. | +| `compositePolicyChildIds(policyId)` | Returns child policy IDs for composite policies; strips bit 63, returns children as stored including per-child invert flags. | +| `invertedPolicyId(policyId)` | Toggles bit 63; never reverts, reads no state. | `isAuthorized` collapses uncreated IDs to empty-set semantics. Callers that write policy IDs into token scopes must validate `policyExists` unless writing a built-in.