From e7bb265465dd27b37baa22709981a11e129e56d9 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Fri, 14 Aug 2026 11:41:19 -0400 Subject: [PATCH 01/21] docs(changelog): split entry-authoring instructions into AGENTS.md README.md now documents only the current state (ordinals, index of shipped/upcoming entries). Naming rules, entry structure, and verification requirements move to changelog/AGENTS.md. Co-Authored-By: Claude --- changelog/AGENTS.md | 62 +++++++++++++++++++++++++++++++++++++++++++++ changelog/README.md | 57 +++-------------------------------------- 2 files changed, 65 insertions(+), 54 deletions(-) create mode 100644 changelog/AGENTS.md diff --git a/changelog/AGENTS.md b/changelog/AGENTS.md new file mode 100644 index 0000000..2d62f06 --- /dev/null +++ b/changelog/AGENTS.md @@ -0,0 +1,62 @@ +# Agent instructions — changelog/ + +Read [README.md](README.md) first for the index of what's already documented. This file covers how +to name, write, and index a new entry. + +## Naming a new entry + +``` +changelog/ + ___.md # one scoped feature change +``` + +Each file name has four parts: + +- ``: a 2-digit, zero-padded hardfork activation sequence number, assigned once per + hardfork, never per file. Check README's [Hardfork ordinals](README.md#hardfork-ordinals) table + and reuse the ordinal if the hardfork already has one. Assign the next unused number only for a + hardfork with no prior entry, and add a row for it in that table. Because filenames sort lexically, + this keeps the directory listing in activation order. +- ``: the PascalCase codename, for example `Cobalt`. +- ``: a PascalCase token matching the same product's directory under + [`docs/`](../docs) and [`test/unit/`](../test/unit), for example `B20Asset` or `PolicyRegistry`. + For a change that spans both B20 variants, use the shared-surface token `B20`. +- ``: a short, lowercase snake_case slug that maps to a release-notes line item, for + example `multiplier`, `seize`, or `composite_policy`. Don't repeat the product or hardfork in the + slug — those are already separate filename components. + +Never rename or renumber a shipped entry. Once a hardfork activates on-chain, its entries are +frozen; only add new files for new hardforks or features. + +## Writing an entry + +Keep entries minimal and migration-focused. Don't restate unchanged behavior. A good entry has: + +1. An audience statement and a one-paragraph summary that leads with the compatibility promise: + what still works, what's deprecated but still dialable, and what's new. State plainly whether + the fork is live yet. +2. A mapping table: old symbol, new symbol, status (`deprecated-dialable`, `renamed`, or `new`), and + a one-line reason. Cover functions, events, and errors, with real signatures and selectors. +3. A "New at `` (adopt these)" section describing the new surface and its lifecycle. +4. A guarantees and edge cases section: a short Q&A covering what a careful integrator would ask, + each answered with the precise on-chain behavior. + +Before you cite a function signature, event signature or topic0, error selector, 4-byte function +selector, or ERC-165 interface ID, verify it against the source (`src/interfaces/**` and the +base/base ABIs). Don't rely on memory, and don't trust a prior entry's numbers even for a symbol +you believe is unchanged. Check selectors with `cast sig` or `cast keccak`. + +If you can't verify a value, flag it in the entry or ask, rather than shipping a plausible-looking +but unverified selector. + +## Indexing a new entry + +Add a row to the current hardfork's table in README's [Index](README.md#index). Sort each +hardfork's table by `Product(s)`, then by change. A change that touches more than one product +because it lives on a shared interface gets one row, not one row per product: list everything it +touches in the `Product(s)` and `Affected interfaces` columns. + +Never edit a shipped hardfork's rows except to append a new one, and never renumber or reorder +existing rows. To add the first entry for a new hardfork, copy the most recent `
` block, +set its `` to the new codename and ordinal, and add it above the existing blocks — don't +touch prior blocks. diff --git a/changelog/README.md b/changelog/README.md index 54334c1..703cd9d 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -8,62 +8,18 @@ hardfork's release notes (for example, Cobalt's "Schedule Multiplier Updates"). This complements the product references in [`docs/`](../docs). `docs/` describes how a product works today. `changelog/` describes what changes at a hardfork and how to migrate across it. -## Layout +See [AGENTS.md](AGENTS.md) for how to name and write a new entry. -``` -changelog/ - ___.md # one scoped feature change -``` - -Each file name has four parts: - -- ``: a 2-digit, zero-padded hardfork activation sequence number, assigned once per - hardfork, never per file. See [Hardfork ordinals](#hardfork-ordinals). This keeps sort order - correct by construction: a flat directory listing always groups and orders files by activation - order, regardless of whether the codenames happen to be alphabetical. -- ``: the PascalCase codename, for example `Cobalt`. -- ``: a PascalCase token matching the same product's directory under - [`docs/`](../docs) and [`test/unit/`](../test/unit), for example `B20Asset` or `PolicyRegistry`. - For a change that spans both B20 variants, use the shared-surface token `B20`. -- ``: a short, lowercase snake_case slug that maps to a release-notes line item, for - example `multiplier`, `seize`, or `composite_policy`. - -Never rename or renumber a shipped entry. When a new hardfork ships, give it the next ordinal. When -a new feature ships within an existing hardfork, add a new file under that hardfork's ordinal. - -### Hardfork ordinals +## Hardfork ordinals | Ordinal | Hardfork | Status | | --- | --- | --- | | `01` | Beryl | Live | | `02` | Cobalt | Upcoming | -Assign the next ordinal here before you name the first entry for a new hardfork. - -## What an entry contains - -Keep entries minimal and migration-focused. Don't restate unchanged behavior. A good entry has: - -1. An audience statement and a one-paragraph summary that leads with the compatibility promise: - what still works, what's deprecated but still dialable, and what's new. State plainly whether - the fork is live yet. -2. A mapping table: old symbol, new symbol, status (`deprecated-dialable`, `renamed`, or `new`), and - a one-line reason. Cover functions, events, and errors, with real signatures and selectors. -3. A "New at `` (adopt these)" section describing the new surface and its lifecycle. -4. A guarantees and edge cases section: a short Q&A covering what a careful integrator would ask, - each answered with the precise on-chain behavior. - -Before you cite a function signature, event signature or topic0, error selector, 4-byte function -selector, or ERC-165 interface ID, verify it against the source (`src/interfaces/**` and the -base/base ABIs). Don't rely on memory. You can check selectors with `cast sig` or `cast keccak`. - ## Index -Entries are grouped by hardfork, one collapsible section per hardfork, newest first. Each -hardfork's table is sorted by `Product(s)`, then by change. A change that touches more than one -product because it lives on a shared interface gets one row, not one row per product: its -`Product(s)` and `Affected interfaces` columns list everything it touches. Never edit a shipped -hardfork's rows except to append a new one, and never renumber or reorder existing rows. +Grouped by hardfork, one collapsible section per hardfork, newest first.
Cobalt (upcoming) — ordinal 02 @@ -75,10 +31,3 @@ hardfork's rows except to append a new one, and never renumber or reorder existi | PolicyRegistry | Composite Policies (UNION/INTERSECT) | `src/interfaces/IPolicyRegistry.sol` | [02_Cobalt_PolicyRegistry_composite_policy](02_Cobalt_PolicyRegistry_composite_policy.md) |
- - From a5d6b48d49dacb6b9ceed24e0075496ac6591669 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 18 Aug 2026 09:37:24 -0400 Subject: [PATCH 02/21] feat: init workflow --- .claude/skills/changelog-grill/SKILL.md | 133 +++++++++++ changelog/02_Cobalt_B20Asset_multiplier.md | 144 ----------- changelog/02_Cobalt_B20_seize.md | 150 ------------ ..._Cobalt_PolicyRegistry_composite_policy.md | 143 ----------- changelog/COMPOSITE_POLICY.md | 226 ++++++++++++++++++ changelog/TEMPLATE_POINT_FORM.md | 64 +++++ 6 files changed, 423 insertions(+), 437 deletions(-) create mode 100644 .claude/skills/changelog-grill/SKILL.md delete mode 100644 changelog/02_Cobalt_B20Asset_multiplier.md delete mode 100644 changelog/02_Cobalt_B20_seize.md delete mode 100644 changelog/02_Cobalt_PolicyRegistry_composite_policy.md create mode 100644 changelog/COMPOSITE_POLICY.md create mode 100644 changelog/TEMPLATE_POINT_FORM.md diff --git a/.claude/skills/changelog-grill/SKILL.md b/.claude/skills/changelog-grill/SKILL.md new file mode 100644 index 0000000..c01e630 --- /dev/null +++ b/.claude/skills/changelog-grill/SKILL.md @@ -0,0 +1,133 @@ +--- +name: changelog-grill +description: Grill a changelog or ADR template until its intent, assumptions, design decisions, and migration impact are understood. Use when reviewing a changelog template, preparing an ADR, or stress-testing a feature proposal before writing the final entry. +disable-model-invocation: true +--- + +# Changelog Grill + +Turn an incomplete changelog or ADR template into a validated context handoff. Do not edit the +template automatically. The primary output is structured Markdown in the conversation that the user +can review, correct, and pass into a new context or use to write the final changelog entry. + +## Input + +The user should provide a path to the template file. If they provide pasted content instead, use it +directly. When a path is provided: + +1. Read the template. +2. Read repository guidance that applies to the file, especially `AGENTS.md`, the changelog README, + and relevant product documentation. +3. Inspect related source, interfaces, tests, existing changelog entries, and configuration when + needed to verify facts or understand the proposal. + +Do not ask the user for facts that can be obtained from the repository or tools. Clearly distinguish +facts found in the repository from decisions that only the user can make. + +## Mission + +Interview the user until there is a shared understanding of the proposed change. Review every +section in the supplied template, including sections that appear complete. Look for: + +- Missing context or prerequisites +- Ambiguous terminology and undefined actors +- Unstated assumptions +- Claims that need source or test verification +- Missing compatibility, rollout, or migration behavior +- Design decisions that were made implicitly +- Important alternatives that were not considered +- Scope that is too broad, too narrow, or inconsistent with the repository +- Security, storage, gas, API, and operational implications where relevant + +Do not merely ask the user to fill in blanks. Challenge the reasoning. When the current approach has +a plausible better alternative, explain it and ask why the user prefers the current approach. The +user owns the decisions; the skill owns fact-finding and identifying questions. + +## Grilling protocol + +Map the proposal as a dependency-aware design tree and work in rounds. + +- The **frontier** is every decision whose prerequisites are settled. +- Ask all questions on the current frontier in one round. +- Do not ask downstream questions whose answers depend on unresolved decisions in the same round. +- Number every question and include a recommendation. +- Wait for the user's answers before recomputing the next frontier. +- Continue until every meaningful branch has been considered and no important assumption remains + silent. + +Use this format: + +```markdown +❓ **Q1 - **: + +➡️ **Recommendation:** +``` + +Ask questions in groups that make sense together, such as scope and audience first, then behavior +and interfaces, then alternatives and migration. Keep questions specific enough to answer without +guessing. + +### Challenge expectations + +For each major design choice, ask: + +- What problem does this solve? +- Why is this the right layer for the behavior? +- What alternatives were considered? +- Why was the chosen approach preferred? +- What future requirement or complexity does it intentionally exclude? +- What breaks, changes, or remains compatible? + +For changelog entries, explicitly examine: + +- Audience and user-facing purpose +- Hardfork, rollout, and activation status +- Existing behavior and compatibility guarantees +- New, renamed, deprecated, or removed interface symbols +- Functions, events, errors, selectors, topics, and interface IDs +- Behavioral and revert-order changes +- Storage layout, encoding, packing, and migration concerns +- Authorization, policy, pause, and activation behavior +- Examples that demonstrate before/after usage +- Migration steps and whether action is required +- Edge cases and precise guarantees + +If a selector, signature, storage slot, or behavior is not verified, mark it as unverified and find +the source or ask the user to resolve the gap. Never invent plausible technical values. + +## Completion and output + +Do not modify, create, or rename template files during grilling. The session is complete only after: + +1. All relevant template sections have been reviewed. +2. Repository facts have been gathered or marked unavailable. +3. The user has answered the meaningful frontier questions. +4. The skill presents a context handoff for confirmation. + +The final handoff must use this structure: + +```markdown +# Grilled Context Handoff + +## Confirmed Understanding + +## Decisions Made + +## Validated Assumptions + +## Design Alternatives Considered + +## Missing Context + +## Open Questions + +## Recommended Template Additions + +## Final Confirmation +``` + +In `Final Confirmation`, state whether the context is ready to use for writing the changelog. If +there are unresolved questions, list them plainly and do not claim the handoff is complete. + +After presenting the handoff, wait for explicit confirmation. Only after confirmation may the user +ask for the template or a new changelog entry to be written. diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md deleted file mode 100644 index 4f9282e..0000000 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ /dev/null @@ -1,144 +0,0 @@ -# B20 Asset: Beryl to Cobalt (ERC-8056) migration - -> **Audience:** teams already integrated against the B20 Asset multiplier surface on Beryl (live -> today). This note covers only the multiplier and 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 or topic0 and -stays dialable at Cobalt. The deprecations below are advisory, not enforced. - -To migrate, 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 hasn't gone live yet. Until it activates, only the Beryl surface exists on-chain. - -## Mapping table - -The selectors and topic0s below are the real values from the frozen ABIs: `abi/v1.rs` for Beryl, -`abi/v2.rs` for Cobalt. Every Beryl symbol keeps its 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) | The canonical path is now the scheduled setter. The instant setter remains 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 risking 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` carry -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 events. 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 or above-ceiling guard. Now also thrown by `updateUIMultiplier`. | -| — | `EffectiveAtInPast(uint256)` `0x14119cf6` | new | Thrown when `effectiveAt <= block.timestamp`. | -| — | `EffectiveAtTooFar(uint256)` `0x1ce214fa` | new | Thrown when `effectiveAt > type(uint64).max`. | -| — | `UIMultiplierUpdateExists(uint256)` `0x4481a68e` | new | Thrown when a live pending update already exists. | -| — | `UIMultiplierUpdateDoesNotExist()` `0xa7d6a5ca` | new | Thrown when you cancel with no live pending update. | - -## New at Cobalt: adopt these - -### Scheduled-update lifecycle - -`updateUIMultiplier(newMultiplier, effectiveAt)` is the canonical path for corporate actions, such -as stock splits and reinvested dividends. Only one pending update can be live at a time. - -1. **Schedule**: call `updateUIMultiplier(newMultiplier, effectiveAt)`. This requires - `OPERATOR_ROLE`, and `effectiveAt` must be strictly in the future. -2. **Read the pending update**: while it's live, `newUIMultiplier()` returns the scheduled target, - `effectiveAt()` returns the flip timestamp, and `uiMultiplier()` / `multiplier()` still return - the current value. -3. **Let it mature**: once `block.timestamp >= effectiveAt`, `uiMultiplier()` / `multiplier()` flip - on read. No event fires at maturation. -4. **Or cancel it**: `cancelUIMultiplierUpdate()` clears a live pending update and emits - `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)`. - -To reorder overlapping actions, cancel and reschedule atomically in one announcement: -`announce([cancelUIMultiplierUpdate(), updateUIMultiplier(...)], ...)`. - -### ERC-8056 view aliases - -- `uiMultiplier()` returns the same value as `multiplier()`. -- `toUIAmount(raw)` returns the same value as `toScaledBalance(raw)`. `fromUIAmount(ui)` returns the - same value as `toRawBalance(ui)`. -- `balanceOfUI(account)` returns the same value as `scaledBalanceOf(account)`. -- `totalSupplyUI()` equals `totalSupply() * uiMultiplier() / WAD_PRECISION`. - -### Bound getter - -`MAX_UI_MULTIPLIER()` returns `type(uint128).max`, the ceiling both setters enforce. This is the -overflow guard that keeps `balance * multiplier` inside `uint256`. - -## `updateMultiplier(uint256)` remains as an instant admin failsafe - -`updateMultiplier(uint256)` sets the multiplier immediately and clears any live pending update. It's -a deprecated admin failsafe, kept for tech debt and emergency overrides, not routine use: use it to -instantly reverse a scheduling mistake, and pair it with pausing in most cases. - -## Guarantees and edge cases - -**Q: A scheduled update can be canceled. How do external consumers detect the cancellation?** -`cancelUIMultiplierUpdate()` emits `UIMultiplierUpdateCancelled(cancelledMultiplier, -cancelledEffectiveAt)` (topic0 `0x8838…1cad`); so does the instant setter, when it supersedes a live -pending update. 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 -update, `UIMultiplierUpdateCancelled` first). Every multiplier change, scheduled or emergency, -appears on the single `UIMultiplierUpdated` stream, so following that one event never misses a -change. The legacy `MultiplierUpdated` topic stays available for indexers that haven't migrated. - -**Q: How do I tell a live pending update apart from one that already matured, or none at all?** -A pending update is live if `effectiveAt() > block.timestamp`. While it's live, `newUIMultiplier()` -returns the scheduled target, which differs from `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 update, or -cancel overwrites it. So a nonzero `effectiveAt()` that's `<= block.timestamp` means "already -applied," not "pending." If no update has ever been scheduled, `effectiveAt() == 0`. - -**Q: What happens if I schedule an update while one is already pending?** -It reverts `UIMultiplierUpdateExists(effectiveAt)`, but only a live pending update blocks the call. -A matured (stale) pending update is silently folded into the current multiplier and overwritten. To -replace a live schedule, call `cancelUIMultiplierUpdate()` then `updateUIMultiplier(...)`, atomically, -via `announce`. - -**Q: What are the bounds on `effectiveAt`?** -It must be strictly in the future: `effectiveAt <= block.timestamp` reverts -`EffectiveAtInPast(effectiveAt)`. It must also fit the on-chain field: -`effectiveAt > type(uint64).max` reverts `EffectiveAtTooFar(effectiveAt)`. - -**Q: What are the bounds on the multiplier?** -`0 < newMultiplier <= MAX_UI_MULTIPLIER()` (`type(uint128).max`). Zero or above reverts -`InvalidMultiplier()`. This applies to both `updateUIMultiplier` and `updateMultiplier`. You can -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 no multiplier change, scheduled or instant, -affects them. Only the `*UI` / scaled reads move. diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md deleted file mode 100644 index 752ae46..0000000 --- a/changelog/02_Cobalt_B20_seize.md +++ /dev/null @@ -1,150 +0,0 @@ -# B20: Beryl to Cobalt, seize surface and `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 or topic0 and stays dialable at Cobalt. In particular, `burnBlocked` is deprecated but -unchanged (same selector, same events, same behavior) and remains callable. - -To migrate, move administrative balance removal from `burnBlocked` to `seizeWithMemo`: seize to a -treasury or self address, then call `burn` if you want the supply destroyed. - -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 hasn't gone live yet. Until it activates, only the Beryl surface exists on-chain, and every -`seize*`/`SEIZE_*` selector below is undialable. - -## Mapping table - -The selectors and topic0s below are the real values from the frozen ABIs: -`crates/common/precompiles/src/common/abi/v1.rs` for Beryl, -`crates/common/precompiles/src/common/abi/v2.rs` for Cobalt. Every Beryl symbol keeps its selector -at Cobalt. Seize lives on the shared `IB20` surface, so it's identical across Asset and Stablecoin. - -### Functions - -| Beryl symbol (selector) | Cobalt (selector) | Status | Why | -| --- | --- | --- | --- | -| `burnBlocked(address,uint256)` `0xec0cf3dc` | `burnBlocked(address,uint256)` `0xec0cf3dc` | deprecated-dialable | Kept unchanged for backward compatibility. Prefer `seizeWithMemo` then `burn`. Destroys supply and 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 checked against `from`. Value `keccak256("SEIZE_HOLDER_POLICY")` = `0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2`. | -| — | `SEIZE_RECEIVER_POLICY()` `0xb31da27f` | new | Policy slot checked 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` (that is, not blocked). | -| — | `AccountNotSeizable(address)` `0x91dbbc8d` | new | Thrown by `seizeWithMemo` when `from` is authorized under `SEIZE_HOLDER_POLICY` (that is, 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)` - -This is the canonical administrative balance-removal path. It's a transfer: the balance moves from -`from` to `to`, and `totalSupply` is unchanged. It runs as an admin operation that skips allowance -and the transfer policies (`TRANSFER_SENDER/RECEIVER/EXECUTOR_POLICY`). It emits, in order: - -1. `Transfer(from, to, amount)` -2. `Memo(caller, memo)` (a memo of `bytes32(0)` is allowed) -3. `Seized(caller, from, to, amount)` - -Requirements and guards: - -- **Role**: the caller must hold `SEIZE_ROLE`, or the call reverts `AccessControlUnauthorizedAccount`. -- **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`. -- **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. -- **Balance**: `from`'s balance must be `>= amount`, or the call reverts `InsufficientBalance`. - -When multiple guards would fail, they take this precedence: holder gate, then destination gate, -then balance. That is, `AccountNotSeizable` fires before `PolicyForbids(SEIZE_RECEIVER_POLICY, -...)`, which fires before `InsufficientBalance`. - -## `burnBlocked` is deprecated, but unchanged and still dialable - -`burnBlocked(from, amount)` keeps working exactly as it does on Beryl: - -- It destroys `amount` from a `from` blocked under `TRANSFER_SENDER_POLICY`, without spending an - allowance. It emits `Transfer(from, address(0), amount)` and `BurnedBlocked(caller, from, amount)` - (no `Memo`). -- It's gated by `BURN_BLOCKED_ROLE` and the `BURN` pause vector. -- It reverts `AccountNotBlocked` when `from` is authorized under `TRANSFER_SENDER_POLICY`. - -To migrate, replace `burnBlocked(from, amount)` with `seizeWithMemo(from, treasury, amount, memo)`, -then call `burn(amount)` from the treasury if you still want the supply destroyed. This crosses two -policy, role, and pause domains (see the edge cases below), so it isn't a drop-in selector swap. - -## Guarantees and 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 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. - -**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 -stop `seizeWithMemo`, and pausing `SEIZE` doesn't stop `burn`, `burnWithMemo`, or `burnBlocked`. - -**Q: Do `SEIZE_ROLE` and `BURN_BLOCKED_ROLE` overlap?** -No. `seizeWithMemo` requires `SEIZE_ROLE`. `burnBlocked` requires `BURN_BLOCKED_ROLE`. Granting one -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.) - -**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, -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's 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. diff --git a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md deleted file mode 100644 index 60366a8..0000000 --- a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md +++ /dev/null @@ -1,143 +0,0 @@ -# PolicyRegistry: composite policies (UNION / INTERSECT) - -> **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. - -## 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. Create one with the new -`createCompositePolicy`, and mutate it in full with the new `updateComposite`. - -Nothing you call today breaks: every Beryl selector, event topic, and error keeps its exact 4-byte -selector or topic0 and stays dialable at Cobalt. The only change to existing behavior is that -`createPolicy` and `createPolicyWithAccounts` gain one new, previously unreachable, revert path, -rejecting a 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 - -The selectors and topic0s below are computed directly from `src/interfaces/IPolicyRegistry.sol` -with `cast sig` and `cast sig-event`. Every Beryl symbol keeps its 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. There's 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` carry over unchanged. - -### Events - -| Beryl event (topic0) | Cobalt (topic0) | Status | Why | -| --- | --- | --- | --- | -| `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` call. Carries the complete post-update child set. | - -`PolicyAdminStaged`, `PolicyAdminUpdated`, `AllowlistUpdated`, and `BlocklistUpdated` carry over -unchanged. They aren't emitted for composites, because 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` (a composite `policyType` passed to a simple constructor), `createCompositePolicy` (`policyType` isn't `UNION`/`INTERSECT`), and `updateComposite` (the 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 | Thrown when the child count is outside `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (`[2, 4]`). | -| — | `InvalidChildPolicy(uint64)` `0x46508ef6` | new | Thrown when 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. Each check fires before -the next, in this order: - -1. `ZeroAddress`: `admin == address(0)`. -2. `IncompatiblePolicyType`: `policyType` isn't `UNION`/`INTERSECT`. -3. `ChildPoliciesOutsideOfRange`: `childPolicyIds.length` is outside `[2, 4]`. -4. `PolicyNotFound`: any child doesn't exist. This is checked as one pass over the whole set, before - the next check. -5. `InvalidChildPolicy`: any child is a composite or a built-in sentinel. This is a second pass. - -On success, it 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's no partial-update or clear-the-list path. Checks run in this order: - -1. `PolicyNotFound`: `policyId` doesn't exist. -2. `IncompatiblePolicyType`: `policyId` is a simple policy, not a composite. -3. `Unauthorized`: the caller isn't the current admin. A renounced composite (admin `address(0)`) - can never be updated. -4. `ChildPoliciesOutsideOfRange`: the new count is outside `[2, 4]`. -5. `PolicyNotFound`: any new child doesn't exist. -6. `InvalidChildPolicy`: any new child is a composite or a built-in sentinel. - -It emits `CompositePolicyUpdated(policyId, updater, childPolicyIds)`. - -### Live, depth-1 evaluation - -`isAuthorized` on a composite calls each child policy's `isAuthorized`; it never uses a snapshot -taken at creation or the last update. `UNION` returns `true` on the first authorizing child (it -short-circuits). `INTERSECT` returns `false` on the first non-authorizing child. Recursion never -exceeds depth 1, because 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 and 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, for the same `InvalidChildPolicy` revert. To mix always-allow or always-block behavior into a -composite gate, use a real `ALLOWLIST`/`BLOCKLIST` policy that reproduces the effect you want -instead. - -**Q: Can I pass the same child ID twice, or shrink a composite below 2 children?** -Duplicates are allowed. The registry neither sorts nor deduplicates the stored child list; the only -cost is extra evaluation, since `UNION`/`INTERSECT` are idempotent under duplicates. Shrinking below -2 isn't possible: every `updateComposite` call enforces the same `[2, 4]` range as creation, so -there's no path to an empty or undersized 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`, and others. There's 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 (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. diff --git a/changelog/COMPOSITE_POLICY.md b/changelog/COMPOSITE_POLICY.md new file mode 100644 index 0000000..a668203 --- /dev/null +++ b/changelog/COMPOSITE_POLICY.md @@ -0,0 +1,226 @@ +# ADR Template (Point Form) + +- **Feature Name**: composite_policy +- **Start Date**: 2026-08-17 +- **Authors**: Rayyan Alam +- **Title**: Composite Policies (UNION/INTERSECT) + +--- + +## Summary + +- Introduce two new `PolicyRegistry` policy types, `UNION` (OR) and `INTERSECT` (AND) — collectively + "composite policies" — that authorize by combining 2 to 4 existing simple policies +- For users utilizing the policy registry who may want to combine many policies together, without + flattening into one big list +- Composite policies are built up of only existing simple policies (`ALLOWLIST`/`BLOCKLIST`) — never + another composite, and never a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`) +- Ships at the **Cobalt** hardfork. Every mutating call (`createCompositePolicy`, `updateComposite`) + is gated by `ActivationRegistry`, same as every other mutating `PolicyRegistry` function — not + automatically enabled. Read-only calls (`isAuthorized`, `compositePolicyChildIds`, + `MIN_COMPOSITE_CHILD_POLICIES`, `MAX_COMPOSITE_CHILD_POLICIES`) are always callable regardless of + activation + +--- + +## Motivation + +> State the problem this change solves, why the current state is insufficient, and brief context +> on prerequisites if needed (full detail lives in Background). +- The policy registry currently supports simple boolean policies via `isAuthorized`, where each + policy independently returns true or false +- In practice, access control often requires combining multiple policies (e.g., KYC + ProUser, or + ProUser OR LifetimeUser) +- The current architecture requires a user to listen to changes on a different allowlist and + flatten into one, duplicating lists and requiring infra to keep them up to date +- We want to allow for policy reuse by creating composite policies which can reference other simple + policies + - should return "is authorized" by combining the results of other policies + - simplifies maintenance — updating one child policy updates every composite that references it + +--- + +## Background + +> Link to or summarize concepts the reader needs before understanding the Specs: prior art, +> relevant EIPs/ERCs, existing patterns, and any domain-specific terms used in this document. +- B20 Token + - B20 is a token precompile that uses policies to restrict operations such as + transfers, minting, and seizing. + - For each restricted operation, B20 stores a Policy Registry policy ID in a + dedicated policy scope. + - When an operation is attempted, B20 passes the relevant policy ID and account + address to the Policy Registry. If the account is not authorized, B20 + rejects the operation. + +- Policy Registry: + - Is a singleton precompile contract used by B20 tokens. + - It manages a list of policies; B20 tokens call `isAuthorized(policyId, account)` against a + policy ID stored on the relevant policy scope + - Currently used by B20 tokens for `TRANSFER_FROM`, `TRANSFER_TO`, `SEIZE_HOLDER` +- Simple policies: + - These are the non-composite policy types: `ALLOWLIST` / `BLOCKLIST` + - `ALLOWLIST` has a list of addresses; returns authorized `true` if the address is in the + list, `false` otherwise + - `BLOCKLIST` has a list of addresses; returns authorized `false` if the address is in the + list, `true` for all other addresses + +--- + +## Specs + +> Implementation details live here. Add more headings as needed (e.g., Storage Layout Changes, +> Deprecated Assets, Access Control, etc.). + + +### Interface Changes + +> New functions, events, errors (include signatures). Renamed or deprecated symbols (old → new). +> Selector / topic0 values (verified via `cast sig` or `cast keccak`). + +- Introduce 2 new values in the `PolicyType` enum + - `UNION = 2` — authorized if *any* child policy authorizes the account (OR) + - `INTERSECT = 3` — authorized only if *every* child policy authorizes the account (AND) +- New function `createCompositePolicy(admin, policyType, childPolicyIds)` + - `childPolicyIds` must be between 2 and 4 entries (`MIN_COMPOSITE_CHILD_POLICIES` / + `MAX_COMPOSITE_CHILD_POLICIES`) + - Every child must be an *existing simple* policy (`ALLOWLIST`/`BLOCKLIST`) — never another + composite, never a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`) + - Canonical revert order: `ZeroAddress` (admin) → `IncompatiblePolicyType` (policyType not + UNION/INTERSECT) → `ChildPoliciesOutsideOfRange` (count not in `[2,4]`) → `PolicyNotFound` + (a child doesn't exist, checked as one pass over the whole set) → `InvalidChildPolicy` (a + child is itself composite/sentinel, checked as a second pass) + - Emits, in order: `PolicyCreated(policyId, creator, policyType)`, + `PolicyAdminUpdated(policyId, address(0), admin)`, `CompositePolicyUpdated(policyId, creator, + childPolicyIds)` +- New function `updateComposite(policyId, childPolicyIds)` + - Full replacement of the child set — there's no partial-update or clear-the-list operation + - Same child-validity rules as `createCompositePolicy` (existing simple policies only, 2-4 of + them) + - Canonical revert order: `PolicyNotFound` (composite itself doesn't exist) → + `IncompatiblePolicyType` (`policyId` is a simple policy) → `Unauthorized` (caller isn't the + current admin — fires before the count check) → `ChildPoliciesOutsideOfRange` → + `PolicyNotFound` (a new child doesn't exist) → `InvalidChildPolicy` + - Emits only `CompositePolicyUpdated(policyId, updater, childPolicyIds)` — no + `PolicyAdminUpdated`, since admin doesn't change +- Existing function, new revert path: `createPolicy` and `createPolicyWithAccounts` (both already + live on Beryl) now also revert `IncompatiblePolicyType` when `policyType` is `UNION`/`INTERSECT` — + a previously-unreachable path, since those enum values didn't exist before Cobalt + + +### Behavioural Changes + +> How execution flow differs from the previous version. Storage layout changes (new slots, moved +> fields, packing changes). Gas cost implications if meaningful. +- A composite policy ID is passed to a B20 policy slot exactly like a simple policy ID — B20 needs + **zero code changes**, since it stores policy slots as an opaque `uint64` and calls + `isAuthorized` generically +- `isAuthorized` on a composite is live and short-circuiting, not a snapshot: + - Reads each child's *current* membership on every call — no snapshot from creation or the + last `updateComposite` + - `UNION` short-circuits `true` on the first authorizing child + - `INTERSECT` short-circuits `false` on the first non-authorizing child + - Recursion never exceeds depth 1, because every child is validated to be a simple policy at + write time — a composite's children can never themselves be composites +- Child order affects gas, never the outcome: + - `UNION`/`INTERSECT` are commutative, so reordering `childPolicyIds` never changes whether an + account is authorized + - It only shifts where the short-circuit lands — put the child most likely to short-circuit + first (broadest ALLOWLIST for `UNION`, tightest BLOCKLIST for `INTERSECT`) to save gas +- Duplicate child IDs are allowed. The registry neither sorts nor deduplicates the stored child + list — deduplicating would cost extra gas on every write for a set already capped at 4 entries, + for little value; `UNION`/`INTERSECT` are idempotent under duplicates anyway +- A composite can never shrink below 2 children via `updateComposite` — it enforces the same + `[2,4]` range as creation, so there's no path to an empty or undersized composite +- If a child policy's admin renounces, the parent composite keeps working: `renounceAdmin` only + clears the child's 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 +- State changes + - New state: `mapping(uint64 policyId => uint64[] childPolicyIds) children`, at **offset 4** + within the `base.policy_registry` ERC-7201 namespace (not a literal EVM slot 4) + - Reused state: one shared global counter (`nextCounter`) across simple and composite + policies, starting at 2 (`0` and `1` are reserved for `ALWAYS_ALLOW`/`ALWAYS_BLOCK`). A + composite policy ID encodes `PolicyType` in the top byte and the next available counter + value in the low 56 bits — the same encoding scheme as simple policies, not a separate + counter + + +### Examples + +- **Before (simple policy)**: + - Assign one existing policy directly to a B20 policy scope + - `b20.updatePolicy(TRANSFER_SENDER_POLICY, allowlistPolicyId)` + - Only accounts in `allowlistPolicyId` can transfer + +- **After (composite policy)**: + - Assume existing simple policies: `employeesPolicyId` (ALLOWLIST), `approvedRegionPolicyId` (ALLOWLIST) + - Create a UNION composite: + - `policyRegistry.createCompositePolicy(admin, UNION, [employeesPolicyId, approvedRegionPolicyId])` + - Emits: `PolicyCreated(policyId, admin, UNION)` + `PolicyAdminUpdated(policyId, 0, admin)` + + `CompositePolicyUpdated(policyId, admin, [children])` + - Assign to B20: `b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId)` + - B20 has no composite-specific logic — it passes the policy ID to the registry as usual + +- **Updating a composite**: + - `policyRegistry.updateComposite(compositePolicyId, [employeesPolicyId, trustedPartnersPolicyId])` + - Emits: `CompositePolicyUpdated(policyId, admin, [newChildren])` + - B20 continues using the same policy ID — no token-side update required + - Future authorization checks use the new child set immediately (live evaluation, no snapshot) + +--- + +## Design Decisions & Alternatives Considered + +- **Decision**: Two explicit policy types (`UNION`, `INTERSECT`) with a single `createCompositePolicy` function and full-replacement `updateComposite` + +- **Alternative 1: One generic COMPOSITE type** + - Store a separate operator (AND, OR, NOT, XOR) in composite storage + - Rejected: + - Requires storing both "composite" flag and the operator + - Adds storage reads or more complicated ID encoding + - Unnecessary complexity before there's a requirement for NOT, XOR, or nested expressions + - Generic boolean expressions create a larger gas and audit surface + +- **Alternative 2: Token-level policy groups** + - Keep Policy Registry unchanged; have each B20 token store multiple policy IDs + an operator + - Rejected: + - Composite policies would not be reusable entities + - Requires changes across B20, token variants, factories, and token hot paths + - Does not support sharing one composite policy across multiple tokens + - Spreads complexity across more contracts + +- **Alternative 3: Incremental child updates** + - Provide `addCompositeOperand` / `removeCompositeOperand` functions + - Rejected: + - Child list is capped at 4 entries + - Dynamic-array mutation requires swap/remove, length, and deduplication logic + - Full replacement is simpler and atomic + - Caller can resend the complete list at low cost + +- **Alternative 4: Separate creator functions** + - Use `createUnionPolicy` and `createIntersectPolicy` + - Rejected: + - Doubles the creation API surface + - A single `createCompositePolicy` keeps policy creation consistent + - Future operators would require additional functions + +--- + +## Migration Steps + +- **Backwards-compatible**: Existing simple policies (ALLOWLIST/BLOCKLIST) continue to work unchanged. No action required if you don't need composite behavior. + +- **For users currently flattening multiple lists into one policy**: + 1. Identify the simple policies you want to combine + 2. Call `policyRegistry.createCompositePolicy(admin, UNION or INTERSECT, [childPolicyIds])` + 3. Update the B20 token's policy scope to point to the new composite policy ID: + - `b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId)` + - No B20 contract change is required — B20 treats the composite ID as an opaque `uint64` + exactly like a simple policy ID + 4. Remove the old flattened policy if no longer needed + +- **No breaking changes**: All existing selectors, events, and errors remain dialable at Cobalt + +- **No storage migration**: `children` is a new, empty mapping at ERC-7201 offset 4. Existing + `PolicyRegistry` state at offsets 0–3 is unmodified by Cobalt activation diff --git a/changelog/TEMPLATE_POINT_FORM.md b/changelog/TEMPLATE_POINT_FORM.md new file mode 100644 index 0000000..812b126 --- /dev/null +++ b/changelog/TEMPLATE_POINT_FORM.md @@ -0,0 +1,64 @@ +# ADR Template (Point Form) + +- **Feature Name**: my_awesome_feature +- **Start Date**: 2026-08-17 +- **Authors**: +- **Title**: + +> Fill in each section below when documenting a change. Pointform here can be sufficient + +--- + +## Summary + +> Give a summary of the change: who it's for, what it does. + +--- + +## Motivation + +> State the problem this change solves, why the current state is insufficient, and brief context +> on prerequisites if needed (full detail lives in Background). + +--- + +## Background + +> Link to or summarize concepts the reader needs before understanding the Specs: prior art, +> relevant EIPs/ERCs, existing patterns, and any domain-specific terms used in this document. + +--- + +## Specs + +> Implementation details live here. Add more headings as needed (e.g., Storage Layout Changes, +> Deprecated Assets, Access Control, etc.). + +### Interface Changes + +> New functions, events, errors (include signatures). Renamed or deprecated symbols (old → new). +> Selector / topic0 values. + +### Behavioural Changes + +> How execution flow differs from the previous version. Storage layout changes (new slots, moved +> fields, packing changes). Gas cost implications if meaningful. + +### Examples + +> Before/after code snippets or call sequences. Expected return values or emitted events. + +--- + +## Design Decisions & Alternatives Considered + +> Describe the approach taken and why. Document alternatives considered and why they were rejected. +> Note any opinionated choices and their rationale. + +--- + +## Migration Steps + +> Steps for integrators to adopt the new interface. Call out what is backwards-compatible, any +> deprecation timeline, and breaking changes that require action before activation. + From 8f580015a40f1b001c177127cc780d436c7640b9 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 18 Aug 2026 11:21:27 -0400 Subject: [PATCH 03/21] chore: techinal document --- .claude/skills/technical-document/SKILL.md | 216 ++++++++++++++++++ .../examples/generated-spec.md | 57 +++++ .../examples/point-form-input.md | 49 ++++ .../references/google-technical-writing.md | 49 ++++ .../simplified-technical-english.md | 29 +++ .../references/template-point-form.md | 40 ++++ 6 files changed, 440 insertions(+) create mode 100644 .claude/skills/technical-document/SKILL.md create mode 100644 .claude/skills/technical-document/examples/generated-spec.md create mode 100644 .claude/skills/technical-document/examples/point-form-input.md create mode 100644 .claude/skills/technical-document/references/google-technical-writing.md create mode 100644 .claude/skills/technical-document/references/simplified-technical-english.md create mode 100644 .claude/skills/technical-document/references/template-point-form.md diff --git a/.claude/skills/technical-document/SKILL.md b/.claude/skills/technical-document/SKILL.md new file mode 100644 index 0000000..7e206f1 --- /dev/null +++ b/.claude/skills/technical-document/SKILL.md @@ -0,0 +1,216 @@ +--- +name: technical-document +description: "Convert changelog/TEMPLATE_POINT_FORM.md from point form into a written technical specification. Use the exact template structure. Apply ASD-STE100-inspired Simplified Technical English and Google technical-writing guidance while preserving technical facts, uncertainty, and scope." +version: 2.0.0 +--- + +# Convert the point-form template into a written specification + +Use this skill for one task only: convert the repository template +`changelog/TEMPLATE_POINT_FORM.md` into a complete written specification. + +Do not use this skill to create a generic RFC, design document, technical overview, +or implementation plan. The template is the source of truth for the output structure. + +## Source template + +Read and follow: + +```text +changelog/TEMPLATE_POINT_FORM.md +``` + +Also follow the repository guidance in: + +```text +changelog/AGENTS.md +changelog/README.md +``` + +## Exact output structure + +The output must use this structure and heading text in this order: + +```markdown +# [Title] + +- **Feature Name**: [value] +- **Start Date**: [value] +- **Authors**: [value] +- **Title**: [value] + +## Summary + +## Motivation + +## Background + +## Specs + +### Interface Changes + +### Behavioural Changes + +### Examples + +## Design Decisions & Alternatives Considered + +## Migration Steps +``` + +Template conformance is mandatory: + +- Use the exact heading text and heading order. +- Keep all required headings, even when a section contains a TODO. +- Do not add headings such as `Goals`, `Risks`, `Open Questions`, `Decision`, + `Rollout`, or `Appendix`. +- Put content that would normally use those headings into the closest defined section. +- Add headings under `## Specs` only when the source template or user explicitly requires them. +- Do not add generic preambles, summaries, transformation notes, or closing reports. +- Return only the completed specification unless the user explicitly asks for an audit or explanation. + +## Conversion workflow + +### 1. Read the complete input + +Read the entire point-form document before drafting. Extract: + +- Feature name, start date, authors, and title. +- Summary and audience. +- Problem, motivation, and current limitations. +- Background concepts, prior art, standards, and existing patterns. +- Interface changes, including functions, events, errors, signatures, and selectors. +- Behavioral changes, including execution flow, storage, compatibility, and gas effects. +- Examples, expected values, emitted events, and call sequences. +- Design decisions and rejected alternatives. +- Migration steps, deprecations, compatibility, and breaking changes. + +Treat comments in the template as instructions. Do not copy those comments into the output. + +### 2. Map content to the fixed sections + +Use this mapping: + +- Metadata → document metadata. +- Summary bullets → `## Summary`. +- Problem and rationale → `## Motivation`. +- Prerequisites, prior art, standards, and terminology → `## Background`. +- Interface facts → `### Interface Changes`. +- Runtime, storage, gas, and compatibility facts → `### Behavioural Changes`. +- Before/after snippets and call sequences → `### Examples`. +- Chosen approach and rejected options → `## Design Decisions & Alternatives Considered`. +- Integrator adoption and rollout actions → `## Migration Steps`. + +Do not move content to a new heading to make the document easier to organize. + +### 3. Preserve technical fidelity + +- Preserve every fact, number, condition, qualifier, and scope boundary. +- Preserve uncertainty. Keep `may`, `could`, `estimated`, and `unknown` unchanged in strength. +- Do not invent selectors, topic values, signatures, storage slots, gas costs, owners, dates, or compatibility claims. +- Mark missing information with `[TODO: ...]` in the relevant section. +- Mark an inference with `[Inferred — verify: ...]`. +- If a technical value is not verified, write `[TODO: verify against source]`. +- Keep code and signatures exactly as provided unless the user asks for a correction. + +Before citing a function signature, event signature, error selector, topic0, function +selector, or ERC-165 interface ID, verify it against the source or mark it for verification. + +### 4. Write the specification + +Convert point-form fragments into complete technical prose. Keep the meaning and +technical detail, but improve structure, readability, and consistency. + +Use tables for mappings when the input contains multiple comparable entries. Use +numbered lists for migration steps. Use code blocks for verified code or call sequences. + +### 5. Apply Simplified Technical English principles + +Use the public `asd-ste100-skill` as a source of writing principles: + +```text +https://github.com/danyuchn/asd-ste100-skill +``` + +Apply these rules: + +- Prefer active voice and name the actor when the actor is known. +- Use one clear action per sentence. +- Keep procedures near 20 words per sentence and descriptions near 25 words when possible. +- Split long or compound sentences when splitting preserves precision. +- Do not use semicolons in procedural text. +- Keep subjects, verbs, articles, and conditions explicit. +- Put conditions before instructions. +- Replace ambiguous phrasal verbs such as `spin up` and `kick off` with plain verbs. +- Use one term for one technical concept. Do not rotate synonyms. +- Prefer direct, common words over formal or vague alternatives. +- Preserve technical terms when simplifying them would reduce accuracy. + +Apply structural rules confidently. Treat dictionary-specific lexical rules as guidance +only. Never claim that the output is officially ASD-STE100 compliant, and never reproduce +the official ASD-STE100 dictionary. + +### 6. Apply Google technical-writing guidance + +Use Google’s technical-writing guidance as the style reference: + +```text +https://developers.google.com/style +https://developers.google.com/tech-writing +``` + +Apply these rules: + +- Write for the technical reader’s role and existing knowledge. +- State the purpose and scope in `Summary` and `Motivation`. +- Use clear, descriptive sentence-case headings from the template. +- Put important information before supporting detail. +- Use plain, globally understandable English. +- Define acronyms and unfamiliar domain terms at first use. +- Use numbered lists for ordered steps and bullets for unordered information. +- Avoid idioms, slang, cultural references, buzzwords, and marketing language. +- Replace vague claims with measurements when the input provides measurements. +- Use descriptive link text and unambiguous dates. + +### 7. Review before returning + +Check all of the following: + +- The output matches the exact template heading structure. +- No template heading is missing, renamed, duplicated, or reordered. +- No unsupported technical fact was added. +- No source fact, qualifier, or condition was removed. +- The output contains no invented selector, signature, storage detail, or compatibility claim. +- Interface changes are separated from behavioral changes. +- Examples contain only supplied or verified values. +- Migration steps identify compatibility and breaking changes when the input provides them. +- Missing information uses TODO markers. +- Active voice, terminology consistency, sentence clarity, and plain language were applied. + +## Output rules + +Return only the written specification by default. Do not explain the conversion and do +not add headings outside the template. + +If the user asks for an audit, return the conforming specification first. Add the audit +after a plain-text separator such as `--- Review ---`, and do not treat the audit as part +of the specification. + +## Boundaries + +This skill will not: + +- Generate other document types. +- Invent technical facts or evidence. +- Claim official ASD-STE100 compliance. +- Reproduce the ASD-STE100 approved-word dictionary. +- Verify repository behavior without reading the relevant source. +- Manufacture selectors, signatures, storage layouts, or migration guarantees. + +## References + +- `references/google-technical-writing.md` +- `references/template-point-form.md` +- `references/simplified-technical-english.md` +- `examples/point-form-input.md` +- `examples/generated-spec.md` diff --git a/.claude/skills/technical-document/examples/generated-spec.md b/.claude/skills/technical-document/examples/generated-spec.md new file mode 100644 index 0000000..1cdb6c9 --- /dev/null +++ b/.claude/skills/technical-document/examples/generated-spec.md @@ -0,0 +1,57 @@ +# Add a temporary worker pool for release builds + +- **Feature Name**: temporary_release_worker_pool +- **Start Date**: 2026-08-18 +- **Authors**: [TODO: provide author] +- **Title**: Add a temporary worker pool for release builds + +## Summary + +Release periods increase build wait time because normal builds and release builds +share one worker pool. This specification defines a temporary worker pool for +release builds. + +## Motivation + +The change reduces release-build wait time without adding permanent workers. The +current wait-time baseline is `[TODO: provide the baseline]`. + +## Background + +One shared worker pool handles normal builds and release builds. The release-period +threshold and available worker capacity are `[TODO: provide the missing values]`. + +## Specs + +### Interface Changes + +No interface changes are currently identified. + +### Behavioural Changes + +The system adds a temporary worker pool for release builds. The system limits the +pool size so release builds cannot use all available capacity. The temporary pool +could starve normal builds. + +### Examples + +Test the pool with one repository before expanding it. The expected wait-time target +is `[TODO: provide the target]`. + +## Design Decisions & Alternatives Considered + +The preferred approach adds a temporary release worker pool. This approach avoids +permanent worker capacity but adds queue configuration and a starvation risk. + +The alternative keeps one pool and increases its size. This approach preserves the +current queue model but adds permanent worker capacity. + +## Migration Steps + +1. Measure the current release-build wait time. +2. Configure the temporary pool for one repository. +3. Monitor normal-build wait time during the pilot. +4. Expand the pool after validation. + +Disable the temporary pool to roll back the change. The rollback owner and trigger +are `[TODO: provide the missing values]`. diff --git a/.claude/skills/technical-document/examples/point-form-input.md b/.claude/skills/technical-document/examples/point-form-input.md new file mode 100644 index 0000000..22d7941 --- /dev/null +++ b/.claude/skills/technical-document/examples/point-form-input.md @@ -0,0 +1,49 @@ +# Point-form input for TEMPLATE_POINT_FORM + +- **Feature Name**: temporary_release_worker_pool +- **Start Date**: 2026-08-18 +- **Authors**: [TODO: provide author] +- **Title**: Add a temporary worker pool for release builds + +## Summary + +- Release periods increase build wait time. +- Normal builds and release builds share one worker pool. + +## Motivation + +- Reduce release-build wait time. +- Avoid adding permanent workers. + +## Background + +- One shared worker pool handles normal builds and release builds. +- The current wait-time baseline is not available. + +## Specs + +### Interface Changes + +- No interface changes are currently identified. + +### Behavioural Changes + +- Add a temporary worker pool for release builds. +- Limit the release-pool size. +- The temporary pool could starve normal builds. + +### Examples + +- Test the pool with one repository before expanding it. + +## Design Decisions & Alternatives Considered + +- Preferred approach: add a temporary release worker pool. +- Alternative: keep one pool and increase its size. +- Mitigation: set a maximum release-pool size. + +## Migration Steps + +- Test with one repository. +- Expand after validation. +- The target wait time is not defined. diff --git a/.claude/skills/technical-document/references/google-technical-writing.md b/.claude/skills/technical-document/references/google-technical-writing.md new file mode 100644 index 0000000..4b847cd --- /dev/null +++ b/.claude/skills/technical-document/references/google-technical-writing.md @@ -0,0 +1,49 @@ +# Google technical-writing checklist + +Use this checklist as a practical guide. It summarizes public Google technical-writing +and developer documentation guidance. It is not a replacement for project-specific style rules. + +## Audience and scope + +- Identify the reader's role and existing knowledge. +- State what the document covers. +- State what the document does not cover when readers could reasonably expect it. +- Explain what the reader should know or do after reading the document. + +## Organization + +- Put the key information in the opening summary. +- Use a logical order based on reader needs. +- Use descriptive, hierarchical headings. +- Use sentence case for titles and headings. +- Do not skip heading levels. +- Introduce a concept before asking the reader to use it. +- Use progressive disclosure for long documents. + +## Voice and language + +- Prefer active voice. +- Address the reader as `you` when giving instructions. +- Use the imperative for procedures. +- Use plain, globally understandable language. +- Avoid idioms, slang, and culturally specific references. +- Define unfamiliar terms and acronyms at first use. +- Prefer specific verbs over vague verbs such as `do`, `occur`, or `happen`. +- Replace subjective claims with data. + +## Formatting + +- Use numbered lists for ordered steps. +- Use bullets for unordered items. +- Use code formatting for commands, identifiers, and code elements. +- Use descriptive link text. +- Add alt text when including images or diagrams. +- Use unambiguous dates such as `2026-08-18`. + +Sources: + +- https://developers.google.com/style +- https://developers.google.com/style/highlights +- https://developers.google.com/style/headings +- https://developers.google.com/tech-writing/one/audience +- https://developers.google.com/tech-writing/one/documents diff --git a/.claude/skills/technical-document/references/simplified-technical-english.md b/.claude/skills/technical-document/references/simplified-technical-english.md new file mode 100644 index 0000000..d6b95a8 --- /dev/null +++ b/.claude/skills/technical-document/references/simplified-technical-english.md @@ -0,0 +1,29 @@ +# Simplified Technical English principles + +This reference applies principles from the public skill: + +https://github.com/danyuchn/asd-ste100-skill + +It does not reproduce the official ASD-STE100 dictionary and does not claim official +ASD-STE100 compliance. + +## Apply these structural rules + +- Prefer active voice when the actor is known. +- Use one clear action per sentence. +- Keep procedures near 20 words per sentence and descriptions near 25 words when possible. +- Split long or compound sentences when precision is preserved. +- Do not use semicolons in procedural text. +- Keep subjects, verbs, articles, and conditions explicit. +- Put conditions before instructions. +- Replace ambiguous phrasal verbs with plain verbs. +- Use one term for one technical concept. +- Preserve every number, condition, qualifier, and scope boundary. + +## Apply lexical rules carefully + +Use plain, common words and consistent terminology. Treat dictionary-specific rules as +guidance only because the official approved-word dictionary is not part of this repository. + +Never convert `may` to a fact, remove a safety condition, or shorten text when the change +would lose technical precision. diff --git a/.claude/skills/technical-document/references/template-point-form.md b/.claude/skills/technical-document/references/template-point-form.md new file mode 100644 index 0000000..ac6dcb2 --- /dev/null +++ b/.claude/skills/technical-document/references/template-point-form.md @@ -0,0 +1,40 @@ +# Point-form template mapping + +The canonical source template is: + +```text +changelog/TEMPLATE_POINT_FORM.md +``` + +The skill converts the template into prose without changing its structure. + +## Section mapping + +| Source content | Written specification section | +| --- | --- | +| Feature name, start date, authors, title | Document metadata | +| Summary bullets | `## Summary` | +| Problem and rationale | `## Motivation` | +| Prior art, standards, prerequisites, terminology | `## Background` | +| Functions, events, errors, signatures, selectors | `### Interface Changes` | +| Execution, storage, gas, compatibility | `### Behavioural Changes` | +| Before/after snippets and call sequences | `### Examples` | +| Chosen approach and rejected options | `## Design Decisions & Alternatives Considered` | +| Integrator adoption and compatibility actions | `## Migration Steps` | + +## Required headings + +```text +## Summary +## Motivation +## Background +## Specs +### Interface Changes +### Behavioural Changes +### Examples +## Design Decisions & Alternatives Considered +## Migration Steps +``` + +Do not add headings such as `Goals`, `Risks`, `Open Questions`, `Decision`, or +`Rollout`. Place that content in the closest required section. From d661d6e862477fa199f536fa2b2b66c26ade25b8 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 18 Aug 2026 14:34:42 -0400 Subject: [PATCH 04/21] feat: create agents.md --- {.claude => .agents}/skills/technical-document/SKILL.md | 0 .../skills/technical-document/examples/generated-spec.md | 0 .../skills/technical-document/examples/point-form-input.md | 0 .../technical-document/references/google-technical-writing.md | 0 .../technical-document/references/simplified-technical-english.md | 0 .../skills/technical-document/references/template-point-form.md | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename {.claude => .agents}/skills/technical-document/SKILL.md (100%) rename {.claude => .agents}/skills/technical-document/examples/generated-spec.md (100%) rename {.claude => .agents}/skills/technical-document/examples/point-form-input.md (100%) rename {.claude => .agents}/skills/technical-document/references/google-technical-writing.md (100%) rename {.claude => .agents}/skills/technical-document/references/simplified-technical-english.md (100%) rename {.claude => .agents}/skills/technical-document/references/template-point-form.md (100%) diff --git a/.claude/skills/technical-document/SKILL.md b/.agents/skills/technical-document/SKILL.md similarity index 100% rename from .claude/skills/technical-document/SKILL.md rename to .agents/skills/technical-document/SKILL.md diff --git a/.claude/skills/technical-document/examples/generated-spec.md b/.agents/skills/technical-document/examples/generated-spec.md similarity index 100% rename from .claude/skills/technical-document/examples/generated-spec.md rename to .agents/skills/technical-document/examples/generated-spec.md diff --git a/.claude/skills/technical-document/examples/point-form-input.md b/.agents/skills/technical-document/examples/point-form-input.md similarity index 100% rename from .claude/skills/technical-document/examples/point-form-input.md rename to .agents/skills/technical-document/examples/point-form-input.md diff --git a/.claude/skills/technical-document/references/google-technical-writing.md b/.agents/skills/technical-document/references/google-technical-writing.md similarity index 100% rename from .claude/skills/technical-document/references/google-technical-writing.md rename to .agents/skills/technical-document/references/google-technical-writing.md diff --git a/.claude/skills/technical-document/references/simplified-technical-english.md b/.agents/skills/technical-document/references/simplified-technical-english.md similarity index 100% rename from .claude/skills/technical-document/references/simplified-technical-english.md rename to .agents/skills/technical-document/references/simplified-technical-english.md diff --git a/.claude/skills/technical-document/references/template-point-form.md b/.agents/skills/technical-document/references/template-point-form.md similarity index 100% rename from .claude/skills/technical-document/references/template-point-form.md rename to .agents/skills/technical-document/references/template-point-form.md From 5ac3454d7d3049ac0e32363bb96e16084b20201a Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 18 Aug 2026 14:52:02 -0400 Subject: [PATCH 05/21] fix: move technical-document skill into .claude/skills Claude Code only discovers skills under .claude/skills/, so the skill was invisible in the skills listing while it lived under .agents/skills/. Co-Authored-By: Claude --- {.agents => .claude}/skills/technical-document/SKILL.md | 0 .../skills/technical-document/examples/generated-spec.md | 0 .../skills/technical-document/examples/point-form-input.md | 0 .../technical-document/references/google-technical-writing.md | 0 .../technical-document/references/simplified-technical-english.md | 0 .../skills/technical-document/references/template-point-form.md | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename {.agents => .claude}/skills/technical-document/SKILL.md (100%) rename {.agents => .claude}/skills/technical-document/examples/generated-spec.md (100%) rename {.agents => .claude}/skills/technical-document/examples/point-form-input.md (100%) rename {.agents => .claude}/skills/technical-document/references/google-technical-writing.md (100%) rename {.agents => .claude}/skills/technical-document/references/simplified-technical-english.md (100%) rename {.agents => .claude}/skills/technical-document/references/template-point-form.md (100%) diff --git a/.agents/skills/technical-document/SKILL.md b/.claude/skills/technical-document/SKILL.md similarity index 100% rename from .agents/skills/technical-document/SKILL.md rename to .claude/skills/technical-document/SKILL.md diff --git a/.agents/skills/technical-document/examples/generated-spec.md b/.claude/skills/technical-document/examples/generated-spec.md similarity index 100% rename from .agents/skills/technical-document/examples/generated-spec.md rename to .claude/skills/technical-document/examples/generated-spec.md diff --git a/.agents/skills/technical-document/examples/point-form-input.md b/.claude/skills/technical-document/examples/point-form-input.md similarity index 100% rename from .agents/skills/technical-document/examples/point-form-input.md rename to .claude/skills/technical-document/examples/point-form-input.md diff --git a/.agents/skills/technical-document/references/google-technical-writing.md b/.claude/skills/technical-document/references/google-technical-writing.md similarity index 100% rename from .agents/skills/technical-document/references/google-technical-writing.md rename to .claude/skills/technical-document/references/google-technical-writing.md diff --git a/.agents/skills/technical-document/references/simplified-technical-english.md b/.claude/skills/technical-document/references/simplified-technical-english.md similarity index 100% rename from .agents/skills/technical-document/references/simplified-technical-english.md rename to .claude/skills/technical-document/references/simplified-technical-english.md diff --git a/.agents/skills/technical-document/references/template-point-form.md b/.claude/skills/technical-document/references/template-point-form.md similarity index 100% rename from .agents/skills/technical-document/references/template-point-form.md rename to .claude/skills/technical-document/references/template-point-form.md From 450a25de5c942cec21b3323137f7617f7117a3d3 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 18 Aug 2026 14:57:10 -0400 Subject: [PATCH 06/21] docs(changelog): composite-policy written spec + template example rename (#204) * docs(changelog): expand composite-policy point-form into written spec Converts the COMPOSITE_POLICY.md point-form draft into a full written changelog entry at changelog/02_Cobalt_PolicyRegistry_composite_policy.md, verified against src/interfaces/IPolicyRegistry.sol. Co-Authored-By: Claude * docs(changelog): add TEMPLATE_COMPOSITE_POLICY.md as a worked example Keeps changelog/COMPOSITE_POLICY.md in place and adds a copy at changelog/TEMPLATE_COMPOSITE_POLICY.md, alongside the generic changelog/TEMPLATE_POINT_FORM.md, so both remain visible for comparison. Co-Authored-By: Claude * Update changelog/02_Cobalt_PolicyRegistry_composite_policy.md Co-authored-by: Stephan Cilliers --------- Co-authored-by: Claude Co-authored-by: Stephan Cilliers --- ..._Cobalt_PolicyRegistry_composite_policy.md | 232 ++++++++++++++++++ changelog/TEMPLATE_COMPOSITE_POLICY.md | 226 +++++++++++++++++ 2 files changed, 458 insertions(+) create mode 100644 changelog/02_Cobalt_PolicyRegistry_composite_policy.md create mode 100644 changelog/TEMPLATE_COMPOSITE_POLICY.md diff --git a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md new file mode 100644 index 0000000..cbc06f3 --- /dev/null +++ b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md @@ -0,0 +1,232 @@ +# Composite Policies (UNION/INTERSECT) + +- **Feature Name**: composite_policy +- **Start Date**: 2026-08-17 +- **Authors**: Rayyan Alam +- **Title**: Composite Policies (UNION/INTERSECT) + +## Summary + +This change adds two new `PolicyRegistry` policy types: `UNION` (OR) and `INTERSECT` (AND). These are called "composite policies." A composite policy authorizes an account by combining the results of 2 to 4 existing simple policies. This feature is for users of the policy registry who want to combine several policies without flattening them into one large list. A composite policy can reference only existing simple policies (`ALLOWLIST` or `BLOCKLIST`). A composite policy can never reference another composite policy, and can never reference a built-in sentinel (`ALWAYS_ALLOW` or `ALWAYS_BLOCK`). + +This feature ships at the **Cobalt** hardfork. Every mutating call — `createCompositePolicy` and `updateComposite` — is gated by `ActivationRegistry`, the same as every other mutating `PolicyRegistry` function. The feature does not activate automatically. Read-only calls — `isAuthorized`, `compositePolicyChildIds`, `MIN_COMPOSITE_CHILD_POLICIES`, and `MAX_COMPOSITE_CHILD_POLICIES` — remain callable regardless of activation status. + +## Motivation + +The policy registry currently supports simple boolean policies through `isAuthorized`. Each policy independently returns `true` or `false`. In practice, access control often requires combining multiple policies. For example, an integrator may need to require KYC status *and* pro-user status, or may need to accept either pro-user status *or* lifetime-user status. + +Today, the only way to combine policies is for a user to listen for changes on separate allowlists and flatten the results into one list. This duplicates the underlying lists and requires infrastructure to keep the flattened copy in sync. + +This change lets a user create a composite policy that references other simple policies directly. A composite policy returns "is authorized" by combining the results of its referenced policies. This design simplifies maintenance: updating one child policy automatically updates every composite policy that references it. + +## Background + +**B20 Token** + +B20 is a token precompile that uses policies to restrict operations such as transfers, minting, and seizing. For each restricted operation, B20 stores a Policy Registry policy ID in a dedicated policy scope. When an operation is attempted, B20 passes the relevant policy ID and account address to the Policy Registry. If the Policy Registry reports that the account is not authorized, B20 rejects the operation. + +**Policy Registry** + +The Policy Registry is a singleton precompile contract used by B20 tokens. It manages a list of policies. B20 tokens call `isAuthorized(policyId, account)` against a policy ID stored on the relevant policy scope. The Policy Registry is currently used by B20 tokens for the `TRANSFER_FROM`, `TRANSFER_TO`, and `SEIZE_HOLDER` operations. + +**Simple policies** + +Simple policies are the non-composite policy types: `ALLOWLIST` and `BLOCKLIST`. + +- `ALLOWLIST` maintains a list of addresses. It returns authorized `true` if the queried address is in the list, and `false` otherwise. +- `BLOCKLIST` maintains a list of addresses. It returns authorized `false` if the queried address is in the list, and `true` for all other addresses. + +## Specs + +### Interface Changes + +**`PolicyType` enum** + +This change adds two new values to the `PolicyType` enum: + +```solidity +enum PolicyType { + BLOCKLIST, + ALLOWLIST, + UNION, // = 2, OR — authorized if any child policy authorizes the account + INTERSECT // = 3, AND — authorized only if every child policy authorizes the account +} +``` + +**New function: `createCompositePolicy`** + +```solidity +function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata childPolicyIds) + external + returns (uint64 newPolicyId); +``` + +`childPolicyIds` must contain between 2 and 4 entries, inclusive. These bounds are exposed as `MIN_COMPOSITE_CHILD_POLICIES` and `MAX_COMPOSITE_CHILD_POLICIES`. Every entry in `childPolicyIds` must be an existing simple policy (`ALLOWLIST` or `BLOCKLIST`). An entry can never be another composite policy, and can never be a built-in sentinel (`ALWAYS_ALLOW` or `ALWAYS_BLOCK`). + +The function reverts in this canonical order: + +1. `ZeroAddress` — `admin` is the zero address. +2. `IncompatiblePolicyType` — `policyType` is not `UNION` or `INTERSECT`. +3. `ChildPoliciesOutsideOfRange` — the number of entries in `childPolicyIds` is outside `[2, 4]`. +4. `PolicyNotFound` — a child policy does not exist. The function checks this in one pass over the whole set. +5. `InvalidChildPolicy(uint64 childPolicyId)` — a child policy is itself composite or is a built-in sentinel. The function checks this in a second pass over the set. + +On success, the function emits, in order: + +1. `PolicyCreated(uint64 indexed policyId, address indexed creator, PolicyType policyType)` +2. `PolicyAdminUpdated(uint64 indexed policyId, address indexed previousAdmin, address indexed newAdmin)`, with `previousAdmin = address(0)` and `newAdmin = admin` +3. `CompositePolicyUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds)` + +**New function: `updateComposite`** + +```solidity +function updateComposite(uint64 policyId, uint64[] calldata childPolicyIds) external; +``` + +This function fully replaces the child-policy set of an existing composite policy. The interface provides no partial-update operation and no operation to clear the list. The function applies the same child-validity rules as `createCompositePolicy`: every entry must be an existing simple policy, and the set must contain between 2 and 4 entries. + +The function reverts in this canonical order: + +1. `PolicyNotFound` — the composite policy referenced by `policyId` does not exist. +2. `IncompatiblePolicyType` — `policyId` refers to a simple policy, not a composite policy. +3. `Unauthorized` — the caller is not the current admin of the composite policy. This check fires before the child-count check. +4. `ChildPoliciesOutsideOfRange` — the number of entries in the new `childPolicyIds` is outside `[2, 4]`. +5. `PolicyNotFound` — a new child policy does not exist. +6. `InvalidChildPolicy(uint64 childPolicyId)` — a new child policy is itself composite or is a built-in sentinel. + +On success, the function emits only: + +- `CompositePolicyUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds)` + +The function does not emit `PolicyAdminUpdated`, because `updateComposite` never changes the policy's admin. + +**Existing functions: new revert path** + +`createPolicy` and `createPolicyWithAccounts` are both already live at Beryl. Starting at Cobalt, both functions also revert with `IncompatiblePolicyType` when `policyType` is `UNION` or `INTERSECT`. This is a previously unreachable revert path, because the `UNION` and `INTERSECT` enum values did not exist before Cobalt. + +**Verified errors and events** + +The `IPolicyRegistry` interface defines the following errors used by this feature: `ZeroAddress()`, `IncompatiblePolicyType()`, `ChildPoliciesOutsideOfRange()`, `PolicyNotFound()`, `InvalidChildPolicy(uint64 childPolicyId)`, and `Unauthorized()`. It defines the following events used by this feature: `PolicyCreated(uint64 indexed policyId, address indexed creator, PolicyType policyType)`, `PolicyAdminUpdated(uint64 indexed policyId, address indexed previousAdmin, address indexed newAdmin)`, and `CompositePolicyUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds)`. These signatures were verified against `src/interfaces/IPolicyRegistry.sol`. + +### Behavioural Changes + +**No B20 code changes required** + +A composite policy ID is passed to a B20 policy slot exactly like a simple policy ID. B20 requires zero code changes to support composite policies, because it stores policy slots as an opaque `uint64` and calls `isAuthorized` generically. + +**Live, short-circuiting evaluation** + +`isAuthorized` on a composite policy evaluates live on every call. It is not a snapshot taken at creation time or at the time of the last `updateComposite` call. On each call, the registry reads each child policy's current membership state. + +- `UNION` short-circuits to `true` on the first child that authorizes the account. +- `INTERSECT` short-circuits to `false` on the first child that does not authorize the account. + +Recursion never exceeds a depth of 1. Every child is validated to be a simple policy at write time, so a composite policy's children can never themselves be composite policies. + +**Child order affects gas, never the outcome** + +`UNION` and `INTERSECT` are commutative operations. Reordering `childPolicyIds` never changes whether an account is authorized. Reordering only shifts where the short-circuit lands. To save gas, place the child most likely to short-circuit first: the broadest `ALLOWLIST` for `UNION`, or the tightest `BLOCKLIST` for `INTERSECT`. + +**Duplicate child IDs are allowed** + +The registry neither sorts nor deduplicates the stored child-policy list. Deduplication would add gas cost to every write, for a set already capped at 4 entries, for little practical benefit. `UNION` and `INTERSECT` are idempotent under duplicate entries, so duplicates do not change the evaluation result. + +**Composites cannot shrink below the minimum** + +A composite policy can never shrink below 2 children through `updateComposite`. The function enforces the same `[2, 4]` range as `createCompositePolicy`, so there is no path to an empty or undersized composite policy. + +**Renounced child policies keep working** + +If a child policy's admin renounces administration, the parent composite policy keeps working. `renounceAdmin` only clears the child policy's admin and freezes its future membership changes. The child policy continues to exist, and `isAuthorized` on it continues to resolve normally. The composite policy keeps evaluating that child exactly as before. + +**State changes** + +- New state: `mapping(uint64 policyId => uint64[] childPolicyIds) children`, at offset 4 within the `base.policy_registry` ERC-7201 namespace. This offset is a namespace offset, not a literal EVM storage slot 4. `[TODO: verify against source — no implementation file with this storage layout was found in this repository]` +- Reused state: one shared global counter, `nextCounter`, shared across simple and composite policies. The counter starts at 2, because `0` and `1` are reserved for the built-in sentinels `ALWAYS_ALLOW` and `ALWAYS_BLOCK`. A composite policy ID encodes its `PolicyType` in the top byte and the next available counter value in the low 56 bits. This is the same encoding scheme used for simple policies, not a separate counter. `[TODO: verify against source — no implementation file with this encoding scheme was found in this repository]` + +### Examples + +**Before: assigning a simple policy** + +An integrator assigns one existing policy directly to a B20 policy scope: + +```solidity +b20.updatePolicy(TRANSFER_SENDER_POLICY, allowlistPolicyId); +``` + +Only accounts in `allowlistPolicyId` can transfer. + +**After: creating and assigning a composite policy** + +Assume two existing simple policies: `employeesPolicyId` (`ALLOWLIST`) and `approvedRegionPolicyId` (`ALLOWLIST`). + +Create a `UNION` composite policy: + +```solidity +policyRegistry.createCompositePolicy(admin, UNION, [employeesPolicyId, approvedRegionPolicyId]); +``` + +This call emits, in order: + +- `PolicyCreated(policyId, admin, UNION)` +- `PolicyAdminUpdated(policyId, address(0), admin)` +- `CompositePolicyUpdated(policyId, admin, [employeesPolicyId, approvedRegionPolicyId])` + +Assign the new composite policy to B20: + +```solidity +b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId); +``` + +B20 requires no composite-specific logic. It passes the policy ID to the registry exactly as it would for a simple policy. + +**Updating a composite policy** + +```solidity +policyRegistry.updateComposite(compositePolicyId, [employeesPolicyId, trustedPartnersPolicyId]); +``` + +This call emits: + +- `CompositePolicyUpdated(policyId, admin, [employeesPolicyId, trustedPartnersPolicyId])` + +B20 continues using the same policy ID. No token-side update is required. Because evaluation is live, future authorization checks use the new child set immediately — the registry does not take a snapshot. + +## Design Decisions & Alternatives Considered + +**Decision**: Provide two explicit policy types, `UNION` and `INTERSECT`, with a single creation function, `createCompositePolicy`, and a full-replacement update function, `updateComposite`. + +**Alternative 1: One generic `COMPOSITE` type** + +This alternative would store a separate operator (`AND`, `OR`, `NOT`, `XOR`) in composite storage. It was rejected because it requires storing both a "composite" flag and the operator, and adds either extra storage reads or a more complicated ID-encoding scheme. It also adds unnecessary complexity before there is any requirement for `NOT`, `XOR`, or nested expressions. A generic boolean-expression design creates a larger gas and audit surface than the chosen approach. + +**Alternative 2: Token-level policy groups** + +This alternative would keep the Policy Registry unchanged, and instead have each B20 token store multiple policy IDs plus an operator. It was rejected because composite policies would not be reusable entities under this design. It requires changes across B20, its token variants, factories, and token hot paths. It does not support sharing one composite policy across multiple tokens, and it spreads complexity across more contracts than the chosen approach. + +**Alternative 3: Incremental child updates** + +This alternative would provide `addCompositeOperand` and `removeCompositeOperand` functions instead of full-set replacement. It was rejected because the child list is capped at 4 entries, so the benefit of incremental mutation is limited. Dynamic-array mutation requires swap/remove logic, length tracking, and deduplication logic. Full replacement is simpler and atomic, and a caller can resend the complete list at low cost. + +**Alternative 4: Separate creator functions** + +This alternative would use `createUnionPolicy` and `createIntersectPolicy` instead of one function that takes a `policyType` argument. It was rejected because it doubles the creation API surface. A single `createCompositePolicy` function keeps policy creation consistent with the existing `createPolicy` pattern. Adding future operators under the separate-function design would require additional functions for each new operator. + +## Migration Steps + +This change is backwards-compatible. Existing simple policies (`ALLOWLIST` and `BLOCKLIST`) continue to work unchanged. No action is required if you do not need composite behavior. + +For users currently flattening multiple lists into one policy, follow these steps: + +1. Identify the simple policies you want to combine. +2. Call `policyRegistry.createCompositePolicy(admin, UNION or INTERSECT, [childPolicyIds])`. +3. Update the B20 token's policy scope to point to the new composite policy ID: + ```solidity + b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId); + ``` + No B20 contract change is required. B20 treats the composite policy ID as an opaque `uint64`, exactly like a simple policy ID. +4. Remove the old flattened policy if it is no longer needed. + +There are no breaking changes. All existing selectors, events, and errors remain dialable at Cobalt. + +There is no storage migration required. The `children` mapping is a new, empty mapping at ERC-7201 offset 4. Cobalt activation does not modify existing `PolicyRegistry` state at offsets 0–3. diff --git a/changelog/TEMPLATE_COMPOSITE_POLICY.md b/changelog/TEMPLATE_COMPOSITE_POLICY.md new file mode 100644 index 0000000..a668203 --- /dev/null +++ b/changelog/TEMPLATE_COMPOSITE_POLICY.md @@ -0,0 +1,226 @@ +# ADR Template (Point Form) + +- **Feature Name**: composite_policy +- **Start Date**: 2026-08-17 +- **Authors**: Rayyan Alam +- **Title**: Composite Policies (UNION/INTERSECT) + +--- + +## Summary + +- Introduce two new `PolicyRegistry` policy types, `UNION` (OR) and `INTERSECT` (AND) — collectively + "composite policies" — that authorize by combining 2 to 4 existing simple policies +- For users utilizing the policy registry who may want to combine many policies together, without + flattening into one big list +- Composite policies are built up of only existing simple policies (`ALLOWLIST`/`BLOCKLIST`) — never + another composite, and never a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`) +- Ships at the **Cobalt** hardfork. Every mutating call (`createCompositePolicy`, `updateComposite`) + is gated by `ActivationRegistry`, same as every other mutating `PolicyRegistry` function — not + automatically enabled. Read-only calls (`isAuthorized`, `compositePolicyChildIds`, + `MIN_COMPOSITE_CHILD_POLICIES`, `MAX_COMPOSITE_CHILD_POLICIES`) are always callable regardless of + activation + +--- + +## Motivation + +> State the problem this change solves, why the current state is insufficient, and brief context +> on prerequisites if needed (full detail lives in Background). +- The policy registry currently supports simple boolean policies via `isAuthorized`, where each + policy independently returns true or false +- In practice, access control often requires combining multiple policies (e.g., KYC + ProUser, or + ProUser OR LifetimeUser) +- The current architecture requires a user to listen to changes on a different allowlist and + flatten into one, duplicating lists and requiring infra to keep them up to date +- We want to allow for policy reuse by creating composite policies which can reference other simple + policies + - should return "is authorized" by combining the results of other policies + - simplifies maintenance — updating one child policy updates every composite that references it + +--- + +## Background + +> Link to or summarize concepts the reader needs before understanding the Specs: prior art, +> relevant EIPs/ERCs, existing patterns, and any domain-specific terms used in this document. +- B20 Token + - B20 is a token precompile that uses policies to restrict operations such as + transfers, minting, and seizing. + - For each restricted operation, B20 stores a Policy Registry policy ID in a + dedicated policy scope. + - When an operation is attempted, B20 passes the relevant policy ID and account + address to the Policy Registry. If the account is not authorized, B20 + rejects the operation. + +- Policy Registry: + - Is a singleton precompile contract used by B20 tokens. + - It manages a list of policies; B20 tokens call `isAuthorized(policyId, account)` against a + policy ID stored on the relevant policy scope + - Currently used by B20 tokens for `TRANSFER_FROM`, `TRANSFER_TO`, `SEIZE_HOLDER` +- Simple policies: + - These are the non-composite policy types: `ALLOWLIST` / `BLOCKLIST` + - `ALLOWLIST` has a list of addresses; returns authorized `true` if the address is in the + list, `false` otherwise + - `BLOCKLIST` has a list of addresses; returns authorized `false` if the address is in the + list, `true` for all other addresses + +--- + +## Specs + +> Implementation details live here. Add more headings as needed (e.g., Storage Layout Changes, +> Deprecated Assets, Access Control, etc.). + + +### Interface Changes + +> New functions, events, errors (include signatures). Renamed or deprecated symbols (old → new). +> Selector / topic0 values (verified via `cast sig` or `cast keccak`). + +- Introduce 2 new values in the `PolicyType` enum + - `UNION = 2` — authorized if *any* child policy authorizes the account (OR) + - `INTERSECT = 3` — authorized only if *every* child policy authorizes the account (AND) +- New function `createCompositePolicy(admin, policyType, childPolicyIds)` + - `childPolicyIds` must be between 2 and 4 entries (`MIN_COMPOSITE_CHILD_POLICIES` / + `MAX_COMPOSITE_CHILD_POLICIES`) + - Every child must be an *existing simple* policy (`ALLOWLIST`/`BLOCKLIST`) — never another + composite, never a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`) + - Canonical revert order: `ZeroAddress` (admin) → `IncompatiblePolicyType` (policyType not + UNION/INTERSECT) → `ChildPoliciesOutsideOfRange` (count not in `[2,4]`) → `PolicyNotFound` + (a child doesn't exist, checked as one pass over the whole set) → `InvalidChildPolicy` (a + child is itself composite/sentinel, checked as a second pass) + - Emits, in order: `PolicyCreated(policyId, creator, policyType)`, + `PolicyAdminUpdated(policyId, address(0), admin)`, `CompositePolicyUpdated(policyId, creator, + childPolicyIds)` +- New function `updateComposite(policyId, childPolicyIds)` + - Full replacement of the child set — there's no partial-update or clear-the-list operation + - Same child-validity rules as `createCompositePolicy` (existing simple policies only, 2-4 of + them) + - Canonical revert order: `PolicyNotFound` (composite itself doesn't exist) → + `IncompatiblePolicyType` (`policyId` is a simple policy) → `Unauthorized` (caller isn't the + current admin — fires before the count check) → `ChildPoliciesOutsideOfRange` → + `PolicyNotFound` (a new child doesn't exist) → `InvalidChildPolicy` + - Emits only `CompositePolicyUpdated(policyId, updater, childPolicyIds)` — no + `PolicyAdminUpdated`, since admin doesn't change +- Existing function, new revert path: `createPolicy` and `createPolicyWithAccounts` (both already + live on Beryl) now also revert `IncompatiblePolicyType` when `policyType` is `UNION`/`INTERSECT` — + a previously-unreachable path, since those enum values didn't exist before Cobalt + + +### Behavioural Changes + +> How execution flow differs from the previous version. Storage layout changes (new slots, moved +> fields, packing changes). Gas cost implications if meaningful. +- A composite policy ID is passed to a B20 policy slot exactly like a simple policy ID — B20 needs + **zero code changes**, since it stores policy slots as an opaque `uint64` and calls + `isAuthorized` generically +- `isAuthorized` on a composite is live and short-circuiting, not a snapshot: + - Reads each child's *current* membership on every call — no snapshot from creation or the + last `updateComposite` + - `UNION` short-circuits `true` on the first authorizing child + - `INTERSECT` short-circuits `false` on the first non-authorizing child + - Recursion never exceeds depth 1, because every child is validated to be a simple policy at + write time — a composite's children can never themselves be composites +- Child order affects gas, never the outcome: + - `UNION`/`INTERSECT` are commutative, so reordering `childPolicyIds` never changes whether an + account is authorized + - It only shifts where the short-circuit lands — put the child most likely to short-circuit + first (broadest ALLOWLIST for `UNION`, tightest BLOCKLIST for `INTERSECT`) to save gas +- Duplicate child IDs are allowed. The registry neither sorts nor deduplicates the stored child + list — deduplicating would cost extra gas on every write for a set already capped at 4 entries, + for little value; `UNION`/`INTERSECT` are idempotent under duplicates anyway +- A composite can never shrink below 2 children via `updateComposite` — it enforces the same + `[2,4]` range as creation, so there's no path to an empty or undersized composite +- If a child policy's admin renounces, the parent composite keeps working: `renounceAdmin` only + clears the child's 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 +- State changes + - New state: `mapping(uint64 policyId => uint64[] childPolicyIds) children`, at **offset 4** + within the `base.policy_registry` ERC-7201 namespace (not a literal EVM slot 4) + - Reused state: one shared global counter (`nextCounter`) across simple and composite + policies, starting at 2 (`0` and `1` are reserved for `ALWAYS_ALLOW`/`ALWAYS_BLOCK`). A + composite policy ID encodes `PolicyType` in the top byte and the next available counter + value in the low 56 bits — the same encoding scheme as simple policies, not a separate + counter + + +### Examples + +- **Before (simple policy)**: + - Assign one existing policy directly to a B20 policy scope + - `b20.updatePolicy(TRANSFER_SENDER_POLICY, allowlistPolicyId)` + - Only accounts in `allowlistPolicyId` can transfer + +- **After (composite policy)**: + - Assume existing simple policies: `employeesPolicyId` (ALLOWLIST), `approvedRegionPolicyId` (ALLOWLIST) + - Create a UNION composite: + - `policyRegistry.createCompositePolicy(admin, UNION, [employeesPolicyId, approvedRegionPolicyId])` + - Emits: `PolicyCreated(policyId, admin, UNION)` + `PolicyAdminUpdated(policyId, 0, admin)` + + `CompositePolicyUpdated(policyId, admin, [children])` + - Assign to B20: `b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId)` + - B20 has no composite-specific logic — it passes the policy ID to the registry as usual + +- **Updating a composite**: + - `policyRegistry.updateComposite(compositePolicyId, [employeesPolicyId, trustedPartnersPolicyId])` + - Emits: `CompositePolicyUpdated(policyId, admin, [newChildren])` + - B20 continues using the same policy ID — no token-side update required + - Future authorization checks use the new child set immediately (live evaluation, no snapshot) + +--- + +## Design Decisions & Alternatives Considered + +- **Decision**: Two explicit policy types (`UNION`, `INTERSECT`) with a single `createCompositePolicy` function and full-replacement `updateComposite` + +- **Alternative 1: One generic COMPOSITE type** + - Store a separate operator (AND, OR, NOT, XOR) in composite storage + - Rejected: + - Requires storing both "composite" flag and the operator + - Adds storage reads or more complicated ID encoding + - Unnecessary complexity before there's a requirement for NOT, XOR, or nested expressions + - Generic boolean expressions create a larger gas and audit surface + +- **Alternative 2: Token-level policy groups** + - Keep Policy Registry unchanged; have each B20 token store multiple policy IDs + an operator + - Rejected: + - Composite policies would not be reusable entities + - Requires changes across B20, token variants, factories, and token hot paths + - Does not support sharing one composite policy across multiple tokens + - Spreads complexity across more contracts + +- **Alternative 3: Incremental child updates** + - Provide `addCompositeOperand` / `removeCompositeOperand` functions + - Rejected: + - Child list is capped at 4 entries + - Dynamic-array mutation requires swap/remove, length, and deduplication logic + - Full replacement is simpler and atomic + - Caller can resend the complete list at low cost + +- **Alternative 4: Separate creator functions** + - Use `createUnionPolicy` and `createIntersectPolicy` + - Rejected: + - Doubles the creation API surface + - A single `createCompositePolicy` keeps policy creation consistent + - Future operators would require additional functions + +--- + +## Migration Steps + +- **Backwards-compatible**: Existing simple policies (ALLOWLIST/BLOCKLIST) continue to work unchanged. No action required if you don't need composite behavior. + +- **For users currently flattening multiple lists into one policy**: + 1. Identify the simple policies you want to combine + 2. Call `policyRegistry.createCompositePolicy(admin, UNION or INTERSECT, [childPolicyIds])` + 3. Update the B20 token's policy scope to point to the new composite policy ID: + - `b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId)` + - No B20 contract change is required — B20 treats the composite ID as an opaque `uint64` + exactly like a simple policy ID + 4. Remove the old flattened policy if no longer needed + +- **No breaking changes**: All existing selectors, events, and errors remain dialable at Cobalt + +- **No storage migration**: `children` is a new, empty mapping at ERC-7201 offset 4. Existing + `PolicyRegistry` state at offsets 0–3 is unmodified by Cobalt activation From c33897ec8de1ead7c61a781976f11a2bb5e145a7 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 18 Aug 2026 17:21:42 -0400 Subject: [PATCH 07/21] docs(changelog): add Cobalt B20 seize spec + point-form source Convert the seize point-form template into the written changelog entry 02_Cobalt_B20_seize.md (already indexed in changelog/README.md). Co-Authored-By: Claude --- changelog/02_Cobalt_B20_seize.md | 162 ++++++++++++++++++++++++ changelog/SEIZE_POLICY_TEMPLATE.md | 190 +++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 changelog/02_Cobalt_B20_seize.md create mode 100644 changelog/SEIZE_POLICY_TEMPLATE.md diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md new file mode 100644 index 0000000..33bcf24 --- /dev/null +++ b/changelog/02_Cobalt_B20_seize.md @@ -0,0 +1,162 @@ +# Seize surface and `burnBlocked` deprecation + +- **Feature Name**: seize +- **Start Date**: 2026-08-17 +- **Authors**: Rayyan Alam +- **Title**: Seize surface + burnBlocked deprecation + +## Summary + +This change adds a `seizeWithMemo` function to the shared `IB20` interface. Both B20 Asset and B20 Stablecoin inherit the function, and neither variant adds variant-specific logic. + +Today, an operator seizes an asset with a three-step workaround: block the account, call `burnBlocked`, then mint the same amount to a new holder. This destroys the balance and reissues it. `seizeWithMemo` replaces the workaround with a single admin call that reassigns the balance directly from one account to another. + +This change ships at the **Cobalt** hardfork. Cobalt has not activated on-chain yet, and the README lists it as `Upcoming`. Seize has no separate `ActivationRegistry` flag. It activates automatically at the Cobalt hardfork date. This behavior differs from `PolicyRegistry` composite policies, which need an explicit activation flip. + +**Compatibility promise**: `burnBlocked` remains fully dialable with its existing behavior. No removal is planned. This deprecation is permanent, not a phased removal. BOP-471, the re-merge ticket, was canceled. `seizeWithMemo` is new, additive surface, and nothing existing is broken. + +This document is for two audiences: + +- Token issuers and compliance integrators who currently use the block, burn, and mint workaround. +- Anyone who integrates against the deprecated `burnBlocked` path. + +## Motivation + +Compliant asset issuers need freeze and seize models. Today, the standard achieves seize with a `block` + `burnBlocked` + `mint` sequence that destroys the balance and then reissues it. `seizeWithMemo` replaces that workaround with a direct balance reassignment from `from` to `to`. The reassignment neither burns nor mints, so `totalSupply` is unchanged. The function emits a single, purpose-built `Seized` event. + +Burn functionality must be explicitly distinct from seize functionality. The two operations may be gated on different policies. + +The current seize workaround takes three steps and does not record that a seize occurred: + +1. Configure `from` as blocked under `TRANSFER_SENDER_POLICY`. This is the same policy that `burnBlocked` reads today, verified against `IB20.sol`. +2. Burn the assets. This reduces token supply. +3. Mint the assets to complete the seize. + +The workaround emits a burn event and a mint event. No event indicates that a seize occurred. + +The proposed functionality reduces the operation to two actions: + +1. Add the account to the policy so it can be seized from. +2. Call the `seizeWithMemo` function. This emits a `Seized` event. + +## Background + +Read the following concepts before the Specs section. + +**B20 Asset and B20 Stablecoin** are native tokens launched on B20. Both have built-in roles and access controls. Both gate their functions with the policy registry and policy functionality. + +**Policy Registry** is a singleton precompile contract. Its responsibility is to return `isAuthorized(policyId, account)`. This signature is verified against `IPolicyRegistry.sol`. It is not `isAllowed(address, true)`. B20 uses the policy registry to gate operations. B20 passes the stored policy ID and the account to check. + +## Specs + +### Interface Changes + +The following table maps the affected symbols. All selectors and topic0 values were verified with `cast sig`, `cast keccak`, and `cast sig-event` against `src/interfaces/IB20.sol` and `src/lib/B20Constants.sol` on 2026-08-18. + +| Symbol | Selector / topic0 | Status | Notes | +| --- | --- | --- | --- | +| `burnBlocked(address,uint256)` | `0xec0cf3dc` | deprecated-dialable | Behavior unchanged. No removal date committed. | +| `error AccountNotBlocked(address)` | `0x64a5cb46` | unchanged | Still exclusive to `burnBlocked`. | +| `event BurnedBlocked(address,address,uint256)` | topic0 `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` | unchanged | Still exclusive to `burnBlocked`. | +| `seizeWithMemo(address,address,uint256,bytes32)` | `0xf916d81b` | new | Single-call admin seize. Reassigns the balance of `from` to `to`. | +| `SEIZE_ROLE()` | `0x3c7e9ba5` | new | Role value `0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432`. | +| `SEIZE_HOLDER_POLICY()` | `0xb279d311` | new | Policy value `0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2`. Gates who is seizable. Membership is inverted: an account is seizable if it is NOT authorized. | +| `SEIZE_RECEIVER_POLICY()` | `0xb31da27f` | new | Policy value `0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d`. Gates the seize destination. An unset slot means always-allow. | +| `event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount)` | topic0 `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` | new | | +| `error AccountNotSeizable(address)` | `0x91dbbc8d` | new | | +| `PausableFeature.SEIZE` | — | new | Dedicated pause vector, independent of `BURN`. | + +The new seize surface adds the following declarations to `IB20`. These declarations are copied from `src/interfaces/IB20.sol`. + +```solidity +// Pausable operation classes. The SEIZE member is appended after TRANSFER, MINT, and BURN. +enum PausableFeature { + TRANSFER, + MINT, + BURN, + SEIZE +} + +// Reverts when `from` is currently authorized under SEIZE_HOLDER_POLICY (not a member of the seizable set). +error AccountNotSeizable(address account); + +// Emitted by seizeWithMemo, in addition to Transfer(from, to, amount) and Memo(caller, memo). +event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount); + +// Role required to call seizeWithMemo. +function SEIZE_ROLE() external view returns (bytes32); + +// Policy slot consulted against `from` by seizeWithMemo. +function SEIZE_HOLDER_POLICY() external view returns (bytes32); + +// Policy slot consulted against `to` by seizeWithMemo. An unset slot reads as always-allow. +function SEIZE_RECEIVER_POLICY() external view returns (bytes32); + +// Seizes `amount` of `from`'s balance and reassigns it to `to` in a single admin operation. +function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external; +``` + +### Behavioural Changes + +`seizeWithMemo(from, to, amount, memo)` applies its guards and emits its events in a fixed order. This order is verified against the reference implementation in `MockB20.sol` and the natspec in `IB20.sol`. + +1. `whenNotPaused(SEIZE)`. If the `SEIZE` vector is paused, the call reverts `ContractPaused(SEIZE)`. +2. `onlyRole(SEIZE_ROLE)`. If the caller does not hold `SEIZE_ROLE`, the call reverts `AccessControlUnauthorizedAccount`. +3. If `to == address(0)`, the call reverts `InvalidReceiver`. +4. If `from == address(0)`, the call reverts `InvalidSender`. +5. If `from == to`, the call reverts `InvalidReceiver`. This rejects self-seize. +6. If `from` IS authorized under `SEIZE_HOLDER_POLICY`, the call reverts `AccountNotSeizable`. An account is seizable only when it is NOT authorized under this policy. +7. If `to` is NOT authorized under `SEIZE_RECEIVER_POLICY`, the call reverts `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)`. +8. If the balance of `from` is less than `amount`, the call reverts `InsufficientBalance`. +9. The call emits, in order: `Transfer(from, to, amount)`, then `Memo(caller, memo)`, then `Seized(caller, from, to, amount)`. + +`seizeWithMemo` is an admin operation. It skips the allowance check and all three transfer-side policies: `TRANSFER_SENDER_POLICY`, `TRANSFER_RECEIVER_POLICY`, and `TRANSFER_EXECUTOR_POLICY`. + +The `PausableFeature.SEIZE` vector pauses `seizeWithMemo` independently. Pausing `SEIZE` does not pause `burnBlocked` or transfer operations. Pausing `BURN` or `TRANSFER` does not pause seizing. + +Seize is a transfer, not a burn. The balance moves from `from` to `to`, and `totalSupply` is unchanged. This is the key behavioral difference from `burnBlocked`. `burnBlocked` sends the balance to `address(0)` and reduces supply. + +### Examples + +**Before**: the block, burn, and mint workaround. This path is still available and is deprecated. + +1. Configure `from` as blocked under `TRANSFER_SENDER_POLICY`. +2. Call `burnBlocked(from, amount)`. This burns the balance of `from` and is gated by `BURN_BLOCKED_ROLE`. +3. Call `mint(treasury, amount)`. This separately reissues the same amount and is gated by `MINT_ROLE`. + +This path emits `Transfer(from, 0, amount)`, then `BurnedBlocked`, then `Transfer(0, treasury, amount)`. These are two independent operations. No single event ties the burn to the reissue. + +**After**: the new single call. + +1. Configure `from` as NOT authorized under `SEIZE_HOLDER_POLICY`. This marks the account as seizable. +2. Call `seizeWithMemo(from, treasury, amount, memo)`. This is gated by `SEIZE_ROLE`. + +This path emits `Transfer(from, treasury, amount)`, then `Memo(caller, memo)`, then `Seized(caller, from, treasury, amount)`. + +## Design Decisions & Alternatives Considered + +**Final shipped shape**: `seizeWithMemo` and `burnBlocked` use fully independent policy slots and pause vectors. + +- `seizeWithMemo` uses the new `SEIZE_HOLDER_POLICY` for `from`, the new `SEIZE_RECEIVER_POLICY` for `to`, the new `SEIZE_ROLE`, and the new `PausableFeature.SEIZE`. +- `burnBlocked` retains `TRANSFER_SENDER_POLICY`, `BURN_BLOCKED_ROLE`, and the `BURN` pause vector, all unchanged. + +**Shared seize-policy approach (rejected)**: A shared policy for burn and seize was rejected because the two operations have different effects. `burnBlocked` destroys supply, and `seizeWithMemo` reassigns balances. `burnBlocked` therefore remains independent, and no `burnBlockedWithMemo` variant is included. + +**Function naming (`transferFromBlockedWithMemo` rejected)**: The name `transferFromBlockedWithMemo` was also considered. The name `seizeWithMemo` was chosen because it explicitly defines the intent. + +## Migration Steps + +This change is backwards-compatible and introduces no breaking changes: + +- `burnBlocked` continues to work unchanged. No action is required if you do not need seize behavior yet. +- All existing selectors, events, and errors remain dialable. + +To adopt `seizeWithMemo`, complete the following steps: + +1. Grant `SEIZE_ROLE` to the account or accounts that should be able to seize. If no account holds `SEIZE_ROLE`, no one can seize. +2. Configure `SEIZE_HOLDER_POLICY` so that the accounts you want to be seizable are NOT authorized under it. If you configure no policy, the unset slot means always-allow, and no account is seizable. +3. Optionally, configure `SEIZE_RECEIVER_POLICY` to restrict where seized funds may land. The unset slot defaults to always-allow, so an unallowlisted treasury still works. + +To reproduce the destroy-supply outcome of `burnBlocked` with seize, take two steps. `seizeWithMemo` alone does not reduce `totalSupply`. First, seize to a treasury or self address. Then call `burn(amount)` from that address if you want the supply destroyed. + +No storage migration is required. The storage and behavior of `burnBlocked` are untouched by this change. diff --git a/changelog/SEIZE_POLICY_TEMPLATE.md b/changelog/SEIZE_POLICY_TEMPLATE.md new file mode 100644 index 0000000..50bf158 --- /dev/null +++ b/changelog/SEIZE_POLICY_TEMPLATE.md @@ -0,0 +1,190 @@ +# ADR Template (Point Form) + +- **Feature Name**: seize +- **Start Date**: 2026-08-17 +- **Authors**: Rayyan Alam +- **Title**: Seize surface + burnBlocked deprecation + +> Fill in each section below when documenting a change. Pointform here can be sufficient + +--- + +## Summary + +> Give a summary of the change: who it's for, what it does. + +- Add `seizeWithMemo` to the shared `IB20` interface — inherited by both B20 Asset and B20 + Stablecoin, no variant-specific logic +- Original flow to seize an asset was block + `burnBlocked` + mint (destroy then reissue); + `seizeWithMemo` replaces this with a single admin call that reassigns the balance directly +- Ships at the **Cobalt** hardfork, which has not activated on-chain yet (README lists it + `Upcoming`). Seize has no separate `ActivationRegistry` flag — it activates automatically at the + Cobalt hardfork date (unlike `PolicyRegistry`'s composite policies, which need an explicit + activation flip) +- **Compatibility promise**: `burnBlocked` remains fully dialable with its existing behavior — + no removal is planned (permanent, not a phased deprecation; BOP-471, the re-merge ticket, was + canceled). `seizeWithMemo` is new, additive surface; nothing existing is broken +- Audience: token issuers/compliance integrators currently using the block+burn+mint workaround, + and anyone integrating against the deprecated `burnBlocked` path + + +--- + +## Motivation + +> State the problem this change solves, why the current state is insufficient, and brief context +> on prerequisites if needed (full detail lives in Background). + +- Compliant asset issuers need freeze+seize models. Today we achieve this with + block+burnBlocked+mint (destroy then reissue). `seizeWithMemo` replaces that workaround with a + direct balance reassignment (`from` → `to`) that neither burns nor mints — `totalSupply` is + unchanged — and emits a single, purpose-built `Seized` event +- Burn functionality should be explicitly distinct from seize; they may be gated on different + policies +- The current functionality is 3 step to sezie + - Configure `from` as blocked under `TRANSFER_SENDER_POLICY` (the same policy `burnBlocked` + reads today — verified against `IB20.sol`) + - Burn assets --> Effectes token supply + - Mint assets to seieze + - Emits a burn and a Mint + - Does not indicate a seize occured +- Proposed functionlaity + - Add in policy to be seized from + - use the seizeWithMemo function --> emits seize event + +--- + +## Background + +> Link to or summarize concepts the reader needs before understanding the Specs: prior art, +> relevant EIPs/ERCs, existing patterns, and any domain-specific terms used in this document. +- B20 Asset and B20 Stablecoin + - Natve token launched on B20 + - Has in built roles and acess controls + - Functions are gated using policy registry and policy functionality +- Policy Registry + - A singleton precompile contract, whose responsibility is to return `isAuthorized(policyId, + account)` (verified against `IPolicyRegistry.sol` — not `isAllowed(address, true)`) + - Used by B20, gating operations by passing the stored policy ID and the account to check +--- + +## Specs + +> Implementation details live here. Add more headings as needed (e.g., Storage Layout Changes, +> Deprecated Assets, Access Control, etc.). + +### Interface Changes + +> New functions, events, errors (include signatures). Renamed or deprecated symbols (old → new). +> Selector / topic0 values. + - Old → new mapping (selectors/topics verified via `cast sig`/`cast keccak` against + `src/interfaces/IB20.sol` and `src/lib/B20Constants.sol`): + - `burnBlocked(address,uint256)` `0xec0cf3dc` — **deprecated-dialable**, unchanged + behavior, no removal date committed + - `error AccountNotBlocked(address)` `0x64a5cb46` — unchanged, still exclusive to + `burnBlocked` + - `event BurnedBlocked(address,address,uint256)` topic0 + `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` — unchanged, still + exclusive to `burnBlocked` + - NEW `seizeWithMemo(address,address,uint256,bytes32)` `0xf916d81b` — single-call + admin seize, reassigns `from`'s balance to `to` + - NEW `SEIZE_ROLE()` `0x3c7e9ba5` (role value `0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432`) + - NEW `SEIZE_HOLDER_POLICY()` `0xb279d311` (`0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2`) + — gates who is seizable; inverted membership (seizable if NOT authorized) + - NEW `SEIZE_RECEIVER_POLICY()` `0xb31da27f` (`0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d`) + — gates seize destination; unset slot = always-allow + - NEW `event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount)` + topic0 `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` + - NEW `error AccountNotSeizable(address)` `0x91dbbc8d` + - NEW `PausableFeature.SEIZE` — dedicated pause vector, independent of `BURN` + - All function/error selectors, event topic0s, and role/policy hash values above re-verified + via `cast sig`/`cast keccak`/`cast sig-event` against `IB20.sol` and `B20Constants.sol` on + 2026-08-18 + +### Behavioural Changes + +> How execution flow differs from the previous version. Storage layout changes (new slots, moved +> fields, packing changes). Gas cost implications if meaningful. +- `seizeWithMemo(from, to, amount, memo)` — verified exact guard/emission order, per + `MockB20.sol`'s reference implementation and `IB20.sol`'s natspec: + 1. `whenNotPaused(SEIZE)` — reverts `ContractPaused(SEIZE)` + 2. `onlyRole(SEIZE_ROLE)` — reverts `AccessControlUnauthorizedAccount` + 3. `to == address(0)` — reverts `InvalidReceiver` + 4. `from == address(0)` — reverts `InvalidSender` + 5. `from == to` — reverts `InvalidReceiver` (rejects self-seize) + 6. `from` must NOT be authorized under `SEIZE_HOLDER_POLICY` — else reverts + `AccountNotSeizable` + 7. `to` must be authorized under `SEIZE_RECEIVER_POLICY` — else reverts + `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` + 8. balance check — reverts `InsufficientBalance` + 9. emits, in order: `Transfer(from, to, amount)` → `Memo(caller, memo)` → `Seized(caller, from, to, amount)` +- Admin operation: skips allowance and all three transfer-side policies + (`TRANSFER_SENDER_POLICY`/`TRANSFER_RECEIVER_POLICY`/`TRANSFER_EXECUTOR_POLICY`) +- Dedicated pause vector: `PausableFeature.SEIZE` pauses `seizeWithMemo` independently. Pausing + `SEIZE` does not pause `burnBlocked` or transfer operations, and pausing `BURN` or `TRANSFER` + does not pause seizing. +- Seize is a transfer, not a burn: the balance moves `from` → `to` and `totalSupply` is + unchanged. This is the key behavioral difference from `burnBlocked`, which sends to + `address(0)` and reduces supply + + + +### Examples + +> Before/after code snippets or call sequences. Expected return values or emitted events. + +- Before (old block+burn+mint workaround, still available, deprecated): + - Configure `from` as blocked under `TRANSFER_SENDER_POLICY` + - `burnBlocked(from, amount)` — burns `from`'s balance, gated by `BURN_BLOCKED_ROLE` + - `mint(treasury, amount)` — separately reissues the same amount, gated by `MINT_ROLE` + - Emits `Transfer(from, 0, amount)` + `BurnedBlocked` + `Transfer(0, treasury, amount)` — + two independent operations, no single event ties the burn to the reissue +- After (new, single call): + - Configure `from` as NOT authorized under `SEIZE_HOLDER_POLICY` (i.e. blocked) + - `seizeWithMemo(from, treasury, amount, memo)` — gated by `SEIZE_ROLE` + - Emits `Transfer(from, treasury, amount)` → `Memo(caller, memo)` → `Seized(caller, from, treasury, amount)` +--- + +## Design Decisions & Alternatives Considered + +> Describe the approach taken and why. Document alternatives considered and why they were rejected. +> Note any opinionated choices and their rationale. + +- **Final shipped shape:** `seizeWithMemo` and `burnBlocked` use fully independent policy slots and + pause vectors. + - `seizeWithMemo` uses the new `SEIZE_HOLDER_POLICY` (for `from`) and + `SEIZE_RECEIVER_POLICY` (for `to`), the new `SEIZE_ROLE`, and the new + `PausableFeature.SEIZE`. + - `burnBlocked` retains `TRANSFER_SENDER_POLICY`, `BURN_BLOCKED_ROLE`, and the `BURN` pause + vector unchanged. + +### Function Naming Alternatives + +- The shared seize-policy approach was rejected because burning and seizing have different effects: + `burnBlocked` destroys supply, while `seizeWithMemo` reassigns balances. `burnBlocked` therefore + remains independent, and no `burnBlockedWithMemo` variant is included. + +- transferFromBlockedWithMemo function name was also brought up + - seizeWithMemo is used because it explictly defines the intent + +--- + +## Migration Steps + +> Steps for integrators to adopt the new interface. Call out what is backwards-compatible, any +> deprecation timeline, and breaking changes that require action before activation. + +- **Backwards-compatible**: `burnBlocked` continues to work unchanged; no action required if you + don't need seize behavior yet +- **No breaking changes**: all existing selectors, events, and errors remain dialable +- **To adopt `seizeWithMemo`**: + 1. Grant `SEIZE_ROLE` to the account(s) that should be able to seize — with no `SEIZE_ROLE` + holders, no one can seize + 2. Configure `SEIZE_HOLDER_POLICY` so the accounts you want seizable are NOT authorized under + it — with no policy configured (unset = always-allow), no account is seizable + 3. Optionally configure `SEIZE_RECEIVER_POLICY` to restrict where seized funds may land; + unset defaults to always-allow (e.g. an unallowlisted treasury still works) +- **To reproduce `burnBlocked`'s destroy-supply outcome with seize**: `seizeWithMemo` alone does + not reduce `totalSupply`. Seize to a treasury/self address, then call `burn(amount)` from that + address if you want the supply destroyed +- No storage migration: `burnBlocked`'s storage and behavior are untouched by this change From 8bfb701b05f671d39347b9b5a038721270e68048 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 18 Aug 2026 17:28:54 -0400 Subject: [PATCH 08/21] feat: seize policy --- .claude/skills/technical-document/SKILL.md | 55 +++++++++++++++++----- changelog/02_Cobalt_B20_seize.md | 8 +--- changelog/SEIZE_POLICY_TEMPLATE.md | 4 -- 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/.claude/skills/technical-document/SKILL.md b/.claude/skills/technical-document/SKILL.md index 7e206f1..b60a258 100644 --- a/.claude/skills/technical-document/SKILL.md +++ b/.claude/skills/technical-document/SKILL.md @@ -27,6 +27,23 @@ changelog/AGENTS.md changelog/README.md ``` +## Required reading before drafting + +Before writing any output, read every file below in full. These are the bundled, +offline copies of this skill's writing rules — use them as the authoritative source +instead of fetching the external sites they summarize: + +```text +references/template-point-form.md +references/simplified-technical-english.md +references/google-technical-writing.md +``` + +Do not skip this reading pass, and do not substitute a `WebFetch` of the external URLs +named in Steps 5 and 6 for reading these files — the referenced sites may be unreachable, +paywalled, or have drifted from what this skill applies, and the bundled files are the +version this skill is designed against. + ## Exact output structure The output must use this structure and heading text in this order: @@ -116,6 +133,26 @@ Do not move content to a new heading to make the document easier to organize. Before citing a function signature, event signature, error selector, topic0, function selector, or ERC-165 interface ID, verify it against the source or mark it for verification. +### 3a. Offer to help fill `### Interface Changes` and `### Examples` gaps + +`### Interface Changes` and `### Examples` are the two sections most likely to need facts +the point-form input does not supply in full: exact signatures, selectors, topic0 values, +or a working call sequence. When either section would otherwise ship with a `[TODO: ...]` +or `[TODO: verify against source]` marker, stop before finalizing that section and ask the +user whether they want help closing the gap — for example, whether you should search the +repository (`src/interfaces/**` and related source) to find or verify the missing +signature, selector, or example, or whether they would rather supply the value themselves +or leave the TODO in place. + +- Ask once per document, covering all outstanding interface/example gaps together, rather + than once per gap. +- If the user says yes, read the relevant source before writing the section, and verify + each value you cite rather than guessing. +- If the user declines, or doesn't respond, keep the `[TODO: ...]` markers rather than + inventing a plausible-looking signature, selector, or example. +- Do not ask about gaps elsewhere in the document (Summary, Motivation, Background, Design + Decisions, Migration Steps) — only Interface Changes and Examples warrant this offer. + ### 4. Write the specification Convert point-form fragments into complete technical prose. Keep the meaning and @@ -126,11 +163,9 @@ numbered lists for migration steps. Use code blocks for verified code or call se ### 5. Apply Simplified Technical English principles -Use the public `asd-ste100-skill` as a source of writing principles: - -```text -https://github.com/danyuchn/asd-ste100-skill -``` +Apply the rules in `references/simplified-technical-english.md` (read in the required +reading pass above). That file is the source of truth for this step — do not fetch the +public `asd-ste100-skill` repository it summarizes. Apply these rules: @@ -152,12 +187,9 @@ the official ASD-STE100 dictionary. ### 6. Apply Google technical-writing guidance -Use Google’s technical-writing guidance as the style reference: - -```text -https://developers.google.com/style -https://developers.google.com/tech-writing -``` +Apply the checklist in `references/google-technical-writing.md` (read in the required +reading pass above). That file is the source of truth for this step — do not fetch +developers.google.com. Apply these rules: @@ -185,6 +217,7 @@ Check all of the following: - Examples contain only supplied or verified values. - Migration steps identify compatibility and breaking changes when the input provides them. - Missing information uses TODO markers. +- Any Interface Changes/Examples gap was offered to the user per Step 3a before shipping a TODO. - Active voice, terminology consistency, sentence clarity, and plain language were applied. ## Output rules diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index 33bcf24..7047461 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -11,14 +11,8 @@ This change adds a `seizeWithMemo` function to the shared `IB20` interface. Both Today, an operator seizes an asset with a three-step workaround: block the account, call `burnBlocked`, then mint the same amount to a new holder. This destroys the balance and reissues it. `seizeWithMemo` replaces the workaround with a single admin call that reassigns the balance directly from one account to another. -This change ships at the **Cobalt** hardfork. Cobalt has not activated on-chain yet, and the README lists it as `Upcoming`. Seize has no separate `ActivationRegistry` flag. It activates automatically at the Cobalt hardfork date. This behavior differs from `PolicyRegistry` composite policies, which need an explicit activation flip. - **Compatibility promise**: `burnBlocked` remains fully dialable with its existing behavior. No removal is planned. This deprecation is permanent, not a phased removal. BOP-471, the re-merge ticket, was canceled. `seizeWithMemo` is new, additive surface, and nothing existing is broken. -This document is for two audiences: - -- Token issuers and compliance integrators who currently use the block, burn, and mint workaround. -- Anyone who integrates against the deprecated `burnBlocked` path. ## Motivation @@ -66,7 +60,7 @@ The following table maps the affected symbols. All selectors and topic0 values w | `error AccountNotSeizable(address)` | `0x91dbbc8d` | new | | | `PausableFeature.SEIZE` | — | new | Dedicated pause vector, independent of `BURN`. | -The new seize surface adds the following declarations to `IB20`. These declarations are copied from `src/interfaces/IB20.sol`. +The new seize surface adds the following declarations to `IB20`. ```solidity // Pausable operation classes. The SEIZE member is appended after TRANSFER, MINT, and BURN. diff --git a/changelog/SEIZE_POLICY_TEMPLATE.md b/changelog/SEIZE_POLICY_TEMPLATE.md index 50bf158..3af9287 100644 --- a/changelog/SEIZE_POLICY_TEMPLATE.md +++ b/changelog/SEIZE_POLICY_TEMPLATE.md @@ -17,10 +17,6 @@ Stablecoin, no variant-specific logic - Original flow to seize an asset was block + `burnBlocked` + mint (destroy then reissue); `seizeWithMemo` replaces this with a single admin call that reassigns the balance directly -- Ships at the **Cobalt** hardfork, which has not activated on-chain yet (README lists it - `Upcoming`). Seize has no separate `ActivationRegistry` flag — it activates automatically at the - Cobalt hardfork date (unlike `PolicyRegistry`'s composite policies, which need an explicit - activation flip) - **Compatibility promise**: `burnBlocked` remains fully dialable with its existing behavior — no removal is planned (permanent, not a phased deprecation; BOP-471, the re-merge ticket, was canceled). `seizeWithMemo` is new, additive surface; nothing existing is broken From 12f6a6d63beb11e7927392b5c4d5993469af7641 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 19 Aug 2026 11:08:22 -0400 Subject: [PATCH 09/21] changelog: add seize surface + burnBlocked deprecation for Cobalt Adds 02_Cobalt_B20_seize.md documenting the new seizeWithMemo function added to the shared IB20 interface, with independent policy slots (SEIZE_HOLDER_POLICY, SEIZE_RECEIVER_POLICY), dedicated pause vector PausableFeature.SEIZE, and SEIZE_ROLE. burnBlocked is deprecated-dialable. Co-authored-by: OpenCode Co-Authored-By: Claude --- changelog/02_Cobalt_B20_seize.md | 189 +++++++++++++------------------ 1 file changed, 78 insertions(+), 111 deletions(-) diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index 7047461..95bb4a2 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -1,4 +1,4 @@ -# Seize surface and `burnBlocked` deprecation +# Seize Surface + `burnBlocked` Deprecation - **Feature Name**: seize - **Start Date**: 2026-08-17 @@ -7,150 +7,117 @@ ## Summary -This change adds a `seizeWithMemo` function to the shared `IB20` interface. Both B20 Asset and B20 Stablecoin inherit the function, and neither variant adds variant-specific logic. +This change adds `seizeWithMemo` to the shared `IB20` interface (inherited by both B20 Asset and B20 Stablecoin). The Cobalt hardfork activates this feature. Read calls against `IB20` are callable regardless of activation status; the `seizeWithMemo` write path is gated by `ActivationRegistry` and will revert with `Unauthorized` if Cobalt is not active. -Today, an operator seizes an asset with a three-step workaround: block the account, call `burnBlocked`, then mint the same amount to a new holder. This destroys the balance and reissues it. `seizeWithMemo` replaces the workaround with a single admin call that reassigns the balance directly from one account to another. +The original workflow to seize an asset required three steps: block the sender under `TRANSFER_SENDER_POLICY`, call `burnBlocked` (which burns supply), then call `mint` to reissue to the seizure account. `seizeWithMemo` replaces this with a single administrative call that reassigns the balance directly from `from` to `to`. Total supply is unchanged. -**Compatibility promise**: `burnBlocked` remains fully dialable with its existing behavior. No removal is planned. This deprecation is permanent, not a phased removal. BOP-471, the re-merge ticket, was canceled. `seizeWithMemo` is new, additive surface, and nothing existing is broken. +`burnBlocked` remains callable but is now **deprecated-dialable**. Its behavior, selector (`0xec0cf3dc`), error (`AccountNotBlocked`), and event (`BurnedBlocked`) are unchanged. No removal date is committed. +**Compatibility promise**: all existing selectors, events, and errors remain dialable. No breaking changes. -## Motivation +## Mapping Table -Compliant asset issuers need freeze and seize models. Today, the standard achieves seize with a `block` + `burnBlocked` + `mint` sequence that destroys the balance and then reissues it. `seizeWithMemo` replaces that workaround with a direct balance reassignment from `from` to `to`. The reassignment neither burns nor mints, so `totalSupply` is unchanged. The function emits a single, purpose-built `Seized` event. +| Old symbol | New symbol | Status | Reason | +|------------|------------|--------|--------| +| — | `seizeWithMemo(address,address,uint256,bytes32)` | new | Single-call admin seize; selector `0xf916d81b` | +| — | `SEIZE_ROLE()` | new | Role constant; selector `0x3c7e9ba5` | +| — | `SEIZE_HOLDER_POLICY()` | new | Policy scope; selector `0xb279d311` — gates who is seizable (inverted membership) | +| — | `SEIZE_RECEIVER_POLICY()` | new | Policy scope; selector `0xb31da27f` — gates seizure destination | +| — | `event Seized(address,address,address,uint256)` | new | Emitted after `Transfer` + `Memo`; topic0 `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` | +| — | `error AccountNotSeizable(address)` | new | Selector `0x91dbbc8d` | +| — | `PausableFeature.SEIZE` | new | Dedicated pause vector, independent of `BURN` | +| `burnBlocked(address,uint256)` | `burnBlocked(address,uint256)` | deprecated-dialable | Unchanged; retains `BURN_BLOCKED_ROLE`, `TRANSFER_SENDER_POLICY`, `BURN` pause vector | -Burn functionality must be explicitly distinct from seize functionality. The two operations may be gated on different policies. +## New at Cobalt (adopt these) -The current seize workaround takes three steps and does not record that a seize occurred: +### `seizeWithMemo` -1. Configure `from` as blocked under `TRANSFER_SENDER_POLICY`. This is the same policy that `burnBlocked` reads today, verified against `IB20.sol`. -2. Burn the assets. This reduces token supply. -3. Mint the assets to complete the seize. - -The workaround emits a burn event and a mint event. No event indicates that a seize occurred. - -The proposed functionality reduces the operation to two actions: +```solidity +function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external; +``` -1. Add the account to the policy so it can be seized from. -2. Call the `seizeWithMemo` function. This emits a `Seized` event. +**Caller**: must hold `SEIZE_ROLE`. +**Policies checked** (in order): +1. `SEIZE_HOLDER_POLICY` — `from` must be **not authorized** (inverted membership). Reverts `AccountNotSeizable` if authorized. +2. `SEIZE_RECEIVER_POLICY` — `to` must be authorized. Reverts `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` if not. Unset slot defaults to always-allow. -## Background +**Policies NOT checked**: `TRANSFER_SENDER_POLICY`, `TRANSFER_RECEIVER_POLICY`, `TRANSFER_EXECUTOR_POLICY`, and allowance. -Read the following concepts before the Specs section. +**Events emitted** (in order): +1. `Transfer(from, to, amount)` +2. `Memo(caller, memo)` +3. `Seized(caller, from, to, amount)` -**B20 Asset and B20 Stablecoin** are native tokens launched on B20. Both have built-in roles and access controls. Both gate their functions with the policy registry and policy functionality. +**Reverts** (canonical order): +- `ContractPaused(SEIZE)` — if `SEIZE` is paused +- `AccessControlUnauthorizedAccount` — if caller lacks `SEIZE_ROLE` +- `InvalidReceiver` — if `to == address(0)` or `from == to` +- `InvalidSender` — if `from == address(0)` +- `AccountNotSeizable` — if `from` authorized under `SEIZE_HOLDER_POLICY` +- `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` — if `to` not authorized +- `InsufficientBalance` — if `from` balance < `amount` -**Policy Registry** is a singleton precompile contract. Its responsibility is to return `isAuthorized(policyId, account)`. This signature is verified against `IPolicyRegistry.sol`. It is not `isAllowed(address, true)`. B20 uses the policy registry to gate operations. B20 passes the stored policy ID and the account to check. +### Pause vector -## Specs +`PausableFeature.SEIZE` is a dedicated pause vector. It is independent of `BURN` (which gates `burnBlocked`, `burn`, `burnWithMemo`). Call `pause([SEIZE])` / `unpause([SEIZE])` with `PAUSE_ROLE` / `UNPAUSE_ROLE`. -### Interface Changes +### Policy scopes -The following table maps the affected symbols. All selectors and topic0 values were verified with `cast sig`, `cast keccak`, and `cast sig-event` against `src/interfaces/IB20.sol` and `src/lib/B20Constants.sol` on 2026-08-18. +- `SEIZE_HOLDER_POLICY` — evaluated against `from`. Inverted: `from` is seizable **only when NOT authorized**. Unset = always-allow → no account is seizable until configured. +- `SEIZE_RECEIVER_POLICY` — evaluated against `to`. Standard: `to` must be authorized. Unset = always-allow → treasury need not be allowlisted. -| Symbol | Selector / topic0 | Status | Notes | -| --- | --- | --- | --- | -| `burnBlocked(address,uint256)` | `0xec0cf3dc` | deprecated-dialable | Behavior unchanged. No removal date committed. | -| `error AccountNotBlocked(address)` | `0x64a5cb46` | unchanged | Still exclusive to `burnBlocked`. | -| `event BurnedBlocked(address,address,uint256)` | topic0 `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` | unchanged | Still exclusive to `burnBlocked`. | -| `seizeWithMemo(address,address,uint256,bytes32)` | `0xf916d81b` | new | Single-call admin seize. Reassigns the balance of `from` to `to`. | -| `SEIZE_ROLE()` | `0x3c7e9ba5` | new | Role value `0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432`. | -| `SEIZE_HOLDER_POLICY()` | `0xb279d311` | new | Policy value `0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2`. Gates who is seizable. Membership is inverted: an account is seizable if it is NOT authorized. | -| `SEIZE_RECEIVER_POLICY()` | `0xb31da27f` | new | Policy value `0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d`. Gates the seize destination. An unset slot means always-allow. | -| `event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount)` | topic0 `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` | new | | -| `error AccountNotSeizable(address)` | `0x91dbbc8d` | new | | -| `PausableFeature.SEIZE` | — | new | Dedicated pause vector, independent of `BURN`. | +These two policy IDs are packed into a new `seizePolicyIds` storage slot (additive; `burnBlocked` storage unchanged). -The new seize surface adds the following declarations to `IB20`. +### `burnBlocked` (deprecated but supported) ```solidity -// Pausable operation classes. The SEIZE member is appended after TRANSFER, MINT, and BURN. -enum PausableFeature { - TRANSFER, - MINT, - BURN, - SEIZE -} - -// Reverts when `from` is currently authorized under SEIZE_HOLDER_POLICY (not a member of the seizable set). -error AccountNotSeizable(address account); - -// Emitted by seizeWithMemo, in addition to Transfer(from, to, amount) and Memo(caller, memo). -event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount); - -// Role required to call seizeWithMemo. -function SEIZE_ROLE() external view returns (bytes32); - -// Policy slot consulted against `from` by seizeWithMemo. -function SEIZE_HOLDER_POLICY() external view returns (bytes32); - -// Policy slot consulted against `to` by seizeWithMemo. An unset slot reads as always-allow. -function SEIZE_RECEIVER_POLICY() external view returns (bytes32); - -// Seizes `amount` of `from`'s balance and reassigns it to `to` in a single admin operation. -function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external; +function burnBlocked(address from, uint256 amount) external; ``` -### Behavioural Changes - -`seizeWithMemo(from, to, amount, memo)` applies its guards and emits its events in a fixed order. This order is verified against the reference implementation in `MockB20.sol` and the natspec in `IB20.sol`. - -1. `whenNotPaused(SEIZE)`. If the `SEIZE` vector is paused, the call reverts `ContractPaused(SEIZE)`. -2. `onlyRole(SEIZE_ROLE)`. If the caller does not hold `SEIZE_ROLE`, the call reverts `AccessControlUnauthorizedAccount`. -3. If `to == address(0)`, the call reverts `InvalidReceiver`. -4. If `from == address(0)`, the call reverts `InvalidSender`. -5. If `from == to`, the call reverts `InvalidReceiver`. This rejects self-seize. -6. If `from` IS authorized under `SEIZE_HOLDER_POLICY`, the call reverts `AccountNotSeizable`. An account is seizable only when it is NOT authorized under this policy. -7. If `to` is NOT authorized under `SEIZE_RECEIVER_POLICY`, the call reverts `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)`. -8. If the balance of `from` is less than `amount`, the call reverts `InsufficientBalance`. -9. The call emits, in order: `Transfer(from, to, amount)`, then `Memo(caller, memo)`, then `Seized(caller, from, to, amount)`. - -`seizeWithMemo` is an admin operation. It skips the allowance check and all three transfer-side policies: `TRANSFER_SENDER_POLICY`, `TRANSFER_RECEIVER_POLICY`, and `TRANSFER_EXECUTOR_POLICY`. - -The `PausableFeature.SEIZE` vector pauses `seizeWithMemo` independently. Pausing `SEIZE` does not pause `burnBlocked` or transfer operations. Pausing `BURN` or `TRANSFER` does not pause seizing. - -Seize is a transfer, not a burn. The balance moves from `from` to `to`, and `totalSupply` is unchanged. This is the key behavioral difference from `burnBlocked`. `burnBlocked` sends the balance to `address(0)` and reduces supply. +Retained for back-compat. Continues to: +- Gate by `BURN_BLOCKED_ROLE` and `BURN` pause vector (not `SEIZE`) +- Read `TRANSFER_SENDER_POLICY` for blocked check (distinct from `SEIZE_HOLDER_POLICY`) +- Emit `Transfer(from, address(0), amount)` + `BurnedBlocked(caller, from, amount)` +- Reduce `totalSupply` -### Examples +**Prefer**: `seizeWithMemo(from, treasury, amount, memo)` followed by `burn(amount)` from `treasury` if you want supply destroyed. -**Before**: the block, burn, and mint workaround. This path is still available and is deprecated. +## Guarantees & Edge Cases -1. Configure `from` as blocked under `TRANSFER_SENDER_POLICY`. -2. Call `burnBlocked(from, amount)`. This burns the balance of `from` and is gated by `BURN_BLOCKED_ROLE`. -3. Call `mint(treasury, amount)`. This separately reissues the same amount and is gated by `MINT_ROLE`. +**Q: Does `seizeWithMemo` affect `totalSupply`?** +No. It is a transfer, not a burn. Balance moves `from` → `to`; `totalSupply` unchanged. -This path emits `Transfer(from, 0, amount)`, then `BurnedBlocked`, then `Transfer(0, treasury, amount)`. These are two independent operations. No single event ties the burn to the reissue. +**Q: Can I seize from an account that is not blocked under `TRANSFER_SENDER_POLICY`?** +Yes. `seizeWithMemo` uses `SEIZE_HOLDER_POLICY`, which is completely independent. An account can be seizable even if it is not blocked for transfers. -**After**: the new single call. +**Q: What happens if `SEIZE_RECEIVER_POLICY` is unset?** +Defaults to always-allow. Seizure can send to any destination (e.g., an unallowlisted treasury). -1. Configure `from` as NOT authorized under `SEIZE_HOLDER_POLICY`. This marks the account as seizable. -2. Call `seizeWithMemo(from, treasury, amount, memo)`. This is gated by `SEIZE_ROLE`. +**Q: What if `SEIZE_HOLDER_POLICY` is unset?** +Unset = always-allow. Under inverted membership, this means **no account is seizable** until the policy is configured. This is intentional: seizure is opt-in. -This path emits `Transfer(from, treasury, amount)`, then `Memo(caller, memo)`, then `Seized(caller, from, treasury, amount)`. +**Q: Does `seizeWithMemo` check `transferFrom` allowance?** +No. It is an admin operation that skips all transfer-side policies and allowance. -## Design Decisions & Alternatives Considered +**Q: Can I call `seizeWithMemo` if Cobalt is not active?** +No. The write path is gated by `ActivationRegistry`. The call reverts `Unauthorized` until Cobalt activates. Read-only calls (e.g., `SEIZE_ROLE()`, `SEIZE_HOLDER_POLICY()`) work regardless. -**Final shipped shape**: `seizeWithMemo` and `burnBlocked` use fully independent policy slots and pause vectors. +**Q: Is there a `burnBlockedWithMemo` variant?** +No. The shared seize-policy approach was rejected because burning and seizing have different effects: `burnBlocked` destroys supply, `seizeWithMemo` reassigns balances. -- `seizeWithMemo` uses the new `SEIZE_HOLDER_POLICY` for `from`, the new `SEIZE_RECEIVER_POLICY` for `to`, the new `SEIZE_ROLE`, and the new `PausableFeature.SEIZE`. -- `burnBlocked` retains `TRANSFER_SENDER_POLICY`, `BURN_BLOCKED_ROLE`, and the `BURN` pause vector, all unchanged. +**Q: What if I want the old `burnBlocked` + `mint` supply-destroying behavior?** +Call `seizeWithMemo(from, treasury, amount, memo)` then `burn(amount)` from `treasury`. This is two calls instead of the old three, and the `Seized` event ties the two operations together. -**Shared seize-policy approach (rejected)**: A shared policy for burn and seize was rejected because the two operations have different effects. `burnBlocked` destroys supply, and `seizeWithMemo` reassigns balances. `burnBlocked` therefore remains independent, and no `burnBlockedWithMemo` variant is included. - -**Function naming (`transferFromBlockedWithMemo` rejected)**: The name `transferFromBlockedWithMemo` was also considered. The name `seizeWithMemo` was chosen because it explicitly defines the intent. +**Q: Any storage migration?** +No. The new `seizePolicyIds` slot is additive. `burnBlocked` storage is untouched. ## Migration Steps -This change is backwards-compatible and introduces no breaking changes: - -- `burnBlocked` continues to work unchanged. No action is required if you do not need seize behavior yet. -- All existing selectors, events, and errors remain dialable. - -To adopt `seizeWithMemo`, complete the following steps: - -1. Grant `SEIZE_ROLE` to the account or accounts that should be able to seize. If no account holds `SEIZE_ROLE`, no one can seize. -2. Configure `SEIZE_HOLDER_POLICY` so that the accounts you want to be seizable are NOT authorized under it. If you configure no policy, the unset slot means always-allow, and no account is seizable. -3. Optionally, configure `SEIZE_RECEIVER_POLICY` to restrict where seized funds may land. The unset slot defaults to always-allow, so an unallowlisted treasury still works. - -To reproduce the destroy-supply outcome of `burnBlocked` with seize, take two steps. `seizeWithMemo` alone does not reduce `totalSupply`. First, seize to a treasury or self address. Then call `burn(amount)` from that address if you want the supply destroyed. +1. **No action required** if you don't need seize behavior yet. `burnBlocked` works unchanged. +2. **To adopt `seizeWithMemo`**: + a. Grant `SEIZE_ROLE` to the account(s) that should seize. With no holders, no one can seize. + b. Configure `SEIZE_HOLDER_POLICY` so the accounts you want seizable are **NOT authorized**. With no policy configured (unset = always-allow), no account is seizable. + c. Optionally configure `SEIZE_RECEIVER_POLICY` to restrict where seized funds may land. Unset defaults to always-allow. +3. **To reproduce `burnBlocked`'s destroy-supply outcome**: `seizeWithMemo(from, treasury, amount, memo)` then `burn(amount)` from `treasury`. -No storage migration is required. The storage and behavior of `burnBlocked` are untouched by this change. +All existing selectors, events, and errors remain dialable. No breaking changes. No storage migration. \ No newline at end of file From 2e9befb62bea30c8e54ebf057dbf87c1ecf5dde5 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 19 Aug 2026 11:14:20 -0400 Subject: [PATCH 10/21] changelog: add seize surface + burnBlocked deprecation for Cobalt (technical-document format) Adds 02_Cobalt_B20_seize.md with the full technical specification generated from the point-form template via /technical-document skill. Includes Interface Changes with selector/topic0 table, trimmed code snippet, Behavioural Changes with execution flow, Examples before/after, Design Decisions, and Migration Steps. Co-authored-by: OpenCode Co-Authored-By: Claude --- changelog/02_Cobalt_B20_seize.md | 207 +++++++++++++++++++------------ 1 file changed, 130 insertions(+), 77 deletions(-) diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index 95bb4a2..3d2f4d7 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -1,4 +1,4 @@ -# Seize Surface + `burnBlocked` Deprecation +# Seize Policy - **Feature Name**: seize - **Start Date**: 2026-08-17 @@ -7,117 +7,170 @@ ## Summary -This change adds `seizeWithMemo` to the shared `IB20` interface (inherited by both B20 Asset and B20 Stablecoin). The Cobalt hardfork activates this feature. Read calls against `IB20` are callable regardless of activation status; the `seizeWithMemo` write path is gated by `ActivationRegistry` and will revert with `Unauthorized` if Cobalt is not active. +This change adds the `seizeWithMemo` function to the shared `IB20` interface. Both B20 Asset and B20 Stablecoin inherit this function with no variant-specific logic. -The original workflow to seize an asset required three steps: block the sender under `TRANSFER_SENDER_POLICY`, call `burnBlocked` (which burns supply), then call `mint` to reissue to the seizure account. `seizeWithMemo` replaces this with a single administrative call that reassigns the balance directly from `from` to `to`. Total supply is unchanged. +The original workflow to seize an asset required three steps: block the sender, call `burnBlocked`, then mint to the seizure account. This workflow destroys and then reissues supply. The `seizeWithMemo` function replaces this with a single administrative call that reassigns the balance directly from the source account to the destination account. -`burnBlocked` remains callable but is now **deprecated-dialable**. Its behavior, selector (`0xec0cf3dc`), error (`AccountNotBlocked`), and event (`BurnedBlocked`) are unchanged. No removal date is committed. +The `burnBlocked` function remains available but is now deprecated. It continues to burn blocked assets through the existing flow. -**Compatibility promise**: all existing selectors, events, and errors remain dialable. No breaking changes. +## Motivation -## Mapping Table +Burn functionality and seize functionality must be explicitly distinct because they may be gated on different policies. Compliant asset issuers require freeze-and-seize models. -| Old symbol | New symbol | Status | Reason | -|------------|------------|--------|--------| -| — | `seizeWithMemo(address,address,uint256,bytes32)` | new | Single-call admin seize; selector `0xf916d81b` | -| — | `SEIZE_ROLE()` | new | Role constant; selector `0x3c7e9ba5` | -| — | `SEIZE_HOLDER_POLICY()` | new | Policy scope; selector `0xb279d311` — gates who is seizable (inverted membership) | -| — | `SEIZE_RECEIVER_POLICY()` | new | Policy scope; selector `0xb31da27f` — gates seizure destination | -| — | `event Seized(address,address,address,uint256)` | new | Emitted after `Transfer` + `Memo`; topic0 `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` | -| — | `error AccountNotSeizable(address)` | new | Selector `0x91dbbc8d` | -| — | `PausableFeature.SEIZE` | new | Dedicated pause vector, independent of `BURN` | -| `burnBlocked(address,uint256)` | `burnBlocked(address,uint256)` | deprecated-dialable | Unchanged; retains `BURN_BLOCKED_ROLE`, `TRANSFER_SENDER_POLICY`, `BURN` pause vector | +Currently, the system achieves seizure through a workaround that uses `burnBlocked` to burn the asset and `mint` to reissue it to the seizure account. This workaround has three steps: -## New at Cobalt (adopt these) +1. Configure the `from` account as blocked under `TRANSFER_SENDER_POLICY` (the same policy that `burnBlocked` reads today — verified against `IB20.sol`) +2. Burn assets from the `from` account — this affects token supply +3. Mint assets to the seizure account -### `seizeWithMemo` +This process emits a `BurnedBlocked` event and a `Transfer` event for the mint. It does not emit an event that indicates a seizure occurred. -```solidity -function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external; -``` +The main issues with this approach are: + +- The events emitted are misleading for burning and minting operations +- The token supply is affected when assets are burned -**Caller**: must hold `SEIZE_ROLE`. -**Policies checked** (in order): -1. `SEIZE_HOLDER_POLICY` — `from` must be **not authorized** (inverted membership). Reverts `AccountNotSeizable` if authorized. -2. `SEIZE_RECEIVER_POLICY` — `to` must be authorized. Reverts `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` if not. Unset slot defaults to always-allow. +The proposed `seizeWithMemo` function replaces this workaround with a direct transfer to the seizure account that emits a `Seized` event. The new flow: -**Policies NOT checked**: `TRANSFER_SENDER_POLICY`, `TRANSFER_RECEIVER_POLICY`, `TRANSFER_EXECUTOR_POLICY`, and allowance. +1. Add a policy to define which accounts are seizable +2. Call the `seizeWithMemo` function — this emits a `Seized` event -**Events emitted** (in order): -1. `Transfer(from, to, amount)` -2. `Memo(caller, memo)` -3. `Seized(caller, from, to, amount)` +This change makes seizure functionality and burning functionality explicitly distinct. -**Reverts** (canonical order): -- `ContractPaused(SEIZE)` — if `SEIZE` is paused -- `AccessControlUnauthorizedAccount` — if caller lacks `SEIZE_ROLE` -- `InvalidReceiver` — if `to == address(0)` or `from == to` -- `InvalidSender` — if `from == address(0)` -- `AccountNotSeizable` — if `from` authorized under `SEIZE_HOLDER_POLICY` -- `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` — if `to` not authorized -- `InsufficientBalance` — if `from` balance < `amount` +## Background -### Pause vector +### B20 Asset and B20 Stablecoin -`PausableFeature.SEIZE` is a dedicated pause vector. It is independent of `BURN` (which gates `burnBlocked`, `burn`, `burnWithMemo`). Call `pause([SEIZE])` / `unpause([SEIZE])` with `PAUSE_ROLE` / `UNPAUSE_ROLE`. +B20 Asset and B20 Stablecoin are native tokens launched on Base. They extend ERC-20 with compliance checks for transfers and roles for specific methods. Roles follow the OpenZeppelin role-based model. Compliance checks execute before and after specific functions as defined in the specification. These checks rely on the policy ID defined in the contract and are tested for compliance against a contract address. The contracts have built-in roles and access controls. Functions are gated using the Policy Registry and policy functionality. -### Policy scopes +### Policy Registry -- `SEIZE_HOLDER_POLICY` — evaluated against `from`. Inverted: `from` is seizable **only when NOT authorized**. Unset = always-allow → no account is seizable until configured. -- `SEIZE_RECEIVER_POLICY` — evaluated against `to`. Standard: `to` must be authorized. Unset = always-allow → treasury need not be allowlisted. +The Policy Registry is a singleton precompile contract. Its responsibility is to return `isAuthorized(policyId, account)`. B20 uses the Policy Registry to gate operations by passing the stored policy ID and the account to check. -These two policy IDs are packed into a new `seizePolicyIds` storage slot (additive; `burnBlocked` storage unchanged). +## Specs -### `burnBlocked` (deprecated but supported) +### Interface Changes + +The following changes are made to the interface. Old-to-new mappings for selectors and topic0 values are verified via `cast sig`/`cast keccak` against `src/interfaces/IB20.sol` and `src/lib/B20Constants.sol`. + +| Symbol | Selector / Topic0 | Status | Notes | +|--------|-------------------|--------|-------| +| `burnBlocked(address,uint256)` | `0xec0cf3dc` | **deprecated-dialable** | Unchanged behavior, no removal date committed | +| `AccountNotBlocked(address)` | `0x64a5cb46` | unchanged | Still exclusive to `burnBlocked` | +| `BurnedBlocked(address,address,uint256)` | `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` | unchanged | Still exclusive to `burnBlocked` | +| `seizeWithMemo(address,address,uint256,bytes32)` | `0xf916d81b` | NEW | Single-call admin seize, reassigns `from`'s balance to `to` | +| `SEIZE_ROLE()` | `0x3c7e9ba5` | NEW | Role value: `0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432` | +| `SEIZE_HOLDER_POLICY()` | `0xb279d311` | NEW | Value: `0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2` — gates who is seizable; inverted membership (seizable if NOT authorized) | +| `SEIZE_RECEIVER_POLICY()` | `0xb31da27f` | NEW | Value: `0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d` — gates seizure destination; unset slot = always-allow | +| `Seized(address indexed caller, address indexed from, address indexed to, uint256 amount)` | `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` | NEW | Seized event | +| `AccountNotSeizable(address)` | `0x91dbbc8d` | NEW | New error | +| `PausableFeature.SEIZE` | — | NEW | Dedicated pause vector, independent of `BURN` | + +The following code snippet shows the net additions to the `IB20` interface surface. The `burnBlocked` function and its associated error/event remain but are marked deprecated. ```solidity +// PausableFeature enum — SEIZE appended +enum PausableFeature { + TRANSFER, + MINT, + BURN, + SEIZE +} + +error AccountNotSeizable(address account); + +event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount); + +function SEIZE_ROLE() external view returns (bytes32); + +function SEIZE_HOLDER_POLICY() external view returns (bytes32); +function SEIZE_RECEIVER_POLICY() external view returns (bytes32); + +/// @notice Seizes `amount` of `from`'s balance and reassigns it to `to` in a single admin operation. +/// Emits `Transfer`, then `Memo`, then `Seized`. +function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external; + +// DEPRECATED — retained for back-compat function burnBlocked(address from, uint256 amount) external; ``` -Retained for back-compat. Continues to: -- Gate by `BURN_BLOCKED_ROLE` and `BURN` pause vector (not `SEIZE`) -- Read `TRANSFER_SENDER_POLICY` for blocked check (distinct from `SEIZE_HOLDER_POLICY`) -- Emit `Transfer(from, address(0), amount)` + `BurnedBlocked(caller, from, amount)` -- Reduce `totalSupply` +### Behavioural Changes + +#### New function: `seizeWithMemo` + +The `seizeWithMemo(from, to, amount, memo)` function executes the following steps: -**Prefer**: `seizeWithMemo(from, treasury, amount, memo)` followed by `burn(amount)` from `treasury` if you want supply destroyed. +1. Check that `SEIZE` is not paused, else revert `ContractPaused(SEIZE)`. +2. Check that the caller has `SEIZE_ROLE`, else revert `AccessControlUnauthorizedAccount`. +3. Reject zero or self destinations, else revert `InvalidReceiver`. +4. Reject zero source, else revert `InvalidSender`. +5. Require `from` to be not authorized under `SEIZE_HOLDER_POLICY`, else revert `AccountNotSeizable`. +6. Require `to` to be allowed by `SEIZE_RECEIVER_POLICY`, else revert `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)`. +7. Check balance, else revert `InsufficientBalance`. +8. Emit `Transfer`, then `Memo`, then `Seized`. -## Guarantees & Edge Cases +The function does not check allowance on the three transfer-side policies: `TRANSFER_SENDER_POLICY`, `TRANSFER_RECEIVER_POLICY`, or `TRANSFER_EXECUTOR_POLICY`. -**Q: Does `seizeWithMemo` affect `totalSupply`?** -No. It is a transfer, not a burn. Balance moves `from` → `to`; `totalSupply` unchanged. +A dedicated pause vector `PausableFeature.SEIZE` pauses `seizeWithMemo`. When paused, the function returns `ContractPaused(SEIZE)`. -**Q: Can I seize from an account that is not blocked under `TRANSFER_SENDER_POLICY`?** -Yes. `seizeWithMemo` uses `SEIZE_HOLDER_POLICY`, which is completely independent. An account can be seizable even if it is not blocked for transfers. +Seizure is a transfer, not a burn. The balance moves from `from` to `to` and `totalSupply` remains unchanged. This is the key behavioral difference from `burnBlocked`, which sends to `address(0)` and reduces supply. -**Q: What happens if `SEIZE_RECEIVER_POLICY` is unset?** -Defaults to always-allow. Seizure can send to any destination (e.g., an unallowlisted treasury). +#### Storage layout change -**Q: What if `SEIZE_HOLDER_POLICY` is unset?** -Unset = always-allow. Under inverted membership, this means **no account is seizable** until the policy is configured. This is intentional: seizure is opt-in. +A packed `seizePolicyIds` slot is added for `SEIZE_HOLDER_POLICY` and `SEIZE_RECEIVER_POLICY`. This change is additive. The `burnBlocked` storage remains unchanged. -**Q: Does `seizeWithMemo` check `transferFrom` allowance?** -No. It is an admin operation that skips all transfer-side policies and allowance. +### Examples -**Q: Can I call `seizeWithMemo` if Cobalt is not active?** -No. The write path is gated by `ActivationRegistry`. The call reverts `Unauthorized` until Cobalt activates. Read-only calls (e.g., `SEIZE_ROLE()`, `SEIZE_HOLDER_POLICY()`) work regardless. +#### Before (old block+burn+mint workaround, still available, deprecated) -**Q: Is there a `burnBlockedWithMemo` variant?** -No. The shared seize-policy approach was rejected because burning and seizing have different effects: `burnBlocked` destroys supply, `seizeWithMemo` reassigns balances. +1. Configure `from` as blocked under `TRANSFER_SENDER_POLICY` +2. Call `burnBlocked(from, amount)` — burns `from`'s balance, gated by `BURN_BLOCKED_ROLE` +3. Call `mint(treasury, amount)` — separately reissues the same amount, gated by `MINT_ROLE` +4. Emits `Transfer(from, 0, amount)` + `BurnedBlocked` + `Transfer(0, treasury, amount)` — two independent operations, no single event ties the burn to the reissue -**Q: What if I want the old `burnBlocked` + `mint` supply-destroying behavior?** -Call `seizeWithMemo(from, treasury, amount, memo)` then `burn(amount)` from `treasury`. This is two calls instead of the old three, and the `Seized` event ties the two operations together. +#### After (new, single call) -**Q: Any storage migration?** -No. The new `seizePolicyIds` slot is additive. `burnBlocked` storage is untouched. +1. Configure `from` as NOT authorized under `SEIZE_HOLDER_POLICY` (i.e. blocked) +2. Call `seizeWithMemo(from, treasury, amount, memo)` — gated by `SEIZE_ROLE` +3. Emits `Transfer(from, treasury, amount)` → `Memo(caller, memo)` → `Seized(caller, from, treasury, amount)` + +## Design Decisions & Alternatives Considered + +### Final shipped shape + +The `seizeWithMemo` and `burnBlocked` functions use fully independent policy slots and pause vectors. + +- `seizeWithMemo` uses the new `SEIZE_HOLDER_POLICY` (for `from`) and `SEIZE_RECEIVER_POLICY` (for `to`), the new `SEIZE_ROLE`, and the new `PausableFeature.SEIZE`. +- `burnBlocked` retains `TRANSFER_SENDER_POLICY`, `BURN_BLOCKED_ROLE`, and the `BURN` pause vector unchanged. + +Seize operations are rare, so the reserved lane in the transfer packed policy slot was not reused for seizure. That lane is kept open for a possible future transfer-side optimization where another hot-path transfer policy could be packed into the existing transfer slot without adding a second `SLOAD`. Because seizure is a cold-path / rare-path operation, it gets its own packed `seizePolicyIds` slot. + +### Function Naming Alternatives + +The shared seize-policy approach was rejected because burning and seizing have different effects: `burnBlocked` destroys supply, while `seizeWithMemo` reassigns balances. `burnBlocked` therefore remains independent, and no `burnBlockedWithMemo` variant is included. + +The name `transferFromBlockedWithMemo` was also considered. `seizeWithMemo` was chosen because it explicitly defines the intent. ## Migration Steps -1. **No action required** if you don't need seize behavior yet. `burnBlocked` works unchanged. -2. **To adopt `seizeWithMemo`**: - a. Grant `SEIZE_ROLE` to the account(s) that should seize. With no holders, no one can seize. - b. Configure `SEIZE_HOLDER_POLICY` so the accounts you want seizable are **NOT authorized**. With no policy configured (unset = always-allow), no account is seizable. - c. Optionally configure `SEIZE_RECEIVER_POLICY` to restrict where seized funds may land. Unset defaults to always-allow. -3. **To reproduce `burnBlocked`'s destroy-supply outcome**: `seizeWithMemo(from, treasury, amount, memo)` then `burn(amount)` from `treasury`. +### Backwards-compatible + +`burnBlocked` continues to work unchanged. No action is required if you do not need seizure behavior yet. + +### No breaking changes + +All existing selectors, events, and errors remain dialable. + +### To adopt `seizeWithMemo` + +1. Grant `SEIZE_ROLE` to the account(s) that should be able to seize. With no `SEIZE_ROLE` holders, no one can seize. +2. Configure `SEIZE_HOLDER_POLICY` so the accounts you want seizable are NOT authorized under it. With no policy configured (unset = always-allow), no account is seizable. +3. Optionally configure `SEIZE_RECEIVER_POLICY` to restrict where seized funds may land. Unset defaults to always-allow (for example, an unallowlisted treasury still works). + +### To reproduce `burnBlocked`'s destroy-supply outcome with seizure + +`seizeWithMemo` alone does not reduce `totalSupply`. Seize to a treasury or self address, then call `burn(amount)` from that address if you want the supply destroyed. + +### No storage migration -All existing selectors, events, and errors remain dialable. No breaking changes. No storage migration. \ No newline at end of file +`burnBlocked`'s storage and behavior are untouched by this change. \ No newline at end of file From c615e6976d81ed5d1d74a78e5de4acda540f163a Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 19 Aug 2026 11:19:30 -0400 Subject: [PATCH 11/21] feat: init --- .gitignore | 3 +- changelog/02_Cobalt_B20_seize.md | 2 +- changelog/AGENTS.md | 20 --- .../SEIZE_POLICY_TEMPLATE.md | 122 +++++++++++------- .../TEMPLATE_COMPOSITE_POLICY.md | 0 5 files changed, 77 insertions(+), 70 deletions(-) rename {changelog => changelog_template}/SEIZE_POLICY_TEMPLATE.md (57%) rename {changelog => changelog_template}/TEMPLATE_COMPOSITE_POLICY.md (100%) diff --git a/.gitignore b/.gitignore index 5372477..534b1a3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ __pycache__/ *.swp *.swo .idea/ -.vscode/ \ No newline at end of file +.vscode/ +.changelog_template/ \ No newline at end of file diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index 3d2f4d7..f2f51ac 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -41,7 +41,7 @@ This change makes seizure functionality and burning functionality explicitly dis ### B20 Asset and B20 Stablecoin -B20 Asset and B20 Stablecoin are native tokens launched on Base. They extend ERC-20 with compliance checks for transfers and roles for specific methods. Roles follow the OpenZeppelin role-based model. Compliance checks execute before and after specific functions as defined in the specification. These checks rely on the policy ID defined in the contract and are tested for compliance against a contract address. The contracts have built-in roles and access controls. Functions are gated using the Policy Registry and policy functionality. +B20 Asset and B20 Stablecoin are native tokens launched on Base. They extend ERC-20 with compliance checks for transfers and roles for specific methods. Roles follow the OpenZeppelin role-based model. Compliance checks execute before and after specific functions as defined in the specification. These checks rely on the policy ID's defined in the contract and are tested for compliance against a contract address. The contracts have built-in roles and access controls. Functions are gated using the Policy Registry and policy functionality. ### Policy Registry diff --git a/changelog/AGENTS.md b/changelog/AGENTS.md index 2d62f06..d483a7c 100644 --- a/changelog/AGENTS.md +++ b/changelog/AGENTS.md @@ -28,26 +28,6 @@ Each file name has four parts: Never rename or renumber a shipped entry. Once a hardfork activates on-chain, its entries are frozen; only add new files for new hardforks or features. -## Writing an entry - -Keep entries minimal and migration-focused. Don't restate unchanged behavior. A good entry has: - -1. An audience statement and a one-paragraph summary that leads with the compatibility promise: - what still works, what's deprecated but still dialable, and what's new. State plainly whether - the fork is live yet. -2. A mapping table: old symbol, new symbol, status (`deprecated-dialable`, `renamed`, or `new`), and - a one-line reason. Cover functions, events, and errors, with real signatures and selectors. -3. A "New at `` (adopt these)" section describing the new surface and its lifecycle. -4. A guarantees and edge cases section: a short Q&A covering what a careful integrator would ask, - each answered with the precise on-chain behavior. - -Before you cite a function signature, event signature or topic0, error selector, 4-byte function -selector, or ERC-165 interface ID, verify it against the source (`src/interfaces/**` and the -base/base ABIs). Don't rely on memory, and don't trust a prior entry's numbers even for a symbol -you believe is unchanged. Check selectors with `cast sig` or `cast keccak`. - -If you can't verify a value, flag it in the entry or ask, rather than shipping a plausible-looking -but unverified selector. ## Indexing a new entry diff --git a/changelog/SEIZE_POLICY_TEMPLATE.md b/changelog_template/SEIZE_POLICY_TEMPLATE.md similarity index 57% rename from changelog/SEIZE_POLICY_TEMPLATE.md rename to changelog_template/SEIZE_POLICY_TEMPLATE.md index 3af9287..a4ae82b 100644 --- a/changelog/SEIZE_POLICY_TEMPLATE.md +++ b/changelog_template/SEIZE_POLICY_TEMPLATE.md @@ -15,13 +15,9 @@ - Add `seizeWithMemo` to the shared `IB20` interface — inherited by both B20 Asset and B20 Stablecoin, no variant-specific logic -- Original flow to seize an asset was block + `burnBlocked` + mint (destroy then reissue); - `seizeWithMemo` replaces this with a single admin call that reassigns the balance directly -- **Compatibility promise**: `burnBlocked` remains fully dialable with its existing behavior — - no removal is planned (permanent, not a phased deprecation; BOP-471, the re-merge ticket, was - canceled). `seizeWithMemo` is new, additive surface; nothing existing is broken -- Audience: token issuers/compliance integrators currently using the block+burn+mint workaround, - and anyone integrating against the deprecated `burnBlocked` path +- Original flow to seize an asset was 3 steps block + `burnBlocked` + mint (destroy then reissue); +- `seizeWithMemo` looks to replaces this with a single admin call that reassigns the balance directly +- `burnBlocked` burning blocked assets through burn blocked is still a flow, now depreceated but supported. --- @@ -31,22 +27,23 @@ > State the problem this change solves, why the current state is insufficient, and brief context > on prerequisites if needed (full detail lives in Background). -- Compliant asset issuers need freeze+seize models. Today we achieve this with - block+burnBlocked+mint (destroy then reissue). `seizeWithMemo` replaces that workaround with a - direct balance reassignment (`from` → `to`) that neither burns nor mints — `totalSupply` is - unchanged — and emits a single, purpose-built `Seized` event - Burn functionality should be explicitly distinct from seize; they may be gated on different policies -- The current functionality is 3 step to sezie +- Compliant asset issuers need freeze+seize models. +- Today we achieve this with a work arround, using burnBlock to burn the asset and mint to the seize account + - The functionality is 3 step to sezie - Configure `from` as blocked under `TRANSFER_SENDER_POLICY` (the same policy `burnBlocked` reads today — verified against `IB20.sol`) - Burn assets --> Effectes token supply - Mint assets to seieze - Emits a burn and a Mint - Does not indicate a seize occured -- Proposed functionlaity + - Main issue with this is two, the events emitted are misleading for burning, and minting + - The token supply is effected when data is bruned +- Proposed functionlaity `seizeWithMemo` replaces that workaround with a direct transfer to the seize account emitting a seize event - Add in policy to be seized from - use the seizeWithMemo function --> emits seize event + - This, makes sezieng functionality and burning functionality explicityl distinct --- @@ -54,14 +51,15 @@ > Link to or summarize concepts the reader needs before understanding the Specs: prior art, > relevant EIPs/ERCs, existing patterns, and any domain-specific terms used in this document. -- B20 Asset and B20 Stablecoin - - Natve token launched on B20 - - Has in built roles and acess controls - - Functions are gated using policy registry and policy functionality -- Policy Registry - - A singleton precompile contract, whose responsibility is to return `isAuthorized(policyId, - account)` (verified against `IPolicyRegistry.sol` — not `isAllowed(address, true)`) - - Used by B20, gating operations by passing the stored policy ID and the account to check + +### B20 Asset and B20 Stablecoin + +B20 Asset and B20 Stablecoin are Base-native token contracts that extend ERC-20 with role-gated administrative functions and policy-gated operation checks. Each token stores fine-grained policy slots, keyed by operation and actor position, and consults the PolicyRegistry through `isAuthorized(policyId, account)` to decide whether a given account is allowed for that slot. For example, transfer flows can independently gate the sender, receiver, and executor, while other operations use their own dedicated policy slots, such as mint receiver and seize holder/receiver. This design separates access control from compliance logic: roles determine who may call privileged methods, and policy slots determine which accounts may participate in a given token operation. + +### Policy Registry + +The Policy Registry is a singleton precompile contract. Its responsibility is to return `isAuthorized(policyId, account)`. B20 uses the Policy Registry to gate operations by passing the stored policy ID and the account to check. + --- ## Specs @@ -75,13 +73,13 @@ > Selector / topic0 values. - Old → new mapping (selectors/topics verified via `cast sig`/`cast keccak` against `src/interfaces/IB20.sol` and `src/lib/B20Constants.sol`): - - `burnBlocked(address,uint256)` `0xec0cf3dc` — **deprecated-dialable**, unchanged + - Deprecated `burnBlocked(address,uint256)` `0xec0cf3dc` — **deprecated-dialable**, unchanged behavior, no removal date committed - - `error AccountNotBlocked(address)` `0x64a5cb46` — unchanged, still exclusive to - `burnBlocked` - - `event BurnedBlocked(address,address,uint256)` topic0 - `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` — unchanged, still - exclusive to `burnBlocked` + - `error AccountNotBlocked(address)` `0x64a5cb46` — unchanged, still exclusive to + `burnBlocked` + - `event BurnedBlocked(address,address,uint256)` topic0 + `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` — unchanged, still + exclusive to `burnBlocked` - NEW `seizeWithMemo(address,address,uint256,bytes32)` `0xf916d81b` — single-call admin seize, reassigns `from`'s balance to `to` - NEW `SEIZE_ROLE()` `0x3c7e9ba5` (role value `0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432`) @@ -93,35 +91,58 @@ topic0 `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` - NEW `error AccountNotSeizable(address)` `0x91dbbc8d` - NEW `PausableFeature.SEIZE` — dedicated pause vector, independent of `BURN` - - All function/error selectors, event topic0s, and role/policy hash values above re-verified - via `cast sig`/`cast keccak`/`cast sig-event` against `IB20.sol` and `B20Constants.sol` on - 2026-08-18 + +The following code snippet shows the net additions to the `IB20` interface surface. The `burnBlocked` function and its associated error/event remain but are marked deprecated. + +```solidity +// PausableFeature enum — SEIZE appended +enum PausableFeature { + TRANSFER, + MINT, + BURN, + SEIZE +} + +error AccountNotSeizable(address account); + +event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount); + +function SEIZE_ROLE() external view returns (bytes32); + +function SEIZE_HOLDER_POLICY() external view returns (bytes32); +function SEIZE_RECEIVER_POLICY() external view returns (bytes32); + +/// @notice Seizes `amount` of `from`'s balance and reassigns it to `to` in a single admin operation. +/// Emits `Transfer`, then `Memo`, then `Seized`. +function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external; + +// DEPRECATED — retained for back-compat +function burnBlocked(address from, uint256 amount) external; +``` ### Behavioural Changes > How execution flow differs from the previous version. Storage layout changes (new slots, moved > fields, packing changes). Gas cost implications if meaningful. -- `seizeWithMemo(from, to, amount, memo)` — verified exact guard/emission order, per - `MockB20.sol`'s reference implementation and `IB20.sol`'s natspec: - 1. `whenNotPaused(SEIZE)` — reverts `ContractPaused(SEIZE)` - 2. `onlyRole(SEIZE_ROLE)` — reverts `AccessControlUnauthorizedAccount` - 3. `to == address(0)` — reverts `InvalidReceiver` - 4. `from == address(0)` — reverts `InvalidSender` - 5. `from == to` — reverts `InvalidReceiver` (rejects self-seize) - 6. `from` must NOT be authorized under `SEIZE_HOLDER_POLICY` — else reverts - `AccountNotSeizable` - 7. `to` must be authorized under `SEIZE_RECEIVER_POLICY` — else reverts - `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` - 8. balance check — reverts `InsufficientBalance` - 9. emits, in order: `Transfer(from, to, amount)` → `Memo(caller, memo)` → `Seized(caller, from, to, amount)` -- Admin operation: skips allowance and all three transfer-side policies - (`TRANSFER_SENDER_POLICY`/`TRANSFER_RECEIVER_POLICY`/`TRANSFER_EXECUTOR_POLICY`) -- Dedicated pause vector: `PausableFeature.SEIZE` pauses `seizeWithMemo` independently. Pausing - `SEIZE` does not pause `burnBlocked` or transfer operations, and pausing `BURN` or `TRANSFER` - does not pause seizing. + +New function seizeWithMemo +- `seizeWithMemo(from, to, amount, memo)`: + 1. Check `SEIZE` is not paused, else revert `ContractPaused(SEIZE)`. + 2. Check caller has `SEIZE_ROLE`, else revert `AccessControlUnauthorizedAccount`. + 3. Reject zero or self destinations, else revert `InvalidReceiver`. + 4. Reject zero source, else revert `InvalidSender`. + 5. Require `from` to be not authorized under `SEIZE_HOLDER_POLICY`, else revert `AccountNotSeizable`. + 6. Require `to` to be allowed by `SEIZE_RECEIVER_POLICY`, else revert `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)`. + 7. Check balance, else revert `InsufficientBalance`. + 8. Emit `Transfer`, then `Memo`, then `Seized`. +- does not check allowance on all three transfer-side policies + (`TRANSFER_SENDER_POLICY`/`TRANSFER_RECEIVER_POLICY`/`TRANSFER_EXECUTOR_POLICY`) +- Dedicated pause vector: `PausableFeature.SEIZE` pauses `seizeWithMemo`. This means the function would return ContractPaused(SEIZE) - Seize is a transfer, not a burn: the balance moves `from` → `to` and `totalSupply` is unchanged. This is the key behavioral difference from `burnBlocked`, which sends to `address(0)` and reduces supply +- Storage layout change: add a packed `seizePolicyIds` slot for `SEIZE_HOLDER_POLICY` and + `SEIZE_RECEIVER_POLICY`. This is additive; `burnBlocked` storage remains unchanged. @@ -153,6 +174,11 @@ `PausableFeature.SEIZE`. - `burnBlocked` retains `TRANSFER_SENDER_POLICY`, `BURN_BLOCKED_ROLE`, and the `BURN` pause vector unchanged. + - Seize operations are rare, so the reserved lane in the transfer packed policy slot was not + reused for seize. We kept that lane open for a possible future transfer-side optimization, + where another hot-path transfer policy could be packed into the existing transfer slot without + adding a second `SLOAD`. Because seize is cold-path / rare path , it instead gets its own packed + `seizePolicyIds` slot. ### Function Naming Alternatives diff --git a/changelog/TEMPLATE_COMPOSITE_POLICY.md b/changelog_template/TEMPLATE_COMPOSITE_POLICY.md similarity index 100% rename from changelog/TEMPLATE_COMPOSITE_POLICY.md rename to changelog_template/TEMPLATE_COMPOSITE_POLICY.md From b0e6270ddf57711f14ec795e98c95c068afee49e Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 19 Aug 2026 11:19:44 -0400 Subject: [PATCH 12/21] changelog: update B20 Asset/Stablecoin background section Rewrites the Background section to precisely describe the policy-slot architecture: fine-grained slots keyed by operation and actor position, PolicyRegistry isAuthorized consultation, and the separation of access control (roles) from compliance logic (policy slots). Co-authored-by: OpenCode Co-Authored-By: Claude --- changelog/02_Cobalt_B20_seize.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index f2f51ac..184ac23 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -41,7 +41,7 @@ This change makes seizure functionality and burning functionality explicitly dis ### B20 Asset and B20 Stablecoin -B20 Asset and B20 Stablecoin are native tokens launched on Base. They extend ERC-20 with compliance checks for transfers and roles for specific methods. Roles follow the OpenZeppelin role-based model. Compliance checks execute before and after specific functions as defined in the specification. These checks rely on the policy ID's defined in the contract and are tested for compliance against a contract address. The contracts have built-in roles and access controls. Functions are gated using the Policy Registry and policy functionality. +B20 Asset and B20 Stablecoin are Base-native token contracts that extend ERC-20 with role-gated administrative functions and policy-gated operation checks. Each token stores fine-grained policy slots, keyed by operation and actor position, and consults the PolicyRegistry through `isAuthorized(policyId, account)` to decide whether a given account is allowed for that slot. For example, transfer flows can independently gate the sender, receiver, and executor, while other operations use their own dedicated policy slots, such as mint receiver and seize holder/receiver. This design separates access control from compliance logic: roles determine who may call privileged methods, and policy slots determine which accounts may participate in a given token operation. ### Policy Registry From 0ff4343501c1f55eed9c7a7096dfff738f146d66 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 19 Aug 2026 11:20:27 -0400 Subject: [PATCH 13/21] chore: renaming --- changelog/02_Cobalt_B20_seize.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index 184ac23..639ef1c 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -41,7 +41,7 @@ This change makes seizure functionality and burning functionality explicitly dis ### B20 Asset and B20 Stablecoin -B20 Asset and B20 Stablecoin are Base-native token contracts that extend ERC-20 with role-gated administrative functions and policy-gated operation checks. Each token stores fine-grained policy slots, keyed by operation and actor position, and consults the PolicyRegistry through `isAuthorized(policyId, account)` to decide whether a given account is allowed for that slot. For example, transfer flows can independently gate the sender, receiver, and executor, while other operations use their own dedicated policy slots, such as mint receiver and seize holder/receiver. This design separates access control from compliance logic: roles determine who may call privileged methods, and policy slots determine which accounts may participate in a given token operation. +B20 Asset and B20 Stablecoin are Base-native token contracts that extend ERC-20 with role-gated administrative functions and policy-gated operation checks. Each token stores fine-grained policy slots, keyed by operation and actor position, and consults the PolicyRegistry through `isAuthorized(policyId, account)` to decide whether a given account is allowed for that slot. For example, transfer flows can independently gate the sender, receiver, and executor, while other operations use their own dedicated policy ids, such as mint receiver and seize holder/receiver. This design separates access control from compliance logic: roles determine who may call privileged methods, and policy slots determine which accounts may participate in a given token operation. ### Policy Registry From 3279955a545bd400b4e7b00c046ea57919e021c9 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 19 Aug 2026 11:35:51 -0400 Subject: [PATCH 14/21] chore: update git ignore --- .gitignore | 2 +- .../TEMPLATE_COMPOSITE_POLICY.md | 226 ------------------ 2 files changed, 1 insertion(+), 227 deletions(-) delete mode 100644 changelog_template/TEMPLATE_COMPOSITE_POLICY.md diff --git a/.gitignore b/.gitignore index 534b1a3..97a2200 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,4 @@ __pycache__/ *.swo .idea/ .vscode/ -.changelog_template/ \ No newline at end of file +changelog_template/ \ No newline at end of file diff --git a/changelog_template/TEMPLATE_COMPOSITE_POLICY.md b/changelog_template/TEMPLATE_COMPOSITE_POLICY.md deleted file mode 100644 index a668203..0000000 --- a/changelog_template/TEMPLATE_COMPOSITE_POLICY.md +++ /dev/null @@ -1,226 +0,0 @@ -# ADR Template (Point Form) - -- **Feature Name**: composite_policy -- **Start Date**: 2026-08-17 -- **Authors**: Rayyan Alam -- **Title**: Composite Policies (UNION/INTERSECT) - ---- - -## Summary - -- Introduce two new `PolicyRegistry` policy types, `UNION` (OR) and `INTERSECT` (AND) — collectively - "composite policies" — that authorize by combining 2 to 4 existing simple policies -- For users utilizing the policy registry who may want to combine many policies together, without - flattening into one big list -- Composite policies are built up of only existing simple policies (`ALLOWLIST`/`BLOCKLIST`) — never - another composite, and never a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`) -- Ships at the **Cobalt** hardfork. Every mutating call (`createCompositePolicy`, `updateComposite`) - is gated by `ActivationRegistry`, same as every other mutating `PolicyRegistry` function — not - automatically enabled. Read-only calls (`isAuthorized`, `compositePolicyChildIds`, - `MIN_COMPOSITE_CHILD_POLICIES`, `MAX_COMPOSITE_CHILD_POLICIES`) are always callable regardless of - activation - ---- - -## Motivation - -> State the problem this change solves, why the current state is insufficient, and brief context -> on prerequisites if needed (full detail lives in Background). -- The policy registry currently supports simple boolean policies via `isAuthorized`, where each - policy independently returns true or false -- In practice, access control often requires combining multiple policies (e.g., KYC + ProUser, or - ProUser OR LifetimeUser) -- The current architecture requires a user to listen to changes on a different allowlist and - flatten into one, duplicating lists and requiring infra to keep them up to date -- We want to allow for policy reuse by creating composite policies which can reference other simple - policies - - should return "is authorized" by combining the results of other policies - - simplifies maintenance — updating one child policy updates every composite that references it - ---- - -## Background - -> Link to or summarize concepts the reader needs before understanding the Specs: prior art, -> relevant EIPs/ERCs, existing patterns, and any domain-specific terms used in this document. -- B20 Token - - B20 is a token precompile that uses policies to restrict operations such as - transfers, minting, and seizing. - - For each restricted operation, B20 stores a Policy Registry policy ID in a - dedicated policy scope. - - When an operation is attempted, B20 passes the relevant policy ID and account - address to the Policy Registry. If the account is not authorized, B20 - rejects the operation. - -- Policy Registry: - - Is a singleton precompile contract used by B20 tokens. - - It manages a list of policies; B20 tokens call `isAuthorized(policyId, account)` against a - policy ID stored on the relevant policy scope - - Currently used by B20 tokens for `TRANSFER_FROM`, `TRANSFER_TO`, `SEIZE_HOLDER` -- Simple policies: - - These are the non-composite policy types: `ALLOWLIST` / `BLOCKLIST` - - `ALLOWLIST` has a list of addresses; returns authorized `true` if the address is in the - list, `false` otherwise - - `BLOCKLIST` has a list of addresses; returns authorized `false` if the address is in the - list, `true` for all other addresses - ---- - -## Specs - -> Implementation details live here. Add more headings as needed (e.g., Storage Layout Changes, -> Deprecated Assets, Access Control, etc.). - - -### Interface Changes - -> New functions, events, errors (include signatures). Renamed or deprecated symbols (old → new). -> Selector / topic0 values (verified via `cast sig` or `cast keccak`). - -- Introduce 2 new values in the `PolicyType` enum - - `UNION = 2` — authorized if *any* child policy authorizes the account (OR) - - `INTERSECT = 3` — authorized only if *every* child policy authorizes the account (AND) -- New function `createCompositePolicy(admin, policyType, childPolicyIds)` - - `childPolicyIds` must be between 2 and 4 entries (`MIN_COMPOSITE_CHILD_POLICIES` / - `MAX_COMPOSITE_CHILD_POLICIES`) - - Every child must be an *existing simple* policy (`ALLOWLIST`/`BLOCKLIST`) — never another - composite, never a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`) - - Canonical revert order: `ZeroAddress` (admin) → `IncompatiblePolicyType` (policyType not - UNION/INTERSECT) → `ChildPoliciesOutsideOfRange` (count not in `[2,4]`) → `PolicyNotFound` - (a child doesn't exist, checked as one pass over the whole set) → `InvalidChildPolicy` (a - child is itself composite/sentinel, checked as a second pass) - - Emits, in order: `PolicyCreated(policyId, creator, policyType)`, - `PolicyAdminUpdated(policyId, address(0), admin)`, `CompositePolicyUpdated(policyId, creator, - childPolicyIds)` -- New function `updateComposite(policyId, childPolicyIds)` - - Full replacement of the child set — there's no partial-update or clear-the-list operation - - Same child-validity rules as `createCompositePolicy` (existing simple policies only, 2-4 of - them) - - Canonical revert order: `PolicyNotFound` (composite itself doesn't exist) → - `IncompatiblePolicyType` (`policyId` is a simple policy) → `Unauthorized` (caller isn't the - current admin — fires before the count check) → `ChildPoliciesOutsideOfRange` → - `PolicyNotFound` (a new child doesn't exist) → `InvalidChildPolicy` - - Emits only `CompositePolicyUpdated(policyId, updater, childPolicyIds)` — no - `PolicyAdminUpdated`, since admin doesn't change -- Existing function, new revert path: `createPolicy` and `createPolicyWithAccounts` (both already - live on Beryl) now also revert `IncompatiblePolicyType` when `policyType` is `UNION`/`INTERSECT` — - a previously-unreachable path, since those enum values didn't exist before Cobalt - - -### Behavioural Changes - -> How execution flow differs from the previous version. Storage layout changes (new slots, moved -> fields, packing changes). Gas cost implications if meaningful. -- A composite policy ID is passed to a B20 policy slot exactly like a simple policy ID — B20 needs - **zero code changes**, since it stores policy slots as an opaque `uint64` and calls - `isAuthorized` generically -- `isAuthorized` on a composite is live and short-circuiting, not a snapshot: - - Reads each child's *current* membership on every call — no snapshot from creation or the - last `updateComposite` - - `UNION` short-circuits `true` on the first authorizing child - - `INTERSECT` short-circuits `false` on the first non-authorizing child - - Recursion never exceeds depth 1, because every child is validated to be a simple policy at - write time — a composite's children can never themselves be composites -- Child order affects gas, never the outcome: - - `UNION`/`INTERSECT` are commutative, so reordering `childPolicyIds` never changes whether an - account is authorized - - It only shifts where the short-circuit lands — put the child most likely to short-circuit - first (broadest ALLOWLIST for `UNION`, tightest BLOCKLIST for `INTERSECT`) to save gas -- Duplicate child IDs are allowed. The registry neither sorts nor deduplicates the stored child - list — deduplicating would cost extra gas on every write for a set already capped at 4 entries, - for little value; `UNION`/`INTERSECT` are idempotent under duplicates anyway -- A composite can never shrink below 2 children via `updateComposite` — it enforces the same - `[2,4]` range as creation, so there's no path to an empty or undersized composite -- If a child policy's admin renounces, the parent composite keeps working: `renounceAdmin` only - clears the child's 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 -- State changes - - New state: `mapping(uint64 policyId => uint64[] childPolicyIds) children`, at **offset 4** - within the `base.policy_registry` ERC-7201 namespace (not a literal EVM slot 4) - - Reused state: one shared global counter (`nextCounter`) across simple and composite - policies, starting at 2 (`0` and `1` are reserved for `ALWAYS_ALLOW`/`ALWAYS_BLOCK`). A - composite policy ID encodes `PolicyType` in the top byte and the next available counter - value in the low 56 bits — the same encoding scheme as simple policies, not a separate - counter - - -### Examples - -- **Before (simple policy)**: - - Assign one existing policy directly to a B20 policy scope - - `b20.updatePolicy(TRANSFER_SENDER_POLICY, allowlistPolicyId)` - - Only accounts in `allowlistPolicyId` can transfer - -- **After (composite policy)**: - - Assume existing simple policies: `employeesPolicyId` (ALLOWLIST), `approvedRegionPolicyId` (ALLOWLIST) - - Create a UNION composite: - - `policyRegistry.createCompositePolicy(admin, UNION, [employeesPolicyId, approvedRegionPolicyId])` - - Emits: `PolicyCreated(policyId, admin, UNION)` + `PolicyAdminUpdated(policyId, 0, admin)` + - `CompositePolicyUpdated(policyId, admin, [children])` - - Assign to B20: `b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId)` - - B20 has no composite-specific logic — it passes the policy ID to the registry as usual - -- **Updating a composite**: - - `policyRegistry.updateComposite(compositePolicyId, [employeesPolicyId, trustedPartnersPolicyId])` - - Emits: `CompositePolicyUpdated(policyId, admin, [newChildren])` - - B20 continues using the same policy ID — no token-side update required - - Future authorization checks use the new child set immediately (live evaluation, no snapshot) - ---- - -## Design Decisions & Alternatives Considered - -- **Decision**: Two explicit policy types (`UNION`, `INTERSECT`) with a single `createCompositePolicy` function and full-replacement `updateComposite` - -- **Alternative 1: One generic COMPOSITE type** - - Store a separate operator (AND, OR, NOT, XOR) in composite storage - - Rejected: - - Requires storing both "composite" flag and the operator - - Adds storage reads or more complicated ID encoding - - Unnecessary complexity before there's a requirement for NOT, XOR, or nested expressions - - Generic boolean expressions create a larger gas and audit surface - -- **Alternative 2: Token-level policy groups** - - Keep Policy Registry unchanged; have each B20 token store multiple policy IDs + an operator - - Rejected: - - Composite policies would not be reusable entities - - Requires changes across B20, token variants, factories, and token hot paths - - Does not support sharing one composite policy across multiple tokens - - Spreads complexity across more contracts - -- **Alternative 3: Incremental child updates** - - Provide `addCompositeOperand` / `removeCompositeOperand` functions - - Rejected: - - Child list is capped at 4 entries - - Dynamic-array mutation requires swap/remove, length, and deduplication logic - - Full replacement is simpler and atomic - - Caller can resend the complete list at low cost - -- **Alternative 4: Separate creator functions** - - Use `createUnionPolicy` and `createIntersectPolicy` - - Rejected: - - Doubles the creation API surface - - A single `createCompositePolicy` keeps policy creation consistent - - Future operators would require additional functions - ---- - -## Migration Steps - -- **Backwards-compatible**: Existing simple policies (ALLOWLIST/BLOCKLIST) continue to work unchanged. No action required if you don't need composite behavior. - -- **For users currently flattening multiple lists into one policy**: - 1. Identify the simple policies you want to combine - 2. Call `policyRegistry.createCompositePolicy(admin, UNION or INTERSECT, [childPolicyIds])` - 3. Update the B20 token's policy scope to point to the new composite policy ID: - - `b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId)` - - No B20 contract change is required — B20 treats the composite ID as an opaque `uint64` - exactly like a simple policy ID - 4. Remove the old flattened policy if no longer needed - -- **No breaking changes**: All existing selectors, events, and errors remain dialable at Cobalt - -- **No storage migration**: `children` is a new, empty mapping at ERC-7201 offset 4. Existing - `PolicyRegistry` state at offsets 0–3 is unmodified by Cobalt activation From 4c70da1dcb54b3650f88a77575d56cf3107f350c Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 19 Aug 2026 11:58:30 -0400 Subject: [PATCH 15/21] chore: update seize --- changelog/02_Cobalt_B20_seize.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index 639ef1c..af98147 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -2,7 +2,7 @@ - **Feature Name**: seize - **Start Date**: 2026-08-17 -- **Authors**: Rayyan Alam +- **Authors**: Stephan - **Title**: Seize surface + burnBlocked deprecation ## Summary From 96458a9b808045d6b10e9096084d9380db67169f Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 19 Aug 2026 13:00:38 -0400 Subject: [PATCH 16/21] changelog: add Cobalt B20Asset multiplier scheduling (ERC-8056) - Adds scheduled multiplier path via updateUIMultiplier(newMultiplier, effectiveAt) - Adds cancelUIMultiplierUpdate, newUIMultiplier, effectiveAt, totalSupplyUI, MAX_UI_MULTIPLIER - Adds ERC-8056 view aliases: uiMultiplier, balanceOfUI, toUIAmount, fromUIAmount, supportsInterface - Adds UIMultiplierUpdated and UIMultiplierUpdateCancelled events - Adds EffectiveAtInPast, EffectiveAtTooFar, UIMultiplierUpdateExists, UIMultiplierUpdateDoesNotExist errors - Retains updateMultiplier as deprecated instant failsafe - New PendingMultiplier storage at PENDING_OFFSET=4 in ERC-7201 namespace - No breaking changes; all legacy selectors remain dialable Co-authored-by: OpenCode Co-Authored-By: Claude --- changelog/02_Cobalt_B20Asset_multiplier.md | 229 +++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 changelog/02_Cobalt_B20Asset_multiplier.md diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md new file mode 100644 index 0000000..5019fe1 --- /dev/null +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -0,0 +1,229 @@ +# Schedule Multiplier Updates (ERC-8056) + +- **Feature Name**: Scheduled Multiplier +- **Start Date**: 2026-08-17 +- **Authors**: Markus +- **Title**: Schedule Multiplier Updates (ERC-8056) + +## Summary + +This change introduces a scheduled multiplier path for B20 Asset issuers running corporate actions such as stock splits and in-kind dividends. The change moves the multiplier setter from an instant path to a scheduled path aligned with ERC-8056. The change applies only to B20 Asset (Cobalt hardfork, upcoming). + +Previously, B20 Asset multiplier updates were applied immediately through the `updateMultiplier` function. The new scheduled path allows issuers to define a new multiplier in advance and activate it at a specific future timestamp. This supports planned corporate actions and aligns B20 multiplier behavior with the ERC-8056 standard. + +The two use cases enabled are stock splits and in-kind dividends. The legacy `updateMultiplier` instant setter is retained as an emergency failsafe if a wrong value is passed. + +## Motivation + +The change solves the problem that B20 Asset did not have a scheduled setter for multiplier updates. Before this change, multiplier changes used only the instant `updateMultiplier` path, which does not allow issuers to schedule changes in advance. + +The ERC-8056 standard (https://eips.ethereum.org/EIPS/eip-8056) defines a standard for scheduling changes to an RWA token. This change conforms B20 Asset to that standard for scheduling multipliers. + +## Background + +### ERC-8056 + +ERC-8056 (https://eips.ethereum.org/EIPS/eip-8056) is an Ethereum Improvement Proposal that creates a standard for scheduling changes to an RWA token. The standard defines a core interface and optional extensions for UI balance queries, conversion helpers, and pending multiplier reads. + +### B20 Asset + +B20 Asset is an RWA token standard that conforms to the ERC-20 specification. Prior to this change, B20 Asset included these multiplier-related functions: + +- `updateMultiplier(uint256 newMultiplier)`: Applies the multiplier immediately +- `toScaledBalance(uint256)`: Legacy read/conversion alias that predates ERC-8056 naming +- `toRawBalance(uint256)`: Legacy read/conversion alias that predates ERC-8056 naming + +### Terminology + +- **WAD_PRECISION**: Fixed-point precision used to scale the multiplier, equal to `1e18` (18 decimal places). `1e18 = 1.0` multiplier. +- **UI multiplier**: The cosmetic multiplier that rescales displayed balances without minting, transferring, or rewriting raw balances (ERC-8056 terminology). +- **Raw balance**: The canonical on-chain token amount stored in the contract. +- **Scaled balance / UI balance**: The derived display balance calculated as `rawBalance * multiplier / WAD_PRECISION`. +- **Pending update**: A scheduled multiplier change that has not yet reached its `effectiveAt` timestamp. +- **OPERATOR_ROLE**: Pre-existing role required to call `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and `updateMultiplier`. This role is not introduced by this change and already gates `announce`. + +## Specs + +### Interface Changes + +#### Functions + +| Symbol | Selector | Status | Notes | +| --- | --- | --- | --- | +| `updateUIMultiplier(uint256,uint256)` | `0x628e600f` | new | Canonical scheduled setter for corporate actions. | +| `cancelUIMultiplierUpdate()` | `0x2c97a0f0` | new | Cancels the single live pending update. | +| `newUIMultiplier()` | `0xdc767007` | new | ERC-8056 pending-schedule read (target multiplier). | +| `effectiveAt()` | `0x97a4064f` | new | ERC-8056 pending-schedule read (flip timestamp). | +| `totalSupplyUI()` | `0x9bea6429` | new | ERC-8056 Balances extension. | +| `MAX_UI_MULTIPLIER()` | `0x785c0cf0` | new | Reads the multiplier ceiling (`type(uint128).max`) without triggering the revert path. | +| `supportsInterface(bytes4)` | `0x01ffc9a7` | new | ERC-165 feature detection. | +| `uiMultiplier()` | `0xa60bf13d` | new alias | ERC-8056 core naming. Aliases `multiplier()`; same effective value. | +| `balanceOfUI(address)` | `0x437a9958` | new alias | ERC-8056 Balances extension. Aliases `scaledBalanceOf(address)`; same value. | +| `toUIAmount(uint256)` | `0x3248d4ff` | new | ERC-8056 Conversion extension. Byte-identical to `toScaledBalance`. | +| `fromUIAmount(uint256)` | `0x65cd9b3c` | new | ERC-8056 Conversion extension. Byte-identical to `toRawBalance`. | +| `multiplier()` | `0x1b3ed722` | unchanged (canonical name) | Canonical B20 name; `uiMultiplier()` is the ERC-8056 alias. | +| `scaledBalanceOf(address)` | `0x1da24f3e` | unchanged (canonical name) | Canonical B20 name; `balanceOfUI(address)` is the ERC-8056 alias. | +| `toScaledBalance(uint256)` | `0x04f04c99` | deprecated-dialable | Prefer `toUIAmount(uint256)`. Byte-identical behavior. | +| `toRawBalance(uint256)` | `0x0ca06c44` | deprecated-dialable | Prefer `fromUIAmount(uint256)`. Byte-identical behavior. | +| `updateMultiplier(uint256)` | `0x5ffe6146` | deprecated-dialable | Retained as emergency failsafe. Instant setter; clears any live pending update. Prefer scheduled `updateUIMultiplier`. | + +#### Events + +| Symbol | Topic0 | Status | Notes | +| --- | --- | --- | --- | +| `UIMultiplierUpdated(uint256,uint256,uint256)` | `0x2205df4534432b2f60654a3fdb48737ffdaf3e9edb1a498bd985bc026b15b055` | new | ERC-8056 canonical multiplier-change event. Emitted by both setters. | +| `UIMultiplierUpdateCancelled(uint256,uint256)` | `0x883856335ba5f60c18b9817c4505d3c7d3f6223dcf39516b30c508c46a5e1cad` | new | Signals a cleared pending update (via cancel or a superseding instant setter). | +| `MultiplierUpdated(uint256)` | `0x4dbe4840d7465bd162f67814cea0b519567a2e0e578bcde61e7f4ced361e5a3d` | deprecated-still-emitted | Legacy event. Emitted only by the instant setter (`updateMultiplier`) alongside `UIMultiplierUpdated`. The scheduled setter emits only `UIMultiplierUpdated`. | + +#### Errors + +| Symbol | Selector | Status | Notes | +| --- | --- | --- | --- | +| `EffectiveAtInPast(uint256)` | `0x14119cf6` | new | Thrown when `effectiveAt <= block.timestamp`. | +| `EffectiveAtTooFar(uint256)` | `0x1ce214fa` | new | Thrown when `effectiveAt > type(uint64).max`. | +| `UIMultiplierUpdateExists(uint256)` | `0x4481a68e` | new | Thrown when a live pending update already exists. | +| `UIMultiplierUpdateDoesNotExist()` | `0xa7d6a5ca` | new | Thrown when cancel is called with no live pending update. | +| `InvalidMultiplier()` | `0x6f12f3dc` | unchanged | Zero or above-ceiling guard. Now also thrown by `updateUIMultiplier`. | + +#### Interface IDs advertised via `supportsInterface` + +| Interface ID | Interface | Status | +| --- | --- | --- | +| `0x01ffc9a7` | `IERC165` | new advertisement | +| `0xa60bf13d` | `IScaledUIAmount` (ERC-8056 core) | new advertisement | +| `0x4bd27648` | `IScaledUIAmountNewUIMultiplier` (ERC-8056 pending) | new advertisement | +| `0xd890fd71` | `IScaledUIAmountBalances` (ERC-8056 optional) | new advertisement | +| `0x57854fc3` | `IScaledUIAmountConversion` (ERC-8056 optional) | new advertisement | + +The following code snippet shows the net additions to the `IB20Asset` interface surface. The legacy `updateMultiplier`, `toScaledBalance`, and `toRawBalance` functions remain but are marked deprecated. + +```solidity +// ERC-8056 core (IScaledUIAmount) +function uiMultiplier() external view returns (uint256); + +// ERC-8056 pending (IScaledUIAmountNewUIMultiplier) +function newUIMultiplier() external view returns (uint256); +function effectiveAt() external view returns (uint256); + +// ERC-8056 balances (IScaledUIAmountBalances) +function balanceOfUI(address account) external view returns (uint256); +function totalSupplyUI() external view returns (uint256); + +// ERC-8056 conversion (IScaledUIAmountConversion) +function toUIAmount(uint256 rawAmount) external view returns (uint256); +function fromUIAmount(uint256 uiAmount) external view returns (uint256); + +// ERC-165 +function supportsInterface(bytes4 interfaceId) external view returns (bool); + +// Constants +function MAX_UI_MULTIPLIER() external view returns (uint256); + +// New scheduled path +function updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) external; +function cancelUIMultiplierUpdate() external; + +// Deprecated — retained for back-compat +function updateMultiplier(uint256 newMultiplier) external; +function toScaledBalance(uint256 rawBalance) external view returns (uint256); +function toRawBalance(uint256 scaledBalance) external view returns (uint256); +``` + +### Behavioural Changes + +#### Execution Flow + +**Old behavior:** +- `updateMultiplier(uint256)` applied the multiplier immediately. +- The change emitted the deprecated `MultiplierUpdated(uint256)` event. + +**New behavior:** +- `updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt)` is the canonical path for routine corporate actions. +- The caller schedules one pending multiplier update for a future timestamp. +- The pending update becomes effective lazily on read when `block.timestamp >= effectiveAt`. +- No extra event fires at maturation time. Off-chain systems must read `uiMultiplier()` or watch the pending schedule. +- `newUIMultiplier()` and `effectiveAt()` expose the live pending update. +- `cancelUIMultiplierUpdate()` clears the live pending update and emits `UIMultiplierUpdateCancelled(uint256,uint256)`. + +**Compatibility behavior:** +- `updateMultiplier(uint256)` remains callable as a deprecated instant failsafe. +- The instant setter applies the multiplier immediately and clears any live pending update. +- If it clears a live pending update, it emits `UIMultiplierUpdateCancelled(...)` first, then emits the legacy `MultiplierUpdated(uint256)` event and the canonical `UIMultiplierUpdated(uint256,uint256,uint256)` event. + +#### Access Control + +All three write functions — `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and `updateMultiplier` — require `OPERATOR_ROLE`. `OPERATOR_ROLE` is pre-existing (not introduced by this change) and already gates `announce`. + +#### Gas Cost Implications + +Every scaled-view read (`uiMultiplier`, `multiplier`, `balanceOfUI`, `scaledBalanceOf`, `toUIAmount`, `fromUIAmount`, `totalSupplyUI`) now includes an extra `SLOAD` for the pending slot plus a `block.timestamp` compare. Raw `balanceOf` is unchanged. + +#### Storage Layout Changes + +- New field `PendingMultiplier pending` appended to the `base.b20.asset` ERC-7201 namespace. + - Namespace location: `0xfdc6d4552d1286ade4d9facdbf0fb50d2ec9b89a90e104f26fd277585e374b00`. + - Placed at `PENDING_OFFSET = 4`. +- Packed into a single 256-bit slot: + - Bits 0-127: `uint128 multiplier` (target). + - Bits 128-191: `uint64 effectiveAt` (flip timestamp). + - Bits 192-255: unused (32 bytes free for future packing). +- Additive change; pre-existing offsets 0-3 are unchanged: + - offset 0: `uint8 decimals` + - offset 1: `uint256 multiplier` (stored `0` still interpreted as `WAD_PRECISION` on read) + - offset 2: `mapping usedAnnouncementIds` + - offset 3: `mapping extraMetadata` +- Must match the `base/base` Rust precompile slot-for-slot (AGENTS.md invariant). + +### ERC-8056 View Aliases + +- `uiMultiplier()` returns the same value as `multiplier()`. +- `toUIAmount(raw)` returns the same value as `toScaledBalance(raw)`. `fromUIAmount(ui)` returns the same value as `toRawBalance(ui)`. +- `balanceOfUI(account)` returns the same value as `scaledBalanceOf(account)`. +- `totalSupplyUI()` equals `totalSupply() * uiMultiplier() / WAD_PRECISION`. + +### Examples + +`updateUIMultiplier(newMultiplier, effectiveAt)` is the canonical path for corporate actions such as stock splits and reinvested dividends. Only one pending update can be live at a time. + +1. **Schedule**: Call `updateUIMultiplier(newMultiplier, effectiveAt)`. This requires `OPERATOR_ROLE`, and `effectiveAt` must be strictly in the future. +2. **Read the pending update**: While it is live, `newUIMultiplier()` returns the scheduled target, `effectiveAt()` returns the flip timestamp, and `uiMultiplier()` / `multiplier()` still return the current value. +3. **Let it mature**: Once `block.timestamp >= effectiveAt`, `uiMultiplier()` / `multiplier()` flip on read. No event fires at maturation. +4. **Or cancel it**: `cancelUIMultiplierUpdate()` clears a live pending update and emits `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)`. + +To reorder overlapping actions, cancel and reschedule atomically in one announcement: + +```solidity +announce([cancelUIMultiplierUpdate(), updateUIMultiplier(...)], ...) +``` + +## Design Decisions & Alternatives Considered + +The design retains the instant setter (`updateMultiplier`) as a deprecated dialable failsafe. + +**Rationale:** +- It is the only on-chain recourse to correct or supersede a scheduled multiplier without waiting for `effectiveAt`. +- Cancel-then-schedule cannot fix a bad scheduled value if the correction must apply immediately. +- Removing it would leave operators no emergency override if a wrong `newMultiplier` or wrong `effectiveAt` were scheduled. + +## Migration Steps + +### Backwards-compatible + +`updateMultiplier`, `toScaledBalance`, and `toRawBalance` continue to work unchanged. No action is required if you do not need scheduled multiplier behavior yet. + +### No breaking changes + +All existing selectors, events, and errors remain dialable. The legacy `MultiplierUpdated` event is still emitted by the instant setter. + +### To adopt the scheduled path + +1. Replace direct calls to `updateMultiplier(newMultiplier)` with `updateUIMultiplier(newMultiplier, effectiveAt)` for routine corporate actions. +2. Use `newUIMultiplier()` and `effectiveAt()` to read the pending schedule. +3. Use `cancelUIMultiplierUpdate()` to clear a pending update before it matures. +4. For emergency corrections, retain the ability to call `updateMultiplier(newMultiplier)` which immediately applies the change and clears any pending update. +5. Update off-chain indexers to listen for `UIMultiplierUpdated` instead of (or in addition to) the deprecated `MultiplierUpdated` event. +6. Update display logic to prefer ERC-8056 naming: `uiMultiplier()`, `balanceOfUI()`, `toUIAmount()`, `fromUIAmount()`, `totalSupplyUI()`. + +### No storage migration + +The pre-existing multiplier storage at offset 1 is untouched. The new `PendingMultiplier` slot at offset 4 is additive and starts in the no-pending state (`effectiveAt = 0`). \ No newline at end of file From 022bde6ae02eb99b6f1f735020e62e7af28e4a03 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 19 Aug 2026 13:38:04 -0400 Subject: [PATCH 17/21] chore: remove stray COMPOSITE_POLICY.md from changelog/ This file was accidentally committed at the top level of changelog/; the canonical composite-policy entry lives at changelog/02_Cobalt_PolicyRegistry_composite_policy.md. --- changelog/COMPOSITE_POLICY.md | 226 ---------------------------------- 1 file changed, 226 deletions(-) delete mode 100644 changelog/COMPOSITE_POLICY.md diff --git a/changelog/COMPOSITE_POLICY.md b/changelog/COMPOSITE_POLICY.md deleted file mode 100644 index a668203..0000000 --- a/changelog/COMPOSITE_POLICY.md +++ /dev/null @@ -1,226 +0,0 @@ -# ADR Template (Point Form) - -- **Feature Name**: composite_policy -- **Start Date**: 2026-08-17 -- **Authors**: Rayyan Alam -- **Title**: Composite Policies (UNION/INTERSECT) - ---- - -## Summary - -- Introduce two new `PolicyRegistry` policy types, `UNION` (OR) and `INTERSECT` (AND) — collectively - "composite policies" — that authorize by combining 2 to 4 existing simple policies -- For users utilizing the policy registry who may want to combine many policies together, without - flattening into one big list -- Composite policies are built up of only existing simple policies (`ALLOWLIST`/`BLOCKLIST`) — never - another composite, and never a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`) -- Ships at the **Cobalt** hardfork. Every mutating call (`createCompositePolicy`, `updateComposite`) - is gated by `ActivationRegistry`, same as every other mutating `PolicyRegistry` function — not - automatically enabled. Read-only calls (`isAuthorized`, `compositePolicyChildIds`, - `MIN_COMPOSITE_CHILD_POLICIES`, `MAX_COMPOSITE_CHILD_POLICIES`) are always callable regardless of - activation - ---- - -## Motivation - -> State the problem this change solves, why the current state is insufficient, and brief context -> on prerequisites if needed (full detail lives in Background). -- The policy registry currently supports simple boolean policies via `isAuthorized`, where each - policy independently returns true or false -- In practice, access control often requires combining multiple policies (e.g., KYC + ProUser, or - ProUser OR LifetimeUser) -- The current architecture requires a user to listen to changes on a different allowlist and - flatten into one, duplicating lists and requiring infra to keep them up to date -- We want to allow for policy reuse by creating composite policies which can reference other simple - policies - - should return "is authorized" by combining the results of other policies - - simplifies maintenance — updating one child policy updates every composite that references it - ---- - -## Background - -> Link to or summarize concepts the reader needs before understanding the Specs: prior art, -> relevant EIPs/ERCs, existing patterns, and any domain-specific terms used in this document. -- B20 Token - - B20 is a token precompile that uses policies to restrict operations such as - transfers, minting, and seizing. - - For each restricted operation, B20 stores a Policy Registry policy ID in a - dedicated policy scope. - - When an operation is attempted, B20 passes the relevant policy ID and account - address to the Policy Registry. If the account is not authorized, B20 - rejects the operation. - -- Policy Registry: - - Is a singleton precompile contract used by B20 tokens. - - It manages a list of policies; B20 tokens call `isAuthorized(policyId, account)` against a - policy ID stored on the relevant policy scope - - Currently used by B20 tokens for `TRANSFER_FROM`, `TRANSFER_TO`, `SEIZE_HOLDER` -- Simple policies: - - These are the non-composite policy types: `ALLOWLIST` / `BLOCKLIST` - - `ALLOWLIST` has a list of addresses; returns authorized `true` if the address is in the - list, `false` otherwise - - `BLOCKLIST` has a list of addresses; returns authorized `false` if the address is in the - list, `true` for all other addresses - ---- - -## Specs - -> Implementation details live here. Add more headings as needed (e.g., Storage Layout Changes, -> Deprecated Assets, Access Control, etc.). - - -### Interface Changes - -> New functions, events, errors (include signatures). Renamed or deprecated symbols (old → new). -> Selector / topic0 values (verified via `cast sig` or `cast keccak`). - -- Introduce 2 new values in the `PolicyType` enum - - `UNION = 2` — authorized if *any* child policy authorizes the account (OR) - - `INTERSECT = 3` — authorized only if *every* child policy authorizes the account (AND) -- New function `createCompositePolicy(admin, policyType, childPolicyIds)` - - `childPolicyIds` must be between 2 and 4 entries (`MIN_COMPOSITE_CHILD_POLICIES` / - `MAX_COMPOSITE_CHILD_POLICIES`) - - Every child must be an *existing simple* policy (`ALLOWLIST`/`BLOCKLIST`) — never another - composite, never a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`) - - Canonical revert order: `ZeroAddress` (admin) → `IncompatiblePolicyType` (policyType not - UNION/INTERSECT) → `ChildPoliciesOutsideOfRange` (count not in `[2,4]`) → `PolicyNotFound` - (a child doesn't exist, checked as one pass over the whole set) → `InvalidChildPolicy` (a - child is itself composite/sentinel, checked as a second pass) - - Emits, in order: `PolicyCreated(policyId, creator, policyType)`, - `PolicyAdminUpdated(policyId, address(0), admin)`, `CompositePolicyUpdated(policyId, creator, - childPolicyIds)` -- New function `updateComposite(policyId, childPolicyIds)` - - Full replacement of the child set — there's no partial-update or clear-the-list operation - - Same child-validity rules as `createCompositePolicy` (existing simple policies only, 2-4 of - them) - - Canonical revert order: `PolicyNotFound` (composite itself doesn't exist) → - `IncompatiblePolicyType` (`policyId` is a simple policy) → `Unauthorized` (caller isn't the - current admin — fires before the count check) → `ChildPoliciesOutsideOfRange` → - `PolicyNotFound` (a new child doesn't exist) → `InvalidChildPolicy` - - Emits only `CompositePolicyUpdated(policyId, updater, childPolicyIds)` — no - `PolicyAdminUpdated`, since admin doesn't change -- Existing function, new revert path: `createPolicy` and `createPolicyWithAccounts` (both already - live on Beryl) now also revert `IncompatiblePolicyType` when `policyType` is `UNION`/`INTERSECT` — - a previously-unreachable path, since those enum values didn't exist before Cobalt - - -### Behavioural Changes - -> How execution flow differs from the previous version. Storage layout changes (new slots, moved -> fields, packing changes). Gas cost implications if meaningful. -- A composite policy ID is passed to a B20 policy slot exactly like a simple policy ID — B20 needs - **zero code changes**, since it stores policy slots as an opaque `uint64` and calls - `isAuthorized` generically -- `isAuthorized` on a composite is live and short-circuiting, not a snapshot: - - Reads each child's *current* membership on every call — no snapshot from creation or the - last `updateComposite` - - `UNION` short-circuits `true` on the first authorizing child - - `INTERSECT` short-circuits `false` on the first non-authorizing child - - Recursion never exceeds depth 1, because every child is validated to be a simple policy at - write time — a composite's children can never themselves be composites -- Child order affects gas, never the outcome: - - `UNION`/`INTERSECT` are commutative, so reordering `childPolicyIds` never changes whether an - account is authorized - - It only shifts where the short-circuit lands — put the child most likely to short-circuit - first (broadest ALLOWLIST for `UNION`, tightest BLOCKLIST for `INTERSECT`) to save gas -- Duplicate child IDs are allowed. The registry neither sorts nor deduplicates the stored child - list — deduplicating would cost extra gas on every write for a set already capped at 4 entries, - for little value; `UNION`/`INTERSECT` are idempotent under duplicates anyway -- A composite can never shrink below 2 children via `updateComposite` — it enforces the same - `[2,4]` range as creation, so there's no path to an empty or undersized composite -- If a child policy's admin renounces, the parent composite keeps working: `renounceAdmin` only - clears the child's 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 -- State changes - - New state: `mapping(uint64 policyId => uint64[] childPolicyIds) children`, at **offset 4** - within the `base.policy_registry` ERC-7201 namespace (not a literal EVM slot 4) - - Reused state: one shared global counter (`nextCounter`) across simple and composite - policies, starting at 2 (`0` and `1` are reserved for `ALWAYS_ALLOW`/`ALWAYS_BLOCK`). A - composite policy ID encodes `PolicyType` in the top byte and the next available counter - value in the low 56 bits — the same encoding scheme as simple policies, not a separate - counter - - -### Examples - -- **Before (simple policy)**: - - Assign one existing policy directly to a B20 policy scope - - `b20.updatePolicy(TRANSFER_SENDER_POLICY, allowlistPolicyId)` - - Only accounts in `allowlistPolicyId` can transfer - -- **After (composite policy)**: - - Assume existing simple policies: `employeesPolicyId` (ALLOWLIST), `approvedRegionPolicyId` (ALLOWLIST) - - Create a UNION composite: - - `policyRegistry.createCompositePolicy(admin, UNION, [employeesPolicyId, approvedRegionPolicyId])` - - Emits: `PolicyCreated(policyId, admin, UNION)` + `PolicyAdminUpdated(policyId, 0, admin)` + - `CompositePolicyUpdated(policyId, admin, [children])` - - Assign to B20: `b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId)` - - B20 has no composite-specific logic — it passes the policy ID to the registry as usual - -- **Updating a composite**: - - `policyRegistry.updateComposite(compositePolicyId, [employeesPolicyId, trustedPartnersPolicyId])` - - Emits: `CompositePolicyUpdated(policyId, admin, [newChildren])` - - B20 continues using the same policy ID — no token-side update required - - Future authorization checks use the new child set immediately (live evaluation, no snapshot) - ---- - -## Design Decisions & Alternatives Considered - -- **Decision**: Two explicit policy types (`UNION`, `INTERSECT`) with a single `createCompositePolicy` function and full-replacement `updateComposite` - -- **Alternative 1: One generic COMPOSITE type** - - Store a separate operator (AND, OR, NOT, XOR) in composite storage - - Rejected: - - Requires storing both "composite" flag and the operator - - Adds storage reads or more complicated ID encoding - - Unnecessary complexity before there's a requirement for NOT, XOR, or nested expressions - - Generic boolean expressions create a larger gas and audit surface - -- **Alternative 2: Token-level policy groups** - - Keep Policy Registry unchanged; have each B20 token store multiple policy IDs + an operator - - Rejected: - - Composite policies would not be reusable entities - - Requires changes across B20, token variants, factories, and token hot paths - - Does not support sharing one composite policy across multiple tokens - - Spreads complexity across more contracts - -- **Alternative 3: Incremental child updates** - - Provide `addCompositeOperand` / `removeCompositeOperand` functions - - Rejected: - - Child list is capped at 4 entries - - Dynamic-array mutation requires swap/remove, length, and deduplication logic - - Full replacement is simpler and atomic - - Caller can resend the complete list at low cost - -- **Alternative 4: Separate creator functions** - - Use `createUnionPolicy` and `createIntersectPolicy` - - Rejected: - - Doubles the creation API surface - - A single `createCompositePolicy` keeps policy creation consistent - - Future operators would require additional functions - ---- - -## Migration Steps - -- **Backwards-compatible**: Existing simple policies (ALLOWLIST/BLOCKLIST) continue to work unchanged. No action required if you don't need composite behavior. - -- **For users currently flattening multiple lists into one policy**: - 1. Identify the simple policies you want to combine - 2. Call `policyRegistry.createCompositePolicy(admin, UNION or INTERSECT, [childPolicyIds])` - 3. Update the B20 token's policy scope to point to the new composite policy ID: - - `b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId)` - - No B20 contract change is required — B20 treats the composite ID as an opaque `uint64` - exactly like a simple policy ID - 4. Remove the old flattened policy if no longer needed - -- **No breaking changes**: All existing selectors, events, and errors remain dialable at Cobalt - -- **No storage migration**: `children` is a new, empty mapping at ERC-7201 offset 4. Existing - `PolicyRegistry` state at offsets 0–3 is unmodified by Cobalt activation From 6a3d3d4c552e1b9782fbc3649ba28c51d2838651 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 20 Aug 2026 09:23:01 -0400 Subject: [PATCH 18/21] feat: add gap check --- .claude/skills/changelog-gap-check/skill.md | 124 ++++++++++++ .claude/skills/changelog-grill/SKILL.md | 138 +++---------- changelog_template/SEIZE_POLICY_TEMPLATE.md | 212 -------------------- 3 files changed, 147 insertions(+), 327 deletions(-) create mode 100644 .claude/skills/changelog-gap-check/skill.md delete mode 100644 changelog_template/SEIZE_POLICY_TEMPLATE.md diff --git a/.claude/skills/changelog-gap-check/skill.md b/.claude/skills/changelog-gap-check/skill.md new file mode 100644 index 0000000..13db8ac --- /dev/null +++ b/.claude/skills/changelog-gap-check/skill.md @@ -0,0 +1,124 @@ +# changelog-gap-check + +Check a changelog entry for progressive-discovery ordering and content quality. + +## When to use + +Invoke with `/changelog-gap-check ` where `` is the absolute or repo-relative path to a changelog markdown file (e.g. `changelog/02_Cobalt_B20_seize.md`). + +## What this skill does + +This is a **read-only audit**. It produces a report with reorder suggestions and consolidation recommendations — it does not modify the file. + +### 1. Progressive-discovery check + +For each section (delimited by `##` headings), scan the bullet points and sentences in order. A concept (identifier, acronym, named entity) must be **introduced before it is referenced**. If sentence/bullet N references concept X but concept X is first defined or introduced at sentence/bullet M where M > N, flag it as an ordering violation. + +**How to detect:** +- Extract named concepts: function names, role names, policy names, event names, error names, acronyms, and domain terms that are not common English. +- For each concept, record its first-mention position (section + bullet/sentence index). +- If a reference appears before its introduction within the same section, report it. + +**Output format for each violation:** +``` +REORDER: section "" + - "" (line ~N) + references `X`, first introduced at line ~M ("") + → Suggest: move introduction before first reference +``` + +### 2. Duplicate/redundant content check + +Scan across all sections for bullets or sentences that convey the same information. Flag pairs where: +- The same fact is stated in two places (even with different wording). +- A later bullet restates what an earlier bullet already covered. + +**Output format:** +``` +DUPLICATE: + - line ~N: "" + - line ~M: "" + → Suggest: consolidate into a single bullet, keep in whichever section introduces it first +``` + +### 3. Point-form style recommendations + +For any paragraph-style prose that could be tightened into point-form technical English, suggest a rewrite. Technical English means: +- Active voice, present tense +- One fact per bullet +- No filler words ("it should be noted that", "in order to", "basically") +- Named subjects (not "it" or "this" without antecedent) + +**Output format:** +``` +STYLE: line ~N + Original: "" + Suggested: + - + - +``` + +### 4. Motivation gap detection (what without why) + +Scan for statements that introduce a NEW capability, behavior change, or design decision without explaining the problem it solves or the reason it exists. + +**Symptoms to flag:** +- "allows X to Y" / "enables X" / "adds support for X" without a preceding or following "because" / "so that" / problem statement +- Specs sections that describe HOW a mechanism works (storage layout, gas, ordering, event sequence) without stating WHY it matters to the reader +- Design-decision bullets that state a choice without rationale ("we chose X" with no "because Y") + +**What is NOT a gap (do not flag):** +- A feature whose motivation is explained earlier in the same document (Summary or Motivation section) does not need re-explanation in Specs — unless the Specs section exceeds ~30 lines, at which point a reader skimming only that section loses context +- Standard interface conformance ("ERC-8056 conformant") is a valid WHY when the standard is linked or named +- Backwards-compatibility statements ("unchanged for back-compat") are self-motivating +- Q&A pairs in a "Guarantees and edge cases" section — these answer "how does it behave?" which is their purpose + +**Output format:** +``` +WHY-GAP: line ~N, section "" + Statement: "" + Missing: motivation / problem statement — why does this capability exist? + Hint: +``` + +**Example:** +``` +WHY-GAP: line ~12, section "Summary" + Statement: "The new scheduled path allows issuers to define a new multiplier in advance and activate it at a specific future timestamp." + Missing: motivation — why do issuers need advance scheduling rather than instant updates? + Hint: State the operational driver (e.g., corporate actions require advance notice to exchanges/custodians before the multiplier flips). +``` + +## Output structure + +``` +# Changelog Audit: + +## Progressive Discovery +### Section: + + +## Duplicates + + +## Style Suggestions + + +## Motivation Gaps + + +## Summary +- Ordering violations: N +- Duplicate pairs: N +- Style suggestions: N +- Motivation gaps: N +``` + +## Rules + +- Do NOT edit the file. Report only. +- If the user passes `--fix`, then apply the suggested reorderings and consolidations directly to the file. Still output the report showing what changed. +- Treat markdown tables as atomic units — do not reorder rows within a table for progressive discovery (tables have their own sort order conventions). +- Ignore the audience blockquote (`> **Audience:**...`) — it's introductory framing, not subject to reorder. +- Cross-section references are fine: a concept introduced in an earlier section can be referenced in a later section without violation. +- Only flag ordering issues within the same `##` section. diff --git a/.claude/skills/changelog-grill/SKILL.md b/.claude/skills/changelog-grill/SKILL.md index c01e630..385b000 100644 --- a/.claude/skills/changelog-grill/SKILL.md +++ b/.claude/skills/changelog-grill/SKILL.md @@ -1,133 +1,41 @@ --- name: changelog-grill -description: Grill a changelog or ADR template until its intent, assumptions, design decisions, and migration impact are understood. Use when reviewing a changelog template, preparing an ADR, or stress-testing a feature proposal before writing the final entry. -disable-model-invocation: true +description: Interview the user relentlessly about a changelog or ADR template until reaching shared understanding. Proposes point-form edits after each round. --- # Changelog Grill -Turn an incomplete changelog or ADR template into a validated context handoff. Do not edit the -template automatically. The primary output is structured Markdown in the conversation that the user -can review, correct, and pass into a new context or use to write the final changelog entry. +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. -## Input +## Setup -The user should provide a path to the template file. If they provide pasted content instead, use it -directly. When a path is provided: +1. Read the template file (path provided by user, or pasted content). +2. Read `changelog/TEMPLATE_POINT_FORM.md` for expected structure. +3. Inspect related source, interfaces, tests, and existing changelog entries to verify facts. -1. Read the template. -2. Read repository guidance that applies to the file, especially `AGENTS.md`, the changelog README, - and relevant product documentation. -3. Inspect related source, interfaces, tests, existing changelog entries, and configuration when - needed to verify facts or understand the proposal. +## Protocol -Do not ask the user for facts that can be obtained from the repository or tools. Clearly distinguish -facts found in the repository from decisions that only the user can make. +Walk down each branch of the design tree resolving dependencies between decisions one by one. -## Mission +- Ask exactly ONE question per message. Never ask two or more questions in the same output. +- If the question can be answered by exploring the codebase, explore the codebase instead of asking. If there are key details missing in the template suggest them. +- For each question, provide your recommended answer. +- After the user responds, apply the edit to the point-form template immediately, then ask the + next single question. -Interview the user until there is a shared understanding of the proposed change. Review every -section in the supplied template, including sections that appear complete. Look for: +Question format: -- Missing context or prerequisites -- Ambiguous terminology and undefined actors -- Unstated assumptions -- Claims that need source or test verification -- Missing compatibility, rollout, or migration behavior -- Design decisions that were made implicitly -- Important alternatives that were not considered -- Scope that is too broad, too narrow, or inconsistent with the repository -- Security, storage, gas, API, and operational implications where relevant - -Do not merely ask the user to fill in blanks. Challenge the reasoning. When the current approach has -a plausible better alternative, explain it and ask why the user prefers the current approach. The -user owns the decisions; the skill owns fact-finding and identifying questions. - -## Grilling protocol - -Map the proposal as a dependency-aware design tree and work in rounds. - -- The **frontier** is every decision whose prerequisites are settled. -- Ask all questions on the current frontier in one round. -- Do not ask downstream questions whose answers depend on unresolved decisions in the same round. -- Number every question and include a recommendation. -- Wait for the user's answers before recomputing the next frontier. -- Continue until every meaningful branch has been considered and no important assumption remains - silent. - -Use this format: - -```markdown -❓ **Q1 - **: - -➡️ **Recommendation:** +``` +❓ ****: <question + context> +➡️ **Recommendation:** <your recommended answer and why> ``` -Ask questions in groups that make sense together, such as scope and audience first, then behavior -and interfaces, then alternatives and migration. Keep questions specific enough to answer without -guessing. - -### Challenge expectations - -For each major design choice, ask: - -- What problem does this solve? -- Why is this the right layer for the behavior? -- What alternatives were considered? -- Why was the chosen approach preferred? -- What future requirement or complexity does it intentionally exclude? -- What breaks, changes, or remains compatible? - -For changelog entries, explicitly examine: - -- Audience and user-facing purpose -- Hardfork, rollout, and activation status -- Existing behavior and compatibility guarantees -- New, renamed, deprecated, or removed interface symbols -- Functions, events, errors, selectors, topics, and interface IDs -- Behavioral and revert-order changes -- Storage layout, encoding, packing, and migration concerns -- Authorization, policy, pause, and activation behavior -- Examples that demonstrate before/after usage -- Migration steps and whether action is required -- Edge cases and precise guarantees - -If a selector, signature, storage slot, or behavior is not verified, mark it as unverified and find -the source or ask the user to resolve the gap. Never invent plausible technical values. - -## Completion and output - -Do not modify, create, or rename template files during grilling. The session is complete only after: - -1. All relevant template sections have been reviewed. -2. Repository facts have been gathered or marked unavailable. -3. The user has answered the meaningful frontier questions. -4. The skill presents a context handoff for confirmation. - -The final handoff must use this structure: - -```markdown -# Grilled Context Handoff - -## Confirmed Understanding - -## Decisions Made - -## Validated Assumptions - -## Design Alternatives Considered - -## Missing Context - -## Open Questions - -## Recommended Template Additions +## After each answer -## Final Confirmation -``` +Apply the result directly to the point-form template file. Edits must use point-form technical +English: active voice, present tense, one fact per bullet, named subjects, state WHY alongside WHAT. -In `Final Confirmation`, state whether the context is ready to use for writing the changelog. If -there are unresolved questions, list them plainly and do not claim the handoff is complete. +## Completion -After presenting the handoff, wait for explicit confirmation. Only after confirmation may the user -ask for the template or a new changelog entry to be written. +Done when every section has been reviewed, no assumption remains silent, and the user confirms +shared understanding. diff --git a/changelog_template/SEIZE_POLICY_TEMPLATE.md b/changelog_template/SEIZE_POLICY_TEMPLATE.md deleted file mode 100644 index a4ae82b..0000000 --- a/changelog_template/SEIZE_POLICY_TEMPLATE.md +++ /dev/null @@ -1,212 +0,0 @@ -# ADR Template (Point Form) - -- **Feature Name**: seize -- **Start Date**: 2026-08-17 -- **Authors**: Rayyan Alam -- **Title**: Seize surface + burnBlocked deprecation - -> Fill in each section below when documenting a change. Pointform here can be sufficient - ---- - -## Summary - -> Give a summary of the change: who it's for, what it does. - -- Add `seizeWithMemo` to the shared `IB20` interface — inherited by both B20 Asset and B20 - Stablecoin, no variant-specific logic -- Original flow to seize an asset was 3 steps block + `burnBlocked` + mint (destroy then reissue); -- `seizeWithMemo` looks to replaces this with a single admin call that reassigns the balance directly -- `burnBlocked` burning blocked assets through burn blocked is still a flow, now depreceated but supported. - - ---- - -## Motivation - -> State the problem this change solves, why the current state is insufficient, and brief context -> on prerequisites if needed (full detail lives in Background). - -- Burn functionality should be explicitly distinct from seize; they may be gated on different - policies -- Compliant asset issuers need freeze+seize models. -- Today we achieve this with a work arround, using burnBlock to burn the asset and mint to the seize account - - The functionality is 3 step to sezie - - Configure `from` as blocked under `TRANSFER_SENDER_POLICY` (the same policy `burnBlocked` - reads today — verified against `IB20.sol`) - - Burn assets --> Effectes token supply - - Mint assets to seieze - - Emits a burn and a Mint - - Does not indicate a seize occured - - Main issue with this is two, the events emitted are misleading for burning, and minting - - The token supply is effected when data is bruned -- Proposed functionlaity `seizeWithMemo` replaces that workaround with a direct transfer to the seize account emitting a seize event - - Add in policy to be seized from - - use the seizeWithMemo function --> emits seize event - - This, makes sezieng functionality and burning functionality explicityl distinct - ---- - -## Background - -> Link to or summarize concepts the reader needs before understanding the Specs: prior art, -> relevant EIPs/ERCs, existing patterns, and any domain-specific terms used in this document. - -### B20 Asset and B20 Stablecoin - -B20 Asset and B20 Stablecoin are Base-native token contracts that extend ERC-20 with role-gated administrative functions and policy-gated operation checks. Each token stores fine-grained policy slots, keyed by operation and actor position, and consults the PolicyRegistry through `isAuthorized(policyId, account)` to decide whether a given account is allowed for that slot. For example, transfer flows can independently gate the sender, receiver, and executor, while other operations use their own dedicated policy slots, such as mint receiver and seize holder/receiver. This design separates access control from compliance logic: roles determine who may call privileged methods, and policy slots determine which accounts may participate in a given token operation. - -### Policy Registry - -The Policy Registry is a singleton precompile contract. Its responsibility is to return `isAuthorized(policyId, account)`. B20 uses the Policy Registry to gate operations by passing the stored policy ID and the account to check. - ---- - -## Specs - -> Implementation details live here. Add more headings as needed (e.g., Storage Layout Changes, -> Deprecated Assets, Access Control, etc.). - -### Interface Changes - -> New functions, events, errors (include signatures). Renamed or deprecated symbols (old → new). -> Selector / topic0 values. - - Old → new mapping (selectors/topics verified via `cast sig`/`cast keccak` against - `src/interfaces/IB20.sol` and `src/lib/B20Constants.sol`): - - Deprecated `burnBlocked(address,uint256)` `0xec0cf3dc` — **deprecated-dialable**, unchanged - behavior, no removal date committed - - `error AccountNotBlocked(address)` `0x64a5cb46` — unchanged, still exclusive to - `burnBlocked` - - `event BurnedBlocked(address,address,uint256)` topic0 - `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` — unchanged, still - exclusive to `burnBlocked` - - NEW `seizeWithMemo(address,address,uint256,bytes32)` `0xf916d81b` — single-call - admin seize, reassigns `from`'s balance to `to` - - NEW `SEIZE_ROLE()` `0x3c7e9ba5` (role value `0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432`) - - NEW `SEIZE_HOLDER_POLICY()` `0xb279d311` (`0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2`) - — gates who is seizable; inverted membership (seizable if NOT authorized) - - NEW `SEIZE_RECEIVER_POLICY()` `0xb31da27f` (`0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d`) - — gates seize destination; unset slot = always-allow - - NEW `event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount)` - topic0 `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` - - NEW `error AccountNotSeizable(address)` `0x91dbbc8d` - - NEW `PausableFeature.SEIZE` — dedicated pause vector, independent of `BURN` - -The following code snippet shows the net additions to the `IB20` interface surface. The `burnBlocked` function and its associated error/event remain but are marked deprecated. - -```solidity -// PausableFeature enum — SEIZE appended -enum PausableFeature { - TRANSFER, - MINT, - BURN, - SEIZE -} - -error AccountNotSeizable(address account); - -event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount); - -function SEIZE_ROLE() external view returns (bytes32); - -function SEIZE_HOLDER_POLICY() external view returns (bytes32); -function SEIZE_RECEIVER_POLICY() external view returns (bytes32); - -/// @notice Seizes `amount` of `from`'s balance and reassigns it to `to` in a single admin operation. -/// Emits `Transfer`, then `Memo`, then `Seized`. -function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external; - -// DEPRECATED — retained for back-compat -function burnBlocked(address from, uint256 amount) external; -``` - -### Behavioural Changes - -> How execution flow differs from the previous version. Storage layout changes (new slots, moved -> fields, packing changes). Gas cost implications if meaningful. - -New function seizeWithMemo -- `seizeWithMemo(from, to, amount, memo)`: - 1. Check `SEIZE` is not paused, else revert `ContractPaused(SEIZE)`. - 2. Check caller has `SEIZE_ROLE`, else revert `AccessControlUnauthorizedAccount`. - 3. Reject zero or self destinations, else revert `InvalidReceiver`. - 4. Reject zero source, else revert `InvalidSender`. - 5. Require `from` to be not authorized under `SEIZE_HOLDER_POLICY`, else revert `AccountNotSeizable`. - 6. Require `to` to be allowed by `SEIZE_RECEIVER_POLICY`, else revert `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)`. - 7. Check balance, else revert `InsufficientBalance`. - 8. Emit `Transfer`, then `Memo`, then `Seized`. -- does not check allowance on all three transfer-side policies - (`TRANSFER_SENDER_POLICY`/`TRANSFER_RECEIVER_POLICY`/`TRANSFER_EXECUTOR_POLICY`) -- Dedicated pause vector: `PausableFeature.SEIZE` pauses `seizeWithMemo`. This means the function would return ContractPaused(SEIZE) -- Seize is a transfer, not a burn: the balance moves `from` → `to` and `totalSupply` is - unchanged. This is the key behavioral difference from `burnBlocked`, which sends to - `address(0)` and reduces supply -- Storage layout change: add a packed `seizePolicyIds` slot for `SEIZE_HOLDER_POLICY` and - `SEIZE_RECEIVER_POLICY`. This is additive; `burnBlocked` storage remains unchanged. - - - -### Examples - -> Before/after code snippets or call sequences. Expected return values or emitted events. - -- Before (old block+burn+mint workaround, still available, deprecated): - - Configure `from` as blocked under `TRANSFER_SENDER_POLICY` - - `burnBlocked(from, amount)` — burns `from`'s balance, gated by `BURN_BLOCKED_ROLE` - - `mint(treasury, amount)` — separately reissues the same amount, gated by `MINT_ROLE` - - Emits `Transfer(from, 0, amount)` + `BurnedBlocked` + `Transfer(0, treasury, amount)` — - two independent operations, no single event ties the burn to the reissue -- After (new, single call): - - Configure `from` as NOT authorized under `SEIZE_HOLDER_POLICY` (i.e. blocked) - - `seizeWithMemo(from, treasury, amount, memo)` — gated by `SEIZE_ROLE` - - Emits `Transfer(from, treasury, amount)` → `Memo(caller, memo)` → `Seized(caller, from, treasury, amount)` ---- - -## Design Decisions & Alternatives Considered - -> Describe the approach taken and why. Document alternatives considered and why they were rejected. -> Note any opinionated choices and their rationale. - -- **Final shipped shape:** `seizeWithMemo` and `burnBlocked` use fully independent policy slots and - pause vectors. - - `seizeWithMemo` uses the new `SEIZE_HOLDER_POLICY` (for `from`) and - `SEIZE_RECEIVER_POLICY` (for `to`), the new `SEIZE_ROLE`, and the new - `PausableFeature.SEIZE`. - - `burnBlocked` retains `TRANSFER_SENDER_POLICY`, `BURN_BLOCKED_ROLE`, and the `BURN` pause - vector unchanged. - - Seize operations are rare, so the reserved lane in the transfer packed policy slot was not - reused for seize. We kept that lane open for a possible future transfer-side optimization, - where another hot-path transfer policy could be packed into the existing transfer slot without - adding a second `SLOAD`. Because seize is cold-path / rare path , it instead gets its own packed - `seizePolicyIds` slot. - -### Function Naming Alternatives - -- The shared seize-policy approach was rejected because burning and seizing have different effects: - `burnBlocked` destroys supply, while `seizeWithMemo` reassigns balances. `burnBlocked` therefore - remains independent, and no `burnBlockedWithMemo` variant is included. - -- transferFromBlockedWithMemo function name was also brought up - - seizeWithMemo is used because it explictly defines the intent - ---- - -## Migration Steps - -> Steps for integrators to adopt the new interface. Call out what is backwards-compatible, any -> deprecation timeline, and breaking changes that require action before activation. - -- **Backwards-compatible**: `burnBlocked` continues to work unchanged; no action required if you - don't need seize behavior yet -- **No breaking changes**: all existing selectors, events, and errors remain dialable -- **To adopt `seizeWithMemo`**: - 1. Grant `SEIZE_ROLE` to the account(s) that should be able to seize — with no `SEIZE_ROLE` - holders, no one can seize - 2. Configure `SEIZE_HOLDER_POLICY` so the accounts you want seizable are NOT authorized under - it — with no policy configured (unset = always-allow), no account is seizable - 3. Optionally configure `SEIZE_RECEIVER_POLICY` to restrict where seized funds may land; - unset defaults to always-allow (e.g. an unallowlisted treasury still works) -- **To reproduce `burnBlocked`'s destroy-supply outcome with seize**: `seizeWithMemo` alone does - not reduce `totalSupply`. Seize to a treasury/self address, then call `burn(amount)` from that - address if you want the supply destroyed -- No storage migration: `burnBlocked`'s storage and behavior are untouched by this change From a0ff0f01a5ba7fac2452632ae6a8fb88e2d643a6 Mon Sep 17 00:00:00 2001 From: Rayyan Alam <rayyan.alam@coinbase.com> Date: Thu, 20 Aug 2026 10:13:13 -0400 Subject: [PATCH 19/21] changelog(cobalt): add seize surface + burnBlocked deprecation specification Convert SEIZE_POLICY_POINTFORM.md point-form template to written specification following changelog/TEMPLATE_POINT_FORM.md structure. Verified all selectors, topic0 values, and signatures against src/interfaces/IB20.sol and src/lib/B20Constants.sol. Applied Simplified Technical English and Google technical-writing guidelines for clarity and consistency. Co-authored-by: OpenCode <opencode-noreply@coinbase.com> Co-Authored-By: Claude <noreply@anthropic.com> --- changelog/02_Cobalt_B20_seize.md | 153 ++++++++++++++----------------- 1 file changed, 68 insertions(+), 85 deletions(-) diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index af98147..a32350a 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -1,47 +1,27 @@ -# Seize Policy +# Seize surface + burnBlocked deprecation - **Feature Name**: seize - **Start Date**: 2026-08-17 -- **Authors**: Stephan +- **Authors**: Rayyan Alam - **Title**: Seize surface + burnBlocked deprecation ## Summary -This change adds the `seizeWithMemo` function to the shared `IB20` interface. Both B20 Asset and B20 Stablecoin inherit this function with no variant-specific logic. - -The original workflow to seize an asset required three steps: block the sender, call `burnBlocked`, then mint to the seizure account. This workflow destroys and then reissues supply. The `seizeWithMemo` function replaces this with a single administrative call that reassigns the balance directly from the source account to the destination account. - -The `burnBlocked` function remains available but is now deprecated. It continues to burn blocked assets through the existing flow. +Compliant asset issuers need freeze and seize models. This change adds `seizeWithMemo` to the shared `IB20` interface. Both B20 Asset and B20 Stablecoin inherit this function with no variant-specific logic. The original flow to seize an asset required three steps: block the account, call `burnBlocked`, then mint to reissue. The new `seizeWithMemo` function replaces this workaround with a single admin call that reassigns the balance directly. The `burnBlocked` function is deprecated but remains supported with unchanged behavior and no committed removal date. ## Motivation -Burn functionality and seize functionality must be explicitly distinct because they may be gated on different policies. Compliant asset issuers require freeze-and-seize models. - -Currently, the system achieves seizure through a workaround that uses `burnBlocked` to burn the asset and `mint` to reissue it to the seizure account. This workaround has three steps: - -1. Configure the `from` account as blocked under `TRANSFER_SENDER_POLICY` (the same policy that `burnBlocked` reads today — verified against `IB20.sol`) -2. Burn assets from the `from` account — this affects token supply -3. Mint assets to the seizure account - -This process emits a `BurnedBlocked` event and a `Transfer` event for the mint. It does not emit an event that indicates a seizure occurred. - -The main issues with this approach are: +Compliant asset issuers need freeze and seize models. Burn functionality must be explicitly distinct from seize because they may be gated on different policies. -- The events emitted are misleading for burning and minting operations -- The token supply is affected when assets are burned +Today the workaround to achieve a seizure is: call `burnBlocked` to burn the asset (gated by `TRANSFER_SENDER_POLICY`, the same policy that `burnBlocked` reads), then call `mint` to reissue the same amount to the seize account. This approach has two problems. First, the emitted events (burn + mint) misrepresent the operation as a burn. No single event indicates that a seizure occurred. Second, `totalSupply` changes when the balance is burned, then changes again on reissue. -The proposed `seizeWithMemo` function replaces this workaround with a direct transfer to the seizure account that emits a `Seized` event. The new flow: - -1. Add a policy to define which accounts are seizable -2. Call the `seizeWithMemo` function — this emits a `Seized` event - -This change makes seizure functionality and burning functionality explicitly distinct. +The `seizeWithMemo` function replaces that workaround with a direct transfer to the seize account. It emits a dedicated `Seized` event. This makes seizing and burning explicitly distinct. ## Background ### B20 Asset and B20 Stablecoin -B20 Asset and B20 Stablecoin are Base-native token contracts that extend ERC-20 with role-gated administrative functions and policy-gated operation checks. Each token stores fine-grained policy slots, keyed by operation and actor position, and consults the PolicyRegistry through `isAuthorized(policyId, account)` to decide whether a given account is allowed for that slot. For example, transfer flows can independently gate the sender, receiver, and executor, while other operations use their own dedicated policy ids, such as mint receiver and seize holder/receiver. This design separates access control from compliance logic: roles determine who may call privileged methods, and policy slots determine which accounts may participate in a given token operation. +B20 Asset and B20 Stablecoin are Base-native token contracts that extend ERC-20 with role-gated administrative functions and policy-gated operation checks. Each token stores fine-grained policy slots, keyed by operation and actor position. The token consults the Policy Registry through `isAuthorized(policyId, account)` to decide whether a given account is allowed for that slot. For example, transfer flows can independently gate the sender, receiver, and executor. Other operations use their own dedicated policy slots, such as mint receiver and seize holder/receiver. This design separates access control from compliance logic: roles determine who may call privileged methods, and policy slots determine which accounts may participate in a given token operation. ### Policy Registry @@ -51,22 +31,29 @@ The Policy Registry is a singleton precompile contract. Its responsibility is to ### Interface Changes -The following changes are made to the interface. Old-to-new mappings for selectors and topic0 values are verified via `cast sig`/`cast keccak` against `src/interfaces/IB20.sol` and `src/lib/B20Constants.sol`. +The following changes add new functions, events, errors, and role/policy constants to the `IB20` interface. The deprecated `burnBlocked` function and its associated error and event remain but are marked deprecated. + +**Deprecated (dialable, unchanged behavior, no removal date committed):** + +| Symbol | Selector / topic0 | +|--------|-------------------| +| `burnBlocked(address,uint256)` | `0xec0cf3dc` | +| `error AccountNotBlocked(address)` | `0x64a5cb46` | +| `event BurnedBlocked(address,address,uint256)` | `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` | + +**New additions:** -| Symbol | Selector / Topic0 | Status | Notes | -|--------|-------------------|--------|-------| -| `burnBlocked(address,uint256)` | `0xec0cf3dc` | **deprecated-dialable** | Unchanged behavior, no removal date committed | -| `AccountNotBlocked(address)` | `0x64a5cb46` | unchanged | Still exclusive to `burnBlocked` | -| `BurnedBlocked(address,address,uint256)` | `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` | unchanged | Still exclusive to `burnBlocked` | -| `seizeWithMemo(address,address,uint256,bytes32)` | `0xf916d81b` | NEW | Single-call admin seize, reassigns `from`'s balance to `to` | -| `SEIZE_ROLE()` | `0x3c7e9ba5` | NEW | Role value: `0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432` | -| `SEIZE_HOLDER_POLICY()` | `0xb279d311` | NEW | Value: `0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2` — gates who is seizable; inverted membership (seizable if NOT authorized) | -| `SEIZE_RECEIVER_POLICY()` | `0xb31da27f` | NEW | Value: `0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d` — gates seizure destination; unset slot = always-allow | -| `Seized(address indexed caller, address indexed from, address indexed to, uint256 amount)` | `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` | NEW | Seized event | -| `AccountNotSeizable(address)` | `0x91dbbc8d` | NEW | New error | -| `PausableFeature.SEIZE` | — | NEW | Dedicated pause vector, independent of `BURN` | +| Symbol | Selector / topic0 / value | +|--------|---------------------------| +| `seizeWithMemo(address,address,uint256,bytes32)` | `0xf916d81b` | +| `SEIZE_ROLE()` | `0x3c7e9ba5` (role value `0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432`) | +| `SEIZE_HOLDER_POLICY()` | `0xb279d311` (value `0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2`) | +| `SEIZE_RECEIVER_POLICY()` | `0xb31da27f` (value `0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d`) | +| `event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount)` | `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` | +| `error AccountNotSeizable(address)` | `0x91dbbc8d` | +| `PausableFeature.SEIZE` | Enum value appended after `BURN` | -The following code snippet shows the net additions to the `IB20` interface surface. The `burnBlocked` function and its associated error/event remain but are marked deprecated. +The net additions to the `IB20` interface surface are shown below. The `burnBlocked` function and its associated error and event remain but are marked deprecated. ```solidity // PausableFeature enum — SEIZE appended @@ -94,83 +81,79 @@ function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) e function burnBlocked(address from, uint256 amount) external; ``` -### Behavioural Changes +**Policy semantics:** + +- `SEIZE_HOLDER_POLICY` gates who is seizable. The membership is inverted: an account is seizable when it is **not** authorized under this policy. This mirrors the blocklist semantics of `burnBlocked`'s `TRANSFER_SENDER_POLICY` so the "blocked = seizable" model carries over. An unset slot reads as `0` (always-allow), so no account is seizable until an issuer configures the slot. This is a safe default. -#### New function: `seizeWithMemo` +- `SEIZE_RECEIVER_POLICY` gates the seize destination. It mirrors `MINT_RECEIVER_POLICY`: always enforced on the seize destination. An unset slot defaults to always-allow, so an unconfigured token may seize to any destination (a treasury need not be allowlisted). -The `seizeWithMemo(from, to, amount, memo)` function executes the following steps: +### Behavioural Changes -1. Check that `SEIZE` is not paused, else revert `ContractPaused(SEIZE)`. -2. Check that the caller has `SEIZE_ROLE`, else revert `AccessControlUnauthorizedAccount`. -3. Reject zero or self destinations, else revert `InvalidReceiver`. -4. Reject zero source, else revert `InvalidSender`. -5. Require `from` to be not authorized under `SEIZE_HOLDER_POLICY`, else revert `AccountNotSeizable`. -6. Require `to` to be allowed by `SEIZE_RECEIVER_POLICY`, else revert `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)`. -7. Check balance, else revert `InsufficientBalance`. +**New function `seizeWithMemo(from, to, amount, memo)` execution flow:** + +1. Check that `SEIZE` is not paused; else revert `ContractPaused(SEIZE)`. +2. Check that the caller holds `SEIZE_ROLE`; else revert `AccessControlUnauthorizedAccount`. +3. Reject zero or self destinations; else revert `InvalidReceiver`. +4. Reject zero source; else revert `InvalidSender`. +5. Require `from` to be not authorized under `SEIZE_HOLDER_POLICY`; else revert `AccountNotSeizable`. +6. Require `to` to be allowed by `SEIZE_RECEIVER_POLICY`; else revert `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)`. +7. Check balance; else revert `InsufficientBalance`. 8. Emit `Transfer`, then `Memo`, then `Seized`. -The function does not check allowance on the three transfer-side policies: `TRANSFER_SENDER_POLICY`, `TRANSFER_RECEIVER_POLICY`, or `TRANSFER_EXECUTOR_POLICY`. +The `memo` parameter attaches an on-chain reference (for example, a case ID or legal order) to each seizure for compliance and audit trails. It is surfaced via the `Memo` event. -A dedicated pause vector `PausableFeature.SEIZE` pauses `seizeWithMemo`. When paused, the function returns `ContractPaused(SEIZE)`. +The `seizeWithMemo` function bypasses all three transfer-side policies (`TRANSFER_SENDER_POLICY`, `TRANSFER_RECEIVER_POLICY`, `TRANSFER_EXECUTOR_POLICY`). A seizure is a privileged admin action gated by `SEIZE_ROLE` and the seize policies, not a peer transfer. Therefore transfer-side compliance gating does not apply. -Seizure is a transfer, not a burn. The balance moves from `from` to `to` and `totalSupply` remains unchanged. This is the key behavioral difference from `burnBlocked`, which sends to `address(0)` and reduces supply. +A dedicated pause vector `PausableFeature.SEIZE` pauses `seizeWithMemo`. When `SEIZE` is paused, the function reverts with `ContractPaused(SEIZE)`. -#### Storage layout change +Seize is a transfer, not a burn. The balance moves from `from` to `to` and `totalSupply` remains unchanged. This is the key behavioral difference from `burnBlocked`, which sends to `address(0)` and reduces supply. -A packed `seizePolicyIds` slot is added for `SEIZE_HOLDER_POLICY` and `SEIZE_RECEIVER_POLICY`. This change is additive. The `burnBlocked` storage remains unchanged. +**Storage layout change:** A packed `seizePolicyIds` slot is added for `SEIZE_HOLDER_POLICY` and `SEIZE_RECEIVER_POLICY`. This change is additive; `burnBlocked` storage remains unchanged. ### Examples -#### Before (old block+burn+mint workaround, still available, deprecated) +**Before (old block + burn + mint workaround, still available, deprecated):** -1. Configure `from` as blocked under `TRANSFER_SENDER_POLICY` -2. Call `burnBlocked(from, amount)` — burns `from`'s balance, gated by `BURN_BLOCKED_ROLE` -3. Call `mint(treasury, amount)` — separately reissues the same amount, gated by `MINT_ROLE` -4. Emits `Transfer(from, 0, amount)` + `BurnedBlocked` + `Transfer(0, treasury, amount)` — two independent operations, no single event ties the burn to the reissue +1. Configure `from` as blocked under `TRANSFER_SENDER_POLICY`. +2. Call `burnBlocked(from, amount)` — burns `from`'s balance, gated by `BURN_BLOCKED_ROLE`. +3. Call `mint(treasury, amount)` — separately reissues the same amount, gated by `MINT_ROLE`. +4. Emits: `Transfer(from, address(0), amount)` + `BurnedBlocked(caller, from, amount)` + `Transfer(address(0), treasury, amount)` — two independent operations. -#### After (new, single call) +**After (new, single call):** -1. Configure `from` as NOT authorized under `SEIZE_HOLDER_POLICY` (i.e. blocked) -2. Call `seizeWithMemo(from, treasury, amount, memo)` — gated by `SEIZE_ROLE` -3. Emits `Transfer(from, treasury, amount)` → `Memo(caller, memo)` → `Seized(caller, from, treasury, amount)` +1. Configure `from` as NOT authorized under `SEIZE_HOLDER_POLICY` (i.e., blocked). +2. Call `seizeWithMemo(from, treasury, amount, memo)` — gated by `SEIZE_ROLE`. +3. Emits, in order: + - `Transfer(from, treasury, amount)` + - `Memo(caller, memo)` + - `Seized(caller, from, treasury, amount)` ## Design Decisions & Alternatives Considered -### Final shipped shape - -The `seizeWithMemo` and `burnBlocked` functions use fully independent policy slots and pause vectors. +**Final shipped shape:** `seizeWithMemo` and `burnBlocked` use fully independent policy slots and pause vectors. - `seizeWithMemo` uses the new `SEIZE_HOLDER_POLICY` (for `from`) and `SEIZE_RECEIVER_POLICY` (for `to`), the new `SEIZE_ROLE`, and the new `PausableFeature.SEIZE`. - `burnBlocked` retains `TRANSFER_SENDER_POLICY`, `BURN_BLOCKED_ROLE`, and the `BURN` pause vector unchanged. - -Seize operations are rare, so the reserved lane in the transfer packed policy slot was not reused for seizure. That lane is kept open for a possible future transfer-side optimization where another hot-path transfer policy could be packed into the existing transfer slot without adding a second `SLOAD`. Because seizure is a cold-path / rare-path operation, it gets its own packed `seizePolicyIds` slot. +- Seize operations are rare, so the reserved lane in the transfer packed policy slot was not reused for seize. That lane is kept open for a possible future transfer-side optimization where another hot-path transfer policy could be packed into the existing transfer slot without adding a second `SLOAD`. Because seize is a cold-path/rare-path operation, it instead gets its own packed `seizePolicyIds` slot. ### Function Naming Alternatives -The shared seize-policy approach was rejected because burning and seizing have different effects: `burnBlocked` destroys supply, while `seizeWithMemo` reassigns balances. `burnBlocked` therefore remains independent, and no `burnBlockedWithMemo` variant is included. +A shared seize-policy approach was rejected: burning and seizing have different effects (see Behavioural Changes), so `burnBlocked` remains independent and no `burnBlockedWithMemo` variant is included. -The name `transferFromBlockedWithMemo` was also considered. `seizeWithMemo` was chosen because it explicitly defines the intent. +The name `transferFromBlockedWithMemo` was considered and rejected. `seizeWithMemo` names the intent (seizure) rather than the mechanism (blocked transfer). ## Migration Steps -### Backwards-compatible - -`burnBlocked` continues to work unchanged. No action is required if you do not need seizure behavior yet. - -### No breaking changes +**Backwards-compatible:** `burnBlocked` continues to work unchanged. No action is required if you do not need seize behavior yet. -All existing selectors, events, and errors remain dialable. +**No breaking changes:** All existing selectors, events, and errors remain dialable. -### To adopt `seizeWithMemo` +**To adopt `seizeWithMemo`:** 1. Grant `SEIZE_ROLE` to the account(s) that should be able to seize. With no `SEIZE_ROLE` holders, no one can seize. 2. Configure `SEIZE_HOLDER_POLICY` so the accounts you want seizable are NOT authorized under it. With no policy configured (unset = always-allow), no account is seizable. 3. Optionally configure `SEIZE_RECEIVER_POLICY` to restrict where seized funds may land. Unset defaults to always-allow (for example, an unallowlisted treasury still works). -### To reproduce `burnBlocked`'s destroy-supply outcome with seizure - -`seizeWithMemo` alone does not reduce `totalSupply`. Seize to a treasury or self address, then call `burn(amount)` from that address if you want the supply destroyed. - -### No storage migration +**To reproduce `burnBlocked`'s destroy-supply outcome with seize:** `seizeWithMemo` alone does not reduce `totalSupply`. Seize to a treasury or self address, then call `burn(amount)` from that address if you want the supply destroyed. -`burnBlocked`'s storage and behavior are untouched by this change. \ No newline at end of file +**No storage migration:** `burnBlocked`'s storage and behavior are untouched by this change. \ No newline at end of file From 34a1fbafef7b5e0350976a009ac890739b705d96 Mon Sep 17 00:00:00 2001 From: Rayyan Alam <rayyan.alam@coinbase.com> Date: Thu, 20 Aug 2026 10:13:40 -0400 Subject: [PATCH 20/21] feat: add new changes --- changelog/02_Cobalt_B20Asset_multiplier.md | 197 ++++----- ..._Cobalt_PolicyRegistry_composite_policy.md | 379 ++++++++++-------- 2 files changed, 290 insertions(+), 286 deletions(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index 5019fe1..60751b7 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -7,45 +7,29 @@ ## Summary -This change introduces a scheduled multiplier path for B20 Asset issuers running corporate actions such as stock splits and in-kind dividends. The change moves the multiplier setter from an instant path to a scheduled path aligned with ERC-8056. The change applies only to B20 Asset (Cobalt hardfork, upcoming). +This change introduces a scheduled multiplier setter for B20 Asset issuers running corporate actions. The multiplier setter moves from an instant path to a scheduled path aligned with ERC-8056. The change applies only to B20 Asset in the Cobalt hardfork. -Previously, B20 Asset multiplier updates were applied immediately through the `updateMultiplier` function. The new scheduled path allows issuers to define a new multiplier in advance and activate it at a specific future timestamp. This supports planned corporate actions and aligns B20 multiplier behavior with the ERC-8056 standard. +Two audiences are affected. Issuers and operators own the write path and use `updateUIMultiplier` to schedule a multiplier change, `cancelUIMultiplierUpdate` to clear a pending update, and the retained `updateMultiplier` instant setter as an emergency failsafe. All three write functions require `OPERATOR_ROLE`. Integrators, indexers, and custodians own the read and event path. They read the pending schedule through `newUIMultiplier()` and `effectiveAt()`, prefer the new `UIMultiplierUpdated` event over the deprecated `MultiplierUpdated` event, and handle lazy maturation of the multiplier flip. -The two use cases enabled are stock splits and in-kind dividends. The legacy `updateMultiplier` instant setter is retained as an emergency failsafe if a wrong value is passed. +The scheduled setter enables two corporate action use cases: stock splits (both forward and reverse) and in-kind dividends. Forward splits and reinvested dividends are value-neutral to raw venues and do not require an on-chain halt. Reverse splits are not value-neutral. Operators should pause `PausableFeature.TRANSFER` across the flip window for reverse splits, and should similarly bracket any instant `updateMultiplier` call used for a reverse-adjacent change. The legacy `updateMultiplier` instant setter is retained as an emergency failsafe to correct a wrong scheduled value. ## Motivation -The change solves the problem that B20 Asset did not have a scheduled setter for multiplier updates. Before this change, multiplier changes used only the instant `updateMultiplier` path, which does not allow issuers to schedule changes in advance. - -The ERC-8056 standard (https://eips.ethereum.org/EIPS/eip-8056) defines a standard for scheduling changes to an RWA token. This change conforms B20 Asset to that standard for scheduling multipliers. +Before this change, B20 Asset did not have a scheduled setter. Multiplier changes used only the instant `updateMultiplier` path. Corporate actions such as stock splits and in-kind dividends require advance notice. Exchanges, custodians, and off-chain accounting systems must prepare before the multiplier flips. An instant setter forces every downstream system to react at write time, which is not operable at issuer scale. A scheduled setter lets issuers commit to a target multiplier and a future effective timestamp on-chain. Downstream systems can read the pending update and prepare before it takes effect. This change conforms to ERC-8056, which defines a standard for scheduling changes to a real-world asset token. ## Background -### ERC-8056 - -ERC-8056 (https://eips.ethereum.org/EIPS/eip-8056) is an Ethereum Improvement Proposal that creates a standard for scheduling changes to an RWA token. The standard defines a core interface and optional extensions for UI balance queries, conversion helpers, and pending multiplier reads. - -### B20 Asset - -B20 Asset is an RWA token standard that conforms to the ERC-20 specification. Prior to this change, B20 Asset included these multiplier-related functions: +ERC-8056 (https://eips.ethereum.org/EIPS/eip-8056) defines a standard for scheduling changes to a real-world asset token. B20 Asset is an RWA token standard that conforms to the ERC-20 specification. Prior to this change, B20 Asset provided these functions: -- `updateMultiplier(uint256 newMultiplier)`: Applies the multiplier immediately -- `toScaledBalance(uint256)`: Legacy read/conversion alias that predates ERC-8056 naming -- `toRawBalance(uint256)`: Legacy read/conversion alias that predates ERC-8056 naming - -### Terminology - -- **WAD_PRECISION**: Fixed-point precision used to scale the multiplier, equal to `1e18` (18 decimal places). `1e18 = 1.0` multiplier. -- **UI multiplier**: The cosmetic multiplier that rescales displayed balances without minting, transferring, or rewriting raw balances (ERC-8056 terminology). -- **Raw balance**: The canonical on-chain token amount stored in the contract. -- **Scaled balance / UI balance**: The derived display balance calculated as `rawBalance * multiplier / WAD_PRECISION`. -- **Pending update**: A scheduled multiplier change that has not yet reached its `effectiveAt` timestamp. -- **OPERATOR_ROLE**: Pre-existing role required to call `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and `updateMultiplier`. This role is not introduced by this change and already gates `announce`. +- `updateMultiplier(uint256 newMultiplier)`: applies the multiplier immediately +- `toScaledBalance(uint256)` and `toRawBalance(uint256)`: legacy read and conversion aliases that predate the ERC-8056 naming ## Specs ### Interface Changes +The following tables describe new, renamed, and deprecated symbols. Selector and topic0 values are verified against the implementation. + #### Functions | Symbol | Selector | Status | Notes | @@ -55,10 +39,10 @@ B20 Asset is an RWA token standard that conforms to the ERC-20 specification. Pr | `newUIMultiplier()` | `0xdc767007` | new | ERC-8056 pending-schedule read (target multiplier). | | `effectiveAt()` | `0x97a4064f` | new | ERC-8056 pending-schedule read (flip timestamp). | | `totalSupplyUI()` | `0x9bea6429` | new | ERC-8056 Balances extension. | -| `MAX_UI_MULTIPLIER()` | `0x785c0cf0` | new | Reads the multiplier ceiling (`type(uint128).max`) without triggering the revert path. | +| `MAX_UI_MULTIPLIER()` | `0x785c0cf0` | new | Reads the multiplier ceiling (`type(uint128).max`), letting callers validate a proposed multiplier before scheduling without triggering the `InvalidMultiplier` revert path. | | `supportsInterface(bytes4)` | `0x01ffc9a7` | new | ERC-165 feature detection. | -| `uiMultiplier()` | `0xa60bf13d` | new alias | ERC-8056 core naming. Aliases `multiplier()`; same effective value. | -| `balanceOfUI(address)` | `0x437a9958` | new alias | ERC-8056 Balances extension. Aliases `scaledBalanceOf(address)`; same value. | +| `uiMultiplier()` | `0xa60bf13d` | new alias | ERC-8056 core naming. Aliases `multiplier()`; returns the same effective value. | +| `balanceOfUI(address)` | `0x437a9958` | new alias | ERC-8056 Balances extension. Aliases `scaledBalanceOf(address)`; returns the same value. | | `toUIAmount(uint256)` | `0x3248d4ff` | new | ERC-8056 Conversion extension. Byte-identical to `toScaledBalance`. | | `fromUIAmount(uint256)` | `0x65cd9b3c` | new | ERC-8056 Conversion extension. Byte-identical to `toRawBalance`. | | `multiplier()` | `0x1b3ed722` | unchanged (canonical name) | Canonical B20 name; `uiMultiplier()` is the ERC-8056 alias. | @@ -71,7 +55,7 @@ B20 Asset is an RWA token standard that conforms to the ERC-20 specification. Pr | Symbol | Topic0 | Status | Notes | | --- | --- | --- | --- | -| `UIMultiplierUpdated(uint256,uint256,uint256)` | `0x2205df4534432b2f60654a3fdb48737ffdaf3e9edb1a498bd985bc026b15b055` | new | ERC-8056 canonical multiplier-change event. Emitted by both setters. | +| `UIMultiplierUpdated(uint256,uint256,uint256)` | `0x2205df4534432b2f60654a3fdb48737ffdaf3e9edb1a498bd985bc026b15b055` | new | ERC-8056 canonical multiplier-change event. Parameters are `(oldMultiplier, newMultiplier, effectiveAtTimestamp)`. Emitted by both setters; the instant setter stamps `effectiveAtTimestamp = block.timestamp`. | | `UIMultiplierUpdateCancelled(uint256,uint256)` | `0x883856335ba5f60c18b9817c4505d3c7d3f6223dcf39516b30c508c46a5e1cad` | new | Signals a cleared pending update (via cancel or a superseding instant setter). | | `MultiplierUpdated(uint256)` | `0x4dbe4840d7465bd162f67814cea0b519567a2e0e578bcde61e7f4ced361e5a3d` | deprecated-still-emitted | Legacy event. Emitted only by the instant setter (`updateMultiplier`) alongside `UIMultiplierUpdated`. The scheduled setter emits only `UIMultiplierUpdated`. | @@ -83,7 +67,7 @@ B20 Asset is an RWA token standard that conforms to the ERC-20 specification. Pr | `EffectiveAtTooFar(uint256)` | `0x1ce214fa` | new | Thrown when `effectiveAt > type(uint64).max`. | | `UIMultiplierUpdateExists(uint256)` | `0x4481a68e` | new | Thrown when a live pending update already exists. | | `UIMultiplierUpdateDoesNotExist()` | `0xa7d6a5ca` | new | Thrown when cancel is called with no live pending update. | -| `InvalidMultiplier()` | `0x6f12f3dc` | unchanged | Zero or above-ceiling guard. Now also thrown by `updateUIMultiplier`. | +| `InvalidMultiplier()` | `0x6f12f3dc` | unchanged | Error symbol and selector unchanged. Zero or above-ceiling guard. Now also thrown by `updateUIMultiplier`, and newly thrown by `updateMultiplier` for `newMultiplier > type(uint128).max`. Pre-Cobalt `updateMultiplier` rejected only zero. See Compatibility behavior under Behavioural Changes. | #### Interface IDs advertised via `supportsInterface` @@ -95,64 +79,43 @@ B20 Asset is an RWA token standard that conforms to the ERC-20 specification. Pr | `0xd890fd71` | `IScaledUIAmountBalances` (ERC-8056 optional) | new advertisement | | `0x57854fc3` | `IScaledUIAmountConversion` (ERC-8056 optional) | new advertisement | -The following code snippet shows the net additions to the `IB20Asset` interface surface. The legacy `updateMultiplier`, `toScaledBalance`, and `toRawBalance` functions remain but are marked deprecated. +ERC-8056 conformance note: The optional `TransferWithUIAmount` event is intentionally not implemented. Scaled balances are derivable from the raw `Transfer` log and the active multiplier, so the event is redundant (see `docs/B20/Asset.md`). -```solidity -// ERC-8056 core (IScaledUIAmount) -function uiMultiplier() external view returns (uint256); +### Behavioural Changes -// ERC-8056 pending (IScaledUIAmountNewUIMultiplier) -function newUIMultiplier() external view returns (uint256); -function effectiveAt() external view returns (uint256); +#### Old Behavior -// ERC-8056 balances (IScaledUIAmountBalances) -function balanceOfUI(address account) external view returns (uint256); -function totalSupplyUI() external view returns (uint256); +The `updateMultiplier(uint256)` function applied the multiplier immediately. The change emitted the deprecated `MultiplierUpdated(uint256)` event. -// ERC-8056 conversion (IScaledUIAmountConversion) -function toUIAmount(uint256 rawAmount) external view returns (uint256); -function fromUIAmount(uint256 uiAmount) external view returns (uint256); +#### New Behavior -// ERC-165 -function supportsInterface(bytes4 interfaceId) external view returns (bool); +The `updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt)` function is the canonical path for routine corporate actions. The caller schedules one pending multiplier update for a future timestamp. The pending update becomes effective lazily on read when `block.timestamp >= effectiveAt`. No extra event fires at maturation time. Off-chain systems must read `uiMultiplier()` or watch the pending schedule. The `newUIMultiplier()` and `effectiveAt()` functions expose the live pending update. The `cancelUIMultiplierUpdate()` function clears the live pending update and emits `UIMultiplierUpdateCancelled(uint256,uint256)`. -// Constants -function MAX_UI_MULTIPLIER() external view returns (uint256); +#### Live Pending Definition -// New scheduled path -function updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) external; -function cancelUIMultiplierUpdate() external; +A pending update is **live** while `effectiveAt > block.timestamp` and **matured** once `effectiveAt <= block.timestamp`. The `updateUIMultiplier` function reverts with `UIMultiplierUpdateExists` only against a **live** pending update. A matured pending update does **not** block a new schedule; it is folded first (see Maturation). The `cancelUIMultiplierUpdate` function reverts with `UIMultiplierUpdateDoesNotExist` when there is no live pending update, including when the only pending update has already matured. -// Deprecated — retained for back-compat -function updateMultiplier(uint256 newMultiplier) external; -function toScaledBalance(uint256 rawBalance) external view returns (uint256); -function toRawBalance(uint256 scaledBalance) external view returns (uint256); -``` +#### Maturation and Materialization -### Behavioural Changes +After `effectiveAt`, reads **compute** the flipped value on the fly. Storage slot 1 (current multiplier) is **not** written at maturation. The matured value is "folded" into slot 1 only on the **next** `updateUIMultiplier`, `updateMultiplier`, or `cancelUIMultiplierUpdate` call. This fold emits **no** event. + +While matured-but-unfolded: `newUIMultiplier()` mirrors `uiMultiplier()` (both return the matured value, **not** 0), and `effectiveAt()` retains its now-past timestamp (**not** reset to 0) until the next setter folds it. -#### Execution Flow +Integration guidance: Detect a live pending update via `effectiveAt() > block.timestamp`. Never test `effectiveAt() == 0`. -**Old behavior:** -- `updateMultiplier(uint256)` applied the multiplier immediately. -- The change emitted the deprecated `MultiplierUpdated(uint256)` event. +#### Compatibility Behavior -**New behavior:** -- `updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt)` is the canonical path for routine corporate actions. -- The caller schedules one pending multiplier update for a future timestamp. -- The pending update becomes effective lazily on read when `block.timestamp >= effectiveAt`. -- No extra event fires at maturation time. Off-chain systems must read `uiMultiplier()` or watch the pending schedule. -- `newUIMultiplier()` and `effectiveAt()` expose the live pending update. -- `cancelUIMultiplierUpdate()` clears the live pending update and emits `UIMultiplierUpdateCancelled(uint256,uint256)`. +The `updateMultiplier(uint256)` function remains callable as a deprecated instant failsafe. It newly reverts with `InvalidMultiplier` for `newMultiplier > type(uint128).max`. Pre-Cobalt it rejected only zero; the ceiling is added in this change so `balance * multiplier` stays within `uint256` (matching the scheduled setter). The bound (~`3.4e20`× as a WAD multiplier) is unreachable for realistic corporate actions. This is a precise-guarantee note, not a practical breaking change. -**Compatibility behavior:** -- `updateMultiplier(uint256)` remains callable as a deprecated instant failsafe. -- The instant setter applies the multiplier immediately and clears any live pending update. -- If it clears a live pending update, it emits `UIMultiplierUpdateCancelled(...)` first, then emits the legacy `MultiplierUpdated(uint256)` event and the canonical `UIMultiplierUpdated(uint256,uint256,uint256)` event. +The instant setter applies the multiplier immediately and clears any pending update. If it clears a **live** pending update, it emits `UIMultiplierUpdateCancelled(...)` first, then emits the legacy `MultiplierUpdated(uint256)` event and the canonical `UIMultiplierUpdated(uint256,uint256,uint256)` event. If it clears a **matured** pending update, it folds the matured value silently (**no** `UIMultiplierUpdateCancelled`), then emits `MultiplierUpdated(uint256)` and `UIMultiplierUpdated(uint256,uint256,uint256)`. #### Access Control -All three write functions — `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and `updateMultiplier` — require `OPERATOR_ROLE`. `OPERATOR_ROLE` is pre-existing (not introduced by this change) and already gates `announce`. +All three write functions — `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and `updateMultiplier` — require `OPERATOR_ROLE`. This role is pre-existing (not introduced by this change) and already gates `announce`. + +#### Pause Interaction + +No new `PausableFeature` is added. The `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and `updateMultiplier` functions are not subject to any pause vector. For a reverse split (not value-neutral — see Summary), operators should manually pause `TRANSFER` across the flip window (see `docs/B20/Asset.md`). The instant `updateMultiplier` bypasses the scheduling window entirely, so a reverse-adjacent instant change should likewise be pause-bracketed. #### Gas Cost Implications @@ -160,70 +123,64 @@ Every scaled-view read (`uiMultiplier`, `multiplier`, `balanceOfUI`, `scaledBala #### Storage Layout Changes -- New field `PendingMultiplier pending` appended to the `base.b20.asset` ERC-7201 namespace. - - Namespace location: `0xfdc6d4552d1286ade4d9facdbf0fb50d2ec9b89a90e104f26fd277585e374b00`. - - Placed at `PENDING_OFFSET = 4`. -- Packed into a single 256-bit slot: - - Bits 0-127: `uint128 multiplier` (target). - - Bits 128-191: `uint64 effectiveAt` (flip timestamp). - - Bits 192-255: unused (32 bytes free for future packing). -- Additive change; pre-existing offsets 0-3 are unchanged: - - offset 0: `uint8 decimals` - - offset 1: `uint256 multiplier` (stored `0` still interpreted as `WAD_PRECISION` on read) - - offset 2: `mapping usedAnnouncementIds` - - offset 3: `mapping extraMetadata` -- Must match the `base/base` Rust precompile slot-for-slot (AGENTS.md invariant). - -### ERC-8056 View Aliases - -- `uiMultiplier()` returns the same value as `multiplier()`. -- `toUIAmount(raw)` returns the same value as `toScaledBalance(raw)`. `fromUIAmount(ui)` returns the same value as `toRawBalance(ui)`. -- `balanceOfUI(account)` returns the same value as `scaledBalanceOf(account)`. -- `totalSupplyUI()` equals `totalSupply() * uiMultiplier() / WAD_PRECISION`. +A new field `PendingMultiplier pending` is appended to the `base.b20.asset` ERC-7201 namespace. + +- Namespace location: `0xfdc6d4552d1286ade4d9facdbf0fb50d2ec9b89a90e104f26fd277585e374b00` +- Placed at `PENDING_OFFSET = 4` + +The field is packed into a single 256-bit slot: + +- Bits 0-127: `uint128 multiplier` (target) +- Bits 128-191: `uint64 effectiveAt` (flip timestamp) +- Bits 192-255: unused (32 bytes free for future packing) + +This is an additive change. Pre-existing offsets 0-3 are unchanged: + +- Offset 0: `uint8 decimals` +- Offset 1: `uint256 multiplier` (stored `0` still interpreted as `WAD_PRECISION` on read) +- Offset 2: `mapping usedAnnouncementIds` +- Offset 3: `mapping extraMetadata` + +The layout must match the `base/base` Rust precompile slot-for-slot (AGENTS.md invariant). + +#### ERC-8056 View Aliases + +Alias mappings (`uiMultiplier`↔`multiplier`, `balanceOfUI`↔`scaledBalanceOf`, `toUIAmount`↔`toScaledBalance`, `fromUIAmount`↔`toRawBalance`) are listed in the Functions table. Each returns the same value as its canonical counterpart. The `totalSupplyUI()` function equals `totalSupply() * uiMultiplier() / WAD_PRECISION`. + +#### Edge Cases and Precision + +Raw balances are **canonical** and are never rewritten by a multiplier flip. A flip only changes the derived scaled/UI view. + +Scaled views are computed as `raw * multiplier / WAD_PRECISION`, floored (integer division). The `fromUIAmount` / `toRawBalance` functions are also floored, so the round-trip is lossy by up to one unit (1 ULP) when `multiplier != WAD_PRECISION`. + +A deep **reverse split** can make floored dust economically visible at low decimals. Prefer 18 decimals for equities so it stays noise (see `docs/B20/Asset.md`). + +Scheduling boundary: `effectiveAt` must be strictly in the future (`effectiveAt <= block.timestamp` reverts `EffectiveAtInPast`); maturation triggers at `block.timestamp >= effectiveAt`. There is no overlap — a schedule cannot target "now," and the pending flips the instant its timestamp is reached. ### Examples -`updateUIMultiplier(newMultiplier, effectiveAt)` is the canonical path for corporate actions such as stock splits and reinvested dividends. Only one pending update can be live at a time. +The `updateUIMultiplier(newMultiplier, effectiveAt)` function is the canonical path for corporate actions such as stock splits and reinvested dividends. Only one pending update can be live at a time. 1. **Schedule**: Call `updateUIMultiplier(newMultiplier, effectiveAt)`. This requires `OPERATOR_ROLE`, and `effectiveAt` must be strictly in the future. 2. **Read the pending update**: While it is live, `newUIMultiplier()` returns the scheduled target, `effectiveAt()` returns the flip timestamp, and `uiMultiplier()` / `multiplier()` still return the current value. 3. **Let it mature**: Once `block.timestamp >= effectiveAt`, `uiMultiplier()` / `multiplier()` flip on read. No event fires at maturation. 4. **Or cancel it**: `cancelUIMultiplierUpdate()` clears a live pending update and emits `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)`. -To reorder overlapping actions, cancel and reschedule atomically in one announcement: - -```solidity -announce([cancelUIMultiplierUpdate(), updateUIMultiplier(...)], ...) -``` +To reorder overlapping actions, cancel and reschedule atomically in one announcement: `announce([cancelUIMultiplierUpdate(), updateUIMultiplier(...)], ...)`. ## Design Decisions & Alternatives Considered -The design retains the instant setter (`updateMultiplier`) as a deprecated dialable failsafe. +The instant setter (`updateMultiplier`) is retained as a deprecated dialable failsafe. It is the only on-chain recourse to correct or supersede a scheduled multiplier without waiting for `effectiveAt`. A cancel-then-schedule sequence cannot fix a bad scheduled value if the correction must apply immediately. Removing the instant setter would leave operators with no emergency override if a wrong `newMultiplier` or wrong `effectiveAt` were scheduled. It is gated by the pre-existing `OPERATOR_ROLE` (same as scheduling), not a narrower emergency-only role. -**Rationale:** -- It is the only on-chain recourse to correct or supersede a scheduled multiplier without waiting for `effectiveAt`. -- Cancel-then-schedule cannot fix a bad scheduled value if the correction must apply immediately. -- Removing it would leave operators no emergency override if a wrong `newMultiplier` or wrong `effectiveAt` were scheduled. +A single pending slot (one live update at a time) is used instead of a queue. This choice was made for simplicity, gas efficiency, and single-slot storage packing. Reordering overlapping actions is handled by an atomic cancel-then-schedule in one announcement (see Examples). ## Migration Steps -### Backwards-compatible - -`updateMultiplier`, `toScaledBalance`, and `toRawBalance` continue to work unchanged. No action is required if you do not need scheduled multiplier behavior yet. - -### No breaking changes - -All existing selectors, events, and errors remain dialable. The legacy `MultiplierUpdated` event is still emitted by the instant setter. - -### To adopt the scheduled path +Old functions work; there are no breaking changes. Migration steps are to update the workflow to use what is shown in the Examples section. -1. Replace direct calls to `updateMultiplier(newMultiplier)` with `updateUIMultiplier(newMultiplier, effectiveAt)` for routine corporate actions. -2. Use `newUIMultiplier()` and `effectiveAt()` to read the pending schedule. -3. Use `cancelUIMultiplierUpdate()` to clear a pending update before it matures. -4. For emergency corrections, retain the ability to call `updateMultiplier(newMultiplier)` which immediately applies the change and clears any pending update. -5. Update off-chain indexers to listen for `UIMultiplierUpdated` instead of (or in addition to) the deprecated `MultiplierUpdated` event. -6. Update display logic to prefer ERC-8056 naming: `uiMultiplier()`, `balanceOfUI()`, `toUIAmount()`, `fromUIAmount()`, `totalSupplyUI()`. +Deprecation lifecycle (two tiers): -### No storage migration +- `updateMultiplier` is retained **indefinitely** as the emergency failsafe. It is not scheduled for removal — it is the only immediate on-chain override for a mis-scheduled value or timestamp. +- `toScaledBalance`, `toRawBalance`, and the legacy `MultiplierUpdated` event are deprecated-dialable for backward compatibility, with **no removal committed**. A future hardfork may remove them; none is scheduled. -The pre-existing multiplier storage at offset 1 is untouched. The new `PendingMultiplier` slot at offset 4 is additive and starts in the no-pending state (`effectiveAt = 0`). \ No newline at end of file +Off-chain integrators: Detect a live pending update via `effectiveAt() > block.timestamp`, never `== 0` (see Maturation and Materialization under Behavioural Changes). Prefer listening for `UIMultiplierUpdated` over the deprecated `MultiplierUpdated`. \ No newline at end of file diff --git a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md index cbc06f3..9a8a483 100644 --- a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md +++ b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md @@ -7,226 +7,273 @@ ## Summary -This change adds two new `PolicyRegistry` policy types: `UNION` (OR) and `INTERSECT` (AND). These are called "composite policies." A composite policy authorizes an account by combining the results of 2 to 4 existing simple policies. This feature is for users of the policy registry who want to combine several policies without flattening them into one large list. A composite policy can reference only existing simple policies (`ALLOWLIST` or `BLOCKLIST`). A composite policy can never reference another composite policy, and can never reference a built-in sentinel (`ALWAYS_ALLOW` or `ALWAYS_BLOCK`). - -This feature ships at the **Cobalt** hardfork. Every mutating call — `createCompositePolicy` and `updateComposite` — is gated by `ActivationRegistry`, the same as every other mutating `PolicyRegistry` function. The feature does not activate automatically. Read-only calls — `isAuthorized`, `compositePolicyChildIds`, `MIN_COMPOSITE_CHILD_POLICIES`, and `MAX_COMPOSITE_CHILD_POLICIES` — remain callable regardless of activation status. +This change introduces two new `PolicyRegistry` policy types: `UNION` (OR) and `INTERSECT` (AND). These composite policies authorize by combining 2 to 4 existing simple policies. The feature is intended for users of the policy registry who want to combine multiple policies without flattening them into a single large list. Composite policies reference only existing simple policies (`ALLOWLIST` or `BLOCKLIST`), never other composites or built-in sentinels (`ALWAYS_ALLOW` or `ALWAYS_BLOCK`). This constraint is enforced at write time. The feature ships at the **Cobalt** hardfork. Every mutating call (`createCompositePolicy`, `updateComposite`) is gated by `ActivationRegistry`, the same as every other mutating `PolicyRegistry` function. Read-only calls (`isAuthorized`, `compositePolicyChildIds`, `MIN_COMPOSITE_CHILD_POLICIES`, `MAX_COMPOSITE_CHILD_POLICIES`) are always callable regardless of activation status. ## Motivation -The policy registry currently supports simple boolean policies through `isAuthorized`. Each policy independently returns `true` or `false`. In practice, access control often requires combining multiple policies. For example, an integrator may need to require KYC status *and* pro-user status, or may need to accept either pro-user status *or* lifetime-user status. - -Today, the only way to combine policies is for a user to listen for changes on separate allowlists and flatten the results into one list. This duplicates the underlying lists and requires infrastructure to keep the flattened copy in sync. - -This change lets a user create a composite policy that references other simple policies directly. A composite policy returns "is authorized" by combining the results of its referenced policies. This design simplifies maintenance: updating one child policy automatically updates every composite policy that references it. +The policy registry currently supports simple boolean policies via `isAuthorized`, where each policy independently returns true or false. In practice, access control often requires combining multiple policies. For example, a system may need KYC plus ProUser, or ProUser OR LifetimeUser. The current architecture requires a user to listen to changes on a different allowlist and flatten it into one, which duplicates lists and requires infrastructure to keep them up to date. This change allows policy reuse by creating composite policies that reference other simple policies. A composite policy returns "is authorized" by combining the results of its child policies. This simplifies maintenance because updating one child policy updates every composite that references it. ## Background -**B20 Token** +### B20 Token -B20 is a token precompile that uses policies to restrict operations such as transfers, minting, and seizing. For each restricted operation, B20 stores a Policy Registry policy ID in a dedicated policy scope. When an operation is attempted, B20 passes the relevant policy ID and account address to the Policy Registry. If the Policy Registry reports that the account is not authorized, B20 rejects the operation. +B20 is a token precompile that uses policies to restrict operations such as transfers, minting, and seizing. For each restricted operation, B20 stores a Policy Registry policy ID in a dedicated policy scope. When an operation is attempted, B20 passes the relevant policy ID and account address to the Policy Registry. If the account is not authorized, B20 rejects the operation. -**Policy Registry** +### Policy Registry -The Policy Registry is a singleton precompile contract used by B20 tokens. It manages a list of policies. B20 tokens call `isAuthorized(policyId, account)` against a policy ID stored on the relevant policy scope. The Policy Registry is currently used by B20 tokens for the `TRANSFER_FROM`, `TRANSFER_TO`, and `SEIZE_HOLDER` operations. +The Policy Registry is a singleton precompile contract used by B20 tokens. It manages a list of policies. B20 tokens call `isAuthorized(policyId, account)` against a policy ID stored on the relevant policy scope. Currently, B20 tokens use the Policy Registry for `TRANSFER_FROM`, `TRANSFER_TO`, and `SEIZE_HOLDER`. -**Simple policies** +### Simple Policies Simple policies are the non-composite policy types: `ALLOWLIST` and `BLOCKLIST`. -- `ALLOWLIST` maintains a list of addresses. It returns authorized `true` if the queried address is in the list, and `false` otherwise. -- `BLOCKLIST` maintains a list of addresses. It returns authorized `false` if the queried address is in the list, and `true` for all other addresses. +- `ALLOWLIST` has a list of addresses. It returns authorized `true` if the address is in the list, `false` otherwise. +- `BLOCKLIST` has a list of addresses. It returns authorized `false` if the address is in the list, `true` for all other addresses. ## Specs ### Interface Changes -**`PolicyType` enum** - -This change adds two new values to the `PolicyType` enum: +The following interface delta is verified via `cast sig` or `cast keccak` against `src/interfaces/IPolicyRegistry.sol`: -```solidity -enum PolicyType { - BLOCKLIST, - ALLOWLIST, - UNION, // = 2, OR — authorized if any child policy authorizes the account - INTERSECT // = 3, AND — authorized only if every child policy authorizes the account -} -``` +| Symbol | Selector / Topic0 | Status | Notes | +|--------|-------------------|--------|-------| +| `createCompositePolicy(address,uint8,uint64[])` | `0x6fdd1491` | NEW | `PolicyType` ABI-encodes as `uint8`; creates a UNION/INTERSECT composite | +| `updateComposite(uint64,uint64[])` | `0xbfe142c0` | NEW | Full replacement of the child set | +| `compositePolicyChildIds(uint64)` | `0x7c40df74` | NEW (view) | Returns the stored child set verbatim; empty for non-composites | +| `MIN_COMPOSITE_CHILD_POLICIES()` | `0xb3ae29f7` | NEW (view) | Returns `2` | +| `MAX_COMPOSITE_CHILD_POLICIES()` | `0x54309870` | NEW (view) | Returns `4` | +| `ChildPoliciesOutsideOfRange()` | `0x697ec868` | NEW (error) | Child count not in `[2, 4]`; distinct from `BatchSizeTooLarge` (64-account cap) | +| `InvalidChildPolicy(uint64)` | `0x46508ef6` | NEW (error) | Child is itself a composite or a built-in sentinel | +| `CompositePolicyUpdated(uint64,address,uint64[])` | `0x4ff6adaab31b0df87aa7b8b7320c52b8b3b5eede3bf28a6baaaa8b8b7e1d6363` | NEW (event) | Topic0; emitted on composite create and every update; carries full post-update set | +| `isAuthorized(uint64,address)` | (unchanged) | extended | Now dispatches composites (live child evaluation); signature unchanged | +| `createPolicy(address,uint8)` | `0xca5d55f6` | extended | Does not accept composite policy types; reverts with `IncompatiblePolicyType` (see below) | +| `createPolicyWithAccounts(address,uint8,address[])` | `0xa2d3044f` | extended | Same composite-policy-type rejection | -**New function: `createCompositePolicy`** +**Solidity interface additions:** ```solidity -function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata childPolicyIds) - external - returns (uint64 newPolicyId); -``` - -`childPolicyIds` must contain between 2 and 4 entries, inclusive. These bounds are exposed as `MIN_COMPOSITE_CHILD_POLICIES` and `MAX_COMPOSITE_CHILD_POLICIES`. Every entry in `childPolicyIds` must be an existing simple policy (`ALLOWLIST` or `BLOCKLIST`). An entry can never be another composite policy, and can never be a built-in sentinel (`ALWAYS_ALLOW` or `ALWAYS_BLOCK`). - -The function reverts in this canonical order: - -1. `ZeroAddress` — `admin` is the zero address. -2. `IncompatiblePolicyType` — `policyType` is not `UNION` or `INTERSECT`. -3. `ChildPoliciesOutsideOfRange` — the number of entries in `childPolicyIds` is outside `[2, 4]`. -4. `PolicyNotFound` — a child policy does not exist. The function checks this in one pass over the whole set. -5. `InvalidChildPolicy(uint64 childPolicyId)` — a child policy is itself composite or is a built-in sentinel. The function checks this in a second pass over the set. - -On success, the function emits, in order: - -1. `PolicyCreated(uint64 indexed policyId, address indexed creator, PolicyType policyType)` -2. `PolicyAdminUpdated(uint64 indexed policyId, address indexed previousAdmin, address indexed newAdmin)`, with `previousAdmin = address(0)` and `newAdmin = admin` -3. `CompositePolicyUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds)` - -**New function: `updateComposite`** - -```solidity -function updateComposite(uint64 policyId, uint64[] calldata childPolicyIds) external; +// SPDX-License-Identifier: MIT +// File: src/interfaces/IPolicyRegistry.sol + +interface IPolicyRegistry { + // New enum values (UNION = 2, INTERSECT = 3) + enum PolicyType { + ALLOWLIST, + BLOCKLIST, + UNION, + INTERSECT + } + + // New errors + error ChildPoliciesOutsideOfRange(); + error InvalidChildPolicy(uint64 childPolicyId); + + // New events + event CompositePolicyUpdated( + uint64 indexed policyId, + address indexed updater, + uint64[] childPolicyIds + ); + + // New functions + function createCompositePolicy( + address admin, + PolicyType policyType, + uint64[] calldata childPolicyIds + ) external returns (uint64 policyId); + + function updateComposite( + uint64 policyId, + uint64[] calldata childPolicyIds + ) external; + + function compositePolicyChildIds(uint64 policyId) + external + view + returns (uint64[] memory); + + function MIN_COMPOSITE_CHILD_POLICIES() external pure returns (uint256); + function MAX_COMPOSITE_CHILD_POLICIES() external pure returns (uint256); + + // Extended: now dispatches composites + function isAuthorized(uint64 policyId, address account) + external + view + returns (bool); + + // Extended: reject UNION/INTERSECT + function createPolicy(address admin, PolicyType policyType) + external + returns (uint64 policyId); + + function createPolicyWithAccounts( + address admin, + PolicyType policyType, + address[] calldata accounts + ) external returns (uint64 policyId); +} ``` -This function fully replaces the child-policy set of an existing composite policy. The interface provides no partial-update operation and no operation to clear the list. The function applies the same child-validity rules as `createCompositePolicy`: every entry must be an existing simple policy, and the set must contain between 2 and 4 entries. +Two new values are introduced in the `PolicyType` enum: -The function reverts in this canonical order: +- `UNION = 2` — authorized if *any* child policy authorizes the account (OR) +- `INTERSECT = 3` — authorized only if *every* child policy authorizes the account (AND) -1. `PolicyNotFound` — the composite policy referenced by `policyId` does not exist. -2. `IncompatiblePolicyType` — `policyId` refers to a simple policy, not a composite policy. -3. `Unauthorized` — the caller is not the current admin of the composite policy. This check fires before the child-count check. -4. `ChildPoliciesOutsideOfRange` — the number of entries in the new `childPolicyIds` is outside `[2, 4]`. -5. `PolicyNotFound` — a new child policy does not exist. -6. `InvalidChildPolicy(uint64 childPolicyId)` — a new child policy is itself composite or is a built-in sentinel. +#### `createCompositePolicy(admin, policyType, childPolicyIds)` -On success, the function emits only: +- `childPolicyIds` must be between 2 and 4 entries (`MIN_COMPOSITE_CHILD_POLICIES` / `MAX_COMPOSITE_CHILD_POLICIES`). The cap of 4 bounds worst-case `isAuthorized` gas and the authorization audit surface. +- Every child must be an *existing simple* policy (`ALLOWLIST` or `BLOCKLIST`) — never another composite, never a built-in sentinel (`ALWAYS_ALLOW` or `ALWAYS_BLOCK`). +- Canonical revert order: + 1. `ZeroAddress` (admin) + 2. `IncompatiblePolicyType` (policyType not UNION/INTERSECT) + 3. `ChildPoliciesOutsideOfRange` (count not in `[2,4]`) + 4. `PolicyNotFound` (a child doesn't exist, checked as one pass over the whole set) + 5. `InvalidChildPolicy` (a child is itself composite/sentinel, checked as a second pass) +- Emits, in order: + 1. `PolicyCreated(policyId, creator, policyType)` + 2. `PolicyAdminUpdated(policyId, address(0), admin)` + 3. `CompositePolicyUpdated(policyId, creator, childPolicyIds)` -- `CompositePolicyUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds)` +#### `updateComposite(policyId, childPolicyIds)` -The function does not emit `PolicyAdminUpdated`, because `updateComposite` never changes the policy's admin. +- Full replacement of the child set. There is no partial-update or clear-the-list operation. +- Same child-validity rules as `createCompositePolicy` (existing simple policies only, 2 to 4 of them). +- Canonical revert order: + 1. `PolicyNotFound` (composite itself doesn't exist) + 2. `IncompatiblePolicyType` (`policyId` is a simple policy) + 3. `Unauthorized` (caller isn't the current admin — fires before the count check) + 4. `ChildPoliciesOutsideOfRange` + 5. `PolicyNotFound` (a new child doesn't exist) + 6. `InvalidChildPolicy` +- Emits only `CompositePolicyUpdated(policyId, updater, childPolicyIds)`. No `PolicyAdminUpdated` is emitted because the admin does not change. -**Existing functions: new revert path** +#### Existing functions with changed revert behavior for identical calldata -`createPolicy` and `createPolicyWithAccounts` are both already live at Beryl. Starting at Cobalt, both functions also revert with `IncompatiblePolicyType` when `policyType` is `UNION` or `INTERSECT`. This is a previously unreachable revert path, because the `UNION` and `INTERSECT` enum values did not exist before Cobalt. +`createPolicy` and `createPolicyWithAccounts` (both already live on Beryl) are simple-policy constructors. They do not accept composite policy types and revert with `IncompatiblePolicyType`. -**Verified errors and events** +This is not merely a newly-reachable branch. The revert for the *same calldata* changes across the fork. Pre-Cobalt the `PolicyType` enum had only `BLOCKLIST` and `ALLOWLIST`, so calldata carrying type byte `2` or `3` failed ABI enum decode (Solidity reference: `Panic(0x21)`, enum-conversion out of range). Post-Cobalt byte `2` or `3` decodes cleanly as `UNION` or `INTERSECT`, then the explicit guard reverts `IncompatiblePolicyType`. -The `IPolicyRegistry` interface defines the following errors used by this feature: `ZeroAddress()`, `IncompatiblePolicyType()`, `ChildPoliciesOutsideOfRange()`, `PolicyNotFound()`, `InvalidChildPolicy(uint64 childPolicyId)`, and `Unauthorized()`. It defines the following events used by this feature: `PolicyCreated(uint64 indexed policyId, address indexed creator, PolicyType policyType)`, `PolicyAdminUpdated(uint64 indexed policyId, address indexed previousAdmin, address indexed newAdmin)`, and `CompositePolicyUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds)`. These signatures were verified against `src/interfaces/IPolicyRegistry.sol`. +[TODO: verify against source] The exact pre-Cobalt revert of the *Rust precompile* for an out-of-range `PolicyType` byte is not asserted here. The Solidity mock does not model ABI enum decode. Confirm via `base-forge test` before publishing, or document only as Solidity-reference behaviour. ### Behavioural Changes -**No B20 code changes required** - -A composite policy ID is passed to a B20 policy slot exactly like a simple policy ID. B20 requires zero code changes to support composite policies, because it stores policy slots as an opaque `uint64` and calls `isAuthorized` generically. - -**Live, short-circuiting evaluation** - -`isAuthorized` on a composite policy evaluates live on every call. It is not a snapshot taken at creation time or at the time of the last `updateComposite` call. On each call, the registry reads each child policy's current membership state. - -- `UNION` short-circuits to `true` on the first child that authorizes the account. -- `INTERSECT` short-circuits to `false` on the first child that does not authorize the account. - -Recursion never exceeds a depth of 1. Every child is validated to be a simple policy at write time, so a composite policy's children can never themselves be composite policies. - -**Child order affects gas, never the outcome** - -`UNION` and `INTERSECT` are commutative operations. Reordering `childPolicyIds` never changes whether an account is authorized. Reordering only shifts where the short-circuit lands. To save gas, place the child most likely to short-circuit first: the broadest `ALLOWLIST` for `UNION`, or the tightest `BLOCKLIST` for `INTERSECT`. - -**Duplicate child IDs are allowed** - -The registry neither sorts nor deduplicates the stored child-policy list. Deduplication would add gas cost to every write, for a set already capped at 4 entries, for little practical benefit. `UNION` and `INTERSECT` are idempotent under duplicate entries, so duplicates do not change the evaluation result. - -**Composites cannot shrink below the minimum** - -A composite policy can never shrink below 2 children through `updateComposite`. The function enforces the same `[2, 4]` range as `createCompositePolicy`, so there is no path to an empty or undersized composite policy. - -**Renounced child policies keep working** - -If a child policy's admin renounces administration, the parent composite policy keeps working. `renounceAdmin` only clears the child policy's admin and freezes its future membership changes. The child policy continues to exist, and `isAuthorized` on it continues to resolve normally. The composite policy keeps evaluating that child exactly as before. - -**State changes** - -- New state: `mapping(uint64 policyId => uint64[] childPolicyIds) children`, at offset 4 within the `base.policy_registry` ERC-7201 namespace. This offset is a namespace offset, not a literal EVM storage slot 4. `[TODO: verify against source — no implementation file with this storage layout was found in this repository]` -- Reused state: one shared global counter, `nextCounter`, shared across simple and composite policies. The counter starts at 2, because `0` and `1` are reserved for the built-in sentinels `ALWAYS_ALLOW` and `ALWAYS_BLOCK`. A composite policy ID encodes its `PolicyType` in the top byte and the next available counter value in the low 56 bits. This is the same encoding scheme used for simple policies, not a separate counter. `[TODO: verify against source — no implementation file with this encoding scheme was found in this repository]` +- A composite policy ID is passed to a B20 policy slot exactly like a simple policy ID. B20 needs **zero code changes**, since it stores policy slots as an opaque `uint64` and calls `isAuthorized` generically. +- `isAuthorized` on a composite is live and short-circuiting, not a snapshot: + - It reads each child's *current* membership on every call. There is no snapshot from creation or the last `updateComposite`. + - `UNION` short-circuits `true` on the first authorizing child. + - `INTERSECT` short-circuits `false` on the first non-authorizing child. + - Recursion never exceeds depth 1, because every child is validated to be a simple policy at write time. A composite's children can never themselves be composites. +- `isAuthorized` on a well-formed but **never-created** composite ID collapses to empty-child-set semantics: + - `UNION` → `false` (deny-all) + - `INTERSECT` → `true` (**allow-all** — an AND over zero children is vacuously true). + This parallels the simple-policy empty-set rule (ALLOWLIST → `false`, BLOCKLIST → `true`). Consumers that store a composite ID (e.g., on a B20 policy slot) MUST validate `policyExists(policyId)` at write time. A typo'd INTERSECT ID would silently behave as `ALWAYS_ALLOW`. +- Gas: a composite reads more policy IDs than a simple policy (its child list, plus each evaluated child's membership), so `isAuthorized` on a composite costs more gas than on a simple policy. +- Child order affects gas, never the outcome: + - `UNION` and `INTERSECT` are commutative, so reordering `childPolicyIds` never changes whether an account is authorized. + - It only shifts where the short-circuit lands. Put the child most likely to short-circuit first (broadest ALLOWLIST for `UNION`, tightest BLOCKLIST for `INTERSECT`) to save gas. +- Duplicate child IDs are allowed. The registry neither sorts nor deduplicates the stored child list. Deduplicating would cost extra gas on every write for a set already capped at 4 entries, for little value. `UNION` and `INTERSECT` are idempotent under duplicates anyway. +- A composite can never shrink below 2 children via `updateComposite`. It enforces the same `[2,4]` range as creation, so there is no path to an empty or undersized composite. +- If a child policy's admin renounces, the parent composite keeps working. `renounceAdmin` only clears the child's 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. +- State changes: + - New state: `mapping(uint64 policyId => uint64[] childPolicyIds) children`, appended at **offset 4** within the `base.policy_registry` ERC-7201 namespace (not a literal EVM slot 4). It is appended so existing state at offsets 0–3 is unmodified and no storage migration is needed. + - Reused state: one shared global counter (`nextCounter`) across simple and composite policies, starting at 2 (`0` and `1` are reserved for `ALWAYS_ALLOW` and `ALWAYS_BLOCK`). A composite policy ID encodes `PolicyType` in the top byte and the next available counter value in the low 56 bits. This uses the same encoding scheme as simple policies, not a separate counter. ### Examples -**Before: assigning a simple policy** - -An integrator assigns one existing policy directly to a B20 policy scope: - -```solidity -b20.updatePolicy(TRANSFER_SENDER_POLICY, allowlistPolicyId); -``` - -Only accounts in `allowlistPolicyId` can transfer. - -**After: creating and assigning a composite policy** - -Assume two existing simple policies: `employeesPolicyId` (`ALLOWLIST`) and `approvedRegionPolicyId` (`ALLOWLIST`). - -Create a `UNION` composite policy: - -```solidity -policyRegistry.createCompositePolicy(admin, UNION, [employeesPolicyId, approvedRegionPolicyId]); -``` - -This call emits, in order: +#### Before (simple policy) + +- Assign one existing policy directly to a B20 policy scope: + ```solidity + b20.updatePolicy(TRANSFER_SENDER_POLICY, allowlistPolicyId) + ``` +- Only accounts in `allowlistPolicyId` can transfer. + +#### After (composite policy) + +- Assume existing simple policies: `employeesPolicyId` (ALLOWLIST), `approvedRegionPolicyId` (ALLOWLIST). +- Create a UNION composite: + ```solidity + policyRegistry.createCompositePolicy(admin, UNION, [employeesPolicyId, approvedRegionPolicyId]) + ``` +- Emits: `PolicyCreated(policyId, admin, UNION)` + `PolicyAdminUpdated(policyId, 0, admin)` + `CompositePolicyUpdated(policyId, admin, [children])`. +- Assign to B20: + ```solidity + b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId) + ``` +- B20 has no composite-specific logic. It passes the policy ID to the registry as usual. + +#### Updating a composite + +- Call: + ```solidity + policyRegistry.updateComposite(compositePolicyId, [employeesPolicyId, trustedPartnersPolicyId]) + ``` +- Emits: `CompositePolicyUpdated(policyId, admin, [newChildren])`. +- B20 continues using the same policy ID. No token-side update is required. +- Future authorization checks use the new child set immediately (live evaluation, no snapshot). -- `PolicyCreated(policyId, admin, UNION)` -- `PolicyAdminUpdated(policyId, address(0), admin)` -- `CompositePolicyUpdated(policyId, admin, [employeesPolicyId, approvedRegionPolicyId])` - -Assign the new composite policy to B20: - -```solidity -b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId); -``` - -B20 requires no composite-specific logic. It passes the policy ID to the registry exactly as it would for a simple policy. - -**Updating a composite policy** - -```solidity -policyRegistry.updateComposite(compositePolicyId, [employeesPolicyId, trustedPartnersPolicyId]); -``` - -This call emits: - -- `CompositePolicyUpdated(policyId, admin, [employeesPolicyId, trustedPartnersPolicyId])` +## Design Decisions & Alternatives Considered -B20 continues using the same policy ID. No token-side update is required. Because evaluation is live, future authorization checks use the new child set immediately — the registry does not take a snapshot. +### Decision: Two explicit policy types (`UNION`, `INTERSECT`) with a single `createCompositePolicy` function and full-replacement `updateComposite` -## Design Decisions & Alternatives Considered +### Alternative 1: One generic COMPOSITE type -**Decision**: Provide two explicit policy types, `UNION` and `INTERSECT`, with a single creation function, `createCompositePolicy`, and a full-replacement update function, `updateComposite`. +- Store a separate operator (AND, OR, NOT, XOR) in composite storage. +- Rejected because: + - Requires storing both "composite" flag and the operator. + - Adds storage reads or more complicated ID encoding. + - Unnecessary complexity before there is a requirement for NOT, XOR, or nested expressions. + - Generic boolean expressions create a larger gas and audit surface. -**Alternative 1: One generic `COMPOSITE` type** +### Alternative 2: Token-level policy groups -This alternative would store a separate operator (`AND`, `OR`, `NOT`, `XOR`) in composite storage. It was rejected because it requires storing both a "composite" flag and the operator, and adds either extra storage reads or a more complicated ID-encoding scheme. It also adds unnecessary complexity before there is any requirement for `NOT`, `XOR`, or nested expressions. A generic boolean-expression design creates a larger gas and audit surface than the chosen approach. +- Keep Policy Registry unchanged; have each B20 token store multiple policy IDs and an operator. +- Rejected because: + - Composite policies would not be reusable entities. + - Requires changes across B20, token variants, factories, and token hot paths. + - Does not support sharing one composite policy across multiple tokens. + - Spreads complexity across more contracts. -**Alternative 2: Token-level policy groups** +### Alternative 3: Incremental child updates -This alternative would keep the Policy Registry unchanged, and instead have each B20 token store multiple policy IDs plus an operator. It was rejected because composite policies would not be reusable entities under this design. It requires changes across B20, its token variants, factories, and token hot paths. It does not support sharing one composite policy across multiple tokens, and it spreads complexity across more contracts than the chosen approach. +- Provide `addCompositeOperand` and `removeCompositeOperand` functions. +- Rejected because: + - Child list is capped at 4 entries. + - Dynamic-array mutation requires swap/remove, length, and deduplication logic. + - Full replacement is simpler and atomic. + - Caller can resend the complete list at low cost. -**Alternative 3: Incremental child updates** +### Alternative 4: Separate creator functions -This alternative would provide `addCompositeOperand` and `removeCompositeOperand` functions instead of full-set replacement. It was rejected because the child list is capped at 4 entries, so the benefit of incremental mutation is limited. Dynamic-array mutation requires swap/remove logic, length tracking, and deduplication logic. Full replacement is simpler and atomic, and a caller can resend the complete list at low cost. +- Use `createUnionPolicy` and `createIntersectPolicy`. +- Rejected because: + - Doubles the creation API surface. + - A single `createCompositePolicy` keeps policy creation consistent. + - Future operators would require additional functions. -**Alternative 4: Separate creator functions** +### Alternative 5: Nested composites (a composite referencing another composite) -This alternative would use `createUnionPolicy` and `createIntersectPolicy` instead of one function that takes a `policyType` argument. It was rejected because it doubles the creation API surface. A single `createCompositePolicy` function keeps policy creation consistent with the existing `createPolicy` pattern. Adding future operators under the separate-function design would require additional functions for each new operator. +- Allow composite children, to some bounded depth, instead of restricting children to simple `ALLOWLIST` and `BLOCKLIST` policies. +- Rejected because: + - Restricting children to simple policies guarantees `isAuthorized` recursion terminates at depth 1. There is no cycle risk and no unbounded traversal. + - Bounds worst-case gas and the audit surface of authorization evaluation. + - No demonstrated need for nested expressions. A wrapper composite can be introduced later if one ever arises. ## Migration Steps -This change is backwards-compatible. Existing simple policies (`ALLOWLIST` and `BLOCKLIST`) continue to work unchanged. No action is required if you do not need composite behavior. - -For users currently flattening multiple lists into one policy, follow these steps: +- **Backwards-compatible**: Existing simple policies (ALLOWLIST and BLOCKLIST) continue to work unchanged. No action is required if you do not need composite behavior. -1. Identify the simple policies you want to combine. -2. Call `policyRegistry.createCompositePolicy(admin, UNION or INTERSECT, [childPolicyIds])`. -3. Update the B20 token's policy scope to point to the new composite policy ID: - ```solidity - b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId); - ``` - No B20 contract change is required. B20 treats the composite policy ID as an opaque `uint64`, exactly like a simple policy ID. -4. Remove the old flattened policy if it is no longer needed. +- **For users currently flattening multiple lists into one policy**: + 1. Identify the simple policies you want to combine. + 2. Call `policyRegistry.createCompositePolicy(admin, UNION or INTERSECT, [childPolicyIds])`. + 3. Update the B20 token's policy scope to point to the new composite policy ID: + ```solidity + b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId) + ``` + No B20 contract change is required. B20 treats the composite ID as an opaque `uint64` exactly like a simple policy ID. + 4. Remove the old flattened policy if no longer needed. -There are no breaking changes. All existing selectors, events, and errors remain dialable at Cobalt. +- **No breaking changes**: All existing selectors, events, and errors remain dialable at Cobalt. -There is no storage migration required. The `children` mapping is a new, empty mapping at ERC-7201 offset 4. Cobalt activation does not modify existing `PolicyRegistry` state at offsets 0–3. +- **No storage migration**: `children` is a new, empty mapping at ERC-7201 offset 4. Existing `PolicyRegistry` state at offsets 0–3 is unmodified by Cobalt activation. \ No newline at end of file From 8ea8e7d5ae199f5260e7878a3dcfb5f8e10e2552 Mon Sep 17 00:00:00 2001 From: Rayyan Alam <rayyan.alam@coinbase.com> Date: Thu, 20 Aug 2026 10:26:50 -0400 Subject: [PATCH 21/21] feat: update policy registry --- ..._Cobalt_PolicyRegistry_composite_policy.md | 287 +++++++----------- 1 file changed, 115 insertions(+), 172 deletions(-) diff --git a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md index 9a8a483..bc427b8 100644 --- a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md +++ b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md @@ -7,11 +7,11 @@ ## Summary -This change introduces two new `PolicyRegistry` policy types: `UNION` (OR) and `INTERSECT` (AND). These composite policies authorize by combining 2 to 4 existing simple policies. The feature is intended for users of the policy registry who want to combine multiple policies without flattening them into a single large list. Composite policies reference only existing simple policies (`ALLOWLIST` or `BLOCKLIST`), never other composites or built-in sentinels (`ALWAYS_ALLOW` or `ALWAYS_BLOCK`). This constraint is enforced at write time. The feature ships at the **Cobalt** hardfork. Every mutating call (`createCompositePolicy`, `updateComposite`) is gated by `ActivationRegistry`, the same as every other mutating `PolicyRegistry` function. Read-only calls (`isAuthorized`, `compositePolicyChildIds`, `MIN_COMPOSITE_CHILD_POLICIES`, `MAX_COMPOSITE_CHILD_POLICIES`) are always callable regardless of activation status. +This feature introduces two new `PolicyRegistry` policy types: `UNION` (OR) and `INTERSECT` (AND), collectively called composite policies. A composite policy authorizes by combining the results of two to four existing simple policies (`ALLOWLIST` or `BLOCKLIST`). The children of composite policies are only existing simple policies; this constraint is enforced at write time. The feature enables policy reuse by allowing a single composite policy to reference multiple simple policies. Updating one child policy automatically updates every composite that references it. ## Motivation -The policy registry currently supports simple boolean policies via `isAuthorized`, where each policy independently returns true or false. In practice, access control often requires combining multiple policies. For example, a system may need KYC plus ProUser, or ProUser OR LifetimeUser. The current architecture requires a user to listen to changes on a different allowlist and flatten it into one, which duplicates lists and requires infrastructure to keep them up to date. This change allows policy reuse by creating composite policies that reference other simple policies. A composite policy returns "is authorized" by combining the results of its child policies. This simplifies maintenance because updating one child policy updates every composite that references it. +The policy registry currently supports simple boolean policies through `isAuthorized`, where each policy independently returns true or false. In practice, access control often requires combining multiple policies. For example, an application might require both KYC verification and ProUser status, or either ProUser status or LifetimeUser status. The current architecture requires a user to listen to changes on a different allowlist and flatten into one, which duplicates lists and requires infrastructure to keep them up to date. This feature allows policy reuse by creating composite policies that combine the results of other policies, which simplifies maintenance because updating one child policy updates every composite that references it. ## Background @@ -21,7 +21,7 @@ B20 is a token precompile that uses policies to restrict operations such as tran ### Policy Registry -The Policy Registry is a singleton precompile contract used by B20 tokens. It manages a list of policies. B20 tokens call `isAuthorized(policyId, account)` against a policy ID stored on the relevant policy scope. Currently, B20 tokens use the Policy Registry for `TRANSFER_FROM`, `TRANSFER_TO`, and `SEIZE_HOLDER`. +The Policy Registry is a singleton precompile contract used by B20 tokens. It manages a list of policies; B20 tokens call `isAuthorized(policyId, account)` against a policy ID stored on the relevant policy scope. Currently, B20 tokens use the Policy Registry for `TRANSFER_FROM`, `TRANSFER_TO`, and `SEIZE_HOLDER`. ### Simple Policies @@ -34,7 +34,7 @@ Simple policies are the non-composite policy types: `ALLOWLIST` and `BLOCKLIST`. ### Interface Changes -The following interface delta is verified via `cast sig` or `cast keccak` against `src/interfaces/IPolicyRegistry.sol`: +The following interface changes are verified via `cast sig` and `cast keccak` against `src/interfaces/IPolicyRegistry.sol`. | Symbol | Selector / Topic0 | Status | Notes | |--------|-------------------|--------|-------| @@ -47,178 +47,127 @@ The following interface delta is verified via `cast sig` or `cast keccak` agains | `InvalidChildPolicy(uint64)` | `0x46508ef6` | NEW (error) | Child is itself a composite or a built-in sentinel | | `CompositePolicyUpdated(uint64,address,uint64[])` | `0x4ff6adaab31b0df87aa7b8b7320c52b8b3b5eede3bf28a6baaaa8b8b7e1d6363` | NEW (event) | Topic0; emitted on composite create and every update; carries full post-update set | | `isAuthorized(uint64,address)` | (unchanged) | extended | Now dispatches composites (live child evaluation); signature unchanged | -| `createPolicy(address,uint8)` | `0xca5d55f6` | extended | Does not accept composite policy types; reverts with `IncompatiblePolicyType` (see below) | -| `createPolicyWithAccounts(address,uint8,address[])` | `0xa2d3044f` | extended | Same composite-policy-type rejection | +| `createPolicy(address,uint8)` | `0xca5d55f6` | extended | Now rejects `UNION`/`INTERSECT` with `IncompatiblePolicyType` (see below) | +| `createPolicyWithAccounts(address,uint8,address[])` | `0xa2d3044f` | extended | Same new `IncompatiblePolicyType` rejection | -**Solidity interface additions:** +The `PolicyType` enum introduces two new values: +- `UNION = 2` — authorized if any child policy authorizes the account (OR) +- `INTERSECT = 3` — authorized only if every child policy authorizes the account (AND) -```solidity -// SPDX-License-Identifier: MIT -// File: src/interfaces/IPolicyRegistry.sol - -interface IPolicyRegistry { - // New enum values (UNION = 2, INTERSECT = 3) - enum PolicyType { - ALLOWLIST, - BLOCKLIST, - UNION, - INTERSECT - } - - // New errors - error ChildPoliciesOutsideOfRange(); - error InvalidChildPolicy(uint64 childPolicyId); - - // New events - event CompositePolicyUpdated( - uint64 indexed policyId, - address indexed updater, - uint64[] childPolicyIds - ); - - // New functions - function createCompositePolicy( - address admin, - PolicyType policyType, - uint64[] calldata childPolicyIds - ) external returns (uint64 policyId); - - function updateComposite( - uint64 policyId, - uint64[] calldata childPolicyIds - ) external; - - function compositePolicyChildIds(uint64 policyId) - external - view - returns (uint64[] memory); - - function MIN_COMPOSITE_CHILD_POLICIES() external pure returns (uint256); - function MAX_COMPOSITE_CHILD_POLICIES() external pure returns (uint256); - - // Extended: now dispatches composites - function isAuthorized(uint64 policyId, address account) - external - view - returns (bool); - - // Extended: reject UNION/INTERSECT - function createPolicy(address admin, PolicyType policyType) - external - returns (uint64 policyId); - - function createPolicyWithAccounts( - address admin, - PolicyType policyType, - address[] calldata accounts - ) external returns (uint64 policyId); -} -``` - -Two new values are introduced in the `PolicyType` enum: +#### `createCompositePolicy(admin, policyType, childPolicyIds)` -- `UNION = 2` — authorized if *any* child policy authorizes the account (OR) -- `INTERSECT = 3` — authorized only if *every* child policy authorizes the account (AND) +The `childPolicyIds` array must contain between 2 and 4 entries (enforced by `MIN_COMPOSITE_CHILD_POLICIES` and `MAX_COMPOSITE_CHILD_POLICIES`). The cap of 4 bounds worst-case `isAuthorized` gas and the authorization audit surface. Every child must be an existing simple policy (`ALLOWLIST` or `BLOCKLIST`) — never another composite, never a built-in sentinel (`ALWAYS_ALLOW` or `ALWAYS_BLOCK`). -#### `createCompositePolicy(admin, policyType, childPolicyIds)` +The canonical revert order is: +1. `ZeroAddress` (admin) +2. `IncompatiblePolicyType` (policyType not UNION/INTERSECT) +3. `ChildPoliciesOutsideOfRange` (count not in `[2, 4]`) +4. `PolicyNotFound` (a child doesn't exist, checked as one pass over the whole set) +5. `InvalidChildPolicy` (a child is itself composite or sentinel, checked as a second pass) -- `childPolicyIds` must be between 2 and 4 entries (`MIN_COMPOSITE_CHILD_POLICIES` / `MAX_COMPOSITE_CHILD_POLICIES`). The cap of 4 bounds worst-case `isAuthorized` gas and the authorization audit surface. -- Every child must be an *existing simple* policy (`ALLOWLIST` or `BLOCKLIST`) — never another composite, never a built-in sentinel (`ALWAYS_ALLOW` or `ALWAYS_BLOCK`). -- Canonical revert order: - 1. `ZeroAddress` (admin) - 2. `IncompatiblePolicyType` (policyType not UNION/INTERSECT) - 3. `ChildPoliciesOutsideOfRange` (count not in `[2,4]`) - 4. `PolicyNotFound` (a child doesn't exist, checked as one pass over the whole set) - 5. `InvalidChildPolicy` (a child is itself composite/sentinel, checked as a second pass) -- Emits, in order: - 1. `PolicyCreated(policyId, creator, policyType)` - 2. `PolicyAdminUpdated(policyId, address(0), admin)` - 3. `CompositePolicyUpdated(policyId, creator, childPolicyIds)` +The function emits, in order: +- `PolicyCreated(policyId, creator, policyType)` +- `PolicyAdminUpdated(policyId, address(0), admin)` +- `CompositePolicyUpdated(policyId, creator, childPolicyIds)` #### `updateComposite(policyId, childPolicyIds)` -- Full replacement of the child set. There is no partial-update or clear-the-list operation. -- Same child-validity rules as `createCompositePolicy` (existing simple policies only, 2 to 4 of them). -- Canonical revert order: - 1. `PolicyNotFound` (composite itself doesn't exist) - 2. `IncompatiblePolicyType` (`policyId` is a simple policy) - 3. `Unauthorized` (caller isn't the current admin — fires before the count check) - 4. `ChildPoliciesOutsideOfRange` - 5. `PolicyNotFound` (a new child doesn't exist) - 6. `InvalidChildPolicy` -- Emits only `CompositePolicyUpdated(policyId, updater, childPolicyIds)`. No `PolicyAdminUpdated` is emitted because the admin does not change. +This function performs a full replacement of the child set. There is no partial-update or clear-the-list operation. The same child-validity rules as `createCompositePolicy` apply: existing simple policies only, 2 to 4 of them. + +The canonical revert order is: +1. `PolicyNotFound` (composite itself doesn't exist) +2. `IncompatiblePolicyType` (`policyId` is a simple policy) +3. `Unauthorized` (caller isn't the current admin — fires before the count check) +4. `ChildPoliciesOutsideOfRange` +5. `PolicyNotFound` (a new child doesn't exist) +6. `InvalidChildPolicy` -#### Existing functions with changed revert behavior for identical calldata +The function emits only `CompositePolicyUpdated(policyId, updater, childPolicyIds)` — no `PolicyAdminUpdated`, since the admin does not change. -`createPolicy` and `createPolicyWithAccounts` (both already live on Beryl) are simple-policy constructors. They do not accept composite policy types and revert with `IncompatiblePolicyType`. +#### Existing Functions with Changed Revert Behavior -This is not merely a newly-reachable branch. The revert for the *same calldata* changes across the fork. Pre-Cobalt the `PolicyType` enum had only `BLOCKLIST` and `ALLOWLIST`, so calldata carrying type byte `2` or `3` failed ABI enum decode (Solidity reference: `Panic(0x21)`, enum-conversion out of range). Post-Cobalt byte `2` or `3` decodes cleanly as `UNION` or `INTERSECT`, then the explicit guard reverts `IncompatiblePolicyType`. +`createPolicy` and `createPolicyWithAccounts` (both already live on Beryl) are simple-policy constructors that now reject `UNION`/`INTERSECT` with `IncompatiblePolicyType`. This is not merely a newly-reachable branch — the revert for the same calldata changes across the fork. Pre-Cobalt, the `PolicyType` enum had only `BLOCKLIST`/`ALLOWLIST`, so calldata carrying type byte `2`/`3` failed ABI enum decode (Solidity reference: `Panic(0x21)`, enum-conversion out of range). Post-Cobalt, byte `2`/`3` decodes cleanly as `UNION`/`INTERSECT`, then the explicit guard reverts `IncompatiblePolicyType`. -[TODO: verify against source] The exact pre-Cobalt revert of the *Rust precompile* for an out-of-range `PolicyType` byte is not asserted here. The Solidity mock does not model ABI enum decode. Confirm via `base-forge test` before publishing, or document only as Solidity-reference behaviour. +**UNVERIFIED**: The exact pre-Cobalt revert of the Rust precompile for an out-of-range `PolicyType` byte is not asserted here. The Solidity mock does not model ABI enum decode. Confirm via `base-forge test` before publishing, or document only as Solidity-reference behavior. ### Behavioural Changes -- A composite policy ID is passed to a B20 policy slot exactly like a simple policy ID. B20 needs **zero code changes**, since it stores policy slots as an opaque `uint64` and calls `isAuthorized` generically. -- `isAuthorized` on a composite is live and short-circuiting, not a snapshot: - - It reads each child's *current* membership on every call. There is no snapshot from creation or the last `updateComposite`. - - `UNION` short-circuits `true` on the first authorizing child. - - `INTERSECT` short-circuits `false` on the first non-authorizing child. - - Recursion never exceeds depth 1, because every child is validated to be a simple policy at write time. A composite's children can never themselves be composites. -- `isAuthorized` on a well-formed but **never-created** composite ID collapses to empty-child-set semantics: - - `UNION` → `false` (deny-all) - - `INTERSECT` → `true` (**allow-all** — an AND over zero children is vacuously true). - This parallels the simple-policy empty-set rule (ALLOWLIST → `false`, BLOCKLIST → `true`). Consumers that store a composite ID (e.g., on a B20 policy slot) MUST validate `policyExists(policyId)` at write time. A typo'd INTERSECT ID would silently behave as `ALWAYS_ALLOW`. -- Gas: a composite reads more policy IDs than a simple policy (its child list, plus each evaluated child's membership), so `isAuthorized` on a composite costs more gas than on a simple policy. -- Child order affects gas, never the outcome: - - `UNION` and `INTERSECT` are commutative, so reordering `childPolicyIds` never changes whether an account is authorized. - - It only shifts where the short-circuit lands. Put the child most likely to short-circuit first (broadest ALLOWLIST for `UNION`, tightest BLOCKLIST for `INTERSECT`) to save gas. -- Duplicate child IDs are allowed. The registry neither sorts nor deduplicates the stored child list. Deduplicating would cost extra gas on every write for a set already capped at 4 entries, for little value. `UNION` and `INTERSECT` are idempotent under duplicates anyway. -- A composite can never shrink below 2 children via `updateComposite`. It enforces the same `[2,4]` range as creation, so there is no path to an empty or undersized composite. -- If a child policy's admin renounces, the parent composite keeps working. `renounceAdmin` only clears the child's 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. -- State changes: - - New state: `mapping(uint64 policyId => uint64[] childPolicyIds) children`, appended at **offset 4** within the `base.policy_registry` ERC-7201 namespace (not a literal EVM slot 4). It is appended so existing state at offsets 0–3 is unmodified and no storage migration is needed. - - Reused state: one shared global counter (`nextCounter`) across simple and composite policies, starting at 2 (`0` and `1` are reserved for `ALWAYS_ALLOW` and `ALWAYS_BLOCK`). A composite policy ID encodes `PolicyType` in the top byte and the next available counter value in the low 56 bits. This uses the same encoding scheme as simple policies, not a separate counter. +A composite policy ID is passed to a B20 policy slot exactly like a simple policy ID. B20 needs zero code changes because it stores policy slots as an opaque `uint64` and calls `isAuthorized` generically. + +`isAuthorized` on a composite is live and short-circuiting, not a snapshot: +- It reads each child's current membership on every call — no snapshot from creation or the last `updateComposite`. +- `UNION` short-circuits `true` on the first authorizing child. +- `INTERSECT` short-circuits `false` on the first non-authorizing child. +- Recursion never exceeds depth 1 because every child is validated to be a simple policy at write time. A composite's children can never themselves be composites. + +`isAuthorized` on a well-formed but never-created composite ID collapses to empty-child-set semantics: `UNION` returns `false` (deny-all), `INTERSECT` returns `true` (allow-all — an AND over zero children is vacuously true). This parallels the simple-policy empty-set rule (`ALLOWLIST` → `false`, `BLOCKLIST` → `true`). Consumers that store a composite ID (for example, on a B20 policy slot) MUST validate `policyExists(policyId)` at write time. A typo'd INTERSECT ID would silently behave as `ALWAYS_ALLOW`. + +Gas: a composite reads more policy IDs than a simple policy (its child list, plus each evaluated child's membership), so `isAuthorized` on a composite costs more gas than on a simple policy. + +Child order affects gas, never the outcome: +- `UNION`/`INTERSECT` are commutative, so reordering `childPolicyIds` never changes whether an account is authorized. +- It only shifts where the short-circuit lands. Put the child most likely to short-circuit first (broadest ALLOWLIST for `UNION`, tightest BLOCKLIST for `INTERSECT`) to save gas. + +Duplicate child IDs are allowed. The registry neither sorts nor deduplicates the stored child list. Deduplicating would cost extra gas on every write for a set already capped at 4 entries, for little value. `UNION`/`INTERSECT` are idempotent under duplicates anyway. + +A composite can never shrink below 2 children via `updateComposite` — it enforces the same `[2, 4]` range as creation, so there is no path to an empty or undersized composite. + +If a child policy's admin renounces, the parent composite keeps working. `renounceAdmin` only clears the child's 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. + +#### State Changes + +- New state: `mapping(uint64 policyId => uint64[] childPolicyIds) children`, appended at offset 4 within the `base.policy_registry` ERC-7201 namespace (not a literal EVM slot 4). This is appended so existing state at offsets 0–3 is unmodified and no storage migration is needed. +- Reused state: one shared global counter (`nextCounter`) across simple and composite policies, starting at 2 (`0` and `1` are reserved for `ALWAYS_ALLOW`/`ALWAYS_BLOCK`). A composite policy ID encodes `PolicyType` in the top byte and the next available counter value in the low 56 bits — the same encoding scheme as simple policies, not a separate counter. ### Examples -#### Before (simple policy) - -- Assign one existing policy directly to a B20 policy scope: - ```solidity - b20.updatePolicy(TRANSFER_SENDER_POLICY, allowlistPolicyId) - ``` -- Only accounts in `allowlistPolicyId` can transfer. - -#### After (composite policy) - -- Assume existing simple policies: `employeesPolicyId` (ALLOWLIST), `approvedRegionPolicyId` (ALLOWLIST). -- Create a UNION composite: - ```solidity - policyRegistry.createCompositePolicy(admin, UNION, [employeesPolicyId, approvedRegionPolicyId]) - ``` -- Emits: `PolicyCreated(policyId, admin, UNION)` + `PolicyAdminUpdated(policyId, 0, admin)` + `CompositePolicyUpdated(policyId, admin, [children])`. -- Assign to B20: - ```solidity - b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId) - ``` -- B20 has no composite-specific logic. It passes the policy ID to the registry as usual. - -#### Updating a composite - -- Call: - ```solidity - policyRegistry.updateComposite(compositePolicyId, [employeesPolicyId, trustedPartnersPolicyId]) - ``` -- Emits: `CompositePolicyUpdated(policyId, admin, [newChildren])`. -- B20 continues using the same policy ID. No token-side update is required. -- Future authorization checks use the new child set immediately (live evaluation, no snapshot). +#### Before (Simple Policy) -## Design Decisions & Alternatives Considered +Assign one existing policy directly to a B20 policy scope: + +```solidity +b20.updatePolicy(TRANSFER_SENDER_POLICY, allowlistPolicyId) +``` + +Only accounts in `allowlistPolicyId` can transfer. + +#### After (Composite Policy) + +Assume existing simple policies: `employeesPolicyId` (ALLOWLIST), `approvedRegionPolicyId` (ALLOWLIST). -### Decision: Two explicit policy types (`UNION`, `INTERSECT`) with a single `createCompositePolicy` function and full-replacement `updateComposite` +Create a UNION composite: -### Alternative 1: One generic COMPOSITE type +```solidity +policyRegistry.createCompositePolicy(admin, UNION, [employeesPolicyId, approvedRegionPolicyId]) +``` + +Emits: `PolicyCreated(policyId, admin, UNION)` + `PolicyAdminUpdated(policyId, 0, admin)` + `CompositePolicyUpdated(policyId, admin, [children])`. + +Assign to B20: + +```solidity +b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId) +``` + +B20 has no composite-specific logic — it passes the policy ID to the registry as usual. + +#### Updating a Composite + +```solidity +policyRegistry.updateComposite(compositePolicyId, [employeesPolicyId, trustedPartnersPolicyId]) +``` + +Emits: `CompositePolicyUpdated(policyId, admin, [newChildren])`. + +B20 continues using the same policy ID — no token-side update required. + +Future authorization checks use the new child set immediately (live evaluation, no snapshot). + +## Design Decisions & Alternatives Considered +**Decision**: Two explicit policy types (`UNION`, `INTERSECT`) with a single `createCompositePolicy` function and full-replacement `updateComposite`. + +**Alternative 1: One generic COMPOSITE type** - Store a separate operator (AND, OR, NOT, XOR) in composite storage. - Rejected because: - Requires storing both "composite" flag and the operator. @@ -226,52 +175,46 @@ This is not merely a newly-reachable branch. The revert for the *same calldata* - Unnecessary complexity before there is a requirement for NOT, XOR, or nested expressions. - Generic boolean expressions create a larger gas and audit surface. -### Alternative 2: Token-level policy groups - -- Keep Policy Registry unchanged; have each B20 token store multiple policy IDs and an operator. +**Alternative 2: Token-level policy groups** +- Keep Policy Registry unchanged; have each B20 token store multiple policy IDs + an operator. - Rejected because: - Composite policies would not be reusable entities. - Requires changes across B20, token variants, factories, and token hot paths. - Does not support sharing one composite policy across multiple tokens. - Spreads complexity across more contracts. -### Alternative 3: Incremental child updates - -- Provide `addCompositeOperand` and `removeCompositeOperand` functions. +**Alternative 3: Incremental child updates** +- Provide `addCompositeOperand` / `removeCompositeOperand` functions. - Rejected because: - Child list is capped at 4 entries. - Dynamic-array mutation requires swap/remove, length, and deduplication logic. - Full replacement is simpler and atomic. - Caller can resend the complete list at low cost. -### Alternative 4: Separate creator functions - +**Alternative 4: Separate creator functions** - Use `createUnionPolicy` and `createIntersectPolicy`. - Rejected because: - Doubles the creation API surface. - A single `createCompositePolicy` keeps policy creation consistent. - Future operators would require additional functions. -### Alternative 5: Nested composites (a composite referencing another composite) - -- Allow composite children, to some bounded depth, instead of restricting children to simple `ALLOWLIST` and `BLOCKLIST` policies. +**Alternative 5: Nested composites (a composite referencing another composite)** +- Allow composite children, to some bounded depth, instead of restricting children to simple `ALLOWLIST`/`BLOCKLIST` policies. - Rejected because: - - Restricting children to simple policies guarantees `isAuthorized` recursion terminates at depth 1. There is no cycle risk and no unbounded traversal. + - Restricting children to simple policies guarantees `isAuthorized` recursion terminates at depth 1 — no cycle risk, no unbounded traversal. - Bounds worst-case gas and the audit surface of authorization evaluation. - - No demonstrated need for nested expressions. A wrapper composite can be introduced later if one ever arises. + - No demonstrated need for nested expressions; a wrapper composite can be introduced later if one ever arises. ## Migration Steps -- **Backwards-compatible**: Existing simple policies (ALLOWLIST and BLOCKLIST) continue to work unchanged. No action is required if you do not need composite behavior. +- **Backwards-compatible**: Existing simple policies (`ALLOWLIST`/`BLOCKLIST`) continue to work unchanged. No action required if you do not need composite behavior. - **For users currently flattening multiple lists into one policy**: 1. Identify the simple policies you want to combine. 2. Call `policyRegistry.createCompositePolicy(admin, UNION or INTERSECT, [childPolicyIds])`. 3. Update the B20 token's policy scope to point to the new composite policy ID: - ```solidity - b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId) - ``` - No B20 contract change is required. B20 treats the composite ID as an opaque `uint64` exactly like a simple policy ID. + - `b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId)` + - No B20 contract change is required — B20 treats the composite ID as an opaque `uint64` exactly like a simple policy ID. 4. Remove the old flattened policy if no longer needed. - **No breaking changes**: All existing selectors, events, and errors remain dialable at Cobalt.