documentation

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

viem-nativetree-shakeablezero runtime depsgolden-vector CIMIT
New to B20? Read B20 in 2 minutes first, then the Quickstart.

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 variantsASSET (configurable 6–18 decimals, rebase multiplier, batch mint) and STABLECOIN (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 bytes32 on transfers/mints/burns, emitted as a Memo event 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.

bash
pnpm add @b20labs/sdk viem
# or:  npm i @b20labs/sdk viem
# or:  yarn add @b20labs/sdk viem

Quickstart — deploy a B20#

A single createB20 call deploys the token and runs your bootstrap initCalls atomically.

typescript
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);
The salt is yours to choose — the same (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.

typescript
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
];
The bypass is not total: 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)
decimals6–18 (set at creation, immutable)fixed 6
identityISO currency() code (A–Z)
extrasrebase multiplier, announcements, batchMint, OPERATOR_ROLE
encoderencodeAssetCreateParamsencodeStablecoinCreateParams

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.

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

scopegates
TRANSFER_SENDER_POLICYthe from account in a transfer
TRANSFER_RECEIVER_POLICYthe to account in a transfer
TRANSFER_EXECUTOR_POLICYmsg.sender in transferFrom (when distinct)
MINT_RECEIVER_POLICYthe 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.

typescript
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…");                       // → false

layout: [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.

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

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

typescript
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;                // 84532

Want the full holder distribution, transfer history with memo joins, and safety verdicts? The Explorer is built on this SDK.

Constants reference#

exportvalue
B20_FACTORY0xB20f000000000000000000000000000000000000
ACTIVATION_REGISTRY0x8453…0001
POLICY_REGISTRY0x8453…0002
MAX_SUPPLY_CAPtype(uint128).max — the "no cap" sentinel
ROLESMINT / BURN / BURN_BLOCKED / PAUSE / UNPAUSE / METADATA / OPERATOR
FEATURE_KEYSkeccak256("base.b20_asset") / ("base.b20_stablecoin")

Error reference#

errormeaning
UnsupportedVersionparams version byte ≠ 1 — encoder mismatch
TokenAlreadyExistssalt reused for this (variant, deployer)
InvalidDecimalsASSET decimals outside 6–18
InvalidCurrencystablecoin currency not uppercase A–Z
InitCallFailed(index)a bootstrap initCall reverted — index surfaced
PolicyForbids(scope, id)account not authorized under a policy
SupplyCapExceededmint would exceed the cap
FeatureNotActivatedvariant not activated on this network
LastAdminCannotRenounceuse renounceLastAdmin() to go admin-less

All 48 errors are in B20_ERRORS_ABI; unknown data returns null.

Networks#

networkchain idstatus
Base Mainnet8453● B20 live (since Jul 8)
Base Sepolia84532● live
Vibenet84538453● live (devnet)
local base-anvil31337base-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.

No firm date and no published RPC method names yet. Watch this page — we ship the typed bindings the moment Base publishes the spec, and we track the devnet branches so you don't have to.
Built by B20 Labs · @b20lab · official B20 spec ↗