Skip to content
BaseHub by wbnns Updated

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.

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.

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:

Terminal window
curl -L https://raw.githubusercontent.com/base/base-anvil/HEAD/foundryup/install | bash
base-foundryup --install v1.1.1

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:

Terminal window
REG=0x8453000000000000000000000000000000000001 # Activation Registry precompile
RPC=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 $RPC
base-cast call $REG "isActivated(bytes32)(bool)" $(base-cast keccak "base.b20_stablecoin") --rpc-url $RPC
Terminal window
base-forge init b20-quickstart && cd b20-quickstart
base-forge install base/[email protected] --no-git

That 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 = true
remappings = [
"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.

Create a .env inside b20-quickstart for the network you picked. Connecting to Base has the full network table.

SettingValue
RPC URLhttps://mainnet.base.org
Chain ID8453
Explorerbasescan.org
Terminal window
export RPC_URL="https://mainnet.base.org"
export PRIVATE_KEY="0x..."
export ACCOUNT_ADDRESS="0x..."
export CHAIN_ID="8453"

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:

Terminal window
source .env
base-cast balance $ACCOUNT_ADDRESS --rpc-url $RPC_URL

Against local anvil that prints 10000000000000000000000, its default 10,000 ETH. Anywhere else, any non-zero number will do.

The factory exposes one entry point, createB20(variant, salt, params, initCalls), and its four arguments are the whole configuration surface:

  • variantASSET or STABLECOIN.
  • 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: MIT
pragma 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:

Terminal window
source .env
base-forge script script/CreateToken.s.sol --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast

The 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:

Terminal window
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.

Change the variant and the params encoder. The ISO currency code is uppercase AZ 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.

initCalls already granted MINT_ROLE to your account, so this works straight away:

Terminal window
base-cast send $TOKEN_ADDRESS "mint(address,uint256)" $ACCOUNT_ADDRESS 1000000000000000000000 \
--rpc-url $RPC_URL --private-key $PRIVATE_KEY

A receipt with status 1 (success) means it landed. Read the balance back:

Terminal window
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.

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:

Terminal window
mkdir b20-viem && cd b20-viem
npm init -y
npm install [email protected]
import { 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.

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.