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