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

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 have no inherent effect — B20 only enforces the seven roles listed above.

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 transferPolicyAdmin(policyId, newAdmin), then the nominee calls acceptPolicyAdmin(policyId). Calling renouncePolicyAdmin(policyId) freezes the policy for good — its membership can never change again.

// Create a policy
uint64 policyId = policyRegistry.createPolicy(PolicyType.BLOCKLIST, adminAddress);
// Update membership (batched)
policyRegistry.addToPolicy(policyId, accounts);
policyRegistry.removeFromPolicy(policyId, 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 (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(uint256).max signals no cap, which is what a token starts with. updateSupplyCap(newCap) is admin-gated and emits SupplyCapUpdated, and a cap set beneath the current totalSupply reverts with InvalidSupplyCap.

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 IB20Factory precompile via createB20(variant, params, initCalls, salt).

ParameterDescription
variantASSET or STABLECOIN
paramsABI-encoded, variant-specific creation struct (versioned by leading byte)
initCallsOptional array of ABI-encoded calls dispatched post-creation with admin privilege bypass
saltCaller-chosen entropy for 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. The factory also exposes getB20Address(variant, deployer, salt), isB20(addr), and isB20Initialized(addr).

initCalls run after creation with an admin-privilege bypass, so configuration (setting policies, granting roles) can happen in the same transaction as deployment. That bypass is only 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.

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 introduces an OPERATOR_ROLE covering the capabilities below.

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.

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.