From 340c6a80a31eb00c2af8676130fa27b9201a5424 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:34:31 +0000 Subject: [PATCH] docs: sync from base-std@64bd955 --- .../b20/changelog/02-cobalt-b20-seize.mdx | 25 +++++--- ...cobalt-policyregistry-composite-policy.mdx | 6 +- .../b20/reference/constants-addresses.mdx | 2 + .../i-policy-registry/is-authorized.mdx | 6 +- .../interfaces/ib20/seize-holder-policy.mdx | 2 +- .../interfaces/ib20/seize-with-memo.mdx | 6 +- .../b20/reference/invariants-tests.mdx | 61 +++++++++++-------- 7 files changed, 64 insertions(+), 44 deletions(-) diff --git a/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize.mdx b/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize.mdx index 521116f4f..651b358cd 100644 --- a/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize.mdx +++ b/docs/base-chain/specs/reference/b20/changelog/02-cobalt-b20-seize.mdx @@ -60,7 +60,7 @@ at Cobalt. Seize lives on the shared `IB20` surface, so it's identical across As | Beryl error (selector) | Cobalt (selector) | Status | Why | | --- | --- | --- | --- | | `AccountNotBlocked(address)` `0x64a5cb46` | unchanged | present on Beryl already | Thrown by `burnBlocked` when `from` is authorized under `TRANSFER_SENDER_POLICY` (that is, not blocked). | -| — | `AccountNotSeizable(address)` `0x91dbbc8d` | new | Thrown by `seizeWithMemo` when `from` is authorized under `SEIZE_HOLDER_POLICY` (that is, not seizable). | +| — | `AccountNotSeizable(address)` `0x91dbbc8d` | new | Thrown by `seizeWithMemo` when `from` is authorized under `SEIZE_HOLDER_POLICY` (that is, `isAuthorized(policyId, from)` returns true, meaning `from` is not seizable). | ### Pause Features @@ -90,9 +90,12 @@ Requirements and guards: - **Pause**: `SEIZE` must not be paused, or the call reverts `ContractPaused(SEIZE)`. - **Addresses**: `to != address(0)` and `from != to`, or the call reverts `InvalidReceiver`. `from != address(0)`, or the call reverts `InvalidSender`. -- **Holder gate**: `from` must be blocked under `SEIZE_HOLDER_POLICY`, that is, not authorized by - it, or the call reverts `AccountNotSeizable`. An unset slot reads as always-allow, so no account - is seizable until an issuer configures `SEIZE_HOLDER_POLICY`. +- **Holder gate**: `from` must be seizable under `SEIZE_HOLDER_POLICY` — that is, + `isAuthorized(policyId, from)` must return false. This is the inverse of the allowlist-style + checks used by `transfer` and `transferFrom`, where `isAuthorized(...) == true` permits the + operation. If `isAuthorized(policyId, from)` returns true, the call reverts `AccountNotSeizable`. + An unset slot reads as always-allow (`isAuthorized` returns true for all accounts), so no account + is seizable until an issuer explicitly configures `SEIZE_HOLDER_POLICY`. - **Destination gate**: `to` must be authorized under `SEIZE_RECEIVER_POLICY`, which mirrors `MINT_RECEIVER_POLICY` and is always enforced. But an unset slot is always-allow, so a token can seize to any destination (a treasury doesn't need to be allowlisted) until the slot is set. @@ -126,8 +129,9 @@ old burn-blocked outcome, seize to a treasury or self address, then call `burn`. **Q: `seizeWithMemo` and `burnBlocked` both target "bad" accounts. Do they read the same set?** No, and this is deliberate. `seizeWithMemo` reads `SEIZE_HOLDER_POLICY`. `burnBlocked` reads `TRANSFER_SENDER_POLICY`. A token can define a seizable set that's distinct from its -transfer-blocked set. In both cases, "eligible" means not authorized by the relevant policy, and an -unset policy (always-allow) means nobody is eligible. +transfer-blocked set. For `SEIZE_HOLDER_POLICY`, an account is seizable when `isAuthorized(policyId, +from)` returns false — the inverse of the allowlist-style check used by transfer flows. An unset +policy (always-allow) means nobody is seizable. **Q: Can I pause seize without pausing burns, or vice versa?** Yes. `SEIZE` (ordinal 3) and `BURN` (ordinal 2) are independent pause bits. Pausing `BURN` doesn't @@ -138,10 +142,11 @@ No. `seizeWithMemo` requires `SEIZE_ROLE`. `burnBlocked` requires `BURN_BLOCKED_ doesn't grant the other. **Q: I never configured the seize policies. What happens if I call `seizeWithMemo`?** -It reverts `AccountNotSeizable(from)` for every `from`, because an unset `SEIZE_HOLDER_POLICY` is -always-allow, so no account is seizable. You must configure `SEIZE_HOLDER_POLICY` to designate -seizable holders before seize does anything. (Leaving `SEIZE_RECEIVER_POLICY` unset simply permits -any destination.) +It reverts `AccountNotSeizable(from)` for every `from`. An unset `SEIZE_HOLDER_POLICY` reads as +always-allow, which means `isAuthorized(policyId, from)` returns true for all accounts — and because +`SEIZE_HOLDER_POLICY` uses inverted semantics, no account is seizable. You must explicitly configure +`SEIZE_HOLDER_POLICY` so that `isAuthorized` returns false for the accounts you want seizable. +Leaving `SEIZE_RECEIVER_POLICY` unset simply permits any destination. **Q: Does seize consult the transfer policies or spend an allowance?** No. It's an admin operation: it bypasses `TRANSFER_SENDER/RECEIVER/EXECUTOR_POLICY` and allowances, 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 231d7f427..7eeefc835 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 @@ -142,5 +142,7 @@ flag. `compositePolicyChildIds`, `MIN_COMPOSITE_CHILD_POLICIES`, `MAX_COMPOSITE_ **Q: Can a B20 token's policy slot (for example, `TRANSFER_SENDER_POLICY` or `SEIZE_HOLDER_POLICY`) reference a composite ID?** Yes. B20 stores every policy slot as an opaque `uint64 policyId` and calls `isAuthorized`, so a -composite ID works exactly like a simple one, and no B20-side change was needed. As with any policy -ID, validate `policyExists(policyId)` before writing it to a slot. +composite ID works exactly like a simple one, and no B20-side change was needed. Note that +`SEIZE_HOLDER_POLICY` uses inverted semantics: a `from` account is seizable only when +`isAuthorized(policyId, from)` returns `false`. This applies equally when the slot holds a composite +ID. As with any policy ID, validate `policyExists(policyId)` before writing it to a slot. diff --git a/docs/specifications/b20/reference/constants-addresses.mdx b/docs/specifications/b20/reference/constants-addresses.mdx index 11c3181a7..68becc704 100644 --- a/docs/specifications/b20/reference/constants-addresses.mdx +++ b/docs/specifications/b20/reference/constants-addresses.mdx @@ -40,6 +40,8 @@ These addresses are identical on every network where B20 is active. | Seize holder | `keccak256("SEIZE_HOLDER_POLICY")` | | Seize receiver | `keccak256("SEIZE_RECEIVER_POLICY")` | +`SEIZE_HOLDER_POLICY` uses **inverted** authorization semantics: a `from` account is seizable only when `isAuthorized(policyId, from)` returns `false`. This is the opposite of transfer-style allowlist gating. An unset slot reads as `0` (`ALWAYS_ALLOW`), so no account is seizable until an issuer explicitly configures the slot. + ## Policy IDs | Name | Value | 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..fbe8aeeae 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 @@ -19,8 +19,8 @@ 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). +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. @@ -34,6 +34,8 @@ Read-only or ERC-20-standard access rules unless the NatSpec states otherwise. This is part of the singleton PolicyRegistry surface used by B20 policy scopes. +Note that `SEIZE_HOLDER_POLICY` uses **inverse** semantics: a `from` account is seizable only when `isAuthorized(policyId, from)` returns `false`. This is the opposite of transfer-style allowlist checks, where authorization permits an action. + ## Example ```solidity Usage Example diff --git a/docs/specifications/b20/reference/interfaces/ib20/seize-holder-policy.mdx b/docs/specifications/b20/reference/interfaces/ib20/seize-holder-policy.mdx index ff5f2602d..8cbc9df97 100644 --- a/docs/specifications/b20/reference/interfaces/ib20/seize-holder-policy.mdx +++ b/docs/specifications/b20/reference/interfaces/ib20/seize-holder-policy.mdx @@ -20,7 +20,7 @@ function SEIZE_HOLDER_POLICY() external view returns (bytes32); Policy slot consulted against `from` by `seizeWithMemo`. -A `from` is seizable only when it is NOT authorized by this policy. An unset slot reads as `0` (always-allow), so no account is seizable until an issuer configures the slot. +A `from` is seizable only when `isAuthorized(policyId, from)` returns false. This uses the inverse of the normal transfer-style gating: accounts are seizable when `isAuthorized(...)` returns false, not true. An unset slot reads as `0` (always-allow), so no account is seizable until an issuer explicitly configures the slot. ## Returns diff --git a/docs/specifications/b20/reference/interfaces/ib20/seize-with-memo.mdx b/docs/specifications/b20/reference/interfaces/ib20/seize-with-memo.mdx index 9310b0564..164e37526 100644 --- a/docs/specifications/b20/reference/interfaces/ib20/seize-with-memo.mdx +++ b/docs/specifications/b20/reference/interfaces/ib20/seize-with-memo.mdx @@ -20,7 +20,7 @@ function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) e Seizes `amount` of `from`'s balance and reassigns it to `to` in a single admin operation. Emits, in order, `Transfer(from, to, amount)`, `Memo(caller, memo)`, and `Seized(caller, from, to, amount)`. A memo of `bytes32(0)` is permitted. -Admin operation: skips allowance and the transfer policies. The membership checks are that `from` is blocked under `SEIZE_HOLDER_POLICY` and `to` is authorized under `SEIZE_RECEIVER_POLICY`. +Admin operation: skips allowance and the transfer policies. The membership checks are that `from` is seizable under `SEIZE_HOLDER_POLICY` and `to` is authorized under `SEIZE_RECEIVER_POLICY`. `to` is gated by `SEIZE_RECEIVER_POLICY`, which defaults to always-allow when unset, so an unconfigured token may seize to any destination (a treasury need not be allowlisted). ## Parameters @@ -37,7 +37,7 @@ Admin operation: skips allowance and the transfer policies. The membership check - `ContractPaused(SEIZE)` when `SEIZE` is paused. - `AccessControlUnauthorizedAccount` when the caller does not hold `SEIZE_ROLE`. - `InvalidReceiver` when `to == address(0)`. -- `AccountNotSeizable` when `from` is currently authorized under `SEIZE_HOLDER_POLICY`. +- `AccountNotSeizable` when `from` is authorized under `SEIZE_HOLDER_POLICY`. Note that `SEIZE_HOLDER_POLICY` uses inverted semantics: `from` is seizable only when `isAuthorized(policyId, from)` returns `false`. - `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` when `to` is not authorized under `SEIZE_RECEIVER_POLICY`. - `InsufficientBalance` when `from`'s balance is below `amount`. @@ -49,6 +49,8 @@ Admin operation: skips allowance and the transfer policies. The membership check Checks `SEIZE_HOLDER_POLICY` for the holder and `SEIZE_RECEIVER_POLICY` for the destination. +`SEIZE_HOLDER_POLICY` uses **inverted** authorization semantics compared to normal transfer-style gating: an account is seizable when `isAuthorized(policyId, from)` returns `false`, not `true`. An unset slot reads as `0` (always-allow), so no account is seizable until an issuer explicitly configures the slot. + ## Example ```solidity Usage Example diff --git a/docs/specifications/b20/reference/invariants-tests.mdx b/docs/specifications/b20/reference/invariants-tests.mdx index 0fafbfc47..56e9f4ece 100644 --- a/docs/specifications/b20/reference/invariants-tests.mdx +++ b/docs/specifications/b20/reference/invariants-tests.mdx @@ -64,12 +64,16 @@ These invariants and conformance test cases are the normative behavioral guarant 31. Multiplier updates affect all holders simultaneously. 32. `batchMint` enforces `MINT_RECEIVER_POLICY` for each recipient individually. +### Seize + +33. `SEIZE_HOLDER_POLICY` uses inverted semantics: a `from` account is seizable only when `isAuthorized(policyId, from)` returns `false`. An unset slot reads as `0` (`ALWAYS_ALLOW`), so no account is seizable until an issuer explicitly configures the slot. + ### 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. +34. B20 addresses are deterministic: same inputs always produce the same address. +35. The variant byte at address position 10 always matches the deployed variant. +36. Each `(deployer, variant, salt)` tuple produces exactly one address. +37. `initCalls` execute in array order. A revert in any initCall reverts the entire deployment. ## Test Cases @@ -127,51 +131,54 @@ These invariants and conformance test cases are the normative behavioral guarant | 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 | +| 31 | `seizeWithMemo` where `from` has `isAuthorized(SEIZE_HOLDER_POLICY, from)` returning `true` | Reverts with `AccountNotSeizable` | +| 32 | `seizeWithMemo` where `from` has `isAuthorized(SEIZE_HOLDER_POLICY, from)` returning `false` | Succeeds (account is seizable) | +| 33 | `seizeWithMemo` before issuer configures `SEIZE_HOLDER_POLICY` slot | Reverts — unset slot reads as `ALWAYS_ALLOW`, so `isAuthorized` returns `true` and no account is seizable | ### 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 | +| 34 | Pause `TRANSFER`, call `transfer` | Reverts | +| 35 | Pause `TRANSFER`, call `mint` | Succeeds | +| 36 | Pause `TRANSFER`, call `approve` | Succeeds | +| 37 | 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 | +| 38 | `transferWithMemo` | Emits `Transfer` then `Memo` at consecutive log indices | +| 39 | `transferFromWithMemo` | `Memo.caller` is `msg.sender`, not `from` | +| 40 | `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 | +| 41 | Valid permit with correct signature, nonce, deadline | Succeeds | +| 42 | Permit with expired deadline | Reverts | +| 43 | Replay used permit signature | Reverts | +| 44 | Permit signed before `updateName`, submitted after | Reverts | +| 45 | Contract wallet signature (ERC-1271) | Reverts | +| 46 | 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 | +| 47 | `getB20Address` then deploy with same params | Addresses match | +| 48 | Inspect byte 10 of deployed Asset address | Returns `0x00` | +| 49 | Deploy same `(deployer, variant, salt)` twice | Second reverts | +| 50 | Deploy when variant feature not activated | Reverts | +| 51 | `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 | +| 52 | Deploy Asset with `decimals = 5` | Reverts | +| 53 | Update multiplier to `2e18`, check `balanceOf` for raw balance 100 | Returns 200 | +| 54 | Reuse announcement ID | Reverts with `DuplicateAnnouncementId` | +| 55 | `batchMint` where one recipient is not on allowlist | Reverts | +| 56 | Deploy Stablecoin with `currency = "usd"` | Reverts — `A`–`Z` only |