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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions docs/specifications/b20/reference/constants-addresses.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand All @@ -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
Expand Down
61 changes: 34 additions & 27 deletions docs/specifications/b20/reference/invariants-tests.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |