B20 Native Token Standard
B20 is Base’s in-house take on ERC-20. Compliance tooling is baked in from the start: role-based access control, transfer policies, supply caps, memos, and a freeze-and-seize path. The complete interface definitions live in the Base Standard Library repository.
This page describes how the standard behaves. For the hands-on path — install the toolchain, call the factory, mint, and verify — see Launch a B20 Token.
Two variants are available:
| Variant | Decimals | Additional Features |
|---|---|---|
| Asset | 6–18 (configurable) | Rebase multiplier, onchain announcements, batched issuance |
| Stablecoin | 6 (fixed) | Self-declared fiat currency code |
ERC-20 compatibility
Section titled “ERC-20 compatibility”Rather than living as EVM smart contracts, B20 tokens run as Rust precompiles, which makes them faster, cheaper, and closer to the chain itself. Every token is deployed through the singleton B20Factory precompile.
Beyond that base surface, B20 layers on methods ERC-20 lacks: memos, mint/burn, policy gating, granular pause, and ERC-2612 permit. The containment runs one way only — anything ERC-20 defines is present on B20, but the additions don’t flow back. Full ABIs are published as interface definitions in the Base Standard Library.
Roles model
Section titled “Roles model”B20’s access control builds on OpenZeppelin AccessControl, adding a fixed role set plus one behavioral tweak to how the admin role is renounced.
| 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 | The deprecated third-party burn path: burnBlocked |
SEIZE_ROLE | seizeWithMemo |
PAUSE_ROLE | pause |
UNPAUSE_ROLE | unpause |
METADATA_ROLE | updateName, updateSymbol, updateContractURI |
You can define your own roles through setRoleAdmin and grantRole, but they carry no behavior on their own — only the eight base-surface roles listed above are actually gated. One more, OPERATOR_ROLE, is introduced by the Asset variant (see Variants).
Admin renunciation
Section titled “Admin renunciation”The final holder of DEFAULT_ADMIN_ROLE can’t be dropped through renounceRole or revokeRole; both revert with LastAdminCannotRenounce. The only way to make a token permanently admin-less is the purpose-built renounceLastAdmin().
Tokens meant to be admin-less from day one are created with initialAdmin == address(0), which skips granting the role and bypasses the renounceLastAdmin step altogether.
Once renounceLastAdmin() has run (or for tokens deployed with initialAdmin == address(0)):
- Anything gated by
DEFAULT_ADMIN_ROLEis permanently uncallable. - Roles already held by other addresses (
MINT_ROLE,BURN_ROLE, and so on) keep working on their own. - The admin can’t be resurrected:
grantRole,revokeRole, andsetRoleAdminall revert withAccessControlUnauthorizedAccount, even for a caller holding a custom role.
Policy registry
Section titled “Policy registry”The PolicyRegistry is one singleton precompile holding allowlists and blocklists. B20 tokens point at policies by uint64 ID, and anyone can create a policy and name its admin.
Policy types
Section titled “Policy types”| Type | Value | Default | 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. |
UNION | 2 | Denied | Authorized when any child policy authorizes. Added at Cobalt. |
INTERSECT | 3 | Authorized | Authorized only when every child policy authorizes. Added at Cobalt. |
The first two are simple policies: they hold a membership set of their own. The last two are composites, added at Cobalt; they hold no members and instead reference 2 to 4 simple policies, which are evaluated live on each check. A composite’s children must be simple, so evaluation never exceeds depth 1. The enum is append-only, so the two new values do not disturb the packed-ID encoding below. Call-level detail is in the B20 precompile reference.
Policy IDs
Section titled “Policy IDs”A policy ID is a uint64. Its top byte holds the PolicyType; the remaining 56 bits are a global counter that begins at 2, since 0 and 1 are reserved. Composites draw from that same counter rather than a separate one, so a policy ID is unique across all four types.
Two reserved IDs work without being created first:
| 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. |
isAuthorized won’t revert when handed a policy ID that doesn’t exist. It reads whatever the ID points at and applies empty-set behavior, which each type resolves differently:
| Type | Nonexistent ID resolves to |
|---|---|
BLOCKLIST | Authorizes everyone (nobody is listed as denied) |
ALLOWLIST | Denies everyone (nobody is listed as allowed) |
UNION | Denies everyone (no child authorizes) |
INTERSECT | Authorizes everyone (no child declines) |
A malformed ID — one whose type byte exceeds INTERSECT — is treated as unauthorized rather than reverting.
From Denim, bit 63 gains a meaning of its own: it inverts the policy. An ID with that bit set authorizes exactly the accounts its base rejects, and it reads as false when the base does not exist. invertedPolicyId(policyId) toggles the bit. Until Denim activates, the same ID is simply malformed and denies everyone, so a slot bound to one changes behaviour at the fork. The precompile reference has the details.
Admin model
Section titled “Admin model”Every policy has exactly one admin, and transfers happen in two steps: the sitting admin calls stageUpdateAdmin(policyId, newAdmin), then the nominee calls finalizeUpdateAdmin(policyId). Calling renounceAdmin(policyId) freezes the policy for good — its membership can never change again.
Creating and managing policies
Section titled “Creating and managing policies”// Create a policy (admin first, then type)uint64 policyId = policyRegistry.createPolicy(adminAddress, PolicyType.BLOCKLIST);// Or seed the initial member set in one call:// uint64 policyId = policyRegistry.createPolicyWithAccounts(adminAddress, PolicyType.BLOCKLIST, accounts);
// Update membership (batched). The setter is type-specific; the bool sets membership state.policyRegistry.updateBlocklist(policyId, true, accounts); // block these accountspolicyRegistry.updateBlocklist(policyId, false, accounts); // unblock these accounts// For ALLOWLIST policies: policyRegistry.updateAllowlist(policyId, allowed, accounts)Read interface
Section titled “Read interface”| Method | Description |
|---|---|
isAuthorized(policyId, account) | Whether account is authorized under policyId. Never reverts. |
policyExists(policyId) | Whether a policy with this ID has been created. |
policyAdmin(policyId) | Current admin address. |
pendingPolicyAdmin(policyId) | Pending admin during a two-step transfer. |
Policy integration
Section titled “Policy integration”B20 exposes a fixed set of policy scopes, each holding a uint64 policy ID into the PolicyRegistry. Each gated operation triggers an isAuthorized check on the matching scope; an unauthorized account makes the call revert with PolicyForbids.
| 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, when distinct from from (not checked on transfer). From Denim it checks the initiator of every transfer, transfer included |
MINT_RECEIVER_POLICY | The to of mint |
SEIZE_EXEMPT_POLICY | The from of seizeWithMemo. Note the inversion — a holder is only seizable while this scope denies them |
SEIZE_RECEIVER_POLICY | The to of seizeWithMemo, which must be authorized |
Note that neither approve nor permit is policy-gated — only real balance movement through transfer / transferFrom is checked.
Scopes are read with policyId(scope) and set with updatePolicy(scope, policyId). updatePolicy is admin-gated and reverts on an unrecognized scope.
Fresh supply comes from mint / mintWithMemo, both gated by MINT_ROLE. The recipient is checked against MINT_RECEIVER_POLICY, and the call reverts with SupplyCapExceeded if it would carry totalSupply past the cap.
There are two ways to burn:
burn/burnWithMemo— a holder destroys supply from their own balance, gated byBURN_ROLE.burnBlocked— destroys supply held by a third party, gated byBURN_BLOCKED_ROLE. That account first has to be denied underTRANSFER_SENDER_POLICY.
seizeWithMemo(from, to, amount, memo) is the current path for regulated issuers who need to move balance out of an account under compulsion. Rather than destroying the supply the way burnBlocked did, it reassigns it: the balance leaves from and lands on to in a single privileged step, emitting Transfer, Memo, and Seized.
The call answers to SEIZE_ROLE, and it deliberately bypasses both allowance and the ordinary transfer policies. In their place it applies two checks of its own, and the first one runs backwards from what you’d expect:
frommust be denied bySEIZE_EXEMPT_POLICY. Authorization here marks an account as protected, so only accounts the policy turns away can be seized from.tomust be authorized bySEIZE_RECEIVER_POLICY, which keeps seized balance flowing only to destinations the issuer has sanctioned.
Missing either check reverts — AccountNotSeizable for the holder, PolicyForbids for the destination.
Supply cap
Section titled “Supply cap”Capping supply is opt-in. The sentinel type(uint128).max signals no cap, which is what a token starts with; it doubles as the highest permitted cap, so totalSupply can never climb past it. updateSupplyCap(newCap) is admin-gated and emits SupplyCapUpdated, reverting with InvalidSupplyCap if newCap falls beneath the current totalSupply or rises above type(uint128).max.
A memo is an optional bytes32 tag carried alongside an operation for off-chain reference. Any operation carrying one emits Memo(address indexed caller, bytes32 indexed memo) immediately after its primary event, and indexers join the two on (transactionHash, logIndex − 1).
The memo-capable entrypoints are transferWithMemo, transferFromWithMemo, mintWithMemo, burnWithMemo, and seizeWithMemo.
Pausing is fine-grained: the PausableFeature enum splits the token surface into operations that pause independently — TRANSFER, MINT, BURN, and SEIZE. That enum only ever grows. By design, pause(features) and unpause(features) answer to different roles (PAUSE_ROLE and UNPAUSE_ROLE), so you can hand out emergency-stop authority without also handing out the ability to lift it.
Each feature covers every entrypoint in its family, and a pause outranks a role — a MINT_ROLE holder still cannot mint while MINT is paused. The attempted call reverts with ContractPaused(feature).
| Feature | Ordinal | Operations it stops |
|---|---|---|
TRANSFER | 0 | transfer, transferFrom, and their memo variants |
MINT | 1 | mint, mintWithMemo, and batchMint |
BURN | 2 | burn, burnWithMemo, and the deprecated burnBlocked |
SEIZE | 3 | seizeWithMemo |
Re-pausing a feature that is already paused is a no-op and does not revert, which keeps an emergency runbook safe to run twice. Read the whole current set in one call with pausedFeatures(). As a bitmask, B20Constants.ALL_FEATURES_PAUSED is 15 (0b1111), every feature bit set.
No feature covers approve. A fully paused token still accepts new allowances, and those become spendable the moment TRANSFER is lifted.
ERC-2612 permit / EIP-712
Section titled “ERC-2612 permit / EIP-712”B20 supports ERC-2612 signed approvals over an EIP-712 domain of (name, version, chainId, verifyingContract), with version pinned to "1". A updateName call rolls the domain separator and fires EIP712DomainChanged (ERC-5267). Only ECDSA signatures count — ERC-1271 contract signatures are rejected.
Contract URI (ERC-7572)
Section titled “Contract URI (ERC-7572)”contractURI() hands back a string pointing at off-chain metadata in the ERC-7572 style. updateContractURI(newUri) is gated by METADATA_ROLE.
Metadata updates
Section titled “Metadata updates”METADATA_ROLE controls:
updateName(newName)— changesnameand cycles the EIP-712 domain separator, emittingNameUpdatedalongsideEIP712DomainChanged.updateSymbol(newSymbol)— changes onlysymbol. EmitsSymbolUpdated.
Factory
Section titled “Factory”Every B20 token is minted through the singleton B20Factory precompile via createB20(variant, salt, params, initCalls). Within base-std the factory is reachable as StdPrecompiles.B20_FACTORY.
| Parameter | Description |
|---|---|
variant | ASSET or STABLECOIN |
salt | Caller-chosen entropy for address derivation |
params | ABI-encoded, variant-specific creation struct (versioned by leading byte) |
initCalls | Optional array of ABI-encoded calls dispatched post-creation; calls originating from the factory bypass role gates and transfer-side policy gates during this window |
If the chain has not yet switched on the feature backing the requested variant, createB20 reverts with IActivationRegistry.FeatureNotActivated.
Address derivation
Section titled “Address derivation”A B20 address is deterministic and carries its variant inline:
[10-byte B20 prefix][1-byte variant][9-byte keccak256(deployer, salt)]Because the variant lives in the address, it’s recoverable without an RPC call — read byte 10 (zero-indexed) to learn the token type. The factory also exposes getB20Address(variant, deployer, salt), isB20(addr), and isB20Initialized(addr).
initCalls semantics
Section titled “initCalls semantics”initCalls run after creation. While this bootstrap window is open, anything the factory dispatches skips two enforcement layers at once: the token’s role checks, plus the sender/receiver/executor transfer policies (TRANSFER_SENDER_POLICY, TRANSFER_RECEIVER_POLICY, TRANSFER_EXECUTOR_POLICY) — letting a deployer apply admin-gated setup (policies, role grants) and seed opening balances all inside the deploy transaction. That relaxation is deliberately partial:
MINT_RECEIVER_POLICYis enforced even insideinitCalls.- Pause state is never bypassed.
- Token invariants (the supply cap and so on) are never bypassed.
Variants
Section titled “Variants”Each variant has a 1-byte identifier written directly into the token’s address (see Address derivation):
| Variant | Byte |
|---|---|
ASSET | 0x00 |
STABLECOIN | 0x01 |
The general-purpose variant, suitable for any kind of asset. Decimals are chosen between 6 and 18 at deployment and locked thereafter.
On top of everything in the base surface, the Asset variant layers on extra capabilities. The newly added OPERATOR_ROLE guards the multiplier and announcements, while batch mint stays under MINT_ROLE and extra metadata under METADATA_ROLE.
Multiplier
Section titled “Multiplier”A WAD-precision rebase multiplier applied across all balance reads. Stored balances stay as-is; the multiplier only scales the value callers see.
| 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 |
updateUIMultiplier(newMultiplier, effectiveAt) | Queue a change to take effect at a future timestamp (ERC-8056). The preferred path. Gated by OPERATOR_ROLE. |
cancelUIMultiplierUpdate() | Drop the pending scheduled change. Gated by OPERATOR_ROLE. |
updateMultiplier(newMultiplier) | Set the multiplier immediately, discarding any pending scheduled change. Deprecated. Gated by OPERATOR_ROLE. |
Scheduling is what issuers should reach for day to day: it puts the change onchain ahead of time, so holders and integrators see a corporate action coming rather than discovering it after the fact. The instant call survives as an emergency failsafe and nothing more. Neither path carries a timelock of its own — the announcement events are notice, not enforced delay — so any waiting period is something the issuer imposes above the standard.
For a worked example of multipliers under real corporate actions, see Tokenized Stocks.
Announcements
Section titled “Announcements”A way to bracket sensitive operations (batch mints, multiplier changes) inside a publicly visible disclosure window, gated by OPERATOR_ROLE.
announce(internalCalls, id, description, uri) emits an Announcement event, runs internalCalls, then emits EndAnnouncement. The id has to be unique and is enforced forever, and any inner-call revert is wrapped in InternalCallFailed.
Batch mint
Section titled “Batch mint”batchMint(recipients, amounts) mints to several recipients at once, gated by MINT_ROLE. It should be wrapped in announce() for transparency.
Extra metadata
Section titled “Extra metadata”An open-ended store of issuer-set on-chain metadata, addressed by key.
| Method | Description |
|---|---|
extraMetadata(key) | Read a value by key |
updateExtraMetadata(key, value) | Write a value. Gated by METADATA_ROLE. Setting an empty value removes the entry. |
Stablecoin
Section titled “Stablecoin”A fiat-backed carveout with locked decimals. The decimal count is fixed at 6 and isn’t configurable.
It adds currency(), returning an ISO-style currency code ("USD", "EUR", and the like). That code is set once at creation through B20StablecoinCreateParams.currency, limited to the characters A-Z. It’s self-declared and isn’t checked against any outside registry.
Precompile addresses
Section titled “Precompile addresses”The supporting precompiles sit at the same fixed addresses on every network where B20 is live — Mainnet, Base Sepolia, Vibenet, and a local base-anvil instance alike.
| Precompile | Address |
|---|---|
| B20Factory | 0xB20f000000000000000000000000000000000000 |
| Activation Registry | 0x8453000000000000000000000000000000000001 |
| Policy registry | 0x8453000000000000000000000000000000000002 |
How B20 executes
Section titled “How B20 executes”None of this is Solidity. The factory, both registries, and every token are precompiles: native code inside the node, sitting at reserved addresses. On each CALL, STATICCALL, or related opcode, the EVM consults its precompile table before loading bytecode. If the target is registered, native code runs and no bytecode is ever read. It is the same table that holds ecrecover and sha256. B20 adds entries to it rather than routing around it.
The difference from those classic precompiles is state. B20 is the first stateful precompile on Base. Each one keeps storage and emits real logs, and it writes that storage into the ordinary account slots at its own address. There is no side database, so eth_getStorageAt on a token address returns exactly what a transfer wrote.
Without an interpreter in the path, the native code does its own gas metering. It charges calldata, SLOAD, SSTORE, and logs at the prices those opcodes would cost, and it fails the ways a contract fails: out of gas, revert, or a custom error. A revert unwinds state exactly like a contract revert.
The three singletons live in the node’s static table. Tokens cannot, because they are created at runtime. The node recognises a token address by decoding its prefix and variant byte instead (see Address derivation).
Versions are frozen per fork
Section titled “Versions are frozen per fork”Changing B20 behaviour takes a hardfork. A fork can add a precompile, or ship a new logic version for an existing address. The address stays the same; what changes is which native implementation answers it after activation.
Old versions are never edited. Each fork maps to one frozen implementation, and the node resolves that mapping on every call. A Beryl block always runs Beryl logic, even on a node that also knows Cobalt, and that is what lets a node syncing from genesis reach the same state as one that has run all along. If no version resolves for the active fork, the call reverts. There is no fallback to whatever is newest.
Two practical consequences:
- Before a precompile’s first fork, its address holds no bytecode and no table entry, so it behaves like an empty account. A raw call to it succeeds and returns no data, which a caller expecting a return value will fail to decode. Tooling that checks for code at the target rejects it outright.
- Before a new selector’s fork, the precompile already exists but its active version does not know the selector, so the call reverts.
invertedPolicyIdbefore Denim is a live example.
Roadmap
Section titled “Roadmap”Running in Rust beyond the EVM gives the node unusually deep control over how B20 tokens behave. The following capabilities are slated for later upgrades.
Frontier features
Section titled “Frontier features”- Pay transaction fees with B20 — cover gas with a custom asset alone, holding no ETH at all.
- Virtual addresses — mint one-off deposit addresses that funnel into a single shared account.
- Indexed data — pull balances, transfer history, and token metadata straight from Base Node RPCs in aggregated form, with no external indexer in the loop.
Performance optimizations
Section titled “Performance optimizations”- Lower fees — transfers roughly 50% cheaper than an ERC-20 contract.
- Higher throughput — about 2× the transfers per block versus an ERC-20 contract.
More tuning is planned specifically for trading and payment workloads.