diff --git a/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx b/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx index 498869141..f1b99e28a 100644 --- a/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx +++ b/docs/base-chain/specs/reference/b20/changelog/03-denim-b20-transfer-executor-enforcement.mdx @@ -71,6 +71,20 @@ On `transfer`, the executor lookup is new. When `TRANSFER_EXECUTOR_POLICY` equal `_transfer` reuses the executor result and skips the sender `isAuthorized` call. A default `transfer` therefore still makes two `isAuthorized` calls. +### `isAuthorized` — Malformed and Unknown ID Semantics + +The `IPolicyRegistry.isAuthorized` NatSpec was refined to distinguish two cases that previously +shared a single description: + +- **Malformed ID**: the invert flag (bit 63) is cleared first; if the remaining top byte is outside + `PolicyType`, `isAuthorized` returns `false`. +- **Well-formed but unknown ID**: treated as an empty set — `ALLOWLIST` and `UNION` return `false`; + `BLOCKLIST` and `INTERSECT` return `true`. +- **Inverted unknown or malformed base**: always returns `false` (negation is not applied when the + base does not exist or is malformed). + +This is a documentation clarification only. No behavior changed. + ## Examples A holder moving their own tokens is now gated by the executor policy: 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..5bbba8713 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 | Clears the invert flag before type-checking. A malformed base returns `false`. A well-formed but unknown base uses empty-set semantics (`ALLOWLIST`/`UNION` → `false`; `BLOCKLIST`/`INTERSECT` → `true`). An inverted base returns the negated result only 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,16 +69,28 @@ function invertedPolicyId(uint64 policyId) external view returns (uint64); #### Authorization -`isAuthorized` gains a leading invert branch. All non-inverted paths are unchanged. +`isAuthorized` never reverts. The invert flag is cleared before the type check, so malformed-ID +detection operates on the stripped value. The evaluation order is: -```text Authorization Evaluation lines expandable wrap highlight={2-6} +1. **Malformed ID** — after clearing the invert flag, if the top byte is outside `PolicyType`, return `false`. +2. **Inverted ID (base exists)** — return the negated result of the base policy. +3. **Inverted unknown or malformed base** — return `false`. +4. **Unknown well-formed ID** — empty-set semantics: `ALLOWLIST` and `UNION` return `false`; `BLOCKLIST` and `INTERSECT` return `true`. +5. **Known ID** — existing `ALLOWLIST` / `BLOCKLIST` / `UNION` / `INTERSECT` dispatch, unchanged. + +```text Authorization Evaluation lines expandable wrap highlight={2-10} isAuthorized(policyId, account): if policyId has INVERTED_POLICY_BIT set: base = policyId without the bit + if base is malformed: + return false if not policyExists(base): # fail-closed guard return false return not isAuthorized(base, account) + if policyId type byte is outside PolicyType: # malformed + return false + ... existing ALLOWLIST / BLOCKLIST / UNION / INTERSECT dispatch ... ``` @@ -129,6 +141,7 @@ policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT ``` Fail-closed: for any never-created base, `isAuthorized(base | INVERTED_POLICY_BIT, account)` returns +`false`. Malformed IDs (type byte outside `PolicyType` after clearing the invert flag) also return `false`. Round-trip: `invertedPolicyId(invertedPolicyId(id)) == id`, and 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..0d4ef72d2 100644 --- a/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx +++ b/docs/build-on-base/issue-rwa/restrict-transfer-initiators.mdx @@ -92,7 +92,7 @@ token.approve(transferAgent, amount); The initiator moves units with `transferFrom(holder, recipient, amount)`. The holder's own `transfer(recipient, amount)` reverts `PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorId)` before balance is checked. -`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. +`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. For a well-formed but unrecognized policy ID, `isAuthorized` treats it as an empty set: an ALLOWLIST or UNION returns `false`, a BLOCKLIST or INTERSECT returns `true`. A malformed ID always returns `false`. An inverted unknown or malformed base also returns `false`. 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..fb4905638 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 @@ -43,7 +43,9 @@ How the seize scope works: | `SEIZE_EXEMPT_POLICY` | `from` | `isAuthorized` is **false** | Everyone is authorized, so nobody is seizable. | | `SEIZE_RECEIVER_POLICY` | `to` | `isAuthorized` is **true** | Any destination is allowed. | -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. +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 returns false for every account, so every holder would be seizable. + +`isAuthorized` never reverts. A malformed policy ID returns false. A well-formed but unknown ID is treated as an empty set: ALLOWLIST and UNION return false; BLOCKLIST and INTERSECT return true. An inverted unknown or malformed policy ID returns false regardless of type. ## 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 c249ac0bc..5f3ab88f5 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,8 @@ 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 malformed policy ID returns `false`. A well-formed but unknown ID uses empty-set semantics: ALLOWLIST and UNION return `false`; BLOCKLIST and INTERSECT return `true`. Always validate that a policy exists with `policyExists(policyId)` at write time — storing an unknown ID risks unexpected authorization results at query time. + ## 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..ee860f6b2 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 @@ -26,6 +26,8 @@ The Policy Registry is a singleton precompile that stores each member list once. Scopes gate specific functions. `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY` check the sender and recipient on every `transfer` and `transferFrom`. `MINT_RECEIVER_POLICY` checks the recipient on every `mint`. All three default to `ALWAYS_ALLOW` (`0`) until you bind a policy. +`isAuthorized` never reverts. A **malformed** policy ID returns `false`. A well-formed but unknown ID is treated as 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`. + An **allowlist** authorizes only accounts in the set. An empty allowlist authorizes nobody, so seed your intended holders before binding the policy. ## Create and Bind a Holder Allowlist diff --git a/docs/specifications/b20/introduction.mdx b/docs/specifications/b20/introduction.mdx index a8ed537eb..488610fa7 100644 --- a/docs/specifications/b20/introduction.mdx +++ b/docs/specifications/b20/introduction.mdx @@ -143,6 +143,8 @@ 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. A malformed policy ID returns `false`. A well-formed but unknown ID is treated as an empty set: ALLOWLIST and UNION return `false`; BLOCKLIST and INTERSECT return `true`. An inverted unknown or malformed ID also returns `false`. Callers that store policy IDs should validate `policyExists(policyId)` at write time to avoid unintended empty-set behavior. + A policy-gated transfer looks like this: ```mermaid Integrating Compliance Checks Diagram lines wrap expandable highlight={1} @@ -171,4 +173,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..219ba51ac 100644 --- a/docs/specifications/b20/reference/constants.mdx +++ b/docs/specifications/b20/reference/constants.mdx @@ -44,6 +44,20 @@ description: "B20 precompile addresses, role identifiers, policy scopes, and val | `SEIZE_EXEMPT_POLICY` | `keccak256("SEIZE_EXEMPT_POLICY")`
`0xedb5da348cfb67af08746d3afd1be81034b50d5c8576f31aff688f39dfd540ed` | Consulted for `from` on `seizeWithMemo`; an authorized `from` is seize-exempt, so `from` is seizable only when unauthorized under this policy. Named `SEIZE_HOLDER_POLICY` before Cobalt. | | `SEIZE_RECEIVER_POLICY` | `keccak256("SEIZE_RECEIVER_POLICY")`
`0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d` | Consulted for `to` on `seizeWithMemo`. | +## `isAuthorized` edge-case semantics + +`isAuthorized` never reverts. The table below summarizes how it handles IDs that are malformed, unknown, or inverted. + +| Case | Result | +|---|---| +| Malformed ID (top byte outside `PolicyType` after clearing the invert flag, bit 63) | `false` | +| Well-formed but unknown ID — `ALLOWLIST` or `UNION` | `false` (empty set) | +| Well-formed but unknown ID — `BLOCKLIST` or `INTERSECT` | `true` (empty set) | +| Inverted ID whose base exists | Negation of the base result | +| Inverted unknown or malformed base | `false` | + +Callers that store policy IDs must validate `policyExists(policyId)` at write time to avoid relying on empty-set or malformed-ID fallback behavior. + ## Feature and validation bounds *Bitmasks and inclusive bounds used for pause features and B20Asset creation validation. See [`B20Constants`](https://github.com/base/base-std/blob/main/src/lib/B20Constants.sol).* 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..ba0c35704 100644 --- a/docs/specifications/b20/reference/interfaces/i-policy-registry/index.mdx +++ b/docs/specifications/b20/reference/interfaces/i-policy-registry/index.mdx @@ -1,7 +1,7 @@ --- title: "IPolicyRegistry Reference" sidebarTitle: "IPolicy Registry" -description: "Generated B20 reference for IPolicyRegistry functions, events, and errors." +description: "Interface for creating and querying B20 policy registry entries, including simple and composite policies." --- @@ -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 but unknown ID follows empty-set semantics (ALLOWLIST/UNION → false, BLOCKLIST/INTERSECT → true). 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..36cf40350 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 given policy ID, with defined behavior for malformed, unknown, and inverted IDs." --- @@ -18,17 +18,39 @@ 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. + +**Malformed ID** — The invert flag (bit 63) is cleared before the type check. If the remaining top byte is outside the valid `PolicyType` range, the ID is malformed and the function returns `false`. + +**Unknown ID (well-formed, not registered)** — Treated as an empty member set: + +| Policy type | Result | +|---|---| +| `ALLOWLIST` | `false` | +| `UNION` | `false` | +| `BLOCKLIST` | `true` | +| `INTERSECT` | `true` | + +**Invert** — `isAuthorized(invertedPolicyId(id), account)` returns the negated result of the base policy when that base exists. Applies to every policy type. An inverted unknown or malformed base returns `false`. + + +Callers that store policy IDs MUST validate `policyExists(policyId)` at write time. + + +## Parameters + +| Name | Type | Description | +|---|---|---| +| `policyId` | `uint64` | Policy to query. | +| `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. +Read-only view function; no role restriction. ## Policy Interaction