Skip to content
BaseHub by wbnns Updated

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:

VariantDecimalsAdditional Features
Asset6–18 (configurable)Rebase multiplier, onchain announcements, batched issuance
Stablecoin6 (fixed)Self-declared fiat currency code

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.

B20’s access control builds on OpenZeppelin AccessControl, adding a fixed role set plus one behavioral tweak to how the admin role is renounced.

RoleGates
DEFAULT_ADMIN_ROLEAll admin operations: role grants, policy updates, supply-cap changes
MINT_ROLEmint, mintWithMemo
BURN_ROLECaller-side burns: burn, burnWithMemo
BURN_BLOCKED_ROLEThird-party burns against policy-blocked accounts: burnBlocked
PAUSE_ROLEpause
UNPAUSE_ROLEunpause
METADATA_ROLEupdateName, 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).

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_ROLE is 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, and setRoleAdmin all revert with AccessControlUnauthorizedAccount, even for a caller holding a custom role.

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.

TypeDefaultBehavior
BLOCKLISTAuthorizedAll accounts authorized by default; explicitly listed accounts are denied.
ALLOWLISTDeniedAll accounts denied by default; explicitly listed accounts are authorized.

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:

ConstantIDBehavior
ALWAYS_ALLOW0Authorizes every account unconditionally. Default scope value on new B20 tokens.
ALWAYS_BLOCK(uint64(ALLOWLIST) << 56) | 1Denies 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.

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.

// 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 accounts
policyRegistry.updateBlocklist(policyId, false, accounts); // unblock these accounts
// For ALLOWLIST policies: policyRegistry.updateAllowlist(policyId, allowed, accounts)
MethodDescription
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.

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.

ScopeGates
TRANSFER_SENDER_POLICYThe from of transfer / transferFrom
TRANSFER_RECEIVER_POLICYThe to of transfer / transferFrom
TRANSFER_EXECUTOR_POLICYThe msg.sender of transferFrom, when distinct from from (not checked on transfer)
MINT_RECEIVER_POLICYThe 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 by BURN_ROLE.
  • burnBlocked — destroys supply held by a third party, gated by BURN_BLOCKED_ROLE. That account first has to be denied under TRANSFER_SENDER_POLICY, giving regulated issuers their freeze-and-seize mechanism.

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

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.

contractURI() hands back a string pointing at off-chain metadata in the ERC-7572 style. updateContractURI(newUri) is gated by METADATA_ROLE.

METADATA_ROLE controls:

  • updateName(newName) — changes name and cycles the EIP-712 domain separator, emitting NameUpdated alongside EIP712DomainChanged.
  • updateSymbol(newSymbol) — changes only symbol. Emits SymbolUpdated.

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.

ParameterDescription
variantASSET or STABLECOIN
saltCaller-chosen entropy for address derivation
paramsABI-encoded, variant-specific creation struct (versioned by leading byte)
initCallsOptional 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.

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 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_POLICY is enforced even inside initCalls.
  • Pause state is never bypassed.
  • Token invariants (the supply cap and so on) are never bypassed.

Each variant has a 1-byte identifier written directly into the token’s address (see Address derivation):

VariantByte
ASSET0x00
STABLECOIN0x01

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.

A WAD-precision rebase multiplier applied across all balance reads. Stored balances stay as-is; the multiplier only scales the value callers see.

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

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.

batchMint(recipients, amounts) mints to several recipients at once, gated by MINT_ROLE. It should be wrapped in announce() for transparency.

An open-ended store of issuer-set on-chain metadata, addressed by key.

MethodDescription
extraMetadata(key)Read a value by key
updateExtraMetadata(key, value)Write a value. Gated by METADATA_ROLE. Setting an empty value removes the entry.

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.

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.

PrecompileAddress
B20Factory0xB20f000000000000000000000000000000000000
Activation Registry0x8453000000000000000000000000000000000001
Policy registry0x8453000000000000000000000000000000000002

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.

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