From f6378e54f2892d44e64d0c7e8c7f6db181c922bc Mon Sep 17 00:00:00 2001 From: robriks Date: Mon, 10 Aug 2026 16:38:41 -0500 Subject: [PATCH 1/8] docs(changelog): add changelog dir + Cobalt B20 Asset scheduled-multiplier entry Introduce a per-hardfork, per-feature changelog under changelog//, codified in changelog/README.md, and add the first entry: the Beryl -> Cobalt ERC-8056 ("Scaled UI Amount") migration for the B20 Asset multiplier surface. Link it from the top-level README. Co-authored-by: Cursor --- README.md | 6 + changelog/README.md | 44 ++++++ .../b20-asset-scheduled-multiplier-updates.md | 138 ++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 changelog/README.md create mode 100644 changelog/cobalt/b20-asset-scheduled-multiplier-updates.md diff --git a/README.md b/README.md index 4f5f4af..bcc99c6 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ A collection of Solidity interfaces, libraries, and mock implementations for Bas - [**PolicyRegistry**](docs/PolicyRegistry/README.md) — Membership sets controlled by custom admins, initially providing allow and block lists for B20 token operations. - [**B20**](docs/B20/README.md) — Standard ERC-20 implementation with extensions for roles, policies, memos, pausing, ERC-2612 permits, and a variant system. +## Changelog + +Per-hardfork, per-feature migration notes live in [`changelog/`](changelog/README.md) — code-forward +deltas for each scoped feature change crossing a hardfork boundary (e.g. Cobalt's +[Schedule Multiplier Updates](changelog/cobalt/b20-asset-scheduled-multiplier-updates.md)). + ## Source Integration These source files are imported by production contracts to interact with Base precompiles. diff --git a/changelog/README.md b/changelog/README.md new file mode 100644 index 0000000..62d2897 --- /dev/null +++ b/changelog/README.md @@ -0,0 +1,44 @@ +# Changelog + +Per-hardfork, per-feature migration notes for the Base precompile standard. Each entry is a focused, +code-forward changelog for **one scoped feature change crossing a hardfork boundary** — the +API / function / event / error deltas behind a single line item in a hardfork's release notes (e.g. +Cobalt's "Schedule Multiplier Updates"). + +This complements the product references under [`docs/`](../docs): `docs/` describes how a product +works *today*; `changelog/` describes what changes *at a hardfork* and how to migrate across it. + +## Layout + +``` +changelog/ + / # lowercase hardfork name, in activation order (e.g. cobalt) + -.md # one scoped feature change +``` + +- **One directory per hardfork**, named for the fork the changes activate at. +- **One file per scoped feature change**, prefixed by product (`b20-asset-`, `policy-registry-`, …) + so a hardfork directory reads as a list of that fork's line items. +- Filenames are kebab-case and should map to a release-notes line item. + +## What each entry contains + +Keep it **minimal and migration-focused** — do not restate unchanged behavior. A good entry has: + +1. **Audience + one-paragraph summary**, leading with the compatibility promise: what still works, + what is deprecated-but-still-dialable, and what is new. State plainly whether the fork is live yet. +2. **A mapping table** — old symbol → new symbol → status (`deprecated-dialable` / `renamed` / `new`) + → one-line why. Cover functions, events, and errors, with **real** signatures and selectors. +3. **"New at `` (adopt these)"** — the new surface and its lifecycle. +4. **Guarantees / edge cases** — a short Q&A a careful integrator would ask, each answered with the + precise on-chain behavior. + +Verify every function signature, event signature/topic0, error selector, 4-byte function selector, +and ERC-165 interface id against the source (`src/interfaces/**` and the base/base ABIs) **before +citing it** — do not rely on memory. Selectors can be checked with `cast sig` / `cast keccak`. + +## Index + +| Hardfork | Feature | Product | Entry | +| --- | --- | --- | --- | +| Cobalt (upcoming) | Schedule Multiplier Updates (ERC-8056) | B20 Asset | [b20-asset-scheduled-multiplier-updates](cobalt/b20-asset-scheduled-multiplier-updates.md) | diff --git a/changelog/cobalt/b20-asset-scheduled-multiplier-updates.md b/changelog/cobalt/b20-asset-scheduled-multiplier-updates.md new file mode 100644 index 0000000..ba1268f --- /dev/null +++ b/changelog/cobalt/b20-asset-scheduled-multiplier-updates.md @@ -0,0 +1,138 @@ +# B20 Asset — Beryl → Cobalt (ERC-8056) migration + +> **Audience:** teams already integrated against the B20 Asset multiplier surface on **Beryl** +> (live today). This note covers **only** the multiplier / ERC-8056 changes landing at the +> **Cobalt** hardfork. + +## Summary + +At Cobalt the B20 Asset multiplier surface becomes [ERC‑8056 ("Scaled UI Amount")](https://eips.ethereum.org/EIPS/eip-8056) +conformant and gains a **scheduled** multiplier setter for corporate actions. **Nothing you call +today breaks.** Every Beryl selector, event topic, and error keeps its exact 4‑byte selector / +topic0 and stays dialable at Cobalt — the deprecations below are advisory. The migration is: adopt +the canonical ERC‑8056 names (`uiMultiplier`, `toUIAmount`/`fromUIAmount`, `balanceOfUI`, +`totalSupplyUI`) and move routine multiplier changes from the instant `updateMultiplier(uint256)` +to the scheduled `updateUIMultiplier(uint256,uint256)`. **Cobalt is not live yet**; until it +activates, only the Beryl surface exists on‑chain. + +## Mapping table + +Selectors and topic0s below are the real values from the frozen ABIs +(`abi/v1.rs` = Beryl, `abi/v2.rs` = Cobalt); all Beryl symbols keep their selector at Cobalt. + +### Functions + +| Beryl symbol (selector) | Cobalt canonical (selector) | Status | Why | +| --- | --- | --- | --- | +| `multiplier()` `0x1b3ed722` | `uiMultiplier()` `0xa60bf13d` | deprecated‑name‑kept / new alias | ERC‑8056 core naming; both return the same effective multiplier. `multiplier()` stays. | +| `toScaledBalance(uint256)` `0x04f04c99` | `toUIAmount(uint256)` `0x3248d4ff` | deprecated‑dialable / new | ERC‑8056 Conversion extension; byte‑identical behavior. | +| `toRawBalance(uint256)` `0x0ca06c44` | `fromUIAmount(uint256)` `0x65cd9b3c` | deprecated‑dialable / new | ERC‑8056 Conversion extension; byte‑identical behavior. | +| `scaledBalanceOf(address)` `0x1da24f3e` | `balanceOfUI(address)` `0x437a9958` | deprecated‑name‑kept / new alias | ERC‑8056 Balances extension; alias, same value. | +| `updateMultiplier(uint256)` `0x5ffe6146` | `updateUIMultiplier(uint256,uint256)` `0x628e600f` | deprecated‑dialable / new (**not 1:1**) | Canonical path is now the **scheduled** setter; the instant setter is retained as an emergency failsafe. | +| — | `newUIMultiplier()` `0xdc767007` | new | ERC‑8056 pending‑schedule read. | +| — | `effectiveAt()` `0x97a4064f` | new | ERC‑8056 pending‑schedule read (flip timestamp). | +| — | `totalSupplyUI()` `0x9bea6429` | new | ERC‑8056 Balances extension. | +| — | `cancelUIMultiplierUpdate()` `0x2c97a0f0` | new | Cancels the single live pending update. | +| — | `MAX_UI_MULTIPLIER()` `0x785c0cf0` | new | Reads the multiplier ceiling (`type(uint128).max`) without hitting the revert path. | +| — | `supportsInterface(bytes4)` `0x01ffc9a7` | new | ERC‑165 feature detection. | + +`OPERATOR_ROLE()` `0xf5b541a6`, `WAD_PRECISION()` `0x664808a8`, `announce(...)` `0x595135dd`, +`isAnnouncementIdUsed(string)` `0xc0da474e`, `batchMint(...)` `0x68573107`, +`extraMetadata(string)` `0x4ddf9da0`, and `updateExtraMetadata(string,string)` `0xb2851ef5` are +carried over unchanged. + +### Events + +| Beryl event (topic0) | Cobalt canonical (topic0) | Status | Why | +| --- | --- | --- | --- | +| `MultiplierUpdated(uint256)` | `UIMultiplierUpdated(uint256,uint256,uint256)` | deprecated‑still‑emitted / new | ERC‑8056 canonical event. The instant setter emits **both**; the scheduled setter emits **only** `UIMultiplierUpdated`. | +| — | `UIMultiplierUpdateCancelled(uint256,uint256)` | new | Signals a cleared pending update. | + +### Errors + +| Beryl error (selector) | Cobalt (selector) | Status | Why | +| --- | --- | --- | --- | +| `InvalidMultiplier()` `0x6f12f3dc` | `InvalidMultiplier()` `0x6f12f3dc` | present on Beryl already | Zero / above‑ceiling guard; now also thrown by `updateUIMultiplier`. | +| — | `EffectiveAtInPast(uint256)` `0x14119cf6` | new | `effectiveAt <= block.timestamp`. | +| — | `EffectiveAtTooFar(uint256)` `0x1ce214fa` | new | `effectiveAt > type(uint64).max`. | +| — | `UIMultiplierUpdateExists(uint256)` `0x4481a68e` | new | A live pending update already exists. | +| — | `UIMultiplierUpdateDoesNotExist()` `0xa7d6a5ca` | new | Cancel with no live pending. | + +## New at Cobalt (adopt these) + +### Scheduled‑update lifecycle + +`updateUIMultiplier(newMultiplier, effectiveAt)` is the **canonical corporate‑action path** (stock +splits, reinvested dividends). Only **one** pending update is live at a time. + +1. **Schedule** — `updateUIMultiplier(newMultiplier, effectiveAt)` (requires `OPERATOR_ROLE`, + `effectiveAt` strictly in the future). +2. **Pending views** — while live: `newUIMultiplier()` returns the scheduled target, `effectiveAt()` + returns the flip timestamp, and `uiMultiplier()` / `multiplier()` still return the **current** + value. +3. **Matures lazily** — once `block.timestamp >= effectiveAt`, `uiMultiplier()` / `multiplier()` + flip on read; **no event fires at maturation**. +4. **Or cancel** — `cancelUIMultiplierUpdate()` clears a live pending and emits + `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)`. + +To reorder overlapping actions, cancel and reschedule atomically in one announcement: +`announce([cancelUIMultiplierUpdate(), updateUIMultiplier(...)], ...)`. + +### ERC‑8056 view aliases + +- `uiMultiplier()` ≡ `multiplier()` +- `toUIAmount(raw)` ≡ `toScaledBalance(raw)`, `fromUIAmount(ui)` ≡ `toRawBalance(ui)` +- `balanceOfUI(account)` ≡ `scaledBalanceOf(account)` +- `totalSupplyUI()` = `totalSupply() * uiMultiplier() / WAD_PRECISION` + +### Bound getter + +`MAX_UI_MULTIPLIER()` returns `type(uint128).max` — the ceiling both setters enforce (the overflow +guard that keeps `balance * multiplier` inside `uint256`). + +## `updateMultiplier(uint256)` retained as instantaneous admin failsafe + +**Instant path is a deprecated admin failsafe** — `updateMultiplier(uint256)` sets the multiplier + immediately and clears any live pending. It's kept for tech-debt and emergency override only, not routine use. This function can be used to instantly reverse/resolve any mistakes or developer errors made in scheduling. If used thusly it should be paired with pausing in most cases. + +## Guarantees / edge cases + +**Q: If a scheduled update can be cancelled, how do external consumers detect the cancellation?** +`cancelUIMultiplierUpdate()` (and the instant setter, when it supersedes a *live* pending) emits +`UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` +(topic0 `0x8838…1cad`). Watch that topic to retract a pending flip you previously staged from +`UIMultiplierUpdated`. + +**Q: If the admin uses the instant failsafe, how do off‑chain indexers keep a linear, gap‑free UI‑multiplier lifecycle?** +The instant `updateMultiplier(uint256)` emits **both** the deprecated `MultiplierUpdated(uint256)` +**and** the ERC‑8056 `UIMultiplierUpdated(old, new, block.timestamp)` (and, if it clears a live +pending, `UIMultiplierUpdateCancelled` first). So every multiplier change — scheduled or emergency — +appears on the single `UIMultiplierUpdated` stream. Follow that one event and you never miss a +change; the legacy `MultiplierUpdated` topic remains available for indexers that haven't migrated. + +**Q: How do I distinguish a live pending update from one that has already matured (or none)?** +A pending is **live** if `effectiveAt() > block.timestamp`. While live, `newUIMultiplier()` returns +the scheduled target (≠ `uiMultiplier()`). After maturation, `uiMultiplier()` already reflects the +new value, `newUIMultiplier() == uiMultiplier()`, and `effectiveAt()` stays at the (now past) flip +timestamp until the next schedule/instant/cancel overwrites it — so a non‑zero `effectiveAt()` that +is `<= block.timestamp` means "already applied," not "pending." When no update has ever been +scheduled, `effectiveAt() == 0`. + +**Q: What if I schedule while one is already pending?** +Reverts `UIMultiplierUpdateExists(effectiveAt)` — but only a **live** pending blocks. A *matured* +(stale) pending is silently folded into the current multiplier and overwritten. To replace a live +schedule, `cancelUIMultiplierUpdate()` then `updateUIMultiplier(...)` (atomically via `announce`). + +**Q: What are the `effectiveAt` bounds?** +Must be strictly in the future — `effectiveAt <= block.timestamp` reverts `EffectiveAtInPast(effectiveAt)`. +Must fit the on‑chain field — `effectiveAt > type(uint64).max` reverts `EffectiveAtTooFar(effectiveAt)`. + +**Q: What are the multiplier bounds?** +`0 < newMultiplier <= MAX_UI_MULTIPLIER()` (`type(uint128).max`); zero or above reverts +`InvalidMultiplier()`. This applies to both `updateUIMultiplier` and `updateMultiplier`. Read the +ceiling from `MAX_UI_MULTIPLIER()` without risking the revert. + +**Q: Do raw balances or `Transfer` semantics change?** +No. The multiplier is purely cosmetic — it rescales only the *UI/scaled* view. `balanceOf`, +`transfer`, `totalSupply`, and `Transfer` stay raw and are mechanically unaffected by any multiplier +change, scheduled or instant. Only the `*UI` / scaled reads move. From ac73791b2b6ac7b692d19dfda5ffa7c4bec7f1fa Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Tue, 11 Aug 2026 16:48:16 +0200 Subject: [PATCH 2/8] docs(changelog): add Cobalt B20 seize surface + burnBlocked deprecation entry Adds changelog/cobalt/b20-seize-surface.md documenting the Beryl -> Cobalt seize surface (seizeWithMemo, SEIZE_ROLE, SEIZE_HOLDER_POLICY, SEIZE_RECEIVER_POLICY, Seized event, AccountNotSeizable error, SEIZE pause vector) shared by B20 Asset and Stablecoin, and the advisory deprecation of burnBlocked (unchanged and still dialable). Links it from the changelog index. Co-authored-by: OpenCode --- changelog/README.md | 1 + changelog/cobalt/b20-seize-surface.md | 141 ++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 changelog/cobalt/b20-seize-surface.md diff --git a/changelog/README.md b/changelog/README.md index 62d2897..207ede7 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -42,3 +42,4 @@ citing it** — do not rely on memory. Selectors can be checked with `cast sig` | Hardfork | Feature | Product | Entry | | --- | --- | --- | --- | | Cobalt (upcoming) | Schedule Multiplier Updates (ERC-8056) | B20 Asset | [b20-asset-scheduled-multiplier-updates](cobalt/b20-asset-scheduled-multiplier-updates.md) | +| Cobalt (upcoming) | Seize + `burnBlocked` deprecation | B20 (Asset + Stablecoin) | [b20-seize-surface](cobalt/b20-seize-surface.md) | diff --git a/changelog/cobalt/b20-seize-surface.md b/changelog/cobalt/b20-seize-surface.md new file mode 100644 index 0000000..5bc147a --- /dev/null +++ b/changelog/cobalt/b20-seize-surface.md @@ -0,0 +1,141 @@ +# B20 — Beryl to Cobalt: seize surface + `burnBlocked` deprecation + +> **Audience:** teams integrated against the base B20 surface on **Beryl** (live today) that +> perform administrative balance removal — today via the deprecated `burnBlocked`. This note covers +> **only** the seize surface landing at the **Cobalt** hardfork and what it means for `burnBlocked`. +> The surface is shared, so it applies to **both** B20 Asset and B20 Stablecoin. + +## Summary + +At Cobalt the base B20 surface gains a first-class **seize** operation: `seizeWithMemo(from, to, +amount, memo)` reassigns a holder's balance to a destination in one admin call, gated by a new +`SEIZE_ROLE`, a new `SEIZE` pause vector, and two new policy slots (`SEIZE_HOLDER_POLICY`, +`SEIZE_RECEIVER_POLICY`). **Nothing you call today breaks.** Every Beryl selector, event topic, and +error keeps its exact 4-byte selector / topic0 and stays dialable at Cobalt. In particular +`burnBlocked` is **deprecated but unchanged** — same selector, same events, same behavior — and +remains callable. The migration is: move administrative balance removal from `burnBlocked` to +`seizeWithMemo` (seize to a treasury/self address, then `burn` if you want the supply destroyed). +**Cobalt is not live yet**; until it activates only the Beryl surface exists on-chain, and every +`seize*`/`SEIZE_*` selector below is undialable. + +## Mapping table + +Selectors and topic0s below are the real values from the frozen ABIs +(`crates/common/precompiles/src/common/abi/v1.rs` = Beryl, +`crates/common/precompiles/src/common/abi/v2.rs` = Cobalt); all Beryl symbols keep their selector at +Cobalt. Seize lives on the shared `IB20` surface, so it is identical across Asset and Stablecoin. + +### Functions + +| Beryl symbol (selector) | Cobalt (selector) | Status | Why | +| --- | --- | --- | --- | +| `burnBlocked(address,uint256)` `0xec0cf3dc` | `burnBlocked(address,uint256)` `0xec0cf3dc` | deprecated-dialable | Retained unchanged for back-compat; prefer `seizeWithMemo` then `burn`. Destroys supply; reads `TRANSFER_SENDER_POLICY`. | +| `BURN_BLOCKED_ROLE()` `0x32ad9be8` | `BURN_BLOCKED_ROLE()` `0x32ad9be8` | carried over unchanged | Still gates `burnBlocked` only. | +| — | `seizeWithMemo(address,address,uint256,bytes32)` `0xf916d81b` | new | Admin balance reassignment (a transfer, not a burn). | +| — | `SEIZE_ROLE()` `0x3c7e9ba5` | new | Required to call `seizeWithMemo`. Value `keccak256("SEIZE_ROLE")` = `0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432`. | +| — | `SEIZE_HOLDER_POLICY()` `0xb279d311` | new | Policy slot consulted against `from`. Value `keccak256("SEIZE_HOLDER_POLICY")` = `0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2`. | +| — | `SEIZE_RECEIVER_POLICY()` `0xb31da27f` | new | Policy slot consulted against `to`. Value `keccak256("SEIZE_RECEIVER_POLICY")` = `0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d`. | + +### Events + +| Beryl event (topic0) | Cobalt (topic0) | Status | Why | +| --- | --- | --- | --- | +| `BurnedBlocked(address,address,uint256)` `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` | unchanged | deprecated-still-emitted | Still emitted by `burnBlocked` alongside `Transfer(from, address(0), amount)`. | +| — | `Seized(address,address,address,uint256)` `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` | new | Emitted by `seizeWithMemo` after `Transfer(from, to, amount)` and `Memo(caller, memo)`. | + +### Errors + +| 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` (i.e. not blocked). | +| — | `AccountNotSeizable(address)` `0x91dbbc8d` | new | Thrown by `seizeWithMemo` when `from` is authorized under `SEIZE_HOLDER_POLICY` (i.e. not seizable). | + +### Pause features + +`PausableFeature` is append-only; Cobalt adds one ordinal. + +| Beryl ordinals | Cobalt addition | Storage bit | Why | +| --- | --- | --- | --- | +| `TRANSFER=0`, `MINT=1`, `BURN=2` | `SEIZE=3` | `1 << 3 = 8` | Independent pause vector for `seizeWithMemo`. `ALL_FEATURES_PAUSED` becomes `15` (`0b1111`). | + +`seizeWithMemo` is gated by the new `SEIZE` vector — **not** `BURN`. `burnBlocked` stays under `BURN`. + +## New at Cobalt (adopt these) + +### `seizeWithMemo(from, to, amount, memo)` + +The canonical administrative balance-removal path. It is a **transfer** (balance moves `from -> to`; +`totalSupply` is unchanged), performed as an admin op that **skips allowance and the transfer +policies** (`TRANSFER_SENDER/RECEIVER/EXECUTOR_POLICY`). Emits, in order: + +1. `Transfer(from, to, amount)` +2. `Memo(caller, memo)` — a memo of `bytes32(0)` is permitted +3. `Seized(caller, from, to, amount)` + +Requirements and guards: + +- **Role:** caller holds `SEIZE_ROLE` (else `AccessControlUnauthorizedAccount`). +- **Pause:** `SEIZE` not paused (else `ContractPaused(SEIZE)`). +- **Addresses:** `to != address(0)` and `from != to` (else `InvalidReceiver`); `from != address(0)` + (else `InvalidSender`). +- **Holder gate:** `from` must be **blocked** under `SEIZE_HOLDER_POLICY` — that is, *not* authorized + by it (else `AccountNotSeizable`). An **unset** slot reads as always-allow, so **no account is + seizable until an issuer configures `SEIZE_HOLDER_POLICY`.** +- **Destination gate:** `to` must be authorized under `SEIZE_RECEIVER_POLICY`, which mirrors + `MINT_RECEIVER_POLICY` — always enforced, but an **unset** slot is always-allow, so a token may + seize to any destination (a treasury need not be allowlisted) until the slot is set. +- **Balance:** `from`'s balance >= `amount` (else `InsufficientBalance`). + +Precedence when multiple guards would fail: holder gate > destination gate > balance +(`AccountNotSeizable` before `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` before +`InsufficientBalance`). + +## `burnBlocked` is deprecated (but unchanged and still dialable) + +`burnBlocked(from, amount)` keeps working exactly as on Beryl: + +- Destroys `amount` from a `from` **blocked under `TRANSFER_SENDER_POLICY`**, without spending an + allowance. Emits `Transfer(from, address(0), amount)` and `BurnedBlocked(caller, from, amount)` + (no `Memo`). +- Gated by `BURN_BLOCKED_ROLE` and the `BURN` pause vector. +- Reverts `AccountNotBlocked` when `from` is authorized under `TRANSFER_SENDER_POLICY`. + +To migrate, replace a `burnBlocked(from, amount)` with `seizeWithMemo(from, treasury, amount, memo)` +followed by `burn(amount)` from the treasury if you still want the supply destroyed. Note this +crosses two policy/role/pause domains (see edge cases) — it is not a drop-in selector swap. + +## Guarantees / edge cases + +**Q: Does seize change `totalSupply`? Is it a burn?** +No. Seize is a transfer: it reassigns `amount` from `from` to `to` and leaves `totalSupply` +untouched. `burnBlocked` is the burn — it sends to `address(0)` and reduces supply. To reproduce the +old burn-blocked outcome, seize to a treasury/self address and then `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 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. + +**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` does not +stop `seizeWithMemo`, and pausing `SEIZE` does not stop `burn`/`burnWithMemo`/`burnBlocked`. + +**Q: Do `SEIZE_ROLE` and `BURN_BLOCKED_ROLE` overlap?** +No. `seizeWithMemo` requires `SEIZE_ROLE`; `burnBlocked` requires `BURN_BLOCKED_ROLE`. Granting one +does not grant the other. + +**Q: I never configured the seize policies — what happens if I call `seizeWithMemo`?** +It reverts `AccountNotSeizable(from)` for every `from`: an unset `SEIZE_HOLDER_POLICY` is +always-allow, so no account is seizable. `SEIZE_HOLDER_POLICY` must be configured to designate +seizable holders before seize does anything. (`SEIZE_RECEIVER_POLICY` left unset simply permits any +destination.) + +**Q: Does seize consult the transfer policies or spend an allowance?** +No. It is an admin op: it bypasses `TRANSFER_SENDER/RECEIVER/EXECUTOR_POLICY` and allowances, and +enforces only `SEIZE_HOLDER_POLICY` (on `from`) and `SEIZE_RECEIVER_POLICY` (on `to`). + +**Q: Is seize available on B20 Stablecoin as well as B20 Asset?** +Yes. It is defined on the shared `IB20` surface, so both variants expose the identical +`seizeWithMemo` selector, `Seized` topic0, `AccountNotSeizable` selector, `SEIZE_*` getters, and +`SEIZE` pause bit at Cobalt. From 2a4574fa0f09b8b6464a06c315473a63f95a166f Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Tue, 11 Aug 2026 16:52:57 +0200 Subject: [PATCH 3/8] docs(changelog): note seize is opt-in per token issuer via SEIZE_HOLDER_POLICY Co-authored-by: OpenCode --- changelog/cobalt/b20-seize-surface.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/changelog/cobalt/b20-seize-surface.md b/changelog/cobalt/b20-seize-surface.md index 5bc147a..73d9536 100644 --- a/changelog/cobalt/b20-seize-surface.md +++ b/changelog/cobalt/b20-seize-surface.md @@ -15,8 +15,11 @@ error keeps its exact 4-byte selector / topic0 and stays dialable at Cobalt. In `burnBlocked` is **deprecated but unchanged** — same selector, same events, same behavior — and remains callable. The migration is: move administrative balance removal from `burnBlocked` to `seizeWithMemo` (seize to a treasury/self address, then `burn` if you want the supply destroyed). -**Cobalt is not live yet**; until it activates only the Beryl surface exists on-chain, and every -`seize*`/`SEIZE_*` selector below is undialable. +**Seize is opt-in per token.** The surface exists at Cobalt, but seize does nothing until the issuer +configures `SEIZE_HOLDER_POLICY`: with the slot unset (always-allow), no account is seizable and +every `seizeWithMemo` call reverts `AccountNotSeizable`. An issuer that never sets the policy has, in +effect, no seize capability on that token. **Cobalt is not live yet**; until it activates only the +Beryl surface exists on-chain, and every `seize*`/`SEIZE_*` selector below is undialable. ## Mapping table From d1ded7d1c285b32e5c7d68cae1a4d9596cb9277c Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 11 Aug 2026 11:19:50 -0400 Subject: [PATCH 4/8] docs(changelog): add Cobalt PolicyRegistry composite-policies entry Document the UNION/INTERSECT composite policy surface landing at Cobalt, mirroring the seize/scheduled-multiplier changelog entries: mapping table with verified selectors/topic0s, revert precedence for createCompositePolicy/updateComposite, and guarantees/edge-case Q&A. Co-Authored-By: Claude --- changelog/README.md | 1 + .../policy-registry-composite-policies.md | 141 ++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 changelog/cobalt/policy-registry-composite-policies.md diff --git a/changelog/README.md b/changelog/README.md index 207ede7..cb0e046 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -43,3 +43,4 @@ citing it** — do not rely on memory. Selectors can be checked with `cast sig` | --- | --- | --- | --- | | Cobalt (upcoming) | Schedule Multiplier Updates (ERC-8056) | B20 Asset | [b20-asset-scheduled-multiplier-updates](cobalt/b20-asset-scheduled-multiplier-updates.md) | | Cobalt (upcoming) | Seize + `burnBlocked` deprecation | B20 (Asset + Stablecoin) | [b20-seize-surface](cobalt/b20-seize-surface.md) | +| Cobalt (upcoming) | Composite Policies (UNION/INTERSECT) | PolicyRegistry | [policy-registry-composite-policies](cobalt/policy-registry-composite-policies.md) | diff --git a/changelog/cobalt/policy-registry-composite-policies.md b/changelog/cobalt/policy-registry-composite-policies.md new file mode 100644 index 0000000..66170f9 --- /dev/null +++ b/changelog/cobalt/policy-registry-composite-policies.md @@ -0,0 +1,141 @@ +# PolicyRegistry — composite policies (UNION / INTERSECT) + +> **Audience:** teams integrated against `PolicyRegistry` on **Beryl** (live today) — creating and +> administering simple `ALLOWLIST`/`BLOCKLIST` policies, and referencing policy IDs on B20 policy +> slots. This note covers **only** the composite (`UNION`/`INTERSECT`) policy support landing at the +> **Cobalt** hardfork. + +## Summary + +At Cobalt, `PolicyRegistry` gains **composite policies**: a policy that authorizes by combining 2–4 +existing *simple* policies under a `UNION` (OR) or `INTERSECT` (AND) gate, created with the new +`createCompositePolicy` and mutated in full with the new `updateComposite`. **Nothing you call today +breaks.** Every Beryl selector, event topic, and error keeps its exact 4-byte selector / topic0 and +stays dialable at Cobalt. The only change to existing behavior: `createPolicy` and +`createPolicyWithAccounts` gain one new — previously unreachable — revert path, rejecting a +composite `policyType` with the already-existing `IncompatiblePolicyType` error. Composite support +was flagged in the Beryl-era docs as "planned for a future hardfork via additive `PolicyType` enum +values and sibling creator functions"; this is that hardfork. **Cobalt is not live yet**; until it +activates, only the Beryl (simple-policy) surface exists on-chain, and every composite selector below +is undialable. + +## Mapping table + +Selectors and topic0s below are computed directly from `src/interfaces/IPolicyRegistry.sol` with +`cast sig` / `cast sig-event`; all Beryl symbols keep their selector at Cobalt. + +### `PolicyType` enum + +| Beryl | Cobalt addition | Why | +| --- | --- | --- | +| `BLOCKLIST = 0`, `ALLOWLIST = 1` | `UNION = 2`, `INTERSECT = 3` | Append-only — existing values and the packed-ID top-byte encoding are unchanged. | + +### Functions + +| Beryl symbol (selector) | Cobalt (selector) | Status | Why | +| --- | --- | --- | --- | +| `createPolicy(address,uint8)` `0xca5d55f6` | unchanged | present on Beryl already, new revert path | Now also reverts `IncompatiblePolicyType` when `policyType` is `UNION`/`INTERSECT`, checked after `ZeroAddress`. | +| `createPolicyWithAccounts(address,uint8,address[])` `0xa2d3044f` | unchanged | present on Beryl already, new revert path | Same composite-type rejection, checked after `ZeroAddress` and before `BatchSizeTooLarge`. | +| — | `createCompositePolicy(address,uint8,uint64[])` `0x6fdd1491` | new | Creates a `UNION`/`INTERSECT` policy from 2–4 existing simple policy IDs. | +| — | `updateComposite(uint64,uint64[])` `0xbfe142c0` | new | Replaces a composite's child-policy set in full — no partial-update or clear-the-list path. | +| — | `compositePolicyChildIds(uint64)` `0x7c40df74` | new | Read-only child-set getter. Always callable (not gated). | +| — | `MIN_COMPOSITE_CHILD_POLICIES()` `0xb3ae29f7` | new | Constant `2`. Always callable. | +| — | `MAX_COMPOSITE_CHILD_POLICIES()` `0x54309870` | new | Constant `4`. Always callable. | + +`isAuthorized(uint64,address)` `0x55a1179e`, `policyExists(uint64)` `0x330f5637`, +`policyAdmin(uint64)` `0x09dd0a47`, `pendingPolicyAdmin(uint64)` `0x017548b7`, +`updateAllowlist(uint64,bool,address[])` `0x3388fb5b`, `updateBlocklist(uint64,bool,address[])` +`0x5c4e51b8`, `stageUpdateAdmin(uint64,address)` `0x1d7ae695`, `finalizeUpdateAdmin(uint64)` +`0x33031a9c`, and `renounceAdmin(uint64)` `0xefdb7fa3` are carried over unchanged. + +### Events + +| Beryl event (topic0) | Cobalt (topic0) | Status | Why | +| --- | --- | --- | --- | +| `PolicyCreated(uint64,address,uint8)` `0x718d…2ec27` | unchanged | carried over | Also emitted for composite creation, with `policyType` `UNION`/`INTERSECT`. | +| — | `CompositePolicyUpdated(uint64,address,uint64[])` `0x4ff6adaab31b0df87aa7b8b7320c52b8b3b5eede3bf28a6baaaa8b8b7e1d6363` | new | Emitted on composite creation **and** every `updateComposite`; carries the complete post-update child set. | + +`PolicyAdminStaged`, `PolicyAdminUpdated`, `AllowlistUpdated`, and `BlocklistUpdated` are carried +over unchanged and are not emitted for composites (composites have no membership set of their own). + +### Errors + +| Beryl error (selector) | Cobalt (selector) | Status | Why | +| --- | --- | --- | --- | +| `IncompatiblePolicyType()` `0xf1011ef5` | unchanged | present on Beryl already, new call sites | Now also thrown by `createPolicy`/`createPolicyWithAccounts` (composite `policyType` passed to a simple constructor), `createCompositePolicy` (`policyType` isn't `UNION`/`INTERSECT`), and `updateComposite` (target isn't a composite). | +| `PolicyNotFound()` `0x720caa4f` | unchanged | present on Beryl already, new call sites | Now also thrown for the composite target itself in `updateComposite`, and for any nonexistent child in `createCompositePolicy`/`updateComposite`. | +| — | `ChildPoliciesOutsideOfRange()` `0x697ec868` | new | Child count outside `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (`[2, 4]`). | +| — | `InvalidChildPolicy(uint64)` `0x46508ef6` | new | A child is itself a composite, or a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`). | + +## New at Cobalt (adopt these) + +### `createCompositePolicy(admin, policyType, childPolicyIds)` + +Creates a `UNION`/`INTERSECT` policy over 2–4 existing simple policy IDs. Canonical revert +precedence — each check fires before the next, in this order: + +1. `ZeroAddress` — `admin == address(0)`. +2. `IncompatiblePolicyType` — `policyType` is not `UNION`/`INTERSECT`. +3. `ChildPoliciesOutsideOfRange` — `childPolicyIds.length` outside `[2, 4]`. +4. `PolicyNotFound` — any child does not exist (checked as one pass over the whole set, before the + next check). +5. `InvalidChildPolicy` — any child is a composite or a built-in sentinel (second pass). + +On success, emits `PolicyCreated(policyId, creator, policyType)`, +`PolicyAdminUpdated(policyId, 0, admin)`, then `CompositePolicyUpdated(policyId, creator, childPolicyIds)`. + +### `updateComposite(policyId, childPolicyIds)` + +Replaces a composite's child-policy set **in full** — a child omitted from the new set no longer +governs the composite; there is no partial-update or clear-the-list path. Canonical order: + +1. `PolicyNotFound` — `policyId` does not exist. +2. `IncompatiblePolicyType` — `policyId` is a simple policy, not a composite. +3. `Unauthorized` — caller is not the current admin. A renounced composite (admin `address(0)`) can + never be updated. +4. `ChildPoliciesOutsideOfRange` — new count outside `[2, 4]`. +5. `PolicyNotFound` — any new child does not exist. +6. `InvalidChildPolicy` — any new child is a composite or a built-in sentinel. + +Emits `CompositePolicyUpdated(policyId, updater, childPolicyIds)`. + +### Live, depth-1 evaluation + +`isAuthorized` on a composite re-reads each child's **current** membership on every call — never a +snapshot taken at creation or last update. `UNION` returns `true` on the first authorizing child +(short-circuits); `INTERSECT` returns `false` on the first non-authorizing child. Recursion never +exceeds depth 1: every child is validated to be a simple (`ALLOWLIST`/`BLOCKLIST`) policy at write +time, so a composite's children can never themselves be composites. + +## Guarantees / edge cases + +**Q: Can a composite's child be another composite (nested composites)?** +No. `createCompositePolicy` and `updateComposite` revert `InvalidChildPolicy(childPolicyId)` for any +child whose type is `UNION`/`INTERSECT`. Nesting is impossible by construction. + +**Q: Can a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`) be a composite child?** +No — same `InvalidChildPolicy` revert. To mix always-allow/always-block behavior into a composite +gate, use a real `ALLOWLIST`/`BLOCKLIST` policy that reproduces the desired effect instead. + +**Q: Can I pass the same child ID twice, or shrink a composite below 2 children?** +Duplicates are permitted — the registry neither sorts nor de-duplicates the stored child list (extra +evaluation cost only; `UNION`/`INTERSECT` are idempotent under duplicates). Shrinking below 2 is not +possible: every `updateComposite` call re-enforces the same `[2, 4]` range as creation, so there is +no path to an empty or under-sized composite. + +**Q: If a child policy's admin renounces, does the parent composite break?** +No. `renounceAdmin` on the child only clears its admin and freezes its *future* membership changes — +the child still exists and `isAuthorized` on it still resolves normally, so the composite keeps +evaluating it exactly as before. + +**Q: Is composite mutation gated separately from simple-policy mutation?** +No. `createCompositePolicy` and `updateComposite` are gated by the same `ActivationRegistry` flag +that gates `createPolicy`, `updateAllowlist`, etc. — there is no composite-specific activation flag. +`compositePolicyChildIds`, `MIN_COMPOSITE_CHILD_POLICIES`, `MAX_COMPOSITE_CHILD_POLICIES`, and +`isAuthorized` on a composite ID are all always callable, whether or not the feature is active. + +**Q: Can a B20 token's policy slot (e.g. `TRANSFER_SENDER_POLICY`, `SEIZE_HOLDER_POLICY`) reference a +composite ID?** +Yes. B20 stores every policy slot as an opaque `uint64 policyId` and calls `isAuthorized` — 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. From 99cc75db95f6af562ecc0abbd8bfe8d7abf8add1 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 11 Aug 2026 12:01:37 -0400 Subject: [PATCH 5/8] docs(changelog): trim planned-for-future-hardfork aside per review Co-Authored-By: Claude --- changelog/cobalt/policy-registry-composite-policies.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/changelog/cobalt/policy-registry-composite-policies.md b/changelog/cobalt/policy-registry-composite-policies.md index 66170f9..7a4302c 100644 --- a/changelog/cobalt/policy-registry-composite-policies.md +++ b/changelog/cobalt/policy-registry-composite-policies.md @@ -13,11 +13,9 @@ existing *simple* policies under a `UNION` (OR) or `INTERSECT` (AND) gate, creat breaks.** Every Beryl selector, event topic, and error keeps its exact 4-byte selector / topic0 and stays dialable at Cobalt. The only change to existing behavior: `createPolicy` and `createPolicyWithAccounts` gain one new — previously unreachable — revert path, rejecting a -composite `policyType` with the already-existing `IncompatiblePolicyType` error. Composite support -was flagged in the Beryl-era docs as "planned for a future hardfork via additive `PolicyType` enum -values and sibling creator functions"; this is that hardfork. **Cobalt is not live yet**; until it -activates, only the Beryl (simple-policy) surface exists on-chain, and every composite selector below -is undialable. +composite `policyType` with the already-existing `IncompatiblePolicyType` error. **Cobalt is not +live yet**; until it activates, only the Beryl (simple-policy) surface exists on-chain, and every +composite selector below is undialable. ## Mapping table From 9acaeeb9aba5311eb1ae1a79ebaf0c201941f1bc Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 11 Aug 2026 12:06:02 -0400 Subject: [PATCH 6/8] docs(changelog): simplify composite live-evaluation description per review Co-Authored-By: Claude --- changelog/cobalt/policy-registry-composite-policies.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/changelog/cobalt/policy-registry-composite-policies.md b/changelog/cobalt/policy-registry-composite-policies.md index 7a4302c..c192062 100644 --- a/changelog/cobalt/policy-registry-composite-policies.md +++ b/changelog/cobalt/policy-registry-composite-policies.md @@ -99,11 +99,11 @@ Emits `CompositePolicyUpdated(policyId, updater, childPolicyIds)`. ### Live, depth-1 evaluation -`isAuthorized` on a composite re-reads each child's **current** membership on every call — never a -snapshot taken at creation or last update. `UNION` returns `true` on the first authorizing child -(short-circuits); `INTERSECT` returns `false` on the first non-authorizing child. Recursion never -exceeds depth 1: every child is validated to be a simple (`ALLOWLIST`/`BLOCKLIST`) policy at write -time, so a composite's children can never themselves be composites. +`isAuthorized` on a composite calls each child policy's `isAuthorized` — never a snapshot taken at +creation or last update. `UNION` returns `true` on the first authorizing child (short-circuits); +`INTERSECT` returns `false` on the first non-authorizing child. Recursion never exceeds depth 1: +every child is validated to be a simple (`ALLOWLIST`/`BLOCKLIST`) policy at write time, so a +composite's children can never themselves be composites. ## Guarantees / edge cases From 316b6878cfdefde87bd26eb2d9930a300f5ca041 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 11 Aug 2026 12:37:02 -0400 Subject: [PATCH 7/8] docs(changelog): drop liveness/date framing per review Co-Authored-By: Claude --- changelog/cobalt/policy-registry-composite-policies.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog/cobalt/policy-registry-composite-policies.md b/changelog/cobalt/policy-registry-composite-policies.md index c192062..9848d1d 100644 --- a/changelog/cobalt/policy-registry-composite-policies.md +++ b/changelog/cobalt/policy-registry-composite-policies.md @@ -1,6 +1,6 @@ # PolicyRegistry — composite policies (UNION / INTERSECT) -> **Audience:** teams integrated against `PolicyRegistry` on **Beryl** (live today) — creating and +> **Audience:** teams integrated against `PolicyRegistry` on **Beryl** — creating and > administering simple `ALLOWLIST`/`BLOCKLIST` policies, and referencing policy IDs on B20 policy > slots. This note covers **only** the composite (`UNION`/`INTERSECT`) policy support landing at the > **Cobalt** hardfork. @@ -13,9 +13,9 @@ existing *simple* policies under a `UNION` (OR) or `INTERSECT` (AND) gate, creat breaks.** Every Beryl selector, event topic, and error keeps its exact 4-byte selector / topic0 and stays dialable at Cobalt. The only change to existing behavior: `createPolicy` and `createPolicyWithAccounts` gain one new — previously unreachable — revert path, rejecting a -composite `policyType` with the already-existing `IncompatiblePolicyType` error. **Cobalt is not -live yet**; until it activates, only the Beryl (simple-policy) surface exists on-chain, and every -composite selector below is undialable. +composite `policyType` with the already-existing `IncompatiblePolicyType` error. Until Cobalt +activates, only the Beryl (simple-policy) surface exists on-chain and every composite selector +below is undialable. ## Mapping table From c5ec9d19e257b8568fb083597f4e2b4c79f3f29a Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 11 Aug 2026 15:45:21 -0400 Subject: [PATCH 8/8] docs(changelog): print PolicyCreated topic0 in full for consistency Co-Authored-By: Claude --- changelog/cobalt/policy-registry-composite-policies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/cobalt/policy-registry-composite-policies.md b/changelog/cobalt/policy-registry-composite-policies.md index 9848d1d..b45bda2 100644 --- a/changelog/cobalt/policy-registry-composite-policies.md +++ b/changelog/cobalt/policy-registry-composite-policies.md @@ -50,7 +50,7 @@ Selectors and topic0s below are computed directly from `src/interfaces/IPolicyRe | Beryl event (topic0) | Cobalt (topic0) | Status | Why | | --- | --- | --- | --- | -| `PolicyCreated(uint64,address,uint8)` `0x718d…2ec27` | unchanged | carried over | Also emitted for composite creation, with `policyType` `UNION`/`INTERSECT`. | +| `PolicyCreated(uint64,address,uint8)` `0x718d87917f0c4cfd1263707ef0e77c656ed8d8bfaca06152bdb0b8094142ec27` | unchanged | carried over | Also emitted for composite creation, with `policyType` `UNION`/`INTERSECT`. | | — | `CompositePolicyUpdated(uint64,address,uint64[])` `0x4ff6adaab31b0df87aa7b8b7320c52b8b3b5eede3bf28a6baaaa8b8b7e1d6363` | new | Emitted on composite creation **and** every `updateComposite`; carries the complete post-update child set. | `PolicyAdminStaged`, `PolicyAdminUpdated`, `AllowlistUpdated`, and `BlocklistUpdated` are carried