Skip to main content

1. Abstract

B20 is the Base ecosystem’s native token standard — an ERC-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 repository. To deploy your first token, see the Launch a B20 token quickstart.
Verify the Activation Registry is enabled before attempting to deploy.

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. The variant byte is encoded directly in the token address 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 and 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.
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.

4.1. Policy Types

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.
Two built-in IDs require no creation:

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:
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.

4.4. Creating and Managing Policies

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

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

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

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.
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. 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

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

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.

6.5. Reading and Writing Scopes

6.6. initCalls Bypass

During token creation via the Factory, initCalls bypass the three transfer-side policy scopes: 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

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.
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

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: Stablecoin params:

8.2. Address Derivation

B20 addresses are deterministic and encode the variant directly:
The variant is recoverable from the address alone — inspect byte 10 (zero-indexed) to identify the token type without an RPC call.

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: Typical initCalls sequence:
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

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

9.2. Seize (burnBlocked)

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

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.
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.
Multiplier updates should be wrapped in an announce() call for transparency.

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.
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

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).

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

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.

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

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

12.2. Feature Interaction Matrix

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

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

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:
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.
indexer-example.js
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 signed approvals using an 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

14.2. permit

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 onlyecrecover 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 enforcementblock.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:

16. Precompile Addresses

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

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

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

Transfer Policies

  1. approve is never policy-gated.
  2. TRANSFER_EXECUTOR_POLICY is checked only on transferFrom, never on transfer.
  3. MINT_RECEIVER_POLICY is always enforced, even during factory initCalls.
  4. All three transfer-side scopes are bypassed during initCalls.
  5. Every scope defaults to ALWAYS_ALLOW at token creation.

Supply

  1. totalSupply can never exceed the supply cap.
  2. The supply cap can never be set below the current totalSupply.
  3. Burns reduce totalSupply and create headroom under the cap.

Pause

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

Memos

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

Permit

  1. permit only accepts ECDSA signatures. ERC-1271 contract signatures always fail.
  2. Each successful permit increments the owner’s nonce by exactly 1.
  3. Permits signed before updateName fail after the name change.

Variants

  1. Asset decimals are set at creation and immutable. Valid range is 6–18.
  2. Stablecoin decimals are always 6.
  3. OPERATOR_ROLE exists only on Asset tokens.
  4. Announcement IDs are unique across a token’s lifetime.
  5. The currency code on a Stablecoin is immutable and contains only AZ characters.
  6. Multiplier updates affect all holders simultaneously.
  7. batchMint enforces MINT_RECEIVER_POLICY for each recipient individually.

Factory

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

18. Test Cases

Policy Registry

Roles

Transfer Policies

Mint and Supply Cap

Burn and Seize

Pause

Memos

Permit

Factory

Variants

19. Error Reference

Access Control

Policies

Supply

Pause

Permit

Factory

Variants