From f1722d31455c455c49adfc65009f7612ca4867da Mon Sep 17 00:00:00 2001 From: Soheima M Date: Tue, 21 Jul 2026 21:52:21 +0200 Subject: [PATCH 1/6] added demo --- docs/AGENTS.md | 4 +- .../specs/upgrades/beryl/b20-playground.mdx | 86 ++ docs/docs.json | 3 +- docs/snippets/B20PlaygroundDemo.jsx | 1189 +++++++++++++++++ 4 files changed, 1279 insertions(+), 3 deletions(-) create mode 100644 docs/base-chain/specs/upgrades/beryl/b20-playground.mdx create mode 100644 docs/snippets/B20PlaygroundDemo.jsx diff --git a/docs/AGENTS.md b/docs/AGENTS.md index a696801d9..27558067e 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -80,7 +80,7 @@ npx skills add base/base-skills |base-chain/api-reference/ethereum-json-rpc-api:eth_blockNumber,eth_call,eth_chainId,eth_estimateGas,eth_feeHistory,eth_gasPrice,eth_getBalance,eth_getBlockByHash,eth_getBlockByNumber,eth_getBlockReceipts,eth_getBlockTransactionCountByHash,eth_getBlockTransactionCountByNumber,eth_getCode,eth_getLogs,eth_getStorageAt,eth_getTransactionByBlockHashAndIndex,eth_getTransactionByBlockNumberAndIndex,eth_getTransactionByHash,eth_getTransactionCount,eth_getTransactionReceipt,eth_maxPriorityFeePerGas,eth_sendRawTransaction,eth_subscribe,eth_syncing,eth_unsubscribe,net_version,web3_clientVersion |base-chain/api-reference/flashblocks-api:base_transactionStatus,eth_simulateV1,flashblocks-api-overview,newFlashblockTransactions,newFlashblocks,pendingLogs |base-chain/flashblocks:faq -|base-chain/network-information:base-contracts,base-solana-bridge,bridging-and-withdrawals,ecosystem-bridges,network-faucets,network-fees,throughput-and-limits,transaction-finality,transaction-ordering,troubleshooting-transactions +|base-chain/network-information:base-contracts,base-solana-bridge,bridging-and-withdrawals,configuration-changelog,ecosystem-bridges,network-faucets,network-fees,throughput-and-limits,transaction-finality,transaction-ordering,troubleshooting-transactions |base-chain/node-operators:node-providers,performance-tuning,run-a-base-node,snapshots,troubleshooting |base-chain/quickstart:connecting-to-base |base-chain/security:avoid-malicious-flags,bug-bounty,report-vulnerability,security-council @@ -93,7 +93,7 @@ npx skills add base/base-skills |base-chain/specs/protocol/proofs:challenger,contracts,index,proposer,registrar,tee-prover,zk-prover |base-chain/specs/reference:configurability,glossary |base-chain/specs/upgrades/azul:exec-engine,node-upgrade,overview,proofs -|base-chain/specs/upgrades/beryl:b20,overview +|base-chain/specs/upgrades/beryl:b20-playground,b20,overview |base-chain/specs/upgrades/canyon:overview |base-chain/specs/upgrades/delta:overview,span-batches |base-chain/specs/upgrades/ecotone:derivation,l1-attributes,overview diff --git a/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx b/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx new file mode 100644 index 000000000..b7806bcf1 --- /dev/null +++ b/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx @@ -0,0 +1,86 @@ +--- +title: "B20 Playground" +description: "Interactive playground for the B20 native token standard - simulate policies, roles, freeze-and-seize, supply caps, granular pause, memos, and permit in your browser." +--- + +import { B20PlaygroundDemo } from "/snippets/B20PlaygroundDemo.jsx" + +[B20](/base-chain/specs/upgrades/beryl/b20) is Base's native token standard: an ERC-20 superset with a built-in compliance toolkit. This page lets you create a simulated B20 token and exercise its compliance features tab by tab, from choosing a variant to freezing an account and seizing its balance. + + +The playground simulates B20 semantics entirely client-side. Nothing touches a chain or wallet, and no state persists. To deploy a real token, see [Launch a B20 Token](/get-started/launch-b20-token). + + +## Feature map at a glance + +| You want to… | B20 feature | Demo section | +|--------------|-------------|--------------| +| Choose a token type and predict its address | Variants + deterministic addresses | Create a token | +| Restrict who can send, receive, or mint | Policy scopes + PolicyRegistry | Policy gating | +| Freeze a bad actor and seize funds | `TRANSFER_SENDER_POLICY` + `burnBlocked` | Freeze and seize | +| Separate operational duties or go admin-less | Roles + `renounceLastAdmin` | Roles and metadata | +| Hard-limit issuance | Supply cap | Supply cap and mint | +| Halt transfers without halting everything | Granular pause | Granular pause | +| Attach references to transactions | Memos | Memos | +| Gasless approvals | ERC-2612 `permit` | Permit | + + + + + +## Create a token + +Pick a variant and the demo derives everything else. An `ASSET` token takes configurable decimals from 6 to 18; a `STABLECOIN` is fixed at 6 decimals and carries a self-declared `currency()` code. You can set an optional supply cap and an initial admin, including `address(0)` for an admin-less launch that never grants `DEFAULT_ADMIN_ROLE`. The simulated address is deterministic: `[10-byte prefix][1-byte variant][9-byte keccak256(deployer, salt)]`, so you can read the variant straight off byte 10 without an RPC call. See [Address Derivation](/base-chain/specs/upgrades/beryl/b20#address-derivation) for the exact scheme. + +## Policy gating + +Attach allowlist or blocklist policies to the four transfer scopes and watch gated operations revert with `PolicyForbids`. `TRANSFER_SENDER_POLICY` checks the `from`, `TRANSFER_RECEIVER_POLICY` checks the `to`, `MINT_RECEIVER_POLICY` checks the recipient of a mint, and `TRANSFER_EXECUTOR_POLICY` checks the `msg.sender` of `transferFrom` only when the caller is not the sender. Note that `approve` is never policy-gated; only actual balance movement is checked. See [Policy Integration](/base-chain/specs/upgrades/beryl/b20#policy-integration). + + +Every scope defaults to `ALWAYS_ALLOW` at token creation. An unattended B20 deployment is fully open: token behavior must be intentionally constrained. + + +## Freeze and seize + +Freeze-and-seize is two steps. First, deny the target under `TRANSFER_SENDER_POLICY` so it can no longer send. Then call `burnBlocked` to burn from that account: the call requires the caller to hold `BURN_BLOCKED_ROLE` and requires the target to be denied by `TRANSFER_SENDER_POLICY`. Both conditions must hold, which is why the demo blocks the account before it can seize the balance. See [Burn](/base-chain/specs/upgrades/beryl/b20#burn). + +## Roles and metadata + +Grant and revoke the seven base roles (`DEFAULT_ADMIN_ROLE`, `MINT_ROLE`, `BURN_ROLE`, `BURN_BLOCKED_ROLE`, `PAUSE_ROLE`, `UNPAUSE_ROLE`, `METADATA_ROLE`) and trigger the one gated action behind each. `METADATA_ROLE` gates `updateName`, `updateSymbol`, and `updateContractURI`. Try to remove the last admin with `renounceRole` and it reverts with `LastAdminCannotRenounce`; the only path to an admin-less token is `renounceLastAdmin()`, which is permanent. See [Roles Model](/base-chain/specs/upgrades/beryl/b20#roles-model). + +## Supply cap and mint + +Mint with `MINT_ROLE` and push past the cap to see `SupplyCapExceeded`. Adjust the cap with `updateSupplyCap`, which reverts with `InvalidSupplyCap` when the new cap is below current `totalSupply` or above the `type(uint128).max` sentinel that marks no cap. When the simulated token is an Asset, extra controls appear: `batchMint`, the rebase `multiplier`, and `announce`. See [Supply Cap](/base-chain/specs/upgrades/beryl/b20#supply-cap). + +## Granular pause + +Pause `TRANSFER`, `MINT`, and `BURN` independently instead of freezing the whole token. Pausing transfers still lets minting continue, and vice versa. Pausing and unpausing are gated by distinct roles by design: `PAUSE_ROLE` and `UNPAUSE_ROLE`. See [Pause](/base-chain/specs/upgrades/beryl/b20#pause). + +## Memos + +Send a transaction with `transferWithMemo` (and the sibling `transferFromWithMemo`, `mintWithMemo`, `burnWithMemo`) to attach a `bytes32` reference. Each emits `Memo(caller, memo)` immediately after the operation's primary event, so an indexer joins the memo to its transaction via `(transactionHash, logIndex − 1)`. See [Memos](/base-chain/specs/upgrades/beryl/b20#memos). + +## Permit + +Sign an ERC-2612 approval offchain and submit it to grant an allowance without a prior `approve` transaction. The demo increments the account nonce on each use so a signature can't be replayed. Permit is ECDSA-only; ERC-1271 contract signatures are not accepted. See [ERC-2612 Permit / EIP-712](/base-chain/specs/upgrades/beryl/b20#erc-2612-permit--eip-712). + + +The playground intentionally leaves out a few advanced behaviors: the `initCalls` bootstrap bypass, two-step policy admin transfer, and custom user-defined roles. For those, see [initCalls Semantics](/base-chain/specs/upgrades/beryl/b20#initcalls-semantics), the [Policy Registry](/base-chain/specs/upgrades/beryl/b20#policy-registry) admin model, and [Roles Model](/base-chain/specs/upgrades/beryl/b20#roles-model) in the spec. + + +## Related docs + + + + The full spec: variants, policies, roles, and the complete function surface. + + + Deploy a real B20 token onchain. + + + Match transactions to orders with onchain memos. + + + The upgrade that introduces B20 and native token features. + + diff --git a/docs/docs.json b/docs/docs.json index 10d4e9b06..5607db4f9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -263,7 +263,8 @@ "group": "Beryl", "pages": [ "base-chain/specs/upgrades/beryl/overview", - "base-chain/specs/upgrades/beryl/b20" + "base-chain/specs/upgrades/beryl/b20", + "base-chain/specs/upgrades/beryl/b20-playground" ] }, { diff --git a/docs/snippets/B20PlaygroundDemo.jsx b/docs/snippets/B20PlaygroundDemo.jsx new file mode 100644 index 000000000..3656089c4 --- /dev/null +++ b/docs/snippets/B20PlaygroundDemo.jsx @@ -0,0 +1,1189 @@ + +export const B20PlaygroundDemo = () => { + const sans = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif"; + const serif = "'Tiempos Headline','Iowan Old Style','Source Serif Pro',ui-serif,Georgia,serif"; + const mono = "ui-monospace,'SF Mono','Cascadia Code',Menlo,Monaco,Consolas,monospace"; + + const c = { + bg: "#1f1e1d", header: "#262624", border: "#34322f", inputBg: "#2a2926", + text: "#f5f4ed", body: "#e8e4dc", muted: "#a8a39d", dim: "#6b6663", + accent: "#D97757", bubble: "#2c2b28", bubbleText: "#f5f4ed", + code: "#e89972", codeBg: "rgba(217,119,87,0.12)", + toolBg: "#272622", toolBorder: "#3a3835", success: "#a3c585", + error: "#e57373", errorBg: "rgba(229,115,115,0.10)", + }; + + // ----- Preset accounts ----- + const ACCOUNTS = [ + { key: "Issuer", label: "Issuer", addr: "0x155e…d001", dot: "#D97757" }, + { key: "Processor", label: "Processor", addr: "0x9r0c…d002", dot: "#7c9fe8" }, + { key: "Alice", label: "Alice", addr: "0xA11c…e001", dot: "#a3c585" }, + { key: "Bob", label: "Bob", addr: "0xB0b0…e002", dot: "#e5c07b" }, + { key: "Carol", label: "Carol", addr: "0xCar0…e003", dot: "#c586d9" }, + ]; + const acctOf = (k) => ACCOUNTS.find(a => a.key === k) || { key: k, label: k, addr: "0x0000…0000", dot: c.dim }; + + const ROLE_NAMES_BASE = [ + "DEFAULT_ADMIN_ROLE", "MINT_ROLE", "BURN_ROLE", "BURN_BLOCKED_ROLE", + "PAUSE_ROLE", "UNPAUSE_ROLE", "METADATA_ROLE", + ]; + const SCOPES = ["TRANSFER_SENDER", "TRANSFER_RECEIVER", "TRANSFER_EXECUTOR", "MINT_RECEIVER"]; + + // Built-in policy IDs. ALWAYS_ALLOW = 0. ALWAYS_BLOCK = (ALLOWLIST(=1) << 56) | 1. + const ALWAYS_ALLOW = 0; + const ALWAYS_BLOCK = "ALWAYS_BLOCK"; // sentinel token used internally for the built-in block id + // Rendered composed form of ALWAYS_BLOCK, i.e. 0x0100…01. + const ALWAYS_BLOCK_RENDER = "0x0100…01"; + + const UINT128_MAX = "type(uint128).max (no cap)"; + + // ----- djb2-style deterministic string hash (simulated keccak) ----- + const hashHex = (deployer, salt) => { + let h = 5381; + const s = deployer + "|" + salt; + for (let i = 0; i < s.length; i++) { + h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; + } + // spread into 9 bytes deterministically + let out = ""; + let x = h; + for (let i = 0; i < 9; i++) { + x = (x * 1103515245 + 12345 + i * 7) >>> 0; + const b = (x >>> ((i % 4) * 8)) & 0xff; + out += b.toString(16).padStart(2, "0"); + } + return out; // 18 hex chars = 9 bytes + }; + + const deriveAddress = (variant, deployer, salt) => { + const prefix = "b2000000000000000000"; // 10-byte B20 prefix + const vByte = variant === "STABLECOIN" ? "01" : "00"; + const tail = hashHex(deployer, salt); // 9 bytes + return { prefix, vByte, tail }; + }; + + // ----- state ----- + const [token, setToken] = useState(null); + const [balances, setBalances] = useState({}); + const [roles, setRoles] = useState({}); + const [policies, setPolicies] = useState({}); + const [nextPolicyId, setNextPolicyId] = useState(2); + const [scopes, setScopes] = useState({ TRANSFER_SENDER: 0, TRANSFER_RECEIVER: 0, TRANSFER_EXECUTOR: 0, MINT_RECEIVER: 0 }); + const [paused, setPaused] = useState({ TRANSFER: false, MINT: false, BURN: false }); + const [allowances, setAllowances] = useState({}); + const [nonces, setNonces] = useState({}); + const [eventLog, setEventLog] = useState([]); + const [activeTab, setActiveTab] = useState("Create"); + const [actingAs, setActingAs] = useState("Issuer"); + + const idRef = useRef(0); + const logRef = useRef(null); + + useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [eventLog]); + + // ----- Create-tab form state ----- + const [cVariant, setCVariant] = useState("STABLECOIN"); + const [cName, setCName] = useState("Acme Dollar"); + const [cSymbol, setCSymbol] = useState("aUSD"); + const [cDecimals, setCDecimals] = useState(6); + const [cCurrency, setCCurrency] = useState("USD"); + const [cSalt, setCSalt] = useState("0x01"); + const [cInitialAdminZero, setCInitialAdminZero] = useState(false); + + const totalSupply = () => Object.values(balances).reduce((a, b) => a + b, 0); + + // ----- log helpers ----- + const pushLog = (entries) => { + setEventLog(prev => { + const tag = "0x" + (prev.length % 16).toString(16) + "f4…" + ((prev.length * 7) % 256).toString(16).padStart(2, "0"); + const withIds = entries.map((e, i) => ({ + id: ++idRef.current, + logIndex: e.logIndex, + txTag: tag, + kind: e.kind, + name: e.name, + args: e.args || "", + })); + return [...prev, ...withIds]; + }); + }; + const emit = (name, args, logIndex) => ({ kind: "event", name, args, logIndex }); + const revert = (name, args) => ({ kind: "revert", name, args: args || "", logIndex: null }); + const info = (name, args) => ({ kind: "info", name, args: args || "", logIndex: null }); + + // ----- authorization helper (never throws) ----- + const isAuthorized = (policyId, account) => { + if (policyId === 0 || policyId === undefined || policyId === null) return true; + if (policyId === ALWAYS_BLOCK) return false; + const p = policies[policyId]; + if (!p) { + // non-existent id collapses to empty-set semantics. + // We cannot know the intended type; spec: non-existent BLOCKLIST authorizes, + // non-existent ALLOWLIST denies. Encoded type is in the top byte of the id. + // Our simulated ids carry their type on the policy object only; for a missing + // object we decode by convention: ids created as ALLOWLIST were tracked, so a + // truly-missing id is treated as an empty ALLOWLIST (denies everyone). + return false; + } + if (p.type === "BLOCKLIST") return !p.members.includes(account); + if (p.type === "ALLOWLIST") return p.members.includes(account); + return true; + }; + + const roleHas = (roleName, account) => (roles[roleName] || []).includes(account); + const adminLess = () => token && (token.adminRenounced || token.initialAdminZero); + const isAdmin = (account) => !adminLess() && roleHas("DEFAULT_ADMIN_ROLE", account); + + // ----- create token ----- + const createToken = () => { + const deployer = acctOf(actingAs).addr; + const dec = cVariant === "STABLECOIN" ? 6 : cDecimals; + const t = { + variant: cVariant, + name: cName, + symbol: cSymbol, + decimals: dec, + currency: cVariant === "STABLECOIN" ? cCurrency : null, + supplyCap: null, + salt: cSalt, + deployer, + contractURI: "", + multiplier: 1.0, + extraMetadata: {}, + adminRenounced: false, + initialAdminZero: cInitialAdminZero, + }; + const roleMap = {}; + ROLE_NAMES_BASE.forEach(r => { roleMap[r] = []; }); + if (cVariant === "ASSET") roleMap["OPERATOR_ROLE"] = []; + // initialAdmin gets every gate role for a friendly playground, unless admin-less. + if (!cInitialAdminZero) { + const admin = actingAs; + Object.keys(roleMap).forEach(r => { roleMap[r] = [admin]; }); + } + setToken(t); + setRoles(roleMap); + setBalances({}); + setPolicies({}); + setNextPolicyId(2); + setScopes({ TRANSFER_SENDER: 0, TRANSFER_RECEIVER: 0, TRANSFER_EXECUTOR: 0, MINT_RECEIVER: 0 }); + setPaused({ TRANSFER: false, MINT: false, BURN: false }); + setAllowances({}); + setNonces({}); + setEventLog([]); + idRef.current = 0; + setActiveTab("Supply"); + setTimeout(() => pushLog([ + info("B20Created", `${cVariant.toLowerCase()} · ${cSymbol} · deployer ${acctOf(actingAs).label}`), + cInitialAdminZero + ? info("admin", "initialAdmin == address(0) — launched admin-less") + : emit("RoleGranted", `DEFAULT_ADMIN_ROLE → ${acctOf(actingAs).label}`, 0), + ]), 0); + }; + + const resetAll = () => { + setToken(null); + setBalances({}); setRoles({}); setPolicies({}); setNextPolicyId(2); + setScopes({ TRANSFER_SENDER: 0, TRANSFER_RECEIVER: 0, TRANSFER_EXECUTOR: 0, MINT_RECEIVER: 0 }); + setPaused({ TRANSFER: false, MINT: false, BURN: false }); + setAllowances({}); setNonces({}); setEventLog([]); idRef.current = 0; + setActiveTab("Create"); setActingAs("Issuer"); + }; + + // ----- capacity check for supply cap ----- + const capExceeded = (addAmount) => { + if (!token || token.supplyCap === null) return false; + return totalSupply() + addAmount > token.supplyCap; + }; + + // --------------------------------------------------------------- + // Single write path. Validates in spec order, appends events/reverts. + // --------------------------------------------------------------- + const execute = (op) => { + const me = actingAs; + const A = (k) => acctOf(k).label; + + switch (op.type) { + // -------- transfer(from, to, amt) -------- + case "transfer": { + const { from, to, amt, memo } = op; + if (paused.TRANSFER) return pushLog([revert("EnforcedPause", "feature: TRANSFER")]); + if (!isAuthorized(scopes.TRANSFER_SENDER, from)) return pushLog([revert("PolicyForbids", `TRANSFER_SENDER · ${A(from)}`)]); + if (!isAuthorized(scopes.TRANSFER_RECEIVER, to)) return pushLog([revert("PolicyForbids", `TRANSFER_RECEIVER · ${A(to)}`)]); + if ((balances[from] || 0) < amt) return pushLog([revert("ERC20InsufficientBalance", `${A(from)} has ${balances[from] || 0}`)]); + setBalances(b => ({ ...b, [from]: (b[from] || 0) - amt, [to]: (b[to] || 0) + amt })); + if (memo !== undefined) { + return pushLog([emit("Transfer", `${A(from)} → ${A(to)} · ${amt}`, 0), emit("Memo", `${A(me)} · ${memo}`, 1)]); + } + return pushLog([emit("Transfer", `${A(from)} → ${A(to)} · ${amt}`, 0)]); + } + + // -------- transferFrom(exec, from, to, amt) -------- + case "transferFrom": { + const { from, to, amt, memo } = op; + const exec = me; + const key = from + "→" + exec; + if (paused.TRANSFER) return pushLog([revert("EnforcedPause", "feature: TRANSFER")]); + if ((allowances[key] || 0) < amt) return pushLog([revert("ERC20InsufficientAllowance", `${A(exec)} allowance ${allowances[key] || 0} < ${amt}`)]); + if (exec !== from && !isAuthorized(scopes.TRANSFER_EXECUTOR, exec)) return pushLog([revert("PolicyForbids", `TRANSFER_EXECUTOR · ${A(exec)}`)]); + if (!isAuthorized(scopes.TRANSFER_SENDER, from)) return pushLog([revert("PolicyForbids", `TRANSFER_SENDER · ${A(from)}`)]); + if (!isAuthorized(scopes.TRANSFER_RECEIVER, to)) return pushLog([revert("PolicyForbids", `TRANSFER_RECEIVER · ${A(to)}`)]); + if ((balances[from] || 0) < amt) return pushLog([revert("ERC20InsufficientBalance", `${A(from)} has ${balances[from] || 0}`)]); + setBalances(b => ({ ...b, [from]: (b[from] || 0) - amt, [to]: (b[to] || 0) + amt })); + setAllowances(al => ({ ...al, [key]: (al[key] || 0) - amt })); + if (memo !== undefined) { + return pushLog([emit("Transfer", `${A(from)} → ${A(to)} · ${amt}`, 0), emit("Memo", `${A(me)} · ${memo}`, 1)]); + } + return pushLog([emit("Transfer", `${A(from)} → ${A(to)} · ${amt} (by ${A(exec)})`, 0)]); + } + + // -------- approve(owner, spender, amt) : never gated -------- + case "approve": { + const { owner, spender, amt } = op; + const key = owner + "→" + spender; + setAllowances(al => ({ ...al, [key]: amt })); + return pushLog([emit("Approval", `${A(owner)} → ${A(spender)} · ${amt}`, 0)]); + } + + // -------- mint(caller, to, amt) -------- + case "mint": { + const { to, amt, memo } = op; + if (paused.MINT) return pushLog([revert("EnforcedPause", "feature: MINT")]); + if (!roleHas("MINT_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks MINT_ROLE`)]); + if (!isAuthorized(scopes.MINT_RECEIVER, to)) return pushLog([revert("PolicyForbids", `MINT_RECEIVER · ${A(to)}`)]); + if (capExceeded(amt)) return pushLog([revert("SupplyCapExceeded", `${totalSupply() + amt} > cap ${token.supplyCap}`)]); + setBalances(b => ({ ...b, [to]: (b[to] || 0) + amt })); + if (memo !== undefined) { + return pushLog([emit("Transfer", `0x0 → ${A(to)} · ${amt}`, 0), emit("Memo", `${A(me)} · ${memo}`, 1)]); + } + return pushLog([emit("Transfer", `0x0 → ${A(to)} · ${amt}`, 0)]); + } + + // -------- burn(caller, amt) -------- + case "burn": { + const { amt, memo } = op; + if (paused.BURN) return pushLog([revert("EnforcedPause", "feature: BURN")]); + if (!roleHas("BURN_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks BURN_ROLE`)]); + if ((balances[me] || 0) < amt) return pushLog([revert("ERC20InsufficientBalance", `${A(me)} has ${balances[me] || 0}`)]); + setBalances(b => ({ ...b, [me]: (b[me] || 0) - amt })); + if (memo !== undefined) { + return pushLog([emit("Transfer", `${A(me)} → 0x0 · ${amt}`, 0), emit("Memo", `${A(me)} · ${memo}`, 1)]); + } + return pushLog([emit("Transfer", `${A(me)} → 0x0 · ${amt}`, 0)]); + } + + // -------- burnBlocked(caller, target, amt) -------- + case "burnBlocked": { + const { target, amt } = op; + if (paused.BURN) return pushLog([revert("EnforcedPause", "feature: BURN")]); + if (!roleHas("BURN_BLOCKED_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks BURN_BLOCKED_ROLE`)]); + if (isAuthorized(scopes.TRANSFER_SENDER, target)) return pushLog([revert("AccountNotBlocked", `${A(target)} not denied by TRANSFER_SENDER`)]); + if ((balances[target] || 0) < amt) return pushLog([revert("ERC20InsufficientBalance", `${A(target)} has ${balances[target] || 0}`)]); + setBalances(b => ({ ...b, [target]: (b[target] || 0) - amt })); + return pushLog([emit("Transfer", `${A(target)} → 0x0 · ${amt} (seized)`, 0)]); + } + + // -------- grantRole -------- + case "grantRole": { + const { role, account } = op; + if (!isAdmin(me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); + if (roleHas(role, account)) return pushLog([info("grantRole", `${A(account)} already holds ${role}`)]); + setRoles(r => ({ ...r, [role]: [...(r[role] || []), account] })); + return pushLog([emit("RoleGranted", `${role} → ${A(account)}`, 0)]); + } + + // -------- revokeRole -------- + case "revokeRole": { + const { role, account } = op; + if (!isAdmin(me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); + if (role === "DEFAULT_ADMIN_ROLE" && (roles["DEFAULT_ADMIN_ROLE"] || []).length <= 1 && roleHas(role, account)) { + return pushLog([revert("LastAdminCannotRenounce", "cannot remove the last admin")]); + } + setRoles(r => ({ ...r, [role]: (r[role] || []).filter(a => a !== account) })); + return pushLog([emit("RoleRevoked", `${role} ✕ ${A(account)}`, 0)]); + } + + // -------- renounceRole (self) -------- + case "renounceRole": { + const { role } = op; + // self-renounce does not require admin + if (!roleHas(role, me)) return pushLog([info("renounceRole", `${A(me)} does not hold ${role}`)]); + if (role === "DEFAULT_ADMIN_ROLE" && (roles["DEFAULT_ADMIN_ROLE"] || []).length <= 1) { + return pushLog([revert("LastAdminCannotRenounce", "use renounceLastAdmin() instead")]); + } + setRoles(r => ({ ...r, [role]: (r[role] || []).filter(a => a !== me) })); + return pushLog([emit("RoleRevoked", `${role} ✕ ${A(me)} (renounced)`, 0)]); + } + + // -------- renounceLastAdmin -------- + case "renounceLastAdmin": { + if (!roleHas("DEFAULT_ADMIN_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); + setRoles(r => ({ ...r, DEFAULT_ADMIN_ROLE: (r["DEFAULT_ADMIN_ROLE"] || []).filter(a => a !== me) })); + setToken(t => ({ ...t, adminRenounced: true })); + return pushLog([emit("RoleRevoked", `DEFAULT_ADMIN_ROLE ✕ ${A(me)}`, 0), info("admin", "token is now permanently admin-less")]); + } + + // -------- updateSupplyCap -------- + case "updateSupplyCap": { + const { newCap } = op; // number or null + if (!isAdmin(me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); + if (newCap !== null && newCap < totalSupply()) return pushLog([revert("InvalidSupplyCap", `${newCap} < totalSupply ${totalSupply()}`)]); + setToken(t => ({ ...t, supplyCap: newCap })); + return pushLog([emit("SupplyCapUpdated", newCap === null ? UINT128_MAX : String(newCap), 0)]); + } + + // -------- updatePolicy(scope, policyId) -------- + case "updatePolicy": { + const { scope, policyId } = op; + if (!isAdmin(me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); + setScopes(s => ({ ...s, [scope]: policyId })); + const label = policyId === 0 ? "ALWAYS_ALLOW (0)" : policyId === ALWAYS_BLOCK ? `ALWAYS_BLOCK (${ALWAYS_BLOCK_RENDER})` : `#${policyId}`; + return pushLog([emit("PolicyUpdated", `${scope} → ${label}`, 0)]); + } + + // -------- createPolicy(type) -------- + case "createPolicy": { + const { policyType } = op; + const id = nextPolicyId; + setPolicies(p => ({ ...p, [id]: { type: policyType, members: [] } })); + setNextPolicyId(n => n + 1); + return pushLog([emit("PolicyCreated", `#${id} · ${policyType} · admin ${A(me)}`, 0)]); + } + + // -------- updateBlocklist / updateAllowlist (membership toggle) -------- + case "togglePolicyMember": { + const { policyId, account } = op; + const p = policies[policyId]; + if (!p) return pushLog([revert("PolicyDoesNotExist", `#${policyId}`)]); + const has = p.members.includes(account); + setPolicies(prev => ({ + ...prev, + [policyId]: { ...prev[policyId], members: has ? prev[policyId].members.filter(a => a !== account) : [...prev[policyId].members, account] }, + })); + const setter = p.type === "BLOCKLIST" ? "updateBlocklist" : "updateAllowlist"; + return pushLog([emit(setter, `#${policyId} · ${has ? "remove" : "add"} ${A(account)}`, 0)]); + } + + // -------- pause / unpause -------- + case "pause": { + const { feature } = op; + if (!roleHas("PAUSE_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks PAUSE_ROLE`)]); + setPaused(p => ({ ...p, [feature]: true })); + return pushLog([emit("Paused", `feature: ${feature}`, 0)]); + } + case "unpause": { + const { feature } = op; + if (!roleHas("UNPAUSE_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks UNPAUSE_ROLE`)]); + setPaused(p => ({ ...p, [feature]: false })); + return pushLog([emit("Unpaused", `feature: ${feature}`, 0)]); + } + + // -------- metadata -------- + case "updateName": { + if (!roleHas("METADATA_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks METADATA_ROLE`)]); + setToken(t => ({ ...t, name: op.value })); + return pushLog([ + emit("NameUpdated", op.value, 0), + info("EIP712DomainChanged", "domain separator rotated"), + ]); + } + case "updateSymbol": { + if (!roleHas("METADATA_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks METADATA_ROLE`)]); + setToken(t => ({ ...t, symbol: op.value })); + return pushLog([emit("SymbolUpdated", op.value, 0)]); + } + case "updateContractURI": { + if (!roleHas("METADATA_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks METADATA_ROLE`)]); + setToken(t => ({ ...t, contractURI: op.value })); + return pushLog([emit("ContractURIUpdated", op.value, 0)]); + } + + // -------- permit -------- + case "permit": { + const { owner, spender, amt, nonce } = op; + const current = nonces[owner] || 0; + if (nonce !== current) return pushLog([revert("ERC2612InvalidSigner", `stale nonce ${nonce} ≠ ${current} — replay rejected`)]); + const key = owner + "→" + spender; + setAllowances(al => ({ ...al, [key]: amt })); + setNonces(n => ({ ...n, [owner]: current + 1 })); + return pushLog([emit("Approval", `${A(owner)} → ${A(spender)} · ${amt} (permit, relayed by ${A(me)})`, 0)]); + } + + // -------- ASSET: updateMultiplier -------- + case "updateMultiplier": { + if (!roleHas("OPERATOR_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks OPERATOR_ROLE`)]); + setToken(t => ({ ...t, multiplier: op.value })); + return pushLog([emit("MultiplierUpdated", `${op.value}× (WAD)`, 0)]); + } + + // -------- ASSET: batchMint -------- + case "batchMint": { + const { recipients, amt } = op; + if (!roleHas("MINT_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks MINT_ROLE`)]); + const total = recipients.length * amt; + if (capExceeded(total)) return pushLog([revert("SupplyCapExceeded", `${totalSupply() + total} > cap ${token.supplyCap}`)]); + setBalances(b => { + const nb = { ...b }; + recipients.forEach(r => { nb[r] = (nb[r] || 0) + amt; }); + return nb; + }); + return pushLog(recipients.map((r, i) => emit("Transfer", `0x0 → ${A(r)} · ${amt}`, i))); + } + + // -------- ASSET: announce(batchMint) -------- + case "announceBatchMint": { + const { recipients, amt, id } = op; + if (!roleHas("OPERATOR_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks OPERATOR_ROLE`)]); + if (!roleHas("MINT_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `inner batchMint needs MINT_ROLE`)]); + const total = recipients.length * amt; + if (capExceeded(total)) return pushLog([revert("SupplyCapExceeded", `${totalSupply() + total} > cap ${token.supplyCap}`)]); + setBalances(b => { + const nb = { ...b }; + recipients.forEach(r => { nb[r] = (nb[r] || 0) + amt; }); + return nb; + }); + const inner = recipients.map((r, i) => emit("Transfer", `0x0 → ${A(r)} · ${amt}`, i + 1)); + return pushLog([ + emit("Announcement", `id ${id} · scheduled batchMint`, 0), + ...inner, + emit("EndAnnouncement", `id ${id}`, inner.length + 1), + ]); + } + + // -------- ASSET: updateExtraMetadata -------- + case "updateExtraMetadata": { + const { mkey, value } = op; + if (!roleHas("METADATA_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks METADATA_ROLE`)]); + setToken(t => { + const em = { ...t.extraMetadata }; + if (value === "") delete em[mkey]; else em[mkey] = value; + return { ...t, extraMetadata: em }; + }); + return pushLog([emit("ExtraMetadataUpdated", value === "" ? `${mkey} (removed)` : `${mkey} = ${value}`, 0)]); + } + + default: + return; + } + }; + + // ====================================================================== + // UI PRIMITIVES + // ====================================================================== + const TrafficLights = () => ( +
+ + + +
+ ); + + const Dot = ({ color, size }) => ( + + ); + + const Btn = ({ onClick, children, tone, disabled, title }) => { + const [hover, setHover] = useState(false); + const base = tone === "accent" ? c.accent : c.toolBorder; + return ( + + ); + }; + + const Chip = ({ active, onClick, children, dot, disabled }) => { + const [hover, setHover] = useState(false); + return ( + + ); + }; + + const Field = ({ label, children }) => ( +
+ {label} + {children} +
+ ); + + const TextInput = ({ value, onChange, width, placeholder, mono: isMono }) => ( + onChange(e.target.value)} + placeholder={placeholder} + style={{ + fontFamily: isMono ? mono : sans, fontSize: 12.5, color: c.text, + background: c.inputBg, border: `1px solid ${c.toolBorder}`, borderRadius: 8, + padding: "7px 10px", width: width || 140, outline: "none", + }} + /> + ); + + const Box = ({ title, children, muted, hint }) => ( +
+ {title &&
{title}
} + {hint &&
{hint}
} + {children} +
+ ); + + const Row = ({ children, wrap }) => ( +
{children}
+ ); + + const Pill = ({ on, children }) => ( + {children} + ); + + // ----- account selector row (small) ----- + const AccountPicker = ({ value, onChange, exclude }) => ( + + {ACCOUNTS.filter(a => !exclude || !exclude.includes(a.key)).map(a => ( + onChange(a.key)}>{a.label} + ))} + + ); + + // ====================================================================== + // TOKEN SUMMARY STRIP + // ====================================================================== + const derived = token ? deriveAddress(token.variant, token.deployer, token.salt) : null; + + const AddressRender = ({ d }) => ( + + 0x{d.prefix.slice(0, 8)}… + {d.vByte} + {d.tail.slice(0, 4)}…{d.tail.slice(-4)} + + ); + + const SummaryStrip = () => { + if (!token) return null; + return ( +
+
+ {token.name} + {token.symbol} + {token.variant} + + {token.currency && currency() = {token.currency}} +
+
+ + totalSupply {totalSupply()} / cap {token.supplyCap === null ? "∞" : token.supplyCap} + + {token.variant === "ASSET" && multiplier() {token.multiplier}×} + + {["TRANSFER", "MINT", "BURN"].map(f => {f}{paused[f] ? " ⏸" : ""})} + + + {adminLess() + ? admin-less + : <>admin: {(roles["DEFAULT_ADMIN_ROLE"] || []).map(a => acctOf(a).label).join(", ") || "—"}} + +
+
+ ); + }; + + // ====================================================================== + // TABS + // ====================================================================== + const TABS = ["Create", "Policies", "Freeze & seize", "Roles", "Supply", "Pause", "Memos", "Permit"]; + + // ----- balances mini-table used in several tabs ----- + const BalanceTable = ({ scaled }) => ( +
+ {ACCOUNTS.map(a => ( + + {a.label} + {balances[a.key] || 0} + {scaled && token && token.variant === "ASSET" && ( + ({((balances[a.key] || 0) * token.multiplier).toFixed(2)}) + )} + + ))} +
+ ); + + // ---------- CREATE TAB ---------- + const CreateTab = () => { + if (token) { + return ( +
+ +
+
Variant: {token.variant} · decimals {token.decimals}{token.currency ? <> · currency {token.currency} : null}
+
Address:
+
prefix | variant | keccak256(deployer, salt) — hash simulated
+
+
+ Reset playground +
+ ); + } + const dprefix = deriveAddress(cVariant, acctOf(actingAs).addr, cSalt); + return ( +
+ + + setCVariant("STABLECOIN")}>Stablecoin (6 decimals) + setCVariant("ASSET")}>Asset (6–18 decimals) + + + +
+ + + {cVariant === "ASSET" ? ( + + + {[6, 8, 12, 18].map(d => setCDecimals(d)}>{d})} + + + ) : ( + setCCurrency(v.replace(/[^A-Za-z]/g, "").toUpperCase().slice(0, 4))} width={70} /> + )} +
+
+ setCInitialAdminZero(!cInitialAdminZero)}> + initialAdmin == address(0) — launch admin-less + +
+
+ + + +
+
+
+
+ createB20({cVariant.toLowerCase()}, …) + deployer = {acctOf(actingAs).label} (acting-as) +
+
+ ); + }; + + // ---------- POLICIES TAB ---------- + const PoliciesTab = () => ( +
+ + + execute({ type: "createPolicy", policyType: "ALLOWLIST" })}>createPolicy(ALLOWLIST) + execute({ type: "createPolicy", policyType: "BLOCKLIST" })}>createPolicy(BLOCKLIST) + +
+ {Object.keys(policies).length === 0 && No custom policies yet.} + {Object.entries(policies).map(([id, p]) => ( +
+
+ #{id} · {p.type} + · default {p.type === "BLOCKLIST" ? "authorized" : "denied"} +
+ + {ACCOUNTS.map(a => ( + execute({ type: "togglePolicyMember", policyId: Number(id), account: a.key })}> + {a.label} + + ))} + +
+ ))} +
+
+ + + {SCOPES.map(s => ( +
+ {s} + execute({ type: "updatePolicy", scope: s, policyId: 0 })}>ALWAYS_ALLOW + execute({ type: "updatePolicy", scope: s, policyId: ALWAYS_BLOCK })}>ALWAYS_BLOCK + {Object.keys(policies).map(id => ( + execute({ type: "updatePolicy", scope: s, policyId: Number(id) })}>#{id} + ))} +
+ ))} +
+ + + + execute({ type: "transfer", from: "Alice", to: "Bob", amt: 10 })}>transfer Alice→Bob (10) + { execute({ type: "approve", owner: "Alice", spender: "Processor", amt: 10 }); execute({ type: "transferFrom", from: "Alice", to: "Bob", amt: 10 }); }}>transferFrom Processor (Alice→Bob) + execute({ type: "mint", to: "Carol", amt: 10 })}>mint → Carol (10) + +
+
+
+ ); + + // ---------- FREEZE & SEIZE TAB ---------- + const FreezeTab = () => { + const blocklistId = Object.entries(policies).find(([, p]) => p.type === "BLOCKLIST" && p.members.includes("Bob")); + const bound = blocklistId && scopes.TRANSFER_SENDER === Number(blocklistId[0]); + return ( +
+ +
+
+
Step 1 — freeze: create a blocklist with Bob and bind it to TRANSFER_SENDER
+ { + const id = nextPolicyId; + execute({ type: "createPolicy", policyType: "BLOCKLIST" }); + execute({ type: "togglePolicyMember", policyId: id, account: "Bob" }); + execute({ type: "updatePolicy", scope: "TRANSFER_SENDER", policyId: id }); + }}>Freeze Bob (create + block + bind) +
+
+
Step 2 — Bob attempts a transfer → PolicyForbids
+ execute({ type: "transfer", from: "Bob", to: "Alice", amt: 5 })}>transfer Bob→Alice (5) +
+
+
Step 3 — seize: grant BURN_BLOCKED_ROLE, then burnBlocked(Bob)
+ + execute({ type: "grantRole", role: "BURN_BLOCKED_ROLE", account: actingAs })}>grantRole BURN_BLOCKED_ROLE → {acctOf(actingAs).label} + execute({ type: "burnBlocked", target: "Bob", amt: 5 })}>burnBlocked(Bob, 5) + +
+
+
+ + + execute({ type: "burnBlocked", target: "Alice", amt: 5 })}>burnBlocked(Alice) — not blocked + { execute({ type: "revokeRole", role: "BURN_BLOCKED_ROLE", account: actingAs }); execute({ type: "burnBlocked", target: "Bob", amt: 5 }); }}>burnBlocked without role + + +
+ {bound ? "Bob is currently frozen (denied by TRANSFER_SENDER)." : "Bob is not yet frozen."} +
+ +
+ ); + }; + + // ---------- ROLES TAB ---------- + const RolesTab = () => { + const allRoles = Object.keys(roles); + const gatedTry = { + MINT_ROLE: { label: "mint → Carol (5)", op: { type: "mint", to: "Carol", amt: 5 } }, + BURN_ROLE: { label: "burn (5)", op: { type: "burn", amt: 5 } }, + PAUSE_ROLE: { label: "pause TRANSFER", op: { type: "pause", feature: "TRANSFER" } }, + UNPAUSE_ROLE: { label: "unpause TRANSFER", op: { type: "unpause", feature: "TRANSFER" } }, + }; + const [mkey, setMkey] = useState("prospectus"); + const [mval, setMval] = useState("ipfs://Qm…"); + const [emKey, setEmKey] = useState("cusip"); + const [emVal, setEmVal] = useState("38259P508"); + return ( +
+ +
+ + + + + {ACCOUNTS.map(a => )} + + + + {allRoles.map(r => ( + + + {ACCOUNTS.map(a => { + const has = roleHas(r, a.key); + return ( + + ); + })} + + ))} + +
role{a.label}
{r} + execute(has + ? { type: "revokeRole", role: r, account: a.key } + : { type: "grantRole", role: r, account: a.key })}> + {has ? "✓" : "+"} + +
+
+
+ + + + {Object.entries(gatedTry).map(([role, g]) => execute(g.op)}>{g.label})} + + + + + + execute({ type: "updateName", value: token.name + " v2" })}>updateName + execute({ type: "updateSymbol", value: token.symbol + "x" })}>updateSymbol + execute({ type: "updateContractURI", value: "ipfs://Qm…/contract.json" })}>updateContractURI + + + + + + execute({ type: "renounceRole", role: "DEFAULT_ADMIN_ROLE" })}>renounceRole(DEFAULT_ADMIN_ROLE) + execute({ type: "renounceLastAdmin" })}>renounceLastAdmin() + execute({ type: "grantRole", role: "MINT_ROLE", account: "Carol" })}>grantRole MINT_ROLE → Carol + execute({ type: "mint", to: "Alice", amt: 5 })}>existing MINT_ROLE still mints + + + + {token && token.variant === "ASSET" && ( + + + + +
execute({ type: "updateExtraMetadata", mkey: emKey, value: emVal })}>updateExtraMetadata
+
+
+ {Object.keys(token.extraMetadata).length === 0 ? "extraMetadata: {}" : Object.entries(token.extraMetadata).map(([k, v]) => {k}={v})} +
+
+ )} +
+ ); + }; + + // ---------- SUPPLY TAB ---------- + const SupplyTab = () => { + const [mintTo, setMintTo] = useState("Alice"); + const [mintAmt, setMintAmt] = useState(100); + const [capVal, setCapVal] = useState(1000); + return ( +
+ + + + + + {[10, 50, 100, 500].map(v => setMintAmt(v)}>{v})} + execute({ type: "mint", to: mintTo, amt: mintAmt })}>mint({acctOf(mintTo).label}, {mintAmt}) + +
+
+ + + + {[500, 1000, 5000].map(v => setCapVal(v)}>{v})} + execute({ type: "updateSupplyCap", newCap: capVal })}>updateSupplyCap({capVal}) + execute({ type: "updateSupplyCap", newCap: null })}>updateSupplyCap(no cap) + +
+ Set the cap below totalSupply ({totalSupply()}) to see InvalidSupplyCap; mint past it to see SupplyCapExceeded. +
+
+ + + + {[10, 50].map(v => execute({ type: "burn", amt: v })}>burn({v}) as {acctOf(actingAs).label})} + + + + {token && token.variant === "ASSET" && ( + <> + + + {[1.0, 1.05, 1.25, 0.9].map(v => execute({ type: "updateMultiplier", value: v })}>{v}×)} + +
raw (scaled) shown side by side:
+
+
+ + + execute({ type: "batchMint", recipients: ["Alice", "Bob", "Carol"], amt: 25 })}>batchMint([Alice,Bob,Carol], 25) + execute({ type: "announceBatchMint", recipients: ["Alice", "Bob", "Carol"], amt: 25, id: 1 })}>announce(batchMint…) (OPERATOR_ROLE) + + + + )} +
+ ); + }; + + // ---------- PAUSE TAB ---------- + const PauseTab = () => ( +
+ + {["TRANSFER", "MINT", "BURN"].map(f => ( +
+ {f} + {paused[f] ? "paused" : "live"} + execute({ type: "pause", feature: f })} disabled={paused[f]}>pause + execute({ type: "unpause", feature: f })} disabled={!paused[f]}>unpause +
+ ))} +
+ + + execute({ type: "mint", to: "Alice", amt: 10 })}>mint → Alice (10) + execute({ type: "transfer", from: "Alice", to: "Bob", amt: 5 })}>transfer Alice→Bob (5) + execute({ type: "approve", owner: "Alice", spender: "Processor", amt: 20 })}>approve (never pause-gated) + + +
+ ); + + // ---------- MEMOS TAB ---------- + const MemosTab = () => { + const [memo, setMemo] = useState("invoice-8842"); + const asHex = "0x" + Array.from(memo).map(ch => ch.charCodeAt(0).toString(16).padStart(2, "0")).join("").slice(0, 20).padEnd(20, "0") + "…"; + return ( +
+ + + +
{asHex}
+
+
+ + execute({ type: "transfer", from: "Alice", to: "Bob", amt: 10, memo })}>transferWithMemo Alice→Bob (10) + execute({ type: "mint", to: "Carol", amt: 10, memo })}>mintWithMemo → Carol (10) + execute({ type: "burn", amt: 10, memo })}>burnWithMemo (10) as {acctOf(actingAs).label} + +
+
+ + + +
+ ); + }; + + const MemoJoinView = () => { + const pairs = []; + for (let i = 0; i < eventLog.length - 1; i++) { + const a = eventLog[i], b = eventLog[i + 1]; + if (b.kind === "event" && b.name === "Memo" && b.logIndex === (a.logIndex + 1) && a.kind === "event") { + pairs.push([a, b]); + } + } + if (pairs.length === 0) return Fire a *WithMemo op to see the join.; + return ( +
+ {pairs.slice(-4).map(([a, b], i) => ( +
+
+
+
[{a.logIndex}] {a.name} {a.args}
+
[{b.logIndex}] {b.name} {b.args} ← join on logIndex − 1
+
+
+ ))} +
+ ); + }; + + // ---------- PERMIT TAB ---------- + const PermitTab = () => { + const [pOwner, setPOwner] = useState("Alice"); + const [pSpender, setPSpender] = useState("Processor"); + const [pValue, setPValue] = useState(100); + const [signed, setSigned] = useState(null); + const ownerNonce = nonces[pOwner] || 0; + const key = pOwner + "→" + pSpender; + return ( +
+ + + + + + + + + {[50, 100, 250].map(v => setPValue(v)}>{v})} + setSigned({ owner: pOwner, spender: pSpender, value: pValue, nonce: ownerNonce })}>Sign permit + + {signed && ( +
+
✎ signed payload
+ owner: {acctOf(signed.owner).label} · spender: {acctOf(signed.spender).label}
+ value: {signed.value} · nonce: {signed.nonce} · deadline: ∞
+ v,r,s: 0x{(signed.nonce * 7 + 12).toString(16)}…ecdsa +
+ )} +
+ + + + signed && execute({ type: "permit", owner: signed.owner, spender: signed.spender, amt: signed.value, nonce: signed.nonce })}> + Submit permit as {acctOf(actingAs).label} + + signed && execute({ type: "permit", owner: signed.owner, spender: signed.spender, amt: signed.value, nonce: signed.nonce })}> + Replay same signature (stale nonce → revert) + + +
+ allowance({acctOf(pOwner).label} → {acctOf(pSpender).label}) = {allowances[key] || 0} + nonce({acctOf(pOwner).label}) = {ownerNonce} +
+
+
+ ); + }; + + // ----- tab dispatcher ----- + const renderTab = () => { + if (activeTab === "Create") return ; + if (!token) return
Create a token first.
; + if (activeTab === "Policies") return ; + if (activeTab === "Freeze & seize") return ; + if (activeTab === "Roles") return ; + if (activeTab === "Supply") return ; + if (activeTab === "Pause") return ; + if (activeTab === "Memos") return ; + if (activeTab === "Permit") return ; + return null; + }; + + // ====================================================================== + // RENDER + // ====================================================================== + return ( +
+ + + {/* Header bar */} +
+ + B20 Playground + simulated · client-side +
+ +
+ + {/* Token summary strip */} + + + {/* Acting-as selector */} +
+ acting as (msg.sender): + {ACCOUNTS.map(a => ( + setActingAs(a.key)}>{a.label} + ))} +
+ + {/* Tab bar */} +
+ {TABS.map(t => { + const disabled = t !== "Create" && !token; + const active = activeTab === t; + return ( + + ); + })} +
+ + {/* Tab body */} +
+ {renderTab()} +
+ + {/* Event log */} +
+
+ EVENT LOG + {eventLog.length > 0 && ( + + )} +
+
+ {eventLog.length === 0 &&
Emitted events and reverts appear here.
} + {eventLog.map(e => ( +
+ + {e.logIndex !== null && e.logIndex !== undefined ? e.logIndex : "·"} + + + {e.kind === "event" && } + {e.kind === "revert" && } + {e.kind === "info" && } + + + {e.name} + {e.args && · {e.args}} + +
+ ))} +
+
+
+ ); +}; From b09c76ee1d55c69ec4c245fe6b4d0fb5ef384207 Mon Sep 17 00:00:00 2001 From: Soheima M Date: Tue, 21 Jul 2026 21:52:21 +0200 Subject: [PATCH 2/6] added demo --- docs/AGENTS.md | 4 +- .../specs/upgrades/beryl/b20-playground.mdx | 86 ++ docs/docs.json | 3 +- docs/snippets/B20PlaygroundDemo.jsx | 1189 +++++++++++++++++ 4 files changed, 1279 insertions(+), 3 deletions(-) create mode 100644 docs/base-chain/specs/upgrades/beryl/b20-playground.mdx create mode 100644 docs/snippets/B20PlaygroundDemo.jsx diff --git a/docs/AGENTS.md b/docs/AGENTS.md index a696801d9..27558067e 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -80,7 +80,7 @@ npx skills add base/base-skills |base-chain/api-reference/ethereum-json-rpc-api:eth_blockNumber,eth_call,eth_chainId,eth_estimateGas,eth_feeHistory,eth_gasPrice,eth_getBalance,eth_getBlockByHash,eth_getBlockByNumber,eth_getBlockReceipts,eth_getBlockTransactionCountByHash,eth_getBlockTransactionCountByNumber,eth_getCode,eth_getLogs,eth_getStorageAt,eth_getTransactionByBlockHashAndIndex,eth_getTransactionByBlockNumberAndIndex,eth_getTransactionByHash,eth_getTransactionCount,eth_getTransactionReceipt,eth_maxPriorityFeePerGas,eth_sendRawTransaction,eth_subscribe,eth_syncing,eth_unsubscribe,net_version,web3_clientVersion |base-chain/api-reference/flashblocks-api:base_transactionStatus,eth_simulateV1,flashblocks-api-overview,newFlashblockTransactions,newFlashblocks,pendingLogs |base-chain/flashblocks:faq -|base-chain/network-information:base-contracts,base-solana-bridge,bridging-and-withdrawals,ecosystem-bridges,network-faucets,network-fees,throughput-and-limits,transaction-finality,transaction-ordering,troubleshooting-transactions +|base-chain/network-information:base-contracts,base-solana-bridge,bridging-and-withdrawals,configuration-changelog,ecosystem-bridges,network-faucets,network-fees,throughput-and-limits,transaction-finality,transaction-ordering,troubleshooting-transactions |base-chain/node-operators:node-providers,performance-tuning,run-a-base-node,snapshots,troubleshooting |base-chain/quickstart:connecting-to-base |base-chain/security:avoid-malicious-flags,bug-bounty,report-vulnerability,security-council @@ -93,7 +93,7 @@ npx skills add base/base-skills |base-chain/specs/protocol/proofs:challenger,contracts,index,proposer,registrar,tee-prover,zk-prover |base-chain/specs/reference:configurability,glossary |base-chain/specs/upgrades/azul:exec-engine,node-upgrade,overview,proofs -|base-chain/specs/upgrades/beryl:b20,overview +|base-chain/specs/upgrades/beryl:b20-playground,b20,overview |base-chain/specs/upgrades/canyon:overview |base-chain/specs/upgrades/delta:overview,span-batches |base-chain/specs/upgrades/ecotone:derivation,l1-attributes,overview diff --git a/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx b/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx new file mode 100644 index 000000000..b7806bcf1 --- /dev/null +++ b/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx @@ -0,0 +1,86 @@ +--- +title: "B20 Playground" +description: "Interactive playground for the B20 native token standard - simulate policies, roles, freeze-and-seize, supply caps, granular pause, memos, and permit in your browser." +--- + +import { B20PlaygroundDemo } from "/snippets/B20PlaygroundDemo.jsx" + +[B20](/base-chain/specs/upgrades/beryl/b20) is Base's native token standard: an ERC-20 superset with a built-in compliance toolkit. This page lets you create a simulated B20 token and exercise its compliance features tab by tab, from choosing a variant to freezing an account and seizing its balance. + + +The playground simulates B20 semantics entirely client-side. Nothing touches a chain or wallet, and no state persists. To deploy a real token, see [Launch a B20 Token](/get-started/launch-b20-token). + + +## Feature map at a glance + +| You want to… | B20 feature | Demo section | +|--------------|-------------|--------------| +| Choose a token type and predict its address | Variants + deterministic addresses | Create a token | +| Restrict who can send, receive, or mint | Policy scopes + PolicyRegistry | Policy gating | +| Freeze a bad actor and seize funds | `TRANSFER_SENDER_POLICY` + `burnBlocked` | Freeze and seize | +| Separate operational duties or go admin-less | Roles + `renounceLastAdmin` | Roles and metadata | +| Hard-limit issuance | Supply cap | Supply cap and mint | +| Halt transfers without halting everything | Granular pause | Granular pause | +| Attach references to transactions | Memos | Memos | +| Gasless approvals | ERC-2612 `permit` | Permit | + + + + + +## Create a token + +Pick a variant and the demo derives everything else. An `ASSET` token takes configurable decimals from 6 to 18; a `STABLECOIN` is fixed at 6 decimals and carries a self-declared `currency()` code. You can set an optional supply cap and an initial admin, including `address(0)` for an admin-less launch that never grants `DEFAULT_ADMIN_ROLE`. The simulated address is deterministic: `[10-byte prefix][1-byte variant][9-byte keccak256(deployer, salt)]`, so you can read the variant straight off byte 10 without an RPC call. See [Address Derivation](/base-chain/specs/upgrades/beryl/b20#address-derivation) for the exact scheme. + +## Policy gating + +Attach allowlist or blocklist policies to the four transfer scopes and watch gated operations revert with `PolicyForbids`. `TRANSFER_SENDER_POLICY` checks the `from`, `TRANSFER_RECEIVER_POLICY` checks the `to`, `MINT_RECEIVER_POLICY` checks the recipient of a mint, and `TRANSFER_EXECUTOR_POLICY` checks the `msg.sender` of `transferFrom` only when the caller is not the sender. Note that `approve` is never policy-gated; only actual balance movement is checked. See [Policy Integration](/base-chain/specs/upgrades/beryl/b20#policy-integration). + + +Every scope defaults to `ALWAYS_ALLOW` at token creation. An unattended B20 deployment is fully open: token behavior must be intentionally constrained. + + +## Freeze and seize + +Freeze-and-seize is two steps. First, deny the target under `TRANSFER_SENDER_POLICY` so it can no longer send. Then call `burnBlocked` to burn from that account: the call requires the caller to hold `BURN_BLOCKED_ROLE` and requires the target to be denied by `TRANSFER_SENDER_POLICY`. Both conditions must hold, which is why the demo blocks the account before it can seize the balance. See [Burn](/base-chain/specs/upgrades/beryl/b20#burn). + +## Roles and metadata + +Grant and revoke the seven base roles (`DEFAULT_ADMIN_ROLE`, `MINT_ROLE`, `BURN_ROLE`, `BURN_BLOCKED_ROLE`, `PAUSE_ROLE`, `UNPAUSE_ROLE`, `METADATA_ROLE`) and trigger the one gated action behind each. `METADATA_ROLE` gates `updateName`, `updateSymbol`, and `updateContractURI`. Try to remove the last admin with `renounceRole` and it reverts with `LastAdminCannotRenounce`; the only path to an admin-less token is `renounceLastAdmin()`, which is permanent. See [Roles Model](/base-chain/specs/upgrades/beryl/b20#roles-model). + +## Supply cap and mint + +Mint with `MINT_ROLE` and push past the cap to see `SupplyCapExceeded`. Adjust the cap with `updateSupplyCap`, which reverts with `InvalidSupplyCap` when the new cap is below current `totalSupply` or above the `type(uint128).max` sentinel that marks no cap. When the simulated token is an Asset, extra controls appear: `batchMint`, the rebase `multiplier`, and `announce`. See [Supply Cap](/base-chain/specs/upgrades/beryl/b20#supply-cap). + +## Granular pause + +Pause `TRANSFER`, `MINT`, and `BURN` independently instead of freezing the whole token. Pausing transfers still lets minting continue, and vice versa. Pausing and unpausing are gated by distinct roles by design: `PAUSE_ROLE` and `UNPAUSE_ROLE`. See [Pause](/base-chain/specs/upgrades/beryl/b20#pause). + +## Memos + +Send a transaction with `transferWithMemo` (and the sibling `transferFromWithMemo`, `mintWithMemo`, `burnWithMemo`) to attach a `bytes32` reference. Each emits `Memo(caller, memo)` immediately after the operation's primary event, so an indexer joins the memo to its transaction via `(transactionHash, logIndex − 1)`. See [Memos](/base-chain/specs/upgrades/beryl/b20#memos). + +## Permit + +Sign an ERC-2612 approval offchain and submit it to grant an allowance without a prior `approve` transaction. The demo increments the account nonce on each use so a signature can't be replayed. Permit is ECDSA-only; ERC-1271 contract signatures are not accepted. See [ERC-2612 Permit / EIP-712](/base-chain/specs/upgrades/beryl/b20#erc-2612-permit--eip-712). + + +The playground intentionally leaves out a few advanced behaviors: the `initCalls` bootstrap bypass, two-step policy admin transfer, and custom user-defined roles. For those, see [initCalls Semantics](/base-chain/specs/upgrades/beryl/b20#initcalls-semantics), the [Policy Registry](/base-chain/specs/upgrades/beryl/b20#policy-registry) admin model, and [Roles Model](/base-chain/specs/upgrades/beryl/b20#roles-model) in the spec. + + +## Related docs + + + + The full spec: variants, policies, roles, and the complete function surface. + + + Deploy a real B20 token onchain. + + + Match transactions to orders with onchain memos. + + + The upgrade that introduces B20 and native token features. + + diff --git a/docs/docs.json b/docs/docs.json index 10d4e9b06..5607db4f9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -263,7 +263,8 @@ "group": "Beryl", "pages": [ "base-chain/specs/upgrades/beryl/overview", - "base-chain/specs/upgrades/beryl/b20" + "base-chain/specs/upgrades/beryl/b20", + "base-chain/specs/upgrades/beryl/b20-playground" ] }, { diff --git a/docs/snippets/B20PlaygroundDemo.jsx b/docs/snippets/B20PlaygroundDemo.jsx new file mode 100644 index 000000000..3656089c4 --- /dev/null +++ b/docs/snippets/B20PlaygroundDemo.jsx @@ -0,0 +1,1189 @@ + +export const B20PlaygroundDemo = () => { + const sans = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif"; + const serif = "'Tiempos Headline','Iowan Old Style','Source Serif Pro',ui-serif,Georgia,serif"; + const mono = "ui-monospace,'SF Mono','Cascadia Code',Menlo,Monaco,Consolas,monospace"; + + const c = { + bg: "#1f1e1d", header: "#262624", border: "#34322f", inputBg: "#2a2926", + text: "#f5f4ed", body: "#e8e4dc", muted: "#a8a39d", dim: "#6b6663", + accent: "#D97757", bubble: "#2c2b28", bubbleText: "#f5f4ed", + code: "#e89972", codeBg: "rgba(217,119,87,0.12)", + toolBg: "#272622", toolBorder: "#3a3835", success: "#a3c585", + error: "#e57373", errorBg: "rgba(229,115,115,0.10)", + }; + + // ----- Preset accounts ----- + const ACCOUNTS = [ + { key: "Issuer", label: "Issuer", addr: "0x155e…d001", dot: "#D97757" }, + { key: "Processor", label: "Processor", addr: "0x9r0c…d002", dot: "#7c9fe8" }, + { key: "Alice", label: "Alice", addr: "0xA11c…e001", dot: "#a3c585" }, + { key: "Bob", label: "Bob", addr: "0xB0b0…e002", dot: "#e5c07b" }, + { key: "Carol", label: "Carol", addr: "0xCar0…e003", dot: "#c586d9" }, + ]; + const acctOf = (k) => ACCOUNTS.find(a => a.key === k) || { key: k, label: k, addr: "0x0000…0000", dot: c.dim }; + + const ROLE_NAMES_BASE = [ + "DEFAULT_ADMIN_ROLE", "MINT_ROLE", "BURN_ROLE", "BURN_BLOCKED_ROLE", + "PAUSE_ROLE", "UNPAUSE_ROLE", "METADATA_ROLE", + ]; + const SCOPES = ["TRANSFER_SENDER", "TRANSFER_RECEIVER", "TRANSFER_EXECUTOR", "MINT_RECEIVER"]; + + // Built-in policy IDs. ALWAYS_ALLOW = 0. ALWAYS_BLOCK = (ALLOWLIST(=1) << 56) | 1. + const ALWAYS_ALLOW = 0; + const ALWAYS_BLOCK = "ALWAYS_BLOCK"; // sentinel token used internally for the built-in block id + // Rendered composed form of ALWAYS_BLOCK, i.e. 0x0100…01. + const ALWAYS_BLOCK_RENDER = "0x0100…01"; + + const UINT128_MAX = "type(uint128).max (no cap)"; + + // ----- djb2-style deterministic string hash (simulated keccak) ----- + const hashHex = (deployer, salt) => { + let h = 5381; + const s = deployer + "|" + salt; + for (let i = 0; i < s.length; i++) { + h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; + } + // spread into 9 bytes deterministically + let out = ""; + let x = h; + for (let i = 0; i < 9; i++) { + x = (x * 1103515245 + 12345 + i * 7) >>> 0; + const b = (x >>> ((i % 4) * 8)) & 0xff; + out += b.toString(16).padStart(2, "0"); + } + return out; // 18 hex chars = 9 bytes + }; + + const deriveAddress = (variant, deployer, salt) => { + const prefix = "b2000000000000000000"; // 10-byte B20 prefix + const vByte = variant === "STABLECOIN" ? "01" : "00"; + const tail = hashHex(deployer, salt); // 9 bytes + return { prefix, vByte, tail }; + }; + + // ----- state ----- + const [token, setToken] = useState(null); + const [balances, setBalances] = useState({}); + const [roles, setRoles] = useState({}); + const [policies, setPolicies] = useState({}); + const [nextPolicyId, setNextPolicyId] = useState(2); + const [scopes, setScopes] = useState({ TRANSFER_SENDER: 0, TRANSFER_RECEIVER: 0, TRANSFER_EXECUTOR: 0, MINT_RECEIVER: 0 }); + const [paused, setPaused] = useState({ TRANSFER: false, MINT: false, BURN: false }); + const [allowances, setAllowances] = useState({}); + const [nonces, setNonces] = useState({}); + const [eventLog, setEventLog] = useState([]); + const [activeTab, setActiveTab] = useState("Create"); + const [actingAs, setActingAs] = useState("Issuer"); + + const idRef = useRef(0); + const logRef = useRef(null); + + useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [eventLog]); + + // ----- Create-tab form state ----- + const [cVariant, setCVariant] = useState("STABLECOIN"); + const [cName, setCName] = useState("Acme Dollar"); + const [cSymbol, setCSymbol] = useState("aUSD"); + const [cDecimals, setCDecimals] = useState(6); + const [cCurrency, setCCurrency] = useState("USD"); + const [cSalt, setCSalt] = useState("0x01"); + const [cInitialAdminZero, setCInitialAdminZero] = useState(false); + + const totalSupply = () => Object.values(balances).reduce((a, b) => a + b, 0); + + // ----- log helpers ----- + const pushLog = (entries) => { + setEventLog(prev => { + const tag = "0x" + (prev.length % 16).toString(16) + "f4…" + ((prev.length * 7) % 256).toString(16).padStart(2, "0"); + const withIds = entries.map((e, i) => ({ + id: ++idRef.current, + logIndex: e.logIndex, + txTag: tag, + kind: e.kind, + name: e.name, + args: e.args || "", + })); + return [...prev, ...withIds]; + }); + }; + const emit = (name, args, logIndex) => ({ kind: "event", name, args, logIndex }); + const revert = (name, args) => ({ kind: "revert", name, args: args || "", logIndex: null }); + const info = (name, args) => ({ kind: "info", name, args: args || "", logIndex: null }); + + // ----- authorization helper (never throws) ----- + const isAuthorized = (policyId, account) => { + if (policyId === 0 || policyId === undefined || policyId === null) return true; + if (policyId === ALWAYS_BLOCK) return false; + const p = policies[policyId]; + if (!p) { + // non-existent id collapses to empty-set semantics. + // We cannot know the intended type; spec: non-existent BLOCKLIST authorizes, + // non-existent ALLOWLIST denies. Encoded type is in the top byte of the id. + // Our simulated ids carry their type on the policy object only; for a missing + // object we decode by convention: ids created as ALLOWLIST were tracked, so a + // truly-missing id is treated as an empty ALLOWLIST (denies everyone). + return false; + } + if (p.type === "BLOCKLIST") return !p.members.includes(account); + if (p.type === "ALLOWLIST") return p.members.includes(account); + return true; + }; + + const roleHas = (roleName, account) => (roles[roleName] || []).includes(account); + const adminLess = () => token && (token.adminRenounced || token.initialAdminZero); + const isAdmin = (account) => !adminLess() && roleHas("DEFAULT_ADMIN_ROLE", account); + + // ----- create token ----- + const createToken = () => { + const deployer = acctOf(actingAs).addr; + const dec = cVariant === "STABLECOIN" ? 6 : cDecimals; + const t = { + variant: cVariant, + name: cName, + symbol: cSymbol, + decimals: dec, + currency: cVariant === "STABLECOIN" ? cCurrency : null, + supplyCap: null, + salt: cSalt, + deployer, + contractURI: "", + multiplier: 1.0, + extraMetadata: {}, + adminRenounced: false, + initialAdminZero: cInitialAdminZero, + }; + const roleMap = {}; + ROLE_NAMES_BASE.forEach(r => { roleMap[r] = []; }); + if (cVariant === "ASSET") roleMap["OPERATOR_ROLE"] = []; + // initialAdmin gets every gate role for a friendly playground, unless admin-less. + if (!cInitialAdminZero) { + const admin = actingAs; + Object.keys(roleMap).forEach(r => { roleMap[r] = [admin]; }); + } + setToken(t); + setRoles(roleMap); + setBalances({}); + setPolicies({}); + setNextPolicyId(2); + setScopes({ TRANSFER_SENDER: 0, TRANSFER_RECEIVER: 0, TRANSFER_EXECUTOR: 0, MINT_RECEIVER: 0 }); + setPaused({ TRANSFER: false, MINT: false, BURN: false }); + setAllowances({}); + setNonces({}); + setEventLog([]); + idRef.current = 0; + setActiveTab("Supply"); + setTimeout(() => pushLog([ + info("B20Created", `${cVariant.toLowerCase()} · ${cSymbol} · deployer ${acctOf(actingAs).label}`), + cInitialAdminZero + ? info("admin", "initialAdmin == address(0) — launched admin-less") + : emit("RoleGranted", `DEFAULT_ADMIN_ROLE → ${acctOf(actingAs).label}`, 0), + ]), 0); + }; + + const resetAll = () => { + setToken(null); + setBalances({}); setRoles({}); setPolicies({}); setNextPolicyId(2); + setScopes({ TRANSFER_SENDER: 0, TRANSFER_RECEIVER: 0, TRANSFER_EXECUTOR: 0, MINT_RECEIVER: 0 }); + setPaused({ TRANSFER: false, MINT: false, BURN: false }); + setAllowances({}); setNonces({}); setEventLog([]); idRef.current = 0; + setActiveTab("Create"); setActingAs("Issuer"); + }; + + // ----- capacity check for supply cap ----- + const capExceeded = (addAmount) => { + if (!token || token.supplyCap === null) return false; + return totalSupply() + addAmount > token.supplyCap; + }; + + // --------------------------------------------------------------- + // Single write path. Validates in spec order, appends events/reverts. + // --------------------------------------------------------------- + const execute = (op) => { + const me = actingAs; + const A = (k) => acctOf(k).label; + + switch (op.type) { + // -------- transfer(from, to, amt) -------- + case "transfer": { + const { from, to, amt, memo } = op; + if (paused.TRANSFER) return pushLog([revert("EnforcedPause", "feature: TRANSFER")]); + if (!isAuthorized(scopes.TRANSFER_SENDER, from)) return pushLog([revert("PolicyForbids", `TRANSFER_SENDER · ${A(from)}`)]); + if (!isAuthorized(scopes.TRANSFER_RECEIVER, to)) return pushLog([revert("PolicyForbids", `TRANSFER_RECEIVER · ${A(to)}`)]); + if ((balances[from] || 0) < amt) return pushLog([revert("ERC20InsufficientBalance", `${A(from)} has ${balances[from] || 0}`)]); + setBalances(b => ({ ...b, [from]: (b[from] || 0) - amt, [to]: (b[to] || 0) + amt })); + if (memo !== undefined) { + return pushLog([emit("Transfer", `${A(from)} → ${A(to)} · ${amt}`, 0), emit("Memo", `${A(me)} · ${memo}`, 1)]); + } + return pushLog([emit("Transfer", `${A(from)} → ${A(to)} · ${amt}`, 0)]); + } + + // -------- transferFrom(exec, from, to, amt) -------- + case "transferFrom": { + const { from, to, amt, memo } = op; + const exec = me; + const key = from + "→" + exec; + if (paused.TRANSFER) return pushLog([revert("EnforcedPause", "feature: TRANSFER")]); + if ((allowances[key] || 0) < amt) return pushLog([revert("ERC20InsufficientAllowance", `${A(exec)} allowance ${allowances[key] || 0} < ${amt}`)]); + if (exec !== from && !isAuthorized(scopes.TRANSFER_EXECUTOR, exec)) return pushLog([revert("PolicyForbids", `TRANSFER_EXECUTOR · ${A(exec)}`)]); + if (!isAuthorized(scopes.TRANSFER_SENDER, from)) return pushLog([revert("PolicyForbids", `TRANSFER_SENDER · ${A(from)}`)]); + if (!isAuthorized(scopes.TRANSFER_RECEIVER, to)) return pushLog([revert("PolicyForbids", `TRANSFER_RECEIVER · ${A(to)}`)]); + if ((balances[from] || 0) < amt) return pushLog([revert("ERC20InsufficientBalance", `${A(from)} has ${balances[from] || 0}`)]); + setBalances(b => ({ ...b, [from]: (b[from] || 0) - amt, [to]: (b[to] || 0) + amt })); + setAllowances(al => ({ ...al, [key]: (al[key] || 0) - amt })); + if (memo !== undefined) { + return pushLog([emit("Transfer", `${A(from)} → ${A(to)} · ${amt}`, 0), emit("Memo", `${A(me)} · ${memo}`, 1)]); + } + return pushLog([emit("Transfer", `${A(from)} → ${A(to)} · ${amt} (by ${A(exec)})`, 0)]); + } + + // -------- approve(owner, spender, amt) : never gated -------- + case "approve": { + const { owner, spender, amt } = op; + const key = owner + "→" + spender; + setAllowances(al => ({ ...al, [key]: amt })); + return pushLog([emit("Approval", `${A(owner)} → ${A(spender)} · ${amt}`, 0)]); + } + + // -------- mint(caller, to, amt) -------- + case "mint": { + const { to, amt, memo } = op; + if (paused.MINT) return pushLog([revert("EnforcedPause", "feature: MINT")]); + if (!roleHas("MINT_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks MINT_ROLE`)]); + if (!isAuthorized(scopes.MINT_RECEIVER, to)) return pushLog([revert("PolicyForbids", `MINT_RECEIVER · ${A(to)}`)]); + if (capExceeded(amt)) return pushLog([revert("SupplyCapExceeded", `${totalSupply() + amt} > cap ${token.supplyCap}`)]); + setBalances(b => ({ ...b, [to]: (b[to] || 0) + amt })); + if (memo !== undefined) { + return pushLog([emit("Transfer", `0x0 → ${A(to)} · ${amt}`, 0), emit("Memo", `${A(me)} · ${memo}`, 1)]); + } + return pushLog([emit("Transfer", `0x0 → ${A(to)} · ${amt}`, 0)]); + } + + // -------- burn(caller, amt) -------- + case "burn": { + const { amt, memo } = op; + if (paused.BURN) return pushLog([revert("EnforcedPause", "feature: BURN")]); + if (!roleHas("BURN_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks BURN_ROLE`)]); + if ((balances[me] || 0) < amt) return pushLog([revert("ERC20InsufficientBalance", `${A(me)} has ${balances[me] || 0}`)]); + setBalances(b => ({ ...b, [me]: (b[me] || 0) - amt })); + if (memo !== undefined) { + return pushLog([emit("Transfer", `${A(me)} → 0x0 · ${amt}`, 0), emit("Memo", `${A(me)} · ${memo}`, 1)]); + } + return pushLog([emit("Transfer", `${A(me)} → 0x0 · ${amt}`, 0)]); + } + + // -------- burnBlocked(caller, target, amt) -------- + case "burnBlocked": { + const { target, amt } = op; + if (paused.BURN) return pushLog([revert("EnforcedPause", "feature: BURN")]); + if (!roleHas("BURN_BLOCKED_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks BURN_BLOCKED_ROLE`)]); + if (isAuthorized(scopes.TRANSFER_SENDER, target)) return pushLog([revert("AccountNotBlocked", `${A(target)} not denied by TRANSFER_SENDER`)]); + if ((balances[target] || 0) < amt) return pushLog([revert("ERC20InsufficientBalance", `${A(target)} has ${balances[target] || 0}`)]); + setBalances(b => ({ ...b, [target]: (b[target] || 0) - amt })); + return pushLog([emit("Transfer", `${A(target)} → 0x0 · ${amt} (seized)`, 0)]); + } + + // -------- grantRole -------- + case "grantRole": { + const { role, account } = op; + if (!isAdmin(me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); + if (roleHas(role, account)) return pushLog([info("grantRole", `${A(account)} already holds ${role}`)]); + setRoles(r => ({ ...r, [role]: [...(r[role] || []), account] })); + return pushLog([emit("RoleGranted", `${role} → ${A(account)}`, 0)]); + } + + // -------- revokeRole -------- + case "revokeRole": { + const { role, account } = op; + if (!isAdmin(me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); + if (role === "DEFAULT_ADMIN_ROLE" && (roles["DEFAULT_ADMIN_ROLE"] || []).length <= 1 && roleHas(role, account)) { + return pushLog([revert("LastAdminCannotRenounce", "cannot remove the last admin")]); + } + setRoles(r => ({ ...r, [role]: (r[role] || []).filter(a => a !== account) })); + return pushLog([emit("RoleRevoked", `${role} ✕ ${A(account)}`, 0)]); + } + + // -------- renounceRole (self) -------- + case "renounceRole": { + const { role } = op; + // self-renounce does not require admin + if (!roleHas(role, me)) return pushLog([info("renounceRole", `${A(me)} does not hold ${role}`)]); + if (role === "DEFAULT_ADMIN_ROLE" && (roles["DEFAULT_ADMIN_ROLE"] || []).length <= 1) { + return pushLog([revert("LastAdminCannotRenounce", "use renounceLastAdmin() instead")]); + } + setRoles(r => ({ ...r, [role]: (r[role] || []).filter(a => a !== me) })); + return pushLog([emit("RoleRevoked", `${role} ✕ ${A(me)} (renounced)`, 0)]); + } + + // -------- renounceLastAdmin -------- + case "renounceLastAdmin": { + if (!roleHas("DEFAULT_ADMIN_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); + setRoles(r => ({ ...r, DEFAULT_ADMIN_ROLE: (r["DEFAULT_ADMIN_ROLE"] || []).filter(a => a !== me) })); + setToken(t => ({ ...t, adminRenounced: true })); + return pushLog([emit("RoleRevoked", `DEFAULT_ADMIN_ROLE ✕ ${A(me)}`, 0), info("admin", "token is now permanently admin-less")]); + } + + // -------- updateSupplyCap -------- + case "updateSupplyCap": { + const { newCap } = op; // number or null + if (!isAdmin(me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); + if (newCap !== null && newCap < totalSupply()) return pushLog([revert("InvalidSupplyCap", `${newCap} < totalSupply ${totalSupply()}`)]); + setToken(t => ({ ...t, supplyCap: newCap })); + return pushLog([emit("SupplyCapUpdated", newCap === null ? UINT128_MAX : String(newCap), 0)]); + } + + // -------- updatePolicy(scope, policyId) -------- + case "updatePolicy": { + const { scope, policyId } = op; + if (!isAdmin(me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); + setScopes(s => ({ ...s, [scope]: policyId })); + const label = policyId === 0 ? "ALWAYS_ALLOW (0)" : policyId === ALWAYS_BLOCK ? `ALWAYS_BLOCK (${ALWAYS_BLOCK_RENDER})` : `#${policyId}`; + return pushLog([emit("PolicyUpdated", `${scope} → ${label}`, 0)]); + } + + // -------- createPolicy(type) -------- + case "createPolicy": { + const { policyType } = op; + const id = nextPolicyId; + setPolicies(p => ({ ...p, [id]: { type: policyType, members: [] } })); + setNextPolicyId(n => n + 1); + return pushLog([emit("PolicyCreated", `#${id} · ${policyType} · admin ${A(me)}`, 0)]); + } + + // -------- updateBlocklist / updateAllowlist (membership toggle) -------- + case "togglePolicyMember": { + const { policyId, account } = op; + const p = policies[policyId]; + if (!p) return pushLog([revert("PolicyDoesNotExist", `#${policyId}`)]); + const has = p.members.includes(account); + setPolicies(prev => ({ + ...prev, + [policyId]: { ...prev[policyId], members: has ? prev[policyId].members.filter(a => a !== account) : [...prev[policyId].members, account] }, + })); + const setter = p.type === "BLOCKLIST" ? "updateBlocklist" : "updateAllowlist"; + return pushLog([emit(setter, `#${policyId} · ${has ? "remove" : "add"} ${A(account)}`, 0)]); + } + + // -------- pause / unpause -------- + case "pause": { + const { feature } = op; + if (!roleHas("PAUSE_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks PAUSE_ROLE`)]); + setPaused(p => ({ ...p, [feature]: true })); + return pushLog([emit("Paused", `feature: ${feature}`, 0)]); + } + case "unpause": { + const { feature } = op; + if (!roleHas("UNPAUSE_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks UNPAUSE_ROLE`)]); + setPaused(p => ({ ...p, [feature]: false })); + return pushLog([emit("Unpaused", `feature: ${feature}`, 0)]); + } + + // -------- metadata -------- + case "updateName": { + if (!roleHas("METADATA_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks METADATA_ROLE`)]); + setToken(t => ({ ...t, name: op.value })); + return pushLog([ + emit("NameUpdated", op.value, 0), + info("EIP712DomainChanged", "domain separator rotated"), + ]); + } + case "updateSymbol": { + if (!roleHas("METADATA_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks METADATA_ROLE`)]); + setToken(t => ({ ...t, symbol: op.value })); + return pushLog([emit("SymbolUpdated", op.value, 0)]); + } + case "updateContractURI": { + if (!roleHas("METADATA_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks METADATA_ROLE`)]); + setToken(t => ({ ...t, contractURI: op.value })); + return pushLog([emit("ContractURIUpdated", op.value, 0)]); + } + + // -------- permit -------- + case "permit": { + const { owner, spender, amt, nonce } = op; + const current = nonces[owner] || 0; + if (nonce !== current) return pushLog([revert("ERC2612InvalidSigner", `stale nonce ${nonce} ≠ ${current} — replay rejected`)]); + const key = owner + "→" + spender; + setAllowances(al => ({ ...al, [key]: amt })); + setNonces(n => ({ ...n, [owner]: current + 1 })); + return pushLog([emit("Approval", `${A(owner)} → ${A(spender)} · ${amt} (permit, relayed by ${A(me)})`, 0)]); + } + + // -------- ASSET: updateMultiplier -------- + case "updateMultiplier": { + if (!roleHas("OPERATOR_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks OPERATOR_ROLE`)]); + setToken(t => ({ ...t, multiplier: op.value })); + return pushLog([emit("MultiplierUpdated", `${op.value}× (WAD)`, 0)]); + } + + // -------- ASSET: batchMint -------- + case "batchMint": { + const { recipients, amt } = op; + if (!roleHas("MINT_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks MINT_ROLE`)]); + const total = recipients.length * amt; + if (capExceeded(total)) return pushLog([revert("SupplyCapExceeded", `${totalSupply() + total} > cap ${token.supplyCap}`)]); + setBalances(b => { + const nb = { ...b }; + recipients.forEach(r => { nb[r] = (nb[r] || 0) + amt; }); + return nb; + }); + return pushLog(recipients.map((r, i) => emit("Transfer", `0x0 → ${A(r)} · ${amt}`, i))); + } + + // -------- ASSET: announce(batchMint) -------- + case "announceBatchMint": { + const { recipients, amt, id } = op; + if (!roleHas("OPERATOR_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks OPERATOR_ROLE`)]); + if (!roleHas("MINT_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `inner batchMint needs MINT_ROLE`)]); + const total = recipients.length * amt; + if (capExceeded(total)) return pushLog([revert("SupplyCapExceeded", `${totalSupply() + total} > cap ${token.supplyCap}`)]); + setBalances(b => { + const nb = { ...b }; + recipients.forEach(r => { nb[r] = (nb[r] || 0) + amt; }); + return nb; + }); + const inner = recipients.map((r, i) => emit("Transfer", `0x0 → ${A(r)} · ${amt}`, i + 1)); + return pushLog([ + emit("Announcement", `id ${id} · scheduled batchMint`, 0), + ...inner, + emit("EndAnnouncement", `id ${id}`, inner.length + 1), + ]); + } + + // -------- ASSET: updateExtraMetadata -------- + case "updateExtraMetadata": { + const { mkey, value } = op; + if (!roleHas("METADATA_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks METADATA_ROLE`)]); + setToken(t => { + const em = { ...t.extraMetadata }; + if (value === "") delete em[mkey]; else em[mkey] = value; + return { ...t, extraMetadata: em }; + }); + return pushLog([emit("ExtraMetadataUpdated", value === "" ? `${mkey} (removed)` : `${mkey} = ${value}`, 0)]); + } + + default: + return; + } + }; + + // ====================================================================== + // UI PRIMITIVES + // ====================================================================== + const TrafficLights = () => ( +
+ + + +
+ ); + + const Dot = ({ color, size }) => ( + + ); + + const Btn = ({ onClick, children, tone, disabled, title }) => { + const [hover, setHover] = useState(false); + const base = tone === "accent" ? c.accent : c.toolBorder; + return ( + + ); + }; + + const Chip = ({ active, onClick, children, dot, disabled }) => { + const [hover, setHover] = useState(false); + return ( + + ); + }; + + const Field = ({ label, children }) => ( +
+ {label} + {children} +
+ ); + + const TextInput = ({ value, onChange, width, placeholder, mono: isMono }) => ( + onChange(e.target.value)} + placeholder={placeholder} + style={{ + fontFamily: isMono ? mono : sans, fontSize: 12.5, color: c.text, + background: c.inputBg, border: `1px solid ${c.toolBorder}`, borderRadius: 8, + padding: "7px 10px", width: width || 140, outline: "none", + }} + /> + ); + + const Box = ({ title, children, muted, hint }) => ( +
+ {title &&
{title}
} + {hint &&
{hint}
} + {children} +
+ ); + + const Row = ({ children, wrap }) => ( +
{children}
+ ); + + const Pill = ({ on, children }) => ( + {children} + ); + + // ----- account selector row (small) ----- + const AccountPicker = ({ value, onChange, exclude }) => ( + + {ACCOUNTS.filter(a => !exclude || !exclude.includes(a.key)).map(a => ( + onChange(a.key)}>{a.label} + ))} + + ); + + // ====================================================================== + // TOKEN SUMMARY STRIP + // ====================================================================== + const derived = token ? deriveAddress(token.variant, token.deployer, token.salt) : null; + + const AddressRender = ({ d }) => ( + + 0x{d.prefix.slice(0, 8)}… + {d.vByte} + {d.tail.slice(0, 4)}…{d.tail.slice(-4)} + + ); + + const SummaryStrip = () => { + if (!token) return null; + return ( +
+
+ {token.name} + {token.symbol} + {token.variant} + + {token.currency && currency() = {token.currency}} +
+
+ + totalSupply {totalSupply()} / cap {token.supplyCap === null ? "∞" : token.supplyCap} + + {token.variant === "ASSET" && multiplier() {token.multiplier}×} + + {["TRANSFER", "MINT", "BURN"].map(f => {f}{paused[f] ? " ⏸" : ""})} + + + {adminLess() + ? admin-less + : <>admin: {(roles["DEFAULT_ADMIN_ROLE"] || []).map(a => acctOf(a).label).join(", ") || "—"}} + +
+
+ ); + }; + + // ====================================================================== + // TABS + // ====================================================================== + const TABS = ["Create", "Policies", "Freeze & seize", "Roles", "Supply", "Pause", "Memos", "Permit"]; + + // ----- balances mini-table used in several tabs ----- + const BalanceTable = ({ scaled }) => ( +
+ {ACCOUNTS.map(a => ( + + {a.label} + {balances[a.key] || 0} + {scaled && token && token.variant === "ASSET" && ( + ({((balances[a.key] || 0) * token.multiplier).toFixed(2)}) + )} + + ))} +
+ ); + + // ---------- CREATE TAB ---------- + const CreateTab = () => { + if (token) { + return ( +
+ +
+
Variant: {token.variant} · decimals {token.decimals}{token.currency ? <> · currency {token.currency} : null}
+
Address:
+
prefix | variant | keccak256(deployer, salt) — hash simulated
+
+
+ Reset playground +
+ ); + } + const dprefix = deriveAddress(cVariant, acctOf(actingAs).addr, cSalt); + return ( +
+ + + setCVariant("STABLECOIN")}>Stablecoin (6 decimals) + setCVariant("ASSET")}>Asset (6–18 decimals) + + + +
+ + + {cVariant === "ASSET" ? ( + + + {[6, 8, 12, 18].map(d => setCDecimals(d)}>{d})} + + + ) : ( + setCCurrency(v.replace(/[^A-Za-z]/g, "").toUpperCase().slice(0, 4))} width={70} /> + )} +
+
+ setCInitialAdminZero(!cInitialAdminZero)}> + initialAdmin == address(0) — launch admin-less + +
+
+ + + +
+
+
+
+ createB20({cVariant.toLowerCase()}, …) + deployer = {acctOf(actingAs).label} (acting-as) +
+
+ ); + }; + + // ---------- POLICIES TAB ---------- + const PoliciesTab = () => ( +
+ + + execute({ type: "createPolicy", policyType: "ALLOWLIST" })}>createPolicy(ALLOWLIST) + execute({ type: "createPolicy", policyType: "BLOCKLIST" })}>createPolicy(BLOCKLIST) + +
+ {Object.keys(policies).length === 0 && No custom policies yet.} + {Object.entries(policies).map(([id, p]) => ( +
+
+ #{id} · {p.type} + · default {p.type === "BLOCKLIST" ? "authorized" : "denied"} +
+ + {ACCOUNTS.map(a => ( + execute({ type: "togglePolicyMember", policyId: Number(id), account: a.key })}> + {a.label} + + ))} + +
+ ))} +
+
+ + + {SCOPES.map(s => ( +
+ {s} + execute({ type: "updatePolicy", scope: s, policyId: 0 })}>ALWAYS_ALLOW + execute({ type: "updatePolicy", scope: s, policyId: ALWAYS_BLOCK })}>ALWAYS_BLOCK + {Object.keys(policies).map(id => ( + execute({ type: "updatePolicy", scope: s, policyId: Number(id) })}>#{id} + ))} +
+ ))} +
+ + + + execute({ type: "transfer", from: "Alice", to: "Bob", amt: 10 })}>transfer Alice→Bob (10) + { execute({ type: "approve", owner: "Alice", spender: "Processor", amt: 10 }); execute({ type: "transferFrom", from: "Alice", to: "Bob", amt: 10 }); }}>transferFrom Processor (Alice→Bob) + execute({ type: "mint", to: "Carol", amt: 10 })}>mint → Carol (10) + +
+
+
+ ); + + // ---------- FREEZE & SEIZE TAB ---------- + const FreezeTab = () => { + const blocklistId = Object.entries(policies).find(([, p]) => p.type === "BLOCKLIST" && p.members.includes("Bob")); + const bound = blocklistId && scopes.TRANSFER_SENDER === Number(blocklistId[0]); + return ( +
+ +
+
+
Step 1 — freeze: create a blocklist with Bob and bind it to TRANSFER_SENDER
+ { + const id = nextPolicyId; + execute({ type: "createPolicy", policyType: "BLOCKLIST" }); + execute({ type: "togglePolicyMember", policyId: id, account: "Bob" }); + execute({ type: "updatePolicy", scope: "TRANSFER_SENDER", policyId: id }); + }}>Freeze Bob (create + block + bind) +
+
+
Step 2 — Bob attempts a transfer → PolicyForbids
+ execute({ type: "transfer", from: "Bob", to: "Alice", amt: 5 })}>transfer Bob→Alice (5) +
+
+
Step 3 — seize: grant BURN_BLOCKED_ROLE, then burnBlocked(Bob)
+ + execute({ type: "grantRole", role: "BURN_BLOCKED_ROLE", account: actingAs })}>grantRole BURN_BLOCKED_ROLE → {acctOf(actingAs).label} + execute({ type: "burnBlocked", target: "Bob", amt: 5 })}>burnBlocked(Bob, 5) + +
+
+
+ + + execute({ type: "burnBlocked", target: "Alice", amt: 5 })}>burnBlocked(Alice) — not blocked + { execute({ type: "revokeRole", role: "BURN_BLOCKED_ROLE", account: actingAs }); execute({ type: "burnBlocked", target: "Bob", amt: 5 }); }}>burnBlocked without role + + +
+ {bound ? "Bob is currently frozen (denied by TRANSFER_SENDER)." : "Bob is not yet frozen."} +
+ +
+ ); + }; + + // ---------- ROLES TAB ---------- + const RolesTab = () => { + const allRoles = Object.keys(roles); + const gatedTry = { + MINT_ROLE: { label: "mint → Carol (5)", op: { type: "mint", to: "Carol", amt: 5 } }, + BURN_ROLE: { label: "burn (5)", op: { type: "burn", amt: 5 } }, + PAUSE_ROLE: { label: "pause TRANSFER", op: { type: "pause", feature: "TRANSFER" } }, + UNPAUSE_ROLE: { label: "unpause TRANSFER", op: { type: "unpause", feature: "TRANSFER" } }, + }; + const [mkey, setMkey] = useState("prospectus"); + const [mval, setMval] = useState("ipfs://Qm…"); + const [emKey, setEmKey] = useState("cusip"); + const [emVal, setEmVal] = useState("38259P508"); + return ( +
+ +
+ + + + + {ACCOUNTS.map(a => )} + + + + {allRoles.map(r => ( + + + {ACCOUNTS.map(a => { + const has = roleHas(r, a.key); + return ( + + ); + })} + + ))} + +
role{a.label}
{r} + execute(has + ? { type: "revokeRole", role: r, account: a.key } + : { type: "grantRole", role: r, account: a.key })}> + {has ? "✓" : "+"} + +
+
+
+ + + + {Object.entries(gatedTry).map(([role, g]) => execute(g.op)}>{g.label})} + + + + + + execute({ type: "updateName", value: token.name + " v2" })}>updateName + execute({ type: "updateSymbol", value: token.symbol + "x" })}>updateSymbol + execute({ type: "updateContractURI", value: "ipfs://Qm…/contract.json" })}>updateContractURI + + + + + + execute({ type: "renounceRole", role: "DEFAULT_ADMIN_ROLE" })}>renounceRole(DEFAULT_ADMIN_ROLE) + execute({ type: "renounceLastAdmin" })}>renounceLastAdmin() + execute({ type: "grantRole", role: "MINT_ROLE", account: "Carol" })}>grantRole MINT_ROLE → Carol + execute({ type: "mint", to: "Alice", amt: 5 })}>existing MINT_ROLE still mints + + + + {token && token.variant === "ASSET" && ( + + + + +
execute({ type: "updateExtraMetadata", mkey: emKey, value: emVal })}>updateExtraMetadata
+
+
+ {Object.keys(token.extraMetadata).length === 0 ? "extraMetadata: {}" : Object.entries(token.extraMetadata).map(([k, v]) => {k}={v})} +
+
+ )} +
+ ); + }; + + // ---------- SUPPLY TAB ---------- + const SupplyTab = () => { + const [mintTo, setMintTo] = useState("Alice"); + const [mintAmt, setMintAmt] = useState(100); + const [capVal, setCapVal] = useState(1000); + return ( +
+ + + + + + {[10, 50, 100, 500].map(v => setMintAmt(v)}>{v})} + execute({ type: "mint", to: mintTo, amt: mintAmt })}>mint({acctOf(mintTo).label}, {mintAmt}) + +
+
+ + + + {[500, 1000, 5000].map(v => setCapVal(v)}>{v})} + execute({ type: "updateSupplyCap", newCap: capVal })}>updateSupplyCap({capVal}) + execute({ type: "updateSupplyCap", newCap: null })}>updateSupplyCap(no cap) + +
+ Set the cap below totalSupply ({totalSupply()}) to see InvalidSupplyCap; mint past it to see SupplyCapExceeded. +
+
+ + + + {[10, 50].map(v => execute({ type: "burn", amt: v })}>burn({v}) as {acctOf(actingAs).label})} + + + + {token && token.variant === "ASSET" && ( + <> + + + {[1.0, 1.05, 1.25, 0.9].map(v => execute({ type: "updateMultiplier", value: v })}>{v}×)} + +
raw (scaled) shown side by side:
+
+
+ + + execute({ type: "batchMint", recipients: ["Alice", "Bob", "Carol"], amt: 25 })}>batchMint([Alice,Bob,Carol], 25) + execute({ type: "announceBatchMint", recipients: ["Alice", "Bob", "Carol"], amt: 25, id: 1 })}>announce(batchMint…) (OPERATOR_ROLE) + + + + )} +
+ ); + }; + + // ---------- PAUSE TAB ---------- + const PauseTab = () => ( +
+ + {["TRANSFER", "MINT", "BURN"].map(f => ( +
+ {f} + {paused[f] ? "paused" : "live"} + execute({ type: "pause", feature: f })} disabled={paused[f]}>pause + execute({ type: "unpause", feature: f })} disabled={!paused[f]}>unpause +
+ ))} +
+ + + execute({ type: "mint", to: "Alice", amt: 10 })}>mint → Alice (10) + execute({ type: "transfer", from: "Alice", to: "Bob", amt: 5 })}>transfer Alice→Bob (5) + execute({ type: "approve", owner: "Alice", spender: "Processor", amt: 20 })}>approve (never pause-gated) + + +
+ ); + + // ---------- MEMOS TAB ---------- + const MemosTab = () => { + const [memo, setMemo] = useState("invoice-8842"); + const asHex = "0x" + Array.from(memo).map(ch => ch.charCodeAt(0).toString(16).padStart(2, "0")).join("").slice(0, 20).padEnd(20, "0") + "…"; + return ( +
+ + + +
{asHex}
+
+
+ + execute({ type: "transfer", from: "Alice", to: "Bob", amt: 10, memo })}>transferWithMemo Alice→Bob (10) + execute({ type: "mint", to: "Carol", amt: 10, memo })}>mintWithMemo → Carol (10) + execute({ type: "burn", amt: 10, memo })}>burnWithMemo (10) as {acctOf(actingAs).label} + +
+
+ + + +
+ ); + }; + + const MemoJoinView = () => { + const pairs = []; + for (let i = 0; i < eventLog.length - 1; i++) { + const a = eventLog[i], b = eventLog[i + 1]; + if (b.kind === "event" && b.name === "Memo" && b.logIndex === (a.logIndex + 1) && a.kind === "event") { + pairs.push([a, b]); + } + } + if (pairs.length === 0) return Fire a *WithMemo op to see the join.; + return ( +
+ {pairs.slice(-4).map(([a, b], i) => ( +
+
+
+
[{a.logIndex}] {a.name} {a.args}
+
[{b.logIndex}] {b.name} {b.args} ← join on logIndex − 1
+
+
+ ))} +
+ ); + }; + + // ---------- PERMIT TAB ---------- + const PermitTab = () => { + const [pOwner, setPOwner] = useState("Alice"); + const [pSpender, setPSpender] = useState("Processor"); + const [pValue, setPValue] = useState(100); + const [signed, setSigned] = useState(null); + const ownerNonce = nonces[pOwner] || 0; + const key = pOwner + "→" + pSpender; + return ( +
+ + + + + + + + + {[50, 100, 250].map(v => setPValue(v)}>{v})} + setSigned({ owner: pOwner, spender: pSpender, value: pValue, nonce: ownerNonce })}>Sign permit + + {signed && ( +
+
✎ signed payload
+ owner: {acctOf(signed.owner).label} · spender: {acctOf(signed.spender).label}
+ value: {signed.value} · nonce: {signed.nonce} · deadline: ∞
+ v,r,s: 0x{(signed.nonce * 7 + 12).toString(16)}…ecdsa +
+ )} +
+ + + + signed && execute({ type: "permit", owner: signed.owner, spender: signed.spender, amt: signed.value, nonce: signed.nonce })}> + Submit permit as {acctOf(actingAs).label} + + signed && execute({ type: "permit", owner: signed.owner, spender: signed.spender, amt: signed.value, nonce: signed.nonce })}> + Replay same signature (stale nonce → revert) + + +
+ allowance({acctOf(pOwner).label} → {acctOf(pSpender).label}) = {allowances[key] || 0} + nonce({acctOf(pOwner).label}) = {ownerNonce} +
+
+
+ ); + }; + + // ----- tab dispatcher ----- + const renderTab = () => { + if (activeTab === "Create") return ; + if (!token) return
Create a token first.
; + if (activeTab === "Policies") return ; + if (activeTab === "Freeze & seize") return ; + if (activeTab === "Roles") return ; + if (activeTab === "Supply") return ; + if (activeTab === "Pause") return ; + if (activeTab === "Memos") return ; + if (activeTab === "Permit") return ; + return null; + }; + + // ====================================================================== + // RENDER + // ====================================================================== + return ( +
+ + + {/* Header bar */} +
+ + B20 Playground + simulated · client-side +
+ +
+ + {/* Token summary strip */} + + + {/* Acting-as selector */} +
+ acting as (msg.sender): + {ACCOUNTS.map(a => ( + setActingAs(a.key)}>{a.label} + ))} +
+ + {/* Tab bar */} +
+ {TABS.map(t => { + const disabled = t !== "Create" && !token; + const active = activeTab === t; + return ( + + ); + })} +
+ + {/* Tab body */} +
+ {renderTab()} +
+ + {/* Event log */} +
+
+ EVENT LOG + {eventLog.length > 0 && ( + + )} +
+
+ {eventLog.length === 0 &&
Emitted events and reverts appear here.
} + {eventLog.map(e => ( +
+ + {e.logIndex !== null && e.logIndex !== undefined ? e.logIndex : "·"} + + + {e.kind === "event" && } + {e.kind === "revert" && } + {e.kind === "info" && } + + + {e.name} + {e.args && · {e.args}} + +
+ ))} +
+
+
+ ); +}; From d811b988ef2b23329c10e300280708cfe47b4452 Mon Sep 17 00:00:00 2001 From: Soheima M Date: Wed, 22 Jul 2026 13:58:39 +0200 Subject: [PATCH 3/6] refined demos and approach --- docs/apps/guides/accept-b20-payments.mdx | 2 +- .../specs/upgrades/beryl/b20-playground.mdx | 91 +- docs/base-chain/specs/upgrades/beryl/b20.mdx | 6 +- docs/get-started/launch-b20-token.mdx | 4 +- docs/snippets/B20PlaygroundDemo.jsx | 1510 +++++------------ 5 files changed, 452 insertions(+), 1161 deletions(-) diff --git a/docs/apps/guides/accept-b20-payments.mdx b/docs/apps/guides/accept-b20-payments.mdx index 6576467d6..88a14ac0e 100644 --- a/docs/apps/guides/accept-b20-payments.mdx +++ b/docs/apps/guides/accept-b20-payments.mdx @@ -58,4 +58,4 @@ Call `publicClient.simulateContract` with the same arguments before sending. It - [B20 token standard](/base-chain/specs/upgrades/beryl/b20): the full interface, including memos, policies, pausing, and roles. - [Query B20 events](https://docs.cdp.coinbase.com/data/sql-api/b20-events): index `Transfer` and `Memo` events with the CDP SQL API to reconcile payments against orders at scale. -- [Launch a B20 Token](/get-started/launch-b20-token): create your own B20 token. \ No newline at end of file +- [Launch a B20 token](/get-started/launch-b20-token): create your own B20 token. \ No newline at end of file diff --git a/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx b/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx index b7806bcf1..914643f9d 100644 --- a/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx +++ b/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx @@ -1,84 +1,67 @@ --- -title: "B20 Playground" -description: "Interactive playground for the B20 native token standard - simulate policies, roles, freeze-and-seize, supply caps, granular pause, memos, and permit in your browser." +title: "B20 playground" +description: "Try B20's issuer flows in your browser: freeze-and-seize, payment memos, gasless approvals, and onchain corporate actions on Base." --- -import { B20PlaygroundDemo } from "/snippets/B20PlaygroundDemo.jsx" - -[B20](/base-chain/specs/upgrades/beryl/b20) is Base's native token standard: an ERC-20 superset with a built-in compliance toolkit. This page lets you create a simulated B20 token and exercise its compliance features tab by tab, from choosing a variant to freezing an account and seizing its balance. +import { B20FlowDemo } from "/snippets/B20PlaygroundDemo.jsx" -The playground simulates B20 semantics entirely client-side. Nothing touches a chain or wallet, and no state persists. To deploy a real token, see [Launch a B20 Token](/get-started/launch-b20-token). +The walkthroughs on this page simulate [B20](/base-chain/specs/upgrades/beryl/b20) semantics entirely client-side. Nothing touches a chain or wallet. -## Feature map at a glance - -| You want to… | B20 feature | Demo section | -|--------------|-------------|--------------| -| Choose a token type and predict its address | Variants + deterministic addresses | Create a token | -| Restrict who can send, receive, or mint | Policy scopes + PolicyRegistry | Policy gating | -| Freeze a bad actor and seize funds | `TRANSFER_SENDER_POLICY` + `burnBlocked` | Freeze and seize | -| Separate operational duties or go admin-less | Roles + `renounceLastAdmin` | Roles and metadata | -| Hard-limit issuance | Supply cap | Supply cap and mint | -| Halt transfers without halting everything | Granular pause | Granular pause | -| Attach references to transactions | Memos | Memos | -| Gasless approvals | ERC-2612 `permit` | Permit | - - - - - -## Create a token - -Pick a variant and the demo derives everything else. An `ASSET` token takes configurable decimals from 6 to 18; a `STABLECOIN` is fixed at 6 decimals and carries a self-declared `currency()` code. You can set an optional supply cap and an initial admin, including `address(0)` for an admin-less launch that never grants `DEFAULT_ADMIN_ROLE`. The simulated address is deterministic: `[10-byte prefix][1-byte variant][9-byte keccak256(deployer, salt)]`, so you can read the variant straight off byte 10 without an RPC call. See [Address Derivation](/base-chain/specs/upgrades/beryl/b20#address-derivation) for the exact scheme. - -## Policy gating +## When to use B20 -Attach allowlist or blocklist policies to the four transfer scopes and watch gated operations revert with `PolicyForbids`. `TRANSFER_SENDER_POLICY` checks the `from`, `TRANSFER_RECEIVER_POLICY` checks the `to`, `MINT_RECEIVER_POLICY` checks the recipient of a mint, and `TRANSFER_EXECUTOR_POLICY` checks the `msg.sender` of `transferFrom` only when the caller is not the sender. Note that `approve` is never policy-gated; only actual balance movement is checked. See [Policy Integration](/base-chain/specs/upgrades/beryl/b20#policy-integration). +Use B20 when: - -Every scope defaults to `ALWAYS_ALLOW` at token creation. An unattended B20 deployment is fully open: token behavior must be intentionally constrained. - +- You need compliance, reconciliation, or corporate-action machinery. It ships as part of the standard, so you don't build and audit a custom contract. +- Your integrators matter. Exchanges, wallets, and indexers wire up one surface that behaves the same across every B20 token. +- Cost matters. B20 runs as a native precompile: cheaper and faster than a contract token. -## Freeze and seize +Stick with a plain ERC-20 if you need transfer logic B20 doesn't model (fee-on-transfer, custom hooks) or you're deploying off Base. -Freeze-and-seize is two steps. First, deny the target under `TRANSFER_SENDER_POLICY` so it can no longer send. Then call `burnBlocked` to burn from that account: the call requires the caller to hold `BURN_BLOCKED_ROLE` and requires the target to be denied by `TRANSFER_SENDER_POLICY`. Both conditions must hold, which is why the demo blocks the account before it can seize the balance. See [Burn](/base-chain/specs/upgrades/beryl/b20#burn). +## A compliance order arrives -## Roles and metadata +A court order targets one account. Freeze it so it can't send, then seize the balance with one call. Seizure only works on an account that is already frozen. Every other holder is untouched and the token keeps trading. See [transfer policies](/base-chain/specs/upgrades/beryl/b20#policy-integration) and [burn](/base-chain/specs/upgrades/beryl/b20#burn) in the spec. -Grant and revoke the seven base roles (`DEFAULT_ADMIN_ROLE`, `MINT_ROLE`, `BURN_ROLE`, `BURN_BLOCKED_ROLE`, `PAUSE_ROLE`, `UNPAUSE_ROLE`, `METADATA_ROLE`) and trigger the one gated action behind each. `METADATA_ROLE` gates `updateName`, `updateSymbol`, and `updateContractURI`. Try to remove the last admin with `renounceRole` and it reverts with `LastAdminCannotRenounce`; the only path to an admin-less token is `renounceLastAdmin()`, which is permanent. See [Roles Model](/base-chain/specs/upgrades/beryl/b20#roles-model). + + + -## Supply cap and mint +## A customer pays an invoice -Mint with `MINT_ROLE` and push past the cap to see `SupplyCapExceeded`. Adjust the cap with `updateSupplyCap`, which reverts with `InvalidSupplyCap` when the new cap is below current `totalSupply` or above the `type(uint128).max` sentinel that marks no cap. When the simulated token is an Asset, extra controls appear: `batchMint`, the rebase `multiplier`, and `announce`. See [Supply Cap](/base-chain/specs/upgrades/beryl/b20#supply-cap). +Your customer pays to one address. The transfer carries the invoice reference, so payment and order ID land in the same transaction. Reconciliation becomes a log query instead of a deposit address for every customer. To build this, see [Accept B20 payments](/apps/guides/accept-b20-payments). -## Granular pause + + + -Pause `TRANSFER`, `MINT`, and `BURN` independently instead of freezing the whole token. Pausing transfers still lets minting continue, and vice versa. Pausing and unpausing are gated by distinct roles by design: `PAUSE_ROLE` and `UNPAUSE_ROLE`. See [Pause](/base-chain/specs/upgrades/beryl/b20#pause). +## A new user approves without gas -## Memos +A new user shouldn't have to buy ETH first. With `permit`, they sign an approval offchain and your platform relays it. They start transacting with zero gas, and nonces stop signature replay. See [permit](/base-chain/specs/upgrades/beryl/b20#erc-2612-permit--eip-712) in the spec. -Send a transaction with `transferWithMemo` (and the sibling `transferFromWithMemo`, `mintWithMemo`, `burnWithMemo`) to attach a `bytes32` reference. Each emits `Memo(caller, memo)` immediately after the operation's primary event, so an indexer joins the memo to its transaction via `(transactionHash, logIndex − 1)`. See [Memos](/base-chain/specs/upgrades/beryl/b20#memos). + + + -## Permit +## The stock splits -Sign an ERC-2612 approval offchain and submit it to grant an allowance without a prior `approve` transaction. The demo increments the account nonce on each use so a signature can't be replayed. Permit is ECDSA-only; ERC-1271 contract signatures are not accepted. See [ERC-2612 Permit / EIP-712](/base-chain/specs/upgrades/beryl/b20#erc-2612-permit--eip-712). +A 2-for-1 split is one call. Every balance doubles without a migration or a new contract. Dividends run inside a public announcement window, so the disclosure lives onchain next to the action. The token also stores identifiers like a CUSIP. See the [Asset variant](/base-chain/specs/upgrades/beryl/b20#asset) in the spec. - -The playground intentionally leaves out a few advanced behaviors: the `initCalls` bootstrap bypass, two-step policy admin transfer, and custom user-defined roles. For those, see [initCalls Semantics](/base-chain/specs/upgrades/beryl/b20#initcalls-semantics), the [Policy Registry](/base-chain/specs/upgrades/beryl/b20#policy-registry) admin model, and [Roles Model](/base-chain/specs/upgrades/beryl/b20#roles-model) in the spec. - + + + -## Related docs +## Build with it - - The full spec: variants, policies, roles, and the complete function surface. - - - Deploy a real B20 token onchain. + + Deploy a fully configured token in one factory call. - Match transactions to orders with onchain memos. + Integration code for memo-tagged payments and B20-specific reverts. + + + Every method, event, role, and policy in the standard. The upgrade that introduces B20 and native token features. diff --git a/docs/base-chain/specs/upgrades/beryl/b20.mdx b/docs/base-chain/specs/upgrades/beryl/b20.mdx index eca60a93a..2daaf3aa5 100644 --- a/docs/base-chain/specs/upgrades/beryl/b20.mdx +++ b/docs/base-chain/specs/upgrades/beryl/b20.mdx @@ -1,11 +1,11 @@ --- -title: "B20 Native Token Standard" -description: "B20 is Base's native token standard - designed for stablecoin issuers, real-world asset (RWA) and equity issuers, and long-tail token creators." +title: "B20 native token standard" +description: "Learn how B20, Base's native token standard, serves stablecoin issuers, real-world asset (RWA) and equity issuers, and long-tail token creators." --- B20 is the Base ecosystem's own version of [ERC-20](https://eips.ethereum.org/EIPS/eip-20). It ships with a built-in compliance toolkit: transfer policies, freeze-and-seize, role-based access control, memos, and supply caps. The full interface specs are available in the [Base Standard Library](https://github.com/base/base-std/tree/main) repository. -To deploy your first B20 token, see the [Launch a B20 Token](/get-started/launch-b20-token) quickstart. +To deploy your first B20 token, see the [Launch a B20 token](/get-started/launch-b20-token) quickstart. [Verify the Activation Registry is enabled](/get-started/launch-b20-token#verify-the-activation-registry-is-enabled) before attempting to deploy. diff --git a/docs/get-started/launch-b20-token.mdx b/docs/get-started/launch-b20-token.mdx index c0f61578a..e1880ae81 100644 --- a/docs/get-started/launch-b20-token.mdx +++ b/docs/get-started/launch-b20-token.mdx @@ -1,6 +1,6 @@ --- -title: "Launch a B20 Token" -description: "Launch a B20 token on Base by calling the B20 Factory precompile." +title: "Launch a B20 token" +description: "Launch a B20 token on Base with one call to the B20 Factory precompile, with roles, supply caps, and compliance controls built in." --- B20 is an ERC-20 superset that runs as a native precompile on Base, which makes transfers cheaper and higher-throughput than a standard contract token while keeping full ERC-20 compatibility. Roles, supply caps, pausing, policy gating, memos, and `permit` are built into the chain. diff --git a/docs/snippets/B20PlaygroundDemo.jsx b/docs/snippets/B20PlaygroundDemo.jsx index 3656089c4..4502cd70e 100644 --- a/docs/snippets/B20PlaygroundDemo.jsx +++ b/docs/snippets/B20PlaygroundDemo.jsx @@ -1,1188 +1,496 @@ -export const B20PlaygroundDemo = () => { - const sans = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif"; - const serif = "'Tiempos Headline','Iowan Old Style','Source Serif Pro',ui-serif,Georgia,serif"; - const mono = "ui-monospace,'SF Mono','Cascadia Code',Menlo,Monaco,Consolas,monospace"; +export const B20FlowDemo = ({ flow }) => { + // Base brand: Base Sans / Base Mono with brand-recommended fallbacks. + const sans = "'Base Sans','Inter Tight',Inter,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif"; + const mono = "'Base Mono','Roboto Mono',ui-monospace,'SF Mono',Menlo,Consolas,monospace"; + // Palette resolves through CSS variables set on .b20f-card (light) and + // overridden under html.dark, so the widget tracks the site theme. const c = { - bg: "#1f1e1d", header: "#262624", border: "#34322f", inputBg: "#2a2926", - text: "#f5f4ed", body: "#e8e4dc", muted: "#a8a39d", dim: "#6b6663", - accent: "#D97757", bubble: "#2c2b28", bubbleText: "#f5f4ed", - code: "#e89972", codeBg: "rgba(217,119,87,0.12)", - toolBg: "#272622", toolBorder: "#3a3835", success: "#a3c585", - error: "#e57373", errorBg: "rgba(229,115,115,0.10)", + bg: "var(--b20-bg)", panel: "var(--b20-panel)", border: "var(--b20-border)", + text: "var(--b20-text)", body: "var(--b20-body)", muted: "var(--b20-muted)", dim: "var(--b20-dim)", + accent: "var(--b20-accent)", accentContrast: "var(--b20-accent-contrast)", + success: "var(--b20-success)", error: "var(--b20-error)", }; - // ----- Preset accounts ----- - const ACCOUNTS = [ - { key: "Issuer", label: "Issuer", addr: "0x155e…d001", dot: "#D97757" }, - { key: "Processor", label: "Processor", addr: "0x9r0c…d002", dot: "#7c9fe8" }, - { key: "Alice", label: "Alice", addr: "0xA11c…e001", dot: "#a3c585" }, - { key: "Bob", label: "Bob", addr: "0xB0b0…e002", dot: "#e5c07b" }, - { key: "Carol", label: "Carol", addr: "0xCar0…e003", dot: "#c586d9" }, - ]; - const acctOf = (k) => ACCOUNTS.find(a => a.key === k) || { key: k, label: k, addr: "0x0000…0000", dot: c.dim }; + // Base secondary palette for account markers. + const acctDot = { Issuer: "var(--b20-accent)", Processor: "#3c8aff", Alice: "#66c800", Bob: "#ffd12f", Carol: "#fea8cd" }; - const ROLE_NAMES_BASE = [ - "DEFAULT_ADMIN_ROLE", "MINT_ROLE", "BURN_ROLE", "BURN_BLOCKED_ROLE", - "PAUSE_ROLE", "UNPAUSE_ROLE", "METADATA_ROLE", - ]; - const SCOPES = ["TRANSFER_SENDER", "TRANSFER_RECEIVER", "TRANSFER_EXECUTOR", "MINT_RECEIVER"]; - - // Built-in policy IDs. ALWAYS_ALLOW = 0. ALWAYS_BLOCK = (ALLOWLIST(=1) << 56) | 1. - const ALWAYS_ALLOW = 0; - const ALWAYS_BLOCK = "ALWAYS_BLOCK"; // sentinel token used internally for the built-in block id - // Rendered composed form of ALWAYS_BLOCK, i.e. 0x0100…01. - const ALWAYS_BLOCK_RENDER = "0x0100…01"; - - const UINT128_MAX = "type(uint128).max (no cap)"; - - // ----- djb2-style deterministic string hash (simulated keccak) ----- - const hashHex = (deployer, salt) => { + // ----- deterministic simulated address ----- + const hashHex = (seed) => { let h = 5381; - const s = deployer + "|" + salt; - for (let i = 0; i < s.length; i++) { - h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; - } - // spread into 9 bytes deterministically - let out = ""; - let x = h; + for (let i = 0; i < seed.length; i++) h = ((h << 5) + h + seed.charCodeAt(i)) >>> 0; + let out = "", x = h; for (let i = 0; i < 9; i++) { x = (x * 1103515245 + 12345 + i * 7) >>> 0; - const b = (x >>> ((i % 4) * 8)) & 0xff; - out += b.toString(16).padStart(2, "0"); + out += ((x >>> ((i % 4) * 8)) & 0xff).toString(16).padStart(2, "0"); } - return out; // 18 hex chars = 9 bytes + return out; }; - - const deriveAddress = (variant, deployer, salt) => { - const prefix = "b2000000000000000000"; // 10-byte B20 prefix + const tokenAddress = (variant, symbol) => { const vByte = variant === "STABLECOIN" ? "01" : "00"; - const tail = hashHex(deployer, salt); // 9 bytes - return { prefix, vByte, tail }; + const tail = hashHex(symbol); + return `0xb2000000…${vByte}${tail.slice(0, 4)}…${tail.slice(-4)}`; }; - // ----- state ----- - const [token, setToken] = useState(null); - const [balances, setBalances] = useState({}); - const [roles, setRoles] = useState({}); - const [policies, setPolicies] = useState({}); - const [nextPolicyId, setNextPolicyId] = useState(2); - const [scopes, setScopes] = useState({ TRANSFER_SENDER: 0, TRANSFER_RECEIVER: 0, TRANSFER_EXECUTOR: 0, MINT_RECEIVER: 0 }); - const [paused, setPaused] = useState({ TRANSFER: false, MINT: false, BURN: false }); - const [allowances, setAllowances] = useState({}); - const [nonces, setNonces] = useState({}); - const [eventLog, setEventLog] = useState([]); - const [activeTab, setActiveTab] = useState("Create"); - const [actingAs, setActingAs] = useState("Issuer"); - - const idRef = useRef(0); - const logRef = useRef(null); - - useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [eventLog]); - - // ----- Create-tab form state ----- - const [cVariant, setCVariant] = useState("STABLECOIN"); - const [cName, setCName] = useState("Acme Dollar"); - const [cSymbol, setCSymbol] = useState("aUSD"); - const [cDecimals, setCDecimals] = useState(6); - const [cCurrency, setCCurrency] = useState("USD"); - const [cSalt, setCSalt] = useState("0x01"); - const [cInitialAdminZero, setCInitialAdminZero] = useState(false); - - const totalSupply = () => Object.values(balances).reduce((a, b) => a + b, 0); - - // ----- log helpers ----- - const pushLog = (entries) => { - setEventLog(prev => { - const tag = "0x" + (prev.length % 16).toString(16) + "f4…" + ((prev.length * 7) % 256).toString(16).padStart(2, "0"); - const withIds = entries.map((e, i) => ({ - id: ++idRef.current, - logIndex: e.logIndex, - txTag: tag, - kind: e.kind, - name: e.name, - args: e.args || "", - })); - return [...prev, ...withIds]; - }); - }; const emit = (name, args, logIndex) => ({ kind: "event", name, args, logIndex }); const revert = (name, args) => ({ kind: "revert", name, args: args || "", logIndex: null }); const info = (name, args) => ({ kind: "info", name, args: args || "", logIndex: null }); - // ----- authorization helper (never throws) ----- - const isAuthorized = (policyId, account) => { - if (policyId === 0 || policyId === undefined || policyId === null) return true; - if (policyId === ALWAYS_BLOCK) return false; - const p = policies[policyId]; - if (!p) { - // non-existent id collapses to empty-set semantics. - // We cannot know the intended type; spec: non-existent BLOCKLIST authorizes, - // non-existent ALLOWLIST denies. Encoded type is in the top byte of the id. - // Our simulated ids carry their type on the policy object only; for a missing - // object we decode by convention: ids created as ALLOWLIST were tracked, so a - // truly-missing id is treated as an empty ALLOWLIST (denies everyone). - return false; - } - if (p.type === "BLOCKLIST") return !p.members.includes(account); - if (p.type === "ALLOWLIST") return p.members.includes(account); - return true; - }; - - const roleHas = (roleName, account) => (roles[roleName] || []).includes(account); - const adminLess = () => token && (token.adminRenounced || token.initialAdminZero); - const isAdmin = (account) => !adminLess() && roleHas("DEFAULT_ADMIN_ROLE", account); - - // ----- create token ----- - const createToken = () => { - const deployer = acctOf(actingAs).addr; - const dec = cVariant === "STABLECOIN" ? 6 : cDecimals; - const t = { - variant: cVariant, - name: cName, - symbol: cSymbol, - decimals: dec, - currency: cVariant === "STABLECOIN" ? cCurrency : null, - supplyCap: null, - salt: cSalt, - deployer, - contractURI: "", + // ----- fresh simulation state ----- + const freshSim = () => ({ + token: null, balances: {}, policies: {}, nextPolicyId: 2, + scopes: { TRANSFER_SENDER: 0, TRANSFER_RECEIVER: 0, TRANSFER_EXECUTOR: 0, MINT_RECEIVER: 0 }, + roles: {}, allowances: {}, nonces: {}, sig: null, + }); + const cloneSim = (s) => ({ + token: s.token ? { ...s.token, extraMetadata: { ...s.token.extraMetadata } } : null, + balances: { ...s.balances }, + policies: Object.fromEntries(Object.entries(s.policies).map(([k, v]) => [k, { ...v, members: [...v.members] }])), + nextPolicyId: s.nextPolicyId, + scopes: { ...s.scopes }, + roles: Object.fromEntries(Object.entries(s.roles).map(([k, v]) => [k, [...v]])), + allowances: { ...s.allowances }, + nonces: { ...s.nonces }, + sig: s.sig ? { ...s.sig } : null, + }); + + // Provision a token directly into the snapshot (simulated createB20). + const createInSim = (s, kind) => { + const isEquity = kind === "EQUITY"; + s.token = { + variant: isEquity ? "ASSET" : "STABLECOIN", + name: isEquity ? "Acme Corp Class A" : "Acme Dollar", + symbol: isEquity ? "ACME" : "aUSD", multiplier: 1.0, - extraMetadata: {}, - adminRenounced: false, - initialAdminZero: cInitialAdminZero, + extraMetadata: isEquity ? { cusip: "38259P508" } : {}, }; const roleMap = {}; - ROLE_NAMES_BASE.forEach(r => { roleMap[r] = []; }); - if (cVariant === "ASSET") roleMap["OPERATOR_ROLE"] = []; - // initialAdmin gets every gate role for a friendly playground, unless admin-less. - if (!cInitialAdminZero) { - const admin = actingAs; - Object.keys(roleMap).forEach(r => { roleMap[r] = [admin]; }); - } - setToken(t); - setRoles(roleMap); - setBalances({}); - setPolicies({}); - setNextPolicyId(2); - setScopes({ TRANSFER_SENDER: 0, TRANSFER_RECEIVER: 0, TRANSFER_EXECUTOR: 0, MINT_RECEIVER: 0 }); - setPaused({ TRANSFER: false, MINT: false, BURN: false }); - setAllowances({}); - setNonces({}); - setEventLog([]); - idRef.current = 0; - setActiveTab("Supply"); - setTimeout(() => pushLog([ - info("B20Created", `${cVariant.toLowerCase()} · ${cSymbol} · deployer ${acctOf(actingAs).label}`), - cInitialAdminZero - ? info("admin", "initialAdmin == address(0) — launched admin-less") - : emit("RoleGranted", `DEFAULT_ADMIN_ROLE → ${acctOf(actingAs).label}`, 0), - ]), 0); - }; - - const resetAll = () => { - setToken(null); - setBalances({}); setRoles({}); setPolicies({}); setNextPolicyId(2); - setScopes({ TRANSFER_SENDER: 0, TRANSFER_RECEIVER: 0, TRANSFER_EXECUTOR: 0, MINT_RECEIVER: 0 }); - setPaused({ TRANSFER: false, MINT: false, BURN: false }); - setAllowances({}); setNonces({}); setEventLog([]); idRef.current = 0; - setActiveTab("Create"); setActingAs("Issuer"); - }; - - // ----- capacity check for supply cap ----- - const capExceeded = (addAmount) => { - if (!token || token.supplyCap === null) return false; - return totalSupply() + addAmount > token.supplyCap; + ["DEFAULT_ADMIN_ROLE", "MINT_ROLE", "BURN_BLOCKED_ROLE"].forEach(r => { roleMap[r] = ["Issuer"]; }); + if (isEquity) roleMap["OPERATOR_ROLE"] = ["Issuer"]; + s.roles = roleMap; }; // --------------------------------------------------------------- - // Single write path. Validates in spec order, appends events/reverts. + // Op interpreter. Mutates the snapshot; validates in spec order; + // returns log entries for the step's inline result. // --------------------------------------------------------------- - const execute = (op) => { - const me = actingAs; - const A = (k) => acctOf(k).label; + const applyOp = (s, op, me, entries) => { + const isAuthorized = (policyId, account) => { + if (!policyId) return true; // ALWAYS_ALLOW = 0, the default on every scope + const p = s.policies[policyId]; + if (!p) return false; + if (p.type === "BLOCKLIST") return !p.members.includes(account); + return p.members.includes(account); // ALLOWLIST + }; + const roleHas = (r, a) => (s.roles[r] || []).includes(a); switch (op.type) { - // -------- transfer(from, to, amt) -------- + case "mint": { + if (!roleHas("MINT_ROLE", me)) return entries.push(revert("AccessControlUnauthorizedAccount", `${me} lacks MINT_ROLE`)); + if (!isAuthorized(s.scopes.MINT_RECEIVER, op.to)) return entries.push(revert("PolicyForbids", `MINT_RECEIVER · ${op.to}`)); + s.balances[op.to] = (s.balances[op.to] || 0) + op.amt; + return entries.push(emit("Transfer", `0x0 → ${op.to} · ${op.amt}`, 0)); + } case "transfer": { const { from, to, amt, memo } = op; - if (paused.TRANSFER) return pushLog([revert("EnforcedPause", "feature: TRANSFER")]); - if (!isAuthorized(scopes.TRANSFER_SENDER, from)) return pushLog([revert("PolicyForbids", `TRANSFER_SENDER · ${A(from)}`)]); - if (!isAuthorized(scopes.TRANSFER_RECEIVER, to)) return pushLog([revert("PolicyForbids", `TRANSFER_RECEIVER · ${A(to)}`)]); - if ((balances[from] || 0) < amt) return pushLog([revert("ERC20InsufficientBalance", `${A(from)} has ${balances[from] || 0}`)]); - setBalances(b => ({ ...b, [from]: (b[from] || 0) - amt, [to]: (b[to] || 0) + amt })); - if (memo !== undefined) { - return pushLog([emit("Transfer", `${A(from)} → ${A(to)} · ${amt}`, 0), emit("Memo", `${A(me)} · ${memo}`, 1)]); - } - return pushLog([emit("Transfer", `${A(from)} → ${A(to)} · ${amt}`, 0)]); + if (!isAuthorized(s.scopes.TRANSFER_SENDER, from)) return entries.push(revert("PolicyForbids", `TRANSFER_SENDER · ${from}`)); + if (!isAuthorized(s.scopes.TRANSFER_RECEIVER, to)) return entries.push(revert("PolicyForbids", `TRANSFER_RECEIVER · ${to}`)); + if ((s.balances[from] || 0) < amt) return entries.push(revert("ERC20InsufficientBalance", `${from} has ${s.balances[from] || 0}`)); + s.balances[from] -= amt; + s.balances[to] = (s.balances[to] || 0) + amt; + entries.push(emit("Transfer", `${from} → ${to} · ${amt}`, 0)); + if (memo !== undefined) entries.push(emit("Memo", `${me} · "${memo}"`, 1)); + return; } - - // -------- transferFrom(exec, from, to, amt) -------- case "transferFrom": { - const { from, to, amt, memo } = op; - const exec = me; - const key = from + "→" + exec; - if (paused.TRANSFER) return pushLog([revert("EnforcedPause", "feature: TRANSFER")]); - if ((allowances[key] || 0) < amt) return pushLog([revert("ERC20InsufficientAllowance", `${A(exec)} allowance ${allowances[key] || 0} < ${amt}`)]); - if (exec !== from && !isAuthorized(scopes.TRANSFER_EXECUTOR, exec)) return pushLog([revert("PolicyForbids", `TRANSFER_EXECUTOR · ${A(exec)}`)]); - if (!isAuthorized(scopes.TRANSFER_SENDER, from)) return pushLog([revert("PolicyForbids", `TRANSFER_SENDER · ${A(from)}`)]); - if (!isAuthorized(scopes.TRANSFER_RECEIVER, to)) return pushLog([revert("PolicyForbids", `TRANSFER_RECEIVER · ${A(to)}`)]); - if ((balances[from] || 0) < amt) return pushLog([revert("ERC20InsufficientBalance", `${A(from)} has ${balances[from] || 0}`)]); - setBalances(b => ({ ...b, [from]: (b[from] || 0) - amt, [to]: (b[to] || 0) + amt })); - setAllowances(al => ({ ...al, [key]: (al[key] || 0) - amt })); - if (memo !== undefined) { - return pushLog([emit("Transfer", `${A(from)} → ${A(to)} · ${amt}`, 0), emit("Memo", `${A(me)} · ${memo}`, 1)]); - } - return pushLog([emit("Transfer", `${A(from)} → ${A(to)} · ${amt} (by ${A(exec)})`, 0)]); - } - - // -------- approve(owner, spender, amt) : never gated -------- - case "approve": { - const { owner, spender, amt } = op; - const key = owner + "→" + spender; - setAllowances(al => ({ ...al, [key]: amt })); - return pushLog([emit("Approval", `${A(owner)} → ${A(spender)} · ${amt}`, 0)]); - } - - // -------- mint(caller, to, amt) -------- - case "mint": { - const { to, amt, memo } = op; - if (paused.MINT) return pushLog([revert("EnforcedPause", "feature: MINT")]); - if (!roleHas("MINT_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks MINT_ROLE`)]); - if (!isAuthorized(scopes.MINT_RECEIVER, to)) return pushLog([revert("PolicyForbids", `MINT_RECEIVER · ${A(to)}`)]); - if (capExceeded(amt)) return pushLog([revert("SupplyCapExceeded", `${totalSupply() + amt} > cap ${token.supplyCap}`)]); - setBalances(b => ({ ...b, [to]: (b[to] || 0) + amt })); - if (memo !== undefined) { - return pushLog([emit("Transfer", `0x0 → ${A(to)} · ${amt}`, 0), emit("Memo", `${A(me)} · ${memo}`, 1)]); - } - return pushLog([emit("Transfer", `0x0 → ${A(to)} · ${amt}`, 0)]); + const { from, to, amt } = op; + const key = from + "→" + me; + if ((s.allowances[key] || 0) < amt) return entries.push(revert("ERC20InsufficientAllowance", `${me} allowance ${s.allowances[key] || 0} < ${amt}`)); + if ((s.balances[from] || 0) < amt) return entries.push(revert("ERC20InsufficientBalance", `${from} has ${s.balances[from] || 0}`)); + s.balances[from] -= amt; + s.balances[to] = (s.balances[to] || 0) + amt; + s.allowances[key] -= amt; + return entries.push(emit("Transfer", `${from} → ${to} · ${amt} (by ${me})`, 0)); } - - // -------- burn(caller, amt) -------- - case "burn": { - const { amt, memo } = op; - if (paused.BURN) return pushLog([revert("EnforcedPause", "feature: BURN")]); - if (!roleHas("BURN_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks BURN_ROLE`)]); - if ((balances[me] || 0) < amt) return pushLog([revert("ERC20InsufficientBalance", `${A(me)} has ${balances[me] || 0}`)]); - setBalances(b => ({ ...b, [me]: (b[me] || 0) - amt })); - if (memo !== undefined) { - return pushLog([emit("Transfer", `${A(me)} → 0x0 · ${amt}`, 0), emit("Memo", `${A(me)} · ${memo}`, 1)]); - } - return pushLog([emit("Transfer", `${A(me)} → 0x0 · ${amt}`, 0)]); - } - - // -------- burnBlocked(caller, target, amt) -------- case "burnBlocked": { const { target, amt } = op; - if (paused.BURN) return pushLog([revert("EnforcedPause", "feature: BURN")]); - if (!roleHas("BURN_BLOCKED_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks BURN_BLOCKED_ROLE`)]); - if (isAuthorized(scopes.TRANSFER_SENDER, target)) return pushLog([revert("AccountNotBlocked", `${A(target)} not denied by TRANSFER_SENDER`)]); - if ((balances[target] || 0) < amt) return pushLog([revert("ERC20InsufficientBalance", `${A(target)} has ${balances[target] || 0}`)]); - setBalances(b => ({ ...b, [target]: (b[target] || 0) - amt })); - return pushLog([emit("Transfer", `${A(target)} → 0x0 · ${amt} (seized)`, 0)]); - } - - // -------- grantRole -------- - case "grantRole": { - const { role, account } = op; - if (!isAdmin(me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); - if (roleHas(role, account)) return pushLog([info("grantRole", `${A(account)} already holds ${role}`)]); - setRoles(r => ({ ...r, [role]: [...(r[role] || []), account] })); - return pushLog([emit("RoleGranted", `${role} → ${A(account)}`, 0)]); - } - - // -------- revokeRole -------- - case "revokeRole": { - const { role, account } = op; - if (!isAdmin(me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); - if (role === "DEFAULT_ADMIN_ROLE" && (roles["DEFAULT_ADMIN_ROLE"] || []).length <= 1 && roleHas(role, account)) { - return pushLog([revert("LastAdminCannotRenounce", "cannot remove the last admin")]); - } - setRoles(r => ({ ...r, [role]: (r[role] || []).filter(a => a !== account) })); - return pushLog([emit("RoleRevoked", `${role} ✕ ${A(account)}`, 0)]); - } - - // -------- renounceRole (self) -------- - case "renounceRole": { - const { role } = op; - // self-renounce does not require admin - if (!roleHas(role, me)) return pushLog([info("renounceRole", `${A(me)} does not hold ${role}`)]); - if (role === "DEFAULT_ADMIN_ROLE" && (roles["DEFAULT_ADMIN_ROLE"] || []).length <= 1) { - return pushLog([revert("LastAdminCannotRenounce", "use renounceLastAdmin() instead")]); - } - setRoles(r => ({ ...r, [role]: (r[role] || []).filter(a => a !== me) })); - return pushLog([emit("RoleRevoked", `${role} ✕ ${A(me)} (renounced)`, 0)]); - } - - // -------- renounceLastAdmin -------- - case "renounceLastAdmin": { - if (!roleHas("DEFAULT_ADMIN_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); - setRoles(r => ({ ...r, DEFAULT_ADMIN_ROLE: (r["DEFAULT_ADMIN_ROLE"] || []).filter(a => a !== me) })); - setToken(t => ({ ...t, adminRenounced: true })); - return pushLog([emit("RoleRevoked", `DEFAULT_ADMIN_ROLE ✕ ${A(me)}`, 0), info("admin", "token is now permanently admin-less")]); - } - - // -------- updateSupplyCap -------- - case "updateSupplyCap": { - const { newCap } = op; // number or null - if (!isAdmin(me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); - if (newCap !== null && newCap < totalSupply()) return pushLog([revert("InvalidSupplyCap", `${newCap} < totalSupply ${totalSupply()}`)]); - setToken(t => ({ ...t, supplyCap: newCap })); - return pushLog([emit("SupplyCapUpdated", newCap === null ? UINT128_MAX : String(newCap), 0)]); - } - - // -------- updatePolicy(scope, policyId) -------- - case "updatePolicy": { - const { scope, policyId } = op; - if (!isAdmin(me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} is not DEFAULT_ADMIN_ROLE`)]); - setScopes(s => ({ ...s, [scope]: policyId })); - const label = policyId === 0 ? "ALWAYS_ALLOW (0)" : policyId === ALWAYS_BLOCK ? `ALWAYS_BLOCK (${ALWAYS_BLOCK_RENDER})` : `#${policyId}`; - return pushLog([emit("PolicyUpdated", `${scope} → ${label}`, 0)]); - } - - // -------- createPolicy(type) -------- - case "createPolicy": { - const { policyType } = op; - const id = nextPolicyId; - setPolicies(p => ({ ...p, [id]: { type: policyType, members: [] } })); - setNextPolicyId(n => n + 1); - return pushLog([emit("PolicyCreated", `#${id} · ${policyType} · admin ${A(me)}`, 0)]); - } - - // -------- updateBlocklist / updateAllowlist (membership toggle) -------- - case "togglePolicyMember": { - const { policyId, account } = op; - const p = policies[policyId]; - if (!p) return pushLog([revert("PolicyDoesNotExist", `#${policyId}`)]); - const has = p.members.includes(account); - setPolicies(prev => ({ - ...prev, - [policyId]: { ...prev[policyId], members: has ? prev[policyId].members.filter(a => a !== account) : [...prev[policyId].members, account] }, - })); - const setter = p.type === "BLOCKLIST" ? "updateBlocklist" : "updateAllowlist"; - return pushLog([emit(setter, `#${policyId} · ${has ? "remove" : "add"} ${A(account)}`, 0)]); - } - - // -------- pause / unpause -------- - case "pause": { - const { feature } = op; - if (!roleHas("PAUSE_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks PAUSE_ROLE`)]); - setPaused(p => ({ ...p, [feature]: true })); - return pushLog([emit("Paused", `feature: ${feature}`, 0)]); - } - case "unpause": { - const { feature } = op; - if (!roleHas("UNPAUSE_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks UNPAUSE_ROLE`)]); - setPaused(p => ({ ...p, [feature]: false })); - return pushLog([emit("Unpaused", `feature: ${feature}`, 0)]); - } - - // -------- metadata -------- - case "updateName": { - if (!roleHas("METADATA_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks METADATA_ROLE`)]); - setToken(t => ({ ...t, name: op.value })); - return pushLog([ - emit("NameUpdated", op.value, 0), - info("EIP712DomainChanged", "domain separator rotated"), - ]); - } - case "updateSymbol": { - if (!roleHas("METADATA_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks METADATA_ROLE`)]); - setToken(t => ({ ...t, symbol: op.value })); - return pushLog([emit("SymbolUpdated", op.value, 0)]); + if (!roleHas("BURN_BLOCKED_ROLE", me)) return entries.push(revert("AccessControlUnauthorizedAccount", `${me} lacks BURN_BLOCKED_ROLE`)); + if (isAuthorized(s.scopes.TRANSFER_SENDER, target)) return entries.push(revert("AccountNotBlocked", `${target} not denied by TRANSFER_SENDER`)); + if ((s.balances[target] || 0) < amt) return entries.push(revert("ERC20InsufficientBalance", `${target} has ${s.balances[target] || 0}`)); + s.balances[target] -= amt; + return entries.push(emit("Transfer", `${target} → 0x0 · ${amt} (seized)`, 0)); } - case "updateContractURI": { - if (!roleHas("METADATA_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks METADATA_ROLE`)]); - setToken(t => ({ ...t, contractURI: op.value })); - return pushLog([emit("ContractURIUpdated", op.value, 0)]); + case "freeze": { + // createPolicy + updateBlocklist + updatePolicy, presented as one action + const id = s.nextPolicyId; + s.policies[id] = { type: "BLOCKLIST", members: [op.target] }; + s.nextPolicyId = id + 1; + s.scopes.TRANSFER_SENDER = id; + entries.push(emit("PolicyCreated", `#${id} · BLOCKLIST · admin ${me}`, 0)); + entries.push(emit("updateBlocklist", `#${id} · add ${op.target}`, 1)); + entries.push(emit("PolicyUpdated", `TRANSFER_SENDER → #${id}`, 2)); + return; } - - // -------- permit -------- case "permit": { const { owner, spender, amt, nonce } = op; - const current = nonces[owner] || 0; - if (nonce !== current) return pushLog([revert("ERC2612InvalidSigner", `stale nonce ${nonce} ≠ ${current} — replay rejected`)]); - const key = owner + "→" + spender; - setAllowances(al => ({ ...al, [key]: amt })); - setNonces(n => ({ ...n, [owner]: current + 1 })); - return pushLog([emit("Approval", `${A(owner)} → ${A(spender)} · ${amt} (permit, relayed by ${A(me)})`, 0)]); + const current = s.nonces[owner] || 0; + if (nonce !== current) return entries.push(revert("ERC2612InvalidSigner", `stale nonce ${nonce} ≠ ${current}`)); + s.allowances[owner + "→" + spender] = amt; + s.nonces[owner] = current + 1; + return entries.push(emit("Approval", `${owner} → ${spender} · ${amt} (permit, relayed by ${me})`, 0)); } - - // -------- ASSET: updateMultiplier -------- case "updateMultiplier": { - if (!roleHas("OPERATOR_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks OPERATOR_ROLE`)]); - setToken(t => ({ ...t, multiplier: op.value })); - return pushLog([emit("MultiplierUpdated", `${op.value}× (WAD)`, 0)]); + if (!roleHas("OPERATOR_ROLE", me)) return entries.push(revert("AccessControlUnauthorizedAccount", `${me} lacks OPERATOR_ROLE`)); + s.token = { ...s.token, multiplier: op.value }; + return entries.push(emit("MultiplierUpdated", `${op.value}× (WAD)`, 0)); } - - // -------- ASSET: batchMint -------- - case "batchMint": { - const { recipients, amt } = op; - if (!roleHas("MINT_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks MINT_ROLE`)]); - const total = recipients.length * amt; - if (capExceeded(total)) return pushLog([revert("SupplyCapExceeded", `${totalSupply() + total} > cap ${token.supplyCap}`)]); - setBalances(b => { - const nb = { ...b }; - recipients.forEach(r => { nb[r] = (nb[r] || 0) + amt; }); - return nb; - }); - return pushLog(recipients.map((r, i) => emit("Transfer", `0x0 → ${A(r)} · ${amt}`, i))); - } - - // -------- ASSET: announce(batchMint) -------- case "announceBatchMint": { const { recipients, amt, id } = op; - if (!roleHas("OPERATOR_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks OPERATOR_ROLE`)]); - if (!roleHas("MINT_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `inner batchMint needs MINT_ROLE`)]); - const total = recipients.length * amt; - if (capExceeded(total)) return pushLog([revert("SupplyCapExceeded", `${totalSupply() + total} > cap ${token.supplyCap}`)]); - setBalances(b => { - const nb = { ...b }; - recipients.forEach(r => { nb[r] = (nb[r] || 0) + amt; }); - return nb; + if (!roleHas("OPERATOR_ROLE", me)) return entries.push(revert("AccessControlUnauthorizedAccount", `${me} lacks OPERATOR_ROLE`)); + entries.push(emit("Announcement", `id ${id} · dividend batchMint`, 0)); + recipients.forEach((r, i) => { + s.balances[r] = (s.balances[r] || 0) + amt; + entries.push(emit("Transfer", `0x0 → ${r} · ${amt}`, i + 1)); }); - const inner = recipients.map((r, i) => emit("Transfer", `0x0 → ${A(r)} · ${amt}`, i + 1)); - return pushLog([ - emit("Announcement", `id ${id} · scheduled batchMint`, 0), - ...inner, - emit("EndAnnouncement", `id ${id}`, inner.length + 1), - ]); - } - - // -------- ASSET: updateExtraMetadata -------- - case "updateExtraMetadata": { - const { mkey, value } = op; - if (!roleHas("METADATA_ROLE", me)) return pushLog([revert("AccessControlUnauthorizedAccount", `${A(me)} lacks METADATA_ROLE`)]); - setToken(t => { - const em = { ...t.extraMetadata }; - if (value === "") delete em[mkey]; else em[mkey] = value; - return { ...t, extraMetadata: em }; - }); - return pushLog([emit("ExtraMetadataUpdated", value === "" ? `${mkey} (removed)` : `${mkey} = ${value}`, 0)]); + entries.push(emit("EndAnnouncement", `id ${id}`, recipients.length + 1)); + return; } - default: return; } }; - // ====================================================================== - // UI PRIMITIVES - // ====================================================================== - const TrafficLights = () => ( -
- - - -
- ); - - const Dot = ({ color, size }) => ( - - ); - - const Btn = ({ onClick, children, tone, disabled, title }) => { - const [hover, setHover] = useState(false); - const base = tone === "accent" ? c.accent : c.toolBorder; - return ( - - ); - }; - - const Chip = ({ active, onClick, children, dot, disabled }) => { - const [hover, setHover] = useState(false); - return ( - - ); + const runOps = (s, ops) => { + const entries = []; + ops.forEach(op => applyOp(s, op, op.as, entries)); + return entries; }; - const Field = ({ label, children }) => ( -
- {label} - {children} -
- ); - - const TextInput = ({ value, onChange, width, placeholder, mono: isMono }) => ( - onChange(e.target.value)} - placeholder={placeholder} - style={{ - fontFamily: isMono ? mono : sans, fontSize: 12.5, color: c.text, - background: c.inputBg, border: `1px solid ${c.toolBorder}`, borderRadius: 8, - padding: "7px 10px", width: width || 140, outline: "none", - }} - /> - ); - - const Box = ({ title, children, muted, hint }) => ( -
- {title &&
{title}
} - {hint &&
{hint}
} - {children} -
- ); - - const Row = ({ children, wrap }) => ( -
{children}
- ); - - const Pill = ({ on, children }) => ( - {children} - ); - - // ----- account selector row (small) ----- - const AccountPicker = ({ value, onChange, exclude }) => ( - - {ACCOUNTS.filter(a => !exclude || !exclude.includes(a.key)).map(a => ( - onChange(a.key)}>{a.label} - ))} - - ); - // ====================================================================== - // TOKEN SUMMARY STRIP + // Flow definitions. Each step: plain-language situation → one button → + // visible consequence. API names appear in results, never in the ask. // ====================================================================== - const derived = token ? deriveAddress(token.variant, token.deployer, token.salt) : null; - - const AddressRender = ({ d }) => ( - - 0x{d.prefix.slice(0, 8)}… - {d.vByte} - {d.tail.slice(0, 4)}…{d.tail.slice(-4)} - - ); - - const SummaryStrip = () => { - if (!token) return null; - return ( -
-
- {token.name} - {token.symbol} - {token.variant} - - {token.currency && currency() = {token.currency}} -
-
- - totalSupply {totalSupply()} / cap {token.supplyCap === null ? "∞" : token.supplyCap} - - {token.variant === "ASSET" && multiplier() {token.multiplier}×} - - {["TRANSFER", "MINT", "BURN"].map(f => {f}{paused[f] ? " ⏸" : ""})} - - - {adminLess() - ? admin-less - : <>admin: {(roles["DEFAULT_ADMIN_ROLE"] || []).map(a => acctOf(a).label).join(", ") || "—"}} - -
-
- ); + const FLOWS = { + seize: { + title: "Freeze and seize, as compliance sees it", + erc20: "On plain ERC-20: you build and audit a custom blocklist token.", + readout: "balances", + steps: [ + { + label: "Bob holds 50 aUSD.", + action: "Mint to Bob", + run: (s) => { + if (!s.token) createInSim(s, "STABLECOIN"); + return { entries: runOps(s, [{ as: "Issuer", type: "mint", to: "Bob", amt: 50 }]) }; + }, + }, + { + label: "A court order arrives. Freeze Bob's account.", + action: "Freeze account", + run: (s) => ({ + entries: runOps(s, [{ as: "Issuer", type: "freeze", target: "Bob" }]), + caption: "Bob can no longer send. No one else is affected.", + }), + }, + { + label: "Bob tries to pay Alice anyway.", + action: "Attempt payment", + run: (s) => ({ + entries: runOps(s, [{ as: "Bob", type: "transfer", from: "Bob", to: "Alice", amt: 10 }]), + caption: "Blocked by the protocol, not by custom contract code.", + }), + }, + { + label: "Seize the frozen balance.", + action: "Seize funds", + run: (s) => ({ + entries: runOps(s, [{ as: "Issuer", type: "burnBlocked", target: "Bob", amt: 50 }]), + caption: "Seizure only works on a frozen account. It can't skip the freeze.", + }), + }, + ], + }, + + memo: { + title: "An invoice paid and matched", + erc20: "On plain ERC-20: transfers carry no reference, so you run a deposit address per customer.", + readout: null, + steps: [ + { + label: "Alice has 100 aUSD to spend.", + action: "Fund Alice", + run: (s) => { + if (!s.token) createInSim(s, "STABLECOIN"); + return { entries: runOps(s, [{ as: "Issuer", type: "mint", to: "Alice", amt: 100 }]) }; + }, + }, + { + label: "Alice pays invoice #8842. The reference rides in the payment.", + action: "Pay 25 aUSD", + run: (s) => ({ + entries: runOps(s, [{ as: "Alice", type: "transfer", from: "Alice", to: "Processor", amt: 25, memo: "invoice-8842" }]), + caption: "One transaction: payment and reference.", + }), + }, + { + label: "The back office matches the payment to the order.", + action: "Match payment", + run: () => ({ + entries: [ + info("query", "find Transfer at (txHash, Memo.logIndex − 1)"), + info("matched", '"invoice-8842" → 25 aUSD from Alice ✓'), + ], + caption: "Reconciliation is one log query, not a deposit address per customer.", + }), + }, + ], + }, + + permit: { + title: "A first-time user, zero ETH", + erc20: "On plain ERC-20: the user buys ETH and sends an onchain approve first.", + readout: "allowance", + steps: [ + { + label: "Alice just signed up. She has aUSD and zero ETH for gas.", + action: "Fund with aUSD", + run: (s) => { + if (!s.token) createInSim(s, "STABLECOIN"); + return { entries: runOps(s, [{ as: "Issuer", type: "mint", to: "Alice", amt: 100 }]) }; + }, + }, + { + label: "Alice signs an approval offchain. It costs her nothing.", + action: "Sign approval", + run: (s) => { + s.sig = { nonce: s.nonces["Alice"] || 0 }; + return { + entries: [ + info("signed payload", `owner Alice · spender Processor · value 100 · nonce ${s.sig.nonce}`), + info("cost to Alice", "0 gas, nothing sent onchain"), + ], + }; + }, + }, + { + label: "The platform relays her signature and collects the payment.", + action: "Relay & collect", + run: (s) => ({ + entries: runOps(s, [ + { as: "Processor", type: "permit", owner: "Alice", spender: "Processor", amt: 100, nonce: s.sig ? s.sig.nonce : 0 }, + { as: "Processor", type: "transferFrom", from: "Alice", to: "Processor", amt: 40 }, + ]), + caption: "Alice never sent a transaction. The nonce blocks replay.", + }), + }, + ], + }, + + equity: { + title: "A share of stock, onchain", + erc20: "On plain ERC-20: a rebasing token is a custom contract, and disclosures live offchain.", + readout: "scaled", + steps: [ + { + label: "ACME lists onchain with its security identifiers.", + action: "Create token", + run: (s) => { + createInSim(s, "EQUITY"); + return { + entries: [ + info("createB20", `asset · ACME · ${tokenAddress("ASSET", "ACME")}`), + info("initCalls", 'updateExtraMetadata("cusip", "38259P508")'), + ], + caption: "One factory call, with no contract to write or audit.", + }; + }, + }, + { + label: "Shareholders hold ACME.", + action: "Distribute shares", + run: (s) => ({ + entries: runOps(s, [ + { as: "Issuer", type: "mint", to: "Alice", amt: 100 }, + { as: "Issuer", type: "mint", to: "Bob", amt: 50 }, + ]), + }), + }, + { + label: "The board declares a 2-for-1 split.", + action: "Run the split", + run: (s) => ({ + entries: runOps(s, [{ as: "Issuer", type: "updateMultiplier", value: 2.0 }]), + caption: "Every balance doubles in one call, without a migration or a new contract.", + }), + }, + { + label: "A dividend goes out with public disclosure.", + action: "Announce & distribute", + run: (s) => ({ + entries: runOps(s, [{ as: "Issuer", type: "announceBatchMint", recipients: ["Alice", "Bob"], amt: 10, id: 7 }]), + caption: "Disclosure and distribution land in the same transaction.", + }), + }, + ], + }, }; - // ====================================================================== - // TABS - // ====================================================================== - const TABS = ["Create", "Policies", "Freeze & seize", "Roles", "Supply", "Pause", "Memos", "Permit"]; + const f = FLOWS[flow] || FLOWS.seize; - // ----- balances mini-table used in several tabs ----- - const BalanceTable = ({ scaled }) => ( -
- {ACCOUNTS.map(a => ( - - {a.label} - {balances[a.key] || 0} - {scaled && token && token.variant === "ASSET" && ( - ({((balances[a.key] || 0) * token.multiplier).toFixed(2)}) - )} - - ))} -
- ); + // ----- widget state ----- + const [sim, setSim] = useState(freshSim); + const [results, setResults] = useState([]); // one entry per completed step + const stepIndex = results.length; + const done = stepIndex >= f.steps.length; - // ---------- CREATE TAB ---------- - const CreateTab = () => { - if (token) { + const runStep = () => { + if (done) return; + const s = cloneSim(sim); + const out = f.steps[stepIndex].run(s); + setSim(s); + setResults(r => [...r, out]); + }; + const reset = () => { setSim(freshSim()); setResults([]); }; + + // ----- flow-specific live readout ----- + const Readout = () => { + if (!f.readout || !sim.token) return null; + if (f.readout === "allowance") { return ( -
- -
-
Variant: {token.variant} · decimals {token.decimals}{token.currency ? <> · currency {token.currency} : null}
-
Address:
-
prefix | variant | keccak256(deployer, salt) — hash simulated
-
-
- Reset playground +
+ allowance(Alice → Processor) {sim.allowances["Alice→Processor"] || 0} + nonce(Alice) {sim.nonces["Alice"] || 0}
); } - const dprefix = deriveAddress(cVariant, acctOf(actingAs).addr, cSalt); + const scaled = f.readout === "scaled"; + const frozen = f.readout === "balances" && sim.scopes.TRANSFER_SENDER !== 0; + const holders = Object.keys(sim.balances).length ? Object.keys(sim.balances) : []; + if (!holders.length) return null; return ( -
- - - setCVariant("STABLECOIN")}>Stablecoin (6 decimals) - setCVariant("ASSET")}>Asset (6–18 decimals) - - - -
- - - {cVariant === "ASSET" ? ( - - - {[6, 8, 12, 18].map(d => setCDecimals(d)}>{d})} - - - ) : ( - setCCurrency(v.replace(/[^A-Za-z]/g, "").toUpperCase().slice(0, 4))} width={70} /> +
+ {holders.map(a => ( + + + {a} {sim.balances[a] || 0} + {scaled && sim.token.multiplier !== 1 && ( + → {((sim.balances[a] || 0) * sim.token.multiplier).toFixed(0)} scaled )} -
-
- setCInitialAdminZero(!cInitialAdminZero)}> - initialAdmin == address(0) — launch admin-less - -
- - - - -
-
-
-
- createB20({cVariant.toLowerCase()}, …) - deployer = {acctOf(actingAs).label} (acting-as) -
-
- ); - }; - - // ---------- POLICIES TAB ---------- - const PoliciesTab = () => ( -
- - - execute({ type: "createPolicy", policyType: "ALLOWLIST" })}>createPolicy(ALLOWLIST) - execute({ type: "createPolicy", policyType: "BLOCKLIST" })}>createPolicy(BLOCKLIST) - -
- {Object.keys(policies).length === 0 && No custom policies yet.} - {Object.entries(policies).map(([id, p]) => ( -
-
- #{id} · {p.type} - · default {p.type === "BLOCKLIST" ? "authorized" : "denied"} -
- - {ACCOUNTS.map(a => ( - execute({ type: "togglePolicyMember", policyId: Number(id), account: a.key })}> - {a.label} - - ))} - -
- ))} -
-
- - - {SCOPES.map(s => ( -
- {s} - execute({ type: "updatePolicy", scope: s, policyId: 0 })}>ALWAYS_ALLOW - execute({ type: "updatePolicy", scope: s, policyId: ALWAYS_BLOCK })}>ALWAYS_BLOCK - {Object.keys(policies).map(id => ( - execute({ type: "updatePolicy", scope: s, policyId: Number(id) })}>#{id} - ))} -
+ {frozen && a === "Bob" && ( + frozen + )} + ))} -
- - - - execute({ type: "transfer", from: "Alice", to: "Bob", amt: 10 })}>transfer Alice→Bob (10) - { execute({ type: "approve", owner: "Alice", spender: "Processor", amt: 10 }); execute({ type: "transferFrom", from: "Alice", to: "Bob", amt: 10 }); }}>transferFrom Processor (Alice→Bob) - execute({ type: "mint", to: "Carol", amt: 10 })}>mint → Carol (10) - -
-
-
- ); - - // ---------- FREEZE & SEIZE TAB ---------- - const FreezeTab = () => { - const blocklistId = Object.entries(policies).find(([, p]) => p.type === "BLOCKLIST" && p.members.includes("Bob")); - const bound = blocklistId && scopes.TRANSFER_SENDER === Number(blocklistId[0]); - return ( -
- -
-
-
Step 1 — freeze: create a blocklist with Bob and bind it to TRANSFER_SENDER
- { - const id = nextPolicyId; - execute({ type: "createPolicy", policyType: "BLOCKLIST" }); - execute({ type: "togglePolicyMember", policyId: id, account: "Bob" }); - execute({ type: "updatePolicy", scope: "TRANSFER_SENDER", policyId: id }); - }}>Freeze Bob (create + block + bind) -
-
-
Step 2 — Bob attempts a transfer → PolicyForbids
- execute({ type: "transfer", from: "Bob", to: "Alice", amt: 5 })}>transfer Bob→Alice (5) -
-
-
Step 3 — seize: grant BURN_BLOCKED_ROLE, then burnBlocked(Bob)
- - execute({ type: "grantRole", role: "BURN_BLOCKED_ROLE", account: actingAs })}>grantRole BURN_BLOCKED_ROLE → {acctOf(actingAs).label} - execute({ type: "burnBlocked", target: "Bob", amt: 5 })}>burnBlocked(Bob, 5) - -
-
-
- - - execute({ type: "burnBlocked", target: "Alice", amt: 5 })}>burnBlocked(Alice) — not blocked - { execute({ type: "revokeRole", role: "BURN_BLOCKED_ROLE", account: actingAs }); execute({ type: "burnBlocked", target: "Bob", amt: 5 }); }}>burnBlocked without role - - -
- {bound ? "Bob is currently frozen (denied by TRANSFER_SENDER)." : "Bob is not yet frozen."} -
- -
- ); - }; - - // ---------- ROLES TAB ---------- - const RolesTab = () => { - const allRoles = Object.keys(roles); - const gatedTry = { - MINT_ROLE: { label: "mint → Carol (5)", op: { type: "mint", to: "Carol", amt: 5 } }, - BURN_ROLE: { label: "burn (5)", op: { type: "burn", amt: 5 } }, - PAUSE_ROLE: { label: "pause TRANSFER", op: { type: "pause", feature: "TRANSFER" } }, - UNPAUSE_ROLE: { label: "unpause TRANSFER", op: { type: "unpause", feature: "TRANSFER" } }, - }; - const [mkey, setMkey] = useState("prospectus"); - const [mval, setMval] = useState("ipfs://Qm…"); - const [emKey, setEmKey] = useState("cusip"); - const [emVal, setEmVal] = useState("38259P508"); - return ( -
- -
- - - - - {ACCOUNTS.map(a => )} - - - - {allRoles.map(r => ( - - - {ACCOUNTS.map(a => { - const has = roleHas(r, a.key); - return ( - - ); - })} - - ))} - -
role{a.label}
{r} - execute(has - ? { type: "revokeRole", role: r, account: a.key } - : { type: "grantRole", role: r, account: a.key })}> - {has ? "✓" : "+"} - -
-
-
- - - - {Object.entries(gatedTry).map(([role, g]) => execute(g.op)}>{g.label})} - - - - - - execute({ type: "updateName", value: token.name + " v2" })}>updateName - execute({ type: "updateSymbol", value: token.symbol + "x" })}>updateSymbol - execute({ type: "updateContractURI", value: "ipfs://Qm…/contract.json" })}>updateContractURI - - - - - - execute({ type: "renounceRole", role: "DEFAULT_ADMIN_ROLE" })}>renounceRole(DEFAULT_ADMIN_ROLE) - execute({ type: "renounceLastAdmin" })}>renounceLastAdmin() - execute({ type: "grantRole", role: "MINT_ROLE", account: "Carol" })}>grantRole MINT_ROLE → Carol - execute({ type: "mint", to: "Alice", amt: 5 })}>existing MINT_ROLE still mints - - - - {token && token.variant === "ASSET" && ( - - - - -
execute({ type: "updateExtraMetadata", mkey: emKey, value: emVal })}>updateExtraMetadata
-
-
- {Object.keys(token.extraMetadata).length === 0 ? "extraMetadata: {}" : Object.entries(token.extraMetadata).map(([k, v]) => {k}={v})} -
-
- )} -
- ); - }; - - // ---------- SUPPLY TAB ---------- - const SupplyTab = () => { - const [mintTo, setMintTo] = useState("Alice"); - const [mintAmt, setMintAmt] = useState(100); - const [capVal, setCapVal] = useState(1000); - return ( -
- - - - - - {[10, 50, 100, 500].map(v => setMintAmt(v)}>{v})} - execute({ type: "mint", to: mintTo, amt: mintAmt })}>mint({acctOf(mintTo).label}, {mintAmt}) - -
-
- - - - {[500, 1000, 5000].map(v => setCapVal(v)}>{v})} - execute({ type: "updateSupplyCap", newCap: capVal })}>updateSupplyCap({capVal}) - execute({ type: "updateSupplyCap", newCap: null })}>updateSupplyCap(no cap) - -
- Set the cap below totalSupply ({totalSupply()}) to see InvalidSupplyCap; mint past it to see SupplyCapExceeded. -
-
- - - - {[10, 50].map(v => execute({ type: "burn", amt: v })}>burn({v}) as {acctOf(actingAs).label})} - - - - {token && token.variant === "ASSET" && ( - <> - - - {[1.0, 1.05, 1.25, 0.9].map(v => execute({ type: "updateMultiplier", value: v })}>{v}×)} - -
raw (scaled) shown side by side:
-
-
- - - execute({ type: "batchMint", recipients: ["Alice", "Bob", "Carol"], amt: 25 })}>batchMint([Alice,Bob,Carol], 25) - execute({ type: "announceBatchMint", recipients: ["Alice", "Bob", "Carol"], amt: 25, id: 1 })}>announce(batchMint…) (OPERATOR_ROLE) - - - + {scaled && sim.token.multiplier !== 1 && ( + multiplier() = {sim.token.multiplier}× )}
); }; - // ---------- PAUSE TAB ---------- - const PauseTab = () => ( -
- - {["TRANSFER", "MINT", "BURN"].map(f => ( -
- {f} - {paused[f] ? "paused" : "live"} - execute({ type: "pause", feature: f })} disabled={paused[f]}>pause - execute({ type: "unpause", feature: f })} disabled={!paused[f]}>unpause -
- ))} -
- - - execute({ type: "mint", to: "Alice", amt: 10 })}>mint → Alice (10) - execute({ type: "transfer", from: "Alice", to: "Bob", amt: 5 })}>transfer Alice→Bob (5) - execute({ type: "approve", owner: "Alice", spender: "Processor", amt: 20 })}>approve (never pause-gated) - - -
- ); - - // ---------- MEMOS TAB ---------- - const MemosTab = () => { - const [memo, setMemo] = useState("invoice-8842"); - const asHex = "0x" + Array.from(memo).map(ch => ch.charCodeAt(0).toString(16).padStart(2, "0")).join("").slice(0, 20).padEnd(20, "0") + "…"; - return ( -
- - - -
{asHex}
-
-
- - execute({ type: "transfer", from: "Alice", to: "Bob", amt: 10, memo })}>transferWithMemo Alice→Bob (10) - execute({ type: "mint", to: "Carol", amt: 10, memo })}>mintWithMemo → Carol (10) - execute({ type: "burn", amt: 10, memo })}>burnWithMemo (10) as {acctOf(actingAs).label} - -
-
- - - -
- ); - }; - - const MemoJoinView = () => { - const pairs = []; - for (let i = 0; i < eventLog.length - 1; i++) { - const a = eventLog[i], b = eventLog[i + 1]; - if (b.kind === "event" && b.name === "Memo" && b.logIndex === (a.logIndex + 1) && a.kind === "event") { - pairs.push([a, b]); - } - } - if (pairs.length === 0) return Fire a *WithMemo op to see the join.; - return ( -
- {pairs.slice(-4).map(([a, b], i) => ( -
-
-
-
[{a.logIndex}] {a.name} {a.args}
-
[{b.logIndex}] {b.name} {b.args} ← join on logIndex − 1
-
-
- ))} -
- ); - }; - - // ---------- PERMIT TAB ---------- - const PermitTab = () => { - const [pOwner, setPOwner] = useState("Alice"); - const [pSpender, setPSpender] = useState("Processor"); - const [pValue, setPValue] = useState(100); - const [signed, setSigned] = useState(null); - const ownerNonce = nonces[pOwner] || 0; - const key = pOwner + "→" + pSpender; - return ( -
- - - - - - - - - {[50, 100, 250].map(v => setPValue(v)}>{v})} - setSigned({ owner: pOwner, spender: pSpender, value: pValue, nonce: ownerNonce })}>Sign permit - - {signed && ( -
-
✎ signed payload
- owner: {acctOf(signed.owner).label} · spender: {acctOf(signed.spender).label}
- value: {signed.value} · nonce: {signed.nonce} · deadline: ∞
- v,r,s: 0x{(signed.nonce * 7 + 12).toString(16)}…ecdsa -
- )} -
- - - - signed && execute({ type: "permit", owner: signed.owner, spender: signed.spender, amt: signed.value, nonce: signed.nonce })}> - Submit permit as {acctOf(actingAs).label} - - signed && execute({ type: "permit", owner: signed.owner, spender: signed.spender, amt: signed.value, nonce: signed.nonce })}> - Replay same signature (stale nonce → revert) - - -
- allowance({acctOf(pOwner).label} → {acctOf(pSpender).label}) = {allowances[key] || 0} - nonce({acctOf(pOwner).label}) = {ownerNonce} -
-
-
- ); - }; - - // ----- tab dispatcher ----- - const renderTab = () => { - if (activeTab === "Create") return ; - if (!token) return
Create a token first.
; - if (activeTab === "Policies") return ; - if (activeTab === "Freeze & seize") return ; - if (activeTab === "Roles") return ; - if (activeTab === "Supply") return ; - if (activeTab === "Pause") return ; - if (activeTab === "Memos") return ; - if (activeTab === "Permit") return ; - return null; - }; - - // ====================================================================== - // RENDER - // ====================================================================== return ( -
+
- {/* Header bar */} -
- - B20 Playground - simulated · client-side + {/* Header */} +
+ {f.title} + SIMULATED
- + {results.length > 0 && }
- {/* Token summary strip */} - - - {/* Acting-as selector */} -
- acting as (msg.sender): - {ACCOUNTS.map(a => ( - setActingAs(a.key)}>{a.label} - ))} -
- - {/* Tab bar */} -
- {TABS.map(t => { - const disabled = t !== "Create" && !token; - const active = activeTab === t; + {/* Steps */} +
+ {f.steps.map((step, i) => { + const state = i < stepIndex ? "done" : i === stepIndex ? "now" : "future"; + const res = results[i]; return ( - +
+
+ + {state === "done" ? "✓" : i + 1} + + + {step.label} + + {state !== "done" && ( + + )} +
+ {res && ( +
+ {res.entries.map((e, j) => ( +
+ + {e.kind === "event" && } + {e.kind === "revert" && } + {e.kind === "info" && } + + + {e.logIndex !== null && e.logIndex !== undefined && [{e.logIndex}] } + {e.name} + {e.args && · {e.args}} + +
+ ))} + {res.caption && ( +
{res.caption}
+ )} +
+ )} +
); })} -
- {/* Tab body */} -
- {renderTab()} + {/* Live readout */} + {(f.readout && Object.keys(sim.balances).length > 0) && ( +
+ +
+ )}
- {/* Event log */} -
-
- EVENT LOG - {eventLog.length > 0 && ( - - )} -
-
- {eventLog.length === 0 &&
Emitted events and reverts appear here.
} - {eventLog.map(e => ( -
- - {e.logIndex !== null && e.logIndex !== undefined ? e.logIndex : "·"} - - - {e.kind === "event" && } - {e.kind === "revert" && } - {e.kind === "info" && } - - - {e.name} - {e.args && · {e.args}} - -
- ))} -
+ {/* Footer */} +
+ {f.erc20} +
+ {done && ✓ flow complete}
); From 1fad0f62455d2ed7fc8ebbbbcb7dd7eeffa22265 Mon Sep 17 00:00:00 2001 From: Soheima M Date: Wed, 22 Jul 2026 14:29:03 +0200 Subject: [PATCH 4/6] updated sentence for b20 --- docs/base-chain/specs/upgrades/beryl/b20-playground.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx b/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx index 914643f9d..5af7000e7 100644 --- a/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx +++ b/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx @@ -17,7 +17,7 @@ Use B20 when: - Your integrators matter. Exchanges, wallets, and indexers wire up one surface that behaves the same across every B20 token. - Cost matters. B20 runs as a native precompile: cheaper and faster than a contract token. -Stick with a plain ERC-20 if you need transfer logic B20 doesn't model (fee-on-transfer, custom hooks) or you're deploying off Base. +B20 is a superset of ERC-20: every ERC-20 call works unchanged, so there's no compatibility trade-off in choosing it. ## A compliance order arrives From 5e109d8c556b8621a5245b287f77cda1e47b92c0 Mon Sep 17 00:00:00 2001 From: sohey Date: Wed, 22 Jul 2026 18:33:03 +0200 Subject: [PATCH 5/6] updated --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index ae14091fc..6ab682576 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "base-docs", + "name": "docs", "lockfileVersion": 3, "requires": true, "packages": {} From aca5509c9105b08f4e6b00b5203b177bd277d74d Mon Sep 17 00:00:00 2001 From: sohey Date: Wed, 22 Jul 2026 18:48:07 +0200 Subject: [PATCH 6/6] changed position within docs architecture --- docs/base-chain/specs/upgrades/beryl/b20-playground.mdx | 5 ----- docs/docs.json | 6 +++--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx b/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx index 5af7000e7..88d70ca4d 100644 --- a/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx +++ b/docs/base-chain/specs/upgrades/beryl/b20-playground.mdx @@ -5,14 +5,9 @@ description: "Try B20's issuer flows in your browser: freeze-and-seize, payment import { B20FlowDemo } from "/snippets/B20PlaygroundDemo.jsx" - -The walkthroughs on this page simulate [B20](/base-chain/specs/upgrades/beryl/b20) semantics entirely client-side. Nothing touches a chain or wallet. - ## When to use B20 -Use B20 when: - - You need compliance, reconciliation, or corporate-action machinery. It ships as part of the standard, so you don't build and audit a custom contract. - Your integrators matter. Exchanges, wallets, and indexers wire up one surface that behaves the same across every B20 token. - Cost matters. B20 runs as a native precompile: cheaper and faster than a contract token. diff --git a/docs/docs.json b/docs/docs.json index 5607db4f9..9387e4ea1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -102,7 +102,8 @@ "group": "Beryl Upgrade", "tag": "New", "pages": [ - "base-chain/specs/upgrades/beryl/overview" + "base-chain/specs/upgrades/beryl/overview", + "base-chain/specs/upgrades/beryl/b20-playground" ] }, { @@ -263,8 +264,7 @@ "group": "Beryl", "pages": [ "base-chain/specs/upgrades/beryl/overview", - "base-chain/specs/upgrades/beryl/b20", - "base-chain/specs/upgrades/beryl/b20-playground" + "base-chain/specs/upgrades/beryl/b20" ] }, {