Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions docs/B20/Asset.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,42 @@ The Asset variant of B20 — designed for assets of all kinds. Everything in [B2

## Multiplier

Each account's stored balance is the **raw** balance. A uniform on-chain **multiplier** scales that raw balance into a derived **scaled** view that consumers display. The multiplier applies to all accounts equally, which lets issuers rebase every balance at once — without rewriting individual balances — the shape is similar to wstETH wrapping stETH, where the stored unit is the unwrapped quantity and the derived unit is the rebased view.
Each account's stored balance is the **raw** balance. A uniform on-chain **multiplier** scales that raw balance into a derived **scaled** view that consumers display. The multiplier applies to all accounts equally, which lets issuers rebase every balance at once — without rewriting individual balances — the shape is similar to wstETH wrapping stETH, where the stored unit is the unwrapped quantity and the derived unit is the rebased view. Because it only rescales the *displayed* balance, the multiplier is purely cosmetic: `balanceOf`, `transfer`, and `totalSupply` stay raw, so raw-denominated venues (AMMs, etc.) are mechanically unaffected by an update.

Read the current multiplier with `multiplier()`; the value is in WAD precision (`1e18`, exposed as `WAD_PRECISION()`). `toScaledBalance(rawBalance)` converts a raw amount to its scaled view, `toRawBalance(scaledBalance)` is the reverse converter (integer-floored, so the round-trip can lose up to one ULP), and `scaledBalanceOf(account)` is a convenience over ERC-20's `balanceOf` that returns the same account's raw balance in its scaled form.

`updateMultiplier(newMultiplier)` updates the multiplier and should be wrapped in an announcement (see [Announcements](#announcements)).
Both multiplier setters validate `newMultiplier` is non-zero and at most `type(uint128).max` (reverting `InvalidMultiplier` otherwise). The `uint128` ceiling is the overflow guard: with supply capped at `type(uint128).max`, a `uint128` multiplier keeps `raw * multiplier` inside `uint256`, so scaled reads can never overflow.

### Scheduling multiplier updates

The standard path for a corporate action (a stock split or reinvested stock dividend) is to **schedule** the change ahead of time with `setUIMultiplier(newMultiplier, effectiveAt)`, wrapped in an [announcement](#announcements). Evaluation is lazy, so `multiplier()` / `uiMultiplier()` flip on their own once `block.timestamp` reaches `effectiveAt`.

Only **one pending update is live at a time**. Attempting to schedule over an existing pending update reverts `ScheduleOverlap`. To reorder overlapping corporate actions, explicitly cancel and re-schedule in a single announcement bracket using `announce([cancelScheduledMultiplier, setUIMultiplier(...)])`. `cancelScheduledMultiplier()` clears the live pending and restores the no-pending state (reverting `NoScheduledMultiplier` when nothing live is scheduled).

`updateMultiplier(newMultiplier)` is retained as an **instant failsafe / emergency override**: it sets the multiplier immediately, stamping `effectiveAt = block.timestamp` and clearing any pending update.

The pending schedule is observable through the ERC-8056 surface: `newUIMultiplier()` returns the scheduled target while it is live (otherwise it mirrors `uiMultiplier()`).

### ERC-8056 conformance

The Asset variant conforms to [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) ("Scaled UI Amount"):

- `uiMultiplier()` is the standard alias of `multiplier()` (core interface `0xa60bf13d`).
- `newUIMultiplier()` / `effectiveAt()` expose the pending schedule (required extension `0x4bd27648`).
- `balanceOfUI(account)` aliases `scaledBalanceOf`, and `totalSupplyUI()` returns `totalSupply() * uiMultiplier() / 1e18` (optional Balances extension `0xd890fd71`).
- `supportsInterface(bytes4)` (ERC-165, `0x01ffc9a7`) returns `true` for those three IDs and for ERC-165 itself. The optional Conversion extension (`0x57854fc3`) is **not** claimed — the native `toScaledBalance` / `toRawBalance` names are kept unaliased for backwards compatibility.

**Events.** Every multiplier change emits `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` — from `setUIMultiplier` and from `updateMultiplier`. `MultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` is emitted by `cancelScheduledMultiplier` and by `updateMultiplier` when it clears a live pending.

### Precision & decimals

All multiplier-derived reads (`toScaledBalance` / `scaledBalanceOf` / `totalSupplyUI` divide by `WAD_PRECISION`; `toRawBalance` divides by the multiplier) round **down**, and raw balances are never rewritten. This guarantees that rounding loss is rare and confined to the scaled view (and to `toRawBalance` conversions). In the rare case where rounding loss occurs, the loss cannot exceed 1 wei of the *scaled* amount only.

**Thus, prefer 18 decimals for equities**: at 6 decimals, a deep reverse split on a very valuable stock could make 1-wei floor dust economically visible; at 18 it stays noise

### Pause & market-halt policy

Because a multiplier update is value-neutral to raw venues, forward splits and reinvested dividends need no halt on-chain. A reverse split, however, warrants halting via `PausableFeature.TRANSFER` across the flip window so trading windows are paused and re-enabled in orderly fashion. The instant `updateMultiplier` bypasses the scheduling window entirely, so it should likewise be pause-bracketed.

## Announcements

Expand All @@ -25,14 +56,14 @@ Indexers should treat every `Announcement` log as the start of exactly one brack
Wrap a set of operations in a single announcement by calling `announce(internalCalls, id, description, uri)`. The function (gated by `OPERATOR_ROLE`) emits `Announcement`, dispatches each internal call via self-`delegatecall` (which preserves `msg.sender` so the inner role checks see the operator), then emits `EndAnnouncement`. Inner reverts are wrapped in `InternalCallFailed` rather than bubbled — replay the call directly to debug. Nested calls to `announce` revert with `AnnouncementInProgress`; calls shorter than 4 bytes revert with `InternalCallMalformed`.

```solidity
// Disclose and apply a community-ratified rebase atomically.
// Disclose and schedule a 2:1 forward split, effective at the ex-date.
bytes[] memory internalCalls = new bytes[](1);
internalCalls[0] = abi.encodeCall(IB20Asset.updateMultiplier, (newMultiplier));
internalCalls[0] = abi.encodeCall(IB20Asset.setUIMultiplier, (2e18, exDateTimestamp));

IB20Asset(token).announce({
internalCalls: internalCalls,
id: "2026-Q3-rebase",
description: "Community governance proposal #42: ratified rebase",
id: "2026-Q3-split",
description: "2:1 forward split, effective at ex-date",
uri: "https://disclosures.example.com/..."
});
```
Expand All @@ -51,7 +82,7 @@ Each Asset token can carry an arbitrary set of named metadata entries — a gene

### `OPERATOR_ROLE`

Gates the `updateMultiplier` and the `announce`. These are metadata-like operations — they post disclosures and rescale the displayed balance rather than moving raw balances directly — but a compromised operator carries materially higher severity than ordinary metadata edits, so the capability is elevated into its own independent role instead of being folded into `METADATA_ROLE`. Held separately from `DEFAULT_ADMIN_ROLE` so operators don't need full admin authority.
Gates `announce`, `setUIMultiplier`, `cancelScheduledMultiplier`, and `updateMultiplier`. These are metadata-like operations — they post disclosures and rescale the displayed balance rather than moving raw balances directly — but a compromised operator carries materially higher severity than ordinary metadata edits, so the capability is elevated into its own independent role instead of being folded into `METADATA_ROLE`. Held separately from `DEFAULT_ADMIN_ROLE` so operators don't need full admin authority.

## Configurable Decimals

Expand Down
65 changes: 52 additions & 13 deletions src/interfaces/IB20Asset.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
pragma solidity >=0.8.20 <0.9.0;

import {IB20} from "./IB20.sol";
import {IERC165} from "./IERC165.sol";
import {IScaledUIAmount, IScaledUIAmountNewUIMultiplier, IScaledUIAmountBalances} from "./IScaledUIAmount.sol";

/// @title IB20Asset
/// @author Coinbase
///
/// @notice A B-20 token variant for assets of all kinds. Extends `IB20` with announcements,
/// multiplier-based scaling, batched mint for bulk issuance, and extra-metadata
/// entries.
interface IB20Asset is IB20 {
interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMultiplier, IScaledUIAmountBalances {
/*//////////////////////////////////////////////////////////////
ERRORS
//////////////////////////////////////////////////////////////*/
Expand All @@ -20,9 +22,30 @@ interface IB20Asset is IB20 {
/// @notice `updateExtraMetadata` was called with an empty `key`.
error InvalidMetadataKey();

/// @notice `updateMultiplier` was called with a zero multiplier.
/// @notice A multiplier setter (`setUIMultiplier` or `updateMultiplier`) was called with a
/// multiplier of zero or above the `type(uint128).max` overflow guard.
error InvalidMultiplier();

/// @notice `setUIMultiplier` was called with an `effectiveAt` that is not in the future
/// (`effectiveAt <= block.timestamp`).
///
/// @param effectiveAt Rejected effective-at timestamp.
error EffectiveAtInPast(uint256 effectiveAt);

/// @notice `setUIMultiplier` was called with an `effectiveAt` above `type(uint64).max`, the
/// width of the on-chain `effectiveAt` field.
///
/// @param effectiveAt Rejected effective-at timestamp.
error EffectiveAtTooFar(uint256 effectiveAt);

/// @notice `setUIMultiplier` was called while a live pending update already exists
///
/// @param pendingEffectiveAt The `effectiveAt` of the live pending update.
error ScheduleOverlap(uint256 pendingEffectiveAt);

/// @notice `cancelScheduledMultiplier` was called when there is no live pending update
error NoScheduledMultiplier();

/// @notice A batched function was called with parallel arrays of differing lengths.
///
/// @param leftLen Length of the first array argument.
Expand All @@ -49,8 +72,13 @@ interface IB20Asset is IB20 {
EVENTS
//////////////////////////////////////////////////////////////*/

/// @notice Emitted by `updateMultiplier`.
event MultiplierUpdated(uint256 multiplier);
/// @notice Base-local companion to ERC-8056's `UIMultiplierUpdated`. Fills the standard's

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can simplify this comment to. Old multiprler scheduled has been cancelled something along those lines

/// cancellation gap: emitted by `cancelScheduledMultiplier`, and by `updateMultiplier`
/// whenever it clears a live pending update.
///
/// @param cancelledMultiplier The pending multiplier that was cleared.
/// @param cancelledEffectiveAt The `effectiveAt` of the pending update that was cleared.
event MultiplierUpdateCancelled(uint256 cancelledMultiplier, uint256 cancelledEffectiveAt);

/// @notice Emitted by `updateExtraMetadata`. An empty `value` indicates removal.
event ExtraMetadataUpdated(string key, string value);
Expand All @@ -65,9 +93,9 @@ interface IB20Asset is IB20 {
ROLE CONSTANTS
//////////////////////////////////////////////////////////////*/

/// @notice Required to call `announce` and `updateMultiplier`. The metadata setters
/// (`updateName`, `updateSymbol`, `updateExtraMetadata`) are gated by the
/// inherited `METADATA_ROLE` instead.
/// @notice Required to call `announce`, `setUIMultiplier`, `cancelScheduledMultiplier`, and
/// `updateMultiplier`. The metadata setters (`updateName`, `updateSymbol`,
/// `updateExtraMetadata`) are gated by the inherited `METADATA_ROLE` instead.
/// @return Role constant.
function OPERATOR_ROLE() external view returns (bytes32);

Expand Down Expand Up @@ -119,7 +147,9 @@ interface IB20Asset is IB20 {
/// @notice The current multiplier, scaled to `WAD_PRECISION`. Holder balances are stored
/// as raw units; the multiplier scales them into a derived "scaled" view, similar
/// in shape to wstETH wrapping stETH.
/// @return Current multiplier.
///
/// @dev Alias of the ERC-8056 `uiMultiplier()`.
/// @return Current (effective) multiplier.
function multiplier() external view returns (uint256);

/// @notice Converts a raw balance to its scaled view: `rawBalance * multiplier / WAD_PRECISION`.
Expand Down Expand Up @@ -148,13 +178,22 @@ interface IB20Asset is IB20 {
/// @return Scaled balance.
function scaledBalanceOf(address account) external view returns (uint256);

/// @notice Sets a new multiplier. Holder raw balances are not rewritten; scaled balances
/// derive from the new multiplier at read time. Emits `MultiplierUpdated`.
/// @notice Schedules a multiplier update to take effect at `effectiveAt` — the standard path
/// for corporate actions (splits, reinvested dividends).
///
/// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `OPERATOR_ROLE`.
/// @dev Reverts with `InvalidMultiplier` when `newMultiplier` is zero.
/// @param newMultiplier New multiplier scaled to `WAD_PRECISION`
/// @param effectiveAt Timestamp at which `newMultiplier` becomes effective; must be in the future.
function setUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) external;

/// @notice Cancels the single live pending update, restoring the no-pending state
/// (`effectiveAt` resets to 0).
function cancelScheduledMultiplier() external;

/// @notice Instant failsafe / emergency override — sets the current multiplier immediately and
/// cancels any live pending update without a scheduling window.
/// Prefer `setUIMultiplier` for routine corporate actions
///
/// @param newMultiplier New multiplier scaled to `WAD_PRECISION`; must be non-zero.
/// @param newMultiplier New multiplier scaled to `WAD_PRECISION`; must be in `(0, type(uint128).max]`.
function updateMultiplier(uint256 newMultiplier) external;

/*//////////////////////////////////////////////////////////////
Expand Down
18 changes: 18 additions & 0 deletions src/interfaces/IERC165.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.20 <0.9.0;

/// @title IERC165
/// @author Ethereum (ERC-165)
///
/// @notice Standard interface-detection interface.
///
/// @dev Interface ID: `0x01ffc9a7`. Declared locally because base-std carries no external
/// dependency that provides it (ERC-165 is greenfield here).
interface IERC165 {
/// @notice Whether this contract implements the interface defined by `interfaceId`.
///
/// @param interfaceId The interface identifier, per ERC-165.
///
/// @return `true` if the contract implements `interfaceId` and `interfaceId != 0xffffffff`.
function supportsInterface(bytes4 interfaceId) external view returns (bool);
}
60 changes: 60 additions & 0 deletions src/interfaces/IScaledUIAmount.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.20 <0.9.0;

/// @title IScaledUIAmount
/// @author Ethereum (ERC-8056)
///
/// @notice ERC-8056 core interface. A compliant token exposes an updatable, cosmetic
/// `uiMultiplier` (18-decimal WAD, `1e18 = 1.0`) that rescales the *displayed*
/// balance without minting, transferring, or rewriting any raw balance.
///
/// @dev Interface ID: `0xa60bf13d` — self-checked via `type(IScaledUIAmount).interfaceId`, which
/// is derived solely from `uiMultiplier()`. ERC-8056 also defines an OPTIONAL
/// `TransferWithUIAmount(address,address,uint256,uint256)` event; B20 deliberately omits it
/// (D9: no per-transfer scaled event, since the multiplier is cosmetic and scaled balances
/// are reconstructable off-chain). Events do not contribute to the interface ID, so omitting
/// it leaves `0xa60bf13d` unchanged.
interface IScaledUIAmount {
/// @notice Emitted when the UI multiplier is updated.
event UIMultiplierUpdated(uint256 oldMultiplier, uint256 newMultiplier, uint256 effectiveAtTimestamp);

/// @notice Returns the current UI multiplier. Multiplier is represented with 18 decimals (`1e18 = 1.0`).
/// @return Current UI multiplier.
function uiMultiplier() external view returns (uint256);
}

/// @title IScaledUIAmountNewUIMultiplier
/// @author Ethereum (ERC-8056)
///
/// @notice "Pending Multiplier" extension required by ERC-8056
///
/// @dev Interface ID: `0x4bd27648`.
interface IScaledUIAmountNewUIMultiplier {
/// @notice Returns the pending UI multiplier scheduled to take effect at `effectiveAt`.
/// Multiplier is represented with 18 decimals (`1e18 = 1.0`).
/// @return Pending UI multiplier.
function newUIMultiplier() external view returns (uint256);

/// @notice Returns the timestamp at which the pending multiplier becomes effective.
/// @return Effective-at timestamp.
function effectiveAt() external view returns (uint256);
}

/// @title IScaledUIAmountBalances
/// @author Ethereum (ERC-8056)
///
/// @notice ERC-8056 optional "Balances" extension for on-chain UI balance queries.
///
/// @dev Interface ID: `0xd890fd71`.
interface IScaledUIAmountBalances {
/// @notice Returns the UI-adjusted balance of an account.
///
/// @param account Account whose UI-adjusted balance is being queried.
///
/// @return UI-adjusted balance.
function balanceOfUI(address account) external view returns (uint256);

/// @notice Returns the UI-adjusted total supply.
/// @return UI-adjusted total supply.
function totalSupplyUI() external view returns (uint256);
}
12 changes: 12 additions & 0 deletions src/lib/B20FactoryLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ library B20FactoryLib {
return abi.encodeCall(IB20Asset.updateMultiplier, (newMultiplier));
}

/// @notice Encodes an initCall / announce inner call to `IB20Asset.setUIMultiplier`
/// @param newMultiplier New multiplier, scaled to `WAD_PRECISION`.
/// @param effectiveAt Timestamp at which `newMultiplier` becomes effective; must be in the future.
function encodeSetUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) internal pure returns (bytes memory) {
return abi.encodeCall(IB20Asset.setUIMultiplier, (newMultiplier, effectiveAt));
}

/// @notice Encodes an announce inner call to `IB20Asset.cancelScheduledMultiplier`.
function encodeCancelScheduledMultiplier() internal pure returns (bytes memory) {
return abi.encodeCall(IB20Asset.cancelScheduledMultiplier, ());
}

/*//////////////////////////////////////////////////////////////
INIT-CALL ARRAY BUILDERS
//////////////////////////////////////////////////////////////*/
Expand Down
16 changes: 16 additions & 0 deletions test/lib/B20AssetTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ contract B20AssetTest is B20Test {
asset().updateMultiplier(newMultiplier);
}

/// @notice Schedules a pending multiplier via the `operator` actor,
/// lazily granting `OPERATOR_ROLE` on first call.
function _setUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) internal {
_grantOperator();
vm.prank(operator);
asset().setUIMultiplier(newMultiplier, effectiveAt);
}

/// @notice Cancels the live pending multiplier via the `operator`
/// actor, lazily granting `OPERATOR_ROLE` on first call.
function _cancelScheduledMultiplier() internal {
_grantOperator();
vm.prank(operator);
asset().cancelScheduledMultiplier();
}

// ============================================================
// ANNOUNCEMENT HELPERS
// ============================================================
Expand Down
Loading
Loading