Launch a B20 Token
A B20 token needs no contract of your own. The B20 standard lives in the chain as a precompile, so access control, pause switches, cap enforcement, policy gates, memo fields, and ERC-2612 permit are all already there before you write a line of Solidity. One call to the factory hands back a configured token. Nothing to deploy, nothing to audit, nothing to maintain.
This guide takes an Asset token from nothing to a verified onchain balance. The reference material sits elsewhere: the specification for behaviour, the precompile reference for selectors and ABIs.
Pick a variant first
Section titled “Pick a variant first”The singleton B20 Factory builds both variants, and the choice is fixed at creation:
- Asset — decimals are yours to set anywhere in
[6, 18]. It also gains batched issuance, announcements published onchain, and a rebase multiplier. Reach for it with long-tail tokens, real-world assets, and equities. - Stablecoin — decimals are pinned at 6, and the token carries an immutable ISO currency code instead. This is the variant for fiat-backed tokens.
Past that split the two behave identically. Both get the same roles, caps, policies, pause controls, memos, and permit.
The steps below use ASSET. A stablecoin swaps two lines, shown at the end of the create step.
Install Base’s Foundry build
Section titled “Install Base’s Foundry build”Stock Foundry cannot do this work. B20 precompiles sit at fixed addresses that hold no bytecode, so forge refuses the call with call to non-contract address. Base ships base-forge, base-cast, and base-anvil, which register those addresses into the EVM. They install beside your existing Foundry rather than over it.
Pin the Beryl-compatible release:
curl -L https://raw.githubusercontent.com/base/base-anvil/HEAD/foundryup/install | bashbase-foundryup --install v1.1.1Confirm the feature is switched on
Section titled “Confirm the feature is switched on”Each variant is gated separately by the Activation Registry. Deploy against a network where the gate is closed and the call reverts with FeatureNotActivated. Check before you spend gas — query the variant you plan to use and read true back:
REG=0x8453000000000000000000000000000000000001 # Activation Registry precompileRPC=https://mainnet.base.org # replace with your target network (e.g. https://sepolia.base.org, https://rpc.vibes.base.org)
base-cast call $REG "isActivated(bytes32)(bool)" $(base-cast keccak "base.b20_asset") --rpc-url $RPCbase-cast call $REG "isActivated(bytes32)(bool)" $(base-cast keccak "base.b20_stablecoin") --rpc-url $RPCSet up the project
Section titled “Set up the project”base-forge init b20-quickstart && cd b20-quickstartThat pulls in Base Standard Library v1.0.0, which is where the shipped Beryl interfaces live, along with the constants and the helpers that encode calls for you.
Then add the remappings to foundry.toml, under [profile.default]. The base = true flag is what puts the precompiles inside the build’s EVM, which is what lets the deploy script simulate its own factory call locally:
base = trueremappings = [ "base-std/=lib/base-std/src/", "base-std-test/=lib/base-std/test/",]Any Solidity in >=0.8.20 <0.9.0 compiles the interfaces.
Choose a network
Section titled “Choose a network”Create a .env inside b20-quickstart for the network you picked. Connecting to Base has the full network table.
| Setting | Value |
|---|---|
| RPC URL | https://mainnet.base.org |
| Chain ID | 8453 |
| Explorer | basescan.org |
export RPC_URL="https://mainnet.base.org"export PRIVATE_KEY="0x..."export ACCOUNT_ADDRESS="0x..."export CHAIN_ID="8453"| Setting | Value |
|---|---|
| RPC URL | https://sepolia.base.org |
| Chain ID | 84532 |
| Faucet | CDP Faucet |
| Explorer | sepolia.basescan.org |
export RPC_URL="https://sepolia.base.org"export PRIVATE_KEY="0x..."export ACCOUNT_ADDRESS="0x..."export CHAIN_ID="84532"| Setting | Value |
|---|---|
| RPC URL | https://rpc.vibes.base.org/ |
| Chain ID | 84538453 |
| Faucet | chain.base.org/vibenet/faucet |
| Explorer | chain.base.org/vibenet/explorer |
export RPC_URL="https://rpc.vibes.base.org/"export PRIVATE_KEY="0x..."export ACCOUNT_ADDRESS="0x..."export CHAIN_ID="84538453"Run a Base node in another terminal:
base-anvilAnvil’s first pre-funded account is enough:
export RPC_URL="http://127.0.0.1:8545"export PRIVATE_KEY="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"export ACCOUNT_ADDRESS="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"export CHAIN_ID="31337"base-cast wallet new prints a fresh address and key if you need one. Check the account holds gas before continuing — it signs both the create and the mint, and the supply lands there:
source .envbase-cast balance $ACCOUNT_ADDRESS --rpc-url $RPC_URLAgainst local anvil that prints 10000000000000000000000, its default 10,000 ETH. Anywhere else, any non-zero number will do.
Create the token
Section titled “Create the token”The factory exposes one entry point, createB20(variant, salt, params, initCalls), and its four arguments are the whole configuration surface:
variant—ASSETorSTABLECOIN.salt— entropy you choose, which determines the token’s deterministic address.params— name, symbol, first admin, and decimals, ABI-encoded.initCalls— configuration applied in the same transaction as creation.
Encode both params and initCalls with B20FactoryLib. Write script/CreateToken.s.sol:
// SPDX-License-Identifier: MITpragma solidity ^0.8.20;
import {Script, console} from "forge-std/Script.sol";
import {B20Constants} from "base-std/lib/B20Constants.sol";import {B20FactoryLib} from "base-std/lib/B20FactoryLib.sol";import {IB20Factory} from "base-std/interfaces/IB20Factory.sol";import {StdPrecompiles} from "base-std/StdPrecompiles.sol";
contract CreateToken is Script { function run() external returns (address token) { // For the quickstart, one account is admin + minter. address account = vm.envAddress("ACCOUNT_ADDRESS"); bytes32 salt = keccak256("my-first-b20");
// Name, symbol, initial DEFAULT_ADMIN_ROLE holder, decimals (6-18). bytes memory params = B20FactoryLib.encodeAssetCreateParams("My Token", "MYT", account, 18);
// Configuration applied atomically at creation. bytes[] memory initCalls = new bytes[](2); initCalls[0] = B20FactoryLib.encodeGrantRole(B20Constants.MINT_ROLE, account); initCalls[1] = B20FactoryLib.encodeUpdateSupplyCap(1_000_000e18);
vm.startBroadcast(); token = StdPrecompiles.B20_FACTORY.createB20(IB20Factory.B20Variant.ASSET, salt, params, initCalls); vm.stopBroadcast();
console.log("B20 token created at:", token); }}Two constraints worth knowing before you run it. Asset decimals are locked in at creation and have to land in [6, 18]. The supply cap is optional — pass type(uint128).max to mean no cap, which is also the ceiling the cap can never exceed.
Broadcast it:
source .envbase-forge script script/CreateToken.s.sol --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcastThe factory answers from 0xB20f000000000000000000000000000000000000, the same address on every network, and the tokens it mints all begin 0xB200.... The script logs the one it made:
== Logs == B20 token created at: 0xB200...A TokenAlreadyExists revert means keccak256("my-first-b20") has already been used by your account on that network. Change the salt, or restart base-anvil for a clean slate.
Pull the address out of the broadcast artifact so nothing needs copying by hand. Appending it to .env keeps it around for later steps and later shells:
TOKEN_ADDRESS=$(jq -er '.returns.token.value' \ broadcast/CreateToken.s.sol/$CHAIN_ID/run-latest.json) \ && echo "export TOKEN_ADDRESS=$TOKEN_ADDRESS" >> .env \ && source .env \ && echo "TOKEN_ADDRESS=$TOKEN_ADDRESS"The artifact path carries the chain ID, which is where the CHAIN_ID you exported earns its place.
Making a stablecoin instead
Section titled “Making a stablecoin instead”Change the variant and the params encoder. The ISO currency code is uppercase A–Z and immutable, and it takes the place of the decimals argument:
bytes memory params = B20FactoryLib.encodeStablecoinCreateParams("My USD", "MUSD", account, "USD");
token = StdPrecompiles.B20_FACTORY.createB20(IB20Factory.B20Variant.STABLECOIN, salt, params, initCalls);Roles, caps, minting, and verification are unchanged.
Mint and verify
Section titled “Mint and verify”initCalls already granted MINT_ROLE to your account, so this works straight away:
base-cast send $TOKEN_ADDRESS "mint(address,uint256)" $ACCOUNT_ADDRESS 1000000000000000000000 \ --rpc-url $RPC_URL --private-key $PRIVATE_KEYA receipt with status 1 (success) means it landed. Read the balance back:
base-cast call $TOKEN_ADDRESS "balanceOf(address)(uint256)" $ACCOUNT_ADDRESS --rpc-url $RPC_URL# 1000000000000000000000 [1e21]Search $TOKEN_ADDRESS in the explorer and the token is there.
Calling the token from an app
Section titled “Calling the token from an app”For application code rather than operational commands, upstream’s examples run on [email protected]. Nothing about the client is B20-specific — a B20 token answers the ERC-20 surface natively, so an ordinary viem client reads and writes it:
mkdir b20-viem && cd b20-viemnpm init -yimport { createPublicClient, createWalletClient, http } from 'viem';import { privateKeyToAccount } from 'viem/accounts';import { baseSepolia } from 'viem/chains';
if (!process.env.PRIVATE_KEY) throw new Error('Set PRIVATE_KEY');export const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);export const publicClient = createPublicClient({ chain: baseSepolia, transport: http(process.env.RPC_URL ?? 'https://sepolia.base.org'),});export const walletClient = createWalletClient({ account, chain: baseSepolia, transport: http(process.env.RPC_URL ?? 'https://sepolia.base.org'),});Scripts that create policies need jq as well, to lift PolicyCreated out of a transaction receipt.
Where to go next
Section titled “Where to go next”You now have a token with an admin, a minter, a supply cap, and real supply — none of it written by you.
- Gate transfers or mints behind PolicyRegistry policies, narrow the pause surface, or hand roles around. The B20 specification covers all three.
- Tokenized Stocks shows the same primitives configured for a real-world underlying.
- B20 precompiles lists every selector, event
topic0, and revert. - B20 invariants states what the chain guarantees regardless of configuration.