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.
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 | Third-party burns against policy-blocked accounts: burnBlocked |
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 seven 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 | Default | Behavior |
|---|---|---|
BLOCKLIST | Authorized | All accounts authorized by default; explicitly listed accounts are denied. |
ALLOWLIST | Denied | All accounts denied by default; explicitly listed accounts are authorized. |
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.
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 falls back to empty-set behavior, so a missing BLOCKLIST authorizes everyone and a missing ALLOWLIST denies everyone.
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) |
MINT_RECEIVER_POLICY | The to of mint |
Note that approve is never 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, giving regulated issuers their freeze-and-seize mechanism.
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, and burnWithMemo.
Pausing is fine-grained: the PausableFeature enum splits the token surface into operations that pause independently — TRANSFER, MINT, and BURN. That enum only ever grows. By design, pause(features) and unpause(features) answer to different roles (PAUSE_ROLE and UNPAUSE_ROLE).
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 |
updateMultiplier(newMultiplier) | Update the multiplier. Gated by OPERATOR_ROLE. |
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 |
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.