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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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 ...
```

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Check>
`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`.
</Check>

<Warning>
Expand Down
4 changes: 3 additions & 1 deletion docs/build-on-base/issue-rwa/seize-and-cancel-units.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/build-on-base/issue-stablecoins/block-an-account.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
</Warning>

`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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/specifications/b20/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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.

14 changes: 14 additions & 0 deletions docs/specifications/b20/reference/constants.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ description: "B20 precompile addresses, role identifiers, policy scopes, and val
| `SEIZE_EXEMPT_POLICY` | `keccak256("SEIZE_EXEMPT_POLICY")`<br />`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")`<br />`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).*
Expand Down
Original file line number Diff line number Diff line change
@@ -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."
---


Expand All @@ -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. |
Expand Down
Original file line number Diff line number Diff line change
@@ -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."
---


Expand All @@ -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 -&gt; false,
BLOCKLIST -&gt; true).
Dev: Callers that store policy IDs MUST validate `policyExists(policyId)` at write time.
Param: policyId Policy to query.
Param: account Account to check.
Return: Whether `account` is authorized.
Returns whether `account` is authorized under `policyId`. Never reverts.

**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`.

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

## 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

Expand Down
Loading