Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@

## Recommended Skills

Two consolidated skills that cover the most common use cases. Each uses progressive reference loading — the skill loads a single entry point and pulls in detailed references only when needed.
Skills that cover the most common use cases. Each uses progressive reference loading — the skill loads a single entry point and pulls in detailed references only when needed.

| Skill | Install | Description |
| ----- | ------- | ----------- |
| [build-on-base](./skills/build-on-base/SKILL.md) | `npx skills add base/skills --skill build-on-base` | Complete Base development playbook: network, contracts, wallet auth, payments, attribution, and migrations. Consolidates all individual skills into one. |
| [base-mcp](./skills/base-mcp/SKILL.md) | `npx skills add base/skills --skill base-mcp` | Base MCP server — gives your AI assistant a wallet via mcp.base.org. Sending, swapping, signing, batched calls, balances, and partner plugins for lending, swaps, and more. |
| [b20-token](./skills/b20-token/SKILL.md) | `npx skills add base/skills --skill b20-token` | Deploy, operate, and accept payments in B20 tokens — Base's native ERC-20-superset precompile. Roles, supply caps, policy gating, memos, and the client-side encoding gotchas that trip up wallet-signed integrations. |

## Installation

Expand Down
95 changes: 95 additions & 0 deletions skills/b20-token/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
name: b20-token
description: >
Deploy, operate, and accept payments in B20 tokens on Base — Base's native ERC-20-superset
precompile (roles, supply caps, pausing, policy gating, memos, permit). Covers setup
(base-foundryup, base-std), checking Activation Registry status before deploying (avoids
FeatureNotActivated), createB20 for ASSET/STABLECOIN variants including the abi.encode(struct)
single-tuple encoding gotcha for JS/TS clients, the full roles and admin model (MINT_ROLE,
BURN_ROLE, PAUSE_ROLE, renounceLastAdmin), the PolicyRegistry allowlist/blocklist compliance
model and its open-by-default trap, post-deploy minting/supply-cap/memo operations, accepting
B20 payments with memo-based order tracking in an app/merchant integration, ASSET-only features
(multiplier, announce, batchMint), metadata/permit (ERC-2612, ERC-7572), and the full
custom-error catalog with fixes. Use when deploying, minting, accepting payments in, configuring
roles/policies on, or debugging errors from a B20 token.
---

# B20 Tokens on Base

Playbook for the full B20 lifecycle on Base — Base's native ERC-20-superset precompile: deploying
(Foundry CLI or a wallet-signed viem/wagmi UI), operating (roles, policy, supply), and accepting
B20 as payment in an app.

## What B20 Is

B20 is an ERC-20 superset that runs as a **precompile**, not a deployed contract. There's no
bytecode to verify on an explorer — the logic lives at the protocol level. Two variants:

| Variant | Decimals | Identity |
|---------|----------|----------|
| `ASSET` | Configurable, 6–18 | General-purpose: in-game currencies, loyalty points, reward tokens |
| `STABLECOIN` | Fixed at 6 | Immutable self-declared ISO-style currency code (e.g. `USD`) |

## Safety Guardrails

