From e4709764f8e976971496674573ac2fa507cad905 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:51:06 +0000 Subject: [PATCH 1/3] docs: sync from base-std@3820cf0 --- ...cobalt-policyregistry-composite-policy.mdx | 24 +++++++++++++++---- .../03-denim-policyregistry-not-policy.mdx | 18 +++++++++++--- .../integrate-defi/list-tokenized-stocks.mdx | 8 ++++++- .../restrict-transfer-initiators.mdx | 2 ++ .../issue-rwa/seize-and-cancel-units.mdx | 2 ++ .../issue-stablecoins/block-an-account.mdx | 4 ++++ .../restrict-who-can-hold.mdx | 8 +++++++ docs/specifications/b20/concepts/policies.mdx | 19 ++++++++++++--- docs/specifications/b20/introduction.mdx | 11 ++++++++- .../b20/reference/constants.mdx | 2 +- .../interfaces/i-policy-registry/index.mdx | 2 +- .../i-policy-registry/is-authorized.mdx | 23 ++++++++++++++---- docs/upgrades/beryl/b20.mdx | 11 +++++++-- 13 files changed, 113 insertions(+), 21 deletions(-) diff --git a/docs/base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy.mdx b/docs/base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy.mdx index 20561b309..1dc3df0b6 100644 --- a/docs/base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy.mdx +++ b/docs/base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy.mdx @@ -140,7 +140,7 @@ revert with `IncompatiblePolicyType` if their `policyType` is `UNION` or `INTERS #### Authorization Evaluation -`isAuthorized` evaluates composites as follows: +`isAuthorized` never reverts and evaluates composites as follows: ```text Authorization Evaluation lines expandable wrap highlight={1-12} isAuthorized(policyId, account): @@ -170,10 +170,20 @@ deduplicates them. A child remains effective after its administrator renounces a renunciation freezes future membership updates but does not delete the policy or change its current authorization result. -A well-formed but never-created `UNION` ID has no children and returns `false`; a well-formed but -never-created `INTERSECT` ID has no children and returns `true`. Consumers that store policy IDs -must call `policyExists(policyId)` before storing them. Otherwise, an invalid `INTERSECT` ID can -behave like `ALWAYS_ALLOW`. +#### Malformed, Unknown, and Inverted IDs + +`isAuthorized` distinguishes malformed IDs from well-formed unknown IDs: + +| Policy ID | Result | +| --- | --- | +| Malformed | `false`. The invert flag (bit 63) is cleared before the type check, so an ID is malformed when the remaining top byte is outside `PolicyType`. | +| Well-formed but unknown `ALLOWLIST` or `UNION` | `false`, because the member or child set is empty. | +| Well-formed but unknown `BLOCKLIST` or `INTERSECT` | `true`, because the member or child set is empty. | +| Inverted ID whose base exists | The negated result of the base. | +| Inverted ID whose base is unknown or malformed | `false`. | + +Consumers that store policy IDs must call `policyExists(policyId)` before storing them. Otherwise, an +unknown `BLOCKLIST` or `INTERSECT` ID can behave like `ALWAYS_ALLOW`. #### State Changes @@ -333,3 +343,7 @@ To replace a flattened policy: B20 treats the composite ID as the same opaque `uint64` policy ID it uses for simple policies, so no B20 contract change is required. + +If you store policy IDs, validate `policyExists(policyId)` at write time. `isAuthorized` returns +`false` for a malformed ID and for an inverted ID whose base is unknown or malformed, rather than +reverting. 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 index 1a967a355..6051cd33b 100644 --- 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 @@ -54,7 +54,7 @@ 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. | +| `isAuthorized(uint64,address)` | Unchanged | Extended | An inverted ID resolves the base and returns the negated result when the base exists. An inverted unknown or malformed base returns `false`. | | `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. | @@ -69,7 +69,8 @@ function invertedPolicyId(uint64 policyId) external view returns (uint64); #### Authorization -`isAuthorized` gains a leading invert branch. All non-inverted paths are unchanged. +`isAuthorized` gains a leading invert branch. All non-inverted paths are unchanged. `isAuthorized` +never reverts. ```text Authorization Evaluation lines expandable wrap highlight={2-6} isAuthorized(policyId, account): @@ -83,7 +84,16 @@ isAuthorized(policyId, account): ``` Inverting a composite negates the composite's combined result. An inverted ID over a never-created -base returns `false`; it never becomes allow-everyone. +or malformed base returns `false`; it never becomes allow-everyone. + +The following table shows how `isAuthorized` resolves each kind of ID. + +| ID | Result | +| --- | --- | +| Malformed | `false`. The invert flag (bit 63) is cleared before the type check, so an ID is malformed when the remaining top byte is outside `PolicyType`. | +| Well-formed but unknown | Treated as an empty set: `ALLOWLIST` and `UNION` return `false`; `BLOCKLIST` and `INTERSECT` return `true`. | +| Inverted, base exists | The negated result of the base. | +| Inverted, base unknown or malformed | `false`. | #### Getters Strip to Base @@ -162,6 +172,8 @@ composites are unaffected. To adopt: 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. +4. Do not rely on an inverted ID to allow an unknown or malformed base. `isAuthorized` returns + `false` for it, and returns `false` for any malformed ID. For the current interface, see the [IPolicyRegistry reference](/specifications/b20/reference/interfaces/i-policy-registry/index). diff --git a/docs/build-on-base/integrate-defi/list-tokenized-stocks.mdx b/docs/build-on-base/integrate-defi/list-tokenized-stocks.mdx index 36a2c2216..20ce82b6e 100644 --- a/docs/build-on-base/integrate-defi/list-tokenized-stocks.mdx +++ b/docs/build-on-base/integrate-defi/list-tokenized-stocks.mdx @@ -60,7 +60,13 @@ The B20 contract provides ERC-8056 helper functions for common calculations, whe To comply with any regulatory requirements that apply to a particular asset, [policies](/specifications/b20/reference/interfaces/i-policy-registry) that manage allowlists and blocklists may be implemented. Policies determine whether a transfer is allowed or rejected. -The B20 contract provides the [`isAuthorized(policyID, account)`](/specifications/b20/reference/interfaces/i-policy-registry/is-authorized) function, which you can use to determine whether a specific account is allowed to transfer funds. +The B20 contract provides the [`isAuthorized(policyID, account)`](/specifications/b20/reference/interfaces/i-policy-registry/is-authorized) function, which you can use to determine whether a specific account is allowed to transfer funds. It never reverts, and its result depends on the policy ID: + +- **Malformed ID:** returns `false`. +- **Well-formed but unknown ID:** treated as an empty set. `ALLOWLIST` and `UNION` policies return `false`; `BLOCKLIST` and `INTERSECT` policies return `true`. +- **Inverted ID:** returns the negated result of the base policy when the base exists. An inverted unknown or malformed base returns `false`. + +Validate `policyExists(policyId)` before you store a policy ID, so an unknown or malformed ID is never interpreted as an authorization result. The standard [`approve()`](/specifications/b20/reference/interfaces/ib20/approve) function is not policy gated. Checking whether a quantity of funds is approved to be transferred does not guarantee that the funds aren't blocked by a policy. diff --git a/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx b/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx index f7575aeed..5ac621ae7 100644 --- a/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx +++ b/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx @@ -95,6 +95,8 @@ The initiator moves units with `transferFrom(holder, recipient, amount)`. The ho `Transfer(from, to, amount)` appears on the initiator's `transferFrom`. A holder's direct `transfer` reverts with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorId)`; that revert confirms the restriction is active, not that something is misconfigured. `isAuthorized(executorId, account)` on the Policy Registry returns `true` for the initiator and `false` for the holder. +`isAuthorized` never reverts, and a malformed policy ID returns `false`. When you store or pass an ID, confirm `policyExists(policyId)` first so a bad ID is not mistaken for a denied account. + Seed every intended initiator before binding the allowlist. Once attached, any account not on it, including the issuer, can no longer initiate transfers until it is added. diff --git a/docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx b/docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx index 21282b6fa..80ef306f2 100644 --- a/docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx +++ b/docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx @@ -45,6 +45,8 @@ How the seize scope works: Because the holder check is inverted, attach a **blocklist** to `SEIZE_EXEMPT_POLICY`. Accounts on the list are unauthorized and therefore seizable; every other holder stays exempt. Never attach an allowlist or `ALWAYS_BLOCK` to this scope: an empty allowlist authorizes nobody, so every holder would be seizable. +`isAuthorized` never reverts, so a bad policy ID does not fail loudly. A malformed ID returns false, and a well-formed but unknown ID behaves as an empty set: `ALLOWLIST` and `UNION` return false, while `BLOCKLIST` and `INTERSECT` return true. An inverted ID negates the base result only when the base exists; an inverted unknown or malformed base returns false. On `SEIZE_EXEMPT_POLICY`, a false result makes the holder seizable, so attach only IDs that exist in the Policy Registry. + ## Seize, Cancel, and Verify Grant the roles once per token: diff --git a/docs/build-on-base/issue-stablecoins/block-an-account.mdx b/docs/build-on-base/issue-stablecoins/block-an-account.mdx index c249ac0bc..f0fa261eb 100644 --- a/docs/build-on-base/issue-stablecoins/block-an-account.mdx +++ b/docs/build-on-base/issue-stablecoins/block-an-account.mdx @@ -74,6 +74,10 @@ See the [B20 token standard](/specifications/b20) for the complete interface, ro This only stops outgoing transfers when the blocklist is bound to `TRANSFER_SENDER_POLICY`. Unblock with the same call and `false`. + +`isAuthorized` never reverts, so a wrong `policyId` can look like a valid result. A well-formed ID that doesn't exist is treated as an empty set: an empty blocklist returns `true` for every account. A malformed ID returns `false`. An inverted ID (from `invertedPolicyId`) returns the negated result only when its base exists; an inverted unknown or malformed base returns `false`. Confirm `policyExists(policyId)` before you rely on the check. + + ## Make a Blocked Account Recoverable Blocking under `TRANSFER_SENDER_POLICY` freezes the account's outgoing transfers. It does not, on its own, let you move the balance out. Seize reads a separate scope, `SEIZE_EXEMPT_POLICY`, whose check is inverted: an account that is **not** authorized under the attached policy is seizable. Attach the same blocklist there so a single hold both freezes the account and makes it recoverable: 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 03a3befa2..6a0469f05 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,14 @@ 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. +`isAuthorized` never reverts, so a bad policy ID fails silently rather than with an error: + +- A malformed policy ID returns `false`. +- A well-formed but unknown policy ID behaves as an empty set. `ALLOWLIST` and `UNION` return `false`; `BLOCKLIST` and `INTERSECT` return `true`. +- An inverted policy ID returns the negated result of its base when that base exists. An inverted unknown or malformed base returns `false`. + +Validate `policyExists(policyId)` before you store or bind a policy ID. + ## Create and Bind a Holder Allowlist diff --git a/docs/specifications/b20/concepts/policies.mdx b/docs/specifications/b20/concepts/policies.mdx index 8414e3522..3e23df4ae 100644 --- a/docs/specifications/b20/concepts/policies.mdx +++ b/docs/specifications/b20/concepts/policies.mdx @@ -87,7 +87,7 @@ An issuer may want the opposite of an existing policy without a second member se Call `invertedPolicyId(policyId)` to set or clear that bit. You can also set bit 63 yourself. Bind the inverted ID to a token scope, or pass it as a composite child ("A AND NOT X"). The flag applies to every type: `ALLOWLIST`, `BLOCKLIST`, `UNION`, and `INTERSECT`. -`isAuthorized` on an inverted ID returns the opposite of the base. If the base does not exist, the result is `false`. That fail-closed guard prevents a mistyped inverted ID from becoming allow-everyone. +`isAuthorized` on an inverted ID returns the opposite of the base when that base exists. If the base is unknown or malformed, the result is `false`. That fail-closed guard prevents a mistyped inverted ID from becoming allow-everyone. Read views strip bit 63 and load the base. `policyExists` and `policyAdmin` on an inverted ID match the base. An inverted ID has no record of its own. @@ -102,6 +102,21 @@ flowchart TD A composite child ID may carry the invert bit. The registry checks existence and simple type against the base. An inverted simple child is valid. An inverted composite child reverts `InvalidChildPolicy`. Across the whole child set, `PolicyNotFound` still takes precedence over `InvalidChildPolicy`. +#### Malformed and unknown IDs + +`isAuthorized` clears bit 63 before it checks the policy type. An ID is malformed when the remaining top byte is outside `PolicyType`. A malformed ID returns `false`. + +A well-formed ID that does not exist behaves as an empty set: + + +| Type | `isAuthorized` on an unknown ID | +| ------------------------- | ------------------------------- | +| `ALLOWLIST` and `UNION` | `false` | +| `BLOCKLIST` and `INTERSECT` | `true` | + + +Callers that store policy IDs must check `policyExists(policyId)` at write time rather than rely on these results. + ### 2.4 Creating and updating Anyone can create a policy. The create call names a single `admin`. That address is the only one that can later change membership, replace a composite's children, transfer administration, or renounce. The creator does not have to be the admin. `admin` cannot be `address(0)`. @@ -425,5 +440,3 @@ A later `updateAllowlist` that adds or removes Carol on `exclusionId` changes th | `ChildPoliciesOutsideOfRange()` | A composite's child count is outside `[2, 4]` | | `InvalidChildPolicy(childPolicyId)` | A composite child is not an existing simple policy | | `NonPayable()` | ETH was attached to a registry call | - - diff --git a/docs/specifications/b20/introduction.mdx b/docs/specifications/b20/introduction.mdx index a8ed537eb..f81bae1f2 100644 --- a/docs/specifications/b20/introduction.mdx +++ b/docs/specifications/b20/introduction.mdx @@ -143,6 +143,16 @@ Those lists live in the Policy Registry, a global singleton precompile, not on t A token admin binds a policy ID to a policy scope with `updatePolicy`. A scope sits in a similar place to a hook: it runs on a specific function. When that function runs, the token asks the registry `isAuthorized(policyId, account)` and reverts with `PolicyForbids` if the check fails. Which scope runs on which function is in [Policies](/specifications/b20/concepts/policies). +`isAuthorized` never reverts, including for IDs that do not resolve to a stored policy: + +- A malformed policy ID returns `false`. +- A well-formed but unknown ID behaves as an empty set. ALLOWLIST and UNION return `false`; BLOCKLIST and INTERSECT return `true`. +- An inverted ID returns the negated result of its base when that base exists. An inverted unknown or malformed base returns `false`. + + +Because `isAuthorized` does not revert on unknown IDs, validate `policyExists(policyId)` when you store a policy ID. A mistyped ID does not fail loudly: it resolves as an empty set, and a malformed or inverted-unknown ID resolves to `false`. + + A policy-gated transfer looks like this: ```mermaid Integrating Compliance Checks Diagram lines wrap expandable highlight={1} @@ -171,4 +181,3 @@ sequenceDiagram 3. On `transfer`, the token asks the registry whether the receiver is authorized. 4. Authorized: the call continues. Denied: the call reverts with `PolicyForbids`. 5. Unset scopes default to always-allow. `approve` is not policy-gated. - diff --git a/docs/specifications/b20/reference/constants.mdx b/docs/specifications/b20/reference/constants.mdx index c8cfa4df0..1f9907f59 100644 --- a/docs/specifications/b20/reference/constants.mdx +++ b/docs/specifications/b20/reference/constants.mdx @@ -12,7 +12,7 @@ description: "B20 precompile addresses, role identifiers, policy scopes, and val | Name | Value | Purpose | |---|---|---| | `B20_FACTORY_ADDRESS` | `0xB20f000000000000000000000000000000000000` | Deploys and looks up B-20 tokens; every asset and stablecoin instance is created through the [`IB20Factory`](/specifications/b20/reference/interfaces#ib20factory) at this address. | -| `POLICY_REGISTRY_ADDRESS` | `0x8453000000000000000000000000000000000002` | Stores allowlist/blocklist/composite policies and answers `isAuthorized` checks consulted by every policy scope (see [Policies](/specifications/b20/concepts/policies)). | +| `POLICY_REGISTRY_ADDRESS` | `0x8453000000000000000000000000000000000002` | Stores allowlist/blocklist/composite policies and answers `isAuthorized` checks consulted by every policy scope (see [Policies](/specifications/b20/concepts/policies)). `isAuthorized` never reverts and returns false for a malformed policy ID. | | `ACTIVATION_REGISTRY_ADDRESS` | `0x8453000000000000000000000000000000000001` | Gates whether a B-20 variant or feature is live on a given chain; checked by the factory before it will create that variant. | ## Roles diff --git a/docs/specifications/b20/reference/interfaces/i-policy-registry/index.mdx b/docs/specifications/b20/reference/interfaces/i-policy-registry/index.mdx index 79a07d469..63b3756d8 100644 --- a/docs/specifications/b20/reference/interfaces/i-policy-registry/index.mdx +++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/index.mdx @@ -18,7 +18,7 @@ description: "Generated B20 reference for IPolicyRegistry functions, events, and | [`updateAllowlist`](/specifications/b20/reference/interfaces/i-policy-registry/update-allowlist) | `0x3388fb5b` | Sets `accounts` membership in an ALLOWLIST policy to `allowed` in one batch. | | [`updateBlocklist`](/specifications/b20/reference/interfaces/i-policy-registry/update-blocklist) | `0x5c4e51b8` | Sets `accounts` membership in a BLOCKLIST policy to `blocked` in one batch. | | [`updateComposite`](/specifications/b20/reference/interfaces/i-policy-registry/update-composite) | `0xbfe142c0` | Replaces a composite policy's child-policy set in full with `childPolicyIds`. | -| [`isAuthorized`](/specifications/b20/reference/interfaces/i-policy-registry/is-authorized) | `0x55a1179e` | Returns whether `account` is authorized under `policyId`. Never reverts; unknown | +| [`isAuthorized`](/specifications/b20/reference/interfaces/i-policy-registry/is-authorized) | `0x55a1179e` | Returns whether `account` is authorized under `policyId`. Never reverts. A malformed ID returns false; a well-formed unknown ID is an empty set (ALLOWLIST and UNION return false, BLOCKLIST and INTERSECT return true). An inverted ID negates the base result only when the base exists; an inverted unknown or malformed base returns false. | | [`MIN_COMPOSITE_CHILD_POLICIES`](/specifications/b20/reference/interfaces/i-policy-registry/min-composite-child-policies) | `0xb3ae29f7` | Minimum number of child policies a composite must reference, inclusive. Never reverts. | | [`MAX_COMPOSITE_CHILD_POLICIES`](/specifications/b20/reference/interfaces/i-policy-registry/max-composite-child-policies) | `0x54309870` | Maximum number of child policies a composite may reference, inclusive. Never reverts. | | [`policyExists`](/specifications/b20/reference/interfaces/i-policy-registry/policy-exists) | `0x330f5637` | Returns whether `policyId` is a built-in sentinel or a previously-assigned custom ID. Never reverts. | 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..75d477893 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,6 +1,6 @@ --- title: "IPolicyRegistry.isAuthorized" -description: "Generated B20 reference for isAuthorized(uint64,address)." +description: "Returns whether an account is authorized under a policy ID, including results for malformed, unknown, and inverted IDs." --- @@ -18,14 +18,29 @@ 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). +Returns whether `account` is authorized under `policyId`. Never reverts. + 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. +## Result by policy ID + +| Policy ID | Result | +|---|---| +| Malformed | `false`. The invert flag (bit 63) is cleared before the type check, so an ID is malformed when the remaining top byte is outside `PolicyType`. | +| Well-formed but unknown, ALLOWLIST or UNION | `false`. An unknown ID is treated as an empty set. | +| Well-formed but unknown, BLOCKLIST or INTERSECT | `true`. An unknown ID is treated as an empty set. | +| Inverted, base exists | The negated result of the base. Applies to every policy type. | +| Inverted, base unknown or malformed | `false` | + +`isAuthorized(invertedPolicyId(id), account)` returns the negated result of the base only when that base exists. + + +A malformed policy ID returns `false` rather than reverting. An inverted unknown or malformed base also returns `false`, not the negation of the empty-set result. Validate `policyExists(policyId)` before you store a policy ID. + + ## Access Control Read-only or ERC-20-standard access rules unless the NatSpec states otherwise. diff --git a/docs/upgrades/beryl/b20.mdx b/docs/upgrades/beryl/b20.mdx index 7f03c7e76..1380a5fb1 100644 --- a/docs/upgrades/beryl/b20.mdx +++ b/docs/upgrades/beryl/b20.mdx @@ -82,7 +82,14 @@ Two built-in IDs require no creation: | `ALWAYS_ALLOW` | `0` | Authorizes every account unconditionally. Default scope value on new B20 tokens. | | `ALWAYS_BLOCK` | `(uint64(ALLOWLIST) << 56) \| 1` | Denies every account unconditionally. | -`isAuthorized` never reverts on a non-existent policy ID - it collapses to empty-member-set semantics (non-existent `BLOCKLIST` authorizes everyone; non-existent `ALLOWLIST` denies everyone). +`isAuthorized` never reverts. Its result depends on the ID: + +| Policy ID | `isAuthorized` result | +|-----------|-----------------------| +| Malformed (after clearing the invert flag, bit 63, the top byte is outside `PolicyType`) | `false` | +| Well-formed but non-existent | Empty-member-set semantics: `ALLOWLIST` and `UNION` return `false`; `BLOCKLIST` and `INTERSECT` return `true` | +| Inverted (`invertedPolicyId(id)`) with an existing base | The negated result of the base | +| Inverted with a non-existent or malformed base | `false` | Consumers that write a policy ID (e.g. `updatePolicy`) MUST validate `policyExists(policyId)` at write time to avoid silently binding to an unintended empty-set policy. @@ -110,7 +117,7 @@ policyRegistry.updateBlocklist(policyId, false, accounts); // unblock these acc | Method | Description | |--------|-------------| -| `isAuthorized(policyId, account)` | Whether `account` is authorized under `policyId`. Never reverts. | +| `isAuthorized(policyId, account)` | Whether `account` is authorized under `policyId`. Never reverts. Returns `false` for a malformed ID. | | `policyExists(policyId)` | Whether a policy with this ID has been created. | | `policyAdmin(policyId)` | Current admin address. | | `pendingPolicyAdmin(policyId)` | Pending admin during a two-step transfer. | From dde43737a814bf14c58579a0c04d6a8eab2d8a8a Mon Sep 17 00:00:00 2001 From: sohey Date: Mon, 5 Oct 2026 18:56:01 +0200 Subject: [PATCH 2/3] docs: trim isAuthorized restatements to the pages that need them The sync repeated the full malformed/unknown/inverted isAuthorized rules on every page that mentions isAuthorized. Keep the canonical table on the is-authorized reference and the Policies concept page; elsewhere use one sentence and a link. - Revert restrict-transfer-initiators and restrict-who-can-hold (passing mentions only). - Revert the Cobalt changelog and Beryl upgrade pages: historical records, and this source change is a NatSpec clarification, not a Cobalt/Beryl change. - Seize and Cancel Units, Block an Account: keep the task-specific risk in one sentence and link to the reference. - List Tokenized Stocks, B20 introduction: one sentence each. - is-authorized: turn leftover Dev/Param/Return lines into Parameters and Returns sections and fix Access Control (as approved in #2054). Co-authored-by: Toshi --- ...cobalt-policyregistry-composite-policy.mdx | 24 ++++--------------- .../integrate-defi/list-tokenized-stocks.mdx | 8 +------ .../restrict-transfer-initiators.mdx | 2 -- .../issue-rwa/seize-and-cancel-units.mdx | 2 +- .../issue-stablecoins/block-an-account.mdx | 6 ++--- .../restrict-who-can-hold.mdx | 8 ------- docs/specifications/b20/concepts/policies.mdx | 12 +++++----- docs/specifications/b20/introduction.mdx | 11 ++------- .../i-policy-registry/is-authorized.mdx | 24 ++++++++++++------- docs/upgrades/beryl/b20.mdx | 11 ++------- 10 files changed, 35 insertions(+), 73 deletions(-) diff --git a/docs/base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy.mdx b/docs/base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy.mdx index 1dc3df0b6..20561b309 100644 --- a/docs/base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy.mdx +++ b/docs/base-chain/specs/reference/b20/changelog/02-cobalt-policyregistry-composite-policy.mdx @@ -140,7 +140,7 @@ revert with `IncompatiblePolicyType` if their `policyType` is `UNION` or `INTERS #### Authorization Evaluation -`isAuthorized` never reverts and evaluates composites as follows: +`isAuthorized` evaluates composites as follows: ```text Authorization Evaluation lines expandable wrap highlight={1-12} isAuthorized(policyId, account): @@ -170,20 +170,10 @@ deduplicates them. A child remains effective after its administrator renounces a renunciation freezes future membership updates but does not delete the policy or change its current authorization result. -#### Malformed, Unknown, and Inverted IDs - -`isAuthorized` distinguishes malformed IDs from well-formed unknown IDs: - -| Policy ID | Result | -| --- | --- | -| Malformed | `false`. The invert flag (bit 63) is cleared before the type check, so an ID is malformed when the remaining top byte is outside `PolicyType`. | -| Well-formed but unknown `ALLOWLIST` or `UNION` | `false`, because the member or child set is empty. | -| Well-formed but unknown `BLOCKLIST` or `INTERSECT` | `true`, because the member or child set is empty. | -| Inverted ID whose base exists | The negated result of the base. | -| Inverted ID whose base is unknown or malformed | `false`. | - -Consumers that store policy IDs must call `policyExists(policyId)` before storing them. Otherwise, an -unknown `BLOCKLIST` or `INTERSECT` ID can behave like `ALWAYS_ALLOW`. +A well-formed but never-created `UNION` ID has no children and returns `false`; a well-formed but +never-created `INTERSECT` ID has no children and returns `true`. Consumers that store policy IDs +must call `policyExists(policyId)` before storing them. Otherwise, an invalid `INTERSECT` ID can +behave like `ALWAYS_ALLOW`. #### State Changes @@ -343,7 +333,3 @@ To replace a flattened policy: B20 treats the composite ID as the same opaque `uint64` policy ID it uses for simple policies, so no B20 contract change is required. - -If you store policy IDs, validate `policyExists(policyId)` at write time. `isAuthorized` returns -`false` for a malformed ID and for an inverted ID whose base is unknown or malformed, rather than -reverting. diff --git a/docs/build-on-base/integrate-defi/list-tokenized-stocks.mdx b/docs/build-on-base/integrate-defi/list-tokenized-stocks.mdx index 20ce82b6e..da7f4284e 100644 --- a/docs/build-on-base/integrate-defi/list-tokenized-stocks.mdx +++ b/docs/build-on-base/integrate-defi/list-tokenized-stocks.mdx @@ -60,13 +60,7 @@ The B20 contract provides ERC-8056 helper functions for common calculations, whe To comply with any regulatory requirements that apply to a particular asset, [policies](/specifications/b20/reference/interfaces/i-policy-registry) that manage allowlists and blocklists may be implemented. Policies determine whether a transfer is allowed or rejected. -The B20 contract provides the [`isAuthorized(policyID, account)`](/specifications/b20/reference/interfaces/i-policy-registry/is-authorized) function, which you can use to determine whether a specific account is allowed to transfer funds. It never reverts, and its result depends on the policy ID: - -- **Malformed ID:** returns `false`. -- **Well-formed but unknown ID:** treated as an empty set. `ALLOWLIST` and `UNION` policies return `false`; `BLOCKLIST` and `INTERSECT` policies return `true`. -- **Inverted ID:** returns the negated result of the base policy when the base exists. An inverted unknown or malformed base returns `false`. - -Validate `policyExists(policyId)` before you store a policy ID, so an unknown or malformed ID is never interpreted as an authorization result. +The B20 contract provides the [`isAuthorized(policyID, account)`](/specifications/b20/reference/interfaces/i-policy-registry/is-authorized) function, which you can use to determine whether a specific account is allowed to transfer funds. It never reverts, so a policy ID that doesn't exist still returns a result; check `policyExists(policyId)` before you rely on it. The standard [`approve()`](/specifications/b20/reference/interfaces/ib20/approve) function is not policy gated. Checking whether a quantity of funds is approved to be transferred does not guarantee that the funds aren't blocked by a policy. diff --git a/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx b/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx index 5ac621ae7..f7575aeed 100644 --- a/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx +++ b/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx @@ -95,8 +95,6 @@ The initiator moves units with `transferFrom(holder, recipient, amount)`. The ho `Transfer(from, to, amount)` appears on the initiator's `transferFrom`. A holder's direct `transfer` reverts with `PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorId)`; that revert confirms the restriction is active, not that something is misconfigured. `isAuthorized(executorId, account)` on the Policy Registry returns `true` for the initiator and `false` for the holder. -`isAuthorized` never reverts, and a malformed policy ID returns `false`. When you store or pass an ID, confirm `policyExists(policyId)` first so a bad ID is not mistaken for a denied account. - Seed every intended initiator before binding the allowlist. Once attached, any account not on it, including the issuer, can no longer initiate transfers until it is added. diff --git a/docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx b/docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx index 80ef306f2..e5d767d3a 100644 --- a/docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx +++ b/docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx @@ -45,7 +45,7 @@ How the seize scope works: Because the holder check is inverted, attach a **blocklist** to `SEIZE_EXEMPT_POLICY`. Accounts on the list are unauthorized and therefore seizable; every other holder stays exempt. Never attach an allowlist or `ALWAYS_BLOCK` to this scope: an empty allowlist authorizes nobody, so every holder would be seizable. -`isAuthorized` never reverts, so a bad policy ID does not fail loudly. A malformed ID returns false, and a well-formed but unknown ID behaves as an empty set: `ALLOWLIST` and `UNION` return false, while `BLOCKLIST` and `INTERSECT` return true. An inverted ID negates the base result only when the base exists; an inverted unknown or malformed base returns false. On `SEIZE_EXEMPT_POLICY`, a false result makes the holder seizable, so attach only IDs that exist in the Policy Registry. +`isAuthorized` never reverts: a malformed ID, or an inverted ID whose base doesn't exist, returns `false`. On `SEIZE_EXEMPT_POLICY`, `false` makes the holder seizable, so confirm `policyExists(policyId)` before you attach an ID. See [`isAuthorized`](/specifications/b20/reference/interfaces/i-policy-registry/is-authorized) for the full rules. ## Seize, Cancel, and Verify diff --git a/docs/build-on-base/issue-stablecoins/block-an-account.mdx b/docs/build-on-base/issue-stablecoins/block-an-account.mdx index f0fa261eb..92f1843d7 100644 --- a/docs/build-on-base/issue-stablecoins/block-an-account.mdx +++ b/docs/build-on-base/issue-stablecoins/block-an-account.mdx @@ -74,9 +74,9 @@ See the [B20 token standard](/specifications/b20) for the complete interface, ro This only stops outgoing transfers when the blocklist is bound to `TRANSFER_SENDER_POLICY`. Unblock with the same call and `false`. - -`isAuthorized` never reverts, so a wrong `policyId` can look like a valid result. A well-formed ID that doesn't exist is treated as an empty set: an empty blocklist returns `true` for every account. A malformed ID returns `false`. An inverted ID (from `invertedPolicyId`) returns the negated result only when its base exists; an inverted unknown or malformed base returns `false`. Confirm `policyExists(policyId)` before you rely on the check. - + +`isAuthorized` never reverts. A blocklist ID that doesn't exist returns `true` for every account, so confirm `policyExists(policyId)` before you rely on the check. See [`isAuthorized`](/specifications/b20/reference/interfaces/i-policy-registry/is-authorized) for how malformed, unknown, and inverted IDs resolve. + ## Make a Blocked Account Recoverable 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 6a0469f05..03a3befa2 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,14 +28,6 @@ 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. -`isAuthorized` never reverts, so a bad policy ID fails silently rather than with an error: - -- A malformed policy ID returns `false`. -- A well-formed but unknown policy ID behaves as an empty set. `ALLOWLIST` and `UNION` return `false`; `BLOCKLIST` and `INTERSECT` return `true`. -- An inverted policy ID returns the negated result of its base when that base exists. An inverted unknown or malformed base returns `false`. - -Validate `policyExists(policyId)` before you store or bind a policy ID. - ## Create and Bind a Holder Allowlist diff --git a/docs/specifications/b20/concepts/policies.mdx b/docs/specifications/b20/concepts/policies.mdx index 3e23df4ae..7ceffff03 100644 --- a/docs/specifications/b20/concepts/policies.mdx +++ b/docs/specifications/b20/concepts/policies.mdx @@ -108,12 +108,10 @@ A composite child ID may carry the invert bit. The registry checks existence and A well-formed ID that does not exist behaves as an empty set: - -| Type | `isAuthorized` on an unknown ID | -| ------------------------- | ------------------------------- | -| `ALLOWLIST` and `UNION` | `false` | -| `BLOCKLIST` and `INTERSECT` | `true` | - +| Type | `isAuthorized` on an unknown ID | +| --- | --- | +| `ALLOWLIST` and `UNION` | `false` | +| `BLOCKLIST` and `INTERSECT` | `true` | Callers that store policy IDs must check `policyExists(policyId)` at write time rather than rely on these results. @@ -440,3 +438,5 @@ A later `updateAllowlist` that adds or removes Carol on `exclusionId` changes th | `ChildPoliciesOutsideOfRange()` | A composite's child count is outside `[2, 4]` | | `InvalidChildPolicy(childPolicyId)` | A composite child is not an existing simple policy | | `NonPayable()` | ETH was attached to a registry call | + + diff --git a/docs/specifications/b20/introduction.mdx b/docs/specifications/b20/introduction.mdx index f81bae1f2..2dd4a81d8 100644 --- a/docs/specifications/b20/introduction.mdx +++ b/docs/specifications/b20/introduction.mdx @@ -143,15 +143,7 @@ Those lists live in the Policy Registry, a global singleton precompile, not on t A token admin binds a policy ID to a policy scope with `updatePolicy`. A scope sits in a similar place to a hook: it runs on a specific function. When that function runs, the token asks the registry `isAuthorized(policyId, account)` and reverts with `PolicyForbids` if the check fails. Which scope runs on which function is in [Policies](/specifications/b20/concepts/policies). -`isAuthorized` never reverts, including for IDs that do not resolve to a stored policy: - -- A malformed policy ID returns `false`. -- A well-formed but unknown ID behaves as an empty set. ALLOWLIST and UNION return `false`; BLOCKLIST and INTERSECT return `true`. -- An inverted ID returns the negated result of its base when that base exists. An inverted unknown or malformed base returns `false`. - - -Because `isAuthorized` does not revert on unknown IDs, validate `policyExists(policyId)` when you store a policy ID. A mistyped ID does not fail loudly: it resolves as an empty set, and a malformed or inverted-unknown ID resolves to `false`. - +`isAuthorized` never reverts, even for a policy ID that doesn't exist: a malformed ID returns `false`, and an unknown ID behaves as an empty set. Validate `policyExists(policyId)` when you store a policy ID. See [`isAuthorized`](/specifications/b20/reference/interfaces/i-policy-registry/is-authorized) for every case. A policy-gated transfer looks like this: @@ -181,3 +173,4 @@ sequenceDiagram 3. On `transfer`, the token asks the registry whether the receiver is authorized. 4. Authorized: the call continues. Denied: the call reverts with `PolicyForbids`. 5. Unset scopes default to always-allow. `approve` is not policy-gated. + 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 75d477893..4bb2f4759 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 @@ -20,11 +20,6 @@ function isAuthorized(uint64 policyId, address account) external view returns (b Returns whether `account` is authorized under `policyId`. Never reverts. -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. - ## Result by policy ID | Policy ID | Result | @@ -35,15 +30,26 @@ Return: Whether `account` is authorized. | Inverted, base exists | The negated result of the base. Applies to every policy type. | | Inverted, base unknown or malformed | `false` | -`isAuthorized(invertedPolicyId(id), account)` returns the negated result of the base only when that base exists. - -A malformed policy ID returns `false` rather than reverting. An inverted unknown or malformed base also returns `false`, not the negation of the empty-set result. Validate `policyExists(policyId)` before you store a policy ID. +Callers that store policy IDs MUST validate `policyExists(policyId)` at write time. An unknown `BLOCKLIST` or `INTERSECT` ID returns `true`, and an inverted unknown or malformed base returns `false` rather than the negation of the empty-set result. +## Parameters + +| Name | Type | Description | +|---|---|---| +| `policyId` | `uint64` | Policy to query. | +| `account` | `address` | Account to check. | + +## Returns + +| Type | Description | +|---|---| +| `bool` | Whether `account` is authorized under `policyId`. | + ## Access Control -Read-only or ERC-20-standard access rules unless the NatSpec states otherwise. +Read-only. No role required. ## Policy Interaction diff --git a/docs/upgrades/beryl/b20.mdx b/docs/upgrades/beryl/b20.mdx index 1380a5fb1..7f03c7e76 100644 --- a/docs/upgrades/beryl/b20.mdx +++ b/docs/upgrades/beryl/b20.mdx @@ -82,14 +82,7 @@ Two built-in IDs require no creation: | `ALWAYS_ALLOW` | `0` | Authorizes every account unconditionally. Default scope value on new B20 tokens. | | `ALWAYS_BLOCK` | `(uint64(ALLOWLIST) << 56) \| 1` | Denies every account unconditionally. | -`isAuthorized` never reverts. Its result depends on the ID: - -| Policy ID | `isAuthorized` result | -|-----------|-----------------------| -| Malformed (after clearing the invert flag, bit 63, the top byte is outside `PolicyType`) | `false` | -| Well-formed but non-existent | Empty-member-set semantics: `ALLOWLIST` and `UNION` return `false`; `BLOCKLIST` and `INTERSECT` return `true` | -| Inverted (`invertedPolicyId(id)`) with an existing base | The negated result of the base | -| Inverted with a non-existent or malformed base | `false` | +`isAuthorized` never reverts on a non-existent policy ID - it collapses to empty-member-set semantics (non-existent `BLOCKLIST` authorizes everyone; non-existent `ALLOWLIST` denies everyone). Consumers that write a policy ID (e.g. `updatePolicy`) MUST validate `policyExists(policyId)` at write time to avoid silently binding to an unintended empty-set policy. @@ -117,7 +110,7 @@ policyRegistry.updateBlocklist(policyId, false, accounts); // unblock these acc | Method | Description | |--------|-------------| -| `isAuthorized(policyId, account)` | Whether `account` is authorized under `policyId`. Never reverts. Returns `false` for a malformed ID. | +| `isAuthorized(policyId, account)` | Whether `account` is authorized under `policyId`. Never reverts. | | `policyExists(policyId)` | Whether a policy with this ID has been created. | | `policyAdmin(policyId)` | Current admin address. | | `pendingPolicyAdmin(policyId)` | Pending admin during a two-step transfer. | From fd966ab6967776accdda0547d8f88005b74607a4 Mon Sep 17 00:00:00 2001 From: sohey Date: Mon, 5 Oct 2026 21:22:17 +0200 Subject: [PATCH 3/3] docs: remove isAuthorized restatements per review feedback Drops the Block an Account note and the Policies 'Malformed and unknown IDs' section; the rules remain on the isAuthorized reference page. Co-authored-by: Toshi --- .../issue-stablecoins/block-an-account.mdx | 4 ---- docs/specifications/b20/concepts/policies.mdx | 13 ------------- 2 files changed, 17 deletions(-) diff --git a/docs/build-on-base/issue-stablecoins/block-an-account.mdx b/docs/build-on-base/issue-stablecoins/block-an-account.mdx index 92f1843d7..c249ac0bc 100644 --- a/docs/build-on-base/issue-stablecoins/block-an-account.mdx +++ b/docs/build-on-base/issue-stablecoins/block-an-account.mdx @@ -74,10 +74,6 @@ See the [B20 token standard](/specifications/b20) for the complete interface, ro This only stops outgoing transfers when the blocklist is bound to `TRANSFER_SENDER_POLICY`. Unblock with the same call and `false`. - -`isAuthorized` never reverts. A blocklist ID that doesn't exist returns `true` for every account, so confirm `policyExists(policyId)` before you rely on the check. See [`isAuthorized`](/specifications/b20/reference/interfaces/i-policy-registry/is-authorized) for how malformed, unknown, and inverted IDs resolve. - - ## Make a Blocked Account Recoverable Blocking under `TRANSFER_SENDER_POLICY` freezes the account's outgoing transfers. It does not, on its own, let you move the balance out. Seize reads a separate scope, `SEIZE_EXEMPT_POLICY`, whose check is inverted: an account that is **not** authorized under the attached policy is seizable. Attach the same blocklist there so a single hold both freezes the account and makes it recoverable: diff --git a/docs/specifications/b20/concepts/policies.mdx b/docs/specifications/b20/concepts/policies.mdx index 7ceffff03..123ec1d8d 100644 --- a/docs/specifications/b20/concepts/policies.mdx +++ b/docs/specifications/b20/concepts/policies.mdx @@ -102,19 +102,6 @@ flowchart TD A composite child ID may carry the invert bit. The registry checks existence and simple type against the base. An inverted simple child is valid. An inverted composite child reverts `InvalidChildPolicy`. Across the whole child set, `PolicyNotFound` still takes precedence over `InvalidChildPolicy`. -#### Malformed and unknown IDs - -`isAuthorized` clears bit 63 before it checks the policy type. An ID is malformed when the remaining top byte is outside `PolicyType`. A malformed ID returns `false`. - -A well-formed ID that does not exist behaves as an empty set: - -| Type | `isAuthorized` on an unknown ID | -| --- | --- | -| `ALLOWLIST` and `UNION` | `false` | -| `BLOCKLIST` and `INTERSECT` | `true` | - -Callers that store policy IDs must check `policyExists(policyId)` at write time rather than rely on these results. - ### 2.4 Creating and updating Anyone can create a policy. The create call names a single `admin`. That address is the only one that can later change membership, replace a composite's children, transfer administration, or renounce. The creator does not have to be the admin. `admin` cannot be `address(0)`.