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.
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.
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.
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.
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.
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
- The issuer calls
createB20with a variant, a salt, and creation parameters (name, symbol, initial admin, and variant-specific fields). - The Factory assigns a deterministic address from
(variant, sender, salt)and seals the token's identity. - Optional
initCallsrun on the new token so the issuer can grant roles, attach policies, or mint in the same transaction. createB20returns. The Factory retains no ongoing access to the token.
Choose Asset for general-purpose issuance, including RWAs, or Stablecoin for a fiat-pegged token with a fixed currency code. Both variants share roles, policies, and the ERC-20 surface. See Token Types.
The Activation Registry is a Base-operated safety switch that turns Factory and token features on. Issuers and apps do not operate it.
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 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. A role-gated call looks like this:
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
- At creation,
initialAdminholdsDEFAULT_ADMIN_ROLE. - That admin grants operating roles such as
MINT_ROLEandPAUSE_ROLE. - A caller without the required role is rejected with
AccessControlUnauthorizedAccount.
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:
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
- A caller who holds
MINT_ROLEcan mint whileMINTis unpaused. - An account with
PAUSE_ROLEpausesMINT. Other features stay live. - The next
mintreverts withContractPaused(MINT), even if the caller still holdsMINT_ROLE. - An account with
UNPAUSE_ROLEunpausesMINT. Minting works again.
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.
A policy-gated transfer looks like this:
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
- Create an allowlist or blocklist on the registry.
- The token admin binds that policy ID to a scope.
- On
transfer, the token asks the registry whether the receiver is authorized. - Authorized: the call continues. Denied: the call reverts with
PolicyForbids. - Unset scopes default to always-allow.
approveis not policy-gated.
If you want to understand how B20 works internally:
If you are integrating B20:
→ Seize a holder's B20 balance → Schedule a stock split
For exact interfaces and protocol definitions: