From 10ea2f61bd795f2e84666f9bd8458576ae24226e Mon Sep 17 00:00:00 2001 From: Soheima M Date: Thu, 20 Aug 2026 15:27:07 +0200 Subject: [PATCH 1/2] added stablecoin gas demo --- app/analytics/events.ts | 6 +- app/demos/b20/B20Demo.tsx | 524 +++++++++++++---- app/demos/b20/components/Activity.tsx | 6 +- .../b20/components/AnnouncementModule.tsx | 8 +- app/demos/b20/components/AttachPolicy.tsx | 6 +- app/demos/b20/components/CreatePolicy.tsx | 2 +- app/demos/b20/components/DeployModule.tsx | 140 +++-- app/demos/b20/components/MemoHistory.tsx | 27 +- app/demos/b20/components/MemoModule.tsx | 158 +++++- app/demos/b20/components/PolicyModule.tsx | 12 +- app/demos/b20/lib/constants.ts | 4 +- app/demos/b20/lib/protocol.ts | 14 + app/demos/b20/lib/wallet8130.test.ts | 95 ++++ app/demos/b20/lib/wallet8130.ts | 525 ++++++++++++++++++ app/demos/catalogue.ts | 8 +- vitest.config.ts | 6 + 16 files changed, 1349 insertions(+), 192 deletions(-) create mode 100644 app/demos/b20/lib/wallet8130.test.ts create mode 100644 app/demos/b20/lib/wallet8130.ts diff --git a/app/analytics/events.ts b/app/analytics/events.ts index bd02e87..48dbc7e 100644 --- a/app/analytics/events.ts +++ b/app/analytics/events.ts @@ -46,8 +46,10 @@ export function trackB20ModuleSelect(module: string): void { track('b20_module_select', { module }); } -export function trackB20WalletConnection(status: 'started' | 'success' | 'error'): void { - track('b20_wallet_connection', { status }); +// The demo mints its wallet locally (EIP-8130 smart account) — this tracks key +// generation, not an injected-wallet connect, hence the distinct event name. +export function trackB20WalletCreation(status: 'started' | 'success' | 'error'): void { + track('b20_wallet_creation', { status }); } export function trackB20Action( diff --git a/app/demos/b20/B20Demo.tsx b/app/demos/b20/B20Demo.tsx index 34d3d65..74deb52 100644 --- a/app/demos/b20/B20Demo.tsx +++ b/app/demos/b20/B20Demo.tsx @@ -1,34 +1,27 @@ 'use client'; -import Link from 'next/link'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { formatEther, isAddress, type Address, type Hex } from 'viem'; -import { trackB20Action, trackB20ModuleSelect, trackB20WalletConnection } from '../../analytics/events'; +import { trackB20Action, trackB20ModuleSelect, trackB20WalletCreation } from '../../analytics/events'; +import { AnimatedAmount } from '../_components/AnimatedAmount'; import { Button } from '../../components/ui/Button'; import { cn } from '../../components/ui/cn'; +import { Spinner } from '../../components/ui/Spinner'; import { Tabs } from '../../components/ui/Tabs'; import { textVariantClasses } from '../../components/ui/Text'; import { CopyableValue } from '../../vibenet/components/CopyableValue'; -import { VIBENET_EXPLORER_PATH, VIBENET_RPC_URL } from '../../vibenet/library/config'; -import { - addEthereumChain, - getChainId, - getEthereum, - isUnrecognizedChain, - isUserRejection, - switchEthereumChain, - walletErrorMessage, -} from '../../vibenet/library/wallet'; +import { walletErrorMessage } from '../../vibenet/library/wallet'; import { Activity } from './components/Activity'; import { AnnouncementModule, SampleAnnouncementViewer } from './components/AnnouncementModule'; import { DeployModule } from './components/DeployModule'; import { MemoModule } from './components/MemoModule'; import { PolicyModule } from './components/PolicyModule'; -import { client, CHAIN_ID, MODULES } from './lib/constants'; +import { client, MODULES } from './lib/constants'; import { b20Abi, b20Variant, + formatAmount, B20_FACTORY, DEFAULT_ADMIN_ROLE, factoryAbi, @@ -41,6 +34,30 @@ import { } from './lib/protocol'; import { readRecent, readRecentPolicies, writeRecent, writeRecentPolicy } from './lib/recent'; import { sampleTokenForAddress } from './lib/samples'; +import { + clearPayer, + clearWallet, + createPayer, + createWallet, + getEthBalance, + loadPayer, + loadWallet, + payerAddress, + payerErrorMessage, + savePayer, + saveWallet, + seedWithEth, + sendSponsored8130, + sendSponsoredBatches, + tokenGasFee, + useDeployment, + walletAddress, + type SendMode, + type SponsoredBatch, + type SponsoredCall, + type StoredB20Payer, + type StoredB20Wallet, +} from './lib/wallet8130'; import type { ActivityItem, CreatedToken, @@ -51,10 +68,29 @@ import type { TokenInfo, } from './lib/types'; +// Retry schedule for reads that race a just-confirmed transaction: the public +// RPC is load-balanced across replicas whose heads differ, so read at t=0 and +// again as state settles. Reads are pinned to a fresh block so lagging replicas +// error instead of answering stale; a success is authoritative and errors never +// downgrade a previous success. +const READ_RETRY_MS = [0, 2_500, 6_000]; + +function annotateMode(label: string, mode: SendMode, symbol?: string): string { + if (mode === 'token' && symbol) return `${label} · paid in ${symbol}`; + if (mode === 'self') return `${label} · self-paid`; + return label; +} + export function B20Demo() { const [module, setModule] = useState('policy'); - const [wallet, setWallet] = useState
(null); + const [storedWallet, setStoredWallet] = useState(null); + const [storedPayer, setStoredPayer] = useState(null); const [walletBalance, setWalletBalance] = useState(null); + const [tokenBalance, setTokenBalance] = useState(null); + // Which token the shown balance belongs to (lowercased address). + const balanceForToken = useRef(null); + const [gasMode, setGasMode] = useState<'sponsored' | 'token'>('sponsored'); + const [resetConfirm, setResetConfirm] = useState(false); const [recent, setRecent] = useState([]); const [recentPolicies, setRecentPolicies] = useState([]); const [tokenAddress, setTokenAddress] = useState(''); @@ -64,6 +100,7 @@ export function B20Demo() { const [checks, setChecks] = useState | null>(null); const [activity, setActivity] = useState([]); const [busy, setBusy] = useState(null); + const [batchProgress, setBatchProgress] = useState<{ label: string; index: number; total: number } | null>(null); const [isOperator, setIsOperator] = useState(false); const [isTokenAdmin, setIsTokenAdmin] = useState(false); const [tokenAdminLoading, setTokenAdminLoading] = useState(false); @@ -72,27 +109,83 @@ export function B20Demo() { // tabs and coming back to Native Deployment. const [created, setCreated] = useState(null); + // Live EIP-8130 system-contract addresses. The wallet address is derived from + // these, so it can shift once the fetch lands (and after a devnet reset). + const deployment = useDeployment(); + const wallet = useMemo
( + () => (storedWallet ? walletAddress(storedWallet, deployment) : null), + [storedWallet, deployment], + ); + const refreshWallet = useCallback(async (account: Address | null) => { if (!account) return; - const balance = await client.getBalance({ address: account }).catch(() => null); - setWalletBalance(balance); setRecent(readRecent(account)); setRecentPolicies(readRecentPolicies(account)); + setWalletBalance(await getEthBalance(account)); + }, []); + + useEffect(() => { + setStoredWallet(loadWallet()); + setStoredPayer(loadPayer()); }, []); useEffect(() => { - const eth = getEthereum(); - if (!eth) return; - eth - .request({ method: 'eth_accounts' }) - .then((value) => { - const account = - Array.isArray(value) && typeof value[0] === 'string' && isAddress(value[0]) ? (value[0] as Address) : null; - setWallet(account); - void refreshWallet(account); - }) - .catch(() => {}); - }, [refreshWallet]); + void refreshWallet(wallet); + }, [wallet, refreshWallet]); + + // The chip shows "funding…" until the faucet seed lands. A single balance + // read isn't enough: the drip takes a few seconds and the load-balanced RPC + // can serve a stale replica — poll until a non-zero balance shows up. + useEffect(() => { + if (!wallet) return; + let cancelled = false; + const poll = window.setInterval(() => { + void getEthBalance(wallet).then((balance) => { + if (cancelled || balance === null) return; + setWalletBalance(balance); + if (balance > 0n) window.clearInterval(poll); + }); + }, 2_000); + const stop = window.setTimeout(() => window.clearInterval(poll), 60_000); + return () => { + cancelled = true; + window.clearInterval(poll); + window.clearTimeout(stop); + }; + }, [wallet]); + + // The wallet's holding of the active token, shown in the header chip so the + // initial mint (and every transfer) is visible. Keyed on the `token` object, + // which is re-fetched after every send — so this re-reads automatically. + useEffect(() => { + let cancelled = false; + if (!token || !wallet || sampleTokenForAddress(token.address)) { + setTokenBalance(null); + balanceForToken.current = null; + return; + } + // Switching to a different token invalidates the shown balance; refreshes + // of the same token keep it on screen (no flash) until the new read lands. + if (balanceForToken.current !== token.address.toLowerCase()) { + balanceForToken.current = token.address.toLowerCase(); + setTokenBalance((previous) => (previous === 0n ? previous : null)); + } + const read = () => + client + .getBlockNumber({ cacheTime: 0 }) + .then((blockNumber) => + client.readContract({ address: token.address, abi: b20Abi, functionName: 'balanceOf', args: [wallet], blockNumber }), + ) + .then((balance) => { + if (!cancelled) setTokenBalance(balance); + }) + .catch(() => {}); + const timers = READ_RETRY_MS.map((delay) => window.setTimeout(() => void read(), delay)); + return () => { + cancelled = true; + timers.forEach((timer) => window.clearTimeout(timer)); + }; + }, [token, wallet]); // Operator status is a function of (token address, wallet) only. send() // re-inspects the token after every tx, which yields a fresh `token` object @@ -104,21 +197,26 @@ export function B20Demo() { let cancelled = false; setIsOperator(false); if (!activeTokenAddress || !wallet || sampleTokenForAddress(activeTokenAddress)) return; - client - .readContract({ - address: activeTokenAddress, - abi: b20Abi, - functionName: 'hasRole', - args: [roleId('OPERATOR_ROLE'), wallet], - }) - .then((allowed) => { - if (!cancelled) setIsOperator(allowed); - }) - .catch(() => { - if (!cancelled) setIsOperator(false); - }); + const read = () => + client + .getBlockNumber({ cacheTime: 0 }) + .then((blockNumber) => + client.readContract({ + address: activeTokenAddress, + abi: b20Abi, + functionName: 'hasRole', + args: [roleId('OPERATOR_ROLE'), wallet], + blockNumber, + }), + ) + .then((allowed) => { + if (!cancelled && allowed) setIsOperator(true); + }) + .catch(() => {}); + const timers = READ_RETRY_MS.map((delay) => window.setTimeout(() => void read(), delay)); return () => { cancelled = true; + timers.forEach((timer) => window.clearTimeout(timer)); }; }, [activeTokenAddress, wallet]); @@ -134,77 +232,129 @@ export function B20Demo() { return; } setTokenAdminLoading(true); - client - .readContract({ - address: activeTokenAddress, - abi: b20Abi, - functionName: 'hasRole', - args: [DEFAULT_ADMIN_ROLE, wallet], - }) - .then((allowed) => { - if (!cancelled) setIsTokenAdmin(allowed); - }) - .catch(() => { - if (!cancelled) setIsTokenAdmin(false); - }) - .finally(() => { - if (!cancelled) { + const lastDelay = READ_RETRY_MS[READ_RETRY_MS.length - 1]; + const read = (delay: number) => + client + .getBlockNumber({ cacheTime: 0 }) + .then((blockNumber) => + client.readContract({ + address: activeTokenAddress, + abi: b20Abi, + functionName: 'hasRole', + args: [DEFAULT_ADMIN_ROLE, wallet], + blockNumber, + }), + ) + .then((allowed) => { + if (cancelled) return; + if (allowed) setIsTokenAdmin(true); setTokenAdminLoading(false); setTokenAdminCheckedFor(checkKey); - } - }); + }) + .catch(() => { + // Keep "checking" until the final attempt fails too. + if (!cancelled && delay === lastDelay) { + setTokenAdminLoading(false); + setTokenAdminCheckedFor(checkKey); + } + }); + const timers = READ_RETRY_MS.map((delay) => window.setTimeout(() => void read(delay), delay)); return () => { cancelled = true; + timers.forEach((timer) => window.clearTimeout(timer)); }; }, [activeTokenAddress, wallet]); - const connect = useCallback(async () => { - const eth = getEthereum(); - trackB20WalletConnection('started'); - if (!eth) { - trackB20WalletConnection('error'); - setInspectError('We could not find a browser wallet. Install or unlock one, then try again.'); - return; - } + // Making a wallet is instant and local: generate a key, derive the smart + // account's CREATE2 address. The account itself deploys as a side effect of + // its first transaction. The faucet seed (0.1 vibenet ETH) runs in the + // background — sponsorship works at zero balance, the ETH just enables the + // self-paid fallback. + const makeWallet = useCallback(() => { + trackB20WalletCreation('started'); try { - const accounts = (await eth.request({ method: 'eth_requestAccounts' })) as string[]; - const account = accounts[0]; - if (!account || !isAddress(account)) throw new Error('Wallet did not return an account.'); - if ((await getChainId(eth)) !== CHAIN_ID) { - try { - await switchEthereumChain(eth, CHAIN_ID); - } catch (error) { - if (!isUnrecognizedChain(error)) throw error; - await addEthereumChain(eth, { - chainId: CHAIN_ID, - chainName: 'base vibenet', - rpcUrl: VIBENET_RPC_URL, - explorerUrl: `${window.location.origin}${VIBENET_EXPLORER_PATH}`, - }); - } + // Never overwrite an existing key: a double-click, a replayed + // pre-hydration click, or a second tab must adopt the stored wallet + // instead of silently replacing it (the old key would be unrecoverable). + const existing = loadWallet(); + if (existing) { + setStoredWallet(existing); + trackB20WalletCreation('success'); + return; } - setWallet(account); - await refreshWallet(account); - trackB20WalletConnection('success'); + const next = createWallet(); + saveWallet(next); + setStoredWallet(next); + setInspectError(''); + trackB20WalletCreation('success'); + const address = walletAddress(next, deployment); + void seedWithEth(address).then(() => refreshWallet(address)); } catch (error) { - trackB20WalletConnection('error'); - setInspectError(isUserRejection(error) ? 'Wallet request dismissed.' : walletErrorMessage(error)); + trackB20WalletCreation('error'); + setInspectError(walletErrorMessage(error)); } - }, [refreshWallet]); + }, [deployment, refreshWallet]); - const disconnect = useCallback(() => { - // EIP-1193 providers do not expose a portable disconnect method. Clear the - // app's session instead; the wallet's site permission remains unchanged. - setWallet(null); + const resetWallet = useCallback(() => { + clearWallet(); + clearPayer(); + setStoredWallet(null); + setStoredPayer(null); setWalletBalance(null); + setGasMode('sponsored'); + setResetConfirm(false); setRecent([]); setRecentPolicies([]); setIsOperator(false); setIsTokenAdmin(false); setTokenAdminLoading(false); setTokenAdminCheckedFor(null); + // The token context belongs to the old wallet — a fresh wallet starts with + // nothing selected, only its faucet ETH. + setToken(null); + setTokenAddress(''); + setTokenBalance(null); + setChecks(null); + setCheckAddress(''); + setCreated(null); + setInspectError(''); }, []); + // Token-paid gas is offered only for a STABLECOIN the wallet manages — + // paying fees in a currency-pegged token is the realistic story; volatile + // asset tokens stay on sponsored/self-paid gas. Stablecoin creators hold + // DEFAULT_ADMIN (not OPERATOR_ROLE, which the stablecoin deploy skips), so + // admin status is the gate. Drop back to sponsored when the active token + // changes, isn't a stablecoin, or access is lost. + const tokenGasEligible = token?.variant === 'stablecoin' && (isTokenAdmin || isOperator); + useEffect(() => { + if (!tokenGasEligible) setGasMode('sponsored'); + }, [tokenGasEligible]); + + const enableTokenGas = useCallback(() => { + let payer = storedPayer; + if (!payer) { + payer = createPayer(); + savePayer(payer); + setStoredPayer(payer); + // Pre-fund the demo payer so the first token-paid send doesn't wait. + void seedWithEth(payerAddress(payer)); + } + setGasMode('token'); + }, [storedPayer]); + + // Guided "first payment" from the token-created screen: flip gas to the new + // stablecoin, jump to Memos, and pre-fill an invoice-style payment so the + // next click is Submit. + const [memoPrefill, setMemoPrefill] = useState<{ to: string; amount: string; memo: string } | null>(null); + const startFirstPayment = useCallback(() => { + if (token?.variant === 'stablecoin') enableTokenGas(); + setMemoPrefill({ to: '0xd0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0', amount: '5', memo: 'Invoice-0001' }); + setModule('memos'); + trackB20ModuleSelect('memos'); + }, [enableTokenGas, token]); + const clearMemoPrefill = useCallback(() => setMemoPrefill(null), []); + const inspect = useCallback( async (candidate = tokenAddress) => { const sampleToken = sampleTokenForAddress(candidate); @@ -310,30 +460,26 @@ export function B20Demo() { setChecks(Object.fromEntries(result)); }, [checkAddress, token]); - const send = useCallback( - async (label: string, to: Address, data: Hex, action: string): Promise => { - const eth = getEthereum(); - if (!wallet || !eth) { - setInspectError('Connect a Vibenet wallet before you continue.'); + // The single transaction chokepoint: every module action lands here. Calls go + // out as one atomic EIP-8130 transaction with gas paid by the hosted payer. + const sendCalls = useCallback( + async (label: string, calls: SponsoredCall[], action: string): Promise => { + if (!storedWallet || !wallet) { + setInspectError('Make a wallet before you continue.'); return null; } setBusy(action); + setInspectError(''); trackB20Action(module, action, 'submitted'); setActivity((rows) => [{ label, state: 'pending' }, ...rows]); try { - // Use the RPC estimate when available so wallets do not apply an oversized - // fallback gas limit to custom precompile calls. Estimation remains - // optional because some injected wallets can still submit when it fails. - const estimatedGas = await client.estimateGas({ account: wallet, to, data }).catch(() => undefined); - const gas = estimatedGas ? `0x${((estimatedGas * 120n) / 100n).toString(16)}` : undefined; - const hash = (await eth.request({ - method: 'eth_sendTransaction', - params: [{ from: wallet, to, data, value: '0x0', ...(gas ? { gas } : {}) }], - })) as Hex; - const receipt = await client.waitForTransactionReceipt({ hash }); - if (receipt.status !== 'success') throw new Error('The transaction did not complete. Check your wallet and try again.'); + const tokenGas = + gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer + ? { token: token.address, symbol: token.symbol, decimals: token.decimals, payer: storedPayer } + : undefined; + const { hash, mode } = await sendSponsored8130({ wallet: storedWallet, deployment, calls, tokenGas }); setActivity((rows) => [ - { label, hash, state: 'success' }, + { label: annotateMode(label, mode, token?.symbol), hash, state: 'success' }, ...rows.filter((row) => row.label !== label || row.state !== 'pending'), ]); trackB20Action(module, action, 'success'); @@ -341,7 +487,7 @@ export function B20Demo() { if (token) await inspect(token.address); return hash; } catch (error) { - const detail = walletErrorMessage(error); + const detail = payerErrorMessage(error) ?? walletErrorMessage(error); setActivity((rows) => [ { label, state: 'error', detail }, ...rows.filter((row) => row.label !== label || row.state !== 'pending'), @@ -353,7 +499,67 @@ export function B20Demo() { setBusy(null); } }, - [inspect, module, refreshWallet, token, wallet], + [deployment, gasMode, inspect, module, refreshWallet, storedPayer, storedWallet, token, wallet], + ); + + const send = useCallback( + (label: string, to: Address, data: Hex, action: string): Promise => + sendCalls(label, [{ to, data }], action), + [sendCalls], + ); + + // Multi-transaction flows (token deployment): the payer sponsors only ~300k + // gas per transaction, so heavy work is split into sequential batches that + // each fit the budget. Shows one activity row per batch. + const sendBatches = useCallback( + async (batches: SponsoredBatch[], action: string): Promise => { + if (!storedWallet || !wallet) { + setInspectError('Make a wallet before you continue.'); + return null; + } + setBusy(action); + setInspectError(''); + trackB20Action(module, action, 'submitted'); + let current = ''; + try { + const results = await sendSponsoredBatches({ + wallet: storedWallet, + deployment, + batches, + onProgress: (label, index, total) => { + current = label; + setBatchProgress({ label, index, total }); + setActivity((rows) => [{ label, state: 'pending' }, ...rows]); + }, + onBatchResult: (label, result) => { + setActivity((rows) => + rows.map((row) => + row.label === label && row.state === 'pending' + ? { ...row, state: 'success' as const, hash: result.hash, label: annotateMode(label, result.mode) } + : row, + ), + ); + }, + }); + trackB20Action(module, action, 'success'); + await refreshWallet(wallet); + if (token) await inspect(token.address); + return results.map((result) => result.hash); + } catch (error) { + const detail = payerErrorMessage(error) ?? walletErrorMessage(error); + setActivity((rows) => [ + { label: current || batches[0]?.label || 'Transaction', state: 'error', detail }, + ...rows.filter((row) => row.state !== 'pending'), + ]); + trackB20Action(module, action, 'error'); + setInspectError(detail); + return null; + } finally { + setBusy(null); + setBatchProgress(null); + } + }, + [deployment, inspect, module, refreshWallet, storedWallet, token, wallet], ); useEffect(() => { @@ -389,28 +595,82 @@ export function B20Demo() { Vibenet - - Faucet - {wallet ? (
- {walletBalance === null ? '…' : `${Number(formatEther(walletBalance)).toFixed(3)} ETH`} + {walletBalance === null || walletBalance === 0n ? ( + + + funding wallet… + + ) : ( + {`${Number(formatEther(walletBalance)).toFixed(3)} ETH`} + )} + {token && tokenBalance !== null ? ( + + + + + {token.symbol} + + + ) : null} + {token && tokenGasEligible ? ( + + Fees: + + + + + + ) : ( + Gasless + )}
) : ( - )} @@ -463,9 +723,20 @@ export function B20Demo() { selectModule('deploy')} onSend={send} + onSendCalls={sendCalls} busy={busy} + refreshKey={activity.length} + prefill={memoPrefill} + onPrefillConsumed={clearMemoPrefill} + feeNote={ + gasMode === 'token' && token + ? `${formatAmount(tokenGasFee(token.decimals), token.decimals)} ${token.symbol}` + : null + } + onEnableTokenGas={tokenGasEligible && gasMode === 'sponsored' ? enableTokenGas : null} /> ) : null} {module === 'announcements' ? ( @@ -486,6 +757,9 @@ export function B20Demo() { { if (wallet) setRecentPolicies(writeRecentPolicy(wallet, policy)); @@ -495,6 +769,10 @@ export function B20Demo() { if (wallet) setRecent(writeRecent(wallet, next)); setTokenAddress(next.address); setCreated(next); + // Mount the chip balance at 0 so the initial deposit rolls up + // to the minted amount when the first read lands. + balanceForToken.current = next.address.toLowerCase(); + setTokenBalance(0n); await inspect(next.address); }} onReset={() => setCreated(null)} diff --git a/app/demos/b20/components/Activity.tsx b/app/demos/b20/components/Activity.tsx index b5f9ef9..570e592 100644 --- a/app/demos/b20/components/Activity.tsx +++ b/app/demos/b20/components/Activity.tsx @@ -18,7 +18,7 @@ export function Activity({ rows }: { rows: ActivityItem[] }) { - {rows.length ? `${rows.length} activity item${rows.length === 1 ? '' : 's'}` : '● Nothing has happened yet'} + {rows.length ? `${rows.length} activity item${rows.length === 1 ? '' : 's'}` : '● Your activity will appear here'} {rows.length ? ( @@ -47,7 +47,9 @@ export function Activity({ rows }: { rows: ActivityItem[] }) { {shortAddress(row.hash)} ↗ ) : ( - {row.detail ?? 'Pending…'} + + {row.detail ?? (row.state === 'pending' ? 'Pending…' : '')} + )} ))} diff --git a/app/demos/b20/components/AnnouncementModule.tsx b/app/demos/b20/components/AnnouncementModule.tsx index d315ec7..ff19cc5 100644 --- a/app/demos/b20/components/AnnouncementModule.tsx +++ b/app/demos/b20/components/AnnouncementModule.tsx @@ -141,7 +141,7 @@ export function AnnouncementModule({ if (!token || token.variant !== 'asset') return; setError(null); try { - if (!wallet) throw new Error('Connect the wallet that manages this token first.'); + if (!wallet) throw new Error('Make a wallet before you announce.'); const announcementId = id.trim(); if (!announcementId || !description.trim()) throw new Error('Announcement ID and description are required.'); const [isOperator, idUsed] = await Promise.all([ @@ -258,14 +258,14 @@ export function AnnouncementModule({ ) : token.variant !== 'asset' ? (

- Announcements are not available on Stablecoin tokens. They are only available on Asset tokens. + Announcements are an Asset token feature. Create an Asset token to publish updates for holders.

) : ( <> {tokenAccess !== 'operator' ? (
- This wallet cannot publish announcements for this asset + Publishing needs the operator role on this asset

Create your own Asset token to write and publish announcements.

@@ -351,7 +351,7 @@ export function AnnouncementModule({ )} diff --git a/app/demos/b20/components/CreatePolicy.tsx b/app/demos/b20/components/CreatePolicy.tsx index 709c18c..8ce7939 100644 --- a/app/demos/b20/components/CreatePolicy.tsx +++ b/app/demos/b20/components/CreatePolicy.tsx @@ -317,7 +317,7 @@ export function CreatePolicy({ )} - + ); } diff --git a/app/demos/b20/components/DeployModule.tsx b/app/demos/b20/components/DeployModule.tsx index 9ef4448..97a8b89 100644 --- a/app/demos/b20/components/DeployModule.tsx +++ b/app/demos/b20/components/DeployModule.tsx @@ -108,6 +108,9 @@ function ConfettiBurst() { export function DeployModule({ wallet, onSend, + onSendBatches, + progress, + onFirstPayment, created, onCreated, onReset, @@ -118,6 +121,14 @@ export function DeployModule({ }: { wallet: Address | null; onSend: (label: string, to: Address, data: Hex, action: string) => Promise; + onSendBatches: ( + batches: Array<{ label: string; calls: Array<{ to: Address; data: Hex }> }>, + action: string, + ) => Promise; + /** Live step info while a batched flow runs (null when idle). */ + progress: { label: string; index: number; total: number } | null; + /** Guided flow: flip gas to the new stablecoin and pre-fill a first payment. */ + onFirstPayment: () => void; created: CreatedToken | null; onCreated: (token: CreatedToken) => Promise; onReset: () => void; @@ -141,13 +152,13 @@ export function DeployModule({ const [policyError, setPolicyError] = useState(null); const [resolvingPolicy, setResolvingPolicy] = useState(false); const [showPolicyCreator, setShowPolicyCreator] = useState(false); - const [predicted, setPredicted] = useState('Connect a wallet to see the address'); + const [predicted, setPredicted] = useState('Make a wallet to see the address'); const [finalizing, setFinalizing] = useState(false); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; if (!wallet) { - setPredicted('Connect a wallet to see the address'); + setPredicted('Make a wallet to see the address'); return; } if (!salt.trim()) { @@ -214,7 +225,7 @@ export function DeployModule({ const submit = async () => { if (!wallet) { - setError('Connect a wallet before you create a token.'); + setError('Make a wallet before you create a token.'); return; } setFinalizing(true); @@ -289,21 +300,35 @@ export function DeployModule({ ); const policyCount = initialPolicies.length; if (policyCount) configured.push(`Added ${policyCount} token ${policyCount === 1 ? 'rule' : 'rules'}`); - const data = encodeFunctionData({ - abi: factoryAbi, - functionName: 'createB20', - args: [variant === 'asset' ? 0 : 1, deploySalt, params, initCalls], - }); const address = await client.readContract({ address: B20_FACTORY, abi: factoryAbi, functionName: 'getB20Address', args: [variant === 'asset' ? 0 : 1, wallet, deploySalt], }); - const hash = await onSend(`Create ${symbol}`, B20_FACTORY, data, 'create_b20'); - if (hash) { + // The payer sponsors only ~300k gas per transaction, so creation can't + // carry the init calls: create the bare token first, then apply the same + // init calls directly to the token in budget-sized follow-up batches. + const createData = encodeFunctionData({ + abi: factoryAbi, + functionName: 'createB20', + args: [variant === 'asset' ? 0 : 1, deploySalt, params, []], + }); + // 6 calls ≈ 200k gas — the most that reliably fits under the payer's + // ~300k per-transaction sponsorship budget alongside the batch overhead. + const chunks: Hex[][] = []; + for (let i = 0; i < initCalls.length; i += 6) chunks.push(initCalls.slice(i, i + 6)); + const batches = [ + { label: `Create ${symbol}`, calls: [{ to: B20_FACTORY, data: createData }] }, + ...chunks.map((chunk, i) => ({ + label: chunks.length > 1 ? `Configure ${symbol} (${i + 1} of ${chunks.length})` : `Configure ${symbol}`, + calls: chunk.map((data) => ({ to: address, data })), + })), + ]; + const hashes = await onSendBatches(batches, 'create_b20'); + if (hashes?.length) { await waitForB20Initialization(address); - await onCreated({ address, name, symbol, decimals: d, variant, hash, configured }); + await onCreated({ address, name, symbol, decimals: d, variant, hash: hashes[0], configured }); setSalt(''); } } catch (error) { @@ -312,7 +337,8 @@ export function DeployModule({ setFinalizing(false); } }; - if (created) return ; + if (created) + return ; const pending = !!busy || finalizing; return (
@@ -341,7 +367,7 @@ export function DeployModule({ {variant === 'asset' ? 'Asset' : 'Stablecoin'}: {variant === 'asset' ? 'Choose this for flexible decimals, announcements, and displayed-balance changes.' - : 'Choose this for a currency-linked token. It always uses six decimals and a currency code, helping wallets identify it consistently.'} + : 'Choose this for a currency-linked token. It always uses six decimals and a currency code, helping wallets identify it consistently. Once created, it can also be used to pay gas.'}
@@ -530,18 +556,33 @@ export function DeployModule({

{predicted}

- Creating the token gives your wallet the permissions it needs, sets your options, and sends the starting - amount to you in one transaction. + Creating the token runs a short series of gas-sponsored transactions: it deploys the token, gives your + wallet the permissions it needs, sets your options, and sends you the starting amount.

{pending ? ( -

- Confirm in your wallet, then wait a few seconds for your token to be ready. -

+
+ {progress ? ( +
+ + + + {progress.label}… +
+ ) : ( +

Preparing your token…

+ )} +

+ Each step is a real onchain transaction — links appear in Recent Activity as they confirm. +

+
) : null}
@@ -556,31 +597,46 @@ function CreatedView({ created, onNavigate, onReset, + onFirstPayment, }: { created: CreatedToken; onNavigate: (module: Module) => void; onReset: () => void; + onFirstPayment: () => void; }) { - const nextSteps: Array<{ module: Module; title: string; body: string }> = [ + // Each variant only lists what it can actually do: stablecoins get the + // pay-fees-in-token step (assets can't), assets get announcements + // (stablecoins can't). + const nextSteps: Array<{ key: string; title: string; body: string; onGo: () => void }> = [ { - module: 'policy', - title: 'Explore policies', - body: 'See who can use each token action and check a wallet before you use it.', - }, - { - module: 'memos', - title: 'View memo history', - body: 'See your initial memo and add references to future token activity.', + key: 'memos', + title: 'Send a transfer with a memo', + body: `Move some ${created.symbol} to another wallet with a short reference attached — the fastest way to see your token in action.`, + onGo: () => onNavigate('memos'), }, - ...(created.variant === 'asset' + ...(created.variant === 'stablecoin' ? [ { - module: 'announcements' as Module, + key: 'token-gas', + title: `Pay network fees with ${created.symbol}`, + body: `Send a payment where the gas fee is charged in ${created.symbol} itself.`, + onGo: onFirstPayment, + }, + ] + : [ + { + key: 'announcements', title: 'Share an update', body: 'Publish information for token holders or schedule a displayed-balance change.', + onGo: () => onNavigate('announcements'), }, - ] - : []), + ]), + { + key: 'policy', + title: 'Explore policies', + body: 'See who can use each token action and check a wallet before you use it.', + onGo: () => onNavigate('policy'), + }, ]; return (
@@ -599,6 +655,20 @@ function CreatedView({ Your {created.variant} token {created.symbol} is ready on Vibenet. Here is what was set up and what you can try next. + {created.variant === 'stablecoin' ? ( + <> + + + This flips the fee switch so the network fee is paid in {created.symbol} too. + + + ) : ( + + Asset tokens use sponsored gas. To try paying network fees with your own token, create a Stablecoin. + + )}
@@ -665,7 +735,7 @@ function CreatedView({ ))}

- Everything was applied together, so the token was ready in one transaction. + Each step ran as its own gas-sponsored transaction — check Recent Activity for the links.

@@ -676,9 +746,9 @@ function CreatedView({
{nextSteps.map((step) => ( +
+ ) : null} {!token ? ( @@ -160,14 +272,50 @@ export function MemoModule({ })() : 'Your memo preview will appear here'}

+ +

+ {feeNote + ? `Network fee: ${feeNote} — paid from your balance.` + : 'Network fee: sponsored.'} + {!feeNote && onEnableTokenGas && token ? ( + <> + {' '} + + + ) : null} +

)}
- {token ? : null} + {token ? ( + + ) : null} ); } diff --git a/app/demos/b20/components/PolicyModule.tsx b/app/demos/b20/components/PolicyModule.tsx index 4125e8e..971eb3c 100644 --- a/app/demos/b20/components/PolicyModule.tsx +++ b/app/demos/b20/components/PolicyModule.tsx @@ -97,11 +97,11 @@ export function PolicyModule({
- No wallet required + Read-only preview Explore a sample token - See how token rules work without connecting a wallet. + See how token rules work before making a wallet. diff --git a/app/demos/b20/components/PolicyModule.tsx b/app/demos/b20/components/PolicyModule.tsx index 971eb3c..349d633 100644 --- a/app/demos/b20/components/PolicyModule.tsx +++ b/app/demos/b20/components/PolicyModule.tsx @@ -8,6 +8,7 @@ import { Button } from '../../../components/ui/Button'; import { Card } from '../../../components/ui/Card'; import { cn } from '../../../components/ui/cn'; import { InfoTooltip } from '../../../components/ui/InfoTooltip'; +import { Select, type SelectGroup } from '../../../components/ui/Select'; import { Text } from '../../../components/ui/Text'; import { VIBENET_EXPLORER_PATH } from '../../../vibenet/library/config'; import { B20_HELP, SCOPE_HELP } from '../lib/glossary'; @@ -60,6 +61,21 @@ export function PolicyModule({ const [showCreator, setShowCreator] = useState(false); const [suggestedPolicyId, setSuggestedPolicyId] = useState(null); const isSample = tokenAccess === 'sample'; + const selectedRecent = recent.find((entry) => entry.address.toLowerCase() === address.trim().toLowerCase()); + const recentGroups: SelectGroup[] = [ + { + label: 'Stablecoins · eligible for gas', + options: recent + .filter((entry) => entry.variant === 'stablecoin') + .map((entry) => ({ value: entry.address, label: `${entry.symbol} — ${entry.name}` })), + }, + { + label: 'Assets · sponsored fees only', + options: recent + .filter((entry) => entry.variant === 'asset') + .map((entry) => ({ value: entry.address, label: `${entry.symbol} — ${entry.name}` })), + }, + ].filter((group) => group.options.length > 0); return (
@@ -140,22 +156,38 @@ export function PolicyModule({
- {recent.length ? ( + {recent.length > 1 ? ( <> -

Or choose a token you recently created.

-
- {recent.map((entry) => ( - - ))} -
+

Or switch between tokens created by this wallet.

+