From 377d045da50f4f3112732ac605dfd48bdbd663c6 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:01:50 -0400 Subject: [PATCH 01/17] docs(changelog): clarify B20 Asset multiplier behavior Expand the Cobalt specification so operators and integrators can understand scheduling, maturation, emergency overrides, and storage implications. Co-authored-by: Cursor --- changelog/02_Cobalt_B20Asset_multiplier.md | 212 +++++++++++++++------ 1 file changed, 154 insertions(+), 58 deletions(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index 60751b7..acd13a8 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -7,30 +7,75 @@ ## Summary -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. +Stock issuers and tokenization platforms need to support corporate actions such as stock splits and reverse stock splits. This change implements ERC-8056 for B20 Asset in the Cobalt hardfork. Which allows issuers to schedule a multiplier change for a specific future timestamp instead of applying it immediately. -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. +Issuers and operators use `updateUIMultiplier` to schedule a change and `cancelUIMultiplierUpdate` to cancel a pending change. The existing `updateMultiplier` function remains available as an emergency failsafe that applies a change immediately. All three functions require `OPERATOR_ROLE`. -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. +For integrators, scheduling a multiplier change does not change a user's raw token balance. Before the scheduled timestamp, the UI functions continue to use the current multiplier. At and after that timestamp, functions such as `balanceOfUI`, `totalSupplyUI`, and `toUIAmount` use the scheduled multiplier and return updated UI values. Integrators can read the pending multiplier and timestamp through `newUIMultiplier()` and `effectiveAt()`, and should use the `UIMultiplierUpdated` event instead of the deprecated `MultiplierUpdated` event. ## Motivation -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. +Traditional financial institutions coordinate stock splits, reverse stock splits, and reinvested dividends around an agreed effective time, often at the start of the next trading day. Exchanges, custodians, and accounting systems need advance notice so they can prepare before the action takes effect. + +The existing `updateMultiplier` function applies a multiplier change when its transaction lands on-chain. Because transaction inclusion time is unpredictable, this function cannot guarantee an agreed effective timestamp. This limitation prevents downstream systems from coordinating a multiplier change reliably and can cause them to report inconsistent UI balances, prices, and accounting values. Issuers still need `updateMultiplier` as an emergency override for an incorrect scheduled multiplier or effective timestamp. ## Background -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: +### B20 Asset + +B20 Asset extends ERC-20 for issuers that tokenize real-world assets on Base, including stocks, bonds, funds, and commodities. It records each holder's balance as a raw amount. A single multiplier converts raw amounts into UI amounts for display. + +This design lets an issuer represent a corporate action, such as a stock split, without rewriting holder balances. ERC-20 transfers and DeFi protocols continue to use the unchanged raw amounts. + +Before this change, B20 Asset provided these multiplier functions: - `updateMultiplier(uint256 newMultiplier)`: applies the multiplier immediately - `toScaledBalance(uint256)` and `toRawBalance(uint256)`: legacy read and conversion aliases that predate the ERC-8056 naming +### ERC-8056 + +[ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) standardizes how ERC-20 tokens expose scaled amounts in user interfaces. It defines an 18-decimal UI multiplier while keeping raw balances, total supply, and transfer amounts unchanged. + +The standard requires tokens to expose the current multiplier, a pending multiplier, and the timestamp when the pending multiplier takes effect. It also defines optional interfaces for converting between raw and UI amounts and reading UI-adjusted balances and total supply. Integrators can detect each supported interface through ERC-165. + ## Specs ### Interface Changes +#### Solidity interface + +The following abridged interface shows the Cobalt additions. + +```solidity +interface IB20AssetCobalt { + error EffectiveAtInPast(uint256 effectiveAt); + error EffectiveAtTooFar(uint256 effectiveAt); + error UIMultiplierUpdateExists(uint256 effectiveAt); + error UIMultiplierUpdateDoesNotExist(); + + event UIMultiplierUpdated(uint256 oldMultiplier, uint256 newMultiplier, uint256 effectiveAtTimestamp); + event UIMultiplierUpdateCancelled(uint256 cancelledMultiplier, uint256 cancelledEffectiveAt); + + function updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) external; + function cancelUIMultiplierUpdate() external; + + function newUIMultiplier() external view returns (uint256); + function effectiveAt() external view returns (uint256); + function MAX_UI_MULTIPLIER() external view returns (uint256); + function supportsInterface(bytes4 interfaceId) external view returns (bool); + function uiMultiplier() external view returns (uint256); + function balanceOfUI(address account) external view returns (uint256); + function totalSupplyUI() external view returns (uint256); + function toUIAmount(uint256 rawAmount) external view returns (uint256); + function fromUIAmount(uint256 uiAmount) external view returns (uint256); +} +``` + +#### ABI changes + The following tables describe new, renamed, and deprecated symbols. Selector and topic0 values are verified against the implementation. -#### Functions +##### Functions | Symbol | Selector | Status | Notes | | --- | --- | --- | --- | @@ -51,7 +96,7 @@ The following tables describe new, renamed, and deprecated symbols. Selector and | `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 +##### Events | Symbol | Topic0 | Status | Notes | | --- | --- | --- | --- | @@ -59,7 +104,7 @@ The following tables describe new, renamed, and deprecated symbols. Selector and | `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 +##### Errors | Symbol | Selector | Status | Notes | | --- | --- | --- | --- | @@ -69,7 +114,7 @@ The following tables describe new, renamed, and deprecated symbols. Selector and | `UIMultiplierUpdateDoesNotExist()` | `0xa7d6a5ca` | new | Thrown when cancel is called with no live pending update. | | `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` +##### Interface IDs advertised via `supportsInterface` | Interface ID | Interface | Status | | --- | --- | --- | @@ -85,98 +130,149 @@ ERC-8056 conformance note: The optional `TransferWithUIAmount` event is intentio #### Old Behavior -The `updateMultiplier(uint256)` function applied the multiplier immediately. The change emitted the deprecated `MultiplierUpdated(uint256)` event. +Previously, an operator called `updateMultiplier(uint256)` to change the multiplier. The contract applied the change in the same transaction, so UI balances reflected the new multiplier immediately. It also emitted `MultiplierUpdated(uint256)`, which is now deprecated. -#### New Behavior +```mermaid +sequenceDiagram + participant Operator + participant Asset as B20 Asset + participant Reader as User or integrator -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)`. + Operator->>Asset: updateMultiplier(newMultiplier) + Asset->>Asset: Store new multiplier immediately + Asset-->>Operator: Emit MultiplierUpdated(newMultiplier) + Reader->>Asset: multiplier() + Asset-->>Reader: New multiplier +``` -#### Live Pending Definition +#### New Behavior -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. +For routine corporate actions, call `updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt)` to schedule a multiplier change. The contract allows one pending change at a time, and `effectiveAt` must be a future timestamp. -#### Maturation and Materialization +The existing `OPERATOR_ROLE` controls `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and `updateMultiplier`. -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. +##### Scheduled update lifecycle -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. +1. **Schedule the update.** An operator calls `updateUIMultiplier(newMultiplier, effectiveAt)`. The `effectiveAt` timestamp must be in the future. +2. **Read the live update.** The update remains live while `effectiveAt > block.timestamp`. During this period, `uiMultiplier()` returns the current multiplier, `newUIMultiplier()` returns the scheduled multiplier, and `effectiveAt()` returns the scheduled timestamp. If an operator tries to schedule another update, `updateUIMultiplier` reverts with `UIMultiplierUpdateExists`. +3. **Apply the matured value.** The update matures when `block.timestamp >= effectiveAt`. From that point, `uiMultiplier()` returns the scheduled multiplier. The contract calculates this effective value when a caller reads it. Maturation does not write to storage or emit an event. +4. **Handle a later multiplier update.** Until another multiplier update occurs, `newUIMultiplier()` mirrors `uiMultiplier()` and returns the matured value, and `effectiveAt()` retains its past timestamp. A later `updateUIMultiplier` call stores the matured multiplier as the current multiplier before recording the new schedule. A later `updateMultiplier` call replaces the matured multiplier immediately and clears the pending schedule. -Integration guidance: Detect a live pending update via `effectiveAt() > block.timestamp`. Never test `effectiveAt() == 0`. +The `effectiveAt` timestamp must be strictly in the future. The function reverts with `EffectiveAtInPast` when +`effectiveAt <= block.timestamp`. The update matures when `block.timestamp >= effectiveAt`, so a schedule cannot +target the current timestamp. -#### Compatibility Behavior +Detect a live pending update by checking `effectiveAt() > block.timestamp`. Do not check `effectiveAt() == 0`. -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. +```mermaid +sequenceDiagram + participant Operator + participant Asset as B20 Asset + participant Reader as User or integrator -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)`. + Operator->>Asset: updateUIMultiplier(newMultiplier, effectiveAt) + Asset-->>Operator: Schedule pending multiplier + Reader->>Asset: uiMultiplier() before effectiveAt + Asset-->>Reader: Current multiplier + Note over Asset: effectiveAt passes
No transaction, event, or storage write + Reader->>Asset: uiMultiplier() at or after effectiveAt + Asset-->>Reader: New multiplier, computed on read +``` -#### Access Control +##### Cancelling a scheduled update -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`. +Call `cancelUIMultiplierUpdate()` before `effectiveAt` to cancel a live pending update. Cancellation clears the pending change and emits `UIMultiplierUpdateCancelled(uint256,uint256)`. -#### Pause Interaction +The function reverts with `UIMultiplierUpdateDoesNotExist` when no live pending update exists, including when the only pending update has matured. -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. +To reorder overlapping actions, cancel and reschedule atomically in one announcement: `announce([cancelUIMultiplierUpdate(), updateUIMultiplier(...)], ...)`. -#### Gas Cost Implications +```mermaid +sequenceDiagram + participant Operator + participant Asset as B20 Asset + participant Reader as User or integrator -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. + Operator->>Asset: updateUIMultiplier(newMultiplier, effectiveAt) + Asset-->>Operator: Schedule pending multiplier + Reader->>Asset: uiMultiplier() before effectiveAt + Asset-->>Reader: Current multiplier + Operator->>Asset: cancelUIMultiplierUpdate() before effectiveAt + Asset-->>Operator: Clear pending update and emit cancellation event + Reader->>Asset: uiMultiplier() + Asset-->>Reader: Current multiplier remains unchanged +``` -#### Storage Layout Changes +#### UI-scaled views -A new field `PendingMultiplier pending` is appended to the `base.b20.asset` ERC-7201 namespace. +The Functions table lists the ERC-8056 aliases. The `uiMultiplier`, `balanceOfUI`, `toUIAmount`, and +`fromUIAmount` functions return the same values as `multiplier`, `scaledBalanceOf`, `toScaledBalance`, and +`toRawBalance`, respectively. The `totalSupplyUI()` function returns +`totalSupply() * uiMultiplier() / WAD_PRECISION`. -- Namespace location: `0xfdc6d4552d1286ade4d9facdbf0fb50d2ec9b89a90e104f26fd277585e374b00` -- Placed at `PENDING_OFFSET = 4` +Multiplier changes do not modify canonical raw balances. They only change the derived UI-scaled values. -The field is packed into a single 256-bit slot: +UI-scaled views calculate `raw * multiplier / WAD_PRECISION` with integer division. The calculation rounds down. +The `fromUIAmount` and `toRawBalance` functions also round down, so a round trip can lose up to one unit in the +last place (ULP) when `multiplier != WAD_PRECISION`. -- Bits 0-127: `uint128 multiplier` (target) -- Bits 128-191: `uint64 effectiveAt` (flip timestamp) -- Bits 192-255: unused (32 bytes free for future packing) +A large reverse split can make this rounding effect economically significant for tokens with few decimals. Use +18 decimals for equities to minimize the effect. For more information, see `docs/B20/Asset.md`. -This is an additive change. Pre-existing offsets 0-3 are unchanged: +Each UI-scaled read performs one additional `SLOAD` and one timestamp comparison to determine whether a pending +multiplier has matured. This change affects `uiMultiplier`, `multiplier`, `balanceOfUI`, `scaledBalanceOf`, +`toUIAmount`, `fromUIAmount`, and `totalSupplyUI`. Raw `balanceOf` reads 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` +#### Deprecated `updateMultiplier` behavior -The layout must match the `base/base` Rust precompile slot-for-slot (AGENTS.md invariant). +The deprecated `updateMultiplier(uint256)` function remains available as an emergency setter. It applies the requested multiplier immediately and clears any pending update. -#### ERC-8056 View Aliases +Cobalt adds an upper bound: the function reverts with `InvalidMultiplier` when `newMultiplier > type(uint128).max`. Before Cobalt, the function rejected only zero. This bound keeps `balance * multiplier` within `uint256` and matches the scheduled setter. The bound, approximately `3.4e20`× as a WAD multiplier, exceeds the range needed for realistic corporate actions. -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`. +`updateMultiplier` handles an existing pending update as follows: -#### Edge Cases and Precision +- If the pending update is scheduled for a future time, `updateMultiplier` cancels it, emits `UIMultiplierUpdateCancelled(...)`, and applies the requested multiplier immediately. +- If the pending update has already matured, `updateMultiplier` clears it without emitting `UIMultiplierUpdateCancelled` and replaces the matured multiplier immediately. The canonical update event reports the matured multiplier as `oldMultiplier`. -Raw balances are **canonical** and are never rewritten by a multiplier flip. A flip only changes the derived scaled/UI view. +After handling any pending update, the function emits the legacy `MultiplierUpdated(uint256)` event followed by the canonical `UIMultiplierUpdated(uint256,uint256,uint256)` event. -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`). +#### Storage Layout Changes + +Cobalt adds the packed `PendingMultiplier pending` field at offset 4 in the `base.b20.asset` ERC-7201 namespace. +This additive change does not modify the existing fields at offsets 0–3 and does not require a storage migration. +The offset is relative to the namespace location, not literal EVM slot 4. -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. +- Namespace location: `0xfdc6d4552d1286ade4d9facdbf0fb50d2ec9b89a90e104f26fd277585e374b00` +- Placed at `PENDING_OFFSET = 4` -### Examples +The field is packed into a single 256-bit slot: -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. +| Bits | Field | Type | Purpose | +| ------- | ------------- | --------- | ------------------------------------- | +| 0–127 | `multiplier` | `uint128` | Target multiplier | +| 128–191 | `effectiveAt` | `uint64` | Timestamp when the multiplier applies | +| 192–255 | Reserved | `uint64` | Unused 8-byte lane for future packing | -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)`. +The resulting namespace layout is: -To reorder overlapping actions, cancel and reschedule atomically in one announcement: `announce([cancelUIMultiplierUpdate(), updateUIMultiplier(...)], ...)`. +| Offset | Field | Type | Status | +| ------ | --------------------- | ------------------- | ------------------------------------------------------------- | +| 0 | `decimals` | `uint8` | Unchanged | +| 1 | `multiplier` | `uint256` | Unchanged; a stored `0` reads as `WAD_PRECISION` | +| 2 | `usedAnnouncementIds` | `mapping` | Unchanged | +| 3 | `extraMetadata` | `mapping` | Unchanged | +| 4 | `pending` | `PendingMultiplier` | New packed field | ## Design Decisions & Alternatives Considered -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. +**Retaining `updateMultiplier`:** The instant setter is retained as a deprecated dialable failsafe because it is the only way to correct or supersede a scheduled multiplier without waiting for `effectiveAt`. A cancel-then-schedule sequence cannot apply an immediate correction. Without the instant setter, operators would have no emergency override for an incorrect `newMultiplier` or `effectiveAt`. The setter uses the pre-existing `OPERATOR_ROLE`, which also controls scheduling, instead of a narrower emergency-only role. -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). +**Allowing one pending multiplier update at a time:** A single pending slot is used instead of a queue to reduce complexity and gas costs and to preserve single-slot storage packing. Operators can reorder overlapping actions with an atomic cancel-then-schedule operation in one announcement. ## Migration Steps -Old functions work; there are no breaking changes. Migration steps are to update the workflow to use what is shown in the Examples section. +Old functions work; there are no breaking changes. Update routine corporate-action workflows to use the scheduled update lifecycle described under Behavioural Changes. Deprecation lifecycle (two tiers): From 6fed0fbe2dc23177b87f5dd704c03799842b3362 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:15:44 -0400 Subject: [PATCH 02/17] feat: clean up --- changelog/02_Cobalt_B20Asset_multiplier.md | 154 ++++++++++++++------- 1 file changed, 103 insertions(+), 51 deletions(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index acd13a8..0191a63 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -21,6 +21,8 @@ The existing `updateMultiplier` function applies a multiplier change when its tr ## Background + + ### B20 Asset B20 Asset extends ERC-20 for issuers that tokenize real-world assets on Base, including stocks, bonds, funds, and commodities. It records each holder's balance as a raw amount. A single multiplier converts raw amounts into UI amounts for display. @@ -32,6 +34,8 @@ Before this change, B20 Asset provided these multiplier functions: - `updateMultiplier(uint256 newMultiplier)`: applies the multiplier immediately - `toScaledBalance(uint256)` and `toRawBalance(uint256)`: legacy read and conversion aliases that predate the ERC-8056 naming + + ### ERC-8056 [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) standardizes how ERC-20 tokens expose scaled amounts in user interfaces. It defines an 18-decimal UI multiplier while keeping raw balances, total supply, and transfer amounts unchanged. @@ -40,8 +44,12 @@ The standard requires tokens to expose the current multiplier, a pending multipl ## Specs + + ### Interface Changes + + #### Solidity interface The following abridged interface shows the Cobalt additions. @@ -71,63 +79,81 @@ interface IB20AssetCobalt { } ``` + + #### ABI changes The following tables describe new, renamed, and deprecated symbols. Selector and topic0 values are verified against the implementation. ##### 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`), 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()`; 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. | -| `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`. | + +| 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`), 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()`; 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. | +| `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. 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`. | + +| Symbol | Topic0 | Status | Notes | +| ---------------------------------------------- | -------------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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`. | + + + ##### 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 | 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. | + +| 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 | 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` -| Interface ID | Interface | Status | -| --- | --- | --- | -| `0x01ffc9a7` | `IERC165` | new advertisement | -| `0xa60bf13d` | `IScaledUIAmount` (ERC-8056 core) | new advertisement | + +| 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 | +| `0xd890fd71` | `IScaledUIAmountBalances` (ERC-8056 optional) | new advertisement | +| `0x57854fc3` | `IScaledUIAmountConversion` (ERC-8056 optional) | new advertisement | + 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`). ### Behavioural Changes + + #### Old Behavior Previously, an operator called `updateMultiplier(uint256)` to change the multiplier. The contract applied the change in the same transaction, so UI balances reflected the new multiplier immediately. It also emitted `MultiplierUpdated(uint256)`, which is now deprecated. @@ -145,6 +171,10 @@ sequenceDiagram Asset-->>Reader: New multiplier ``` + + + + #### New Behavior For routine corporate actions, call `updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt)` to schedule a multiplier change. The contract allows one pending change at a time, and `effectiveAt` must be a future timestamp. @@ -179,6 +209,10 @@ sequenceDiagram Asset-->>Reader: New multiplier, computed on read ``` + + + + ##### Cancelling a scheduled update Call `cancelUIMultiplierUpdate()` before `effectiveAt` to cancel a live pending update. Cancellation clears the pending change and emits `UIMultiplierUpdateCancelled(uint256,uint256)`. @@ -203,6 +237,10 @@ sequenceDiagram Asset-->>Reader: Current multiplier remains unchanged ``` + + + + #### UI-scaled views The Functions table lists the ERC-8056 aliases. The `uiMultiplier`, `balanceOfUI`, `toUIAmount`, and @@ -227,7 +265,7 @@ multiplier has matured. This change affects `uiMultiplier`, `multiplier`, `balan The deprecated `updateMultiplier(uint256)` function remains available as an emergency setter. It applies the requested multiplier immediately and clears any pending update. -Cobalt adds an upper bound: the function reverts with `InvalidMultiplier` when `newMultiplier > type(uint128).max`. Before Cobalt, the function rejected only zero. This bound keeps `balance * multiplier` within `uint256` and matches the scheduled setter. The bound, approximately `3.4e20`× as a WAD multiplier, exceeds the range needed for realistic corporate actions. +The function now also reverts with `InvalidMultiplier` when `newMultiplier > type(uint128).max`. Before Cobalt, the function rejected only zero. This bound keeps `balance * multiplier` within `uint256` and matches the scheduled setter. `updateMultiplier` handles an existing pending update as follows: @@ -236,7 +274,6 @@ Cobalt adds an upper bound: the function reverts with `InvalidMultiplier` when ` After handling any pending update, the function emits the legacy `MultiplierUpdated(uint256)` event followed by the canonical `UIMultiplierUpdated(uint256,uint256,uint256)` event. - #### Storage Layout Changes Cobalt adds the packed `PendingMultiplier pending` field at offset 4 in the `base.b20.asset` ERC-7201 namespace. @@ -248,35 +285,50 @@ The offset is relative to the namespace location, not literal EVM slot 4. The field is packed into a single 256-bit slot: + | Bits | Field | Type | Purpose | | ------- | ------------- | --------- | ------------------------------------- | | 0–127 | `multiplier` | `uint128` | Target multiplier | | 128–191 | `effectiveAt` | `uint64` | Timestamp when the multiplier applies | | 192–255 | Reserved | `uint64` | Unused 8-byte lane for future packing | + The resulting namespace layout is: -| Offset | Field | Type | Status | -| ------ | --------------------- | ------------------- | ------------------------------------------------------------- | -| 0 | `decimals` | `uint8` | Unchanged | -| 1 | `multiplier` | `uint256` | Unchanged; a stored `0` reads as `WAD_PRECISION` | -| 2 | `usedAnnouncementIds` | `mapping` | Unchanged | -| 3 | `extraMetadata` | `mapping` | Unchanged | -| 4 | `pending` | `PendingMultiplier` | New packed field | + +| Offset | Field | Type | Status | +| ------ | --------------------- | ------------------- | ------------------------------------------------ | +| 0 | `decimals` | `uint8` | Unchanged | +| 1 | `multiplier` | `uint256` | Unchanged; a stored `0` reads as `WAD_PRECISION` | +| 2 | `usedAnnouncementIds` | `mapping` | Unchanged | +| 3 | `extraMetadata` | `mapping` | Unchanged | +| 4 | `pending` | `PendingMultiplier` | New packed field | + + + ## Design Decisions & Alternatives Considered -**Retaining `updateMultiplier`:** The instant setter is retained as a deprecated dialable failsafe because it is the only way to correct or supersede a scheduled multiplier without waiting for `effectiveAt`. A cancel-then-schedule sequence cannot apply an immediate correction. Without the instant setter, operators would have no emergency override for an incorrect `newMultiplier` or `effectiveAt`. The setter uses the pre-existing `OPERATOR_ROLE`, which also controls scheduling, instead of a narrower emergency-only role. +**Retaining** `updateMultiplier`**:** The instant setter is retained as a deprecated dialable failsafe because it is the only way to correct or supersede a scheduled multiplier without waiting for `effectiveAt`. A cancel-then-schedule sequence cannot apply an immediate correction. Without the instant setter, operators would have no emergency override for an incorrect `newMultiplier` or `effectiveAt`. The setter uses the pre-existing `OPERATOR_ROLE`, which also controls scheduling, instead of a narrower emergency-only role. **Allowing one pending multiplier update at a time:** A single pending slot is used instead of a queue to reduce complexity and gas costs and to preserve single-slot storage packing. Operators can reorder overlapping actions with an atomic cancel-then-schedule operation in one announcement. ## Migration Steps -Old functions work; there are no breaking changes. Update routine corporate-action workflows to use the scheduled update lifecycle described under Behavioural Changes. +No migration is required because all existing functions remain available. + +### Issuers and operators + +For future corporate actions, use the scheduled update lifecycle described under Behavioural Changes. The +deprecated `updateMultiplier` function remains available indefinitely as an emergency failsafe. It provides the +only immediate on-chain override for an incorrect scheduled value or timestamp. -Deprecation lifecycle (two tiers): +### Off-chain integrators -- `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. +To detect a live pending update, off-chain integrators should check whether `effectiveAt() > block.timestamp`. +Do not check whether `effectiveAt() == 0` because `effectiveAt()` retains the most recent timestamp after an update +matures. Listen for the canonical `UIMultiplierUpdated` event instead of the deprecated `MultiplierUpdated` event, +which is emitted only by the instant `updateMultiplier` function. -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 +The `toScaledBalance` and `toRawBalance` functions and the legacy `MultiplierUpdated` event remain available for +backward compatibility but are deprecated. No removal is scheduled, but a future hardfork may remove them. \ No newline at end of file From add3072114a2d23f417fe149eeb9ff049b33e6bc Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:18:11 -0400 Subject: [PATCH 03/17] docs(changelog): clarify multiplier event handling Explain how off-chain integrators should process scheduled, matured, cancelled, and instant multiplier updates without double-counting events. Co-authored-by: Cursor --- changelog/02_Cobalt_B20Asset_multiplier.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index 0191a63..71e8e98 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -237,6 +237,16 @@ sequenceDiagram Asset-->>Reader: Current multiplier remains unchanged ``` +##### Event handling for integrators + +Use `UIMultiplierUpdated` as the canonical source for multiplier updates. The event is emitted when an update is +scheduled, not when the new multiplier becomes active. If `effectiveAtTimestamp > block.timestamp`, treat the +update as pending until the specified timestamp. The contract does not emit another event when the update matures. + +When `UIMultiplierUpdateCancelled` is emitted, discard the pending update. The instant `updateMultiplier` function +emits both `MultiplierUpdated` and `UIMultiplierUpdated`. Process only `UIMultiplierUpdated` to avoid handling the +same update twice. + From 16ec8bc5c5a0aed500ff4049fe3bba4d0778c8d0 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:20:40 -0400 Subject: [PATCH 04/17] docs(changelog): tighten multiplier summary Keep the summary focused on user-visible behavior while leaving API details to the specification sections. Co-authored-by: Cursor --- changelog/02_Cobalt_B20Asset_multiplier.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index 71e8e98..0799418 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -11,7 +11,7 @@ Stock issuers and tokenization platforms need to support corporate actions such Issuers and operators use `updateUIMultiplier` to schedule a change and `cancelUIMultiplierUpdate` to cancel a pending change. The existing `updateMultiplier` function remains available as an emergency failsafe that applies a change immediately. All three functions require `OPERATOR_ROLE`. -For integrators, scheduling a multiplier change does not change a user's raw token balance. Before the scheduled timestamp, the UI functions continue to use the current multiplier. At and after that timestamp, functions such as `balanceOfUI`, `totalSupplyUI`, and `toUIAmount` use the scheduled multiplier and return updated UI values. Integrators can read the pending multiplier and timestamp through `newUIMultiplier()` and `effectiveAt()`, and should use the `UIMultiplierUpdated` event instead of the deprecated `MultiplierUpdated` event. +Scheduling a multiplier change does not alter raw token balances. UI values continue to use the current multiplier until the scheduled timestamp, then automatically use the new multiplier. Integrators can read pending updates on-chain and should migrate to the canonical `UIMultiplierUpdated` event. ## Motivation From 266597228e8c8f7b77e8f973882a3edc8d145249 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:25:28 -0400 Subject: [PATCH 05/17] docs(changelog): explain scheduled multiplier motivation Connect ERC-8056 scheduling to predictable activation without requiring a transaction at the effective time. Co-authored-by: Cursor --- changelog/02_Cobalt_B20Asset_multiplier.md | 34 ++++------------------ 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index 0799418..79ed0de 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -17,11 +17,11 @@ Scheduling a multiplier change does not alter raw token balances. UI values cont Traditional financial institutions coordinate stock splits, reverse stock splits, and reinvested dividends around an agreed effective time, often at the start of the next trading day. Exchanges, custodians, and accounting systems need advance notice so they can prepare before the action takes effect. -The existing `updateMultiplier` function applies a multiplier change when its transaction lands on-chain. Because transaction inclusion time is unpredictable, this function cannot guarantee an agreed effective timestamp. This limitation prevents downstream systems from coordinating a multiplier change reliably and can cause them to report inconsistent UI balances, prices, and accounting values. Issuers still need `updateMultiplier` as an emergency override for an incorrect scheduled multiplier or effective timestamp. - -## Background +The existing `updateMultiplier` function applies a multiplier change when its transaction lands on-chain. Because transaction inclusion time is unpredictable, operators cannot use this function to guarantee an agreed effective timestamp. Operators need to be able to submit a transaction in advance and schedule the change for a specific activation threshold without predicting when the transaction must land. +ERC-8056 provides this scheduling model. An operator records a pending multiplier and its effective timestamp in advance. The new multiplier becomes effective when `block.timestamp >= effectiveAt`, without requiring another transaction at that time. This gives downstream systems a predictable activation threshold for coordinating UI balances, prices, and accounting values. Retaining `updateMultiplier` provides an emergency override for an incorrect scheduled multiplier or effective timestamp. +## Background ### B20 Asset @@ -34,8 +34,6 @@ Before this change, B20 Asset provided these multiplier functions: - `updateMultiplier(uint256 newMultiplier)`: applies the multiplier immediately - `toScaledBalance(uint256)` and `toRawBalance(uint256)`: legacy read and conversion aliases that predate the ERC-8056 naming - - ### ERC-8056 [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) standardizes how ERC-20 tokens expose scaled amounts in user interfaces. It defines an 18-decimal UI multiplier while keeping raw balances, total supply, and transfer amounts unchanged. @@ -44,12 +42,8 @@ The standard requires tokens to expose the current multiplier, a pending multipl ## Specs - - ### Interface Changes - - #### Solidity interface The following abridged interface shows the Cobalt additions. @@ -79,8 +73,6 @@ interface IB20AssetCobalt { } ``` - - #### ABI changes The following tables describe new, renamed, and deprecated symbols. Selector and topic0 values are verified against the implementation. @@ -108,8 +100,6 @@ The following tables describe new, renamed, and deprecated symbols. Selector and | `updateMultiplier(uint256)` | `0x5ffe6146` | deprecated-dialable | Retained as emergency failsafe. Instant setter; clears any live pending update. Prefer scheduled `updateUIMultiplier`. | - - ##### Events @@ -120,8 +110,6 @@ The following tables describe new, renamed, and deprecated symbols. Selector and | `MultiplierUpdated(uint256)` | `0x4dbe4840d7465bd162f67814cea0b519567a2e0e578bcde61e7f4ced361e5a3d` | deprecated-still-emitted | Legacy event. Emitted only by the instant setter (`updateMultiplier`) alongside `UIMultiplierUpdated`. The scheduled setter emits only `UIMultiplierUpdated`. | - - ##### Errors @@ -134,8 +122,6 @@ The following tables describe new, renamed, and deprecated symbols. Selector and | `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` @@ -152,8 +138,6 @@ ERC-8056 conformance note: The optional `TransferWithUIAmount` event is intentio ### Behavioural Changes - - #### Old Behavior Previously, an operator called `updateMultiplier(uint256)` to change the multiplier. The contract applied the change in the same transaction, so UI balances reflected the new multiplier immediately. It also emitted `MultiplierUpdated(uint256)`, which is now deprecated. @@ -173,8 +157,6 @@ sequenceDiagram - - #### New Behavior For routine corporate actions, call `updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt)` to schedule a multiplier change. The contract allows one pending change at a time, and `effectiveAt` must be a future timestamp. @@ -211,8 +193,6 @@ sequenceDiagram - - ##### Cancelling a scheduled update Call `cancelUIMultiplierUpdate()` before `effectiveAt` to cancel a live pending update. Cancellation clears the pending change and emits `UIMultiplierUpdateCancelled(uint256,uint256)`. @@ -237,6 +217,8 @@ sequenceDiagram Asset-->>Reader: Current multiplier remains unchanged ``` + + ##### Event handling for integrators Use `UIMultiplierUpdated` as the canonical source for multiplier updates. The event is emitted when an update is @@ -247,10 +229,6 @@ When `UIMultiplierUpdateCancelled` is emitted, discard the pending update. The i emits both `MultiplierUpdated` and `UIMultiplierUpdated`. Process only `UIMultiplierUpdated` to avoid handling the same update twice. - - - - #### UI-scaled views The Functions table lists the ERC-8056 aliases. The `uiMultiplier`, `balanceOfUI`, `toUIAmount`, and @@ -315,8 +293,6 @@ The resulting namespace layout is: | 4 | `pending` | `PendingMultiplier` | New packed field | - - ## Design Decisions & Alternatives Considered **Retaining** `updateMultiplier`**:** The instant setter is retained as a deprecated dialable failsafe because it is the only way to correct or supersede a scheduled multiplier without waiting for `effectiveAt`. A cancel-then-schedule sequence cannot apply an immediate correction. Without the instant setter, operators would have no emergency override for an incorrect `newMultiplier` or `effectiveAt`. The setter uses the pre-existing `OPERATOR_ROLE`, which also controls scheduling, instead of a narrower emergency-only role. From fefb94e41e690772d7d6489a74c3fa1a18123091 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:26:23 -0400 Subject: [PATCH 06/17] docs(changelog): simplify B20 Asset background Keep the background focused on multiplier behavior without an unnecessary asset-type list. Co-authored-by: Cursor --- changelog/02_Cobalt_B20Asset_multiplier.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index 79ed0de..59b5768 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -25,7 +25,7 @@ ERC-8056 provides this scheduling model. An operator records a pending multiplie ### B20 Asset -B20 Asset extends ERC-20 for issuers that tokenize real-world assets on Base, including stocks, bonds, funds, and commodities. It records each holder's balance as a raw amount. A single multiplier converts raw amounts into UI amounts for display. +B20 Asset extends ERC-20 for issuers that tokenize real-world assets on Base. It records each holder's balance as a raw amount. A single multiplier converts raw amounts into UI amounts for display. This design lets an issuer represent a corporate action, such as a stock split, without rewriting holder balances. ERC-20 transfers and DeFi protocols continue to use the unchanged raw amounts. From cb3eb4fc9ca61d5b64ccf9b27974504b894a9511 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:32:03 -0400 Subject: [PATCH 07/17] docs(changelog): clarify multiplier UI transition Present raw-balance stability, scheduled UI activation, and integrator preparation as one cohesive summary. Co-authored-by: Cursor --- changelog/02_Cobalt_B20Asset_multiplier.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index 59b5768..78df494 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -9,9 +9,9 @@ Stock issuers and tokenization platforms need to support corporate actions such as stock splits and reverse stock splits. This change implements ERC-8056 for B20 Asset in the Cobalt hardfork. Which allows issuers to schedule a multiplier change for a specific future timestamp instead of applying it immediately. -Issuers and operators use `updateUIMultiplier` to schedule a change and `cancelUIMultiplierUpdate` to cancel a pending change. The existing `updateMultiplier` function remains available as an emergency failsafe that applies a change immediately. All three functions require `OPERATOR_ROLE`. +Issuers and operators can now use `updateUIMultiplier` to schedule a change and `cancelUIMultiplierUpdate` to cancel a pending change. The existing `updateMultiplier` function remains available as an emergency failsafe that applies a change immediately. All three functions require `OPERATOR_ROLE`. -Scheduling a multiplier change does not alter raw token balances. UI values continue to use the current multiplier until the scheduled timestamp, then automatically use the new multiplier. Integrators can read pending updates on-chain and should migrate to the canonical `UIMultiplierUpdated` event. +Scheduling a multiplier update leaves raw token balances unchanged and changes only their UI representation. UI values use the current multiplier before the effective timestamp and the scheduled multiplier at or after it. Integrators can prepare for this transition by reading pending updates on-chain and listening for the canonical `UIMultiplierUpdated` event. ## Motivation From e4660d8c1494ff94a9d4b6d65c62d9edaf0d2ae2 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:33:03 -0400 Subject: [PATCH 08/17] docs(changelog): clarify raw and UI amounts Explain how shared multiplier views remain separate from ERC-20 balances and transfer amounts. Co-authored-by: Cursor --- changelog/02_Cobalt_B20Asset_multiplier.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index 78df494..5a64079 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -25,9 +25,9 @@ ERC-8056 provides this scheduling model. An operator records a pending multiplie ### B20 Asset -B20 Asset extends ERC-20 for issuers that tokenize real-world assets on Base. It records each holder's balance as a raw amount. A single multiplier converts raw amounts into UI amounts for display. +B20 Asset extends ERC-20 for issuers that tokenize real-world assets on Base. It stores balances and transfer amounts in raw ERC-20 units. UI-specific read and conversion functions apply a shared multiplier when returning values for display. -This design lets an issuer represent a corporate action, such as a stock split, without rewriting holder balances. ERC-20 transfers and DeFi protocols continue to use the unchanged raw amounts. +This separation lets an issuer represent a corporate action, such as a stock split, without rewriting balances or changing transfer amounts. DeFi protocols continue to use the unchanged raw units. Before this change, B20 Asset provided these multiplier functions: From 45126a177c28d9cdbcb1c7a5737433c2435eb808 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:37:02 -0400 Subject: [PATCH 09/17] docs(changelog): tighten new behavior intro Link ERC-8056 in Motivation and fold OPERATOR_ROLE into the New Behavior opener. Co-authored-by: Cursor --- changelog/02_Cobalt_B20Asset_multiplier.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index 5a64079..e68d791 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -19,7 +19,7 @@ Traditional financial institutions coordinate stock splits, reverse stock splits The existing `updateMultiplier` function applies a multiplier change when its transaction lands on-chain. Because transaction inclusion time is unpredictable, operators cannot use this function to guarantee an agreed effective timestamp. Operators need to be able to submit a transaction in advance and schedule the change for a specific activation threshold without predicting when the transaction must land. -ERC-8056 provides this scheduling model. An operator records a pending multiplier and its effective timestamp in advance. The new multiplier becomes effective when `block.timestamp >= effectiveAt`, without requiring another transaction at that time. This gives downstream systems a predictable activation threshold for coordinating UI balances, prices, and accounting values. Retaining `updateMultiplier` provides an emergency override for an incorrect scheduled multiplier or effective timestamp. +[ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) provides this scheduling model. An operator records a pending multiplier and its effective timestamp in advance. The new multiplier becomes effective when `block.timestamp >= effectiveAt`, without requiring another transaction at that time. This gives downstream systems a predictable activation threshold for coordinating UI balances, prices, and accounting values. Retaining `updateMultiplier` provides an emergency override for an incorrect scheduled multiplier or effective timestamp. ## Background @@ -159,9 +159,7 @@ sequenceDiagram #### New Behavior -For routine corporate actions, call `updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt)` to schedule a multiplier change. The contract allows one pending change at a time, and `effectiveAt` must be a future timestamp. - -The existing `OPERATOR_ROLE` controls `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and `updateMultiplier`. +For routine corporate actions, an account with `OPERATOR_ROLE` calls `updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt)` to schedule a multiplier change. The contract allows one pending change at a time, and `effectiveAt` must be a future timestamp. The same role also controls `cancelUIMultiplierUpdate` and the emergency `updateMultiplier` failsafe. ##### Scheduled update lifecycle From 1ca0e15c4f7909e9aa65d179ca67c951c894d417 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:39:07 -0400 Subject: [PATCH 10/17] docs(changelog): bullet UI-scaled view notes Make integrator-facing UI view behavior easier to scan as discrete points. Co-authored-by: Cursor --- changelog/02_Cobalt_B20Asset_multiplier.md | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index e68d791..514f9f8 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -229,23 +229,13 @@ same update twice. #### UI-scaled views -The Functions table lists the ERC-8056 aliases. The `uiMultiplier`, `balanceOfUI`, `toUIAmount`, and -`fromUIAmount` functions return the same values as `multiplier`, `scaledBalanceOf`, `toScaledBalance`, and -`toRawBalance`, respectively. The `totalSupplyUI()` function returns -`totalSupply() * uiMultiplier() / WAD_PRECISION`. +The Functions table lists the ERC-8056 aliases. Behavior that matters for integrators: -Multiplier changes do not modify canonical raw balances. They only change the derived UI-scaled values. - -UI-scaled views calculate `raw * multiplier / WAD_PRECISION` with integer division. The calculation rounds down. -The `fromUIAmount` and `toRawBalance` functions also round down, so a round trip can lose up to one unit in the -last place (ULP) when `multiplier != WAD_PRECISION`. - -A large reverse split can make this rounding effect economically significant for tokens with few decimals. Use -18 decimals for equities to minimize the effect. For more information, see `docs/B20/Asset.md`. - -Each UI-scaled read performs one additional `SLOAD` and one timestamp comparison to determine whether a pending -multiplier has matured. This change affects `uiMultiplier`, `multiplier`, `balanceOfUI`, `scaledBalanceOf`, -`toUIAmount`, `fromUIAmount`, and `totalSupplyUI`. Raw `balanceOf` reads are unchanged. +- `uiMultiplier`, `balanceOfUI`, `toUIAmount`, and `fromUIAmount` return the same values as `multiplier`, `scaledBalanceOf`, `toScaledBalance`, and `toRawBalance`, respectively. `totalSupplyUI()` returns `totalSupply() * uiMultiplier() / WAD_PRECISION`. +- Multiplier changes do not modify canonical raw balances. They only change derived UI-scaled values. +- UI-scaled views calculate `raw * multiplier / WAD_PRECISION` with integer division and round down. `fromUIAmount` and `toRawBalance` also round down, so a round trip can lose up to one unit in the last place (ULP) when `multiplier != WAD_PRECISION`. +- A large reverse split can make this rounding effect economically significant for tokens with few decimals. Use 18 decimals for equities to minimize the effect. For more information, see `docs/B20/Asset.md`. +- Each UI-scaled read performs one additional `SLOAD` and one timestamp comparison to determine whether a pending multiplier has matured. This applies to `uiMultiplier`, `multiplier`, `balanceOfUI`, `scaledBalanceOf`, `toUIAmount`, `fromUIAmount`, and `totalSupplyUI`. Raw `balanceOf` reads are unchanged. #### Deprecated `updateMultiplier` behavior From c60eff4335e7faf10fcfae0492cde27ab5017787 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:47:31 -0400 Subject: [PATCH 11/17] docs(changelog): move event guidance to migration Show schedule and cancel events in the sequence diagrams and consolidate integrator event handling under Migration Steps. Co-authored-by: Cursor --- changelog/02_Cobalt_B20Asset_multiplier.md | 29 +++++++--------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index 514f9f8..be51995 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -181,7 +181,7 @@ sequenceDiagram participant Reader as User or integrator Operator->>Asset: updateUIMultiplier(newMultiplier, effectiveAt) - Asset-->>Operator: Schedule pending multiplier + Asset-->>Operator: UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAt) Reader->>Asset: uiMultiplier() before effectiveAt Asset-->>Reader: Current multiplier Note over Asset: effectiveAt passes
No transaction, event, or storage write @@ -206,27 +206,17 @@ sequenceDiagram participant Reader as User or integrator Operator->>Asset: updateUIMultiplier(newMultiplier, effectiveAt) - Asset-->>Operator: Schedule pending multiplier + Asset-->>Operator: UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAt) Reader->>Asset: uiMultiplier() before effectiveAt Asset-->>Reader: Current multiplier Operator->>Asset: cancelUIMultiplierUpdate() before effectiveAt - Asset-->>Operator: Clear pending update and emit cancellation event + Asset-->>Operator: UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt) Reader->>Asset: uiMultiplier() Asset-->>Reader: Current multiplier remains unchanged ``` -##### Event handling for integrators - -Use `UIMultiplierUpdated` as the canonical source for multiplier updates. The event is emitted when an update is -scheduled, not when the new multiplier becomes active. If `effectiveAtTimestamp > block.timestamp`, treat the -update as pending until the specified timestamp. The contract does not emit another event when the update matures. - -When `UIMultiplierUpdateCancelled` is emitted, discard the pending update. The instant `updateMultiplier` function -emits both `MultiplierUpdated` and `UIMultiplierUpdated`. Process only `UIMultiplierUpdated` to avoid handling the -same update twice. - #### UI-scaled views The Functions table lists the ERC-8056 aliases. Behavior that matters for integrators: @@ -299,10 +289,9 @@ only immediate on-chain override for an incorrect scheduled value or timestamp. ### Off-chain integrators -To detect a live pending update, off-chain integrators should check whether `effectiveAt() > block.timestamp`. -Do not check whether `effectiveAt() == 0` because `effectiveAt()` retains the most recent timestamp after an update -matures. Listen for the canonical `UIMultiplierUpdated` event instead of the deprecated `MultiplierUpdated` event, -which is emitted only by the instant `updateMultiplier` function. - -The `toScaledBalance` and `toRawBalance` functions and the legacy `MultiplierUpdated` event remain available for -backward compatibility but are deprecated. No removal is scheduled, but a future hardfork may remove them. \ No newline at end of file +- Listen for the canonical `UIMultiplierUpdated` event instead of the deprecated `MultiplierUpdated` event, which is emitted only by the instant `updateMultiplier` function. +- `UIMultiplierUpdated` is emitted when an update is scheduled, not when the new multiplier becomes active. If `effectiveAtTimestamp > block.timestamp`, treat the update as pending until that timestamp. The contract does not emit another event when the update matures. +- When `UIMultiplierUpdateCancelled` is emitted, discard the pending update. +- The instant `updateMultiplier` function emits both `MultiplierUpdated` and `UIMultiplierUpdated`. Process only `UIMultiplierUpdated` to avoid handling the same update twice. +- Detect a live pending update with `effectiveAt() > block.timestamp`. Do not check `effectiveAt() == 0`, because `effectiveAt()` retains the most recent timestamp after an update matures. +- The `toScaledBalance` and `toRawBalance` functions and the legacy `MultiplierUpdated` event remain available for backward compatibility but are deprecated. No removal is scheduled, but a future hardfork may remove them. \ No newline at end of file From e49b88abbc64e30d66734fbae27726e0e3364de1 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:48:15 -0400 Subject: [PATCH 12/17] docs(changelog): add Rayyan Alam as co-author Acknowledge joint authorship on the scheduled multiplier changelog entry. Co-authored-by: Cursor --- changelog/02_Cobalt_B20Asset_multiplier.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index be51995..c9079f9 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -2,7 +2,7 @@ - **Feature Name**: Scheduled Multiplier - **Start Date**: 2026-08-17 -- **Authors**: Markus +- **Authors**: Rayyan Alam and Markus - **Title**: Schedule Multiplier Updates (ERC-8056) ## Summary @@ -294,4 +294,5 @@ only immediate on-chain override for an incorrect scheduled value or timestamp. - When `UIMultiplierUpdateCancelled` is emitted, discard the pending update. - The instant `updateMultiplier` function emits both `MultiplierUpdated` and `UIMultiplierUpdated`. Process only `UIMultiplierUpdated` to avoid handling the same update twice. - Detect a live pending update with `effectiveAt() > block.timestamp`. Do not check `effectiveAt() == 0`, because `effectiveAt()` retains the most recent timestamp after an update matures. -- The `toScaledBalance` and `toRawBalance` functions and the legacy `MultiplierUpdated` event remain available for backward compatibility but are deprecated. No removal is scheduled, but a future hardfork may remove them. \ No newline at end of file +- The `toScaledBalance` and `toRawBalance` functions and the legacy `MultiplierUpdated` event remain available for backward compatibility but are deprecated. No removal is scheduled, but a future hardfork may remove them. + From dda65a6d84bfeaa9fb7bb9b373e57b397606207f Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 10:49:44 -0400 Subject: [PATCH 13/17] docs(changelog): format lifecycle constraints as notes Call out effectiveAt edge cases as discrete notes under the scheduled update lifecycle. Co-authored-by: Cursor --- changelog/02_Cobalt_B20Asset_multiplier.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index c9079f9..3c61f95 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -168,11 +168,10 @@ For routine corporate actions, an account with `OPERATOR_ROLE` calls `updateUIMu 3. **Apply the matured value.** The update matures when `block.timestamp >= effectiveAt`. From that point, `uiMultiplier()` returns the scheduled multiplier. The contract calculates this effective value when a caller reads it. Maturation does not write to storage or emit an event. 4. **Handle a later multiplier update.** Until another multiplier update occurs, `newUIMultiplier()` mirrors `uiMultiplier()` and returns the matured value, and `effectiveAt()` retains its past timestamp. A later `updateUIMultiplier` call stores the matured multiplier as the current multiplier before recording the new schedule. A later `updateMultiplier` call replaces the matured multiplier immediately and clears the pending schedule. -The `effectiveAt` timestamp must be strictly in the future. The function reverts with `EffectiveAtInPast` when -`effectiveAt <= block.timestamp`. The update matures when `block.timestamp >= effectiveAt`, so a schedule cannot -target the current timestamp. +Notes: -Detect a live pending update by checking `effectiveAt() > block.timestamp`. Do not check `effectiveAt() == 0`. +- `effectiveAt` must be strictly in the future. The function reverts with `EffectiveAtInPast` when `effectiveAt <= block.timestamp`, so a schedule cannot target the current timestamp. +- Detect a live pending update with `effectiveAt() > block.timestamp`. Do not check `effectiveAt() == 0`. ```mermaid sequenceDiagram From 7b744e41b4c9ef71052a488e08f50145723ae2b9 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 11:07:33 -0400 Subject: [PATCH 14/17] docs(changelog): clarify composite policy summary and motivation Separate each paragraph by intent so the problem is fully stated before UNION/INTERSECT are introduced. Co-authored-by: Cursor --- .../02_Cobalt_PolicyRegistry_composite_policy.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md index 31de2dc..ff514c2 100644 --- a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md +++ b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md @@ -7,17 +7,21 @@ ## Summary -Asset issuers often use the Policy Registry to maintain compliance lists. They and other Policy Registry users can also depend on shared lists maintained by other policy owners. This feature lets them compose these policies without copying entries into a new list or maintaining infrastructure to synchronize updates. +Asset issuers use the Policy Registry to enforce compliance on their tokens. Any token can reference any policy, including a list the issuer maintains and a shared list another policy owner maintains — for example a KYC allowlist or a sanctions blocklist. Combining those policies previously required flattening them into a new list. -The feature introduces two new `PolicyRegistry` policy types: `UNION` (OR) and `INTERSECT` (AND), collectively called composite policies. A `UNION` policy authorizes an account if any child policy authorizes it. An `INTERSECT` policy authorizes an account only if every child policy authorizes it. Each composite references two to four existing simple policies (`ALLOWLIST` or `BLOCKLIST`). Composite policies cannot reference other composites, and the registry enforces this constraint when a composite is created or updated. Authorization uses each child's current state, so updating a child automatically affects every composite that references it. +This feature adds composite policies so issuers can combine those lists without flattening. A `UNION` (OR) policy authorizes an account if any child policy authorizes it. An `INTERSECT` (AND) policy authorizes an account only if every child policy authorizes it. + +Each composite references two to four existing simple policies (`ALLOWLIST` or `BLOCKLIST`). Composite policies cannot reference other composites; the registry enforces this constraint when a composite is created or updated. Authorization uses each child's current state, so updating a child automatically affects every composite that references it. ## Motivation -Asset issuance platforms often manage many assets that share authorization requirements. An issuer can reuse one policy across these assets, but assigning that policy directly leaves no way to customize authorization for an individual asset. A composite policy lets the issuer use shared policies by default while preserving per-asset overrides. For example, a `UNION` can combine a shared allowlist with a token-specific allowlist. +Asset issuance platforms often manage many assets that share authorization requirements. An issuer can reuse one policy across these assets, but assigning that policy directly leaves no way to customize authorization for an individual asset. + +Without a way to combine policies, users must copy entries from source policies into a new, flattened policy and operate infrastructure that monitors and synchronizes every source update. This approach duplicates policy data and can leave the copy stale when synchronization is delayed or fails. Until the copy catches up, valid transfers can be rejected or transfers that the source policy no longer authorizes can proceed. -Without composition, users must copy entries from source policies into a new, flattened policy and operate infrastructure that monitors and synchronizes every source update. This approach duplicates policy data and can leave the copy stale when synchronization is delayed or fails. Until the copy catches up, valid transfers can be rejected or transfers that the source policy no longer authorizes can proceed. +Access control can also require more than one condition. An application might require both KYC verification and ProUser status, or accept either ProUser status or LifetimeUser status. A single simple policy cannot express those AND or OR relationships across independent lists. -Access control can also require more than one condition. An application might require both KYC verification and ProUser status, or accept either ProUser status or LifetimeUser status. Composite policies support these cases by introducing `UNION` (OR) and `INTERSECT` (AND). Because authorization evaluates each child policy's current state, one child update immediately applies to every composite that references it, without list-copying infrastructure. +Composite policies address both cases without flattening. A `UNION` (OR) policy authorizes an account if any child authorizes it, so an issuer can combine a shared allowlist with a token-specific allowlist. An `INTERSECT` (AND) policy authorizes an account only if every child authorizes it, so an issuer can require both KYC verification and ProUser status. Authorization evaluates each child's current state, so one child update immediately applies to every composite that references it, without list-copying infrastructure. ## Background From d8dc649bdfe220a8d54a73b0e233f8577704a55a Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 11:24:43 -0400 Subject: [PATCH 15/17] docs(changelog): tighten composite policy examples and migration Show flattening as a listen-and-propagate path, keep authorization details as invariants, and open migration with compatibility in full sentences. Co-authored-by: Cursor --- ..._Cobalt_PolicyRegistry_composite_policy.md | 116 +++++++++++++----- 1 file changed, 84 insertions(+), 32 deletions(-) diff --git a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md index ff514c2..b6b8284 100644 --- a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md +++ b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md @@ -133,11 +133,7 @@ The function emits only `CompositePolicyUpdated(policyId, updater, childPolicyId #### Authorization Implementation -`isAuthorized` uses the same result from each child, whether that child is an `ALLOWLIST` or a `BLOCKLIST`. -The composite only determines how to combine those results: - -Composite creation and updates reject composite children. Authorization therefore evaluates only simple child -policies and does not recurse into another composite. +The `isAuthorized` implementation change works as this pseudo-code: ```text isAuthorized(policyId, account): @@ -162,13 +158,17 @@ isAuthorized(policyId, account): #### Authorization Details +These invariants hold: + +- Composite creation and updates reject composite children, so evaluation never recurses. Each child is a + simple `ALLOWLIST` or `BLOCKLIST`. +- Each child returns one authorization result, whether it is an `ALLOWLIST` or a `BLOCKLIST`. The composite + only combines those results. - Evaluation is live, not a snapshot. Each call reads the current membership of each evaluated child. - Evaluation short-circuits. `UNION` stops at the first authorizing child, and `INTERSECT` stops at the first non-authorizing child. -- Gas cost depends on the number of child policies evaluated. Child order can therefore affect gas, but it - cannot affect the authorization result. Put the child most likely to short-circuit first. -- `ALLOWLIST` and `BLOCKLIST` children use the same composite evaluation path. Each child first resolves its - own authorization result, and then the composite combines those results. +- Child order cannot change the authorization result. It can change gas, because gas depends on how many + children are evaluated. Put the child most likely to short-circuit first. - Duplicate child IDs are allowed. The registry preserves their order and does not deduplicate them. - `updateComposite` requires two to four children, so an existing composite cannot become empty or undersized. - A child remains effective if its admin renounces. Renouncing freezes future membership changes but does not @@ -205,21 +205,71 @@ simple policies use; composite policies do not use a separate counter. ### Examples -#### Before (Simple Policy) +Assume existing simple policies: `employeesPolicyId` (ALLOWLIST) and `approvedRegionPolicyId` (ALLOWLIST). Both Before and After combine them so an account may transfer if it is on either list. + +#### Before (Flattened Policy) -Assign one existing policy directly to a B20 policy scope: +B20 stores one policy ID per scope, so the two allowlists must be copied into a new flattened allowlist. Off-chain infrastructure then has to keep that copy aligned with both sources. + +**1. Flatten once** + +Read members from `employeesPolicyId` and `approvedRegionPolicyId`. Create a new allowlist with the union, then point B20 at it. ```solidity -b20.updatePolicy(TRANSFER_SENDER_POLICY, allowlistPolicyId) +flattenedPolicyId = policyRegistry.createPolicyWithAccounts( + admin, + ALLOWLIST, + [/* union of employees and approved-region addresses */] +) +b20.updatePolicy(TRANSFER_SENDER_POLICY, flattenedPolicyId) ``` -Only accounts in `allowlistPolicyId` can transfer. +```mermaid +flowchart LR + E[employeesPolicyId members] + R[approvedRegionPolicyId members] + F[flattenedPolicyId] + T[B20 TRANSFER_SENDER_POLICY] + E -->|copy| F + R -->|copy| F + T -->|stores| F +``` -#### After (Composite Policy) +**2. Listen to both sources** + +Watch `AllowlistUpdated` on `employeesPolicyId` and `approvedRegionPolicyId`. A change on either list is not visible to B20 until the listener writes it into the flattened copy. + +```mermaid +flowchart LR + E[employeesPolicyId] + R[approvedRegionPolicyId] + L[Sync infrastructure] + E -->|AllowlistUpdated| L + R -->|AllowlistUpdated| L +``` + +**3. Propagate the change** -Assume existing simple policies: `employeesPolicyId` (ALLOWLIST), `approvedRegionPolicyId` (ALLOWLIST). +On each event, copy the membership delta into `flattenedPolicyId` with `updateAllowlist`, or rebuild a new flattened allowlist and call `updatePolicy` again. Until that transaction lands, an account added to a source list is still rejected, and an account removed from a source list can still transfer. -Create a UNION composite: +```mermaid +sequenceDiagram + participant SourceAdmin + participant Employees as employeesPolicyId + participant Listener as Sync infrastructure + participant Flat as flattenedPolicyId + participant B20 + + SourceAdmin->>Employees: updateAllowlist(true, [Alice]) + Employees-->>Listener: AllowlistUpdated(..., true, [Alice]) + Note over B20,Flat: Alice cannot transfer yet + Listener->>Flat: updateAllowlist(true, [Alice]) + Note over B20,Flat: Alice can transfer +``` + +#### After (Composite Policy) + +Create a UNION composite that references the two source policies. Do not copy their members: ```solidity policyRegistry.createCompositePolicy(admin, UNION, [employeesPolicyId, approvedRegionPolicyId]) @@ -233,19 +283,25 @@ 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 +B20 has no composite-specific logic — it passes the policy ID to the registry as usual. Adding Alice to `employeesPolicyId` authorizes her on the next check, with no recopy. -```solidity -policyRegistry.updateComposite(compositePolicyId, [employeesPolicyId, trustedPartnersPolicyId]) -``` - -Emits: `CompositePolicyUpdated(policyId, admin, [newChildren])`. +```mermaid +sequenceDiagram + participant Admin + participant Employees as employeesPolicyId + participant Region as approvedRegionPolicyId + participant Union as UNION composite + participant B20 -B20 continues using the same policy ID — no token-side update required. + Admin->>Union: createCompositePolicy(UNION, [employees, approvedRegion]) + Admin->>B20: updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId) -Future authorization checks use the new child set immediately (live evaluation, no snapshot). + Note over Employees: Alice added to employeesPolicyId + B20->>Union: isAuthorized(compositePolicyId, Alice) + Union->>Employees: isAuthorized(employeesPolicyId, Alice) + Employees-->>Union: true + Union-->>B20: true +``` ## Design Decisions & Alternatives Considered @@ -296,9 +352,9 @@ Future authorization checks use the new child set immediately (live evaluation, ## Migration Steps -**Backwards-compatible**: Existing simple policies (`ALLOWLIST`/`BLOCKLIST`) continue to work unchanged. No action required if you do not need composite behavior. +This change is not breaking. All existing selectors, events, and errors remain dialable at Cobalt, and existing simple policies (`ALLOWLIST` and `BLOCKLIST`) continue to work unchanged. If you do not need composite behavior, you do not need to take any action. -**For users currently flattening multiple lists into one policy**: +If you currently flatten multiple lists into one policy, migrate as follows: 1. Identify the simple policies you want to combine. 2. Call `policyRegistry.createCompositePolicy(admin, UNION or INTERSECT, [childPolicyIds])`. @@ -306,7 +362,3 @@ Future authorization checks use the new child set immediately (live evaluation, - `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. \ No newline at end of file From 6b4b0db9f9eba9c2d59dda994505d0fccd8b17f7 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 11:45:48 -0400 Subject: [PATCH 16/17] docs(changelog): rewrite composite policy spec as flowing prose Give each remaining section one paragraph intent so create/update behavior, authorization details, design alternatives, and migration read as connected prose rather than labeled fragments. Co-authored-by: Cursor --- ..._Cobalt_PolicyRegistry_composite_policy.md | 149 +++++++----------- 1 file changed, 57 insertions(+), 92 deletions(-) diff --git a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md index b6b8284..70141f4 100644 --- a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md +++ b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md @@ -27,24 +27,29 @@ Composite policies address both cases without flattening. A `UNION` (OR) policy ### 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. +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 that policy ID and the account address to the Policy Registry, and rejects the operation if the account is not authorized. -### Policy Registry +For example, a `transfer`: -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`. +```mermaid +flowchart TD + T["b20.transfer(to, amount)"] --> I["policyRegistry.isAuthorized(TRANSFER_SENDER_POLICY, caller)"] + I -->|true| Ok["emit Transfer(caller, to, amount)"] + I -->|false| Revert[revert] +``` -#### Simple Policies -Simple policies are the non-composite policy types: `ALLOWLIST` and `BLOCKLIST`. +### Policy Registry -- `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. +The Policy Registry is a singleton precompile that stores policies. B20 tokens consult it by calling `isAuthorized(policyId, account)` with the policy ID from the relevant scope, including `TRANSFER_FROM`, `TRANSFER_TO`, and `SEIZE_HOLDER`. + +Existing policies are simple `ALLOWLIST` and `BLOCKLIST` types, and they are the only valid children of a composite. ## Specs ### Interface Changes -The relevant `IPolicyRegistry` interface changes are: +The `IPolicyRegistry` interface changes are as follows: ```solidity enum PolicyType { @@ -85,26 +90,25 @@ function MAX_COMPOSITE_CHILD_POLICIES() external view returns (uint256); | `createPolicy(address,uint8)` | `0xca5d55f6` | extended | Now rejects `UNION`/`INTERSECT` with `IncompatiblePolicyType` (see below) | | `createPolicyWithAccounts(address,uint8,address[])` | `0xa2d3044f` | extended | Same new `IncompatiblePolicyType` rejection | -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) +The `PolicyType` enum adds two values. `UNION` (`2`) authorizes an account if any child policy authorizes it (OR). `INTERSECT` (`3`) authorizes an account only if every child policy authorizes it (AND). #### `createCompositePolicy(admin, policyType, childPolicyIds)` -- `childPolicyIds` must contain at least `MIN_COMPOSITE_CHILD_POLICIES` (`2`) and no more than `MAX_COMPOSITE_CHILD_POLICIES` (`4`). -- The `isAuthorized` gas cost increases with each child policy evaluated because each child requires a membership storage read. The highest cost occurs when all four children are evaluated. -- Each child must be an existing `ALLOWLIST` or `BLOCKLIST` policy. Composite policies and the built-in `ALWAYS_ALLOW` and `ALWAYS_BLOCK` policies are not valid children. +`createCompositePolicy` creates a `UNION` or `INTERSECT` policy, sets `admin` as the initial admin, and returns the new policy ID. The function stores `childPolicyIds` as references to existing simple policies. It does not copy child membership, so later `isAuthorized` calls read each child's current state. + +`childPolicyIds` must contain between `MIN_COMPOSITE_CHILD_POLICIES` (`2`) and `MAX_COMPOSITE_CHILD_POLICIES` (`4`) entries. Each child must be an existing `ALLOWLIST` or `BLOCKLIST` policy. Composite policies and the built-in `ALWAYS_ALLOW` and `ALWAYS_BLOCK` policies are not valid children. -The canonical revert order is: +Each child that `isAuthorized` evaluates requires a membership storage read. Gas therefore increases with the number of children evaluated, and is highest when all four children are evaluated. + +The function reverts in this 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) +4. `PolicyNotFound` (a child does not exist, checked as one pass over the whole set) 5. `InvalidChildPolicy` (a child is itself composite or sentinel, checked as a second pass) -The function emits, in order: +The function emits these events in this order: - `PolicyCreated(policyId, creator, policyType)` - `PolicyAdminUpdated(policyId, address(0), admin)` @@ -112,28 +116,28 @@ The function emits, in order: #### `updateComposite(policyId, childPolicyIds)` -This function replaces the entire child set with two to four existing simple policies, subject to the same validation rules as `createCompositePolicy`. It does not support partial updates or an empty child set. +`updateComposite` replaces the entire child set with two to four existing simple policies. The same validation rules as `createCompositePolicy` apply. The function does not support a partial update or an empty child set. -The canonical revert order is: +The function reverts in this order: -1. `PolicyNotFound` (composite itself doesn't exist) +1. `PolicyNotFound` (the composite itself does not exist) 2. `IncompatiblePolicyType` (`policyId` is a simple policy) -3. `Unauthorized` (caller isn't the current admin — fires before the count check) +3. `Unauthorized` (the caller is not the current admin — this check runs before the child-count check) 4. `ChildPoliciesOutsideOfRange` -5. `PolicyNotFound` (a new child doesn't exist) +5. `PolicyNotFound` (a new child does not exist) 6. `InvalidChildPolicy` -The function emits only `CompositePolicyUpdated(policyId, updater, childPolicyIds)` — no `PolicyAdminUpdated`, since the admin does not change. +The function emits `CompositePolicyUpdated(policyId, updater, childPolicyIds)`. It does not emit `PolicyAdminUpdated` because the admin does not change. ### Behavioural Changes #### Existing Functions with Changed Revert Behavior -`createPolicy` and `createPolicyWithAccounts` revert with `IncompatiblePolicyType` when creating a `UNION` or `INTERSECT` policy. +`createPolicy` and `createPolicyWithAccounts` create simple policies. They revert with `IncompatiblePolicyType` when `policyType` is `UNION` or `INTERSECT`. #### Authorization Implementation -The `isAuthorized` implementation change works as this pseudo-code: +`isAuthorized` now evaluates `UNION` and `INTERSECT` policies as follows: ```text isAuthorized(policyId, account): @@ -158,38 +162,27 @@ isAuthorized(policyId, account): #### Authorization Details -These invariants hold: - -- Composite creation and updates reject composite children, so evaluation never recurses. Each child is a - simple `ALLOWLIST` or `BLOCKLIST`. -- Each child returns one authorization result, whether it is an `ALLOWLIST` or a `BLOCKLIST`. The composite - only combines those results. -- Evaluation is live, not a snapshot. Each call reads the current membership of each evaluated child. -- Evaluation short-circuits. `UNION` stops at the first authorizing child, and `INTERSECT` stops at the first - non-authorizing child. -- Child order cannot change the authorization result. It can change gas, because gas depends on how many - children are evaluated. Put the child most likely to short-circuit first. -- Duplicate child IDs are allowed. The registry preserves their order and does not deduplicate them. -- `updateComposite` requires two to four children, so an existing composite cannot become empty or undersized. -- A child remains effective if its admin renounces. Renouncing freezes future membership changes but does not - delete the child or change its current authorization results. -- A well-formed but never-created `UNION` ID has no children and returns `false`. A well-formed but never-created - `INTERSECT` ID has no children and returns `true`. Consumers that store policy IDs MUST call - `policyExists(policyId)` before storing them; otherwise, an invalid `INTERSECT` ID behaves like `ALWAYS_ALLOW`. +Composite creation and updates reject composite children, so evaluation never recurses. Each child is a simple `ALLOWLIST` or `BLOCKLIST`. Each child returns one authorization result, and the composite only combines those results. + +Evaluation is live, not a snapshot: each call reads the current membership of each evaluated child. + +Evaluation also short-circuits. `UNION` stops at the first authorizing child, and `INTERSECT` stops at the first non-authorizing child. Child order cannot change the authorization result. It can change gas, because gas depends on how many children are evaluated. Put the child most likely to short-circuit first. + +Duplicate child IDs are allowed. The registry preserves their order and does not deduplicate them. `updateComposite` requires two to four children, so an existing composite cannot become empty or undersized. + +A child remains effective if its admin renounces. Renouncing freezes future membership changes but does not delete the child or change its current authorization results. + +A well-formed but never-created `UNION` ID has no children and returns `false`. A well-formed but never-created `INTERSECT` ID has no children and returns `true`. Consumers that store policy IDs MUST call `policyExists(policyId)` before storing them. Otherwise an invalid `INTERSECT` ID behaves like `ALWAYS_ALLOW`. #### State Changes -**Storage layout change:** A `children` mapping is added at offset 4 in the `base.policy_registry` ERC-7201 -namespace. The change is additive. Existing state at offsets 0–3 is unchanged, and no storage migration is -needed. Offset 4 is relative to the namespace location, not literal EVM slot 4. +A `children` mapping is added at offset 4 in the `base.policy_registry` ERC-7201 namespace. The change is additive. Existing state at offsets 0–3 is unchanged, and no storage migration is needed. Offset 4 is relative to the namespace location, not literal EVM slot 4. - Namespace location: `0x00503aeb06982fa1fe3151dc68f90b3946c55c449dfd447e49dcaece71ba4a00` - Placed at `CHILDREN_OFFSET = 4` - Field type: `mapping(uint64 policyId => uint64[] childPolicyIds) children` -For each `policyId`, the mapping entry stores the dynamic array length. Array elements start at the hash of that -entry and pack four `uint64` child policy IDs into each 256-bit slot. The two-to-four-child limit means each -composite uses one element slot. +For each `policyId`, the mapping entry stores the dynamic array length. Array elements start at the hash of that entry and pack four `uint64` child policy IDs into each 256-bit slot. The two-to-four-child limit means each composite uses one element slot. | Bits | Array index | Field | | ------- | ----------- | ---------------------- | @@ -198,10 +191,7 @@ composite uses one element slot. | 128–191 | 2 | `childPolicyIds[2]` | | 192–255 | 3 | `childPolicyIds[3]` | -**Reused state:** Simple and composite policies share the global `nextCounter`. The counter starts at 2 because -`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 is the same encoding scheme that -simple policies use; composite policies do not use a separate counter. +Simple and composite policies share the global `nextCounter`. The counter starts at 2 because `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 is the same encoding scheme that simple policies use. Composite policies do not use a separate counter. ### Examples @@ -213,7 +203,7 @@ B20 stores one policy ID per scope, so the two allowlists must be copied into a **1. Flatten once** -Read members from `employeesPolicyId` and `approvedRegionPolicyId`. Create a new allowlist with the union, then point B20 at it. +Read the members of `employeesPolicyId` and `approvedRegionPolicyId`. Create a new allowlist with that union, then point B20 at the copy. ```solidity flattenedPolicyId = policyRegistry.createPolicyWithAccounts( @@ -269,21 +259,21 @@ sequenceDiagram #### After (Composite Policy) -Create a UNION composite that references the two source policies. Do not copy their members: +Create a `UNION` composite that references the two source policies. Do not copy their members. ```solidity policyRegistry.createCompositePolicy(admin, UNION, [employeesPolicyId, approvedRegionPolicyId]) ``` -Emits: `PolicyCreated(policyId, admin, UNION)` + `PolicyAdminUpdated(policyId, 0, admin)` + `CompositePolicyUpdated(policyId, admin, [children])`. +The call emits `PolicyCreated(policyId, admin, UNION)`, then `PolicyAdminUpdated(policyId, 0, admin)`, then `CompositePolicyUpdated(policyId, admin, [children])`. -Assign to B20: +Assign the composite 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. Adding Alice to `employeesPolicyId` authorizes her on the next check, with no recopy. +B20 has no composite-specific logic. It passes the policy ID to the registry as usual. Adding Alice to `employeesPolicyId` authorizes her on the next check, with no recopy. ```mermaid sequenceDiagram @@ -305,50 +295,27 @@ sequenceDiagram ## Design Decisions & Alternatives Considered -**Decision**: Two explicit policy types (`UNION`, `INTERSECT`) with a single `createCompositePolicy` function and full-replacement `updateComposite`. +The chosen design uses two explicit policy types (`UNION` and `INTERSECT`), 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. - - 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. +This alternative stores a separate operator (AND, OR, NOT, XOR) in composite storage. It was rejected because it requires storing both a composite flag and the operator, adds storage reads or more complicated ID encoding, and enlarges the gas and audit surface before there is a requirement for NOT, XOR, or nested expressions. **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. +This alternative keeps Policy Registry unchanged and has each B20 token store multiple policy IDs plus an operator. It was rejected because composite policies would not be reusable entities, the change would spread across B20, token variants, factories, and token hot paths, and one composite could not be shared across multiple tokens. **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. +This alternative provides `addCompositeOperand` and `removeCompositeOperand` functions. It was rejected because the child list is capped at 4 entries, and dynamic-array mutation requires swap/remove, length, and deduplication logic. Full replacement is atomic, and the caller can resend the complete list at low cost. **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. +This alternative uses `createUnionPolicy` and `createIntersectPolicy`. It was rejected because it doubles the creation API surface. A single `createCompositePolicy` keeps policy creation consistent, and future operators would each require another function. **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 — 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. +This alternative allows composite children to some bounded depth, instead of restricting children to simple `ALLOWLIST` and `BLOCKLIST` policies. It was rejected because restricting children to simple policies guarantees that `isAuthorized` recursion terminates at depth 1, with no cycle risk and no unbounded traversal. That bound also limits worst-case gas and the audit surface of authorization evaluation. There is no demonstrated need for nested expressions. A wrapper composite can be introduced later if one arises. ## Migration Steps @@ -358,7 +325,5 @@ If you currently flatten multiple lists into one policy, migrate as follows: 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. +3. Point the B20 token's policy scope at the new composite policy ID with `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 it is no longer needed. From 92bb06d5b85062fc1e079e4b286bb1d3a7ab2e13 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 26 Aug 2026 11:58:59 -0400 Subject: [PATCH 17/17] docs(changelog): attribute seize entry to Stephan Co-authored-by: Cursor --- 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 eea1f10..8d356f3 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