diff --git a/README.md b/README.md index bb6bc054..6e50c616 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,13 @@ A collection of Solidity interfaces, libraries, and mock implementations for Bas ## Products -- [**ActivationRegistry**](docs/ActivationRegistry/README.md) — Feature flags controlled by Base team to activate/deactivate features. -- [**PolicyRegistry**](docs/PolicyRegistry/README.md) — Membership sets controlled by custom admins, initially providing allow and block lists for B20 token operations. -- [**B20**](docs/B20/README.md) — Standard ERC-20 implementation with extensions for roles, policies, memos, pausing, ERC-2612 permits, and a variant system. +- [**ActivationRegistry**](src/interfaces/IActivationRegistry.sol) — Feature flags controlled by Base team to activate/deactivate features. +- [**PolicyRegistry**](docs/concepts/policies.md) — Membership sets controlled by custom admins, initially providing allow and block lists for B20 token operations. +- [**B20**](docs/overview.md) — Standard ERC-20 implementation with extensions for roles, policies, memos, pausing, ERC-2612 permits, and a variant system. + +## Documentation + +See [`docs/`](docs/README.md) for the full documentation map: overview, architecture, audience guides (integrator/indexer), concepts, and reference. ## Changelog diff --git a/docs/ActivationRegistry/README.md b/docs/ActivationRegistry/README.md deleted file mode 100644 index 3d630a7d..00000000 --- a/docs/ActivationRegistry/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# ActivationRegistry - -The ActivationRegistry tracks which Base features are live. This is managed exclusive by the Base and integrators don't typically need to query it. See [`IActivationRegistry`](../../src/interfaces/IActivationRegistry.sol) for the full Solidity interface. - -## Feature IDs - -Feature IDs are opaque `bytes32` values. By convention each is the keccak256 digest of a human-readable feature name (e.g., `keccak256("base.b20_asset")`); a feature ID is permanently bound to its semantic and is never recycled. - -The canonical IDs in use today, defined in [`ActivationRegistryFeatureList`](../../test/lib/mocks/ActivationRegistryFeatureList.sol): - -| Constant | Preimage | Value | -|---|---|---| -| `B20_ASSET` | `"base.b20_asset"` | `0xcdcc772fe4cbdb1029f822861176d09e646db96723d4c1e82ddfdeb8163ef54c` | -| `B20_STABLECOIN` | `"base.b20_stablecoin"` | `0xecfa0def2c10020caaf65e6155aa69c84b24892aaef76eeac52e0e2b3a0b8601` | -| `POLICY_REGISTRY` | `"base.policy_registry"` | `0xb582ebae03f16fee49a6763f78df482fb11ae73f103ed0d330bbe556aa90a43f` | - -## User Flows - -### Activate Feature - -The admin marks a feature as live; downstream consumers can immediately observe the change. - -```mermaid -sequenceDiagram - participant Admin - participant ActivationRegistry - - Admin->>ActivationRegistry: activate(featureId) - Note over ActivationRegistry: features[featureId] = true - ActivationRegistry-->>Admin: emit FeatureActivated(feature, caller) -``` - -Reverts: `Unauthorized` (non-admin caller), `AlreadyActivated`, `DelegateCallNotAllowed` / `StaticCallNotAllowed`. - -### Deactivate Feature - -The admin marks a previously-active feature as inactive. - -```mermaid -sequenceDiagram - participant Admin - participant ActivationRegistry - - Admin->>ActivationRegistry: deactivate(featureId) - Note over ActivationRegistry: features[featureId] = false - ActivationRegistry-->>Admin: emit FeatureDeactivated(feature, caller) -``` - -Reverts: `Unauthorized`, `AlreadyDeactivated`, `DelegateCallNotAllowed` / `StaticCallNotAllowed`. diff --git a/docs/B20/Asset.md b/docs/B20/Asset.md deleted file mode 100644 index 67384bda..00000000 --- a/docs/B20/Asset.md +++ /dev/null @@ -1,90 +0,0 @@ -# B20 Asset - -The Asset variant of B20 — designed for assets of all kinds. Everything in [B20/README.md](README.md) applies; this page covers the deltas only. See [`IB20Asset`](../../src/interfaces/IB20Asset.sol) for the full Solidity interface. - -## 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. 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()`). `toUIAmount(rawAmount)` converts a raw amount to its scaled view, `fromUIAmount(uiAmount)` 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. (The legacy `toScaledBalance` / `toRawBalance` are retained in `IB20Asset` as deprecated aliases — see [ERC-8056 conformance](#erc-8056-conformance).) - -Both multiplier setters validate `newMultiplier` is non-zero and at most `type(uint128).max` (exposed as `MAX_UI_MULTIPLIER()`, reverting `InvalidMultiplier` otherwise). The `uint128` ceiling is the overflow guard: with supply capped at `type(uint128).max`, a `uint128` multiplier keeps `balance * multiplier` inside `uint256`, so balance-derived reads 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 `updateUIMultiplier(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 `UIMultiplierUpdateExists`. To reorder overlapping corporate actions, explicitly cancel and re-schedule in a single announcement bracket using `announce([cancelUIMultiplierUpdate, updateUIMultiplier(...)])`. `cancelUIMultiplierUpdate()` clears the live pending and restores the no-pending state (reverting `UIMultiplierUpdateDoesNotExist` when nothing live is scheduled). - -`updateMultiplier(newMultiplier)` is the **deprecated instant failsafe / emergency override**: it sets the multiplier immediately, stamping `effectiveAt = block.timestamp` and clearing any pending update. It is retained in `IB20Asset` (marked deprecated, still dialable) for backward compatibility; prefer the scheduled `updateUIMultiplier` for routine corporate actions. - -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`). -- `toUIAmount(rawAmount)` / `fromUIAmount(uiAmount)` are the canonical raw ⇄ UI converters (optional Conversion extension `0x57854fc3`), applying the effective multiplier. The legacy `toScaledBalance` / `toRawBalance` are retained as deprecated aliases. -- `supportsInterface(bytes4)` (ERC-165, `0x01ffc9a7`) returns `true` for those four extension IDs and for ERC-165 itself. - -**Events.** Every multiplier change emits `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` — from `updateUIMultiplier` and from `updateMultiplier` (which stamps `effectiveAtTimestamp = block.timestamp`), satisfying ERC-8056's "emit on every multiplier change". The deprecated instant setter (`updateMultiplier`) additionally emits the **deprecated** `MultiplierUpdated(newMultiplier)` event alongside `UIMultiplierUpdated`, so indexers still watching the legacy topic keep working through the transition; the scheduled `updateUIMultiplier` emits only `UIMultiplierUpdated`. `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` is emitted by `cancelUIMultiplierUpdate` and by the instant setter when it clears a *live* pending — so an instant override that supersedes a live schedule emits the cancel, then `MultiplierUpdated`, then `UIMultiplierUpdated`. The optional ERC-8056 `TransferWithUIAmount` event is intentionally omitted — scaled balances are derivable from the raw `Transfer` and the active multiplier. - -### Precision & decimals - -All multiplier-derived reads (`toUIAmount` / `scaledBalanceOf` / `totalSupplyUI` divide by `WAD_PRECISION`; `fromUIAmount` 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 `fromUIAmount` 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 - -Announcements are publicly viewable notifications posted by a token operator. They can represent anything the operator wants to create a record of and can be coupled with actual state changes on the token (updating the multiplier, batched mints, and so on). - -### Event Topology - -An announcement is delimited by a paired `Announcement(msg.sender, id, description, uri)` event (opens the bracket) and `EndAnnouncement(id)` event (closes it). Every state-changing call dispatched inside the bracket belongs to that announcement. A recursion guard prevents nesting, and each `id` is enforced unique forever (`AnnouncementIdAlreadyUsed`) so indexers can correlate brackets across transactions. - -Indexers should treat every `Announcement` log as the start of exactly one bracket; effects between `Announcement` and `EndAnnouncement` belong to the announced action; effects emitted *without* a surrounding bracket are direct invocations and should be flagged as emergency overrides. - -### Wrapping calls in announcements - -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 schedule a 2:1 forward split, effective at the ex-date. -bytes[] memory internalCalls = new bytes[](1); -internalCalls[0] = abi.encodeCall(IB20Asset.updateUIMultiplier, (2e18, exDateTimestamp)); - -IB20Asset(token).announce({ - internalCalls: internalCalls, - id: "2026-Q3-split", - description: "2:1 forward split, effective at ex-date", - uri: "https://disclosures.example.com/..." -}); -``` - -## Batch Mint - -`batchMint(recipients, amounts)` mints to many accounts in one call, gated by `MINT_ROLE`. It should be wrapped in `announce()`, which additionally requires the operator to hold `OPERATOR_ROLE` (typically granted as a single bundle). - -## Extra Metadata - -Each Asset token can carry an arbitrary set of named metadata entries — a general-purpose key/value store the issuer is free to use however they want (e.g. `"category"` → `"electronics"`, `"region"` → `"north-america"`, `"reference"` → `"REF-2024-001"`). Read with `extraMetadata(key)`; the value is a `string`. All entries are optional and added post-creation — the factory does not seed any entry at token creation. - -`updateExtraMetadata(key, value)` adds, updates, or removes an entry, gated by `METADATA_ROLE` (the same role that gates `updateName` / `updateSymbol`). It does NOT require `OPERATOR_ROLE` and can be invoked directly without an `announce()` wrapper. Passing an empty `value` removes the entry. An empty `key` reverts with `InvalidMetadataKey`. - -## Additional roles - -### `OPERATOR_ROLE` - -Gates `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and the deprecated `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 - -`decimals()` is chosen at creation via `B20AssetCreateParams.decimals` and immutable thereafter. The factory enforces the inclusive range `[6, 18]` (exposed as `B20Constants.MIN_ASSET_DECIMALS` and `MAX_ASSET_DECIMALS`); out-of-range values revert `InvalidDecimals(decimals)`. `6` is the smallest unit any asset should use and `18` is a reasonable ceiling that encompasses the supermajority of assets. diff --git a/docs/B20/Factory.md b/docs/B20/Factory.md deleted file mode 100644 index e4c17ca5..00000000 --- a/docs/B20/Factory.md +++ /dev/null @@ -1,48 +0,0 @@ -# B20 Factory - -The B20 Factory is the singleton precompile that creates B20 tokens of every variant. Anyone can call its single entry point, `createB20`. See [`IB20Factory`](../../src/interfaces/IB20Factory.sol) for the full Solidity interface. - -## `createB20` parameters - -`createB20` takes four arguments: - -### `variant` - -Selects which variant of B20 to deploy — currently `ASSET` or `STABLECOIN`. See the [variant overview](README.md#variant-overview) for what each one bundles. - -### `params` - -Variant-specific creation arguments, ABI-encoded as a versioned struct (one struct per variant; the leading byte selects the encoding version). Required and optional fields differ per variant — see [`IB20Factory`](../../src/interfaces/IB20Factory.sol) for each variant's struct spec. - -### `initCalls` - -An optional array of ABI-encoded calls dispatched on the new token immediately after creation. These let you configure anything beyond the variant's defined `params` — role grants, mint operations, policy scopes, contract URI, and so on. They execute on the new token as if the factory were the admin, so admin-gated operations are permitted within this window. The factory itself receives no official roles and has no persisted access to the token. - -The bootstrap bypass is deliberately **not total**. During the window, factory-originated calls skip the token's role gates and its transfer-side policy gates (`TRANSFER_SENDER_POLICY`, `TRANSFER_RECEIVER_POLICY`, `TRANSFER_EXECUTOR_POLICY`), but: - -- **`MINT_RECEIVER_POLICY` is always enforced**, even for factory-originated mints — new supply is never issued to a policy-denied recipient, even at creation. If your `initCalls` set a restrictive `MINT_RECEIVER_POLICY` and then mint to a non-authorized account in the same bundle, the mint reverts `PolicyForbids` and the whole `createB20` reverts. Sequence the mint before the restrictive policy, or mint to an authorized recipient. -- **Pause is never bypassed.** It defaults to nothing-paused at creation, so a start-paused configuration must sequence its `pause(...)` call last. -- **Token invariants** (supply-cap math, balance accounting) are never bypassed. - -Build the array with [`B20FactoryLib`](../../src/lib/B20FactoryLib.sol) helpers (or encode manually): - -```solidity -// Configure the new token: cap supply and gate minting on an allowlist. -bytes[] memory initCalls = new bytes[](2); -initCalls[0] = B20FactoryLib.encodeUpdateSupplyCap(1_000_000e18); -initCalls[1] = B20FactoryLib.encodeUpdatePolicy(B20Constants.MINT_RECEIVER_POLICY, mintPolicyId); -``` - -### `salt` - -Caller-chosen entropy that influences the deployed token's address — see [B20 Address Derivation](#b20-address-derivation). - -## B20 Address Derivation - -B20 addresses are deterministic: `[B20 prefix (10 bytes)][variant byte (1 byte)][bytes9(keccak256(deployer, salt))]`. The variant byte being recoverable from the address means off-chain tooling can identify the variant without an RPC call. - -`getB20Address(variant, deployer, salt)` predicts the address before deployment. `isB20(address)` matches against the prefix pattern (recovered from the address with no storage read), and `isB20Initialized(address)` flips true exactly once when `createB20` completes at that address. - -## Composing with the factory - -The factory is callable from any account, including from your own contract. Wrapping the factory is the standard path for layering access control on top of permissionless creation, bundling defaults into a higher-level builder, or defining a custom salting scheme. diff --git a/docs/B20/README.md b/docs/B20/README.md deleted file mode 100644 index 2171d650..00000000 --- a/docs/B20/README.md +++ /dev/null @@ -1,117 +0,0 @@ -# B20 - -B20 is an ERC-20 superset designed for Base. All B20s are deployed via the singleton `IB20Factory` precompile (see [Factory](Factory.md)). - -B20 supports two variants: - -- **[Asset](Asset.md)** — the general-purpose variant for assets of all kinds -- **[Stablecoin](Stablecoin.md)** — the fixed-decimals, fiat-backed carveout - -This document covers the behavior shared across the variant family. - -## ERC-20 - -Implements the [ERC-20](https://eips.ethereum.org/EIPS/eip-20) standard surface with full selector parity — drop-in for existing tooling. - -## Roles model - -B20 role-based access control follows from [OZ AccessControl](https://docs.openzeppelin.com/contracts/5.x/access-control) with a fixed set of custom roles and one behavior override on admin renunciation. - -Standard role taxonomy: - -| Role | Gates | -|---|---| -| `DEFAULT_ADMIN_ROLE` | All admin operations: role grants, policy updates, supply-cap changes | -| `MINT_ROLE` | `mint`, `mintWithMemo` | -| `BURN_ROLE` | Caller-side burns (`burn`, `burnWithMemo`) | -| `BURN_BLOCKED_ROLE` | Burns against policy-blocked accounts (`burnBlocked`) | -| `PAUSE_ROLE` | `pause` | -| `UNPAUSE_ROLE` | `unpause` | -| `METADATA_ROLE` | `updateName`, `updateSymbol`, `updateContractURI` | - -User-defined roles are supported via `setRoleAdmin` and `grantRole`. They have no built-in effect; B20 only enforces gates against the seven roles above. - -Roles are granted, revoked, and renounced through the standard OZ AccessControl methods. The one departure: the last `DEFAULT_ADMIN_ROLE` holder cannot be removed via `renounceRole` or `revokeRole` (both revert with `LastAdminCannotRenounce`); the dedicated `renounceLastAdmin()` is the only path that permanently transitions the token to admin-less. Tokens that intend to launch admin-less from the start pass `initialAdmin == address(0)` at creation, which never grants the role and skips the `renounceLastAdmin` step entirely. - -After `renounceLastAdmin()` (or for tokens deployed with `initialAdmin == address(0)`), operations gated by `DEFAULT_ADMIN_ROLE` become permanently uncallable. Roles that were already granted to other addresses (`MINT_ROLE`, `BURN_ROLE`, `PAUSE_ROLE`, `UNPAUSE_ROLE`, `METADATA_ROLE`, etc.) continue to function independently. Admin-resurrection is blocked: `grantRole`, `revokeRole`, and `setRoleAdmin` all revert with `AccessControlUnauthorizedAccount` on an admin-less token, even if the caller holds a custom role that would normally satisfy the meta-role gate. A custom-admin chain such as `setRoleAdmin(MINT_ROLE, BURN_ROLE) → grantRole(BURN_ROLE, X)` cannot restore admin power. - -## Policy integration - -B20 declares a fixed set of *policy scopes*. Each scope stores a `uint64` policy ID that points into the [PolicyRegistry](../PolicyRegistry/README.md); on every gated operation, B20 calls `isAuthorized` against the relevant scope and reverts (`PolicyForbids`) if the account isn't authorized. - -Scope names follow the `{ACTION}_{ACTOR}_POLICY` convention: - -| Scope | Gates | -|---|---| -| `TRANSFER_SENDER_POLICY` | The `from` of `transfer` / `transferFrom` | -| `TRANSFER_RECEIVER_POLICY` | The `to` of `transfer` / `transferFrom` | -| `TRANSFER_EXECUTOR_POLICY` | The `msg.sender` of `transferFrom` (not consulted on `transfer`) | -| `MINT_RECEIVER_POLICY` | The `to` of `mint` | - -`approve` itself is not policy-gated — only the actual movement of balance via `transfer` / `transferFrom` is checked. A blocked address can hold or receive allowances; the gate fires when balance moves. - -Because scopes are per-actor, send-side and receive-side rules can be configured independently. Common patterns include allowlisting receivers while leaving sends open (e.g. KYC-only deposits) and restricting `MINT_RECEIVER_POLICY` to a custodian set while leaving everyday transfers unrestricted. - -> ⚠️ **Every scope defaults to `ALWAYS_ALLOW` at token creation** unless overridden in the bootstrap `initCalls`. Token behavior must be intentionally constrained — an unattended deployment of B20 is fully open. - -Scopes are read via `policyId(scope)` and written via `updatePolicy(scope, policyId)`. `updatePolicy` is admin-gated and reverts if the scope isn't recognized. - -See [PolicyRegistry](../PolicyRegistry/README.md) for registry mechanics (built-in policy IDs, encoding, admin lifecycle). - -## Mint - -New supply is created via `mint` / `mintWithMemo`, gated by `MINT_ROLE`. The recipient is policy-checked against `MINT_RECEIVER_POLICY`, and the operation reverts with `SupplyCapExceeded` if it would push `totalSupply` past the cap. - -## Burn - -Two burn paths serve two operational needs: - -- **`burn` / `burnWithMemo`** — caller burns from their own balance. Gated by `BURN_ROLE`. Permissioned so asset issuers can maintain equivalent units for wrapped assets without exposing supply to arbitrary holders. -- **`burnBlocked`** — burns from a third party's balance. Gated by `BURN_BLOCKED_ROLE`. The target account MUST be denied by `TRANSFER_SENDER_POLICY` — this is the freeze-and-seize path required by regulated issuers, deliberately impossible against accounts that aren't policy-blocked. - -## Supply cap - -The supply cap is optional; the sentinel `type(uint256).max` indicates no cap and is the default at creation. `updateSupplyCap(newCap)` is admin-gated and emits `SupplyCapUpdated` — the cap may be raised or lowered freely, but lowering below current `totalSupply` reverts with `InvalidSupplyCap` because already-issued supply is never invalidated. - -## Memos - -A memo is an optional `bytes32` payload that callers attach to a token operation for off-chain reference — payment IDs, compliance tagging, settlement correlation, etc. - -Every memo'd operation emits a `Memo(address indexed caller, bytes32 indexed memo)` event immediately after the operation's primary event, with a `bytes32(0)` memo permitted as a "no memo content" signal. Indexers join the `Memo` log to its parent via `(transactionHash, logIndex − 1)` — the memo always sits immediately after its primary event in log order. - -Memo-emitting entrypoints: - -- `transferWithMemo`, `transferFromWithMemo` — same semantics as their non-memo counterparts plus the `Memo` event. -- `mintWithMemo`, `burnWithMemo` — same pattern on issuance and self-burn. - -## Pause - -B20 pauses are granular: the `PausableFeature` enum partitions the gated surface into independently pausable operations, currently `TRANSFER`, `MINT`, and `BURN`. The enum is append-only across protocol versions, so existing positions are stable forever. `isPaused(feature)` is `O(1)`; `pausedFeatures()` returns the full set as an array. - -`pause(features)` and `unpause(features)` are gated by *separate* roles (`PAUSE_ROLE` and `UNPAUSE_ROLE`) by design — an incident-response operator can pause without holding the authority to re-enable. - -## ERC-2612 Permit / EIP-712 - -B20 implements [ERC-2612](https://eips.ethereum.org/EIPS/eip-2612) (signed approvals) using an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) domain shaped as `(name, version, chainId, verifyingContract)`, with `version` fixed at `"1"` and `salt` unused. Because `name` is re-hashed into the domain on every signed call, `updateName` automatically rotates the domain separator; each successful `updateName` emits one `EIP712DomainChanged` event ([ERC-5267](https://eips.ethereum.org/EIPS/eip-5267)). - -`DOMAIN_SEPARATOR()` and `eip712Domain()` are exposed for callers that want to read the domain dynamically rather than reconstruct it. `nonces(owner)` is the per-account replay counter incremented on every `permit`. - -ERC-1271 contract signatures are deliberately NOT accepted — permit recovers via ECDSA from 65-byte signatures only. Smart-contract accounts should use call-batching or gasless flows. [Permit2](https://github.com/Uniswap/permit2) is usable as a periphery alternative. - -## Contract URI (ERC-7572) - -`contractURI()` returns a string pointing to off-chain metadata about the token (typically a JSON document) per [ERC-7572](https://eips.ethereum.org/EIPS/eip-7572). `updateContractURI(newUri)` is gated by `METADATA_ROLE`. - -## Metadata updates - -`METADATA_ROLE` gates two metadata setters: - -- `updateName(newName)` updates the token name AND rotates the EIP-712 domain separator (see [ERC-2612 Permit / EIP-712](#erc-2612-permit--eip-712)). Emits `NameUpdated` and `EIP712DomainChanged`. -- `updateSymbol(newSymbol)` updates the symbol with no other side effects. Emits `SymbolUpdated`. - -## Variant overview - -| Variant | Decimals | What it adds | -|---|---|---| -| [Asset](Asset.md) | 6-18 (configurable per token) | multiplier, announcements, extra metadata, batched issuance | -| [Stablecoin](Stablecoin.md) | 6 (fixed) | self-declared currency code | diff --git a/docs/B20/Stablecoin.md b/docs/B20/Stablecoin.md deleted file mode 100644 index 62e5fb5c..00000000 --- a/docs/B20/Stablecoin.md +++ /dev/null @@ -1,13 +0,0 @@ -# B20 Stablecoin - -The Stablecoin variant of B20. Everything in [B20/README.md](README.md) applies; this page covers the deltas only. See [`IB20Stablecoin`](../../src/interfaces/IB20Stablecoin.sol) for the Solidity interface. - -## Currency Codes - -`currency()` returns the ISO-style currency code as a `string` (e.g., `"USD"`, `"EUR"`). It is set once via `B20StablecoinCreateParams.currency` at creation, immutable thereafter, and restricted to `A`–`Z` bytes (no lowercase, no digits, no separators). - -The value is **self-declared** — the contract does not verify it against any registry or allowlist. Wallets and indexers can use it to group stablecoins by underlying fiat without an external lookup, but it is not a proof of fiat backing. - -## Fixed Decimals (6) - -`decimals()` is hard-wired to `6`. The choice matches existing popular stablecoins. diff --git a/docs/PolicyRegistry/README.md b/docs/PolicyRegistry/README.md deleted file mode 100644 index bc8e57a2..00000000 --- a/docs/PolicyRegistry/README.md +++ /dev/null @@ -1,181 +0,0 @@ -# PolicyRegistry - -The PolicyRegistry is a singleton precompile for list-based and composite access policies. Any caller can create a policy and nominate its admin; B20 tokens and other consumers reference policies by `uint64` ID for authorization checks. See [`IPolicyRegistry`](../../src/interfaces/IPolicyRegistry.sol) for the full Solidity interface. - -## Policy Types - -Four policy types are supported, split into two kinds: - -**Simple** policies decide from an address set: - -- **`BLOCKLIST`** — accounts are authorized by default; the admin maintains a list of accounts to explicitly deny. -- **`ALLOWLIST`** — accounts are denied by default; the admin maintains a list of accounts to explicitly authorize. - -**Composite** policies decide by combining existing simple policies under a logic gate: - -- **`UNION`** (OR) — authorized if *any* child policy authorizes the account. -- **`INTERSECT`** (AND) — authorized only if *every* child policy authorizes the account. - -A composite's child set is 2–4 existing simple (`ALLOWLIST`/`BLOCKLIST`) policy IDs — never another composite, and never a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`). Composites reference their children live: `isAuthorized` reads current child membership on every call. So updating a child's membership immediately changes what the composite authorizes. - -## Policy IDs - -Each policy is identified by a `uint64` ID. The top byte (`[63:56]`) encodes the `PolicyType`; the low 56 bits (`[55:0]`) are a global counter. Type is recoverable from any ID via pure bit extraction, with no storage read. - -Custom policy IDs are assigned from a single global counter starting at `2`. The values `0` and `1` are reserved for two **built-in policies** that consumers can reference on a slot without creating a policy: - -| Policy | Value | Semantics | -|---|---|---| -| `ALWAYS_ALLOW` | `0` | `isAuthorized(ALWAYS_ALLOW, *) → true` | -| `ALWAYS_BLOCK` | `(uint64(ALLOWLIST) << 56) \| 1` | `isAuthorized(ALWAYS_BLOCK, *) → false` | - -`ALWAYS_ALLOW` is also the default state of every unassigned policy slot on a B20 token. - -> **Precondition for consumers.** `isAuthorized` never reverts on a non-existent or malformed `policyId` — it collapses to empty-member-set semantics (ALLOWLIST → `false`, BLOCKLIST → `true`). Consumers that store policy IDs (notably `IB20.updatePolicy`) MUST validate `policyExists(policyId)` at write time, since a typo'd BLOCKLIST ID would silently behave as `ALWAYS_ALLOW`. - -## Activation - -The `PolicyRegistry` is gated by the [`ActivationRegistry`](../ActivationRegistry/README.md). The gate applies only to functions that change state; read-only functions are always callable, whether or not the feature is active. - -**Always callable:** - -- `isAuthorized` -- `policyExists` -- `policyAdmin` -- `pendingPolicyAdmin` -- `compositePolicyChildIds` -- `MIN_COMPOSITE_CHILD_POLICIES` -- `MAX_COMPOSITE_CHILD_POLICIES` - -**Gated** — revert with `FeatureNotActivated` while the feature is inactive: - -- `createPolicy` -- `createPolicyWithAccounts` -- `createCompositePolicy` -- `stageUpdateAdmin` -- `finalizeUpdateAdmin` -- `renounceAdmin` -- `updateAllowlist` -- `updateBlocklist` -- `updateComposite` - -Because reads are never gated, a consumer — a B20 token calling `isAuthorized` on transfer, or an indexer reading membership and admin state — sees the same behavior whether or not the feature is active. - -## User Flows - -### Create Policy - -A caller deploys a new policy, nominates its admin (often themselves or a multisig), and optionally seeds an initial member set in the same call. - -```mermaid -sequenceDiagram - participant Creator - participant PolicyRegistry - - Creator->>PolicyRegistry: createPolicy(admin, policyType) - Note over PolicyRegistry: allocate new policyId
store type and admin - PolicyRegistry-->>Creator: emit PolicyCreated(policyId, creator, policyType) - PolicyRegistry-->>Creator: emit PolicyAdminUpdated(policyId, 0, admin) -``` - -Use `createPolicyWithAccounts(admin, policyType, accounts)` for the seeded variant — same shape, plus a membership seeding step that emits `AllowlistUpdated` or `BlocklistUpdated` (depending on `policyType`) carrying the full batch. - -Reverts: `ZeroAddress` (if `admin` is `address(0)`), `BatchSizeTooLarge` (seeded variant only). - -### Create Composite Policy - -A caller combines 2–4 existing simple policies under a `UNION` or `INTERSECT` gate and nominates an admin for the composite. - -```mermaid -sequenceDiagram - participant Creator - participant PolicyRegistry - - Creator->>PolicyRegistry: createCompositePolicy(admin, policyType, childPolicyIds) - Note over PolicyRegistry: validate children
allocate new policyId
store type, admin, children - PolicyRegistry-->>Creator: emit PolicyCreated(policyId, creator, policyType) - PolicyRegistry-->>Creator: emit PolicyAdminUpdated(policyId, 0, admin) - PolicyRegistry-->>Creator: emit CompositePolicyUpdated(policyId, creator, childPolicyIds) -``` - -Every entry in `childPolicyIds` must be an existing simple (`ALLOWLIST`/`BLOCKLIST`) policy — never another composite and never a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`). The set size must fall within `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (2–4, inclusive). - -Reverts: `ZeroAddress` (if `admin` is `address(0)`), `IncompatiblePolicyType` (`policyType` isn't `UNION`/`INTERSECT`), `ChildPoliciesOutsideOfRange` (child count outside `[2, 4]`), `PolicyNotFound` (a child doesn't exist), `InvalidChildPolicy` (a child is a composite or a built-in sentinel). - -### Update Membership - -The policy admin sets `accounts` to a uniform membership state — all included or all excluded — in a single batch. - -```mermaid -sequenceDiagram - participant PolicyAdmin - participant PolicyRegistry - - PolicyAdmin->>PolicyRegistry: updateAllowlist(policyId, allowed, accounts) - Note over PolicyRegistry: set each account's
membership to `allowed` - PolicyRegistry-->>PolicyAdmin: emit AllowlistUpdated(policyId, updater, allowed, accounts) -``` - -`updateBlocklist(policyId, blocked, accounts)` has the same shape for `BLOCKLIST` policies; it emits `BlocklistUpdated` instead. Use the matching call for the policy's type — mixing them reverts. - -Reverts: `PolicyNotFound` (unknown `policyId`), `IncompatiblePolicyType` (wrong call for the policy's type), `Unauthorized` (caller isn't current admin), `BatchSizeTooLarge`. - -### Update Composite Children - -The composite's admin replaces its child-policy set in full with `updateComposite`. - -```mermaid -sequenceDiagram - participant PolicyAdmin - participant PolicyRegistry - - PolicyAdmin->>PolicyRegistry: updateComposite(policyId, childPolicyIds) - Note over PolicyRegistry: validate children
replace child set in full - PolicyRegistry-->>PolicyAdmin: emit CompositePolicyUpdated(policyId, updater, childPolicyIds) -``` - -`childPolicyIds` is a full replacement, a child omitted from the new set no longer governs the composite. The new set must still satisfy the same size and child-validity rules as creation. - -Reverts: `PolicyNotFound` (unknown `policyId` or a child that doesn't exist), `IncompatiblePolicyType` (`policyId` isn't `UNION`/`INTERSECT`), `Unauthorized` (caller isn't current admin — a renounced composite can never be updated), `ChildPoliciesOutsideOfRange` (child count outside `[2, 4]`), `InvalidChildPolicy` (a child is a composite or a built-in sentinel). - -### Transfer Admin - -A two-step transfer: the current admin proposes a successor, then the proposed admin accepts. The active admin doesn't change until the second step. - -```mermaid -sequenceDiagram - participant CurrentAdmin - participant PolicyRegistry - participant NewAdmin - - CurrentAdmin->>PolicyRegistry: stageUpdateAdmin(policyId, newAdmin) - Note over PolicyRegistry: pendingAdmin = newAdmin - PolicyRegistry-->>CurrentAdmin: emit PolicyAdminStaged(policyId, currentAdmin, newAdmin) - - NewAdmin->>PolicyRegistry: finalizeUpdateAdmin(policyId) - Note over PolicyRegistry: admin = newAdmin
clear pendingAdmin - PolicyRegistry-->>NewAdmin: emit PolicyAdminUpdated(policyId, currentAdmin, newAdmin) -``` - -`stageUpdateAdmin(policyId, address(0))` cancels an in-flight transfer. Re-staging while a pending admin already exists overwrites the prior nomination — the previous candidate loses their ability to finalize. - -Reverts (Step 1): `PolicyNotFound`, `Unauthorized` (caller isn't current admin). -Reverts (Step 2): `PolicyNotFound`, `NoPendingAdmin` (no transfer in flight), `Unauthorized` (caller isn't the staged pending admin). - -### Renounce Admin - -The current admin permanently relinquishes administration of the policy. The membership set is frozen forever; the policy can never be re-administered. - -```mermaid -sequenceDiagram - participant PolicyAdmin - participant PolicyRegistry - - PolicyAdmin->>PolicyRegistry: renounceAdmin(policyId) - Note over PolicyRegistry: admin = address(0)
clear pendingAdmin - PolicyRegistry-->>PolicyAdmin: emit PolicyAdminUpdated(policyId, oldAdmin, 0) -``` - -The policy continues to exist and remains a valid target of `isAuthorized` queries forever — only mutation is disabled. - -Reverts: `PolicyNotFound`, `Unauthorized` (caller isn't current admin). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..60ed12a6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,16 @@ +## Understanding B20 + +New to B20? + +1. [B20 Overview](overview.md) +2. [How B20 Works](architecture.md) + +Building something? + +- [Seize a holder's B20 balance](guides/seizeing-assets.md) +- [Schedule a UI multiplier change](guides/scheduling-multiplier-changes.md) + +Looking for exact technical details? + +- [Concepts](concepts/) — the mental model: assets, policies, roles, execution, versioning +- [Reference](reference/) — interfaces, events, errors, constants diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..de7a80eb --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,193 @@ +# B20 Execution Architecture + +*How B20 actually executes: how its precompiles differ from ordinary contracts, how a token gets created and recognized as one, and how the protocol evolves without breaking history. For what each primitive means and how to use it (assets, roles, policies), see [Concepts](concepts/). For the "B20 in 10 minutes" tour, see [Overview](overview.md).* + +## 1. How B20 Uses Precompiles + +### 1.1 Normal Contracts vs Precompiles + +Precompiles are code compiled into the node client. Unlike regular smart contracts, they are not deployed as EVM bytecode and the EVM interpreter does not execute them. They run as native code, so they bypass the opcode-by-opcode interpreter loop: decode, execute, update stack and memory, then repeat. That native path is why they are faster. Ethereum introduced them because some operations, such as hashing and cryptographic primitives, were too expensive to run efficiently in the EVM. Callers still see a contract-like interface. + +Because the interpreter is not in the path, a precompile implements its own state access and gas accounting. State is still stored through the EVM state model, the same way regular contracts store state. Gas metering is defined by the precompile itself rather than by per-opcode interpreter costs. + +The node decides which path to take. On every `CALL`, `STATICCALL`, and related opcode, the EVM checks a precompile registry before it loads bytecode at the target address. If the address is registered, native code runs and bytecode is never loaded or interpreted. If it is not registered, the node runs regular EVM code. The client identifies a precompile by a reserved address mapped in that registry. + +```mermaid +flowchart TD + A[Call arrives at node] --> B{Target address in precompile registry?} + B -->|yes| C[Run native precompile] + B -->|no| D[Run regular EVM code] +``` + +Classic Ethereum precompiles (`ecrecover`, `sha256`, `ripemd160`, `modexp`, `ecadd`/`ecmul`/`ecpairing`, `blake2f`, and others) are looked up through this same registry. B20 does not bypass or extend the EVM dispatch path. It registers into that path. An address with no bytecode and no registry entry behaves like an empty account: the call returns immediately with no output. That is how a precompile address looks before the hardfork that introduces it. + +From the outside, the two paths look the same until the EVM reaches the target. The actor submits a transaction, the node validates and gossips it, the block builder executes it, and the EVM calls the contract address. A regular contract then runs bytecode. A precompile runs native client code. Both paths read and write EVM state. + +```mermaid +flowchart TB + classDef highlight fill:#fff3b0,stroke:#d4a017,color:#000 + + subgraph regular [Regular] + direction LR + RA[Actor] -->|submits tx| RN[Node] + RN -->|validate and gossip| RB[Block builder] + RB -->|executes tx| RE[EVM] + RE -->|call contract address| RC[Bytecode] + RC -->|read/write| RS[EVM state] + end + + subgraph precompile [Precompile] + direction LR + PA[Actor] -->|submits tx| PN[Node] + PN -->|validate and gossip| PB[Block builder] + PB -->|executes tx| PE[EVM] + PE -->|call contract address| PC[Native client code] + PC -->|read/write| PS[EVM state] + end + + class RC,PC highlight +``` + +### 1.2 B20's Precompiles + +The Factory, the Policy Registry, the Activation Registry, and every B20 token are precompiles: native, stateful logic at a reserved address, not deployed bytecode. + +- **Factory** — creates B20 tokens through a single `createB20` entrypoint. +- **Policy Registry** — holds shared allowlists, blocklists, and composite policies that tokens query for authorization. +- **Activation Registry** — a Base-operated switch that turns Factory and token features on or off. +- **B20 token** — the asset itself: balances and transfers, plus roles, pause, mint, burn, seize, and policy checks. + +B20 is the first stateful precompile on Base. Classic Ethereum precompiles are pure, stateless functions. B20's precompiles hold persistent storage and emit real events. They behave as system contracts, not one-shot pure functions. The storage they read and write is the same EVM state that regular contracts use. + +There are two kinds of B20 precompile: singletons and many-to-many. + +Singletons have one instance at a fixed address. The Factory, Policy Registry, and Activation Registry are registered in the node's static precompile table and matched there on every call. + +Many-to-many precompiles share one native implementation across many addresses. Token addresses are created at runtime, so they cannot be entries in that fixed table. The node recognizes them dynamically by decoding the address itself. How that routing works is covered in [§2](#2-how-a-token-is-created). + +### 1.3 State and Execution + +Once the node recognizes the target as a precompile, it routes the call to the native code registered for that address. Bytecode is never loaded. + +That registered code is responsible for gas and for errors. It charges gas for calldata, `SLOAD`, `SSTORE`, and logs on the same schedule those opcodes would have paid. It also raises the same class of failures a contract would: out of gas, revert, and custom errors. Out of gas is out of gas. A revert restores EVM state the same way a normal contract revert does. + +The precompile charges gas, then decodes the calldata and runs the function that selector maps to. A `transfer` call runs transfer. A `createB20` call runs createB20. + +Those functions have state to update. Precompiles share state with the EVM: they write straight into the account storage at their own address, the same slots a contract would use. There is no side database. A `transfer` updates that token's balances. A later `balanceOf` or `eth_getStorageAt` is an `SLOAD` of what that write committed. + +```mermaid +flowchart TD + A["Call: transfer(Bob, 100)"] --> B + + subgraph rust [Rust precompile] + B[Charge gas] + B --> C[Decode calldata] + C --> D[Run transfer] + end + + subgraph evm [EVM state] + E[Alice balance] + F[Bob balance] + end + + D -->|"write −100"| E + D -->|"write +100"| F + E --> G["Views and nodes read the same slots"] + F --> G +``` + +Layout is [ERC-7201](https://eips.ethereum.org/EIPS/eip-7201) at the precompile's own address: a namespace root at `keccak256(namespace) - 1`, masked to a slot boundary, fields at fixed offsets from that root, and keyed data — balances, allowances — at `keccak256(key, slot)`, the same mapping formula Solidity uses. Each precompile writes only its own account. Tokens never share a storage account. Shared lists live on the Policy Registry; the token stores only a policy ID. + +Checks include activation, role, pause, and policy. Activation does not hide the address: once a hardfork introduces a precompile, the address stays in the routing table. Inactive writes revert with `FeatureNotActivated`. Reads stay available. Deactivating a variant blocks new Factory creation. Existing tokens keep running. + +## 2. How a Token Is Created + +### 2.1 Creating a Token + +A B20 token can only be created through the Factory. The single entrypoint is `createB20(variant, salt, params, initCalls)`. + +The caller supplies a variant (Asset or Stablecoin), a salt, and `params` that carry the token's metadata: name, symbol, decimals, and variant-specific fields. `initCalls` is optional. When present, it is a list of bootstrap calls the Factory runs on the new token in the same transaction. + +The Factory computes the token's address deterministically from `(variant, sender, salt)`, then checks that nothing already exists there. If the address is occupied, `createB20` reverts with `TokenAlreadyExists`. + +If the address is empty, the Factory plants a single `0xef` byte as the account's bytecode. B20 tokens are not EVM contracts, so they do not carry traditional bytecode. The node never interprets that stub: it routes by address, as described in [§1.2](#12-b20s-precompiles). `0xef` is the [EIP-3541](https://eips.ethereum.org/EIPS/eip-3541) reserved prefix. Ordinary `CREATE` and `CREATE2` cannot produce it. An address with the `0xB2` prefix and that stub can only have come from the Factory. + +```mermaid +flowchart TD + A["createB20(variant, salt, params, initCalls)"] --> B["Compute address from (variant, sender, salt)"] + B --> C{Address already occupied?} + C -->|yes| D["Revert TokenAlreadyExists"] + C -->|no| E["Plant 0xef bytecode stub"] + E --> F[Seal identity] + F --> G[Emit B20Created] + G --> H[Grant initial admin or skip] + H --> I[Run initCalls] + I --> J[Return token address] +``` + +The Factory then seals the token's identity — name, symbol, decimals, and variant-specific fields — emits `B20Created`, and grants the initial admin role. Passing `address(0)` as the initial admin skips that grant and creates an adminless token. It then runs `initCalls` against the new token and returns the token's address. + +Once `createB20` returns, the Factory has no further access to the token. Creation is a one-shot, one-transaction event. + +### 2.2 Recognizing a B20 Token + +Token addresses cannot be entries in the node's static precompile table. They are created at runtime, so the node recognizes them by reading the address and the account's bytecode. + +The address layout is a `0xB2` prefix (byte `[0]` is `0xB2`, bytes `[1:9]` are zero), a variant byte at position `[10]`, and a suffix derived from `keccak256(sender, salt)`. `isB20(address)` reads that prefix directly. It does not consult a registry. + +`isB20` is prefix-only, so it can return true for an address the Factory has not created yet. `isB20Initialized` is the stronger check: the address bears the `0xB2` prefix and the `0xef` stub the Factory planted in [§2.1](#21-creating-a-token). + +Dynamic routing uses both checks. On every call, the node asks: does this address start with `0xB2`, and is the account bytecode the `0xef` stub? If either check fails, the target is not a live B20. The call follows the ordinary empty-account or regular-EVM path described in [§1.1](#11-normal-contracts-vs-precompiles). + +If both checks pass, the node decodes the variant from address byte `[10]` and dispatches to the matching native logic. Asset (`0x00`) runs Asset logic. Stablecoin (`0x01`) runs Stablecoin logic. + +```mermaid +flowchart TD + A[Call arrives at address] --> B{"Starts with 0xB2
and bytecode is 0xef?"} + B -->|no| C[Empty account or regular EVM] + B -->|yes| D{Variant byte at address 10} + D -->|Asset 0x00| E[Asset logic] + D -->|Stablecoin 0x01| F[Stablecoin logic] +``` + +Before a token is created, its predicted address matches the `0xB2` prefix but has no stub. Calling it is a no-op. After `createB20` returns, the `0xef` stub is what flips the address from "looks like a B20" to "is a live B20," and routing begins. + +## 3. How B20 Evolves + +B20 introduces new changes through protocol upgrades. On Base, those upgrades are hardforks: moments when consensus itself changes. For B20, a hardfork is when the protocol can update the logic that runs at a specific precompile address, or introduce a new precompile entirely. + +### 3.1 Protocol Upgrades + +Base ships protocol changes as hardforks (for example Beryl → Cobalt). That is the same gate that any other consensus change uses. A B20 hardfork can do one of two things: + +- Introduce a new precompile. The Activation Registry itself exists only from Beryl onward. +- Update the logic that runs at a specific precompile address, by shipping a new logic version. + +Callers still hit the same address. What changes is which native implementation the node runs for that address after the fork. + +### 3.2 Execution Consensus + +Every hardfork must preserve execution consensus with every earlier hardfork. Logic that ran at Beryl must still run as Beryl logic after Cobalt ships a different version. The code at each hardfork is fixed: later forks add new versions; they do not rewrite the old ones. + +That invariant is what makes genesis sync work. A node that replays every block from genesis must arrive at the same state as a node that has been live the whole time. If Beryl-era logic were edited in place at the precompile's fixed address, historical blocks would execute differently, and the replayed chain would diverge. + +Each shipped version is therefore frozen: self-contained, with no shared mutable state or traits across versions. + +```mermaid +flowchart LR + subgraph beryl [Beryl blocks] + B[Beryl logic] + end + subgraph cobalt [Cobalt blocks] + C[Cobalt logic] + end + G[Sync from genesis] --> B + B --> C + C --> S[Same state as a live node] +``` + +### 3.3 Fork / Version Resolution + +A hardfork resolves to a specific logic version: fork → version enum → frozen implementation. The node resolves that mapping once per call. It never picks "whatever is current." A Beryl block always runs Beryl logic, even after Cobalt has shipped. + +A call reverts if no version is resolved for the active fork — for example, calling logic that does not exist yet. There is no silent fallback to a default version. diff --git a/docs/concepts/policies.md b/docs/concepts/policies.md new file mode 100644 index 00000000..1459b6e3 --- /dev/null +++ b/docs/concepts/policies.md @@ -0,0 +1,350 @@ +# Policies + +*How B20 reuses shared allowlists and blocklists for compliance checks. Roles and pause are a separate authorization layer; see [Roles and Pause](roles-and-pause.md). The Policy Registry precompile itself is in [Architecture](../architecture.md).* + +## 1. Why policies exist + +Most token compliance reduces to a membership check on an address list: is this account allowed to send, receive, or be minted to? Issuers repeat those lists across many tokens. Copying the same KYC allowlist or sanctions blocklist onto every token creates drift. One list update has to land in every copy. + +Policies move the list into one place. The Policy Registry is a singleton precompile. It stores each list once, with the membership logic that runs on it. A token stores only a policy ID in a slot. Before a gated function runs, the token asks the registry whether the relevant address is authorized. Many tokens can share one policy. An update to that policy is visible to every token that references it. + +```mermaid +flowchart LR + T1[Token A] -->|policy ID| R[Policy Registry] + T2[Token B] -->|same policy ID| R + R --> L[One member set] +``` + + + +A role answers who may call a privileged function. Pause answers whether that class of operation is live. A policy answers whether a specific address is authorized for that operation. All three can apply to the same call. + +## 2. How policies work + +### 2.1 The registry and the token + +The Policy Registry owns member sets and composite gates. Creation is permissionless. Each policy has an admin who updates membership, replaces a composite's children, or transfers administration. Tokens never write those lists. They store a `uint64` policy ID per [scope](#32-policy-scopes) and call `isAuthorized(policyId, account)` when that scope runs. + +`isAuthorized` never reverts. It returns whether the account is authorized under that policy. The token decides what a `false` (or, for one scope, a `true`) means. Most scopes revert `PolicyForbids` when the result is `false`. The function then does not run. + +```mermaid +flowchart TD + A[Gated call arrives at the token] --> B[Read policy ID from the scope] + B --> C["Registry isAuthorized(policyId, account)"] + C -->|scope allows| D[Function continues] + C -->|scope denies| E[Revert] +``` + + + +### 2.2 Policy types + +A policy is either simple or composite. + +A **simple** policy decides from one address set: + + +| Type | Authorized when | +| ----------- | ----------------------------- | +| `ALLOWLIST` | The account is in the set | +| `BLOCKLIST` | The account is not in the set | + + +An empty allowlist authorizes nobody. An empty blocklist authorizes everybody. + +A **composite** policy combines two to four existing simple policies. It does not copy their members. Each `isAuthorized` call reads each child's current set: + + +| Type | Authorized when | +| ----------- | ---------------------------------- | +| `UNION` | Any child authorizes the account | +| `INTERSECT` | Every child authorizes the account | + + +Children must be existing `ALLOWLIST` or `BLOCKLIST` policies. Another composite is not a valid child. The built-in sentinels in [§2.4](#24-built-in-sentinels) are not valid children either. Updating a child's members changes every composite that references it. There is no flatten-and-copy step. + +```mermaid +flowchart TD + Q["isAuthorized(policyId, account)"] --> T{Policy type} + T -->|ALLOWLIST| A[In the set?] + T -->|BLOCKLIST| B[Not in the set?] + T -->|UNION| U[Any child authorizes?] + T -->|INTERSECT| I[Every child authorizes?] + A --> R[true or false] + B --> R + U --> R + I --> R +``` + + + +### 2.3 Creating and updating + +Anyone can create a policy. The create call names a single `admin`. That address is the only one that can later change membership, replace a composite's children, transfer administration, or renounce. The creator does not have to be the admin. `admin` cannot be `address(0)`. + +You can also skip creation and reuse an existing policy. If another issuer already maintains the list you need, bind their policy ID to your token. You do not become that policy's admin by attaching it. + +#### 2.3.1 Creating a policy + +A simple policy starts as an `ALLOWLIST` or a `BLOCKLIST`. Call `createPolicy(admin, ALLOWLIST)` or `createPolicy(admin, BLOCKLIST)`. The registry assigns a new policy ID and returns it. The member set is empty. `createPolicyWithAccounts(admin, policyType, accounts)` does the same and seeds the set in that call. Membership batches are capped at 64 accounts. + +A composite starts from policies that already exist. Call `createCompositePolicy(admin, UNION | INTERSECT, childPolicyIds)`. The child count must be in `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (`2` through `4`). The registry stores references, not a snapshot of the children's members. + +Both paths emit `PolicyCreated` and `PolicyAdminUpdated(policyId, address(0), admin)`. `policyAdmin(policyId)` then returns that admin. + +```mermaid +sequenceDiagram + participant Creator + participant Registry as Policy Registry + + Creator->>Registry: createPolicy(admin, ALLOWLIST) + Registry-->>Creator: PolicyCreated + PolicyAdminUpdated + Registry-->>Creator: policyId +``` + + + +#### 2.3.2 Updating a policy + +After creation, only the current admin can change the policy. Any other caller reverts `Unauthorized`. The update must match the policy's type or it reverts `IncompatiblePolicyType`. + +The admin of an allowlist calls `updateAllowlist(policyId, allowed, accounts)` to add or remove members. The admin of a blocklist calls `updateBlocklist(policyId, blocked, accounts)`. The admin of a composite calls `updateComposite(policyId, childPolicyIds)` to replace the child set in full. There is no partial child edit. + +Those writes change what `isAuthorized` returns on the next query. Every token that already stores this policy ID sees the new result. The token does not need a second `updatePolicy`. + +```mermaid +sequenceDiagram + participant Admin + participant Other as Other caller + participant Registry as Policy Registry + + Other->>Registry: updateAllowlist(policyId, ...) + Registry-->>Other: revert Unauthorized + Admin->>Registry: updateAllowlist(policyId, true, [Alice]) + Registry-->>Admin: AllowlistUpdated +``` + + + +#### 2.3.3 Changing the admin + +A policy has one admin at a time. To hand it off, the current admin calls `stageUpdateAdmin(policyId, newAdmin)`. That does not change who can update the policy yet. `policyAdmin` still returns the current admin. `pendingPolicyAdmin` returns `newAdmin`. Passing `address(0)` clears a nomination that has not been finalized. + +The pending admin then calls `finalizeUpdateAdmin(policyId)`. The caller must be the staged address, or the call reverts `Unauthorized`. If nothing is staged, it reverts `NoPendingAdmin`. On success the pending admin becomes the current admin, the pending slot clears, and the previous admin can no longer update the policy. + +```mermaid +sequenceDiagram + participant Admin + participant Next as nextAdmin + participant Registry as Policy Registry + + Admin->>Registry: stageUpdateAdmin(policyId, nextAdmin) + Registry-->>Admin: PolicyAdminStaged + Admin->>Registry: updateAllowlist(policyId, ...) + Registry-->>Admin: AllowlistUpdated + Next->>Registry: finalizeUpdateAdmin(policyId) + Registry-->>Next: PolicyAdminUpdated + Admin->>Registry: updateAllowlist(policyId, ...) + Registry-->>Admin: revert Unauthorized + Next->>Registry: updateAllowlist(policyId, ...) + Registry-->>Next: AllowlistUpdated +``` + + + +To freeze a policy instead of handing it off, the current admin calls `renounceAdmin(policyId)`. Administration is gone for good. Membership and child sets cannot change. `isAuthorized` keeps working. There is no call that assigns a new admin after renounce. + +### 2.4 Built-in sentinels + +Two policy IDs exist without being created: + + +| ID | `isAuthorized` | Typical use | +| -------------------- | ------------------------- | ------------------------------------------------------- | +| `ALWAYS_ALLOW` (`0`) | `true` for every account | No compliance on that scope. This is the unset default. | +| `ALWAYS_BLOCK` | `false` for every account | Deny every account on that scope. | + + + + +## 3. How policies attach to a token + +A scope is an identifier for the policy that runs on a specific function. It works like a hook. When that function is called, the token reads the policy ID bound to the scope and asks the registry `isAuthorized` about the address the scope checks. The registry still holds the list. The token stores only the ID. + +### 3.1 Updating a scope + +`updatePolicy(policyScope, newPolicyId)` binds a policy ID to a scope. It requires `DEFAULT_ADMIN_ROLE`. The ID must be a built-in sentinel or an existing registry policy. Otherwise the call reverts `PolicyNotFound`. An unknown `policyScope` reverts `UnsupportedPolicyType`. + +The write takes effect on the next call that hits that scope. It emits `PolicyUpdated`. Until you update a scope, it reads as `0` (`ALWAYS_ALLOW`), so the check passes for every address. The same policy ID can sit on more than one scope and on more than one token. `policyId(policyScope)` reads the current binding. + +You can also bind a policy in `createB20` `initCalls`, in the same transaction that creates the token. + +```mermaid +sequenceDiagram + participant Admin + participant Registry as Policy Registry + participant Token as B20 token + + Admin->>Registry: createPolicy(admin, ALLOWLIST) + Registry-->>Admin: policyId + Admin->>Token: updatePolicy(TRANSFER_RECEIVER_POLICY, policyId) + Token-->>Admin: PolicyUpdated +``` + +### 3.2 Policy scopes + +Most scopes deny when `isAuthorized` is `false` and revert `PolicyForbids`. `SEIZE_HOLDER_POLICY` denies when `isAuthorized` is `true` and reverts `AccountNotSeizable`. + +| Scope | Runs on | Account checked | Denies when `isAuthorized` is | Error | +| -------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------- | -------------------- | +| `TRANSFER_SENDER_POLICY` | `transfer`, `transferFrom`, and memo'd variants. Skipped on factory `initCalls` transfers. | `from` (`msg.sender` on `transfer`) | `false` | `PolicyForbids` | +| `TRANSFER_RECEIVER_POLICY` | `transfer`, `transferFrom`, and memo'd variants. Skipped on factory `initCalls` transfers. | `to` | `false` | `PolicyForbids` | +| `TRANSFER_EXECUTOR_POLICY` | `transferFrom` and `transferFromWithMemo` when `msg.sender != from`. Not on `transfer`. Skipped on factory `initCalls`. | `msg.sender` | `false` | `PolicyForbids` | +| `MINT_RECEIVER_POLICY` | `mint`, `mintWithMemo`, and Asset `batchMint`. Always checked, including factory `initCalls` mints. | `to` | `false` | `PolicyForbids` | +| `SEIZE_HOLDER_POLICY` | `seizeWithMemo`. Unset (`ALWAYS_ALLOW`) means no account is seizable. | `from` | `true` | `AccountNotSeizable` | +| `SEIZE_RECEIVER_POLICY` | `seizeWithMemo`. Unset (`ALWAYS_ALLOW`) means seize may send to any destination. | `to` | `false` | `PolicyForbids` | + +## 4. Example + +Start with a receiver allowlist. Then combine it with a sanctions blocklist so a transfer requires both. + +### 4.1 One allowlist + +Create an allowlist, add the KYC'd accounts, and bind it to `TRANSFER_RECEIVER_POLICY`. Alice is on the list. Bob is not. A holder can send to Alice. A send to Bob reverts. + +```mermaid +sequenceDiagram + participant Admin + participant Registry as Policy Registry + participant Token as B20 token + participant Holder + participant Alice + participant Bob + + Admin->>Registry: createPolicy(admin, ALLOWLIST) + Registry-->>Admin: kycId + Admin->>Registry: updateAllowlist(kycId, true, [Alice]) + Admin->>Token: updatePolicy(TRANSFER_RECEIVER_POLICY, kycId) + + Holder->>Token: transfer(Alice, amount) + Token->>Registry: isAuthorized(kycId, Alice) + Registry-->>Token: true + Token-->>Holder: allowed + + Holder->>Token: transfer(Bob, amount) + Token->>Registry: isAuthorized(kycId, Bob) + Registry-->>Token: false + Token-->>Holder: revert PolicyForbids(TRANSFER_RECEIVER_POLICY, kycId) +``` + + + +Adding Bob to the allowlist later authorizes him on every token that already points at `kycId`. There is no second write on the token. + +### 4.2 Composite: KYC and sanctions + +A single allowlist cannot express "on the KYC list and not on the sanctions list" when those lists are maintained separately. Create both simple policies, then an `INTERSECT` composite, then bind the composite to the transfer and mint scopes. + +```mermaid +flowchart TD + C["INTERSECT composite"] --> K[KYC ALLOWLIST] + C --> S[Sanctions BLOCKLIST] + K --> A1[Alice: member] + K --> A2[Bob: not a member] + K --> A3[Carol: member] + S --> B1[Alice: not listed] + S --> B2[Bob: not listed] + S --> B3[Carol: listed] +``` + + + +```mermaid +sequenceDiagram + participant Admin + participant Registry as Policy Registry + participant Token as B20 token + participant Alice + participant Dave + participant Carol + + Admin->>Registry: createPolicy(admin, ALLOWLIST) + Registry-->>Admin: kycId + Admin->>Registry: updateAllowlist(kycId, true, [Alice, Dave, Carol]) + Admin->>Registry: createPolicy(admin, BLOCKLIST) + Registry-->>Admin: sanctionsId + Admin->>Registry: updateBlocklist(sanctionsId, true, [Carol]) + Admin->>Registry: createCompositePolicy(admin, INTERSECT, [kycId, sanctionsId]) + Registry-->>Admin: gateId + Admin->>Token: updatePolicy(TRANSFER_SENDER_POLICY, gateId) + Admin->>Token: updatePolicy(TRANSFER_RECEIVER_POLICY, gateId) + Admin->>Token: updatePolicy(MINT_RECEIVER_POLICY, gateId) + + Alice->>Token: transfer(Dave, amount) + Token->>Registry: isAuthorized(gateId, Alice) + Registry-->>Token: true + Token->>Registry: isAuthorized(gateId, Dave) + Registry-->>Token: true + Token-->>Alice: allowed + + Alice->>Token: transfer(Carol, amount) + Token->>Registry: isAuthorized(gateId, Carol) + Registry-->>Token: false + Token-->>Alice: revert PolicyForbids(TRANSFER_RECEIVER_POLICY, gateId) +``` + + + +Alice and Dave are on the KYC list and not on the sanctions list, so both children authorize them and the `INTERSECT` returns `true`. Carol is KYC'd but sanctioned: the blocklist returns `false`, so the composite returns `false` and the transfer reverts. Bob is not on the KYC list, so he is denied even though he is not sanctioned. + +A later `updateBlocklist` that adds or removes Carol changes the composite on the next call. The token still holds `gateId`. The issuer does not call `updatePolicy` again. + +If the issuer later needs the same KYC list or-ed with a token-specific partner allowlist, they create a `UNION` of those two allowlists instead. The token bind step is the same. + +## Events and Errors + +### Token + + +| Event | Emitted by | +| ------------------------------------------------------ | -------------------------------------------------------- | +| `PolicyUpdated(policyScope, oldPolicyId, newPolicyId)` | `updatePolicy`; also token creation (`oldPolicyId == 0`) | + + + +| Error | Thrown when | +| -------------------------------------- | ---------------------------------------------------------------------------------------- | +| `PolicyForbids(policyScope, policyId)` | A deny-on-false scope rejected the account | +| `PolicyNotFound(policyId)` | `updatePolicy` was given an ID that is not a sentinel and does not exist in the registry | +| `UnsupportedPolicyType(policyScope)` | `policyScope` is not a slot this token supports | +| `AccountNotSeizable(account)` | `seizeWithMemo` `from` is still authorized under `SEIZE_HOLDER_POLICY` | +| `AccountNotBlocked(account)` | Deprecated `burnBlocked` `from` is still authorized under `TRANSFER_SENDER_POLICY` | + + +### Policy Registry + + +| Event | Emitted by | +| ----------------------------------------------------------- | ------------------------------------------------------------------- | +| `PolicyCreated(policyId, creator, policyType)` | `createPolicy`, `createPolicyWithAccounts`, `createCompositePolicy` | +| `PolicyAdminStaged(policyId, currentAdmin, pendingAdmin)` | `stageUpdateAdmin` | +| `PolicyAdminUpdated(policyId, previousAdmin, newAdmin)` | `finalizeUpdateAdmin`, `renounceAdmin`; also policy creation | +| `AllowlistUpdated(policyId, updater, allowed, accounts)` | `updateAllowlist` | +| `BlocklistUpdated(policyId, updater, blocked, accounts)` | `updateBlocklist` | +| `CompositePolicyUpdated(policyId, updater, childPolicyIds)` | `createCompositePolicy`, `updateComposite` | + + + +| Error | Thrown when | +| ----------------------------------- | ---------------------------------------------------------------------------------- | +| `Unauthorized()` | Caller is not the policy admin (or not the pending admin on `finalizeUpdateAdmin`) | +| `PolicyNotFound()` | The referenced policy ID does not exist | +| `IncompatiblePolicyType()` | The call does not match the policy's type | +| `ZeroAddress()` | A required address argument was `address(0)` | +| `BatchSizeTooLarge(maxBatchSize)` | A membership batch exceeded 64 accounts | +| `NoPendingAdmin()` | `finalizeUpdateAdmin` was called with no staged admin | +| `ChildPoliciesOutsideOfRange()` | A composite's child count is outside `[2, 4]` | +| `InvalidChildPolicy(childPolicyId)` | A composite child is not an existing simple policy | +| `NonPayable()` | ETH was attached to a registry call | + + diff --git a/docs/concepts/roles-and-pause.md b/docs/concepts/roles-and-pause.md new file mode 100644 index 00000000..44a44a4d --- /dev/null +++ b/docs/concepts/roles-and-pause.md @@ -0,0 +1,259 @@ +# Roles and Pause + +*How B20 gates privileged operations with roles, and how pause freezes one class of operations without stopping the rest of the token. Policy checks are a separate authorization layer; see [Policies](policies.md).* + +## 1. Why roles and pause exist + +Roles let an issuer assign each privileged operation to a specific account. Minting, seizing, and pausing are different jobs, so they are different roles. Each role gates a specific set of admin functions. The mapping is in [§2](#2-roles). + +Pause is a second, independent control. A role answers who may call a function. A `PausableFeature` answers whether that class of operation is live. Issuers pause one feature without pausing the rest of the token. The features are in [§3](#3-pause). + +## 2. Roles + +B20 implements roles with [OpenZeppelin AccessControl](https://docs.openzeppelin.com/contracts/5.x/access-control) on the token. There is no separate role registry. + +### 2.1 The default admin + +`createB20` grants `DEFAULT_ADMIN_ROLE` to `initialAdmin`. That holder is the root administrator. They assign each privileged operation to other accounts by granting an operating role: minter, burner, pauser, metadata editor. They can also grant `DEFAULT_ADMIN_ROLE` itself, so more than one address shares root control. + +Any number of addresses can hold the same role. `hasRole` is a membership check, not a single-holder slot. + +Two functions always require `DEFAULT_ADMIN_ROLE`: `updatePolicy` and `updateSupplyCap`. Those checks do not follow a reassigned admin. + +### 2.2 Available roles + + +| Role | Gates | +| -------------------- | ------------------------------------------------------------------------------------------------------- | +| `DEFAULT_ADMIN_ROLE` | `updatePolicy`, `updateSupplyCap`, `renounceLastAdmin`; default admin of every other role | +| `MINT_ROLE` | `mint`, `mintWithMemo`; Asset also gates `batchMint` | +| `BURN_ROLE` | `burn`, `burnWithMemo` | +| `BURN_BLOCKED_ROLE` | `burnBlocked` (deprecated) | +| `SEIZE_ROLE` | `seizeWithMemo` | +| `PAUSE_ROLE` | `pause` | +| `UNPAUSE_ROLE` | `unpause` | +| `METADATA_ROLE` | `updateName`, `updateSymbol`, `updateContractURI`; Asset also gates `updateExtraMetadata` | +| `OPERATOR_ROLE` | Asset-only: `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, deprecated `updateMultiplier` | + + +`OPERATOR_ROLE` exists only on Asset. See [Token Types](token-types.md). `approve` is not role-gated. Holder `transfer` is not role-gated. A holder can always move their own balance, subject to pause and policy. + +### 2.3 Granting and revoking + +Every role has an admin role. `getRoleAdmin(role)` returns it. On a fresh token, that admin is `DEFAULT_ADMIN_ROLE` for every role. The current admin calls `grantRole(role, account)` to add a holder and `revokeRole(role, account)` to remove one. A holder can also drop a role themselves with `renounceRole(role, callerConfirmation)`. `callerConfirmation` must equal `msg.sender` or the call reverts `AccessControlBadConfirmation`. + +`grantRole` and `revokeRole` are idempotent. A call that does not change membership emits nothing. `RoleGranted` and `RoleRevoked` fire only when membership actually changes. + +`revokeRole` and `renounceRole` on `DEFAULT_ADMIN_ROLE` refuse to remove the last default admin. They revert `LastAdminCannotRenounce`. The path that clears the last admin is [§2.6.1](#261-all-admin). + +### 2.4 Delegating administration + +The admin of a role is not fixed. `setRoleAdmin(role, newAdminRole)` reassigns it to any other role, including a custom one. After that call, `grantRole` and `revokeRole` follow the new admin. They are not hardcoded to `DEFAULT_ADMIN_ROLE`. An issuer can make `BURN_ROLE` holders the admin of `MINT_ROLE` without changing `DEFAULT_ADMIN_ROLE`. Only the role's current admin can call `setRoleAdmin`. The call emits `RoleAdminChanged(role, previousAdminRole, newAdminRole)`. + +```mermaid +flowchart TD + subgraph before [Fresh token] + DA1[DEFAULT_ADMIN_ROLE] --> M1[MINT_ROLE] + DA1 --> B1[BURN_ROLE] + end + subgraph after ["After setRoleAdmin(MINT_ROLE, BURN_ROLE)"] + DA2[DEFAULT_ADMIN_ROLE] --> B2[BURN_ROLE] + B2 --> M2[MINT_ROLE] + end +``` + + + +### 2.5 Example + +#### 2.5.1 Default admin grants and revokes + +The default admin grants `MINT_ROLE` to `minterA`. `minterA` can mint. The admin revokes the role. The next `mint` reverts. + +```mermaid +sequenceDiagram + participant Admin + participant Token as B20 token + participant Minter as minterA + + Admin->>Token: grantRole(MINT_ROLE, minterA) + Token-->>Admin: RoleGranted + Minter->>Token: mint(...) + Token-->>Minter: allowed + Admin->>Token: revokeRole(MINT_ROLE, minterA) + Token-->>Admin: RoleRevoked + Minter->>Token: mint(...) + Token-->>Minter: revert AccessControlUnauthorizedAccount(minterA, MINT_ROLE) +``` + + + +#### 2.5.2 A delegated admin grants + +The default admin makes `BURN_ROLE` the admin of `MINT_ROLE`, then grants `BURN_ROLE` to `burnAdmin`. `burnAdmin` grants `MINT_ROLE` to `minterA`. The default admin does not have to make that grant. + +```mermaid +sequenceDiagram + participant Admin + participant Token as B20 token + participant BurnAdmin as burnAdmin + participant Minter as minterA + + Admin->>Token: setRoleAdmin(MINT_ROLE, BURN_ROLE) + Token-->>Admin: RoleAdminChanged + Admin->>Token: grantRole(BURN_ROLE, burnAdmin) + Token-->>Admin: RoleGranted + BurnAdmin->>Token: grantRole(MINT_ROLE, minterA) + Token-->>BurnAdmin: RoleGranted + Minter->>Token: mint(...) + Token-->>Minter: allowed +``` + + + +### 2.6 Giving up admin + +`renounceLastAdmin` is all-or-nothing. It removes root administration for every role at once. To retire one capability instead — for example, close minting forever — keep `DEFAULT_ADMIN_ROLE` and lock that one role. + +#### 2.6.1 All admin + +A token reaches zero admins in two ways. + +At creation, pass `initialAdmin = address(0)` to `createB20`. The Factory skips the initial grant. The token is adminless from creation. + +After creation, the only path is `renounceLastAdmin()`. The caller must be the sole remaining `DEFAULT_ADMIN_ROLE` holder. Otherwise the call reverts `NotSoleAdmin`. The call emits `RoleRevoked(DEFAULT_ADMIN_ROLE, admin, admin)` and `LastAdminRenounced(admin)`. + +`DEFAULT_ADMIN_ROLE` tracks an internal holder count only to enforce these last-admin guards. The count does not cap membership. There is no setter that assigns `DEFAULT_ADMIN_ROLE` to `address(0)`. + +```mermaid +sequenceDiagram + participant Admin + participant Token as B20 token + + Admin->>Token: renounceLastAdmin() + Token-->>Admin: RoleRevoked + LastAdminRenounced + Admin->>Token: grantRole(...) + Token-->>Admin: revert AccessControlUnauthorizedAccount +``` + + + +Once there are zero admins, `grantRole`, `revokeRole`, and `setRoleAdmin` revert for every role. Custom admin chains freeze too. `updatePolicy` and `updateSupplyCap` become permanently unreachable. A `METADATA_ROLE` holder can still update name, symbol, and URI. + +#### 2.6.2 A single capability + +To close minting, compose `revokeRole` and `setRoleAdmin`: + +1. Call `revokeRole(MINT_ROLE, holder)` for every current `MINT_ROLE` holder. +2. Call `setRoleAdmin(MINT_ROLE, MINT_ROLE)`. The role becomes its own admin. + +A holder left in place keeps minting and can still grant `MINT_ROLE` to others. They are then the only administrators of that role. + +The same two steps retire any operating role. The call emits `RoleAdminChanged(role, previousAdminRole, role)`. Watch for `newAdminRole == role`. + +```mermaid +sequenceDiagram + participant Admin + participant Token as B20 token + + Admin->>Token: revokeRole(MINT_ROLE, holder) for every holder + Token-->>Admin: RoleRevoked + Admin->>Token: setRoleAdmin(MINT_ROLE, MINT_ROLE) + Token-->>Admin: RoleAdminChanged(MINT_ROLE, DEFAULT_ADMIN_ROLE, MINT_ROLE) +``` + + + +## 3. Pause + +Pause freezes one class of operations without freezing the rest of the token. `pause` and `unpause` take `PausableFeature[]`. `PausableFeature` is an enum. The four values are `TRANSFER`, `MINT`, `BURN`, and `SEIZE`. Each value is one independent class. + +A caller who still holds `MINT_ROLE` cannot mint while `MINT` is paused. `pause` requires `PAUSE_ROLE`. `unpause` requires `UNPAUSE_ROLE`. Those roles are separate, so the account that pauses does not have to be the account that resumes. + +### 3.1 The features + + +| Feature | Gates | Introduced | +| ---------- | -------------------------------------------------------- | ---------- | +| `TRANSFER` | `transfer`, `transferFrom`, and memo'd variants | Beryl | +| `MINT` | `mint`, `mintWithMemo`, and Asset `batchMint` | Beryl | +| `BURN` | `burn`, `burnWithMemo`, and the deprecated `burnBlocked` | Beryl | +| `SEIZE` | `seizeWithMemo` | Cobalt | + + +The paused set is one bit per feature in a single storage word. The bit is the `PausableFeature` ordinal: + +```solidity +enum PausableFeature { + TRANSFER, // bit 0 + MINT, // bit 1 + BURN, // bit 2 + SEIZE // bit 3 +} +``` + +`ALL_FEATURES_PAUSED` (`15`) means all four bits are on. + +### 3.2 Pausing and unpausing + +Call `pause(PausableFeature[] features)` to pause one or more features. Call `unpause(PausableFeature[] features)` to resume them. An empty array reverts `EmptyFeatureSet`. + +A feature that is already in the requested state is a no-op. Duplicates in the array are a no-op. The call does not revert. + +The call emits `Paused(updater, features)` or `Unpaused(updater, features)` with the exact array you passed. That array is not the resulting paused set. Read `isPaused(feature)` or `pausedFeatures()` for the current set. + +If a later operation hits a paused feature, it reverts `ContractPaused(feature)`. The error names only the one feature that blocked the call. + +### 3.3 Example + +Pause `MINT` and `BURN` in one call. `transfer` still succeeds. `mint` and `burn` revert `ContractPaused`. + +Then unpause `BURN` only. Burning works again. `MINT` stays paused, so `mint` still reverts. + +```mermaid +sequenceDiagram + participant Pauser + participant Token as B20 token + participant Caller + participant Unpauser + + Pauser->>Token: pause([MINT, BURN]) + Token-->>Pauser: Paused([MINT, BURN]) + Caller->>Token: transfer(...) + Token-->>Caller: allowed + Caller->>Token: mint(...) + Token-->>Caller: revert ContractPaused(MINT) + Unpauser->>Token: unpause([BURN]) + Token-->>Unpauser: Unpaused([BURN]) + Caller->>Token: burn(...) + Token-->>Caller: allowed + Caller->>Token: mint(...) + Token-->>Caller: revert ContractPaused(MINT) +``` + + + +## Events and Errors + + +| Event | Emitted by | +| --------------------------------------------------------- | ------------------------------------------------- | +| `RoleGranted(role, account, sender)` | `grantRole`, initial-admin grant at creation | +| `RoleRevoked(role, account, sender)` | `revokeRole`, `renounceRole`, `renounceLastAdmin` | +| `RoleAdminChanged(role, previousAdminRole, newAdminRole)` | `setRoleAdmin` | +| `LastAdminRenounced(previousAdmin)` | `renounceLastAdmin` | +| `Paused(updater, features)` | `pause` | +| `Unpaused(updater, features)` | `unpause` | + + + +| Error | Thrown when | +| ------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `AccessControlUnauthorizedAccount(account, neededRole)` | Caller lacks the role required for the call | +| `AccessControlBadConfirmation()` | `renounceRole`'s confirmation argument doesn't match the caller | +| `ContractPaused(feature)` | The attempted operation's feature is paused | +| `EmptyFeatureSet()` | `pause`/`unpause` called with an empty array | +| `LastAdminCannotRenounce()` | `revokeRole`/`renounceRole` would remove the last `DEFAULT_ADMIN_ROLE` holder | +| `NotSoleAdmin()` | `renounceLastAdmin` called while other admins still exist | + + diff --git a/docs/concepts/token-types.md b/docs/concepts/token-types.md new file mode 100644 index 00000000..29a7087a --- /dev/null +++ b/docs/concepts/token-types.md @@ -0,0 +1,125 @@ +# Token Types + +*What Asset and Stablecoin are, how `createB20` seals the type into the address, and what each type adds on top of `IB20`. Address encoding and node dispatch are in [Architecture](../architecture.md). Roles and policies are shared across types; see [Roles and Pause](roles-and-pause.md) and [Policies](policies.md).* + +## 1. What a token type is + +A B20 token type is the variant chosen at creation. The ABI name is `B20Variant`. Two variants ship: + +| Variant | Address byte `[10]` | Interface at the token address | +| --- | --- | --- | +| Asset (`ASSET`) | `0x00` | `IB20` and `IB20Asset` | +| Stablecoin (`STABLECOIN`) | `0x01` | `IB20` and `IB20Stablecoin` | + +Both variants implement `IB20`: ERC-20, roles, pause, policies, mint, burn, and seize. Each variant adds a disjoint capability set. Type-specific state uses a disjoint [ERC-7201](https://eips.ethereum.org/EIPS/eip-7201) namespace (`base.b20.asset` or `base.b20.stablecoin`) so those fields cannot collide with shared `base.b20` slots or with the other type. + +The type is chosen once. `createB20` writes it into the token address. After that call returns, the type cannot change. + +```mermaid +flowchart TD + A["createB20(variant, salt, params, initCalls)"] --> B{variant} + B -->|ASSET 0x00| C[Asset token] + B -->|STABLECOIN 0x01| D[Stablecoin token] + C --> E["IB20 + IB20Asset"] + D --> F["IB20 + IB20Stablecoin"] +``` + +## 2. Why there are two + +General-purpose tokens, including RWAs, and fiat-pegged tokens need different class-defining fields. One combined surface would put a currency code on every Asset and announcements on every Stablecoin. B20 splits the surface: Asset carries configurable decimals, announcements, a scheduled UI multiplier, extra metadata, and batched mint. Stablecoin carries an immutable currency code and a fixed `6` decimal convention. + +If those extras were optional flags on one binary, class rules would be runtime checks. A Stablecoin address could then execute Asset selectors. B20 compiles each variant as a separate native implementation. The node reads address byte `[10]` and runs that variant's logic. A Stablecoin address never executes Asset selectors. An Asset address never executes Stablecoin selectors. + +Wallets, indexers, and issuers still need one Factory, one policy model, and one ERC-20 surface. Duplicating that stack per type would split every integration. Shared infrastructure stays shared: Factory, Policy Registry, Activation Registry, and `IB20`. Callers that only need balances, transfers, roles, or policies use `IB20`. Type-specific calls use `IB20Asset` or `IB20Stablecoin` at the same address. + +## 3. How the type is chosen + +The issuer chooses the variant only in Factory `createB20(variant, salt, params, initCalls)`. The Factory encodes that choice into the token address. It derives the address from `(variant, sender, salt)`, writes `0xB2` at byte `[0]`, and writes the discriminant at byte `[10]`: Asset `0x00`, Stablecoin `0x01`. `getB20Address(variant, sender, salt)` returns that address before create. If the address is occupied, `createB20` reverts `TokenAlreadyExists`. After return the type cannot change: the address itself holds it. + +`params` carries identity. Name, symbol, and `initialAdmin` are shared. Asset adds `decimals`. Stablecoin adds `currency`. The blob is ABI-encoded with a leading `version` byte (currently `1`): `B20AssetCreateParams` or `B20StablecoinCreateParams`. Optional `initCalls` run on the new token in the same transaction. Then the Factory drops access. If that variant is not activated (`B20Asset` / `B20Stablecoin`), `createB20` reverts `FeatureNotActivated`. Deactivating a variant blocks new creation. Existing tokens keep running. + +After creation, the node reads the variant byte in the address and runs that variant's logic. How the node recognizes the `0xB2` prefix and the `0xef` stub, and how it dispatches on byte `[10]`, is in [Architecture §2](../architecture.md#2-how-a-token-is-created). + +## 4. Asset + +Asset is the general-purpose variant. That includes real-world assets (RWAs). It is not an RWA-only type. The type-specific surface is [`IB20Asset`](../../src/interfaces/IB20Asset.sol), which extends `IB20` at the same address. + +Creation sets immutable `decimals` in `[6, 18]`. Values outside that range revert `InvalidDecimals`. Asset has no `currency()`. + +It adds the Asset-only calls: `announce` for a corporate-action disclosure with a single-use `id` and optional inner calls, scheduled `updateUIMultiplier` / `cancelUIMultiplierUpdate` ([ERC-8056](https://eips.ethereum.org/EIPS/eip-8056)), an extra-metadata key/value store, and `batchMint`. `OPERATOR_ROLE` is Asset-only and gates `announce` and multiplier updates. Name, symbol, contract URI, and extra metadata still use inherited `METADATA_ROLE`. + +Asset-specific state lives in `base.b20.asset`: `decimals`, `multiplier`, used announcement IDs, extra metadata, and the pending multiplier. Shared ERC-20, role, policy, and pause state stays in `base.b20`. + +## 5. Stablecoin + +Stablecoin is the fiat-pegged variant. + +`currency` is required and immutable. It must be uppercase ASCII `A`–`Z` only, for example `"USD"`. An empty code reverts `MissingRequiredField`. Any other byte reverts `InvalidCurrency`. + +`decimals` is hardcoded to `6`. The issuer does not pass decimals. + +The extra surface on top of `IB20` is `currency()`. Stablecoin has no announce, multiplier, extra metadata, `batchMint`, or `OPERATOR_ROLE`. + +Stablecoin-specific state lives in `base.b20.stablecoin` (`currency` only). Shared ERC-20, role, policy, and pause state stays in `base.b20`. + +`B20Created.variantEventParams` carries ABI-encoded `currency` for Stablecoin. It is empty for Asset. + +## 6. Example + +The same issuer can create both types. Different salts produce different addresses. The type is visible in byte `[10]`. Type-specific selectors do not cross. Reusing the same `(variant, sender, salt)` reverts `TokenAlreadyExists`. + +### 6.1 Creating a Stablecoin + +Predict the address with `getB20Address(STABLECOIN, sender, saltB)`. Then call `createB20` with `B20StablecoinCreateParams`: `version` `1`, name, symbol, `initialAdmin`, and `currency: "USD"`. + +```mermaid +sequenceDiagram + participant Issuer + participant Factory + participant Token as Stablecoin token + + Issuer->>Factory: getB20Address(STABLECOIN, sender, saltB) + Factory-->>Issuer: predicted address + Issuer->>Factory: createB20(STABLECOIN, saltB, params, []) + Factory->>Token: seal identity (byte 10 = 0x01, currency USD, decimals 6) + Factory-->>Issuer: token address +``` + +After return, address byte `[10]` is `0x01`. `decimals()` is `6`. `currency()` is `"USD"`. The type-specific surface is [`IB20Stablecoin`](../../src/interfaces/IB20Stablecoin.sol). Calling `announce` on that address does not run Asset logic. + +### 6.2 Creating an Asset + +Predict the address with `getB20Address(ASSET, sender, saltA)`. Then call `createB20` with `B20AssetCreateParams`: `version` `1`, name, symbol, `initialAdmin`, and `decimals: 18`. Optional `initCalls` can grant `OPERATOR_ROLE` or call `batchMint` in the same transaction. + +```mermaid +sequenceDiagram + participant Issuer + participant Factory + participant Token as Asset token + + Issuer->>Factory: getB20Address(ASSET, sender, saltA) + Factory-->>Issuer: predicted address + Issuer->>Factory: createB20(ASSET, saltA, params, initCalls) + Factory->>Token: seal identity (byte 10 = 0x00, decimals 18) + opt initCalls + Factory->>Token: grant OPERATOR_ROLE / batchMint + end + Factory-->>Issuer: token address +``` + +After return, address byte `[10]` is `0x00`. `decimals()` is `18`. [`IB20Asset`](../../src/interfaces/IB20Asset.sol) `announce` and `updateUIMultiplier` are live. There is no `currency()`. + +### 6.3 A later call + +A call to the token address does not choose the type again. The node reads byte `[10]` and runs that variant's logic. + +```mermaid +flowchart TD + C[Call arrives at token address] --> V{Byte 10} + V -->|0x00 Asset| A[Asset logic] + V -->|0x01 Stablecoin| S[Stablecoin logic] + A --> A1["announce / updateUIMultiplier run"] + S --> S1["currency() runs"] + A --> A2["currency() does not run"] + S --> S2["announce does not run"] +``` diff --git a/docs/guides/scheduling-multiplier-changes.md b/docs/guides/scheduling-multiplier-changes.md new file mode 100644 index 00000000..fddeed99 --- /dev/null +++ b/docs/guides/scheduling-multiplier-changes.md @@ -0,0 +1,328 @@ +# Schedule a UI multiplier change + +## Goal + +Make a corporate action take effect at an agreed time without rewriting anyone's raw balance — for example a 2-for-1 stock split at the start of the next trading day. + +Exchanges, custodians, wallets, and accounting systems need that time in advance so they can prepare UI balances, prices, and books. You schedule the change on-chain ahead of it. At the effective timestamp, displayed holdings double (or shrink for a reverse split). Raw `balanceOf`, `totalSupply`, and transfer amounts stay the same, so DeFi that reads raw units keeps working. + +`updateUIMultiplier` is the path for that schedule ([ERC-8056](https://eips.ethereum.org/EIPS/eip-8056)). The multiplier is an 18-decimal WAD: `1e18` is `1.0` (`WAD_PRECISION`). A 2-for-1 split uses `2e18`. A reverse split uses a value below `1e18`. + +```mermaid +sequenceDiagram + participant Operator + participant Asset as B20 Asset + participant Reader as Wallet or indexer + + Operator->>Asset: updateUIMultiplier(newMultiplier, effectiveAt) + Asset-->>Operator: UIMultiplierUpdated(old, new, effectiveAt) + 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 +``` + +This surface exists only on **B20 Asset**. Stablecoin has no multiplier. The rest of this guide uses "the asset" for an Asset token. + +## Before You Start + +You need all of the following: + +- A B20 Asset you administer. +- `DEFAULT_ADMIN_ROLE` on that asset, so you can grant `OPERATOR_ROLE`. +- An account that will call the multiplier setters (the operator). +- A future `effectiveAt` timestamp and a `newMultiplier` in `(0, MAX_UI_MULTIPLIER]`. + +### Who may schedule + +The caller of `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and the deprecated `updateMultiplier` must hold `OPERATOR_ROLE`. Any other caller reverts `AccessControlUnauthorizedAccount`. + +Pause does not gate those setters. `PausableFeature` freezes only `TRANSFER`, `MINT`, `BURN`, and `SEIZE`. Freezing transfers around a split is possible, but it is **not recommended** for routine corporate actions — see scenario 3 under Steps. If you do pause, you need `PAUSE_ROLE` / `UNPAUSE_ROLE` in addition to the operator. + +### What a multiplier changes + +Balances and transfer amounts stay in raw ERC-20 units. UI-specific reads apply the effective multiplier: + +| Read | Meaning | +| --- | --- | +| `uiMultiplier()` / `multiplier()` | Effective multiplier at `block.timestamp` | +| `balanceOfUI(account)` / `scaledBalanceOf(account)` | `balanceOf(account) * uiMultiplier() / WAD_PRECISION` | +| `totalSupplyUI()` | `totalSupply() * uiMultiplier() / WAD_PRECISION` | +| `toUIAmount(raw)` / `fromUIAmount(ui)` | Convert at the effective multiplier | + +Integer division rounds down. A round trip through `toUIAmount` and `fromUIAmount` can lose up to one unit in the last place (ULP) when `multiplier != WAD_PRECISION`. Prefer 18 decimals for equities to keep that effect small. Raw `balanceOf` does not apply the multiplier at all. + +```mermaid +flowchart LR + R[Raw balance] -->|"unchanged by schedule"| C[Canonical ERC-20] + R -->|"raw * multiplier / 1e18"| U[UI balance] +``` + +### How a schedule lives + +The asset allows **one** pending multiplier at a time. + +1. **Schedule.** `updateUIMultiplier(newMultiplier, effectiveAt)` stores the pending pair. `effectiveAt` must be strictly greater than `block.timestamp`. +2. **Live pending.** While `effectiveAt() > block.timestamp`, `uiMultiplier()` still returns the current multiplier, `newUIMultiplier()` returns the scheduled value, and `effectiveAt()` returns the flip time. A second schedule reverts `UIMultiplierUpdateExists`. +3. **Maturation.** When `block.timestamp >= effectiveAt`, reads return the new multiplier. Maturation does **not** write storage and does **not** emit an event. +4. **After maturity.** Until another update, `newUIMultiplier()` mirrors `uiMultiplier()`, and `effectiveAt()` keeps the past timestamp. Detect a live pending with `effectiveAt() > block.timestamp`. Do **not** check `effectiveAt() == 0`. + +```mermaid +flowchart TD + A[updateUIMultiplier] --> B{Live pending already?} + B -->|yes| X[Revert UIMultiplierUpdateExists] + B -->|no| C[Store pending and emit UIMultiplierUpdated] + C --> D{block.timestamp >= effectiveAt?} + D -->|no| E["uiMultiplier = old
newUIMultiplier = pending"] + D -->|yes| F["uiMultiplier = new
computed on read, no event"] +``` + +## Steps + +Four scenarios. Start with the main schedule path. Use the others only when you need to cancel, freeze transfers, or correct a bad schedule. + +1. Schedule a multiplier. +2. Schedule, then cancel. +3. Schedule while transfers are paused (not recommended). +4. Instant override when the schedule is wrong. + +### 1. Schedule a multiplier + +This is the routine corporate-action path. + +#### Grant `OPERATOR_ROLE` + +```solidity +asset.grantRole(asset.OPERATOR_ROLE(), operator); +``` + +Until this grant lands, every multiplier setter reverts `AccessControlUnauthorizedAccount`. + +#### Call `updateUIMultiplier` + +`effectiveAt` must be in the future. `newMultiplier` must be in `(0, MAX_UI_MULTIPLIER]`. Read `MAX_UI_MULTIPLIER()` if you need the ceiling without triggering `InvalidMultiplier`. + +```solidity +uint256 newMultiplier = 2e18; // 2-for-1 split +uint256 effectiveAt = block.timestamp + 1 days; +asset.updateUIMultiplier(newMultiplier, effectiveAt); +``` + +On success the asset emits: + +`UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` + +That event is the schedule success signal. It fires when the update is **recorded**, not when the multiplier becomes active. If `effectiveAtTimestamp > block.timestamp`, treat the update as pending until that time. + +#### Read the live pending state + +```solidity +asset.uiMultiplier(); // still the old (current) multiplier +asset.newUIMultiplier(); // scheduled target +asset.effectiveAt(); // flip timestamp +``` + +A second `updateUIMultiplier` while this pending is live reverts `UIMultiplierUpdateExists`. + +#### Confirm after `effectiveAt` + +When `block.timestamp >= effectiveAt`, `uiMultiplier()` returns the new multiplier. No second event fires at the flip. No storage write occurs at the flip. + +### 2. Schedule, then cancel + +Use this when a pending update should not take effect — wrong multiplier, wrong timestamp, or the corporate action is delayed. + +Schedule as in scenario 1, then call before `effectiveAt`: + +```solidity +asset.cancelUIMultiplierUpdate(); +``` + +On success the asset emits: + +`UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` + +Discard the pending update when you see this event. `uiMultiplier()` stays at the old value. Cancel after maturity (or with no live pending) reverts `UIMultiplierUpdateDoesNotExist`. + +To replace a pending update with a different one, cancel first, then schedule again. Both can run in one `announce` so they share `msg.sender` and the operator role: + +```solidity +bytes[] memory calls = new bytes[](2); +calls[0] = abi.encodeCall(IB20Asset.cancelUIMultiplierUpdate, ()); +calls[1] = abi.encodeCall(IB20Asset.updateUIMultiplier, (secondMultiplier, secondEffectiveAt)); +asset.announce(calls, "reorder-2026-Q3", "reorder split", "https://disclosures.example/"); +``` + +`announce` also emits `Announcement` then `EndAnnouncement` with the same `id`. The `id` is single-use for the asset's lifetime. + +### 3. Schedule while transfers are paused + +**Not recommended** for routine splits. Pausing `TRANSFER` stops every holder transfer for the window, which is heavier than most corporate actions need. Prefer scenario 1 and let wallets and custodians coordinate off the pending schedule. + +If you still need a hard freeze (for example a reverse split where transfers during the window are unsafe), pause needs `PAUSE_ROLE` / `UNPAUSE_ROLE`. Pause does not block `updateUIMultiplier`. + +```solidity +asset.pause([PausableFeature.TRANSFER]); +asset.updateUIMultiplier(newMultiplier, effectiveAt); +// ... after effectiveAt ... +asset.unpause([PausableFeature.TRANSFER]); +``` + +```mermaid +sequenceDiagram + participant Pauser + participant Operator + participant Asset as B20 Asset + + Note over Operator: already holds OPERATOR_ROLE + Pauser->>Asset: pause([TRANSFER]) + Operator->>Asset: updateUIMultiplier(...) + Asset-->>Operator: allowed + Note over Asset: holders cannot transfer until unpause + Pauser->>Asset: unpause([TRANSFER]) +``` + +### 4. Instant override when the schedule is wrong + +Use this when a live pending update is wrong and you cannot wait for `effectiveAt` — for example the scheduled multiplier is incorrect and books must flip now. Prefer cancel (scenario 2) when waiting is acceptable. Prefer a new schedule after cancel when the fix is still a future timestamp. + +The deprecated `updateMultiplier(newMultiplier)` applies immediately and clears any pending update: + +```solidity +asset.updateMultiplier(correctMultiplier); +``` + +Event order depends on pending state: + +| Situation | Events (in order) | +| --- | --- | +| Live pending (`effectiveAt > block.timestamp`) | `UIMultiplierUpdateCancelled`, then `MultiplierUpdated(new)`, then `UIMultiplierUpdated(old, new, block.timestamp)` | +| Matured or no pending | `MultiplierUpdated(new)`, then `UIMultiplierUpdated(old, new, block.timestamp)` | + +`MultiplierUpdated` is deprecated. Integrators should process only `UIMultiplierUpdated` so they do not handle the same update twice. + +## Example + +A 2-for-1 split scheduled for tomorrow (scenario 1). The grant and schedule are enough for the routine path. + +```mermaid +sequenceDiagram + participant Admin + participant Operator + participant Asset as B20 Asset + participant Reader as Wallet or indexer + + Admin->>Asset: grantRole(OPERATOR_ROLE, operator) + Operator->>Asset: updateUIMultiplier(2e18, T1) + Asset-->>Operator: UIMultiplierUpdated(1e18, 2e18, T1) + Reader->>Asset: uiMultiplier / newUIMultiplier / effectiveAt + Asset-->>Reader: 1e18 / 2e18 / T1 + Note over Asset: T1 passes with no event + Reader->>Asset: uiMultiplier() + Asset-->>Reader: 2e18 +``` + +```solidity +import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; + +IB20Asset asset = IB20Asset(assetAddr); + +asset.grantRole(asset.OPERATOR_ROLE(), operator); + +uint256 splitMultiplier = 2e18; +uint256 effectiveAt = block.timestamp + 1 days; +asset.updateUIMultiplier(splitMultiplier, effectiveAt); + +// While live: uiMultiplier() is still WAD_PRECISION; newUIMultiplier() is 2e18. +// After effectiveAt: uiMultiplier() is 2e18. No second event at the flip. +``` + +## Verify + +Look for `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` on the schedule transaction. That event is the success signal for recording a change. + +Then confirm the three reads while the update is live: + +- `uiMultiplier()` equals the old multiplier. +- `newUIMultiplier()` equals the scheduled multiplier. +- `effectiveAt()` equals the scheduled timestamp and is greater than `block.timestamp`. + +After `effectiveAt`, confirm `uiMultiplier()` equals the new multiplier. Maturation emits nothing. Do not wait for a second event at the flip. + +Integrator rules: + +- Listen for `UIMultiplierUpdated`, not deprecated `MultiplierUpdated`. +- If `effectiveAtTimestamp > block.timestamp`, treat the update as pending until that time. +- On `UIMultiplierUpdateCancelled`, discard the pending update. +- When the instant setter emits both events, process only `UIMultiplierUpdated`. + +## Common Errors + +These errors follow the order `updateUIMultiplier` checks them. Cancel and announce errors follow. + +| Error | Why it happened | What to do | +| --- | --- | --- | +| `AccessControlUnauthorizedAccount(caller, OPERATOR_ROLE)` | The caller does not hold `OPERATOR_ROLE`. | Grant `OPERATOR_ROLE` to the operator. | +| `InvalidMultiplier()` | `newMultiplier` is zero or above `MAX_UI_MULTIPLIER`. | Pass a value in `(0, MAX_UI_MULTIPLIER]`. | +| `EffectiveAtInPast(effectiveAt)` | `effectiveAt <= block.timestamp`. | Pass a strictly future timestamp. | +| `EffectiveAtTooFar(effectiveAt)` | `effectiveAt > type(uint64).max`. | Pass a timestamp that fits in `uint64`. | +| `UIMultiplierUpdateExists(effectiveAt)` | A live pending update already exists. | Cancel first, or cancel-then-reschedule in one `announce`. | +| `UIMultiplierUpdateDoesNotExist()` | `cancelUIMultiplierUpdate` with no live pending (including after maturity). | Schedule first, or cancel only while `effectiveAt() > block.timestamp`. | +| `AnnouncementIdAlreadyUsed(id)` | `announce` reused an `id`. | Choose a new single-use `id`. | +| `InternalCallFailed(call)` | An inner call in `announce` reverted (non-Panic). | Fix the encoded cancel/schedule calldata and retry. | + +## Related Concepts + +- [Token Types](../concepts/token-types.md) +- [Roles and Pause](../concepts/roles-and-pause.md) + +## Reference + +```solidity +function OPERATOR_ROLE() external view returns (bytes32); +function WAD_PRECISION() external view returns (uint256); +function MAX_UI_MULTIPLIER() external view returns (uint256); + +function uiMultiplier() external view returns (uint256); +function multiplier() external view returns (uint256); +function newUIMultiplier() external view returns (uint256); +function effectiveAt() external view returns (uint256); + +function updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) external; +function cancelUIMultiplierUpdate() external; +function updateMultiplier(uint256 newMultiplier) external; // deprecated emergency override + +function announce( + bytes[] calldata internalCalls, + string calldata id, + string calldata description, + string calldata uri +) external; + +function grantRole(bytes32 role, address account) external; +function pause(PausableFeature[] features) external; +function unpause(PausableFeature[] features) external; +``` + +`updateUIMultiplier(uint256,uint256)` selector: `0x628e600f`. + +`cancelUIMultiplierUpdate()` selector: `0x2c97a0f0`. + +`updateMultiplier(uint256)` selector: `0x5ffe6146`. + +`UIMultiplierUpdated(uint256 oldMultiplier, uint256 newMultiplier, uint256 effectiveAtTimestamp)` topic0: `0x2205df4534432b2f60654a3fdb48737ffdaf3e9edb1a498bd985bc026b15b055`. + +`UIMultiplierUpdateCancelled(uint256 cancelledMultiplier, uint256 cancelledEffectiveAt)` topic0: `0x883856335ba5f60c18b9817c4505d3c7d3f6223dcf39516b30c508c46a5e1cad`. + +### Events by call + +| Call | Events (in order) | +| --- | --- | +| `updateUIMultiplier` | `UIMultiplierUpdated(old, new, effectiveAt)` only | +| `cancelUIMultiplierUpdate` | `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` | +| `updateMultiplier` with a live pending | `UIMultiplierUpdateCancelled`, then `MultiplierUpdated(new)`, then `UIMultiplierUpdated(old, new, block.timestamp)` | +| `updateMultiplier` with a matured pending or none | `MultiplierUpdated(new)`, then `UIMultiplierUpdated(old, new, block.timestamp)` | +| Maturation (`block.timestamp >= effectiveAt`) | none | + +`updateMultiplier` remains callable and unchanged in availability. It is deprecated. Prefer `updateUIMultiplier` for routine corporate actions. diff --git a/docs/guides/seizeing-assets.md b/docs/guides/seizeing-assets.md new file mode 100644 index 00000000..5e150fa5 --- /dev/null +++ b/docs/guides/seizeing-assets.md @@ -0,0 +1,326 @@ +# Seize a holder's B20 balance + +## Goal + +Move tokens from one holder to a safekeeping account in a single admin call. `totalSupply` does not change. + +`seizeWithMemo` is the dedicated path for court orders, sanctions, and freeze-and-reissue workflows. It is not a burn and not a mint. Tokens leave `from` and arrive at `to` in one transfer. After the call, `to` is an ordinary holder: it can transfer, burn, or hold the tokens. + +```mermaid +flowchart LR + H[Holder] -->|"seizeWithMemo"| T[Safekeeping account] +``` + + + +Asset and Stablecoin share this surface. The rest of this guide uses "the token" for either variant. + +## Before You Start + +You need all of the following: + +- A B20 token you administer. +- `DEFAULT_ADMIN_ROLE` on that token, so you can grant roles and attach policies. +- An account that will call `seizeWithMemo` (the seizer). +- A non-zero destination that is not the holder (typically a treasury). +- `SEIZE` not paused. `pause([SEIZE])` blocks every seize until `unpause([SEIZE])`. + +Three independent controls then decide whether a seize can run. In this order, the steps later configure them in the same order. + +```mermaid +flowchart TD + A[seizeWithMemo arrives] --> B{SEIZE paused?} + B -->|yes| X1[Revert] + B -->|no| C{Caller holds SEIZE_ROLE?} + C -->|no| X2[Revert] + C -->|yes| D{Holder seizable?} + D -->|no| X3[Revert] + D -->|yes| E{Destination allowed?} + E -->|no| X4[Revert] + E -->|yes| F[Move balance] +``` + + + +### Who may seize + +The caller of `seizeWithMemo` must hold `SEIZE_ROLE`. Any other caller reverts `AccessControlUnauthorizedAccount`. + +Pause is a second switch on the same function. A caller with `SEIZE_ROLE` still cannot seize while `PausableFeature.SEIZE` is paused. The call reverts `ContractPaused(SEIZE)`. Pausing `TRANSFER`, `MINT`, or `BURN` leaves seize live. + +```mermaid +sequenceDiagram + participant Pauser + participant Seizer + participant Token as B20 token + + Note over Seizer: already holds SEIZE_ROLE + Pauser->>Token: pause([TRANSFER]) + Seizer->>Token: seizeWithMemo(...) + Token-->>Seizer: allowed + Pauser->>Token: pause([SEIZE]) + Seizer->>Token: seizeWithMemo(...) + Token-->>Seizer: revert ContractPaused(SEIZE) +``` + + + +### Which accounts are in scope + +Seize uses two scopes, and they answer different questions: + + +| Scope | Account | Question | Default when unset (`0`) | +| ----------------------- | ------- | ---------------------------- | ------------------------------------------------------ | +| `SEIZE_HOLDER_POLICY` | `from` | Is this holder seizable? | No. Every account is authorized, so none are seizable. | +| `SEIZE_RECEIVER_POLICY` | `to` | May seized tokens land here? | Yes. Any destination is allowed. | + + +`SEIZE_HOLDER_POLICY` is inverted relative to transfer and mint scopes. The call proceeds only when `isAuthorized` is **false**. That is why the default (always authorized) blocks every seize until you attach a policy. + +`SEIZE_RECEIVER_POLICY` is a normal allow check. The call proceeds only when `isAuthorized` is **true**. + +```mermaid +flowchart TD + H["isAuthorized(SEIZE_HOLDER_POLICY, from)"] -->|true| R1[Revert AccountNotSeizable] + H -->|false| RV["isAuthorized(SEIZE_RECEIVER_POLICY, to)"] + RV -->|false| R2[Revert PolicyForbids] + RV -->|true| OK[from is seizable and to may receive] +``` + + + +Because the holder check is inverted, pick a policy that returns `false` only for the accounts you intend to seize. A `BLOCKLIST` does that. It authorizes every account that is **not** in the set. Adding a holder makes `isAuthorized` return `false` for that holder, and they become seizable. Everyone else stays authorized and is not seizable. + +An `ALLOWLIST` and the `ALWAYS_BLOCK` sentinel do the opposite of what this scope needs. An empty allowlist authorizes nobody: `isAuthorized` is `false` for every account, so every account is seizable. `ALWAYS_BLOCK` is the same result with no member set. Attach neither to `SEIZE_HOLDER_POLICY`. + +```mermaid +flowchart TD + subgraph blocklist [BLOCKLIST with Alice] + B1[Alice in the set] --> B2["isAuthorized = false"] + B2 --> B3[Alice is seizable] + B4[Bob not in the set] --> B5["isAuthorized = true"] + B5 --> B6[Bob is not seizable] + end + subgraph denyAll [Empty ALLOWLIST or ALWAYS_BLOCK] + D1[Alice] --> D2["isAuthorized = false"] + D2 --> D3[Alice is seizable] + D4[Bob] --> D5["isAuthorized = false"] + D5 --> D6[Bob is seizable] + end +``` + + + +Seize also does not read the transfer scopes. `TRANSFER_SENDER_POLICY` and `TRANSFER_RECEIVER_POLICY` gate `transfer` and `transferFrom`. They are separate slots. Adding a holder to a transfer blocklist does not change `isAuthorized` under `SEIZE_HOLDER_POLICY`, so that holder is not seizable. Adding a treasury to a transfer allowlist does not change `isAuthorized` under `SEIZE_RECEIVER_POLICY`, so that address is not a seize destination unless you attach it there. + +## Steps + +Configure the three controls, then seize. + +1. Grant `SEIZE_ROLE` to the seizer. +2. Create a `BLOCKLIST` for seizable holders. +3. Add the holder to that blocklist. +4. Attach the blocklist to `SEIZE_HOLDER_POLICY`. +5. Optionally restrict destinations with `SEIZE_RECEIVER_POLICY`. +6. Call `seizeWithMemo(from, to, amount, memo)`. +7. Confirm the `Seized` event. + +### 1. Grant `SEIZE_ROLE` + +```solidity +token.grantRole(token.SEIZE_ROLE(), seizer); +``` + +Until this grant lands, every `seizeWithMemo` reverts `AccessControlUnauthorizedAccount`. + +### 2. Create a holder blocklist + +```solidity +uint64 seizableId = POLICY_REGISTRY.createPolicy(policyAdmin, IPolicyRegistry.PolicyType.BLOCKLIST); +``` + +The registry assigns a new ID. The member set starts empty, so every account is still authorized and nobody is seizable yet. + +`createPolicyWithAccounts` can create the policy and seed the first batch in one call. Batches are capped at 64 accounts. + +### 3. Add the holder + +Only the policy admin can change membership. Token admin and policy admin are separate. + +```solidity +address[] memory holders = new address[](1); +holders[0] = alice; +POLICY_REGISTRY.updateBlocklist(seizableId, true, holders); +``` + +Alice is now unauthorized under this policy. She is not seizable on your token until the next step attaches the ID. + +### 4. Attach the blocklist to `SEIZE_HOLDER_POLICY` + +```solidity +token.updatePolicy(token.SEIZE_HOLDER_POLICY(), seizableId); +``` + +The write takes effect on the next `seizeWithMemo` and emits `PolicyUpdated`. You can reuse an existing blocklist. More than one token can point at the same policy ID. + +### 5. Optionally restrict the destination + +Skip this step if any safekeeping address is acceptable. The unset receiver scope already allows every `to`. + +To lock destinations, create an `ALLOWLIST`, add the treasury, and attach it: + +```solidity +uint64 destId = POLICY_REGISTRY.createPolicy(policyAdmin, IPolicyRegistry.PolicyType.ALLOWLIST); +address[] memory dests = new address[](1); +dests[0] = treasury; +POLICY_REGISTRY.updateAllowlist(destId, true, dests); +token.updatePolicy(token.SEIZE_RECEIVER_POLICY(), destId); +``` + +A treasury does not need to be on a transfer allowlist. Seize checks `SEIZE_RECEIVER_POLICY` only. + +### 6. Call `seizeWithMemo` + +The seizer calls the token. `from` and `to` must be distinct and non-zero. A `memo` of `bytes32(0)` is allowed. + +```solidity +token.seizeWithMemo(alice, treasury, amount, memo); +``` + +On success the token emits, in order, `Transfer(from, to, amount)`, `Memo(caller, memo)`, and `Seized(caller, from, to, amount)`. + +### 7. Confirm the seizure + +Look for `Seized(caller, from, to, amount)` on the transaction. That event is the success signal. A revert means the seize did not happen. + +## Example + +Alice holds `amount`. You grant a seizer, mark Alice seizable with a blocklist, allow only `treasury` as the destination, then move the balance. `totalSupply` stays the same. + +```mermaid +sequenceDiagram + participant Admin + participant Registry as Policy Registry + participant Token as B20 token + participant Seizer + participant Alice + participant Treasury + + Admin->>Token: grantRole(SEIZE_ROLE, seizer) + Admin->>Registry: createPolicy(admin, BLOCKLIST) + Registry-->>Admin: seizableId + Admin->>Registry: updateBlocklist(seizableId, true, [Alice]) + Admin->>Token: updatePolicy(SEIZE_HOLDER_POLICY, seizableId) + Admin->>Registry: createPolicy(admin, ALLOWLIST) + Registry-->>Admin: destId + Admin->>Registry: updateAllowlist(destId, true, [Treasury]) + Admin->>Token: updatePolicy(SEIZE_RECEIVER_POLICY, destId) + + Seizer->>Token: seizeWithMemo(Alice, Treasury, amount, memo) + Token->>Registry: isAuthorized(seizableId, Alice) + Registry-->>Token: false + Note right of Token: false means Alice is seizable + Token->>Registry: isAuthorized(destId, Treasury) + Registry-->>Token: true + Token-->>Alice: Transfer(Alice, Treasury, amount) + Note over Alice: loses amount + Note over Treasury: gains amount + Note over Token: totalSupply unchanged + Token-->>Seizer: Memo(seizer, memo) + Token-->>Seizer: Seized(seizer, Alice, Treasury, amount) +``` + + + +```solidity +import {IB20} from "base-std/interfaces/IB20.sol"; +import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; +import {StdPrecompiles} from "base-std/StdPrecompiles.sol"; + +IB20 token = IB20(tokenAddr); +IPolicyRegistry registry = StdPrecompiles.POLICY_REGISTRY; + +token.grantRole(token.SEIZE_ROLE(), seizer); + +uint64 seizableId = registry.createPolicy(policyAdmin, IPolicyRegistry.PolicyType.BLOCKLIST); +address[] memory holders = new address[](1); +holders[0] = alice; +registry.updateBlocklist(seizableId, true, holders); +token.updatePolicy(token.SEIZE_HOLDER_POLICY(), seizableId); + +uint64 destId = registry.createPolicy(policyAdmin, IPolicyRegistry.PolicyType.ALLOWLIST); +address[] memory dests = new address[](1); +dests[0] = treasury; +registry.updateAllowlist(destId, true, dests); +token.updatePolicy(token.SEIZE_RECEIVER_POLICY(), destId); + +token.seizeWithMemo(alice, treasury, amount, keccak256("court-order-123")); +``` + +Prefer this path over the deprecated `burnBlocked` workaround. That workaround blocks the holder under `TRANSFER_SENDER_POLICY`, burns to `address(0)`, then mints to the treasury. `totalSupply` dips and recovers, and there is no `Seized` event. + +## Common Errors + +These errors follow the order `seizeWithMemo` checks them. + + +| Error | Why it happened | What to do | +| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `ContractPaused(SEIZE)` | `SEIZE` is paused. | Call `unpause` with `PausableFeature.SEIZE`. | +| `AccessControlUnauthorizedAccount(caller, SEIZE_ROLE)` | The caller does not hold `SEIZE_ROLE`. | Grant `SEIZE_ROLE` to the seizer. | +| `InvalidReceiver(to)` | `to` is `address(0)`, or `from == to`. | Use a distinct, non-zero safekeeping address. | +| `InvalidSender(from)` | `from` is `address(0)`. | Pass the holder's address. | +| `AccountNotSeizable(from)` | `from` is still authorized under `SEIZE_HOLDER_POLICY`. The slot is unset, or the holder is not on the attached blocklist. | Attach a blocklist and add `from`. | +| `PolicyForbids(SEIZE_RECEIVER_POLICY, policyId)` | `to` is not authorized under `SEIZE_RECEIVER_POLICY`. | Add `to` to the receiver allowlist, or set the scope back to `0` (`ALWAYS_ALLOW`). | +| `InsufficientBalance(from, balance, amount)` | `from` holds less than `amount`. | Seize `balanceOf(from)` or less. | +| `PolicyNotFound(policyId)` | `updatePolicy` received an ID that is not a sentinel and does not exist in the registry. | Create the policy first, then attach the returned ID. | +| `Unauthorized()` | A non-admin called `updateBlocklist` or `updateAllowlist`. | Call as the policy's `policyAdmin`. | + + +```mermaid +flowchart TD + Fail[Call reverted] --> E{Error} + E -->|ContractPaused| F1[Unpause SEIZE] + E -->|AccessControlUnauthorizedAccount| F2[Grant SEIZE_ROLE] + E -->|InvalidReceiver or InvalidSender| F3[Use distinct non-zero addresses] + E -->|AccountNotSeizable| F4[Attach blocklist and add from] + E -->|PolicyForbids| F5[Allowlist to, or unset the receiver scope] + E -->|InsufficientBalance| F6[Lower amount] +``` + + + +## Related Concepts + +- [Policies](../concepts/policies.md) +- [Roles and Pause](../concepts/roles-and-pause.md) + +## Reference + +```solidity +function SEIZE_ROLE() external view returns (bytes32); +function SEIZE_HOLDER_POLICY() external view returns (bytes32); +function SEIZE_RECEIVER_POLICY() external view returns (bytes32); + +function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external; + +function grantRole(bytes32 role, address account) external; +function updatePolicy(bytes32 policyScope, uint64 newPolicyId) external; +function policyId(bytes32 policyScope) external view returns (uint64); +function hasRole(bytes32 role, address account) external view returns (bool); + +// Policy Registry +function createPolicy(address admin, PolicyType policyType) external returns (uint64 newPolicyId); +function updateBlocklist(uint64 policyId, bool blocked, address[] calldata accounts) external; +function updateAllowlist(uint64 policyId, bool allowed, address[] calldata accounts) external; +function isAuthorized(uint64 policyId, address account) external view returns (bool); +``` + +`seizeWithMemo` selector: `0xf916d81b`. + +`Seized(address indexed caller, address indexed from, address indexed to, uint256 amount)` topic0: `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c`. + +`AccountNotSeizable(address)` selector: `0x91dbbc8d`. + +`burnBlocked(address,uint256)` remains callable and unchanged. It is deprecated. \ No newline at end of file diff --git a/docs/guides/template.md b/docs/guides/template.md new file mode 100644 index 00000000..fe40d9fb --- /dev/null +++ b/docs/guides/template.md @@ -0,0 +1,40 @@ +# Configure Compliance for a B20 Asset + +## Goal + +Restrict transfers so only eligible holders can receive the asset. + +## Before You Start + +- You have a B20 asset +- You control the appropriate admin role + +## Steps + +1. Create an allowlist policy +2. Add eligible addresses +3. Attach the policy to the receiver scope +4. Test an allowed transfer +5. Test a denied transfer + +## Example + +... + +## Verify + +... + +## Common Errors + +... + +## Related Concepts + +- Policies +- Policy Registry + +## Reference + +- updatePolicy(...) +- createPolicy(...) \ No newline at end of file diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 00000000..58e6fcfe --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,196 @@ +# B20 Overview + +B20 is Base's native token standard for issuing and managing programmable assets onchain. + +This document provides a high-level introduction to B20: what it is, why it exists, the core primitives it exposes, and how those pieces fit together. + +For a deeper technical explanation, see [How B20 Works](./architecture.md). + +--- + +## What is B20? + +B20 is Base's native token standard for issuing and managing programmable assets onchain. Base created it to standardize real-world asset (RWA) and stablecoin issuance. B20 is an ERC-20 superset: balances, transfers, and approvals work like ERC-20, and every B20 asset shares the same additional interfaces and protocol logic rather than each issuer deploying a custom token implementation. + +The standard also includes compliance and administrative controls. Issuers can configure roles and permissions, attach policies, mint and burn supply, pause operations, and perform other administrative actions that regulated-asset workflows typically require. + +B20 runs as precompiles in the Base node, not as per-token Solidity. Wallets, issuers, and apps call ERC-20-style interfaces; the node runs the shared B20 logic natively. Base upgrades that logic through hardforks, so every caller gets consistent behavior and native execution across all B20 assets. + +### At a Glance + +```mermaid +flowchart TD + W[Wallets] + I[Issuers] + A[Apps] + B[B20 interface] + N[Node] + P[Precompile] + L[Shared logic] + W --> B + I --> B + A --> B + B -->|call| N + N --> P + P --> L +``` + +You call a B20 asset the same way you call any other contract: through its interface at the asset address. Every B20 asset uses that same interface and the same precompile logic, so integrators have one source of truth. + +--- + +## Why B20? + +Real-world asset (RWA) issuance onchain needs a shared token standard with compliance built into the asset. ERC-20 covers balances, transfers, and approvals. Regulated assets also need eligibility checks, roles, mint and burn, pausing, and other administrative controls. Issuers rebuild those primitives for almost every tokenized asset. + +Issuers who implement that stack themselves repeat the same logic, diverge in behavior, and force every wallet and app to integrate a custom token. B20 is the alternative: you create a B20 asset and configure its roles and policies instead of writing and maintaining a one-off token. Compliance is a first-class primitive, not an add-on each issuer designs around transfers. + +A single standard also helps integrators and issuers. Wallets and apps integrate against one interface. Issuers can use shared services, such as oracles, without designing a new integration for each asset. + +--- + +## Creating a B20 Asset + +Every B20 token is created through the Factory, a singleton precompile. You submit `createB20` to a Base node the same way you submit any other contract call. + +```mermaid +sequenceDiagram + participant Issuer + participant Factory + participant Token as B20 token + + Issuer->>Factory: createB20(variant, salt, params, initCalls) + Factory->>Token: seal identity + Factory->>Token: initCalls (grantRole, updatePolicy, mint) + Factory-->>Issuer: token address +``` + +1. The issuer calls `createB20` with a variant, a salt, and creation parameters (name, symbol, initial admin, and variant-specific fields). +2. The Factory assigns a deterministic address from `(variant, sender, salt)` and seals the token's identity. +3. Optional `initCalls` run on the new token so the issuer can grant roles, attach policies, or mint in the same transaction. +4. `createB20` returns. The Factory retains no ongoing access to the token. + +Choose **Asset** for general-purpose issuance, including RWAs, or **Stablecoin** for a fiat-pegged token with a fixed currency code. Both variants share roles, policies, and the ERC-20 surface. See [Token Types](./concepts/token-types.md). + +The Activation Registry is a Base-operated safety switch that turns Factory and token features on. Issuers and apps do not operate it. + +--- + +## Configuring Roles + +Roles let an issuer assign each privileged operation to a specific account. An admin can grant minting to a minter, seizing to a compliance operator, and pausing of a single feature (`TRANSFER`, `MINT`, `BURN`, or `SEIZE`) without pausing the rest of the token. + +B20 implements this with [OpenZeppelin AccessControl](https://docs.openzeppelin.com/contracts/5.x/access-control) on the token. Roles are not a separate registry. One `DEFAULT_ADMIN_ROLE` holder grants and revokes the operating roles. A privileged call checks the role first, then the matching pause vector. Holder `transfer` skips the role check; it still hits the `TRANSFER` pause vector and policy. + +The full role list and what each role gates is in [Roles](./concepts/roles.md). A role-gated call looks like this: + +```mermaid +sequenceDiagram + participant Admin + participant Token as B20 token + participant Caller + + Caller->>Token: mint(to, amount) + Token-->>Caller: revert AccessControlUnauthorizedAccount + + Admin->>Token: grantRole(MINT_ROLE, Caller) + Caller->>Token: mint(to, amount) + Token-->>Caller: allowed +``` + +1. At creation, `initialAdmin` holds `DEFAULT_ADMIN_ROLE`. +2. That admin grants operating roles such as `MINT_ROLE` and `PAUSE_ROLE`. +3. A caller without the required role is rejected with `AccessControlUnauthorizedAccount`. + +--- + +## Pause Vectors + +Pause vectors stop a class of operations on a token without pausing the rest of the asset. An issuer uses them when an off-chain workflow needs a feature frozen (for example a settlement window), or when a vulnerability is found and that path must stop immediately. + +Pause is per feature, not global. The four vectors are `TRANSFER`, `MINT`, `BURN`, and `SEIZE`. Pausing `MINT` halts new issuance while transfers continue. `approve` is not pause-gated. + +`pause` requires `PAUSE_ROLE`. `unpause` requires `UNPAUSE_ROLE`. Those roles are separate, so the account that pauses does not have to be the account that resumes. + +A paused call looks like this: + +```mermaid +sequenceDiagram + participant Pauser + participant Token as B20 token + participant Caller + participant Unpauser + + Caller->>Token: mint(to, amount) + Token-->>Caller: allowed + + Pauser->>Token: pause([MINT]) + Caller->>Token: mint(to, amount) + Token-->>Caller: revert ContractPaused(MINT) + + Unpauser->>Token: unpause([MINT]) + Caller->>Token: mint(to, amount) + Token-->>Caller: allowed +``` + +1. A caller who holds `MINT_ROLE` can mint while `MINT` is unpaused. +2. An account with `PAUSE_ROLE` pauses `MINT`. Other features stay live. +3. The next `mint` reverts with `ContractPaused(MINT)`, even if the caller still holds `MINT_ROLE`. +4. An account with `UNPAUSE_ROLE` unpauses `MINT`. Minting works again. + +--- + +## Integrating Compliance Checks + +Most compliance checks reduce to a set of addresses and an allow-or-deny decision on a specific function. B20 uses that model instead of per-token hooks: you maintain an allowlist or blocklist, bind it to a function on the token, and the call proceeds or reverts. + +Those lists live in the Policy Registry, a global singleton precompile, not on the token. Allowlists, blocklists, and composite policies (union or intersect) are stored there and referenced by policy ID. Because the registry is shared, one list can back many tokens: you maintain membership once, and every attached token sees the same result. + +A token admin binds a policy ID to a policy scope with `updatePolicy`. A scope sits in a similar place to a hook: it runs on a specific function. When that function runs, the token asks the registry `isAuthorized(policyId, account)` and reverts with `PolicyForbids` if the check fails. Which scope runs on which function is in [Policies](./concepts/policies.md). + +A policy-gated transfer looks like this: + +```mermaid +sequenceDiagram + participant Admin + participant Registry as Policy Registry + participant Token as B20 token + participant Alice + + Admin->>Registry: createPolicy(ALLOWLIST) + Admin->>Token: updatePolicy(TRANSFER_RECEIVER_POLICY, id) + Alice->>Token: transfer(Bob) + Token->>Registry: isAuthorized(id, Bob) + Registry-->>Token: false + Token-->>Alice: revert PolicyForbids + + Admin->>Registry: updateAllowlist(Bob) + Alice->>Token: transfer(Bob) + Token->>Registry: isAuthorized(id, Bob) + Registry-->>Token: true + Token-->>Alice: allowed +``` + +1. Create an allowlist or blocklist on the registry. +2. The token admin binds that policy ID to a scope. +3. On `transfer`, the token asks the registry whether the receiver is authorized. +4. Authorized: the call continues. Denied: the call reverts with `PolicyForbids`. +5. Unset scopes default to always-allow. `approve` is not policy-gated. + +--- + +## Where to Go Next + +If you want to understand how B20 works internally: + +→ [B20 Architecture](./architecture.md) + +If you are integrating B20: + +→ [Seize a holder's B20 balance](./guides/seizeing-assets.md) +→ [Schedule a UI multiplier change](./guides/scheduling-multiplier-changes.md) + +For exact interfaces and protocol definitions: + +→ [Reference](./reference/) +→ [Specifications](./specs/) diff --git a/docs/reference/constants.md b/docs/reference/constants.md new file mode 100644 index 00000000..23f40a8d --- /dev/null +++ b/docs/reference/constants.md @@ -0,0 +1,62 @@ +# Constants + +*Role identifiers, policy-type identifiers, precompile addresses, and other fixed constants. See [`B20Constants`](../../src/lib/B20Constants.sol) and [`StdPrecompiles`](../../src/StdPrecompiles.sol).* + +## Precompile addresses + +*Fixed addresses of Base's singleton precompiles. See [`StdPrecompiles`](../../src/StdPrecompiles.sol).* + +| Name | Value | Purpose | +|---|---|---| +| `B20_FACTORY_ADDRESS` | `0xB20f000000000000000000000000000000000000` | Deploys and looks up B-20 tokens; every asset and stablecoin instance is created through the [`IB20Factory`](../../src/interfaces/IB20Factory.sol) at this address. | +| `POLICY_REGISTRY_ADDRESS` | `0x8453000000000000000000000000000000000002` | Stores allowlist/blocklist/composite policies and answers `isAuthorized` checks consulted by every policy scope (see [Policies](../concepts/policies.md)). | +| `ACTIVATION_REGISTRY_ADDRESS` | `0x8453000000000000000000000000000000000001` | Gates whether a B-20 variant or feature is live on a given chain; checked by the factory before it will create that variant. | + +## Roles + +*Role identifiers checked via `hasRole`. See [`B20Constants`](../../src/lib/B20Constants.sol) and [`IB20`](../../src/interfaces/IB20.sol). Hex values are `keccak256` of the role name, verified with `cast keccak ""` and cross-checked in `chisel`.* + +| Name | Value | Purpose | +|---|---|---| +| `DEFAULT_ADMIN_ROLE` | `bytes32(0)` | Required to call `grantRole`, `revokeRole`, `setRoleAdmin`, `updatePolicy`, and `updateSupplyCap`. | +| `MINT_ROLE` | `keccak256("MINT_ROLE")`
`0x154c00819833dac601ee5ddded6fda79d9d8b506b911b3dbd54cdb95fe6c3686` | Required to call `mint` and `mintWithMemo`. | +| `BURN_ROLE` | `keccak256("BURN_ROLE")`
`0xe97b137254058bd94f28d2f3eb79e2d34074ffb488d042e3bc958e0a57d2fa22` | Required to call `burn` and `burnWithMemo`. | +| `BURN_BLOCKED_ROLE` | `keccak256("BURN_BLOCKED_ROLE")`
`0x7408fdc0d31c7bcb349eab611f5d1168acd4303574993f8cdc98b1cd18c41cae` | Required to call the deprecated `burnBlocked`. | +| `SEIZE_ROLE` | `keccak256("SEIZE_ROLE")`
`0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432` | Required to call `seizeWithMemo`. | +| `PAUSE_ROLE` | `keccak256("PAUSE_ROLE")`
`0x139c2898040ef16910dc9f44dc697df79363da767d8bc92f2e310312b816e46d` | Required to call `pause`. | +| `UNPAUSE_ROLE` | `keccak256("UNPAUSE_ROLE")`
`0x265b220c5a8891efdd9e1b1b7fa72f257bd5169f8d87e319cf3dad6ff52b94ae` | Required to call `unpause`. | +| `METADATA_ROLE` | `keccak256("METADATA_ROLE")`
`0x6bd6b5318a46e5fff572d5e4258a20774aab40cc35ac7680654b9081fcc82f80` | Required to call `updateName`, `updateSymbol`, `updateContractURI`, and `updateExtraMetadata`. | +| `OPERATOR_ROLE` | `keccak256("OPERATOR_ROLE")`
`0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929` | B20Asset-only. Required to call `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and the deprecated `updateMultiplier`. | + +## Policy types + +*Policy scopes consulted by the PolicyRegistry. See [`B20Constants`](../../src/lib/B20Constants.sol) and [Policies](../concepts/policies.md). Hex values are `keccak256` of the policy name, verified with `cast keccak ""` and cross-checked in `chisel`.* + +| Name | Value | Purpose | +|---|---|---| +| `TRANSFER_SENDER_POLICY` | `keccak256("TRANSFER_SENDER_POLICY")`
`0xb81736c875ab819dd97f59f2a6542cfb731ad52b4ae15a6f24df2fb02b0327f5` | Consulted for `from` on `transfer` and `transferFrom`. | +| `TRANSFER_RECEIVER_POLICY` | `keccak256("TRANSFER_RECEIVER_POLICY")`
`0x8a4b3fa2d8b921852bc0089c6ef0958aa6961897be36fd731330fe2cd23f8363` | Consulted for `to` on `transfer` and `transferFrom`. | +| `TRANSFER_EXECUTOR_POLICY` | `keccak256("TRANSFER_EXECUTOR_POLICY")`
`0x10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8` | Consulted for `msg.sender` on `transferFrom` only. | +| `MINT_RECEIVER_POLICY` | `keccak256("MINT_RECEIVER_POLICY")`
`0xa0d5ae037e66a09119acf080a1d807abb9b6d03b6b9130eb19f7c1e6bdb8ffc8` | Consulted for `to` on `mint`. | +| `SEIZE_HOLDER_POLICY` | `keccak256("SEIZE_HOLDER_POLICY")`
`0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2` | Consulted for `from` on `seizeWithMemo`; `from` is seizable when unauthorized under this policy. | +| `SEIZE_RECEIVER_POLICY` | `keccak256("SEIZE_RECEIVER_POLICY")`
`0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d` | Consulted for `to` on `seizeWithMemo`. | + +## Feature and validation bounds + +*Bitmasks and inclusive bounds used for pause features and B20Asset creation validation. See [`B20Constants`](../../src/lib/B20Constants.sol).* + +| Name | Value | Purpose | +|---|---|---| +| `ALL_FEATURES_PAUSED` | `15` (`0b1111`) | Bitmask with all `PausableFeature` bits set (`TRANSFER \| MINT \| BURN \| SEIZE`). | +| `MIN_ASSET_DECIMALS` | `6` | Inclusive lower bound for `B20AssetCreateParams.decimals`; the floor most stablecoin-grade integrations expect. | +| `MAX_ASSET_DECIMALS` | `18` | Inclusive upper bound for `B20AssetCreateParams.decimals`; the ERC-20 community ceiling every common wallet/indexer renders correctly. | +| `MAX_SUPPLY_CAP` | `type(uint128).max` | Inclusive upper bound for the supply cap (and therefore `totalSupply`); doubles as the unbounded ("no cap") sentinel. | + +## Asset-variant precision constants + +*Fixed-point constants used by the multiplier/rebasing surface. See [`IB20Asset`](../../src/interfaces/IB20Asset.sol).* + +| Name | Value | Purpose | +|---|---|---| +| `WAD_PRECISION` | `1e18` | Fixed-point precision used to scale `multiplier`; `multiplier`, `toUIAmount`, and `fromUIAmount` all divide/multiply by this. | +| `MAX_UI_MULTIPLIER` | `type(uint128).max` | Maximum multiplier the setters accept — the overflow guard enforced by `updateMultiplier` and `updateUIMultiplier`. Exposed so callers can read the bound without triggering `InvalidMultiplier`. | diff --git a/docs/reference/errors.md b/docs/reference/errors.md new file mode 100644 index 00000000..08a2c793 --- /dev/null +++ b/docs/reference/errors.md @@ -0,0 +1,94 @@ +# Errors + +*Exhaustive list of custom errors, selectors, and the conditions that trigger them. Selectors are the 4-byte `keccak256` hash of the error signature — computed with `cast sig "ErrorName(types...)"`. Enum parameters encode as their underlying `uint8`.* + +*Note: several error names are reused across files with different parameters (or none), which changes the selector. `PolicyNotFound()` ([`IPolicyRegistry`](../../src/interfaces/IPolicyRegistry.sol)) and `PolicyNotFound(uint64)` ([`IB20`](../../src/interfaces/IB20.sol)) are unrelated errors with different selectors, as are `Unauthorized()` (`IB20` / `IPolicyRegistry`) and `Unauthorized(address)` ([`IActivationRegistry`](../../src/interfaces/IActivationRegistry.sol)). Conversely, `LengthMismatch(uint256,uint256)` shares one selector across [`IB20Asset`](../../src/interfaces/IB20Asset.sol) and [`B20FactoryLib`](../../src/lib/B20FactoryLib.sol) — they're independently declared but identical in signature.* + +## [`IB20`](../../src/interfaces/IB20.sol) + +| Error | Selector | Thrown when | +|---|---|---| +| `NonPayable()` | `0x6fb1b0e9` | ETH was attached to a call targeting a nonpayable token selector. | +| `AccessControlUnauthorizedAccount(address account, bytes32 neededRole)` | `0xe2517d3f` | `account` does not hold `neededRole`. | +| `Unauthorized()` | `0x82b42900` | Caller failed a positional authorization check that isn't expressible as "missing role X". | +| `ContractPaused(uint8 feature)` | `0xfd8c4245` | The `PausableFeature` covering the operation is currently paused. | +| `InsufficientAllowance(address spender, uint256 allowance, uint256 needed)` | `0x192b9e4e` | `spender`'s allowance is less than `needed` for the requested `transferFrom`. | +| `InsufficientBalance(address sender, uint256 balance, uint256 needed)` | `0xdb42144d` | `sender`'s balance is less than `needed` for the requested transfer or burn. | +| `InvalidSender(address sender)` | `0x4c14f64c` | The transfer's source address is invalid (typically `address(0)`). | +| `InvalidReceiver(address receiver)` | `0x9cfea583` | The transfer's destination address is invalid (typically `address(0)`). | +| `InvalidApprover(address approver)` | `0x8bc146c4` | The approval's `owner` address is invalid (typically `address(0)`). | +| `InvalidSpender(address spender)` | `0x4e15efda` | The approval's `spender` address is invalid (typically `address(0)`). | +| `InvalidAmount()` | `0x2c5211c6` | An amount argument was zero where a non-zero value is required. Not used for ERC-20 amount arguments. | +| `EmptyFeatureSet()` | `0x4861ff45` | An empty array was passed to a function that requires at least one element. | +| `InvalidSupplyCap(uint256 currentSupply, uint256 proposedCap)` | `0x0a3780ce` | The proposed supply cap is below the current `totalSupply`, or above `type(uint128).max`. | +| `SupplyCapExceeded(uint256 cap, uint256 attempted)` | `0x4b344b11` | The mint would push `totalSupply` past the configured cap. | +| `PolicyForbids(bytes32 policyScope, uint64 policyId)` | `0xa43fec12` | A policy slot denied the operation. | +| `PolicyNotFound(uint64 policyId)` | `0xcccad523` | The provided policy ID does not exist in the policy registry. | +| `UnsupportedPolicyType(bytes32 policyScope)` | `0xcdd98a4a` | `policyScope` is not a slot this token (or its variant) supports. | +| `AccountNotSeizable(address account)` | `0x91dbbc8d` | `seizeWithMemo` was called against a `from` that is not seizable under `SEIZE_HOLDER_POLICY`. | +| `AccountNotBlocked(address account)` | `0x64a5cb46` | The deprecated `burnBlocked` was called against a `from` that is currently authorized under `TRANSFER_SENDER_POLICY` (i.e. not blocked). | +| `ExpiredSignature(uint256 deadline)` | `0xbd2a913c` | An EIP-2612 `permit` was submitted with a `deadline` strictly less than `block.timestamp`. | +| `InvalidSigner(address signer, address owner)` | `0x7ba5ffb5` | ECDSA recovery on an EIP-2612 `permit` returned `signer`, which does not match the claimed `owner`. | +| `LastAdminCannotRenounce()` | `0x361513e7` | `renounceRole(DEFAULT_ADMIN_ROLE, ...)` was called by the sole remaining admin. | +| `NotSoleAdmin()` | `0x2a98e73b` | `renounceLastAdmin()` was called when other accounts also hold `DEFAULT_ADMIN_ROLE`. | +| `AccessControlBadConfirmation()` | `0x6697b232` | The `callerConfirmation` argument to `renounceRole` was not `msg.sender`. | + +## [`IB20Asset`](../../src/interfaces/IB20Asset.sol) + +| Error | Selector | Thrown when | +|---|---|---| +| `AnnouncementIdAlreadyUsed(string id)` | `0xd10b3c9e` | `announce` was called with an `id` that has already been consumed. | +| `InvalidMetadataKey()` | `0x86ea3abb` | `updateExtraMetadata` was called with an empty `key`. | +| `InvalidMultiplier()` | `0x6f12f3dc` | A multiplier setter (`updateUIMultiplier` or the deprecated `updateMultiplier`) was called with a multiplier of zero or above the `type(uint128).max` overflow guard. | +| `EffectiveAtInPast(uint256 effectiveAt)` | `0x14119cf6` | `updateUIMultiplier` was called with an `effectiveAt` that is not in the future. | +| `EffectiveAtTooFar(uint256 effectiveAt)` | `0x1ce214fa` | `updateUIMultiplier` was called with an `effectiveAt` above `type(uint64).max`. | +| `UIMultiplierUpdateExists(uint256 effectiveAt)` | `0x4481a68e` | `updateUIMultiplier` was called while a live pending update already exists. | +| `UIMultiplierUpdateDoesNotExist()` | `0xa7d6a5ca` | `cancelUIMultiplierUpdate` was called when there is no live pending update. | +| `LengthMismatch(uint256 leftLen, uint256 rightLen)` | `0xab8b67c6` | A batched function was called with parallel arrays of differing lengths. | +| `EmptyBatch()` | `0xc2e5347d` | A batched function was called with empty arrays. | +| `AnnouncementInProgress()` | `0x5c5f0829` | An inner call dispatched by `announce` tried to re-invoke `announce`. | +| `InternalCallMalformed(bytes call)` | `0x4e2f143e` | An inner call dispatched by `announce` was shorter than four bytes. | +| `InternalCallFailed(bytes call)` | `0xb288a127` | An inner call dispatched by `announce` reverted with an ordinary revert (reason not bubbled). | + +## [`IB20Factory`](../../src/interfaces/IB20Factory.sol) + +| Error | Selector | Thrown when | +|---|---|---| +| `NonPayable()` | `0x6fb1b0e9` | ETH was attached to a call targeting a nonpayable factory selector. | +| `TokenAlreadyExists(address token)` | `0x15ef3a57` | A token already exists at the deterministic address derived from `(variant, msg.sender, salt)`. | +| `InvalidVariant()` | `0xf10e8e43` | `variant` is not a recognized `B20Variant`. | +| `UnsupportedVersion(uint8 version, uint8 variant)` | `0xc0d8b4e0` | The leading `version` byte in `params` does not match any known encoding for the requested variant. | +| `MissingRequiredField(string field)` | `0x4a43ae87` | A required string argument was the empty string. | +| `InvalidCurrency(string code)` | `0x997c1de8` | The stablecoin `currency` was non-empty but contained a non-`A`-`Z` byte. | +| `InvalidDecimals(uint8 decimals)` | `0xca950391` | The asset `decimals` was outside `[B20Constants.MIN_ASSET_DECIMALS, B20Constants.MAX_ASSET_DECIMALS]`. | +| `InitCallFailed(uint256 index)` | `0x4eae0860` | One of the `initCalls` reverted with no bubbled reason. | + +## [`IPolicyRegistry`](../../src/interfaces/IPolicyRegistry.sol) + +| Error | Selector | Thrown when | +|---|---|---| +| `NonPayable()` | `0x6fb1b0e9` | ETH was attached to a call targeting a nonpayable policy registry selector. | +| `Unauthorized()` | `0x82b42900` | Caller is not the admin required by the attempted operation. | +| `PolicyNotFound()` | `0x720caa4f` | The referenced policy ID does not exist. | +| `IncompatiblePolicyType()` | `0xf1011ef5` | The operation is incompatible with the policy's type. | +| `ZeroAddress()` | `0xd92e233d` | A required address argument was the zero address. | +| `BatchSizeTooLarge(uint256 maxBatchSize)` | `0x083e2f67` | A membership batch exceeded the registry limit. | +| `NoPendingAdmin()` | `0xb4539afa` | `finalizeUpdateAdmin` was called with no pending admin staged. | +| `ChildPoliciesOutsideOfRange()` | `0x697ec868` | A composite policy was created or updated with a child-policy count outside `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]`. | +| `InvalidChildPolicy(uint64 childPolicyId)` | `0x46508ef6` | A child policy is not an existing simple (ALLOWLIST/BLOCKLIST) policy. | + +## [`IActivationRegistry`](../../src/interfaces/IActivationRegistry.sol) + +| Error | Selector | Thrown when | +|---|---|---| +| `Unauthorized(address caller)` | `0x8e4a23d6` | Caller is not the activation admin. | +| `AlreadyActivated(bytes32 feature)` | `0x866b0041` | `activate` was called on a feature that is already activated. | +| `FeatureNotActivated(bytes32 feature)` | `0xb9b2a425` | `checkActivated` was called on an inactive feature, or `deactivate` was called on a feature that is already inactive. | +| `DelegateCallNotAllowed()` | `0x0d89438e` | The precompile was invoked via `DELEGATECALL` or `CALLCODE`. | +| `StaticCallNotAllowed()` | `0xbeaba5b7` | A state-mutating entry point was invoked from a `STATICCALL` frame. | + +## [`B20FactoryLib`](../../src/lib/B20FactoryLib.sol) + +| Error | Selector | Thrown when | +|---|---|---| +| `LengthMismatch(uint256 leftLen, uint256 rightLen)` | `0xab8b67c6` | Two parallel arrays passed to a `build*` helper had different lengths. | diff --git a/docs/reference/events.md b/docs/reference/events.md new file mode 100644 index 00000000..e7c0487e --- /dev/null +++ b/docs/reference/events.md @@ -0,0 +1,67 @@ +# Events + +*Exhaustive list of events emitted by the B20 system, grouped by declaring file.* + +## [`IB20`](../../src/interfaces/IB20.sol) + +| Event | Emitted by | When | +|---|---|---| +| `Transfer(address indexed from, address indexed to, uint256 amount)` | `transfer`, `transferFrom`, `transferWithMemo`, `transferFromWithMemo`, `mint`, `mintWithMemo`, `burn`, `burnWithMemo`, `burnBlocked`, `seizeWithMemo` | Every successful transfer, mint (`from = address(0)`), or burn (`to = address(0)`), including memo'd, blocked-burn, and seize variants. | +| `Approval(address indexed owner, address indexed spender, uint256 amount)` | `approve`, `permit` | An allowance is set. | +| `Memo(address indexed caller, bytes32 indexed memo)` | `transferWithMemo`, `transferFromWithMemo`, `mintWithMemo`, `burnWithMemo` | Immediately after the underlying `Transfer` event. `caller` is the `msg.sender` of the memo'd call. | +| `BurnedBlocked(address indexed caller, address indexed from, uint256 amount)` | `burnBlocked` (deprecated) | In addition to `Transfer(from, address(0), amount)`. | +| `Seized(address indexed caller, address indexed from, address indexed to, uint256 amount)` | `seizeWithMemo` | In addition to `Transfer(from, to, amount)` and `Memo(caller, memo)`. Records a transfer-based seizure. | +| `RoleGranted(bytes32 indexed role, address indexed account, address indexed sender)` | `grantRole` | `account` is granted `role`. `sender` is the originating caller. | +| `RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender)` | `revokeRole`, `renounceRole`, `renounceLastAdmin` | `role` is revoked from `account`. `sender` is the admin bearer (`revokeRole`) or `account` itself (`renounceRole`/`renounceLastAdmin`). | +| `RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole)` | `setRoleAdmin` | The admin role for `role` changes. | +| `LastAdminRenounced(address indexed previousAdmin)` | `renounceLastAdmin` | In addition to the standard `RoleRevoked(DEFAULT_ADMIN_ROLE, previousAdmin, previousAdmin)` event. | +| `Paused(address indexed updater, PausableFeature[] features)` | `pause` | `features` is the call argument (not the resulting paused state). | +| `Unpaused(address indexed updater, PausableFeature[] features)` | `unpause` | `features` is the call argument (not the resulting paused state). | +| `PolicyUpdated(bytes32 indexed policyScope, uint64 oldPolicyId, uint64 newPolicyId)` | `updatePolicy`; also token creation | A token's policy slot changes. Initial slot assignment at creation also emits this with `oldPolicyId == 0`. | +| `SupplyCapUpdated(address indexed updater, uint256 oldSupplyCap, uint256 newSupplyCap)` | `updateSupplyCap` | The supply cap changes. | +| `ContractURIUpdated()` | `updateContractURI` | Parameterless per ERC-7572; integrators re-fetch `contractURI()`. | +| `NameUpdated(address indexed updater, string newName)` | `updateName` | The token name changes. Carries the new name string. | +| `SymbolUpdated(address indexed updater, string newSymbol)` | `updateSymbol` | The token symbol changes. Carries the new symbol string. | +| `EIP712DomainChanged()` | `updateName` | ERC-5267 domain-change signal, emitted exactly once per successful call, immediately after `NameUpdated`. `updateSymbol` does NOT emit this. | + +## [`IB20Asset`](../../src/interfaces/IB20Asset.sol) + +| Event | Emitted by | When | +|---|---|---| +| `MultiplierUpdated(uint256 multiplier)` | `updateMultiplier` (deprecated instant setter) | Deprecated legacy-topic mirror, emitted alongside `UIMultiplierUpdated` so indexers on the old topic keep working.[^1] | +| `UIMultiplierUpdateCancelled(uint256 cancelledMultiplier, uint256 cancelledEffectiveAt)` | `cancelUIMultiplierUpdate`; `updateUIMultiplier` | A scheduled multiplier update is cancelled — explicitly, or implicitly when `updateUIMultiplier` clears a live pending update. | +| `ExtraMetadataUpdated(string key, string value)` | `updateExtraMetadata` | An extra-metadata entry is set, updated, or removed (empty `value` indicates removal). | +| `Announcement(address indexed caller, string id, string description, string uri)` | `announce` | Opens an announcement bracket. | +| `EndAnnouncement(string id)` | `announce` | Closes the bracket opened by the paired `Announcement` with the same `id`. | + +[^1]: The function-level docs show only `updateMultiplier` emitting `MultiplierUpdated`; the scheduled `updateUIMultiplier` emits `UIMultiplierUpdated` only. The event's own doc-comment in source additionally names `updateUIMultiplier` as an emitter of `MultiplierUpdated`, which conflicts with `updateUIMultiplier`'s own `@notice` — flagging here rather than silently picking one. + +## [`IB20Factory`](../../src/interfaces/IB20Factory.sol) + +| Event | Emitted by | When | +|---|---|---| +| `B20Created(address indexed token, B20Variant indexed variant, string name, string symbol, uint8 decimals, bytes variantEventParams)` | `createB20` | Once per invocation, after the token's identity is sealed and before any `initCalls` are dispatched. `variantEventParams` carries variant-specific identity data (empty for ASSET; ABI-encoded `B20StablecoinEventParams` for STABLECOIN). | + +## [`IPolicyRegistry`](../../src/interfaces/IPolicyRegistry.sol) + +| Event | Emitted by | When | +|---|---|---| +| `PolicyCreated(uint64 indexed policyId, address indexed creator, PolicyType policyType)` | `createPolicy`, `createPolicyWithAccounts`, `createCompositePolicy` | A new policy is created. | +| `PolicyAdminStaged(uint64 indexed policyId, address indexed currentAdmin, address indexed pendingAdmin)` | `stageUpdateAdmin` | A new admin is staged. `pendingAdmin == address(0)` clears a prior nomination. | +| `PolicyAdminUpdated(uint64 indexed policyId, address indexed previousAdmin, address indexed newAdmin)` | `finalizeUpdateAdmin`, `renounceAdmin`; also policy creation | The active admin changes. `newAdmin == address(0)` indicates renunciation; `previousAdmin == address(0)` indicates initial assignment at creation. | +| `AllowlistUpdated(uint64 indexed policyId, address indexed updater, bool allowed, address[] accounts)` | `updateAllowlist` | One or more accounts have their ALLOWLIST membership set to `allowed` in a single batch. | +| `BlocklistUpdated(uint64 indexed policyId, address indexed updater, bool blocked, address[] accounts)` | `updateBlocklist` | One or more accounts have their BLOCKLIST membership set to `blocked` in a single batch. | +| `CompositePolicyUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds)` | `createCompositePolicy`, `updateComposite` | A composite policy's child set is set or replaced in full. Emitted on creation and on every subsequent update; carries the complete post-update set. | + +## [`IActivationRegistry`](../../src/interfaces/IActivationRegistry.sol) + +| Event | Emitted by | When | +|---|---|---| +| `FeatureActivated(bytes32 indexed feature, address indexed caller)` | `activate` | `feature` is activated. | +| `FeatureDeactivated(bytes32 indexed feature, address indexed caller)` | `deactivate` | `feature` is deactivated. | + +## [`IERC8056`](../../src/interfaces/IERC8056.sol) (`IScaledUIAmount`) + +| Event | Emitted by | When | +|---|---|---| +| `UIMultiplierUpdated(uint256 oldMultiplier, uint256 newMultiplier, uint256 effectiveAtTimestamp)` | `updateUIMultiplier` (scheduled); `updateMultiplier` (deprecated instant setter) | The UI multiplier is updated — scheduled setters emit this alone; the deprecated instant setter emits this alongside `MultiplierUpdated`. | diff --git a/docs/reference/interfaces.md b/docs/reference/interfaces.md new file mode 100644 index 00000000..de911de2 --- /dev/null +++ b/docs/reference/interfaces.md @@ -0,0 +1,16 @@ +# Interfaces + +*Solidity interfaces for the B20 system and its supporting precompiles.* + +| Interface | Description | +|---|---| +| [`IB20`](../../src/interfaces/IB20.sol) | Core token standard | +| [`IB20Asset`](../../src/interfaces/IB20Asset.sol) | Asset variant of B20 | +| [`IB20Stablecoin`](../../src/interfaces/IB20Stablecoin.sol) | Stablecoin variant of B20 | +| [`IB20Factory`](../../src/interfaces/IB20Factory.sol) | B20 factory precompile | +| [`IPolicyRegistry`](../../src/interfaces/IPolicyRegistry.sol) | Policy registry precompile | +| [`IActivationRegistry`](../../src/interfaces/IActivationRegistry.sol) | Activation registry precompile | +| [`IERC8056`](../../src/interfaces/IERC8056.sol) | Scaled UI Amount standard (Asset variant multiplier) | +| [`IERC165`](../../src/interfaces/IERC165.sol) | Interface detection | + +See [`StdPrecompiles.sol`](../../src/StdPrecompiles.sol) for canonical precompile addresses.