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..f513bd7c --- /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? + +- [Integrator Guide](guides/integrators.md) +- [Indexer Guide](guides/indexers.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..18376ae7 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,142 @@ +# 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 +- Every token comes from one entrypoint on the Factory: `createB20(variant, salt, params, initCalls)`. +- The Factory computes the token's address deterministically, seals its identity (name, symbol, decimals, etc.), emits `B20Created`, grants the initial admin role (or skips it, to create an adminless token), then runs `initCalls` against the new token before returning its 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 +- The address itself encodes the answer: `0xB2` prefix + variant byte + `keccak256(sender, salt)` suffix. `isB20(address)` reads that encoding directly — no registry lookup needed. +- This is also how routing works after creation: since token addresses can't be pre-registered, the node resolves them through a dynamic lookup that decodes the variant straight from the address and dispatches to the right logic (Asset vs Stablecoin) on the fly. +- Before a token is created, its address behaves like any other unregistered address — calling it is a no-op, the same empty-account behavior described in §1.1. + +## 3. How B20 Evolves + +### 3.1 Protocol Upgrades +- B20 changes ship as part of hardforks (e.g. Beryl → Cobalt) — the same mechanism that gates any other protocol-level change. +- A hardfork can introduce an entirely new precompile (the Activation Registry itself only exists from Beryl onward) or a new logic version for an existing one. + +### 3.2 Logic Versions +- Each precompile's logic is versioned. Once a version ships, it's frozen forever — self-contained, with no shared mutable state or traits across versions. +- Why: editing logic in place at a fixed address would change execution for historical blocks too, breaking replay from genesis. Freezing is what preserves consensus. + +### 3.3 Fork / Version Resolution +- A hardfork resolves to a specific logic version (fork → version enum → frozen implementation) — resolved once per call, never "whatever is current." +- A call reverts if no version is resolved for the active fork (calling logic that doesn't exist yet), rather than silently falling back to a default. + +### 3.4 Adding New Functions +- New functionality ships as a new frozen version alongside the old ones — never by editing an existing version in place. +- Additive-only guarantee: a hardfork can add selectors, events, and errors; it never removes or changes ones that already shipped. + +### 3.5 ABI Evolution +- The logic interface itself is append-only: new versions may add methods, never remove or change existing signatures. +- Deprecated symbols are kept, not deleted (e.g. `burnBlocked`, the instant `updateMultiplier`) — old callers keep working unchanged. + +### 3.6 Historical Execution +- Old transactions replay deterministically: the dispatcher resolves the version that was active *at that block's fork*, not "current" logic — so replaying history always re-executes the version that was live at the time. + +### 3.7 Backwards Compatibility +- Nothing that already shipped changes meaning — existing selectors, events, and errors keep their exact semantics across every later fork. +- Consumers integrated against an old version keep working after a new version ships alongside it. They simply don't get new capabilities until they adopt the new ABI surface. diff --git a/docs/concepts/assets.md b/docs/concepts/assets.md new file mode 100644 index 00000000..18c435cb --- /dev/null +++ b/docs/concepts/assets.md @@ -0,0 +1,26 @@ +# Assets + +*The B20 asset model: the core standard and its variants.* + +## The core standard + +_TODO — B20 as an ERC-20 superset. See [`IB20`](../../src/interfaces/IB20.sol)._ + +## Variant family + +_TODO_ + +- **Asset** — the general-purpose variant. See [`IB20Asset`](../../src/interfaces/IB20Asset.sol). +- **Stablecoin** — the fixed-decimals, fiat-backed carveout. See [`IB20Stablecoin`](../../src/interfaces/IB20Stablecoin.sol). + +## Creation + +_TODO — assets are deployed via the singleton [`IB20Factory`](../../src/interfaces/IB20Factory.sol)._ + +## Multiplier (Asset variant) + +_TODO_ + +## Extra metadata (Asset variant) + +_TODO_ diff --git a/docs/concepts/execution.md b/docs/concepts/execution.md new file mode 100644 index 00000000..ad16651f --- /dev/null +++ b/docs/concepts/execution.md @@ -0,0 +1,19 @@ +# Execution Model + +*How a B20 call actually resolves and runs. See [Architecture: Execution model](../architecture.md#execution-model) for where this fits in the full pipeline.* + +## The precompile dispatcher + +_TODO_ + +## Version resolution + +_TODO — see [Versioning](versioning.md)._ + +## Asset logic + +_TODO_ + +## State mutation + +_TODO_ diff --git a/docs/concepts/policies.md b/docs/concepts/policies.md new file mode 100644 index 00000000..2d8a3756 --- /dev/null +++ b/docs/concepts/policies.md @@ -0,0 +1,44 @@ +# Policies + +*The B20 authorization model: policy scopes and their relationship to the PolicyRegistry.* + +## Policy scopes + +Each movement path consults its own scopes. The token stores a policy ID per scope and asks the Policy Registry `isAuthorized(policyId, account)`. + +| Scope | Consulted on | Account | +| --- | --- | --- | +| `TRANSFER_SENDER_POLICY` | `transfer`, `transferFrom` | `from` | +| `TRANSFER_RECEIVER_POLICY` | `transfer`, `transferFrom` | `to` | +| `TRANSFER_EXECUTOR_POLICY` | `transferFrom` only | `msg.sender` | +| `MINT_RECEIVER_POLICY` | `mint` | `to` | +| `SEIZE_HOLDER_POLICY` | `seizeWithMemo` | `from` (seizable when unauthorized) | +| `SEIZE_RECEIVER_POLICY` | `seizeWithMemo` | `to` | + +A denied check reverts with `PolicyForbids`. `approve` is not policy-gated. + +## The PolicyRegistry + +_TODO — see [`IPolicyRegistry`](../../src/interfaces/IPolicyRegistry.sol)._ + +## Defaults + +_TODO — every scope defaults to `ALWAYS_ALLOW` at token creation unless overridden._ + +## Configuring policies + +_TODO_ + + + +# Policies + +- What is a policy? +- What problem does it solve? +- Policy IDs +- Policy scopes +- Allowlist vs blocklist +- Composite policies +- How policies are shared +- How policy administration works +- Examples \ No newline at end of file diff --git a/docs/concepts/roles.md b/docs/concepts/roles.md new file mode 100644 index 00000000..e0c6b296 --- /dev/null +++ b/docs/concepts/roles.md @@ -0,0 +1,29 @@ +# Roles + +*The B20 role-based access control model.* + +## Role taxonomy + +B20 follows [OpenZeppelin AccessControl](https://docs.openzeppelin.com/contracts/5.x/access-control) with a fixed set of built-in roles. The admin uses `grantRole`, `revokeRole`, `renounceRole`, and `setRoleAdmin`. See [`B20Constants`](../../src/lib/B20Constants.sol) for the identifier values. + +| Role | Gates | +| --- | --- | +| `DEFAULT_ADMIN_ROLE` | `grantRole`, `revokeRole`, `setRoleAdmin`, `updatePolicy`, `updateSupplyCap` | +| `MINT_ROLE` | `mint`, `mintWithMemo` | +| `BURN_ROLE` | `burn`, `burnWithMemo` | +| `BURN_BLOCKED_ROLE` | `burnBlocked` (deprecated) | +| `SEIZE_ROLE` | `seizeWithMemo` | +| `PAUSE_ROLE` | `pause` | +| `UNPAUSE_ROLE` | `unpause` | +| `METADATA_ROLE` | `updateName`, `updateSymbol`, `updateContractURI` | +| `OPERATOR_ROLE` | Asset-only: `announce`, multiplier updates | + +Pause is per feature, not global. `PAUSE_ROLE` can pause any of `TRANSFER`, `MINT`, `BURN`, and `SEIZE`. `UNPAUSE_ROLE` is a separate role. `approve` is not pause-gated. Holder `transfer` is not role-gated. + +## Custom roles + +_TODO — `setRoleAdmin` / `grantRole` for user-defined roles._ + +## Admin lifecycle + +_TODO — `renounceLastAdmin` and the admin-less end state._ diff --git a/docs/concepts/versioning.md b/docs/concepts/versioning.md new file mode 100644 index 00000000..711a3144 --- /dev/null +++ b/docs/concepts/versioning.md @@ -0,0 +1,19 @@ +# Versioning + +*How B20 evolves across hardforks, and what that means for callers.* + +## The frozen-vN pattern + +_TODO — each hardfork version's logic is frozen once shipped; the dispatcher resolves calls to the correct version._ + +## What can change between versions + +_TODO_ + +## What's guaranteed to stay stable + +_TODO — ABI/selector stability guarantees for existing integrations._ + +## Where to check the current version + +_TODO — see [`CHANGELOG.md`](../../CHANGELOG.md) and the [changelog index's hardfork ordinals](../../changelog/README.md#hardfork-ordinals)._ diff --git a/docs/guides/indexers.md b/docs/guides/indexers.md new file mode 100644 index 00000000..c28ecd77 --- /dev/null +++ b/docs/guides/indexers.md @@ -0,0 +1,27 @@ +# Indexer Guide + +*Now that you understand B20 (see [Architecture](../architecture.md)), here's what you specifically need to care about as someone indexing B20 activity.* + +## Discovery + +_TODO — how to discover B20 assets on-chain._ + +## Event guarantees + +_TODO — see [Events reference](../reference/events.md) for the exhaustive list._ + +## State reconstruction + +_TODO_ + +## Reorgs and ordering + +_TODO_ + +## Fork boundaries + +_TODO — see [Versioning](../concepts/versioning.md) for the underlying model._ + +## Schema / version compatibility + +_TODO — see the [changelog index's hardfork ordinals](../../changelog/README.md#hardfork-ordinals)._ diff --git a/docs/guides/integrators.md b/docs/guides/integrators.md new file mode 100644 index 00000000..ab818aad --- /dev/null +++ b/docs/guides/integrators.md @@ -0,0 +1,31 @@ +# Integrator Guide + +*Now that you understand B20 (see [Architecture](../architecture.md)), here's what you specifically need to care about as someone calling B20 from an application.* + +## Quickstart + +_TODO_ + +## Calling B20 + +_TODO_ + +## Reading assets + +_TODO — see [Assets](../concepts/assets.md) for the underlying model._ + +## Submitting transactions + +_TODO_ + +## Policies and roles you'll run into + +_TODO — see [Policies](../concepts/policies.md) and [Roles](../concepts/roles.md) for the underlying model._ + +## Handling failures + +_TODO — see [Errors reference](../reference/errors.md) for the exhaustive list._ + +## Compatibility across versions + +_TODO — see [Versioning](../concepts/versioning.md) for the underlying model._ 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..5641bde1 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,199 @@ +# 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 [Assets](./concepts/assets.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: + +→ [Integrator Guide](./guides/integrators.md) + +If you are indexing B20: + +→ [Indexer Guide](./guides/indexers.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.