diff --git a/README.md b/README.md index 3d9cfba..8177c6e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/skills/b20-token/SKILL.md b/skills/b20-token/SKILL.md new file mode 100644 index 0000000..d6d91c1 --- /dev/null +++ b/skills/b20-token/SKILL.md @@ -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 +``` diff --git a/skills/b20-token/references/accepting-payments.md b/skills/b20-token/references/accepting-payments.md new file mode 100644 index 0000000..83ac0fc --- /dev/null +++ b/skills/b20-token/references/accepting-payments.md @@ -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. diff --git a/skills/b20-token/references/activation-check.md b/skills/b20-token/references/activation-check.md new file mode 100644 index 0000000..267c655 --- /dev/null +++ b/skills/b20-token/references/activation-check.md @@ -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. diff --git a/skills/b20-token/references/asset-variant.md b/skills/b20-token/references/asset-variant.md new file mode 100644 index 0000000..5307e87 --- /dev/null +++ b/skills/b20-token/references/asset-variant.md @@ -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. diff --git a/skills/b20-token/references/deploy.md b/skills/b20-token/references/deploy.md new file mode 100644 index 0000000..cc1b7ee --- /dev/null +++ b/skills/b20-token/references/deploy.md @@ -0,0 +1,146 @@ +# Deploying a B20 Token + +## Table of Contents + +- [Input Validation](#input-validation) +- [Foundry Script (CLI Path)](#foundry-script-cli-path) +- [Salt Uniqueness](#salt-uniqueness) +- [Variant Parameter Reference](#variant-parameter-reference) +- [Address Derivation (Deterministic, No RPC Needed)](#address-derivation-deterministic-no-rpc-needed) +- [What `createB20` Actually Does](#what-createb20-actually-does) + +## Input Validation + +Before constructing shell commands, validate all user-provided values: + +- **rpc-url**: Must be a valid HTTPS URL (`^https://[^\s;|&]+$`). Reject non-HTTPS or malformed URLs. +- **account-address**: Must match `^0x[a-fA-F0-9]{40}$`. +- **token-salt**: Free text is fine — it's hashed — but reject shell metacharacters (`;`, `|`, `` ` ``, `$(`) before interpolating into a command. +- **name/symbol/currency**: Reject control characters; `currency` must be 3 uppercase `A`-`Z` letters for STABLECOIN. + +Do not pass unvalidated user input into shell commands or directly into Solidity string literals +without considering injection into the generated script file. + +## Foundry Script (CLI Path) + +```solidity +// 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) { + address account = vm.envAddress("ACCOUNT_ADDRESS"); + bytes32 salt = keccak256(bytes(vm.envString("TOKEN_SALT"))); + + bytes memory params = B20FactoryLib.encodeStablecoinCreateParams("My Token", "MYT", account, "USD"); + + bytes[] memory initCalls = new bytes[](2); + initCalls[0] = B20FactoryLib.encodeGrantRole(B20Constants.MINT_ROLE, account); + initCalls[1] = B20FactoryLib.encodeUpdateSupplyCap(type(uint128).max); // no cap + + vm.startBroadcast(); + token = StdPrecompiles.B20_FACTORY.createB20(IB20Factory.B20Variant.STABLECOIN, salt, params, initCalls); + vm.stopBroadcast(); + + console.log("B20 token created at:", token); + } +} +``` + +For `ASSET`, swap the params encoder and variant: + +```solidity +bytes memory params = B20FactoryLib.encodeAssetCreateParams("My Token", "MYT", account, 18); // decimals 6-18 +// ... +token = StdPrecompiles.B20_FACTORY.createB20(IB20Factory.B20Variant.ASSET, salt, params, initCalls); +``` + +Run it: + +```bash +export RPC_URL=https://sepolia.base.org +export PRIVATE_KEY=0x... # funded testnet/mainnet account — never commit this +export ACCOUNT_ADDRESS=0x... +export TOKEN_SALT=my-unique-salt-string + +base-forge script script/CreateToken.s.sol --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast +``` + +> **Agent behavior:** Never write a private key into a tracked file. Use environment variables or +> Foundry's keystore (`cast wallet import`) and confirm `.env` is in `.gitignore` before proceeding. + +## Salt Uniqueness + +The token's address is deterministic: `address = f(variant, sender, salt)`. Reusing a salt for the +same `(variant, sender)` pair reverts `TokenAlreadyExists(address)` on the second attempt — it +doesn't create a second token at a new address. + +- **CLI scripts**: derive the salt from something that changes per run if uniqueness matters (e.g. + `keccak256(bytes("${symbol}-${block.timestamp}"))`), or let the user supply a fixed salt + intentionally if they want a *predictable, reproducible* address. +- **Client-side (viem/wagmi)**: generate per-deploy, e.g. + `keccak256(toBytes(\`${symbol}-${Date.now()}-${Math.random()}\`))`. + +You can predict the resulting address before broadcasting with the factory's view function: + +```solidity +function getB20Address(B20Variant variant, address sender, bytes32 salt) external view returns (address); +``` + +This never reverts — use it to confirm a salt is unused before spending gas on the real call. + +## Variant Parameter Reference + +| Field | ASSET | STABLECOIN | +|-------|-------|------------| +| `decimals` | Caller-supplied, must be in `[6, 18]` (reverts `InvalidDecimals` otherwise) | Fixed at 6, not a param | +| `currency` | n/a | Required, uppercase `A`-`Z` only (reverts `InvalidCurrency` otherwise) | +| Extra capabilities | Balance multiplier, `announce()`, batch mint, extra metadata — see [asset-variant.md](asset-variant.md) | None — simpler role set | + +## Address Derivation (Deterministic, No RPC Needed) + +``` +[10-byte B20 prefix][1-byte variant][9-byte keccak256(deployer, salt)] +``` + +| Variant | Byte | +|---------|------| +| `ASSET` | `0x00` | +| `STABLECOIN` | `0x01` | + +The variant is recoverable from the address alone, without an RPC call — the byte right after the +10-byte B20 prefix tells you which variant a given B20 address is, before you ever query it. + +```solidity +function isB20(address token) external view returns (bool); // matches the address prefix +function isB20Initialized(address token) external view returns (bool); // flips once createB20 completes +``` + +## What `createB20` Actually Does + +1. Derives the token's address from `(variant, sender, salt)` per the scheme above. +2. Decodes `params` per the leading version byte (see [encoding.md](encoding.md) for the + client-side encoding pitfall). +3. Emits `B20Created`. +4. Runs each entry in `initCalls` in order, with factory-originated calls bypassing the new + token's role gates and transfer-side policy gates (`TRANSFER_SENDER_POLICY`, + `TRANSFER_RECEIVER_POLICY`, `TRANSFER_EXECUTOR_POLICY`) for that window only — so `grantRole`, + `updatePolicy`, and bootstrap transfers work without the factory holding any role itself. + - `MINT_RECEIVER_POLICY` is **always** enforced, even during `initCalls` — a bootstrap mint to a + policy-denied recipient still reverts `PolicyForbids`. + - Pause state is **never** bypassed — it defaults to nothing-paused at creation; sequence a + `pause(...)` call last in `initCalls` if you want a start-paused token. + - Token invariants (supply-cap math, balance accounting) are **never** bypassed. + - The bootstrap window closes the moment `createB20` returns — the factory retains no + persisted access afterward. + +**`createB20` itself never mints anything** — see [post-deploy.md](post-deploy.md) for how to +actually put supply into circulation. For the full roles/admin/policy picture, see +[roles-and-admin.md](roles-and-admin.md) and [policy.md](policy.md). diff --git a/skills/b20-token/references/encoding.md b/skills/b20-token/references/encoding.md new file mode 100644 index 0000000..6e11534 --- /dev/null +++ b/skills/b20-token/references/encoding.md @@ -0,0 +1,131 @@ +# Encoding B20 Params in JS/TS (viem/wagmi) + +If you're building a wallet-signed UI (the user signs with their own wallet — MetaMask, Coinbase +Wallet, WalletConnect — rather than a Foundry script holding a private key), you need to replicate +`B20FactoryLib`'s encoding in TypeScript. This is the single most error-prone part of a client-side +B20 integration. + +## Table of Contents + +- [The Gotcha](#the-gotcha) +- [Correct viem Encoding](#correct-viem-encoding) +- [`initCalls` Entries Are Normal Function-Call Encoding](#initcalls-entries-are-normal-function-call-encoding) +- [Decoding Custom Errors](#decoding-custom-errors) +- [Builder Code Attribution (ERC-8021)](#builder-code-attribution-erc-8021) + +## The Gotcha + +`B20FactoryLib.encodeStablecoinCreateParams` (and the ASSET equivalent) does: + +```solidity +return abi.encode( + IB20Factory.B20StablecoinCreateParams({ version: 1, name: name, symbol: symbol, ... }) +); +``` + +This is `abi.encode` of **a single struct argument**. Solidity ABI-encodes a struct as a tuple — +and because it's the *only* top-level argument and that tuple is dynamic (it contains strings), +the encoding is: + +``` +[offset slot: 0x20] → [the tuple's own head/tail encoding] +``` + +i.e. **one extra 32-byte offset slot precedes the struct's fields.** It is *not* the same as +encoding the fields as a flat parameter list. Encoding it as a flat list silently produces +different (shorter) calldata that the factory will reject — typically as `AbiDecodeFailed` or a +similarly opaque revert, because the bytes "look like" valid ABI data, just shifted by one slot. + +## Correct viem Encoding + +Use a **single tuple-typed parameter**, not a flat parameter list: + +```typescript +import { encodeAbiParameters, parseAbiParameters, type Address, type Hex } from "viem"; + +export function encodeStablecoinCreateParams( + name: string, + symbol: string, + initialAdmin: Address, + currency: string, +): Hex { + return encodeAbiParameters( + parseAbiParameters("(uint8 version, string name, string symbol, address initialAdmin, string currency)"), + [{ version: 1, name, symbol, initialAdmin, currency }], + ); +} + +export function encodeAssetCreateParams( + name: string, + symbol: string, + initialAdmin: Address, + decimals: number, +): Hex { + return encodeAbiParameters( + parseAbiParameters("(uint8 version, string name, string symbol, address initialAdmin, uint8 decimals)"), + [{ version: 1, name, symbol, initialAdmin, decimals }], + ); +} +``` + +The parenthesized type string makes it **one tuple parameter** containing all the fields — matching +`abi.encode(struct)` byte-for-byte. Passing the fields as separate parameters +(`parseAbiParameters("uint8, string, string, address, string")` with 5 separate args) is the bug — +it omits the outer offset slot. + +### How to Verify You Got It Right + +Compare against a known-good Solidity-generated encoding (e.g. from a successful `base-forge +script --broadcast` run, read back via `base-cast tx ` or a trace). Byte-for-byte match is +the only real test — don't trust that it "looks plausible." + +## `initCalls` Entries Are Normal Function-Call Encoding + +Unlike the create-params struct, `initCalls` entries (`grantRole`, `updateSupplyCap`, `mint`, +`transferWithMemo`, ...) are encoded the standard way — they're real function calls, not a struct +argument, so no extra wrapping applies: + +```typescript +import { encodeFunctionData } from "viem"; + +export function encodeGrantRole(role: Hex, account: Address): Hex { + return encodeFunctionData({ abi: b20TokenAbi, functionName: "grantRole", args: [role, account] }); +} +``` + +## Decoding Custom Errors + +Add the relevant `error` entries to your ABI so viem can decode revert reasons into names instead +of raw selectors: + +```typescript +export const b20TokenAbi = [ + // ...functions... + { type: "error", name: "AccessControlUnauthorizedAccount", inputs: [{ name: "account", type: "address" }, { name: "neededRole", type: "bytes32" }] }, + { type: "error", name: "SupplyCapExceeded", inputs: [{ name: "cap", type: "uint256" }, { name: "attempted", type: "uint256" }] }, + { type: "error", name: "FeatureNotActivated", inputs: [{ name: "feature", type: "bytes32" }] }, +] as const; +``` + +Then extract the decoded name from a failed `useWriteContract`/`writeContract` error via +`error.walk((e) => e instanceof ContractFunctionRevertedError)` and `revertError.data?.errorName` — +see [errors.md](errors.md) for the full error catalog and friendly-message mapping. + +## Builder Code Attribution (ERC-8021) + +If you have a Base Builder Code, append it to every transaction so onchain activity is attributed +to it. Both `viem`'s `writeContract`/`sendTransaction` accept a `dataSuffix` field directly +(wagmi passes it through transparently since it spreads extra params into the underlying viem call): + +```typescript +writeContract({ + address: B20_FACTORY_ADDRESS, + abi: b20FactoryAbi, + functionName: "createB20", + args: [variant, salt, params, initCalls], + dataSuffix: BUILDER_CODE_DATA_SUFFIX, // your encoded code from base.dev +}); +``` + +No extra package is required if you already have the pre-encoded suffix — `dataSuffix` is a +first-class viem parameter (`Hex`, appended to calldata, ~16 gas per non-zero byte). diff --git a/skills/b20-token/references/errors.md b/skills/b20-token/references/errors.md new file mode 100644 index 0000000..9c44c2f --- /dev/null +++ b/skills/b20-token/references/errors.md @@ -0,0 +1,59 @@ +# B20 Common Errors + +All of these are custom Solidity errors, not generic reverts — decode them rather than showing the +user a raw selector. In viem, add the error ABI entries to your contract's ABI so +`ContractFunctionRevertedError.data?.errorName` resolves to a name instead of staying opaque. + +| Error | Signature | Cause | Fix | +|-------|-----------|-------|-----| +| `FeatureNotActivated` | `FeatureNotActivated(bytes32)` | The B20 variant isn't activated on this network yet | Check activation first — [activation-check.md](activation-check.md). Not your code's fault; wait or switch network. | +| `TokenAlreadyExists` | `TokenAlreadyExists(address)` | Reused a `salt` for the same `(variant, sender)` pair | Use a fresh, unique salt per deploy | +| `AccessControlUnauthorizedAccount` | `AccessControlUnauthorizedAccount(address,bytes32)` | Caller lacks the role required for this function (e.g. no `MINT_ROLE` for `mint`) | Grant the role first (requires the role's admin, default `DEFAULT_ADMIN_ROLE`) | +| `LastAdminCannotRenounce` | `LastAdminCannotRenounce()` | Called `renounceRole(DEFAULT_ADMIN_ROLE, ...)` as the sole remaining admin | Use `renounceLastAdmin()` instead if intentionally going admin-less | +| `NotSoleAdmin` | `NotSoleAdmin()` | Called `renounceLastAdmin()` while other admins still exist | Revoke other admins first, or accept they'll retain admin | +| `InvalidDecimals` | `InvalidDecimals(uint8)` | ASSET `decimals` outside `[6, 18]` | Pick a value in range | +| `InvalidCurrency` | `InvalidCurrency(string)` | STABLECOIN `currency` contains a non-`A`-`Z` byte | Uppercase ASCII letters only, no symbols/numbers | +| `MissingRequiredField` | `MissingRequiredField(string)` | A required string field (e.g. `currency`) was empty | Supply a non-empty value | +| `SupplyCapExceeded` | `SupplyCapExceeded(uint256,uint256)` | Mint would push `totalSupply` past the cap | Raise the cap (if you hold the role) or mint less | +| `InvalidSupplyCap` | `InvalidSupplyCap(uint256,uint256)` | Proposed cap is below current supply | Cap must be ≥ current `totalSupply` | +| `InsufficientBalance` | `InsufficientBalance(address,uint256,uint256)` | Sender doesn't have enough balance for a transfer/burn | Check `balanceOf` before sending | +| `InsufficientAllowance` | `InsufficientAllowance(address,uint256,uint256)` | `transferFrom` exceeds the approved allowance | `approve` more first | +| `InvalidReceiver` / `InvalidSender` | `InvalidReceiver(address)` / `InvalidSender(address)` | Usually a zero-address misuse | Validate addresses client-side before sending | +| `ContractPaused` | `ContractPaused(uint8)` | The relevant feature bit (TRANSFER/MINT/BURN) is currently paused | Wait for `unpause`, or this is intentional issuer behavior | +| `PolicyForbids` | `PolicyForbids(bytes32,uint64)` | A `PolicyRegistry` allowlist/blocklist denied this transfer/mint | Not a bug — the issuer's compliance policy is blocking this address/action | +| `AccountNotBlocked` | `AccountNotBlocked(address)` | Called `burnBlocked` on an address that isn't currently denied under `TRANSFER_SENDER_POLICY` | `burnBlocked` only seizes from already-blocked accounts, not arbitrary ones | +| `PolicyNotFound` | `PolicyNotFound(uint64)` | Referenced a policy ID that doesn't exist where existence was required | Create the policy first, or use `ALWAYS_ALLOW`/`ALWAYS_BLOCK` | +| `UnsupportedPolicyType` | `UnsupportedPolicyType(bytes32)` | Called `updatePolicy` with a `scope` that isn't one of the four recognized constants | Use `TRANSFER_SENDER_POLICY`/`TRANSFER_RECEIVER_POLICY`/`TRANSFER_EXECUTOR_POLICY`/`MINT_RECEIVER_POLICY` | +| `InternalCallFailed` | `InternalCallFailed(uint256)` | One of the `internalCalls` inside `announce()` reverted (ASSET only) | The real cause is one level deeper — inspect the wrapped inner call, not just this error | +| `ExpiredSignature` | `ExpiredSignature(uint256)` | `permit()` called after its `deadline` | Get a fresh signature with a later deadline | +| `InvalidSigner` | `InvalidSigner(address,address)` | `permit()` recovered a signer that doesn't match the claimed owner | Usually a stale `name()` in the signed domain after `updateName` rotated it — re-fetch `name()` before signing | +| `AbiDecodeFailed` / opaque revert on `createB20` | — | Client-side params encoding is wrong (most often the JS/TS single-tuple gotcha) | See [encoding.md](encoding.md) | + +## TypeScript Error-Formatting Pattern + +```typescript +import { BaseError, ContractFunctionRevertedError } from "viem"; + +const ERROR_MESSAGES: Record = { + FeatureNotActivated: "B20 isn't activated on this network yet.", + AccessControlUnauthorizedAccount: "This address doesn't have the required role.", + SupplyCapExceeded: "That amount would exceed the token's supply cap.", + PolicyForbids: "A policy rule on this token is blocking this transaction.", + // ...add the rest from the table above as needed +}; + +export function formatB20Error(error: unknown): string { + if (error instanceof BaseError) { + const revertError = error.walk((e) => e instanceof ContractFunctionRevertedError); + if (revertError instanceof ContractFunctionRevertedError) { + const name = revertError.data?.errorName; + if (name && ERROR_MESSAGES[name]) return ERROR_MESSAGES[name]; + } + return error.shortMessage; + } + return error instanceof Error ? error.message : "Something went wrong."; +} +``` + +This only resolves `errorName` if the error is declared in the ABI you pass to `writeContract`/ +`simulateContract` — add the ones you expect to hit, not just the function signatures. diff --git a/skills/b20-token/references/metadata-and-permit.md b/skills/b20-token/references/metadata-and-permit.md new file mode 100644 index 0000000..d86482d --- /dev/null +++ b/skills/b20-token/references/metadata-and-permit.md @@ -0,0 +1,83 @@ +# Metadata Updates and ERC-2612 Permit + +## Contract URI (ERC-7572) + +```solidity +function contractURI() external view returns (string memory); +function updateContractURI(string calldata newUri) external; // gated by METADATA_ROLE +``` + +Off-chain JSON metadata (logo, description, socials), following the +[ERC-7572](https://eips.ethereum.org/EIPS/eip-7572) convention that wallets/explorers already know +to look for. + +## Name and Symbol Updates + +Both gated by `METADATA_ROLE`: + +```solidity +function updateName(string calldata newName) external; // emits NameUpdated + EIP712DomainChanged +function updateSymbol(string calldata newSymbol) external; // emits SymbolUpdated only +``` + +**`updateName` rotates the EIP-712 domain separator** (because `name` is part of the domain — see +below) and emits `EIP712DomainChanged` (ERC-5267) in addition to `NameUpdated`. `updateSymbol` +does not affect the domain at all — `symbol` isn't part of the EIP-712 domain. + +> **Agent behavior:** If a user renames a token that has outstanding `permit` signatures pending +> (signed but not yet submitted), warn them those signatures become invalid — the domain separator +> they signed against no longer matches. + +## ERC-2612 Permit (Gasless Approvals) + +B20 implements the full ERC-2612 surface: + +```solidity +function nonces(address owner) external view returns (uint256); // current permit nonce +function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external; +function DOMAIN_SEPARATOR() external view returns (bytes32); +function eip712Domain() external view returns (uint8 fields, string memory name, string memory version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] memory extensions); // ERC-5267 +``` + +EIP-712 domain shape: `(name, version, chainId, verifyingContract)` — `version` is fixed at the +literal string `"1"` (not configurable, not the token's own versioning). + +### Key Constraints + +- **ECDSA signatures only** — `permit` does **not** accept ERC-1271 contract signatures. A smart + contract wallet (e.g. a Safe) cannot use `permit` directly; it must call `approve` normally. +- `nonces(owner)` increments by exactly 1 on each successful `permit` — replay-safe. +- Expired (`deadline < block.timestamp`) reverts `ExpiredSignature(deadline)`. +- Signer mismatch reverts `InvalidSigner(recovered, claimed)`. + +### Client-Side Signing (viem) + +```typescript +const domain = { + name: await tokenContract.read.name(), + version: "1", + chainId, + verifyingContract: tokenAddress, +} as const; + +const types = { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], +} as const; + +const signature = await walletClient.signTypedData({ + account, + domain, + types, + primaryType: "Permit", + message: { owner, spender, value, nonce, deadline }, +}); +``` + +Re-fetch `name()` (not a hardcoded string) if there's any chance `updateName` has been called — +using a stale name produces a domain mismatch and `InvalidSigner`. diff --git a/skills/b20-token/references/policy.md b/skills/b20-token/references/policy.md new file mode 100644 index 0000000..ffcebc6 --- /dev/null +++ b/skills/b20-token/references/policy.md @@ -0,0 +1,139 @@ +# Policy Model (Compliance Allowlist/Blocklist) + +## Table of Contents + +- [PolicyRegistry](#policyregistry) +- [Policy Types](#policy-types) +- [Policy IDs](#policy-ids) +- [Creating and Managing a Policy](#creating-and-managing-a-policy) +- [The Four Policy Scopes B20 Enforces](#the-four-policy-scopes-b20-enforces) +- [The Default Everyone Misses](#the-default-everyone-misses) +- [Reading and Writing a Token's Scope Assignment](#reading-and-writing-a-tokens-scope-assignment) +- [Enforcement](#enforcement) +- [Setting a Policy at Deploy Time](#setting-a-policy-at-deploy-time) + +## PolicyRegistry + +A singleton precompile (`0x8453000000000000000000000000000000000002`) that manages +allowlists/blocklists independently of any specific token. **Any caller can create a policy and +nominate its admin** — policies aren't owned by B20 tokens, tokens just reference policy IDs. + +State-changing functions (`createPolicy`, `updateBlocklist`, etc.) are gated by the +`ActivationRegistry`. Read functions — `isAuthorized`, `policyExists`, `policyAdmin`, +`pendingPolicyAdmin` — are always callable, even if the policy feature itself isn't activated. + +## Policy Types + +| Type | Default-state semantics | +|------|---| +| `BLOCKLIST` | All accounts **authorized** by default; explicitly listed accounts are **denied** | +| `ALLOWLIST` | All accounts **denied** by default; explicitly listed accounts are **authorized** | + +## Policy IDs + +A `uint64` encodes the type in the top byte and a global counter (starting at 2) in the low 56 +bits — you don't construct these by hand except for the two built-in constants: + +| Constant | ID | Behavior | +|----------|----|---| +| `ALWAYS_ALLOW` | `0` | Authorizes every account unconditionally. **This is the default scope value on every new B20 token.** | +| `ALWAYS_BLOCK` | `(uint64(ALLOWLIST) << 56) \| 1` | Denies every account unconditionally | + +`isAuthorized` **never reverts** on a non-existent ID — a non-existent `BLOCKLIST` ID authorizes +everyone (consistent with the type's default-allow semantics); a non-existent `ALLOWLIST` ID +denies everyone. + +## Creating and Managing a Policy + +```solidity +uint64 policyId = policyRegistry.createPolicy(adminAddress, PolicyType.BLOCKLIST); +// or, seeded with initial members: +uint64 policyId = policyRegistry.createPolicyWithAccounts(adminAddress, PolicyType.BLOCKLIST, accounts); + +policyRegistry.updateBlocklist(policyId, /* blocked: */ true, accounts); +policyRegistry.updateBlocklist(policyId, /* blocked: */ false, accounts); // unblock +// ALLOWLIST equivalent: +policyRegistry.updateAllowlist(policyId, /* allowed: */ true, accounts); +``` + +### Two-Step Admin Transfer + +```solidity +policyRegistry.stageUpdateAdmin(policyId, newAdmin); // called by current admin +policyRegistry.finalizeUpdateAdmin(policyId); // called by the new (pending) admin +``` + +### Freezing a Policy Forever + +```solidity +policyRegistry.renounceAdmin(policyId); // membership can never be changed again, irreversible +``` + +### PolicyRegistry-Level Errors + +Distinct from the B20-token-level `PolicyNotFound(uint64)`/`UnsupportedPolicyType(bytes32)` in +[errors.md](errors.md) — these come from the registry itself when managing policies directly: + +| Error | Cause | +|-------|-------| +| `PolicyNotFound()` | Referenced `policyId` doesn't exist (no args — unlike the B20-level error of the same name) | +| `IncompatiblePolicyType()` | Called `updateAllowlist` on a BLOCKLIST policy, or `updateBlocklist` on an ALLOWLIST policy | +| `Unauthorized()` | Caller isn't the policy's current admin | +| `ZeroAddress()` | Passed `address(0)` as `admin` to `createPolicy`/`createPolicyWithAccounts` | +| `BatchSizeTooLarge(uint256 maxBatchSize)` | `accounts` array exceeds the registry's per-call limit | +| `NoPendingAdmin()` | Called `finalizeUpdateAdmin` with no transfer staged | + +## The Four Policy Scopes B20 Enforces + +| Scope | Gates | +|-------|---| +| `TRANSFER_SENDER_POLICY` | The `from` of `transfer` / `transferFrom` | +| `TRANSFER_RECEIVER_POLICY` | The `to` of `transfer` / `transferFrom` | +| `TRANSFER_EXECUTOR_POLICY` | The `msg.sender` of `transferFrom`, when distinct from `from` | +| `MINT_RECEIVER_POLICY` | The `to` of `mint` | + +**`approve` is not policy-gated** — only actual balance movement (`transfer`/`transferFrom`/`mint`) +is checked. Approving a denied address is allowed; that address's subsequent attempt to move funds +is what gets blocked. + +## The Default Everyone Misses + +> **Every scope defaults to `ALWAYS_ALLOW` at token creation unless overridden in the bootstrap +> `initCalls`. An unattended B20 deployment is fully open** — no compliance gating happens unless +> you explicitly wire it up. + +If a user asks for a "compliant" or "regulated" token and you deploy without setting any policy +scopes, you've shipped an open token that happens to support compliance features — not a +compliant token. Confirm with the user whether they actually need policy gating before assuming +the defaults are fine. + +## Reading and Writing a Token's Scope Assignment + +```solidity +function policyId(bytes32 scope) external view returns (uint64); +function updatePolicy(bytes32 scope, uint64 newPolicyId) external; // admin-gated +``` + +`updatePolicy` reverts if `scope` isn't one of the four recognized constants above. + +## Enforcement + +On every gated operation, B20 calls `isAuthorized` against the relevant scope's currently-assigned +policy and **reverts `PolicyForbids(scope, policyId)`** if the account isn't authorized. This is +not a bug when it happens — it's the issuer's own configured compliance rule firing as designed. +See [post-deploy.md](post-deploy.md) for the related `burnBlocked` freeze-and-seize mechanic, which +specifically requires the target to already be denied under `TRANSFER_SENDER_POLICY`. + +## Setting a Policy at Deploy Time + +Bootstrap it via `initCalls` so the token is never open even momentarily: + +```solidity +bytes[] memory initCalls = new bytes[](3); +initCalls[0] = B20FactoryLib.encodeGrantRole(B20Constants.MINT_ROLE, account); +initCalls[1] = B20FactoryLib.encodeUpdateSupplyCap(type(uint128).max); +initCalls[2] = B20FactoryLib.encodeUpdatePolicy(B20Constants.TRANSFER_SENDER_POLICY, myBlocklistPolicyId); +``` + +Note the policy itself must already exist (created via `PolicyRegistry.createPolicy` beforehand) — +`initCalls` only *assigns* an existing policy ID to a scope, it doesn't create policies. diff --git a/skills/b20-token/references/post-deploy.md b/skills/b20-token/references/post-deploy.md new file mode 100644 index 0000000..bf3e315 --- /dev/null +++ b/skills/b20-token/references/post-deploy.md @@ -0,0 +1,117 @@ +# Post-Deploy: Minting, Roles, Supply Cap, Memos + +## Table of Contents + +- [Deploying Never Mints — This Trips Up Almost Everyone](#deploying-never-mints--this-trips-up-almost-everyone) +- [Role-Based Access Control](#role-based-access-control) +- [Supply Cap](#supply-cap) +- [Transfers With Memo (Payment References)](#transfers-with-memo-payment-references) + +## Deploying Never Mints — This Trips Up Almost Everyone + +`createB20` creates the token and runs `initCalls` (typically `grantRole` + `updateSupplyCap`). +**No tokens exist until something calls `mint()` separately.** If a user reports "I deployed with +a 1M supply but my balance is 0," this is almost always the cause — they set a supply *cap* +(ceiling), not an actual mint. + +```bash +base-cast send "mint(address,uint256)" \ + --rpc-url $RPC_URL --private-key $PRIVATE_KEY +``` + +`AMOUNT_RAW` is in the token's smallest unit — multiply by `10^decimals` (6 for STABLECOIN, 6–18 +for ASSET). E.g. 1000 tokens at 6 decimals = `1000000000`. + +This call **requires the sender to hold `MINT_ROLE`** — see below if they don't. + +## Role-Based Access Control + +| Role | Required for | +|------|---------------| +| `DEFAULT_ADMIN_ROLE` | Granting/revoking other roles. **Held by the deployer automatically** — but does *not* itself permit minting. | +| `MINT_ROLE` | `mint`, `mintWithMemo` | +| `BURN_ROLE` | `burn`, `burnWithMemo` | +| `BURN_BLOCKED_ROLE` | `burnBlocked` (seizing tokens from an address already denied under `TRANSFER_SENDER_POLICY` — cannot target an arbitrary, non-blocked address) | +| `PAUSE_ROLE` / `UNPAUSE_ROLE` | `pause`/`unpause` (granular: TRANSFER/MINT/BURN bits) | + +Granting `MINT_ROLE` to yourself (or anyone) after deploy, if it wasn't done in `initCalls`: + +```bash +base-cast send "grantRole(bytes32,address)" \ + $(base-cast keccak "MINT_ROLE") \ + --rpc-url $RPC_URL --private-key $PRIVATE_KEY +``` + +Only the holder of a role's admin role (by default `DEFAULT_ADMIN_ROLE`) can grant/revoke it. + +### Becoming Trustless: `renounceLastAdmin()` + +Unlike a plain `renounceRole(DEFAULT_ADMIN_ROLE, ...)` — which reverts `LastAdminCannotRenounce` +if called by the sole remaining admin — there's a dedicated function for deliberately giving up +all admin power: + +```solidity +function renounceLastAdmin() external; // reverts NotSoleAdmin if other admins still exist +``` + +This is **irreversible** and puts the token into a permanent zero-admin state: no more role +grants/revokes/admin changes, ever. **Before calling it**, revoke or hand off any role you don't +want frozen in its current state — existing holders of `PAUSE_ROLE`/`BURN_BLOCKED_ROLE`/etc. keep +their powers after renouncement; only the ability to *change* role assignments goes away. + +> **Agent behavior:** If a user asks "how do I make my token trustless," explain this tradeoff +> explicitly before suggesting the call — it's a one-way door. + +## Supply Cap + +```bash +base-cast send "updateSupplyCap(uint256)" \ + --rpc-url $RPC_URL --private-key $PRIVATE_KEY +``` + +- `CAP_RAW` is in the token's smallest unit, same scaling as mint amounts. +- "No cap" sentinel: `type(uint128).max` (`B20Constants.MAX_SUPPLY_CAP`) — not literal infinity, + but large enough to be unbounded in practice. +- The cap can be changed later by whoever holds the relevant admin role — it is not fixed at deploy + time unless admin is renounced first. +- Minting beyond the cap reverts `SupplyCapExceeded(cap, attempted)`. + +## Transfers With Memo (Payment References) + +For tagging a payment to an order/invoice ID, use the memo'd variants instead of plain +`transfer`/`transferFrom`: + +```bash +base-cast send "transferWithMemo(address,uint256,bytes32)" \ + \ + --rpc-url $RPC_URL --private-key $PRIVATE_KEY +``` + +`MEMO_BYTES32` is typically a short string padded to 32 bytes (e.g. an order ID). In viem: + +```typescript +import { stringToHex } from "viem"; +const memo = stringToHex("order-42", { size: 32 }); // throws if the string doesn't fit in 32 bytes +``` + +This emits `Memo(address indexed caller, bytes32 indexed memo)` immediately after the standard +`Transfer` event — read it back from a receipt: + +```typescript +import { parseEventLogs, hexToString } from "viem"; +const [memoLog] = parseEventLogs({ abi: b20TokenAbi, logs: receipt.logs, eventName: "Memo" }); +const orderId = hexToString(memoLog.args.memo, { size: 32 }); +``` + +### Pre-Flight Simulation + +B20 transfers can revert where plain ERC-20 transfers wouldn't — a regulated issuer's +`TRANSFER_SENDER_POLICY`/`TRANSFER_RECEIVER_POLICY` allowlist/blocklist, or a paused TRANSFER +feature. Simulate before asking the user to sign: + +```typescript +await publicClient.simulateContract({ address, abi, functionName: "transferWithMemo", args: [...] }); +``` + +This surfaces `PolicyForbids`/`ContractPaused` as a typed error before any signature is requested, +rather than as a failed-transaction surprise after the user already paid gas to attempt it. diff --git a/skills/b20-token/references/roles-and-admin.md b/skills/b20-token/references/roles-and-admin.md new file mode 100644 index 0000000..d4957cd --- /dev/null +++ b/skills/b20-token/references/roles-and-admin.md @@ -0,0 +1,96 @@ +# Roles and Admin Model + +B20 extends OpenZeppelin `AccessControl` with a fixed set of built-in roles, plus support for +arbitrary custom roles. + +## Built-In Roles + +| Role | Gates | +|------|-------| +| `DEFAULT_ADMIN_ROLE` | All admin operations: role grants, policy updates, supply-cap changes | +| `MINT_ROLE` | `mint`, `mintWithMemo` | +| `BURN_ROLE` | Caller-side burns: `burn`, `burnWithMemo` (burning your own balance) | +| `BURN_BLOCKED_ROLE` | Third-party burns via `burnBlocked` — the freeze-and-seize path | +| `PAUSE_ROLE` | `pause(features)` | +| `UNPAUSE_ROLE` | `unpause(features)` — **deliberately a separate role from `PAUSE_ROLE`** | +| `METADATA_ROLE` | `updateName`, `updateSymbol`, `updateContractURI`, (ASSET) `updateExtraMetadata` | +| `OPERATOR_ROLE` | **ASSET variant only** — `updateMultiplier`, `announce` | + +`PAUSE_ROLE` and `UNPAUSE_ROLE` being separate is intentional design, not an oversight — e.g. a +token can let an automated monitor pause on anomaly detection without also letting it unpause. + +## Custom Roles + +Beyond the built-ins, arbitrary roles are supported via `setRoleAdmin` + `grantRole`: + +```solidity +bytes32 CUSTOM_ROLE = keccak256("MY_CUSTOM_ROLE"); +token.setRoleAdmin(CUSTOM_ROLE, DEFAULT_ADMIN_ROLE); // or any other role as the admin +token.grantRole(CUSTOM_ROLE, someAccount); +``` + +Custom roles **carry no built-in effect** — B20 doesn't gate anything on them itself. They only do +something if your own integration code checks `hasRole(CUSTOM_ROLE, ...)` externally (e.g. an +off-chain indexer or a separate contract that reads this token's role state). + +## The Admin Model + +`DEFAULT_ADMIN_ROLE` is the default admin for every other role (i.e. a `DEFAULT_ADMIN_ROLE` holder +can `grantRole`/`revokeRole` for `MINT_ROLE`, `PAUSE_ROLE`, etc., unless `setRoleAdmin` rewires a +role to a different admin role). + +### Why You Can't Just `renounceRole` Your Way to Trustless + +Calling `renounceRole(DEFAULT_ADMIN_ROLE, yourself)` or `revokeRole(DEFAULT_ADMIN_ROLE, lastAdmin)` +as/against the **sole remaining admin** reverts with `LastAdminCannotRenounce`. This is a guardrail +against accidentally bricking a token's admin-gated functions through an ordinary role call. + +### The Real Path: `renounceLastAdmin()` + +```solidity +function renounceLastAdmin() external; // reverts NotSoleAdmin if other admins still hold the role +``` + +This is the **only** way to permanently remove the last admin, and it's irreversible. The same +end-state can also be reached at creation time by passing `initialAdmin == address(0)` in the +create params. + +### What "Admin-less" Actually Means + +After the last admin is gone (via `renounceLastAdmin()` or admin-less creation): + +- Every operation gated by `DEFAULT_ADMIN_ROLE` becomes **permanently uncallable** — no more policy + updates, no more supply-cap changes via the admin path, no more role admin changes. +- **Other already-granted roles keep working independently.** If `PAUSE_ROLE`/`MINT_ROLE`/etc. were + granted to specific accounts before admin was renounced, those accounts retain those powers + forever — going admin-less does not revoke them. +- **Admin resurrection is explicitly blocked**: `grantRole`, `revokeRole`, and `setRoleAdmin` all + revert with `AccessControlUnauthorizedAccount` once admin-less — there is no backdoor to bring + admin back. + +> **Agent behavior:** If a user wants a "fully trustless" token, walk through this explicitly +> before they call `renounceLastAdmin()`: +> 1. Decide final role assignments first (mint, pause, burn-blocked) — these are frozen forever +> once admin is gone. +> 2. Decide final policy scopes (see [policy.md](policy.md)) — these also can't be changed without +> `DEFAULT_ADMIN_ROLE`. +> 3. Only then call `renounceLastAdmin()`, and confirm the user understands it cannot be undone. + +## Pause Model + +`PausableFeature` is an append-only enum partitioning the token surface into independently +pausable operations: + +```solidity +enum PausableFeature { TRANSFER, MINT, BURN } +``` + +```solidity +function pause(PausableFeature[] calldata features) external; // gated by PAUSE_ROLE +function unpause(PausableFeature[] calldata features) external; // gated by UNPAUSE_ROLE +function pausedFeatures() external view returns (PausableFeature[] memory); +function isPaused(PausableFeature feature) external view returns (bool); +``` + +Pausing is granular — you can pause `TRANSFER` while leaving `MINT`/`BURN` operable, etc. Calling a +paused operation reverts `ContractPaused(feature)`. diff --git a/skills/b20-token/references/setup.md b/skills/b20-token/references/setup.md new file mode 100644 index 0000000..4c7342e --- /dev/null +++ b/skills/b20-token/references/setup.md @@ -0,0 +1,85 @@ +# B20 Setup + +## Why Vanilla Foundry Doesn't Work + +B20 contracts (`B20_FACTORY`, `ACTIVATION_REGISTRY`, `POLICY_REGISTRY`) are **precompiles** — they +have no deployed bytecode (`eth_getCode` returns `0x` for them even when fully live), and standard +`forge`/`cast`/`anvil` don't know how to simulate calls against them. Calling one with vanilla +Foundry aborts with: + +``` +Error: call to non-contract address +``` + +Base ships a patched Foundry build (`base-forge`, `base-cast`, `base-anvil`) that registers the +precompiles into the local EVM for simulation. Use these binaries for every B20 operation — +never the vanilla ones. + +## Install + +```bash +base-foundryup +``` + +Verify: + +```bash +base-forge --version +``` + +## Project Setup + +```bash +base-forge init my-b20-project +cd my-b20-project +base-forge install base/base-std --no-git +``` + +`foundry.toml`: + +```toml +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +base = true +remappings = [ + "base-std/=lib/base-std/src/", + "base-std-test/=lib/base-std/test/", +] +``` + +`base = true` is what registers the precompiles for local simulation via `base-anvil` / script dry-runs. + +## Library Surface + +`base-std` exposes: + +| Import | Purpose | +|--------|---------| +| `StdPrecompiles.sol` | Fixed addresses: `B20_FACTORY`, `ACTIVATION_REGISTRY`, `POLICY_REGISTRY` | +| `interfaces/IB20Factory.sol` | `createB20`, `getB20Address`, `B20Variant` enum, create-params structs | +| `interfaces/IB20.sol` | Full token interface — `mint`, `transferWithMemo`, roles, pause, permit | +| `interfaces/IActivationRegistry.sol` | `isActivated(bytes32)`, `checkActivated(bytes32)` | +| `lib/B20FactoryLib.sol` | Pure encoder helpers for `createB20` params and `initCalls` | +| `lib/B20Constants.sol` | Role hashes (`MINT_ROLE`, `BURN_ROLE`, ...), decimals bounds, `MAX_SUPPLY_CAP` | + +Precompile addresses (stable across networks): + +```solidity +address constant B20_FACTORY_ADDRESS = 0xB20f000000000000000000000000000000000000; +address constant ACTIVATION_REGISTRY_ADDRESS = 0x8453000000000000000000000000000000000001; +address constant POLICY_REGISTRY_ADDRESS = 0x8453000000000000000000000000000000000002; +``` + +## Networks + +| Network | Chain ID | RPC | +|---------|----------|-----| +| Base Mainnet | 8453 | `https://mainnet.base.org` | +| Base Sepolia | 84532 | `https://sepolia.base.org` | +| Vibenet | 84538453 | `https://rpc.vibes.base.org` | +| Local | 31337 | `base-anvil` | + +Always check activation before targeting a network — see +[activation-check.md](activation-check.md). diff --git a/skills/b20-token/scripts/check-activation.sh b/skills/b20-token/scripts/check-activation.sh new file mode 100644 index 0000000..187d5a0 --- /dev/null +++ b/skills/b20-token/scripts/check-activation.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Check whether B20 ASSET/STABLECOIN are activated on a given network before deploying. +# Usage: check-activation.sh +# +# Exits 0 and prints "true true" if both variants are activated. +# Exits 1 if either variant is not activated, or the registry isn't reachable. +set -euo pipefail + +RPC_URL="${1:?Usage: check-activation.sh }" +REGISTRY=0x8453000000000000000000000000000000000001 + +ASSET_FEATURE=$(base-cast keccak "base.b20_asset") +STABLECOIN_FEATURE=$(base-cast keccak "base.b20_stablecoin") + +ASSET_ACTIVATED=$(base-cast call "$REGISTRY" "isActivated(bytes32)(bool)" "$ASSET_FEATURE" --rpc-url "$RPC_URL") +STABLECOIN_ACTIVATED=$(base-cast call "$REGISTRY" "isActivated(bytes32)(bool)" "$STABLECOIN_FEATURE" --rpc-url "$RPC_URL") + +echo "ASSET activated: $ASSET_ACTIVATED" +echo "STABLECOIN activated: $STABLECOIN_ACTIVATED" + +if [ "$ASSET_ACTIVATED" = "true" ] && [ "$STABLECOIN_ACTIVATED" = "true" ]; then + exit 0 +fi +exit 1