- **Never assume a network has B20 live.** Check the Activation Registry first (see
[references/activation-check.md](references/activation-check.md)) — attempting `createB20`
before activation reverts with `FeatureNotActivated`, and as of writing this is true on Base
Mainnet even though the precompile addresses already respond to calls.
- **Never hand-roll `abi.encode(struct)` as a flat parameter list in JS/TS.** Solidity wraps a
single struct argument as one dynamic tuple (an outer offset slot + the tuple's own head/tail),
not a flat field sequence. Getting this wrong produces calldata that looks plausible but reverts
with `AbiDecodeFailed` or similar. See [references/encoding.md](references/encoding.md).
- **Never reuse a `salt` for the same `(variant, sender)` pair** — it deterministically derives the
same address and reverts `TokenAlreadyExists` on the second attempt. Derive a fresh salt per
deploy (e.g. hash of symbol + timestamp + randomness).
- **Deploying never mints.** `createB20` only creates the token and runs `initCalls` (role grants,
supply cap). Minting is a separate `mint()` call requiring `MINT_ROLE`.
- **Use `base-forge`/`base-cast`/`base-anvil`, never vanilla `forge`/`cast`/`anvil`** — standard
Foundry can't simulate the precompile and aborts with "call to non-contract address."
- **Don't assume a deployed token is compliance-gated just because it's B20.** Every policy scope
defaults to `ALWAYS_ALLOW` — an unattended deployment is fully open. See
[references/policy.md](references/policy.md).
- **`renounceLastAdmin()` is irreversible.** Walk the user through final role/policy assignments
before suggesting it. See [references/roles-and-admin.md](references/roles-and-admin.md).

## Task Routing

| Task | When to Use | Reference |
|------|-------------|-----------|
| **Setup** | Install `base-foundryup`, add `base-std`, configure `foundry.toml` | [references/setup.md](references/setup.md) |
| **Check activation** | Verify a network's Activation Registry has B20 live before deploying | [references/activation-check.md](references/activation-check.md) |
| **Encode params (JS/TS)** | Building a wallet-signed UI (viem/wagmi) instead of a Foundry script | [references/encoding.md](references/encoding.md) |
| **Deploy** | `createB20` call, address derivation, ASSET vs STABLECOIN params, salt, Foundry script | [references/deploy.md](references/deploy.md) |
| **Roles & admin** | Role list, custom roles, `renounceLastAdmin`, admin-less behavior, pause | [references/roles-and-admin.md](references/roles-and-admin.md) |
| **Policy / compliance gating** | PolicyRegistry, allowlist/blocklist, the 4 policy scopes, defaults | [references/policy.md](references/policy.md) |
| **Mint / supply cap / memo transfers** | Post-deploy token operations | [references/post-deploy.md](references/post-deploy.md) |
| **Accept B20 payments** | Merchant/app integration: receive payments tagged with an order ID, verify them | [references/accepting-payments.md](references/accepting-payments.md) |
| **ASSET-only features** | Multiplier/rebase, `announce()`, `batchMint`, extra metadata | [references/asset-variant.md](references/asset-variant.md) |
| **Metadata & permit** | `updateName`/`updateSymbol`, `contractURI`, ERC-2612 permit, EIP-712 | [references/metadata-and-permit.md](references/metadata-and-permit.md) |
| **Errors** | Decode a revert into what actually went wrong | [references/errors.md](references/errors.md) |

## Operating Procedure

1. **Check activation** on the target network first — don't waste a transaction finding out.
2. **Classify the task** using the table above and read the relevant reference before implementing.
3. **Confirm the path** with the user: Foundry CLI script vs a wallet-signed web UI (viem/wagmi) —
they need different encoding helpers (Solidity vs JS).
4. **Implement** with explicit network, unique salt, and the correct param encoding for the chosen
path.
5. **Deliver** the script/code, the exact deploy command, and a note on what `initCalls` (if any)
will run automatically — most users expect minting to happen on deploy; it doesn't.

## For Edge Cases and Latest API Changes

- **B20 token standard**: [docs.base.org/get-started/launch-b20-token](https://docs.base.org/get-started/launch-b20-token)
- **B20 spec (Beryl upgrade)**: [docs.base.org/base-chain/specs/upgrades/beryl/b20](https://docs.base.org/base-chain/specs/upgrades/beryl/b20)
- **Accepting B20 payments (memos)**: [docs.base.org/apps/guides/accept-b20-payments](https://docs.base.org/apps/guides/accept-b20-payments)
- **Query B20 events at scale**: [docs.cdp.coinbase.com/data/sql-api/b20-events](https://docs.cdp.coinbase.com/data/sql-api/b20-events)
- **AI-optimized docs index**: [docs.base.org/llms.txt](https://docs.base.org/llms.txt)

## Installation

```bash
npx skills add base/skills --skill b20-token
```
99 changes: 99 additions & 0 deletions skills/b20-token/references/accepting-payments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Accepting B20 Payments (Merchant/App Integration)

This is the *receiving* side of B20 — for an app or merchant collecting payments and matching them
to orders. (For the issuer-side operations — minting, roles, supply cap — see
[post-deploy.md](post-deploy.md).)

## Table of Contents

- [Why Memos Instead of Plain `transfer`](#why-memos-instead-of-plain-transfer)
- [Full Pattern](#full-pattern)
- [Allowance-Based Collection](#allowance-based-collection)
- [Pre-Flight Simulation](#pre-flight-simulation)
- [Indexing at Scale](#indexing-at-scale)

## Why Memos Instead of Plain `transfer`

Standard ERC-20 `transfer`/`transferFrom` give you no way to tie an incoming payment to a specific
order without an off-chain side-channel (e.g. asking the payer to message you separately). B20's
`transferWithMemo`/`transferFromWithMemo` attach a `bytes32` reference directly to the onchain
transfer — read it back from the same transaction that moved the funds.

B20 is a full ERC-20 superset, so existing `transfer`/`balanceOf`/`approve` integrations keep
working unchanged if you don't need memo tracking yet — this is purely additive.

## Full Pattern

Read decimals once, send a memo'd payment, read the memo back from the receipt:

```js
import { parseUnits, stringToHex, hexToString, parseEventLogs } from 'viem';

const TOKEN = '0xB200...'; // the B20 token you accept
const MERCHANT = '0x...'; // where payments land

const ABI = [
{ type: 'function', name: 'decimals', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] },
{ type: 'function', name: 'transferWithMemo', stateMutability: 'nonpayable',
inputs: [{ name: 'to', type: 'address' }, { name: 'amount', type: 'uint256' }, { name: 'memo', type: 'bytes32' }],
outputs: [{ type: 'bool' }] },
{ type: 'event', name: 'Memo', inputs: [
{ name: 'caller', type: 'address', indexed: true },
{ name: 'memo', type: 'bytes32', indexed: true },
] },
];

const decimals = await publicClient.readContract({ address: TOKEN, abi: ABI, functionName: 'decimals' });

const hash = await walletClient.writeContract({
address: TOKEN, abi: ABI, functionName: 'transferWithMemo',
args: [MERCHANT, parseUnits('10', decimals), stringToHex('order-42', { size: 32 })],
});

const receipt = await publicClient.waitForTransactionReceipt({ hash });
const [memo] = parseEventLogs({ abi: ABI, logs: receipt.logs, eventName: 'Memo' });
console.log(hexToString(memo.args.memo, { size: 32 }).replace(/\0+$/, '')); // "order-42"
```

**Always read `decimals()` from the token, never hardcode it** — STABLECOIN is fixed at 6, but
ASSET tokens can be anywhere in `[6, 18]`.

**Strip trailing null padding after `hexToString`** — `stringToHex(orderId, { size: 32 })` pads
the string to fill 32 bytes; `hexToString(..., { size: 32 })` decodes the full padded buffer, so
`.replace(/\0+$/, '')` is needed to get back the clean original string for display/comparison.

## Allowance-Based Collection

For pull-based payments (the merchant initiates, with prior `approve`), use
`transferFromWithMemo` instead — it "emits the same `Memo` event," so the read-back side of your
integration doesn't change:

```js
await walletClient.writeContract({
address: TOKEN, abi: ABI, functionName: 'transferFromWithMemo',
args: [payer, MERCHANT, parseUnits('10', decimals), stringToHex('order-42', { size: 32 })],
});
```

## Pre-Flight Simulation

B20 transfers can revert where a plain ERC-20 transfer wouldn't — a regulated issuer's
`TRANSFER_SENDER_POLICY`/`TRANSFER_RECEIVER_POLICY` (see [policy.md](policy.md)) or a paused
`TRANSFER` feature. Simulate with the same arguments before asking the payer to sign, so
`PolicyForbids`/`ContractPaused` surface as a clear error instead of a failed transaction after
gas was already spent:

```js
await publicClient.simulateContract({ address: TOKEN, abi: ABI, functionName: 'transferWithMemo', args: [...] });
```

See [errors.md](errors.md) for the full error catalog and a viem decoding pattern.

## Indexing at Scale

For matching payments to orders across many transactions (rather than reading one receipt right
after sending it), query `Transfer` and `Memo` events directly via the CDP SQL API rather than
running your own indexer:
[docs.cdp.coinbase.com/data/sql-api/b20-events](https://docs.cdp.coinbase.com/data/sql-api/b20-events).
Join `Transfer` and `Memo` rows on `(transactionHash, logIndex − 1)` — `Memo` is always emitted
immediately after the primary event it tags.
79 changes: 79 additions & 0 deletions skills/b20-token/references/activation-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Checking B20 Activation Before You Deploy

B20 features are gated by Base's `ActivationRegistry` precompile. A network can have the
`B20_FACTORY` precompile **responding to calls** while the actual ASSET/STABLECOIN feature is
still **not activated** — in that state, `createB20` reverts with `FeatureNotActivated`, even
though read-only calls like `getB20Address` succeed fine.

**Always verify activation before attempting a deploy on an unfamiliar network.** This avoids a
wasted transaction (and the confusing experience of a read call working while the write call
reverts).

Use the bundled script rather than retyping the commands:

```bash
scripts/check-activation.sh https://mainnet.base.org
# ASSET activated: false
# STABLECOIN activated: false
# (exits 1 — do not proceed with createB20 on this network)
```

Exit code `0` means both variants are activated; `1` means at least one isn't (or the registry
itself isn't reachable). The sections below explain what's happening if you need to debug it
manually instead.

## The Two Things to Check Are Different

| Check | What it tells you |
|-------|---|
| `eth_getCode` on `B20_FACTORY_ADDRESS` | **Nothing useful.** Precompiles are handled natively by the node, not via bytecode — this returns `0x` even when fully live. Don't use this as a liveness signal. |
| `ActivationRegistry.isActivated(feature)` | The actual, authoritative answer. |

## Command

```bash
REG=0x8453000000000000000000000000000000000001 # ActivationRegistry precompile
RPC=https://mainnet.base.org # or sepolia.base.org, 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
```

Both must return `true` for the variant you intend to deploy.

## Reading the Result

| Result | Meaning |
|--------|---|
| `isActivated` returns `true`/`false` cleanly | Registry is live; `false` means genuinely not activated yet — don't deploy. |
| `Error: contract ... does not have any code` on the **registry call itself** | The registry precompile isn't wired up on this network/node at all yet (earlier rollout stage than "not activated"). |
| `createB20` simulated and reverts `FeatureNotActivated` | Confirms the same thing at the factory level — useful as a second, independent check (see below). |

## Optional: Confirm via a Dry-Run Simulation

`base-cast call` on a non-view function performs an `eth_call` simulation without broadcasting —
safe to use as a definitive pre-flight check:

```bash
base-cast call 0xB20f000000000000000000000000000000000000 \
"createB20(uint8,bytes32,bytes,bytes[])(address)" \
1 0x0000000000000000000000000000000000000000000000000000000000000099 \
"$PARAMS" "[]" \
--rpc-url $RPC
```

If this reverts with selector `0xb9b2a425` (`FeatureNotActivated(bytes32)`), activation is
confirmed off — regardless of what any announcement or third party claims. Verify the selector
yourself rather than trusting prose:

```bash
base-cast sig "FeatureNotActivated(bytes32)"
# 0xb9b2a425
```

## Don't Trust Announcements Over the Chain

Activation timelines get communicated informally (docs, social posts, support bots) and can slip.
Treat any "B20 is live on mainnet" claim as a hypothesis, not a fact, until the registry itself
confirms it. The check above takes one RPC call — there's no reason to build on secondhand
information when the ground truth is a single command away.
88 changes: 88 additions & 0 deletions skills/b20-token/references/asset-variant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# ASSET-Variant-Only Features

These three capabilities exist **only** on `B20Variant.ASSET` tokens (variant byte `0x00`) — the
`STABLECOIN` variant doesn't have them.

## Multiplier (Rebase)

A WAD-precision (18-decimal fixed point) multiplier applied to all balance reads. Raw balances are
stored unchanged on-chain; the multiplier scales what callers see.

```solidity
function multiplier() external view returns (uint256); // current multiplier
function scaledBalanceOf(address account) external view returns (uint256); // raw balance × multiplier
function toScaledBalance(uint256 raw) external view returns (uint256);
function toRawBalance(uint256 scaled) external view returns (uint256);
function updateMultiplier(uint256 newMultiplier) external; // gated by OPERATOR_ROLE
```

Use case: rebasing tokens (e.g. a yield-bearing wrapper) without rewriting every holder's stored
balance on every rebase — only the multiplier changes.

## Announcements

On-chain disclosure brackets that wrap sensitive operations (batch mints, multiplier updates) with
a public notice period, gated by `OPERATOR_ROLE`:

```solidity
function announce(bytes[] calldata internalCalls, bytes32 id, string calldata description, string calldata uri) external;
```

Behavior:
1. Emits `Announcement` (with `id`, `description`, `uri`).
2. Executes `internalCalls` in order.
3. Emits `EndAnnouncement`.

**`id` must be unique forever** — reusing one is a contract-level mistake, not just bad practice
(treat it like a nonce). Inner call reverts surface wrapped in `InternalCallFailed`, not the raw
inner revert — when debugging an `announce()` failure, the actual cause is one level deeper than
the visible error.

## Batch Mint

```solidity
function batchMint(address[] calldata recipients, uint256[] calldata amounts) external; // gated by MINT_ROLE
```

Mints to many recipients in a single call — `recipients` and `amounts` must be parallel arrays.
Each recipient is still subject to `MINT_RECEIVER_POLICY` individually. **Should be wrapped in
`announce()`** for transparency on large distributions (e.g. an airdrop) — this is a convention,
not an enforced requirement.

```solidity
bytes[] memory mintCall = new bytes[](1);
mintCall[0] = abi.encodeCall(IB20Asset.batchMint, (recipients, amounts));
token.announce(mintCall, keccak256("airdrop-2024-q1"), "Q1 2024 community airdrop", "ipfs://...");
```

## Extra Metadata (Key-Value Store)

A free-form string-keyed metadata store, separate from `name`/`symbol`/`contractURI`:

```solidity
function extraMetadata(string calldata key) external view returns (string memory);
function updateExtraMetadata(string calldata key, string calldata value) external; // gated by METADATA_ROLE
```

Passing an empty `value` removes the entry. Use this for arbitrary project-specific metadata (e.g.
`"category"` → `"gaming"`) that doesn't warrant a dedicated field.

## Building the initCalls Helpers (B20FactoryLib)

```solidity
function encodeBatchMint(address[] memory recipients, uint256[] memory amounts) internal pure returns (bytes memory) {
return abi.encodeCall(IB20Asset.batchMint, (recipients, amounts));
}

function encodeUpdateMultiplier(uint256 newMultiplier) internal pure returns (bytes memory) {
return abi.encodeCall(IB20Asset.updateMultiplier, (newMultiplier));
}

function encodeUpdateExtraMetadata(string memory key, string memory value) internal pure returns (bytes memory) {
return abi.encodeCall(IB20Asset.updateExtraMetadata, (key, value));
}
```

These are normal function-call encodings (not the struct-as-tuple gotcha from
[encoding.md](encoding.md)) — `encodeFunctionData` in viem works the same way if doing this
client-side.
Loading