> ## Documentation Index
> Fetch the complete documentation index at: https://base-a060aa97-docs-overhaul.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Specifications

> Complete specification for B20 — Base's native ERC-20 superset with built-in compliance, transfer policies, role-based access control, memos, and supply caps.

## 1. Abstract

B20 is the Base ecosystem's native token standard — an [ERC-20](https://eips.ethereum.org/EIPS/eip-20) superset implemented as Rust precompiles rather than EVM smart contracts. It ships with a built-in compliance toolkit: transfer policies, freeze-and-seize, role-based access control, memos, and supply caps. All tokens are deployed via the singleton B20Factory precompile.

The full interface definitions are available in the [Base Standard Library](https://github.com/base/base-std/tree/main) repository. To deploy your first token, see the [Launch a B20 token](/get-started/launch-b20-token) quickstart.

<Warning>
  [Verify the Activation Registry is enabled](/get-started/launch-b20-token#verify-the-activation-registry-is-enabled) before attempting to deploy.
</Warning>

## 2. ERC-20 Compatibility

B20 is a strict superset of ERC-20. Every ERC-20 call (`transfer`, `transferFrom`, `approve`, `balanceOf`, `allowance`, and the standard events) behaves exactly as the standard specifies, so existing ERC-20 tooling and integrations work against B20 with no changes.

B20 adds methods that ERC-20 does not include: memos, mint/burn, policy gating, granular pause, and ERC-2612 `permit`. These extend ERC-20 without altering it — every ERC-20 method exists on B20, but the reverse does not hold.

Because B20 runs as a native precompile, tokens are cheaper and higher-throughput than contract-based tokens while maintaining full ERC-20 wire compatibility.

## 3. Variants

B20 supports two token variants. Each shares the full base surface (ERC-20, roles, policies, pause, memos, permit) and adds capabilities specific to its use case.

| Variant        | Address byte | Decimals                       | Unique features                                                                       |
| -------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------------- |
| **Asset**      | `0x00`       | 6–18 (configurable, immutable) | Rebase multiplier, onchain announcements, batch mint, extra metadata, `OPERATOR_ROLE` |
| **Stablecoin** | `0x01`       | 6 (fixed)                      | Self-declared fiat currency code                                                      |

The variant byte is encoded directly in the [token address](#82-address-derivation) at byte 10, making it identifiable without state queries.

A single token standard cannot serve all use cases equally. Equities need stock splits (multiplier), SEC-style disclosure brackets (announcements), and CUSIP/ISIN storage (extra metadata). Stablecoins need a fixed decimal convention matching fiat (6 decimals) and an on-chain currency code for automated classification. Two concrete variants let each type carry only what it needs.

See [Asset variant](#10-asset-variant) and [Stablecoin variant](#11-stablecoin-variant) for each variant's unique surface.

## 4. Policy Registry

The Policy Registry is a singleton precompile that manages allowlists and blocklists. B20 tokens reference policies by `uint64` ID — they do not store membership data themselves. Any caller can create a policy and nominate its admin, decoupling policy management from token issuance.

Centralizing list management in a shared precompile means one policy can gate multiple tokens, and one compliance team can manage one blocklist that applies across their entire portfolio.

<Note>
  State-changing functions on the Policy Registry are gated by the Activation Registry, which tracks which Base features are live. Read functions (`isAuthorized`, `policyExists`, `policyAdmin`, `pendingPolicyAdmin`) are always callable.
</Note>

### 4.1. Policy Types

| Type        | Enum value | Default authorization | Behavior                                                                  |
| ----------- | ---------- | --------------------- | ------------------------------------------------------------------------- |
| `BLOCKLIST` | `0`        | Authorized            | All accounts authorized by default; explicitly listed accounts are denied |
| `ALLOWLIST` | `1`        | Denied                | All accounts denied by default; explicitly listed accounts are authorized |

### 4.2. Policy IDs

Policy IDs are `uint64` values. The top byte encodes the `PolicyType`; the low 56 bits are a global counter starting at `2`.

```text theme={null}
[top 8 bits: PolicyType][low 56 bits: counter]
```

Two built-in IDs require no creation:

| Constant       | ID                               | Behavior                                                                         |
| -------------- | -------------------------------- | -------------------------------------------------------------------------------- |
| `ALWAYS_ALLOW` | `0`                              | Authorizes every account unconditionally. Default scope value on new B20 tokens. |
| `ALWAYS_BLOCK` | `(uint64(ALLOWLIST) << 56) \| 1` | Denies every account unconditionally.                                            |

### 4.3. `isAuthorized` Semantics

`isAuthorized(policyId, account)` never reverts, even on a non-existent policy ID. Non-existent policies collapse to empty-member-set semantics:

| Policy state               | Type                       | Result                                             |
| -------------------------- | -------------------------- | -------------------------------------------------- |
| Exists, account listed     | `BLOCKLIST`                | `false` (denied)                                   |
| Exists, account not listed | `BLOCKLIST`                | `true` (authorized)                                |
| Exists, account listed     | `ALLOWLIST`                | `true` (authorized)                                |
| Exists, account not listed | `ALLOWLIST`                | `false` (denied)                                   |
| Does not exist             | `BLOCKLIST` (by ID prefix) | `true` (authorizes everyone — no members to block) |
| Does not exist             | `ALLOWLIST` (by ID prefix) | `false` (denies everyone — no members to allow)    |

<Warning>
  Consumers that write a policy ID (e.g. `updatePolicy`) MUST validate `policyExists(policyId)` at write time to avoid silently binding to an unintended empty-set policy.
</Warning>

### 4.4. Creating and Managing Policies

```solidity theme={null}
// Create a blocklist policy with an admin
uint64 policyId = policyRegistry.createPolicy(adminAddress, PolicyType.BLOCKLIST);

// Or seed the initial member set in one call
uint64 policyId = policyRegistry.createPolicyWithAccounts(
    adminAddress, PolicyType.BLOCKLIST, accounts
);
```

Membership is updated via type-specific methods. The `bool` parameter sets the membership state.

```solidity theme={null}
// Blocklist operations (batched)
policyRegistry.updateBlocklist(policyId, true, accounts);   // block these accounts
policyRegistry.updateBlocklist(policyId, false, accounts);  // unblock these accounts

// Allowlist operations (batched)
policyRegistry.updateAllowlist(policyId, true, accounts);   // allow these accounts
policyRegistry.updateAllowlist(policyId, false, accounts);  // deny these accounts
```

### 4.5. Admin Model

Each policy has exactly one admin. Admin transfers use a two-step pattern to prevent accidental transfers to wrong addresses:

1. Current admin calls `stageUpdateAdmin(policyId, newAdmin)`
2. Pending admin calls `finalizeUpdateAdmin(policyId)`

`renounceAdmin(policyId)` permanently freezes the policy — membership can never be changed again. This is irreversible. There is no `grantAdmin` or path to restore an admin after renouncement.

### 4.6. Read Interface

| Method                            | Returns                                          | Reverts? |
| --------------------------------- | ------------------------------------------------ | -------- |
| `isAuthorized(policyId, account)` | Whether `account` is authorized under `policyId` | Never    |
| `policyExists(policyId)`          | Whether a policy with this ID has been created   | Never    |
| `policyAdmin(policyId)`           | Current admin address                            | Never    |
| `pendingPolicyAdmin(policyId)`    | Pending admin during a two-step transfer         | Never    |

## 5. Roles and Access Control

B20 uses a fixed set of roles to gate privileged operations. The model extends OpenZeppelin `AccessControl` with one behavioral override: the last admin cannot be removed through standard revocation.

Token issuers need fine-grained control over who can mint, burn, pause, and configure a token. A single "owner" pattern is too coarse — an issuer may want their treasury to mint while a separate compliance team manages pauses, without either party having the other's privileges. The fixed role set ensures every B20 token has the same permission surface, making tooling, audits, and integrations predictable across the ecosystem.

### 5.1. Base Roles

| Role           | Constant             | Gates                                                                             |
| -------------- | -------------------- | --------------------------------------------------------------------------------- |
| Default admin  | `DEFAULT_ADMIN_ROLE` | All admin operations: role grants/revocations, policy updates, supply-cap changes |
| Minter         | `MINT_ROLE`          | `mint`, `mintWithMemo`                                                            |
| Burner         | `BURN_ROLE`          | Caller-side burns: `burn`, `burnWithMemo`                                         |
| Blocked burner | `BURN_BLOCKED_ROLE`  | Third-party burns against policy-blocked accounts: `burnBlocked`                  |
| Pauser         | `PAUSE_ROLE`         | `pause`                                                                           |
| Unpauser       | `UNPAUSE_ROLE`       | `unpause`                                                                         |
| Metadata       | `METADATA_ROLE`      | `updateName`, `updateSymbol`, `updateContractURI`                                 |
| Operator       | `OPERATOR_ROLE`      | Asset variant only: `updateMultiplier`, `announce`                                |

The first seven roles exist on all B20 tokens. `OPERATOR_ROLE` is exclusive to the Asset variant.

### 5.2. Custom Roles

User-defined roles are supported via `setRoleAdmin` and `grantRole`. They carry no built-in effect — B20 only enforces gates against the eight base-surface roles listed above. Custom roles can be used by external contracts that call `hasRole` to implement additional access patterns.

### 5.3. Admin Renunciation

The last `DEFAULT_ADMIN_ROLE` holder cannot be removed via `renounceRole` or `revokeRole` (both revert with `LastAdminCannotRenounce`). The dedicated `renounceLastAdmin()` is the only path to permanently transition a token to admin-less operation.

```solidity theme={null}
// Remove the last admin — irreversible
token.renounceLastAdmin();
```

Tokens that 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 already granted to other addresses (`MINT_ROLE`, `BURN_ROLE`, etc.) continue to function independently
* Admin resurrection is blocked: `grantRole`, `revokeRole`, and `setRoleAdmin` all revert with `AccessControlUnauthorizedAccount`
* The token's policies, supply cap, and metadata become immutable

OpenZeppelin's standard `AccessControl` allows the last admin to `renounceRole` themselves, which is a footgun. B20 blocks this path and requires `renounceLastAdmin()` — a function that does nothing else and whose name makes the consequence unmistakable.

### 5.4. Pause and Unpause Role Separation

`PAUSE_ROLE` and `UNPAUSE_ROLE` are intentionally separate. This enables security architectures where a monitoring bot can emergency-pause on anomaly detection without having the power to unpause. Only governance (a multisig, timelock, or higher-authority key) can restore operations — preventing a compromised pauser from toggling pause to mask an attack.

## 6. Transfer Policies

B20 declares a fixed set of policy scopes that reference the [Policy Registry](#4-policy-registry). Each scope stores a `uint64` policy ID. On every gated operation, B20 calls `isAuthorized` against the relevant scope and reverts if the account is not authorized.

A bare ERC-20 `transfer` moves tokens between any two addresses with no concept of authorized parties. Regulated issuers need to enforce KYC/AML, sanctions compliance, and jurisdiction restrictions at the transfer level. B20 externalizes these rules to the Policy Registry and connects token operations to policies via scopes — compliance rules can be updated without modifying the token, multiple tokens can share the same policy, and different aspects of a transfer can be gated by different policies.

### 6.1. Scopes

| Scope                      | Checked account    | Operations                                                 |
| -------------------------- | ------------------ | ---------------------------------------------------------- |
| `TRANSFER_SENDER_POLICY`   | The `from` address | `transfer`, `transferFrom`                                 |
| `TRANSFER_RECEIVER_POLICY` | The `to` address   | `transfer`, `transferFrom`                                 |
| `TRANSFER_EXECUTOR_POLICY` | The `msg.sender`   | `transferFrom` only, when `msg.sender` differs from `from` |
| `MINT_RECEIVER_POLICY`     | The `to` address   | `mint`, `mintWithMemo`                                     |

### 6.2. Evaluation Order

For a `transferFrom(from, to, amount)` call where `msg.sender != from`:

1. Check `TRANSFER_SENDER_POLICY` against `from`
2. Check `TRANSFER_RECEIVER_POLICY` against `to`
3. Check `TRANSFER_EXECUTOR_POLICY` against `msg.sender`

If any check fails, the transaction reverts with `PolicyForbids`.

For a `transfer(to, amount)` call, only `TRANSFER_SENDER_POLICY` (against `msg.sender`) and `TRANSFER_RECEIVER_POLICY` (against `to`) are checked. `TRANSFER_EXECUTOR_POLICY` is not checked because the sender and executor are the same account.

### 6.3. Approve Exemption

`approve` is not policy-gated. Only actual balance movement via `transfer` / `transferFrom` is checked. An account on a blocklist can approve a spender, but the spender's `transferFrom` will revert when the sender policy check fails. Gating `approve` would add no security value since the transfer itself is the enforcement point.

### 6.4. Default Values

<Warning>
  Every scope defaults to `ALWAYS_ALLOW` at token creation unless overridden in the bootstrap `initCalls`. An unattended B20 deployment is fully open — token behavior must be intentionally constrained.
</Warning>

### 6.5. Reading and Writing Scopes

```solidity theme={null}
// Read the current policy ID for a scope
uint64 currentPolicy = token.policyId(TRANSFER_SENDER_POLICY);

// Update a scope to a new policy (requires DEFAULT_ADMIN_ROLE)
token.updatePolicy(TRANSFER_SENDER_POLICY, newPolicyId);
```

### 6.6. initCalls Bypass

During token creation via the [Factory](#8-factory-and-deployment), `initCalls` bypass the three transfer-side policy scopes:

| Scope                      | Bypassed during initCalls? |
| -------------------------- | -------------------------- |
| `TRANSFER_SENDER_POLICY`   | Yes                        |
| `TRANSFER_RECEIVER_POLICY` | Yes                        |
| `TRANSFER_EXECUTOR_POLICY` | Yes                        |
| `MINT_RECEIVER_POLICY`     | **No — always enforced**   |

This allows bootstrap transfers (e.g., initial distribution) in the same transaction as deployment, while still ensuring mint recipients pass policy checks from the start. `MINT_RECEIVER_POLICY` is never bypassed because mint creates new exposure — a compromised minter that bypasses receiver checks during init could distribute tokens to sanctioned addresses before the compliance program is configured.

## 7. Mint and Supply Cap

New token supply is created via `mint` and `mintWithMemo`, gated by `MINT_ROLE`. Every mint checks the recipient against `MINT_RECEIVER_POLICY`. An optional supply cap bounds `totalSupply`.

Token issuance is the most consequential supply-side operation. Three controls are necessary: role gating prevents unauthorized inflation, receiver policy ensures newly minted tokens only reach verified recipients (mint is the first moment a token enters an account — there is no prior transfer to gate), and the supply cap provides a hard ceiling that no minter can exceed.

### 7.1. Mint Methods

```solidity theme={null}
// Mint tokens to a recipient (requires MINT_ROLE)
token.mint(to, amount);

// Mint with an attached memo for off-chain reference
token.mintWithMemo(to, amount, memo);
```

Both methods require the caller to hold `MINT_ROLE`, check `to` against `MINT_RECEIVER_POLICY`, check that `totalSupply + amount` does not exceed the supply cap, and revert if the `MINT` pausable feature is paused.

### 7.2. Supply Cap

The supply cap is a `uint128` upper bound on `totalSupply`.

| Value                      | Meaning                                        |
| -------------------------- | ---------------------------------------------- |
| `type(uint128).max`        | No practical cap (the default at creation)     |
| Any value `>= totalSupply` | Active cap — mints that would exceed it revert |

```solidity theme={null}
// Read the current cap
uint128 cap = token.supplyCap();

// Update the cap (requires DEFAULT_ADMIN_ROLE)
token.updateSupplyCap(newCap);
```

`updateSupplyCap` constraints:

* `newCap` must be `>= totalSupply` (cannot set a cap below current supply)
* `newCap` must be `<= type(uint128).max` (the sentinel is also the maximum)
* Emits `SupplyCapUpdated(uint128 oldCap, uint128 newCap)`

Burns reduce `totalSupply`, creating headroom under the cap without requiring admin cap adjustment.

ERC-20 uses `uint256` for balances, but `uint128` (max \~340 undecillion) exceeds any realistic token supply. Using `uint128` for the cap allows the sentinel value to represent "no cap" without consuming the full `uint256` range.

## 8. Factory and Deployment

All B20 tokens are created through the singleton B20Factory precompile. Token addresses are deterministic and encode the variant directly, allowing off-chain identification without RPC calls. The factory supports `initCalls` — a bootstrap window where admin-gated configuration and initial transfers can execute in the same transaction as deployment.

### 8.1. createB20

```solidity theme={null}
address token = b20Factory.createB20(variant, salt, params, initCalls);
```

| Parameter   | Type         | Description                                                               |
| ----------- | ------------ | ------------------------------------------------------------------------- |
| `variant`   | `B20Variant` | `ASSET` or `STABLECOIN`                                                   |
| `salt`      | `bytes32`    | Caller-chosen entropy for deterministic address derivation                |
| `params`    | `bytes`      | ABI-encoded, variant-specific creation struct (versioned by leading byte) |
| `initCalls` | `bytes[]`    | Optional array of ABI-encoded calls dispatched post-creation              |

The factory is exposed in `base-std` as `StdPrecompiles.B20_FACTORY`. `createB20` reverts with `IActivationRegistry.FeatureNotActivated` if the requested variant's feature is not yet activated.

**Asset params:**

| Field          | Type      | Description                                                      |
| -------------- | --------- | ---------------------------------------------------------------- |
| `name`         | `string`  | Token name                                                       |
| `symbol`       | `string`  | Token symbol                                                     |
| `decimals`     | `uint8`   | 6–18 (configurable, immutable after creation)                    |
| `initialAdmin` | `address` | Initial `DEFAULT_ADMIN_ROLE` holder. `address(0)` for admin-less |
| `contractURI`  | `string`  | ERC-7572 metadata URI                                            |

**Stablecoin params:**

| Field          | Type      | Description                                                      |
| -------------- | --------- | ---------------------------------------------------------------- |
| `name`         | `string`  | Token name                                                       |
| `symbol`       | `string`  | Token symbol                                                     |
| `currency`     | `string`  | ISO-style currency code (`A`–`Z` only, self-declared)            |
| `initialAdmin` | `address` | Initial `DEFAULT_ADMIN_ROLE` holder. `address(0)` for admin-less |
| `contractURI`  | `string`  | ERC-7572 metadata URI                                            |

### 8.2. Address Derivation

B20 addresses are deterministic and encode the variant directly:

```text theme={null}
[10-byte B20 prefix][1-byte variant][9-byte keccak256(deployer, salt)]
```

The variant is recoverable from the address alone — inspect byte 10 (zero-indexed) to identify the token type without an RPC call.

| Method                                   | Description                                                                      |
| ---------------------------------------- | -------------------------------------------------------------------------------- |
| `getB20Address(variant, deployer, salt)` | Compute the deterministic address without deploying                              |
| `isB20(addr)`                            | Whether the address has the B20 prefix (true even if no token is deployed there) |
| `isB20Initialized(addr)`                 | Whether a B20 token has been deployed at this address                            |

### 8.3. initCalls Semantics

`initCalls` are dispatched after token creation in the same transaction. During this bootstrap window, factory-originated calls receive special bypass privileges:

| Gate                                    | Bypassed?                |
| --------------------------------------- | ------------------------ |
| Role gates (`DEFAULT_ADMIN_ROLE`, etc.) | **Yes**                  |
| `TRANSFER_SENDER_POLICY`                | **Yes**                  |
| `TRANSFER_RECEIVER_POLICY`              | **Yes**                  |
| `TRANSFER_EXECUTOR_POLICY`              | **Yes**                  |
| `MINT_RECEIVER_POLICY`                  | **No — always enforced** |
| Pause state                             | **No — always enforced** |
| Token invariants (supply cap, etc.)     | **No — always enforced** |

Typical `initCalls` sequence:

```solidity theme={null}
bytes[] memory initCalls = new bytes[](4);

// 1. Set up compliance policies
initCalls[0] = abi.encodeCall(IB20.updatePolicy, (TRANSFER_SENDER_POLICY, senderBlocklistId));
initCalls[1] = abi.encodeCall(IB20.updatePolicy, (TRANSFER_RECEIVER_POLICY, receiverAllowlistId));

// 2. Grant operational roles
initCalls[2] = abi.encodeCall(IAccessControl.grantRole, (MINT_ROLE, treasuryAddress));

// 3. Set supply cap
initCalls[3] = abi.encodeCall(IB20.updateSupplyCap, (1_000_000e6));
```

A revert in any `initCall` reverts the entire deployment. Each `(deployer, variant, salt)` tuple produces exactly one address — deploying the same tuple twice reverts.

## 9. Burn and Seize

B20 provides two distinct burn paths. Standard `burn` lets authorized callers destroy tokens from their own balance. `burnBlocked` lets authorized callers destroy tokens from a third party's balance — but only if that account is already denied by `TRANSFER_SENDER_POLICY`.

Unrestricted third-party burns would allow anyone with burn capability to destroy anyone's tokens. B20 mitigates this by requiring that the target account be frozen first. This ensures seizure is a two-step, auditable process: freeze first, seize second.

### 9.1. Self-Burn

```solidity theme={null}
// Burn from own balance (requires BURN_ROLE)
token.burn(amount);

// Burn with an attached memo
token.burnWithMemo(amount, memo);
```

Requires caller to hold `BURN_ROLE`. Burns from `msg.sender`'s balance. Reverts if the `BURN` pausable feature is paused.

### 9.2. Seize (burnBlocked)

```solidity theme={null}
// Burn from a third party's frozen account (requires BURN_BLOCKED_ROLE)
token.burnBlocked(account, amount);
```

Requires caller to hold `BURN_BLOCKED_ROLE`. The target `account` **must** be denied by `TRANSFER_SENDER_POLICY` — if the target is not frozen, the call reverts. `BURN_BLOCKED_ROLE` is separate from `BURN_ROLE` — combining both in one role would mean every party authorized to redeem could also seize.

### 9.3. Freeze-and-Seize Workflow

```solidity theme={null}
// Step 1: Freeze the account (add to sender blocklist)
policyRegistry.updateBlocklist(senderPolicyId, true, [targetAccount]);
// Target can no longer send tokens

// Step 2: Seize the balance (requires BURN_BLOCKED_ROLE)
uint256 frozenBalance = token.balanceOf(targetAccount);
token.burnBlocked(targetAccount, frozenBalance);
// Tokens destroyed, totalSupply decreases

// Optional Step 3: Re-mint to a recovery address if required
token.mint(recoveryAddress, frozenBalance);
```

The seize path burns tokens rather than transferring them. If tokens need to be redirected, the issuer mints new tokens to the recovery address in a separate call — keeping each primitive simple and auditable.

## 10. Asset Variant

The general-purpose variant for tokenized equities, real-world assets, and long-tail tokens. In addition to the full B20 base surface, Asset tokens add several capabilities gated by `OPERATOR_ROLE` and existing roles.

### 10.1. Multiplier

A WAD-precision (1e18) rebase multiplier applied to all balance reads. Raw balances are stored unchanged; the multiplier scales the view returned to callers.

```solidity theme={null}
// Read the current multiplier (WAD precision, default 1e18)
uint256 m = token.multiplier();

// Update the multiplier (requires OPERATOR_ROLE)
token.updateMultiplier(2e18);  // 2x — doubles all visible balances
```

| Method                            | Description                                     |
| --------------------------------- | ----------------------------------------------- |
| `multiplier()`                    | Current WAD-precision multiplier                |
| `scaledBalanceOf(account)`        | Raw balance × multiplier                        |
| `toScaledBalance(raw)`            | Convert raw amount to scaled                    |
| `toRawBalance(scaled)`            | Convert scaled amount to raw                    |
| `updateMultiplier(newMultiplier)` | Update the multiplier. Gated by `OPERATOR_ROLE` |

`balanceOf` returns the scaled balance (raw × multiplier). `transfer` and `transferFrom` operate on scaled amounts. A multiplier update changes every holder's visible balance simultaneously — this is a stock-split-equivalent operation.

<Warning>
  Multiplier updates should be wrapped in an `announce()` call for transparency.
</Warning>

### 10.2. Announcements

On-chain disclosure brackets that wrap sensitive operations with a public notice. Announcements create an auditable record that an operation was intentional and pre-disclosed.

```solidity theme={null}
// Wrap operations in an announcement (requires OPERATOR_ROLE)
token.announce(
    internalCalls,  // ABI-encoded calls to execute inside the bracket
    id,             // Unique identifier (enforced forever)
    description,    // Human-readable description
    uri             // Link to off-chain disclosure document
);
```

Event sequence: `Announcement(id, description, uri)` emitted → `internalCalls` executed in order → `EndAnnouncement(id)` emitted.

The `id` must be unique across the token's lifetime — reuse reverts with `DuplicateAnnouncementId`. Permanent uniqueness ensures every announcement is independently verifiable and tamper-evident. Inner call reverts are wrapped in `InternalCallFailed`.

### 10.3. Batch Mint

```solidity theme={null}
// Mint to multiple recipients in one call (requires MINT_ROLE)
token.batchMint(recipients, amounts);
```

Applies the same gates as individual `mint` calls: `MINT_ROLE` required, `MINT_RECEIVER_POLICY` checked for each recipient, supply cap enforced across the total.

### 10.4. Extra Metadata

An arbitrary key/value store for issuer-defined on-chain metadata (e.g., CUSIP, ISIN, or custom identifiers).

```solidity theme={null}
// Read a metadata value
string memory value = token.extraMetadata("CUSIP");

// Write a metadata value (requires METADATA_ROLE)
token.updateExtraMetadata("CUSIP", "037833100");

// Remove a metadata entry (set empty value)
token.updateExtraMetadata("CUSIP", "");
```

## 11. Stablecoin Variant

The fixed-decimals, fiat-backed variant. Decimals are hard-wired to `6` and not configurable. This matches the precision of most fiat currencies and ensures consistency across all B20 stablecoins.

### 11.1. Currency Code

```solidity theme={null}
// Read the self-declared currency code
string memory code = token.currency();  // e.g., "USD", "EUR"
```

| Property      | Value                                                      |
| ------------- | ---------------------------------------------------------- |
| Set at        | Creation via `B20StablecoinCreateParams.currency`          |
| Mutability    | Immutable after creation                                   |
| Character set | `A`–`Z` only (uppercase ASCII letters)                     |
| Validation    | Not verified against any external registry — self-declared |

<Note>
  The currency code is a self-declaration by the issuer. It is not verified against ISO 4217 or any other standard. Integrators should treat it as a label, not a guarantee of peg or backing.
</Note>

## 12. Pause

B20 pause is granular: three independently controllable features can be paused and unpaused individually. `PAUSE_ROLE` and `UNPAUSE_ROLE` are separate roles.

If a vulnerability is discovered in the burn path, a global pause would halt all transfers — freezing every holder's liquidity to address a problem in an unrelated operation. Granular pause lets issuers halt only the affected operation.

### 12.1. Pausable Features

| Feature    | Enum value | Operations affected                                                    |
| ---------- | ---------- | ---------------------------------------------------------------------- |
| `TRANSFER` | `0`        | `transfer`, `transferFrom`, `transferWithMemo`, `transferFromWithMemo` |
| `MINT`     | `1`        | `mint`, `mintWithMemo`, `batchMint` (Asset variant)                    |
| `BURN`     | `2`        | `burn`, `burnWithMemo`, `burnBlocked`                                  |

The `PausableFeature` enum is append-only — new features may be added in future upgrades but existing values are never changed or removed.

```solidity theme={null}
// Pause one or more features (requires PAUSE_ROLE)
token.pause([PausableFeature.TRANSFER, PausableFeature.MINT]);

// Unpause one or more features (requires UNPAUSE_ROLE)
token.unpause([PausableFeature.TRANSFER]);

// Check if a specific feature is paused
bool isPaused = token.isPaused(PausableFeature.TRANSFER);
```

### 12.2. Feature Interaction Matrix

| Paused feature | `transfer` | `mint`  | `burn`  | `burnBlocked` | `approve` | `permit` |
| -------------- | ---------- | ------- | ------- | ------------- | --------- | -------- |
| `TRANSFER`     | Reverts    | OK      | OK      | OK            | OK        | OK       |
| `MINT`         | OK         | Reverts | OK      | OK            | OK        | OK       |
| `BURN`         | OK         | OK      | Reverts | Reverts       | OK        | OK       |
| All three      | Reverts    | Reverts | Reverts | Reverts       | OK        | OK       |

`approve` and `permit` are never affected by pause state. Pause state is never bypassed during factory `initCalls`. Pausing an already-paused feature is a no-op (does not revert).

## 13. Memos

A memo is an optional `bytes32` payload attached to a token operation for off-chain reference. On-chain token operations often correspond to off-chain events — invoice payments, redemption references, compliance case IDs. Memos embed the reference directly in the transaction, making the association immutable and auditable.

### 13.1. Memo Event

```solidity theme={null}
event Memo(address indexed caller, bytes32 indexed memo);
```

The `Memo` event is emitted immediately after the parent operation's primary event. Both fields are `indexed` for efficient log filtering.

### 13.2. Memo-Emitting Entrypoints

| Method                                         | Parent event                        | Memo position            |
| ---------------------------------------------- | ----------------------------------- | ------------------------ |
| `transferWithMemo(to, amount, memo)`           | `Transfer(from, to, value)`         | `logIndex(Transfer) + 1` |
| `transferFromWithMemo(from, to, amount, memo)` | `Transfer(from, to, value)`         | `logIndex(Transfer) + 1` |
| `mintWithMemo(to, amount, memo)`               | `Transfer(address(0), to, value)`   | `logIndex(Transfer) + 1` |
| `burnWithMemo(amount, memo)`                   | `Transfer(from, address(0), value)` | `logIndex(Transfer) + 1` |

Each memo method behaves identically to its non-memo counterpart — same role gates, same policy checks, same pause enforcement.

### 13.3. Indexer Join Pattern

To associate a `Memo` event with its parent operation:

```text theme={null}
Join key: (transactionHash, logIndex - 1)
```

Given a `Memo` event at log index `N`, the parent `Transfer` event is at log index `N - 1` in the same transaction. This relationship is guaranteed by the B20 precompile's event emission ordering.

```javascript title="indexer-example.js" theme={null}
const memoEvent = logs.find(l => l.topics[0] === MEMO_SIGNATURE);
const parentTransfer = logs.find(
  l => l.logIndex === memoEvent.logIndex - 1
    && l.transactionHash === memoEvent.transactionHash
);
```

Passing `bytes32(0)` is valid — the `Memo` event is still emitted. If no memo is needed, use the non-memo method variant instead to save gas.

The `bytes32` size is deliberately constrained: large enough for UUIDs, hashes, and encoded identifiers, but small enough to discourage storing data that should live off-chain. Separate `*WithMemo` methods preserve the standard ERC-20 interface while extending it.

## 14. Permit (ERC-2612)

B20 implements [ERC-2612](https://eips.ethereum.org/EIPS/eip-2612) signed approvals using an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed-data domain. Token holders can authorize spending via off-chain signatures, enabling gasless approval flows where a relayer submits the permit transaction.

### 14.1. EIP-712 Domain

```solidity theme={null}
EIP712Domain(
    string name,      // token's current name()
    string version,   // fixed at "1"
    uint256 chainId,  // block.chainid
    address verifyingContract  // token address
)
```

### 14.2. permit

```solidity theme={null}
function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external;
```

On success, sets `allowance(owner, spender) = value` and emits `Approval(owner, spender, value)`. Each account has a monotonically increasing nonce — each successful `permit` call increments the owner's nonce, preventing signature replay.

### 14.3. Domain Separator Rotation

When `updateName(newName)` is called, the EIP-712 domain separator is recomputed with the new name and `EIP712DomainChanged` (ERC-5267) is emitted. Permits signed with the old name become invalid. Integrations that cache the domain separator must re-fetch it after observing this event.

### 14.4. Signature Constraints

* **ECDSA only** — `ecrecover` is used for verification. ERC-1271 contract signatures (smart wallet signatures) are not accepted. Supporting contract signatures would require calling into arbitrary contracts during permit verification, introducing reentrancy and gas-estimation complexity in the precompile environment.
* **Deadline enforcement** — `block.timestamp > deadline` causes a revert.
* **Nonce binding** — the signed nonce must match the account's current nonce.
* `permit` is not affected by pause state (it only sets allowance, it does not transfer tokens) and is not policy-gated (like `approve`).

## 15. Metadata

`METADATA_ROLE` gates name, symbol, and contract URI updates:

| Method                      | Effect                  | Side effects                                                                    |
| --------------------------- | ----------------------- | ------------------------------------------------------------------------------- |
| `updateName(newName)`       | Updates `name()`        | Rotates EIP-712 domain separator, emits `NameUpdated` and `EIP712DomainChanged` |
| `updateSymbol(newSymbol)`   | Updates `symbol()`      | Emits `SymbolUpdated`                                                           |
| `updateContractURI(newUri)` | Updates `contractURI()` | Per ERC-7572                                                                    |

## 16. Precompile Addresses

These addresses are identical on every network where B20 is active (Mainnet, Base Sepolia, Vibenet, and local `base-anvil`).

| Precompile          | Address                                      |
| ------------------- | -------------------------------------------- |
| B20Factory          | `0xB20f000000000000000000000000000000000000` |
| Activation Registry | `0x8453000000000000000000000000000000000001` |
| Policy Registry     | `0x8453000000000000000000000000000000000002` |

## 17. Invariants

### Policy Registry

1. `isAuthorized` never reverts for any combination of `policyId` and `account`.
2. A non-existent `BLOCKLIST` policy authorizes everyone. A non-existent `ALLOWLIST` policy denies everyone.
3. After `renounceAdmin(policyId)`, all membership-mutating calls on that policy revert permanently.
4. `ALWAYS_ALLOW` (ID `0`) authorizes every account. `ALWAYS_BLOCK` denies every account. Neither can be created, modified, or renounced.
5. Policy IDs are globally unique and monotonically increasing within each `PolicyType` prefix.

### Roles

6. The last `DEFAULT_ADMIN_ROLE` holder cannot be removed via `renounceRole` or `revokeRole` — only `renounceLastAdmin()`.
7. After `renounceLastAdmin()`, no address can ever hold `DEFAULT_ADMIN_ROLE` again.
8. Roles granted before admin renunciation continue to function.
9. Custom roles have no built-in effect on any B20 operation.

### Transfer Policies

10. `approve` is never policy-gated.
11. `TRANSFER_EXECUTOR_POLICY` is checked only on `transferFrom`, never on `transfer`.
12. `MINT_RECEIVER_POLICY` is always enforced, even during factory `initCalls`.
13. All three transfer-side scopes are bypassed during `initCalls`.
14. Every scope defaults to `ALWAYS_ALLOW` at token creation.

### Supply

15. `totalSupply` can never exceed the supply cap.
16. The supply cap can never be set below the current `totalSupply`.
17. Burns reduce `totalSupply` and create headroom under the cap.

### Pause

18. Each `PausableFeature` is independent — pausing one does not affect the others.
19. `approve` and `permit` are never affected by any pause state.
20. Pause state is never bypassed during factory `initCalls`.

### Memos

21. The `Memo` event is always emitted at exactly `logIndex + 1` relative to its parent `Transfer` event.
22. Memo methods are functionally identical to their non-memo counterparts in all respects except event emission.

### Permit

23. `permit` only accepts ECDSA signatures. ERC-1271 contract signatures always fail.
24. Each successful `permit` increments the owner's nonce by exactly 1.
25. Permits signed before `updateName` fail after the name change.

### Variants

26. Asset decimals are set at creation and immutable. Valid range is 6–18.
27. Stablecoin decimals are always `6`.
28. `OPERATOR_ROLE` exists only on Asset tokens.
29. Announcement IDs are unique across a token's lifetime.
30. The currency code on a Stablecoin is immutable and contains only `A`–`Z` characters.
31. Multiplier updates affect all holders simultaneously.
32. `batchMint` enforces `MINT_RECEIVER_POLICY` for each recipient individually.

### Factory

33. B20 addresses are deterministic: same inputs always produce the same address.
34. The variant byte at address position 10 always matches the deployed variant.
35. Each `(deployer, variant, salt)` tuple produces exactly one address.
36. `initCalls` execute in array order. A revert in any initCall reverts the entire deployment.

## 18. Test Cases

### Policy Registry

| # | Scenario                                               | Expected                       |
| - | ------------------------------------------------------ | ------------------------------ |
| 1 | Create `BLOCKLIST` policy, check unblocked account     | `isAuthorized` returns `true`  |
| 2 | Add account to blocklist, check                        | `isAuthorized` returns `false` |
| 3 | Remove account from blocklist, check                   | `isAuthorized` returns `true`  |
| 4 | Create `ALLOWLIST` policy, check unlisted account      | `isAuthorized` returns `false` |
| 5 | `isAuthorized` with non-existent blocklist-prefixed ID | Returns `true`                 |
| 6 | `isAuthorized` with non-existent allowlist-prefixed ID | Returns `false`                |
| 7 | `renounceAdmin`, then `updateBlocklist`                | Reverts                        |
| 8 | `finalizeUpdateAdmin` from non-pending address         | Reverts                        |

### Roles

| #  | Scenario                                                   | Expected                               |
| -- | ---------------------------------------------------------- | -------------------------------------- |
| 9  | Grant `MINT_ROLE`, call `mint`                             | Succeeds                               |
| 10 | Call `mint` without `MINT_ROLE`                            | Reverts                                |
| 11 | One admin remains, call `revokeRole(DEFAULT_ADMIN_ROLE)`   | Reverts with `LastAdminCannotRenounce` |
| 12 | Call `renounceLastAdmin()`                                 | Succeeds — token becomes admin-less    |
| 13 | After `renounceLastAdmin`, `MINT_ROLE` holder calls `mint` | Succeeds — non-admin roles still work  |
| 14 | Deploy with `initialAdmin == address(0)`, call `grantRole` | Reverts                                |

### Transfer Policies

| #  | Scenario                                                                 | Expected                     |
| -- | ------------------------------------------------------------------------ | ---------------------------- |
| 15 | Sender on blocklist calls `transfer`                                     | Reverts with `PolicyForbids` |
| 16 | Sender on blocklist calls `approve`                                      | Succeeds                     |
| 17 | `transferFrom` where executor is on executor blocklist                   | Reverts                      |
| 18 | Direct `transfer` by sender on executor blocklist (not sender blocklist) | Succeeds                     |
| 19 | During `initCalls`, transfer from blocklisted sender                     | Succeeds — bypass            |
| 20 | During `initCalls`, mint to receiver on mint-receiver blocklist          | Reverts — never bypassed     |

### Mint and Supply Cap

| #  | Scenario                                      | Expected                         |
| -- | --------------------------------------------- | -------------------------------- |
| 21 | Mint that would push `totalSupply` above cap  | Reverts with `SupplyCapExceeded` |
| 22 | Mint exactly to cap                           | Succeeds                         |
| 23 | `updateSupplyCap` below current `totalSupply` | Reverts with `InvalidSupplyCap`  |
| 24 | Burn tokens, then mint up to cap              | Succeeds                         |
| 25 | Mint while `MINT` is paused                   | Reverts                          |

### Burn and Seize

| #  | Scenario                                                         | Expected |
| -- | ---------------------------------------------------------------- | -------- |
| 26 | `burnBlocked` on frozen account                                  | Succeeds |
| 27 | `burnBlocked` on non-frozen account                              | Reverts  |
| 28 | `burnBlocked` by holder of `BURN_ROLE` (not `BURN_BLOCKED_ROLE`) | Reverts  |
| 29 | Freeze, seize full balance, re-mint to recovery                  | Succeeds |
| 30 | Burn while `BURN` is paused                                      | Reverts  |

### Pause

| #  | Scenario                                      | Expected |
| -- | --------------------------------------------- | -------- |
| 31 | Pause `TRANSFER`, call `transfer`             | Reverts  |
| 32 | Pause `TRANSFER`, call `mint`                 | Succeeds |
| 33 | Pause `TRANSFER`, call `approve`              | Succeeds |
| 34 | Pauser calls `unpause` without `UNPAUSE_ROLE` | Reverts  |

### Memos

| #  | Scenario                      | Expected                                                |
| -- | ----------------------------- | ------------------------------------------------------- |
| 35 | `transferWithMemo`            | Emits `Transfer` then `Memo` at consecutive log indices |
| 36 | `transferFromWithMemo`        | `Memo.caller` is `msg.sender`, not `from`               |
| 37 | `transfer` (non-memo variant) | No `Memo` event                                         |

### Permit

| #  | Scenario                                             | Expected |
| -- | ---------------------------------------------------- | -------- |
| 38 | Valid permit with correct signature, nonce, deadline | Succeeds |
| 39 | Permit with expired deadline                         | Reverts  |
| 40 | Replay used permit signature                         | Reverts  |
| 41 | Permit signed before `updateName`, submitted after   | Reverts  |
| 42 | Contract wallet signature (ERC-1271)                 | Reverts  |
| 43 | Permit while `TRANSFER` is paused                    | Succeeds |

### Factory

| #  | Scenario                                                          | Expected         |
| -- | ----------------------------------------------------------------- | ---------------- |
| 44 | `getB20Address` then deploy with same params                      | Addresses match  |
| 45 | Inspect byte 10 of deployed Asset address                         | Returns `0x00`   |
| 46 | Deploy same `(deployer, variant, salt)` twice                     | Second reverts   |
| 47 | Deploy when variant feature not activated                         | Reverts          |
| 48 | `initCalls` that pause `TRANSFER`, then transfer in next initCall | Transfer reverts |

### Variants

| #  | Scenario                                                           | Expected                               |
| -- | ------------------------------------------------------------------ | -------------------------------------- |
| 49 | Deploy Asset with `decimals = 5`                                   | Reverts                                |
| 50 | Update multiplier to `2e18`, check `balanceOf` for raw balance 100 | Returns 200                            |
| 51 | Reuse announcement ID                                              | Reverts with `DuplicateAnnouncementId` |
| 52 | `batchMint` where one recipient is not on allowlist                | Reverts                                |
| 53 | Deploy Stablecoin with `currency = "usd"`                          | Reverts — `A`–`Z` only                 |

## 19. Error Reference

### Access Control

| Error                              | Trigger                                                               | Recovery                                 |
| ---------------------------------- | --------------------------------------------------------------------- | ---------------------------------------- |
| `AccessControlUnauthorizedAccount` | Caller does not hold the required role                                | Grant the appropriate role               |
| `LastAdminCannotRenounce`          | Attempted to remove the last admin via `renounceRole` or `revokeRole` | Use `renounceLastAdmin()` if intentional |

### Policies

| Error               | Trigger                                                    | Recovery                                                                 |
| ------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------ |
| `PolicyForbids`     | Account failed `isAuthorized` check for the relevant scope | Add account to policy's authorized list, or change the scope's policy ID |
| `InvalidScope`      | Unrecognized scope passed to `updatePolicy`                | Use a defined scope constant                                             |
| `Unauthorized`      | Caller is not the policy admin                             | Call from the policy admin address                                       |
| `PolicyFrozen`      | `renounceAdmin` was called on this policy                  | None — permanently frozen                                                |
| `InvalidPolicyType` | Called the wrong type-specific update method               | Use the correct method for the policy type                               |

### Supply

| Error                      | Trigger                                                  | Recovery                                              |
| -------------------------- | -------------------------------------------------------- | ----------------------------------------------------- |
| `SupplyCapExceeded`        | Mint would exceed the supply cap                         | Reduce amount, raise cap, or burn first               |
| `InvalidSupplyCap`         | New cap below `totalSupply` or above `type(uint128).max` | Set cap between `totalSupply` and `type(uint128).max` |
| `ERC20InsufficientBalance` | Burn amount exceeds account balance                      | Reduce amount                                         |

### Pause

| Error           | Trigger                                 | Recovery                    |
| --------------- | --------------------------------------- | --------------------------- |
| `FeaturePaused` | Operation's `PausableFeature` is paused | Unpause with `UNPAUSE_ROLE` |

### Permit

| Error                     | Trigger                                                       | Recovery                                         |
| ------------------------- | ------------------------------------------------------------- | ------------------------------------------------ |
| `ERC2612InvalidSigner`    | Recovered signer doesn't match `owner`, or contract signature | Verify signing key matches `owner` and is an EOA |
| `ERC2612ExpiredSignature` | `block.timestamp` exceeds deadline                            | Sign a new permit                                |

### Factory

| Error                 | Trigger                                              | Recovery                   |
| --------------------- | ---------------------------------------------------- | -------------------------- |
| `FeatureNotActivated` | Variant feature not active in Activation Registry    | Wait for activation        |
| `TokenAlreadyExists`  | Same `(deployer, variant, salt)` already deployed    | Use a different salt       |
| `InvalidParams`       | Params fail validation (e.g., decimals out of range) | Fix params                 |
| `InitCallFailed`      | An `initCall` reverted during bootstrap              | Check the failing initCall |

### Variants

| Error                     | Trigger                                       | Recovery                        |
| ------------------------- | --------------------------------------------- | ------------------------------- |
| `InvalidDecimals`         | Asset decimals outside 6–18                   | Use 6–18                        |
| `DuplicateAnnouncementId` | ID already used on this token                 | Use a unique ID                 |
| `InternalCallFailed`      | Call inside `announce()` reverted             | Check inner call params         |
| `InvalidCurrency`         | Currency code contains non-`A`–`Z` characters | Use uppercase ASCII only        |
| `InvalidMultiplier`       | Multiplier value is invalid                   | Use a valid WAD-precision value |
