@b20labs/sdk
The canonical open TypeScript SDK for B20 — Base's native token standard. Byte-exact encoders proven against base-std by a golden-vector differential test, zero-RPC address derivation, and typed decoding of every precompile error.
B20 in 2 minutes#
B20 is a token standard built into Base itself — implemented as Rust precompiles, not EVM contracts. It's a superset of ERC-20 (all ERC-20 calls work unchanged) with a native surface generic tooling can't see:
- ▸ Two variants —
ASSET(configurable 6–18 decimals, rebase multiplier, batch mint) andSTABLECOIN(fixed 6 decimals, ISO currency code). - ▸ Roles — MINT / BURN / BURN_BLOCKED / PAUSE / UNPAUSE / METADATA (+ OPERATOR on Asset), plus admin. Can be deployed admin-less.
- ▸ Policy Registry — allow/blocklists gating transfers & mints per scope. Unauthorized transfer →
PolicyForbids. - ▸ Granular pause — TRANSFER / MINT / BURN independently pausable.
- ▸ Memos — an optional
bytes32on transfers/mints/burns, emitted as aMemoevent for payment reconciliation. - ▸ Supply cap, freeze-and-seize (burnBlocked), ERC-2612 permit, contractURI.
Every token is created by a single createB20 call to the factory precompile at 0xB20f…0000, and each token gets a deterministic address that encodes its variant.
Install#
viem is a peer dependency.
pnpm add @b20labs/sdk viem
# or: npm i @b20labs/sdk viem
# or: yarn add @b20labs/sdk viemQuickstart — deploy a B20#
A single createB20 call deploys the token and runs your bootstrap initCalls atomically.
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
import {
encodeAssetCreateParams,
buildRoleGrants,
buildCreateB20Tx,
B20Variant,
} from "@b20labs/sdk";
const account = privateKeyToAccount(process.env.PK as `0x${string}`);
const wallet = createWalletClient({ account, chain: base, transport: http() });
// 1. encode token params (name, symbol, admin, decimals)
const params = encodeAssetCreateParams("My Token", "MYT", account.address, 18);
// 2. bootstrap: grant MINT_ROLE to the deployer
const initCalls = buildRoleGrants({ minter: account.address }, B20Variant.ASSET);
// 3. build + send the unsigned tx
const salt = `0x${"00".repeat(31)}01` as const;
const tx = buildCreateB20Tx({ variant: B20Variant.ASSET, salt, params, initCalls });
const hash = await wallet.sendTransaction(tx);(variant, deployer, salt) always maps to the same address, so you can predict it before deploying. Reusing a salt reverts with TokenAlreadyExists.initCalls & the bootstrap window#
initCalls are calls executed on the fresh token inside the creation transaction. During this bootstrap window, factory-originated calls bypass role gates and transfer-side policy gates — so you can grant roles, set a cap, wire policies, and mint in one atomic deploy without holding any role.
import {
buildRoleGrants, encodeUpdateSupplyCap, encodeUpdatePolicy,
ROLES, POLICY_SCOPES, MAX_SUPPLY_CAP, B20Variant,
} from "@b20labs/sdk";
const initCalls = [
...buildRoleGrants(
{ minter: deployer, pauser: deployer, metadataAdmin: deployer },
B20Variant.ASSET,
),
encodeUpdateSupplyCap(1_000_000n * 10n ** 18n), // cap at 1M
encodeUpdatePolicy(POLICY_SCOPES.TRANSFER_SENDER_POLICY, 42n), // wire policy #42
];MINT_RECEIVER_POLICY is always enforced (even for factory mints), pause is never bypassed (sequence a start-paused config's pause call last), and supply-cap/balance invariants always hold. A bad initCall reverts the whole deploy — decodeB20Error tells you exactly which index failed.Asset vs Stablecoin#
| ASSET (0x00) | STABLECOIN (0x01) | |
|---|---|---|
| decimals | 6–18 (set at creation, immutable) | fixed 6 |
| identity | — | ISO currency() code (A–Z) |
| extras | rebase multiplier, announcements, batchMint, OPERATOR_ROLE | — |
| encoder | encodeAssetCreateParams | encodeStablecoinCreateParams |
The ASSET multiplier is a wstETH-style scaled view — balanceOf stays raw and updateMultiplier does not rewrite balances; the scaled view derives at read time. That means Transfer-sum reconstruction of balances is unaffected by rebases.
import { encodeStablecoinCreateParams, buildCreateB20Tx, B20Variant } from "@b20labs/sdk";
const params = encodeStablecoinCreateParams("USD Coin", "USDC", admin, "USD");
const tx = buildCreateB20Tx({ variant: B20Variant.STABLECOIN, salt, params });Policy Registry#
Policies gate transfers and mints per scope. Each scope defaults to ALWAYS_ALLOW (policy id 0). A policy is either a BLOCKLIST (default authorized) or an ALLOWLIST (default denied).
| scope | gates |
|---|---|
TRANSFER_SENDER_POLICY | the from account in a transfer |
TRANSFER_RECEIVER_POLICY | the to account in a transfer |
TRANSFER_EXECUTOR_POLICY | msg.sender in transferFrom (when distinct) |
MINT_RECEIVER_POLICY | the recipient of a mint (always enforced) |
approve() is not policy-gated — only balance movement is checked. A token can approve fine yet revert transferFrom with PolicyForbids. Simulate before you sign, and surface this in any UI.Addresses — zero-RPC#
A B20's address is deterministic and encodes its variant in byte 10 — predict it before deploying and classify any address without an RPC call.
import { getB20Address, variantOf, isB20Address, B20Variant } from "@b20labs/sdk";
const predicted = getB20Address(B20Variant.ASSET, deployer, salt); // pure, no RPC
variantOf("0xb2000000000000000000018e…"); // → B20Variant.STABLECOIN
isB20Address("0xb2000000000000000000018e…"); // → true
isB20Address("0x1234…"); // → falselayout: [10-byte prefix][1-byte variant][9-byte keccak256(deployer, salt)]
Error decoding#
Precompile reverts are opaque by default. decodeB20Error maps all 48 custom errors to typed results — and for a failed deploy it surfaces the exact failing initCall index.
import { decodeB20Error } from "@b20labs/sdk";
const decoded = decodeB20Error(revertData);
// {
// errorName: "InitCallFailed",
// failingInitCallIndex: 2,
// args: [2n],
// message: "Bootstrap initCall #2 reverted — inspect that call.",
// }
if (decoded?.errorName === "PolicyForbids") {
// decoded.args = [policyScope, policyId]
}Activation gates#
B20 activates per-variant via the Activation Registry. Never gate writes on the calendar — the registry can lag a scheduled activation (mainnet ran ~4h late). Check on-chain, or deploys revert with FeatureNotActivated.
import { createPublicClient, http } from "viem";
import { base } from "viem/chains";
import { isVariantActivated, B20Variant } from "@b20labs/sdk";
const client = createPublicClient({ chain: base, transport: http() });
if (!(await isVariantActivated(client, B20Variant.ASSET))) {
throw new Error("B20 ASSET is not activated on this network yet");
}Reading tokens#
B20 is an ERC-20 superset, so balanceOf/transfer/etc. work with any viem client. The SDK adds typed constants for the native surface — role & policy ids, precompile addresses, chain ids.
import { ROLES, POLICY_SCOPES, POLICY_REGISTRY, CHAIN_IDS } from "@b20labs/sdk";
ROLES.MINT_ROLE; // keccak256("MINT_ROLE")
POLICY_SCOPES.TRANSFER_SENDER_POLICY; // a gate scope
POLICY_REGISTRY; // 0x8453…0002
CHAIN_IDS.BASE_SEPOLIA; // 84532Want the full holder distribution, transfer history with memo joins, and safety verdicts? The Explorer is built on this SDK.
Constants reference#
| export | value |
|---|---|
B20_FACTORY | 0xB20f000000000000000000000000000000000000 |
ACTIVATION_REGISTRY | 0x8453…0001 |
POLICY_REGISTRY | 0x8453…0002 |
MAX_SUPPLY_CAP | type(uint128).max — the "no cap" sentinel |
ROLES | MINT / BURN / BURN_BLOCKED / PAUSE / UNPAUSE / METADATA / OPERATOR |
FEATURE_KEYS | keccak256("base.b20_asset") / ("base.b20_stablecoin") |
Error reference#
| error | meaning |
|---|---|
UnsupportedVersion | params version byte ≠ 1 — encoder mismatch |
TokenAlreadyExists | salt reused for this (variant, deployer) |
InvalidDecimals | ASSET decimals outside 6–18 |
InvalidCurrency | stablecoin currency not uppercase A–Z |
InitCallFailed(index) | a bootstrap initCall reverted — index surfaced |
PolicyForbids(scope, id) | account not authorized under a policy |
SupplyCapExceeded | mint would exceed the cap |
FeatureNotActivated | variant not activated on this network |
LastAdminCannotRenounce | use renounceLastAdmin() to go admin-less |
All 48 errors are in B20_ERRORS_ABI; unknown data returns null.
Networks#
| network | chain id | status |
|---|---|---|
| Base Mainnet | 8453 | ● B20 live (since Jul 8) |
| Base Sepolia | 84532 | ● live |
| Vibenet | 84538453 | ● live (devnet) |
| local base-anvil | 31337 | base-foundryup |
Precompile addresses are identical on every B20-enabled network.
Cobalt-ready#
Cobalt (Base's next hard fork, soft-targeted ~Sep 2026) adds native indexed-data RPC, gas-in-B20, native account abstraction (EIP-8130) and virtual addresses. The SDK will expose these as capability-flagged modules — detected at runtime, so your code works pre- and post-Cobalt behind one flag, with no rewrite.