ERC-20 compatibility
B20 is a superset of ERC-20. Every ERC-20 call (
transfer, transferFrom, approve, balanceOf, allowance, and the standard events) behaves exactly as the standard specifies, so existing ERC-20 tooling and integrations work against B20 with no changes.permit. These extend ERC-20 without altering it - every ERC-20 method exists on B20, but the reverse does not hold. For the complete ABIs, see the interface definitions in the Base Standard Library.
Roles model
B20 role-based access control extends OpenZeppelinAccessControl with a fixed set of roles and one behavioral override on admin renunciation.
User-defined roles are supported via
setRoleAdmin and grantRole. They carry no built-in effect - B20 only enforces gates against the seven base-surface roles above. The Asset variant adds an eighth role, OPERATOR_ROLE (see Variants).
Run a token without an admin
The lastDEFAULT_ADMIN_ROLE holder cannot be removed via renounceRole or revokeRole (both revert with LastAdminCannotRenounce). The dedicated renounceLastAdmin() is the only path to permanently transition a token to admin-less.
Tokens that intend to launch admin-less from the start pass initialAdmin == address(0) at creation, which never grants the role and skips the renounceLastAdmin step entirely.
After renounceLastAdmin() (or for tokens deployed with initialAdmin == address(0)):
- Operations gated by
DEFAULT_ADMIN_ROLEbecome permanently uncallable. - Roles already granted to other addresses (
MINT_ROLE,BURN_ROLE, etc.) continue to function independently. - Admin resurrection is blocked:
grantRole,revokeRole, andsetRoleAdminall revert withAccessControlUnauthorizedAccounteven if the caller holds a custom role.
PolicyRegistry
The PolicyRegistry is a singleton precompile that manages allowlists and blocklists. B20 tokens reference policies byuint64 ID. Any caller can create a policy and nominate its admin.
State-changing functions on the PolicyRegistry are gated by the ActivationRegistry, which tracks which Base features are live. Read functions (
isAuthorized, policyExists, policyAdmin, pendingPolicyAdmin) are always callable.Policy types
Policy IDs
Policy IDs areuint64 values. The top byte encodes the PolicyType; the low 56 bits are a global counter starting at 2.
Two built-in IDs require no creation:
isAuthorized never reverts on a non-existent policy ID - it collapses to empty-member-set semantics (non-existent BLOCKLIST authorizes everyone; non-existent ALLOWLIST denies everyone).
Policy admin
Each policy has one admin. Admin transfers are two-step: the current admin callsstageUpdateAdmin(policyId, newAdmin), then the pending admin calls finalizeUpdateAdmin(policyId). renounceAdmin(policyId) permanently freezes the policy - membership can never be changed again.
Create and manage policies
Read a policy from the PolicyRegistry
Token policy scopes
B20 declares a fixed set of policy scopes. Each scope stores auint64 policy ID pointing into the PolicyRegistry. On every gated operation, B20 calls isAuthorized against the relevant scope and reverts with PolicyForbids if the account is not authorized.
approve is not policy-gated - only actual balance movement via transfer / transferFrom is checked.
Read a token’s policy assignments
Each B20 token exposes its scopes and their current policy IDs directly, so integrators can discover which policies apply without simulating a transfer.
To change an assignment, the token admin calls
updatePolicy(scope, policyId), which emits PolicyUpdated and reverts if the scope is not recognized.
Check if a transfer will succeed
To confirm an account can move tokens, read each scope’s policy ID from the token and check it against the PolicyRegistry, then check pause state:Read-only transferability check
transfer, drop the executor check. Balance and allowance checks still apply as in ERC-20, and policy or pause state can change between the read and the transfer.
Token operations
The token surface beyond ERC-20: issuance, memos, pause, signed approvals, and metadata.Mint
New supply is created viamint / mintWithMemo, gated by MINT_ROLE. The recipient is policy-checked against MINT_RECEIVER_POLICY. The operation reverts with SupplyCapExceeded if it would push totalSupply past the cap.
Burn
Two burn paths exist:burn/burnWithMemo- caller burns from their own balance. Gated byBURN_ROLE.burnBlocked- burns from a third party’s balance. Gated byBURN_BLOCKED_ROLE. The target account MUST be denied byTRANSFER_SENDER_POLICY- this is the freeze-and-seize path for regulated issuers.
Supply cap
The supply cap is optional. The sentineltype(uint128).max indicates no cap (the default at creation); it is also the maximum permitted cap, so totalSupply can never exceed it. updateSupplyCap(newCap) is admin-gated and emits SupplyCapUpdated. It reverts with InvalidSupplyCap if newCap is below the current totalSupply or above type(uint128).max.
Memos
A memo is an optionalbytes32 payload attached to a token operation for offchain reference. Every memo-emitting operation emits Memo(address indexed caller, bytes32 indexed memo) immediately after the operation’s primary event. Indexers join via (transactionHash, logIndex − 1).
Memo-emitting entrypoints: transferWithMemo, transferFromWithMemo, mintWithMemo, burnWithMemo.
Pause
Pauses are granular: thePausableFeature enum partitions the token surface into independently pausable operations - TRANSFER, MINT, and BURN. The enum is append-only. pause(features) and unpause(features) are gated by separate roles (PAUSE_ROLE and UNPAUSE_ROLE) by design.
Pause state is publicly readable:
Pause changes emit
Paused(address indexed updater, PausableFeature[] features) and Unpaused(address indexed updater, PausableFeature[] features).
Permit (ERC-2612)
B20 implements ERC-2612 (signed approvals) using an EIP-712 domain shaped as(name, version, chainId, verifyingContract), with version fixed at "1". updateName rotates the domain separator and emits EIP712DomainChanged (ERC-5267). ERC-1271 contract signatures are not accepted - ECDSA only.
Contract URI (ERC-7572)
contractURI() returns a string pointing to offchain metadata per ERC-7572. updateContractURI(newUri) is gated by METADATA_ROLE.
Update name and symbol
METADATA_ROLE gates:
updateName(newName)- updatesnameand rotates the EIP-712 domain separator. EmitsNameUpdatedandEIP712DomainChanged.updateSymbol(newSymbol)- updatessymbolonly. EmitsSymbolUpdated.
B20Factory
All B20 tokens are created through the singleton B20Factory precompile viacreateB20(variant, salt, params, initCalls). In base-std it is exposed as StdPrecompiles.B20_FACTORY.
createB20 reverts with IActivationRegistry.FeatureNotActivated if the requested variant’s feature is not yet activated on the chain.
Address derivation
B20 addresses are deterministic and encode the variant directly:getB20Address(variant, deployer, salt), isB20(addr), and isB20Initialized(addr) are available on the factory.
initCalls semantics
initCalls are dispatched after token creation. During this bootstrap window, factory-originated calls bypass the token’s role gates and its transfer-side policy gates (TRANSFER_SENDER_POLICY, TRANSFER_RECEIVER_POLICY, TRANSFER_EXECUTOR_POLICY), allowing admin-gated configuration (e.g. setting policies, granting roles) and bootstrap transfers in the same transaction as deployment. The bypass is deliberately not total:
MINT_RECEIVER_POLICYis always enforced even duringinitCalls.- Pause state is never bypassed.
- Token invariants (supply cap, etc.) are never bypassed.
Variants
Each variant is identified by a 1-byte value encoded directly in the token’s address (see Address derivation):Asset
The general-purpose variant for assets of all kinds. Decimals are configurable between 6 and 18 at deployment time and are immutable after creation. In addition to the base B20 surface, Asset tokens add several capabilities. A newOPERATOR_ROLE gates the multiplier and announcements; batch mint and extra metadata reuse the existing MINT_ROLE and METADATA_ROLE respectively.
Multiplier
A WAD-precision rebase multiplier applied to all balance reads. Raw balances are stored unchanged; the multiplier scales the view returned to callers.Announcements
Onchain disclosure brackets that wrap sensitive operations (e.g. batch mints, multiplier updates) with a public notice period. Gated byOPERATOR_ROLE.
announce(internalCalls, id, description, uri) emits an Announcement event, executes internalCalls, then emits EndAnnouncement. The id must be unique and is enforced forever. Inner call reverts are wrapped in InternalCallFailed.
internalCalls is executed but not emitted in either event (see Events for the full signatures). Apps that need the call payloads must read them from the transaction calldata.
Batch mint
batchMint(recipients, amounts) mints to multiple recipients in a single call. Gated by MINT_ROLE. Should be wrapped in announce() for transparency.
Extra metadata
An arbitrary key/value store for issuer-defined onchain metadata.Stablecoin
The fixed-decimals, fiat-backed carveout. Decimals are hard-wired to6 and are not configurable.
Adds currency(), which returns an ISO-style currency code string (e.g. "USD", "EUR"). The code is set once at creation via B20StablecoinCreateParams.currency, restricted to characters A-Z only. It is self-declared and not verified against any external registry.
Events
Full event interfaces, verbatim from the Base Standard Library.indexed parameters are the log topics apps can filter on.
All B20 token events
Every B20 token emits these, regardless of variant.Asset variant events
PolicyRegistry events
B20Factory events
variantEventParams carries variant-specific immutable identity data, ABI-encoded and prefixed with a version byte: empty for ASSET; the currency code for STABLECOIN.
ActivationRegistry events
Precompile addresses
These addresses are identical on every network where B20 is active (Mainnet, Base Sepolia, Vibenet, and localbase-anvil).