From 2fdb0ab8387a13d8cfd1843d10a1e4615a801272 Mon Sep 17 00:00:00 2001 From: Soheima M Date: Thu, 20 Aug 2026 15:27:07 +0200 Subject: [PATCH 1/5] added stablecoin gas demo --- app/vibenet/demos/account/useAccountEngine.ts | 72 +++- app/vibenet/demos/b20/B20Demo.tsx | 350 ++++++++++++++++-- .../b20/components/AnnouncementModule.tsx | 8 +- .../demos/b20/components/AttachPolicy.tsx | 6 +- .../demos/b20/components/CreatePolicy.tsx | 2 +- .../demos/b20/components/DeployModule.tsx | 140 +++++-- .../demos/b20/components/MemoHistory.tsx | 27 +- .../demos/b20/components/MemoModule.tsx | 157 +++++++- .../demos/b20/components/PolicyModule.tsx | 12 +- app/vibenet/demos/b20/lib/constants.ts | 3 +- app/vibenet/demos/b20/lib/gasPayer.test.ts | 50 +++ app/vibenet/demos/b20/lib/gasPayer.ts | 126 +++++++ app/vibenet/demos/b20/lib/protocol.ts | 14 + app/vibenet/demos/catalogue.ts | 8 +- vitest.config.mts | 6 + 15 files changed, 861 insertions(+), 120 deletions(-) create mode 100644 app/vibenet/demos/b20/lib/gasPayer.test.ts create mode 100644 app/vibenet/demos/b20/lib/gasPayer.ts diff --git a/app/vibenet/demos/account/useAccountEngine.ts b/app/vibenet/demos/account/useAccountEngine.ts index 2add707..d891c0e 100644 --- a/app/vibenet/demos/account/useAccountEngine.ts +++ b/app/vibenet/demos/account/useAccountEngine.ts @@ -27,6 +27,7 @@ import { ecrecoverAuthenticator, type Eip8130Deployment, encodeSessionPolicyConfig, + encodeTokenTransfer, encodeWalletCalls, estimateGas, generatePrivateKey, @@ -1206,7 +1207,11 @@ export function useAccountEngine() { changeSeq: number | null, meta: Hex | undefined, sessionPolicy?: AppPolicy, - payerOpt?: { address: Address; phase0?: { to: Address; data: Hex }[] }, + // `localSigner` co-signs `payerAuth` inline with a key this browser holds + // (the B20 demo's own faucet-funded payer EOA, which accepts an arbitrary + // B20 stablecoin as the fee). Without it the tx is serialized with an empty + // `payerAuth` for a hosted payer service to co-sign out of band. + payerOpt?: { address: Address; phase0?: { to: Address; data: Hex }[]; localSigner?: Signer }, ): Promise<{ serialized: Hex; nextSeq: number }> => { const signer = await buildSigner(signerWS); const account = nativeAccountFor(a, signer, signerWS.authenticator); @@ -1360,18 +1365,22 @@ export function useAccountEngine() { gasLimit = BigInt(floorGas(true) || 200_000); } - const serialized = await account.signTransaction({ - chainId, - accountChanges, - calls: wire, - metadata: meta, - nonceKey: 0n, - nonceSequence, - maxFeePerGas: 1_000_000_000n, - maxPriorityFeePerGas: 1_000_000n, - gas: gasLimit, - ...(payerOpt ? { payer: payerOpt.address, payerAuth: '0x' as Hex } : {}), - }); + const serialized = await account.signTransaction( + { + chainId, + accountChanges, + calls: wire, + metadata: meta, + nonceKey: 0n, + nonceSequence, + maxFeePerGas: 1_000_000_000n, + maxPriorityFeePerGas: 1_000_000n, + gas: gasLimit, + // A local payer signs `payerAuth` here, so don't stub it out. + ...(payerOpt ? { payer: payerOpt.address, ...(payerOpt.localSigner ? {} : { payerAuth: '0x' as Hex }) } : {}), + }, + payerOpt?.localSigner ? { payer: { account: payerOpt.localSigner, address: payerOpt.address } } : undefined, + ); return { serialized, nextSeq }; }; @@ -1401,8 +1410,22 @@ export function useAccountEngine() { // transaction builder. It deliberately reuses the full compose/broadcast // implementation so deployment reconciliation, sub-account delegation, gas // estimation, and eligible staged account changes behave consistently. - const sendActiveCall = async ({ to, data }: { to: Address; data: Hex }) => { + // + // `calls` land as one atomic EIP-8130 transaction, so a demo can pair an + // approve with the call that spends it. `tokenGas` routes the transaction + // through a caller-supplied ERC-8168 payer: phase 0 pays that payer a flat + // fee in the given token and the payer's own ETH covers gas, which is how a + // demo lets you pay fees in a token you just created. Without it the account + // pays its own gas. + const sendActiveCalls = async ({ + calls, + tokenGas, + }: { + calls: { to: Address; data: Hex }[]; + tokenGas?: { token: Address; decimals: number; payer: Signer; fee: bigint }; + }): Promise<{ hash: Hex; serialized: Hex; mode: 'self' | 'token' }> => { if (!acct) throw new Error('Select an account before you continue.'); + if (!calls.length) throw new Error('No calls to send.'); const signer = postChangeOwnerSigners.find((s) => s.id === activeSignerId) ?? postChangeOwnerSigners[0] ?? @@ -1412,18 +1435,34 @@ export function useAccountEngine() { const bundle = pendingBundleFor({ mode: 'owner-send' }); const presigned = bundle.map((item) => item.change); const changeSeq = bundle.length ? bundle[bundle.length - 1].sequence : null; + const payerOpt = tokenGas + ? { + address: tokenGas.payer.address, + phase0: [ + (({ to, data }) => ({ to, data }))( + encodeTokenTransfer({ token: tokenGas.token, to: tokenGas.payer.address, amount: tokenGas.fee }), + ), + ], + localSigner: tokenGas.payer, + } + : undefined; const { serialized, nextSeq } = await signComposed( acct, signer, - [newCallRow({ to, data, value: '0' })], + calls.map((call) => newCallRow({ ...call, value: '0' })), presigned, changeSeq, undefined, undefined, - undefined, + payerOpt, ); const hash = await broadcast8130(serialized); applyLandedBundle(acct, nextSeq, bundle); + return { hash, serialized, mode: tokenGas ? 'token' : 'self' }; + }; + + const sendActiveCall = async ({ to, data }: { to: Address; data: Hex }) => { + const { hash, serialized } = await sendActiveCalls({ calls: [{ to, data }] }); return { hash, serialized }; }; @@ -2341,6 +2380,7 @@ export function useAccountEngine() { broadcast8130, signComposed, sendActiveCall, + sendActiveCalls, applyLandedBundle, handleSeqMismatch, pendingBundleFor, diff --git a/app/vibenet/demos/b20/B20Demo.tsx b/app/vibenet/demos/b20/B20Demo.tsx index 3efece0..76e8917 100644 --- a/app/vibenet/demos/b20/B20Demo.tsx +++ b/app/vibenet/demos/b20/B20Demo.tsx @@ -1,22 +1,25 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { isAddress, type Address, type Hex } from 'viem'; import { trackB20Action, trackB20ModuleSelect } from '../../../analytics/events'; +import { cn } from '../../../components/ui/cn'; import { Tabs } from '../../../components/ui/Tabs'; import { walletErrorMessage } from '../../library/wallet'; import { ActivityLog } from '../account/components/ActivityLog'; import { useAccountEngine } from '../account/useAccountEngine'; import { AccountDemoShell } from '../_components/AccountDemoShell'; +import { AnimatedAmount } from '../_components/AnimatedAmount'; 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, @@ -28,8 +31,31 @@ import { } from './lib/protocol'; import { readRecent, readRecentPolicies, writeRecent, writeRecentPolicy } from './lib/recent'; import { sampleTokenForAddress } from './lib/samples'; +import { + createPayer, + ensurePayerFunded, + loadPayer, + payerAddress, + payerErrorMessage, + payerSigner, + savePayer, + seedWithEth, + tokenGasFee, + type StoredB20Payer, +} from './lib/gasPayer'; import type { CreatedToken, Module, RecentPolicy, RecentToken, TokenAccess, 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: 'self' | 'token', symbol?: string): string { + return mode === 'token' && symbol ? `${label} · gas paid in ${symbol}` : label; +} + export function B20Demo() { const [module, setModule] = useState('policy'); // Local EIP-8130 accounts, shared with the account demo via localStorage. B20 @@ -45,6 +71,14 @@ export function B20Demo() { const addressBook = engine.addressBook; + // The demo's own ERC-8168 payer, minted on demand when fees are switched to a + // token. It stays separate from the account: the account spends the token, + // the payer spends the ETH that actually buys the gas. + const [storedPayer, setStoredPayer] = useState(null); + const [gasMode, setGasMode] = useState<'eth' | 'token'>('eth'); + const [tokenBalance, setTokenBalance] = useState(null); + // Which token the shown balance belongs to (lowercased address). + const balanceForToken = useRef(null); const [recent, setRecent] = useState([]); const [recentPolicies, setRecentPolicies] = useState([]); const [tokenAddress, setTokenAddress] = useState(''); @@ -53,6 +87,7 @@ export function B20Demo() { const [checkAddress, setCheckAddress] = useState(''); const [checks, setChecks] = useState | null>(null); 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); @@ -78,6 +113,43 @@ export function B20Demo() { refreshWallet(wallet); }, [wallet, refreshWallet]); + useEffect(() => { + setStoredPayer(loadPayer()); + }, []); + + // The account's holding of the active token, shown beside the tabs 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 // with the same address; keying this effect on the address (not the object) @@ -88,21 +160,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]); @@ -118,30 +195,74 @@ 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]); + // Token-paid gas is offered only for a STABLECOIN the account manages — + // paying fees in a currency-pegged token is the realistic story; volatile + // asset tokens stay on ETH. Stablecoin creators hold DEFAULT_ADMIN (not + // OPERATOR_ROLE, which the stablecoin deploy skips), so admin status is the + // gate. Drop back to ETH when the active token changes, isn't a stablecoin, + // or access is lost. + const tokenGasEligible = token?.variant === 'stablecoin' && (isTokenAdmin || isOperator); + useEffect(() => { + if (!tokenGasEligible) setGasMode('eth'); + }, [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); @@ -247,23 +368,43 @@ export function B20Demo() { setChecks(Object.fromEntries(result)); }, [checkAddress, token]); - const send = useCallback( - async (label: string, to: Address, data: Hex, action: string): Promise => { + // The single transaction chokepoint: every module action lands here. Calls go + // out as one atomic EIP-8130 transaction through the shared account engine, + // with gas paid in ETH or — when fees are switched to a stablecoin the + // account manages — by the demo's own ERC-8168 payer. + const sendCalls = useCallback( + async (label: string, calls: Array<{ to: Address; data: Hex }>, action: string): Promise => { if (!activeAccount) { setInspectError('Select an account before you continue.'); return null; } setBusy(action); + setInspectError(''); trackB20Action(module, action, 'submitted'); try { + const tokenGas = + gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer + ? { + token: token.address, + decimals: token.decimals, + payer: payerSigner(storedPayer), + fee: tokenGasFee(token.decimals), + } + : undefined; + // The payer underwrites the gas in ETH, so it has to be funded before + // it co-signs — the first token-paid send follows key creation closely. + if (storedPayer && tokenGas) await ensurePayerFunded(storedPayer); // Sign + broadcast through the shared account engine so account deploy, // sub-account, gas-estimation, and staged-settings behavior stays in one // implementation across demos. Logging via pushActivity puts this send in // the same history the account demo reads, so both demos share one trail. - const { hash, serialized } = await engine.sendActiveCall({ to, data }); + const { hash, serialized, mode } = await engine.sendActiveCalls({ + calls, + ...(tokenGas ? { tokenGas } : {}), + }); engine.pushActivity({ kind: 'transact', - title: label, + title: annotateMode(label, mode, token?.symbol), txHash: hash, serialized, network: engine.chain.name, @@ -275,15 +416,83 @@ export function B20Demo() { if (token) await inspect(token.address); return hash; } catch (error) { - const detail = walletErrorMessage(error); + const detail = payerErrorMessage(error) ?? walletErrorMessage(error); + trackB20Action(module, action, 'error'); + setInspectError(detail); + return null; + } finally { + setBusy(null); + } + }, + [activeAccount, engine, gasMode, inspect, module, refreshWallet, storedPayer, token], + ); + + const send = useCallback( + (label: string, to: Address, data: Hex, action: string): Promise => + sendCalls(label, [{ to, data }], action), + [sendCalls], + ); + + // Multi-transaction flows (token deployment): the work is split into + // sequential transactions that each stay well inside a block's gas, so a + // heavy create + configure run can't be cut mid-phase. Logs one activity entry + // per batch and stops at the first failure — earlier batches stay applied, so + // each one is written to be meaningful on its own. + const sendBatches = useCallback( + async ( + batches: Array<{ label: string; calls: Array<{ to: Address; data: Hex }> }>, + action: string, + ): Promise => { + if (!activeAccount) { + setInspectError('Select an account before you continue.'); + return null; + } + setBusy(action); + setInspectError(''); + trackB20Action(module, action, 'submitted'); + const hashes: Hex[] = []; + try { + for (const [index, batch] of batches.entries()) { + setBatchProgress({ label: batch.label, index, total: batches.length }); + const tokenGas = + gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer + ? { + token: token.address, + decimals: token.decimals, + payer: payerSigner(storedPayer), + fee: tokenGasFee(token.decimals), + } + : undefined; + if (storedPayer && tokenGas) await ensurePayerFunded(storedPayer); + const { hash, serialized, mode } = await engine.sendActiveCalls({ + calls: batch.calls, + ...(tokenGas ? { tokenGas } : {}), + }); + hashes.push(hash); + engine.pushActivity({ + kind: 'transact', + title: annotateMode(batch.label, mode, token?.symbol), + txHash: hash, + serialized, + network: engine.chain.name, + mode: engine.chain.mode, + account: activeAccount.address as Address, + }); + } + trackB20Action(module, action, 'success'); + refreshWallet(activeAccount.address as Address); + return hashes; + } catch (error) { + const detail = payerErrorMessage(error) ?? walletErrorMessage(error); trackB20Action(module, action, 'error'); setInspectError(detail); return null; } finally { + setBatchProgress(null); setBusy(null); } }, - [inspect, module, refreshWallet, token, activeAccount, engine], + [activeAccount, engine, gasMode, module, refreshWallet, storedPayer, token], ); useEffect(() => { @@ -321,7 +530,7 @@ export function B20Demo() { className="animate-in gap-5 pb-6 dark:text-white" >
-
+
+
+ {token && tokenBalance !== null ? ( + + + {token.symbol} + + ) : null} + {token && tokenGasEligible ? ( + + Fees: + + + + + + ) : null} +
{module === 'policy' ? ( @@ -372,9 +626,20 @@ export function B20Demo() { token={token} tokenAccess={tokenAccess} addressBook={addressBook} + wallet={wallet} onDeploy={() => selectModule('deploy')} onSend={send} + onSendCalls={sendCalls} busy={busy} + refreshKey={engine.activity.length} + prefill={memoPrefill} + onPrefillConsumed={clearMemoPrefill} + feeNote={ + gasMode === 'token' && token + ? `${formatAmount(tokenGasFee(token.decimals), token.decimals)} ${token.symbol}` + : null + } + onEnableTokenGas={tokenGasEligible && gasMode === 'eth' ? enableTokenGas : null} /> ) : null} {module === 'announcements' ? ( @@ -395,6 +660,9 @@ export function B20Demo() { { @@ -405,6 +673,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/vibenet/demos/b20/components/AnnouncementModule.tsx b/app/vibenet/demos/b20/components/AnnouncementModule.tsx index 3a923c0..765f5aa 100644 --- a/app/vibenet/demos/b20/components/AnnouncementModule.tsx +++ b/app/vibenet/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([ @@ -260,14 +260,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.

@@ -353,7 +353,7 @@ export function AnnouncementModule({ )} diff --git a/app/vibenet/demos/b20/components/CreatePolicy.tsx b/app/vibenet/demos/b20/components/CreatePolicy.tsx index 4cec17d..dc45130 100644 --- a/app/vibenet/demos/b20/components/CreatePolicy.tsx +++ b/app/vibenet/demos/b20/components/CreatePolicy.tsx @@ -327,7 +327,7 @@ export function CreatePolicy({ )} - + ); } diff --git a/app/vibenet/demos/b20/components/DeployModule.tsx b/app/vibenet/demos/b20/components/DeployModule.tsx index 3161eee..6949db0 100644 --- a/app/vibenet/demos/b20/components/DeployModule.tsx +++ b/app/vibenet/demos/b20/components/DeployModule.tsx @@ -109,6 +109,9 @@ function ConfettiBurst() { export function DeployModule({ wallet, onSend, + onSendBatches, + progress, + onFirstPayment, created, onCreated, onReset, @@ -120,6 +123,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; @@ -144,13 +155,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()) { @@ -217,7 +228,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); @@ -292,21 +303,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) { @@ -315,7 +340,8 @@ export function DeployModule({ setFinalizing(false); } }; - if (created) return ; + if (created) + return ; const pending = !!busy || finalizing; return (
@@ -344,7 +370,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.'}
@@ -534,18 +560,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}
@@ -560,31 +601,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 (
@@ -603,6 +659,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. + + )}
@@ -669,7 +739,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.

@@ -680,9 +750,9 @@ function CreatedView({
{nextSteps.map((step) => ( +
+ ) : null} {!token ? ( @@ -168,14 +279,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/vibenet/demos/b20/components/PolicyModule.tsx b/app/vibenet/demos/b20/components/PolicyModule.tsx index 0898d92..012a6f3 100644 --- a/app/vibenet/demos/b20/components/PolicyModule.tsx +++ b/app/vibenet/demos/b20/components/PolicyModule.tsx @@ -98,11 +98,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.

{feeNote diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts index 833e66d..712a3f6 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -42,7 +42,7 @@ export const DEMOS: DemoEntry[] = [ points: [ 'One-click 8130 wallet — faucet-seeded, gasless via payer sponsorship', 'Pay gas with your own stablecoin (ERC-8168 token payment)', - 'Policies, memos, and atomic approve + transferFrom batching', + 'Policies, memos, and Asset announcements', ], available: true, }, From df31172c5b7461a4d1b563695dad737a31faf9da Mon Sep 17 00:00:00 2001 From: soheima Date: Thu, 20 Aug 2026 21:58:22 +0200 Subject: [PATCH 3/5] addressed feedback and added logic for more than one token --- app/vibenet/demos/b20/B20Demo.tsx | 68 ++++++++++++-- .../demos/b20/components/DeployModule.tsx | 90 +++++++++++++------ .../demos/b20/components/MemoModule.tsx | 14 ++- .../demos/b20/components/PolicyModule.tsx | 62 +++++++++---- app/vibenet/demos/b20/lib/deployment.test.ts | 41 +++++++++ app/vibenet/demos/b20/lib/deployment.ts | 52 +++++++++++ app/vibenet/demos/b20/lib/gasPayer.ts | 6 +- app/vibenet/demos/b20/lib/tokenGas.test.ts | 21 +++++ app/vibenet/demos/b20/lib/tokenGas.ts | 7 ++ app/vibenet/demos/catalogue.ts | 4 +- 10 files changed, 309 insertions(+), 56 deletions(-) create mode 100644 app/vibenet/demos/b20/lib/deployment.test.ts create mode 100644 app/vibenet/demos/b20/lib/deployment.ts create mode 100644 app/vibenet/demos/b20/lib/tokenGas.test.ts create mode 100644 app/vibenet/demos/b20/lib/tokenGas.ts diff --git a/app/vibenet/demos/b20/B20Demo.tsx b/app/vibenet/demos/b20/B20Demo.tsx index 8ef880b..ebbfd78 100644 --- a/app/vibenet/demos/b20/B20Demo.tsx +++ b/app/vibenet/demos/b20/B20Demo.tsx @@ -11,6 +11,7 @@ import { ActivityLog } from '../account/components/ActivityLog'; import { useAccountEngine } from '../account/useAccountEngine'; import { AccountDemoShell } from '../_components/AccountDemoShell'; import { AnimatedAmount } from '../_components/AnimatedAmount'; +import { Select, type SelectGroup } from '../../../components/ui/Select'; import { AnnouncementModule, SampleAnnouncementViewer } from './components/AnnouncementModule'; import { DeployModule } from './components/DeployModule'; import { MemoModule } from './components/MemoModule'; @@ -31,6 +32,7 @@ import { } from './lib/protocol'; import { readRecent, readRecentPolicies, writeRecent, writeRecentPolicy } from './lib/recent'; import { sampleTokenForAddress } from './lib/samples'; +import { canUseTokenForGas } from './lib/tokenGas'; import { createPayer, ensurePayerFunded, @@ -87,7 +89,12 @@ export function B20Demo() { const [checkAddress, setCheckAddress] = useState(''); const [checks, setChecks] = useState | null>(null); const [busy, setBusy] = useState(null); - const [batchProgress, setBatchProgress] = useState<{ label: string; index: number; total: number } | null>(null); + const [batchProgress, setBatchProgress] = useState<{ + label: string; + detail?: string; + index: number; + total: number; + } | null>(null); const [isOperator, setIsOperator] = useState(false); const [isTokenAdmin, setIsTokenAdmin] = useState(false); const [tokenAdminLoading, setTokenAdminLoading] = useState(false); @@ -234,7 +241,7 @@ export function B20Demo() { // OPERATOR_ROLE, which the stablecoin deploy skips), so admin status is the // gate. Drop back to ETH when the active token changes, isn't a stablecoin, // or access is lost. - const tokenGasEligible = token?.variant === 'stablecoin' && (isTokenAdmin || isOperator); + const tokenGasEligible = canUseTokenForGas(token?.variant, isTokenAdmin, isOperator); useEffect(() => { if (!tokenGasEligible) setGasMode('eth'); }, [tokenGasEligible]); @@ -440,7 +447,7 @@ export function B20Demo() { // each one is written to be meaningful on its own. const sendBatches = useCallback( async ( - batches: Array<{ label: string; calls: Array<{ to: Address; data: Hex }> }>, + batches: Array<{ label: string; detail?: string; calls: Array<{ to: Address; data: Hex }> }>, action: string, ): Promise => { if (!activeAccount) { @@ -453,7 +460,7 @@ export function B20Demo() { const hashes: Hex[] = []; try { for (const [index, batch] of batches.entries()) { - setBatchProgress({ label: batch.label, index, total: batches.length }); + setBatchProgress({ label: batch.label, detail: batch.detail, index, total: batches.length }); const tokenGas = gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer ? { @@ -472,6 +479,7 @@ export function B20Demo() { engine.pushActivity({ kind: 'transact', title: annotateMode(batch.label, mode, token?.symbol), + detail: batch.detail, txHash: hash, serialized, network: engine.chain.name, @@ -521,6 +529,40 @@ export function B20Demo() { : wallet ? 'external' : 'disconnected'; + const selectedCreatedToken = recent.find( + (entry) => entry.address.toLowerCase() === tokenAddress.trim().toLowerCase(), + ); + const headerToken = selectedCreatedToken ?? token; + const switchingCreatedToken = + busy === 'inspect' && + selectedCreatedToken !== undefined && + selectedCreatedToken.address.toLowerCase() !== token?.address.toLowerCase(); + const headerTokenGroups: SelectGroup[] = [ + { + label: 'Stablecoins · can pay network fees', + options: recent + .filter((entry) => entry.variant === 'stablecoin') + .map((entry) => ({ + value: entry.address, + label: + entry.address.toLowerCase() === token?.address.toLowerCase() && tokenBalance !== null + ? `${formatAmount(tokenBalance, entry.decimals)} ${entry.symbol} · Stablecoin` + : `${entry.symbol} — ${entry.name} · Stablecoin`, + })), + }, + { + label: 'Assets · fees in ETH only', + options: recent + .filter((entry) => entry.variant === 'asset') + .map((entry) => ({ + value: entry.address, + label: + entry.address.toLowerCase() === token?.address.toLowerCase() && tokenBalance !== null + ? `${formatAmount(tokenBalance, entry.decimals)} ${entry.symbol} · Asset` + : `${entry.symbol} — ${entry.name} · Asset`, + })), + }, + ].filter((group) => group.options.length > 0); return (

- {token && tokenBalance !== null ? ( + {recent.length > 1 ? ( + { + setAddress(value); + onInspect(value); + }} + groups={recentGroups} + placeholder="Choose one of your tokens" + ariaLabel="Choose a recently created token" + disabled={busy === 'inspect'} + className="mt-3" + /> + + ) : recent.length === 1 ? ( + <> +

Or choose the token you recently created.

+ ) : (

Tokens you create with this wallet will appear here.

diff --git a/app/vibenet/demos/b20/lib/deployment.test.ts b/app/vibenet/demos/b20/lib/deployment.test.ts new file mode 100644 index 0000000..54bc85b --- /dev/null +++ b/app/vibenet/demos/b20/lib/deployment.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { + chunkDeploymentOperations, + describeStablecoinOperations, + type DeploymentOperation, +} from './deployment'; + +const data = '0x1234' as const; + +describe('B20 deployment progress', () => { + it('keeps configuration batches at six calls', () => { + const operations: DeploymentOperation[] = Array.from({ length: 8 }, (_, index) => ({ + data, + kind: 'role' as const, + role: `ROLE_${index + 1}`, + })); + + const chunks = chunkDeploymentOperations(operations); + + expect(chunks).toHaveLength(2); + expect(chunks[0]).toHaveLength(6); + expect(chunks[1]).toHaveLength(2); + expect(chunks.flat()).toEqual(operations); + }); + + it('describes the exact Stablecoin operations in a batch', () => { + const operations: DeploymentOperation[] = [ + { data, kind: 'role', role: 'MINT_ROLE' }, + { data, kind: 'role', role: 'METADATA_ROLE' }, + { data, kind: 'cap', amount: '10,000,000', symbol: 'USDC' }, + { data, kind: 'metadata' }, + { data, kind: 'mint', amount: '100', symbol: 'USDC', memo: 'Initial deposit' }, + { data, kind: 'policy', id: 42n, scope: 'TRANSFER_RECEIVER_POLICY' }, + ]; + + expect(describeStablecoinOperations(operations)).toBe( + 'Grant MINT_ROLE, METADATA_ROLE to the EIP-8130 account; set the supply cap to 10,000,000 USDC; save the token information link; mint 100 USDC to the EIP-8130 account with the “Initial deposit” memo; attach policy 42 to TRANSFER_RECEIVER_POLICY.', + ); + }); +}); diff --git a/app/vibenet/demos/b20/lib/deployment.ts b/app/vibenet/demos/b20/lib/deployment.ts new file mode 100644 index 0000000..93d2355 --- /dev/null +++ b/app/vibenet/demos/b20/lib/deployment.ts @@ -0,0 +1,52 @@ +import type { Hex } from 'viem'; + +export type DeploymentOperation = + | { data: Hex; kind: 'role'; role: string } + | { data: Hex; kind: 'cap'; amount: string; symbol: string } + | { data: Hex; kind: 'metadata' } + | { data: Hex; kind: 'mint'; amount: string; symbol: string; memo: string } + | { data: Hex; kind: 'policy'; id: bigint; scope: string }; + +export function chunkDeploymentOperations( + operations: DeploymentOperation[], + size = 6, +): DeploymentOperation[][] { + const chunks: DeploymentOperation[][] = []; + for (let index = 0; index < operations.length; index += size) { + chunks.push(operations.slice(index, index + size)); + } + return chunks; +} + +// Stablecoin creation is split across several transactions in this demo. +// Keep the description derived from the calls in each transaction so the UI +// never claims that a setting has been applied in a different batch. +export function describeStablecoinOperations(operations: DeploymentOperation[]): string { + const clauses: string[] = []; + const roles = operations.filter((operation) => operation.kind === 'role').map((operation) => operation.role); + if (roles.length) { + clauses.push(`Grant ${roles.join(', ')} to the EIP-8130 account`); + } + for (const operation of operations) { + switch (operation.kind) { + case 'cap': + clauses.push(`set the supply cap to ${operation.amount} ${operation.symbol}`); + break; + case 'metadata': + clauses.push('save the token information link'); + break; + case 'mint': + clauses.push( + `mint ${operation.amount} ${operation.symbol} to the EIP-8130 account with the “${operation.memo}” memo`, + ); + break; + case 'policy': + clauses.push(`attach policy ${operation.id.toString()} to ${operation.scope}`); + break; + case 'role': + break; + } + } + if (!clauses.length) return ''; + return `${clauses.join('; ')}.`; +} diff --git a/app/vibenet/demos/b20/lib/gasPayer.ts b/app/vibenet/demos/b20/lib/gasPayer.ts index b738e4d..e79a26e 100644 --- a/app/vibenet/demos/b20/lib/gasPayer.ts +++ b/app/vibenet/demos/b20/lib/gasPayer.ts @@ -117,10 +117,10 @@ export function payerErrorMessage(error: unknown): string | null { switch (rejected.code) { case 'BUDGET_EXHAUSTED': case 'SENDER_LIMIT_REACHED': - return 'The gas sponsorship budget for this demo is used up. Wait a bit, then try again.'; + return "The demo gas payer's budget is used up. Wait a bit, then try again."; case 'TEMPORARILY_UNAVAILABLE': - return 'The gas sponsor is temporarily unavailable. Try again in a moment.'; + return 'The gas payer is temporarily unavailable. Try again in a moment.'; default: - return `The gas sponsor declined this transaction${rejected.reason ? `: ${rejected.reason}` : '.'}`; + return `The gas payer declined this transaction${rejected.reason ? `: ${rejected.reason}` : '.'}`; } } diff --git a/app/vibenet/demos/b20/lib/tokenGas.test.ts b/app/vibenet/demos/b20/lib/tokenGas.test.ts new file mode 100644 index 0000000..d575794 --- /dev/null +++ b/app/vibenet/demos/b20/lib/tokenGas.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { canUseTokenForGas } from './tokenGas'; + +describe('B20 token gas eligibility', () => { + it('allows a managed Stablecoin to pay gas', () => { + expect(canUseTokenForGas('stablecoin', true, false)).toBe(true); + expect(canUseTokenForGas('stablecoin', false, true)).toBe(true); + }); + + it('never allows an Asset token to pay gas', () => { + expect(canUseTokenForGas('asset', true, false)).toBe(false); + expect(canUseTokenForGas('asset', false, true)).toBe(false); + expect(canUseTokenForGas('asset', true, true)).toBe(false); + }); + + it('requires access to the selected Stablecoin', () => { + expect(canUseTokenForGas('stablecoin', false, false)).toBe(false); + expect(canUseTokenForGas(undefined, true, true)).toBe(false); + }); +}); diff --git a/app/vibenet/demos/b20/lib/tokenGas.ts b/app/vibenet/demos/b20/lib/tokenGas.ts new file mode 100644 index 0000000..38cf103 --- /dev/null +++ b/app/vibenet/demos/b20/lib/tokenGas.ts @@ -0,0 +1,7 @@ +export function canUseTokenForGas( + variant: 'asset' | 'stablecoin' | undefined, + isAdmin: boolean, + isOperator: boolean, +): boolean { + return variant === 'stablecoin' && (isAdmin || isOperator); +} diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts index 712a3f6..6e9d460 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -38,9 +38,9 @@ export const DEMOS: DemoEntry[] = [ title: 'Tokens', shortTitle: 'Tokens', summary: - 'Make a gasless EIP-8130 smart wallet in one click, then inspect policy scopes, attach transaction memos, publish Asset announcements, and create Base-native B20 tokens.', + 'Create an EIP-8130 account in one click, then inspect policy scopes, attach transaction memos, publish Asset announcements, and create Base-native B20 tokens.', points: [ - 'One-click 8130 wallet — faucet-seeded, gasless via payer sponsorship', + 'One-click 8130 account — faucet-seeded and shared with the Accounts demo', 'Pay gas with your own stablecoin (ERC-8168 token payment)', 'Policies, memos, and Asset announcements', ], From fb1a8c9ec2816421adf7042de56ed0c50f4b4c2e Mon Sep 17 00:00:00 2001 From: Montana Wong Date: Tue, 25 Aug 2026 15:37:44 -0400 Subject: [PATCH 4/5] fix(b20): keep the activity log in the page flow, pin the nonce across batches Two problems reported after the rebase onto main's account engine. The activity log moved into the shared bottom drawer, which is sticky and full-bleed, so it read as a separate panel rather than part of the page. B20 narrates multi-transaction flows and the log has to stay readable next to the form that started them, so it goes back to an inline card and the drawer in AccountDemoShell becomes optional. Creating a token failed at the configure step. sendBatches looped over sendActiveCalls, and signComposed re-reads the nonce and probes for code on every call. The public RPC is load-balanced across replicas whose heads can differ, so the read between two sends can answer from a replica that has not seen the first one: the second transaction is signed with the same nonce and dropped as a duplicate. sendActiveCallsBatches reads both once up front and counts each batch's sequence from there. --- .../demos/_components/AccountDemoShell.tsx | 20 ++-- app/vibenet/demos/account/useAccountEngine.ts | 107 +++++++++++++++++- app/vibenet/demos/b20/B20Demo.tsx | 73 ++++++------ app/vibenet/demos/b20/components/Activity.tsx | 34 ++++++ 4 files changed, 187 insertions(+), 47 deletions(-) create mode 100644 app/vibenet/demos/b20/components/Activity.tsx diff --git a/app/vibenet/demos/_components/AccountDemoShell.tsx b/app/vibenet/demos/_components/AccountDemoShell.tsx index 00eb206..91a9b66 100644 --- a/app/vibenet/demos/_components/AccountDemoShell.tsx +++ b/app/vibenet/demos/_components/AccountDemoShell.tsx @@ -6,7 +6,9 @@ // on mobile (the top bar is hidden there); // - a full-page DemoGate (empty state until a local account exists); // - the shared create/details account-management modals; -// - the collapsible ActivityDrawer pinned to the bottom. +// - the collapsible ActivityDrawer pinned to the bottom, for demos that hand +// it activity (B20 keeps its log in the page flow instead, so it passes +// none and the drawer is skipped). // Each demo owns one AccountEngine and passes it here, avoiding duplicate store // instances and repeated account-settings wiring. @@ -29,9 +31,9 @@ type AccountDemoShellProps = { // Empty-state copy. gateTitle?: string; gateDescription?: string; - // Activity drawer. - activity: ReactNode; - activityCount: number; + // Activity drawer. Omit `activity` to render no drawer at all. + activity?: ReactNode; + activityCount?: number; activityEmptyMessage?: string; // Extra classes for the root (gap, demo-specific tweaks). className?: string; @@ -44,7 +46,7 @@ export function AccountDemoShell({ gateTitle, gateDescription, activity, - activityCount, + activityCount = 0, activityEmptyMessage, className, children, @@ -87,9 +89,11 @@ export function AccountDemoShell({ {/* Mobile only — desktop uses the top-bar switcher. */}
{switcher}
{children} - - {activity} - + {activity ? ( + + {activity} + + ) : null}
diff --git a/app/vibenet/demos/account/useAccountEngine.ts b/app/vibenet/demos/account/useAccountEngine.ts index d891c0e..8afc801 100644 --- a/app/vibenet/demos/account/useAccountEngine.ts +++ b/app/vibenet/demos/account/useAccountEngine.ts @@ -1212,6 +1212,12 @@ export function useAccountEngine() { // B20 stablecoin as the fee). Without it the tx is serialized with an empty // `payerAuth` for a hosted payer service to co-sign out of band. payerOpt?: { address: Address; phase0?: { to: Address; data: Hex }[]; localSigner?: Signer }, + // Set by callers that run several transactions back to back. The public RPC + // is served by replicas whose heads can differ, so re-reading the nonce (or + // probing for code) between two sends can answer from a replica that hasn't + // seen the previous one yet. Such a caller reads both once up front and + // pins them here instead. + seqOpt?: { nonceSequence?: bigint; assumeDeployed?: boolean }, ): Promise<{ serialized: Hex; nextSeq: number }> => { const signer = await buildSigner(signerWS); const account = nativeAccountFor(a, signer, signerWS.authenticator); @@ -1235,7 +1241,12 @@ export function useAccountEngine() { // Resolve deployment + both config counters once at the composition // boundary. Lower-level signing never consults the persisted account flags. - const { deployed: effectivelyDeployed } = await fetchOnChainAccountState(account.address as Address); + // A caller running several transactions back to back pins the deployment + // state instead: an earlier transaction in that run already deployed the + // account, and a code probe can still lag it and wrongly re-attach the + // create change. + const effectivelyDeployed = + seqOpt?.assumeDeployed ?? (await fetchOnChainAccountState(account.address as Address)).deployed; const bootstrapChange = effectivelyDeployed ? undefined : firstDeployChange(a, account); if (bootstrapChange) accountChanges.push(bootstrapChange); if (effectivelyDeployed !== a.deployed) updateAccount(a.id, { deployed: effectivelyDeployed }); @@ -1273,10 +1284,12 @@ export function useAccountEngine() { const plainCallCount = Math.max(totalCalls - heavyCallCount, 1); const wire = encodeWalletCalls({ account: account.address, calls: phases }); - const nonceSequence = await getTransactionCount(makeRpcClient(), { - address: account.address as Address, - nonceKey: 0n, - }); + const nonceSequence = + seqOpt?.nonceSequence ?? + (await getTransactionCount(makeRpcClient(), { + address: account.address as Address, + nonceKey: 0n, + })); // Authenticator hint so estimateGas shapes the senderAuth stub for the // actual signer. A delegate-signed sub-account acts via the parent's delegate @@ -1461,6 +1474,89 @@ export function useAccountEngine() { return { hash, serialized, mode: tokenGas ? 'token' : 'self' }; }; + /** + * Run several transactions from the active account back to back. + * + * Not a loop over `sendActiveCalls`: the reads that call depends on — the + * account's nonce and whether it has code — are answered by load-balanced RPC + * replicas whose heads can differ, so re-reading them between two sends can + * return a view that predates the previous one. That drops the second + * transaction as a duplicate nonce, or re-attaches the create change to an + * account that already exists. Both reads happen once here, and each batch + * gets its sequence counted from there. + * + * Pending owner/session changes ride the first batch only. Returns one result + * per batch; throws on the first failure, leaving earlier batches applied + * (callers should make each batch meaningful on its own). + */ + const sendActiveCallsBatches = async ({ + batches, + tokenGas, + onBatchStart, + onBatchResult, + }: { + batches: { calls: { to: Address; data: Hex }[] }[]; + tokenGas?: { token: Address; decimals: number; payer: Signer; fee: bigint }; + onBatchStart?: (index: number, total: number) => void; + onBatchResult?: (index: number, result: { hash: Hex; serialized: Hex; mode: 'self' | 'token' }) => void; + }): Promise<{ hash: Hex; serialized: Hex; mode: 'self' | 'token' }[]> => { + if (!acct) throw new Error('Select an account before you continue.'); + if (!batches.length) throw new Error('No calls to send.'); + const signer = + postChangeOwnerSigners.find((s) => s.id === activeSignerId) ?? + postChangeOwnerSigners[0] ?? + activeSigner; + if (!signer) throw new Error('No local owner key found for this account.'); + + const bundle = pendingBundleFor({ mode: 'owner-send' }); + const presigned = bundle.map((item) => item.change); + const changeSeq = bundle.length ? bundle[bundle.length - 1].sequence : null; + const payerOpt = tokenGas + ? { + address: tokenGas.payer.address, + phase0: [ + (({ to, data }) => ({ to, data }))( + encodeTokenTransfer({ token: tokenGas.token, to: tokenGas.payer.address, amount: tokenGas.fee }), + ), + ], + localSigner: tokenGas.payer, + } + : undefined; + + // Read the starting nonce a few times and keep the highest: a single read + // can land on a replica that is a block behind. + const address = acct.address as Address; + let startSequence = 0n; + for (let i = 0; i < 3; i += 1) { + const count = await getTransactionCount(makeRpcClient(), { address, nonceKey: 0n }).catch(() => null); + if (count !== null && count > startSequence) startSequence = count; + } + + const results: { hash: Hex; serialized: Hex; mode: 'self' | 'token' }[] = []; + const mode: 'self' | 'token' = tokenGas ? 'token' : 'self'; + for (const [index, batch] of batches.entries()) { + onBatchStart?.(index, batches.length); + const first = index === 0; + const { serialized, nextSeq } = await signComposed( + acct, + signer, + batch.calls.map((call) => newCallRow({ ...call, value: '0' })), + first ? presigned : [], + first ? changeSeq : null, + undefined, + undefined, + payerOpt, + { nonceSequence: startSequence + BigInt(index), assumeDeployed: !first || undefined }, + ); + const hash = await broadcast8130(serialized); + if (first) applyLandedBundle(acct, nextSeq, bundle); + const result = { hash, serialized, mode }; + results.push(result); + onBatchResult?.(index, result); + } + return results; + }; + const sendActiveCall = async ({ to, data }: { to: Address; data: Hex }) => { const { hash, serialized } = await sendActiveCalls({ calls: [{ to, data }] }); return { hash, serialized }; @@ -2381,6 +2477,7 @@ export function useAccountEngine() { signComposed, sendActiveCall, sendActiveCalls, + sendActiveCallsBatches, applyLandedBundle, handleSeqMismatch, pendingBundleFor, diff --git a/app/vibenet/demos/b20/B20Demo.tsx b/app/vibenet/demos/b20/B20Demo.tsx index ebbfd78..200ab38 100644 --- a/app/vibenet/demos/b20/B20Demo.tsx +++ b/app/vibenet/demos/b20/B20Demo.tsx @@ -7,11 +7,11 @@ import { trackB20Action, trackB20ModuleSelect } from '../../../analytics/events' import { cn } from '../../../components/ui/cn'; import { Tabs } from '../../../components/ui/Tabs'; import { walletErrorMessage } from '../../library/wallet'; -import { ActivityLog } from '../account/components/ActivityLog'; import { useAccountEngine } from '../account/useAccountEngine'; import { AccountDemoShell } from '../_components/AccountDemoShell'; import { AnimatedAmount } from '../_components/AnimatedAmount'; import { Select, type SelectGroup } from '../../../components/ui/Select'; +import { Activity } from './components/Activity'; import { AnnouncementModule, SampleAnnouncementViewer } from './components/AnnouncementModule'; import { DeployModule } from './components/DeployModule'; import { MemoModule } from './components/MemoModule'; @@ -457,39 +457,46 @@ export function B20Demo() { setBusy(action); setInspectError(''); trackB20Action(module, action, 'submitted'); - const hashes: Hex[] = []; + const tokenGas = + gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer + ? { + token: token.address, + decimals: token.decimals, + payer: payerSigner(storedPayer), + fee: tokenGasFee(token.decimals), + } + : undefined; try { - for (const [index, batch] of batches.entries()) { - setBatchProgress({ label: batch.label, detail: batch.detail, index, total: batches.length }); - const tokenGas = - gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer - ? { - token: token.address, - decimals: token.decimals, - payer: payerSigner(storedPayer), - fee: tokenGasFee(token.decimals), - } - : undefined; - if (storedPayer && tokenGas) await ensurePayerFunded(storedPayer); - const { hash, serialized, mode } = await engine.sendActiveCalls({ - calls: batch.calls, - ...(tokenGas ? { tokenGas } : {}), - }); - hashes.push(hash); - engine.pushActivity({ - kind: 'transact', - title: annotateMode(batch.label, mode, token?.symbol), - detail: batch.detail, - txHash: hash, - serialized, - network: engine.chain.name, - mode: engine.chain.mode, - account: activeAccount.address as Address, - }); - } + // The payer underwrites the gas in ETH, so it has to be funded before + // it co-signs — the first token-paid send follows key creation closely. + if (storedPayer && tokenGas) await ensurePayerFunded(storedPayer); + // One engine call, not one per batch: it pins the nonce and deployment + // state across the whole run so a lagging RPC replica can't make the + // second transaction collide with the first. + const results = await engine.sendActiveCallsBatches({ + batches, + ...(tokenGas ? { tokenGas } : {}), + onBatchStart: (index, total) => { + const batch = batches[index]; + setBatchProgress({ label: batch.label, detail: batch.detail, index, total }); + }, + onBatchResult: (index, { hash, serialized, mode }) => { + const batch = batches[index]; + engine.pushActivity({ + kind: 'transact', + title: annotateMode(batch.label, mode, token?.symbol), + detail: batch.detail, + txHash: hash, + serialized, + network: engine.chain.name, + mode: engine.chain.mode, + account: activeAccount.address as Address, + }); + }, + }); trackB20Action(module, action, 'success'); refreshWallet(activeAccount.address as Address); - return hashes; + return results.map((result) => result.hash); } catch (error) { const detail = payerErrorMessage(error) ?? walletErrorMessage(error); trackB20Action(module, action, 'error'); @@ -566,9 +573,6 @@ export function B20Demo() { return ( } - activityCount={engine.activity.length} - activityEmptyMessage="Nothing has happened yet." className="animate-in gap-5 pb-6 dark:text-white" >
@@ -748,6 +752,7 @@ export function B20Demo() { {inspectError}
) : null} +
); } diff --git a/app/vibenet/demos/b20/components/Activity.tsx b/app/vibenet/demos/b20/components/Activity.tsx new file mode 100644 index 0000000..50645c6 --- /dev/null +++ b/app/vibenet/demos/b20/components/Activity.tsx @@ -0,0 +1,34 @@ +import { Card } from '../../../../components/ui/Card'; +import { Text } from '../../../../components/ui/Text'; +import { ActivityLog } from '../../account/components/ActivityLog'; +import type { ActivityEntry, StoredAccount } from '../../account/library/model'; + +// The account demo's activity history, shown in the page flow beneath every +// module rather than in the shared bottom drawer — the B20 modules narrate +// multi-transaction flows, so the log has to stay readable alongside them +// without covering the form that started them. The entries themselves come +// from the account engine, so both demos read one trail. +export function Activity({ activity, accounts }: { activity: ActivityEntry[]; accounts: StoredAccount[] }) { + return ( + +
+
+ Recent activity + + See what this demo did during your current visit. + +
+ + {activity.length + ? `${activity.length} activity item${activity.length === 1 ? '' : 's'}` + : '● Your activity will appear here'} + +
+ {activity.length ? ( +
+ +
+ ) : null} +
+ ); +} From 32622fe95a74275c77e835677d923ed0983dbddd Mon Sep 17 00:00:00 2001 From: Montana Wong Date: Tue, 25 Aug 2026 15:58:34 -0400 Subject: [PATCH 5/5] fix(b20): retry a batch the node rejected or dropped mid-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a token from a new account failed on the second transaction with "actor is not bound" (surfaced as "Missing or invalid parameters"). An account's code and the actors bound to it reach every RPC replica a moment after the transaction that wrote them lands, so the batch prepared right behind the one that deployed the account was validated against a replica that had not seen it yet and was rejected before it was ever broadcast. Occasionally a later batch was broadcast and then dropped instead: the engine signs at a flat 1 gwei maxFeePerGas, which equals the current base fee, so the transaction carries no priority fee and is not guaranteed a slot. sendActiveCallsBatches now retries a batch on both. A rejection before broadcast waits for the state to propagate and signs again on the same nonce. A broadcast that has not been included gets a longer wait, then the node is asked whether it still holds the transaction — one it has dropped is sent again, one it still holds is left alone so a second copy cannot collide with it. The receipt-and-phase check that broadcast8130 already did moves into awaitInclusion so both paths share it. --- app/vibenet/demos/account/useAccountEngine.ts | 104 +++++++++++++----- 1 file changed, 79 insertions(+), 25 deletions(-) diff --git a/app/vibenet/demos/account/useAccountEngine.ts b/app/vibenet/demos/account/useAccountEngine.ts index 8afc801..988f2d8 100644 --- a/app/vibenet/demos/account/useAccountEngine.ts +++ b/app/vibenet/demos/account/useAccountEngine.ts @@ -1136,18 +1136,12 @@ export function useAccountEngine() { ? account.delegate(a.delegate ?? chain.deployment.accounts.default) : (account as ReturnType).create(); - // Broadcast a signed 8130 tx and wait for inclusion. Throws TxPendingError on - // timeout (submitted but unconfirmed), a plain Error if any phase reverts. - const broadcast8130 = async (signedTx: Hex, onStatus?: (s: 'submitting' | 'confirming') => void): Promise => { - const client = makeRpcClient(); - onStatus?.('submitting'); - const txHash = (await client.request({ - method: 'eth_sendRawTransaction', - params: [signedTx], - })) as Hex; - onStatus?.('confirming'); + // Wait for a broadcast tx to be included and check that it — and every 8130 + // phase in it — succeeded. Throws TxPendingError if it is still not included + // when the timeout runs out, a plain Error if anything reverted. + const awaitInclusion = async (txHash: Hex, timeout = 30_000): Promise => { try { - const receipt = await waitForTransactionReceipt(client as never, { hash: txHash, timeout: 30_000 }); + const receipt = await waitForTransactionReceipt(makeRpcClient() as never, { hash: txHash, timeout }); if (receipt.status === '0x0') throw new Error(`Transaction reverted onchain (${txHash}).`); const phases = receipt.eip8130?.phaseStatuses ?? []; const failedPhase = phases.findIndex((s: Hex) => s === '0x0'); @@ -1159,6 +1153,19 @@ export function useAccountEngine() { return txHash; }; + // Broadcast a signed 8130 tx and wait for inclusion. Throws TxPendingError on + // timeout (submitted but unconfirmed), a plain Error if any phase reverts. + const broadcast8130 = async (signedTx: Hex, onStatus?: (s: 'submitting' | 'confirming') => void): Promise => { + const client = makeRpcClient(); + onStatus?.('submitting'); + const txHash = (await client.request({ + method: 'eth_sendRawTransaction', + params: [signedTx], + })) as Hex; + onStatus?.('confirming'); + return awaitInclusion(txHash); + }; + // Live EIP-8130 state used while preparing a transaction. This is the only // source of truth for deployment and config sequences; the persisted // `deployed` / `configSeq` fields are display caches and are never consulted @@ -1532,25 +1539,72 @@ export function useAccountEngine() { if (count !== null && count > startSequence) startSequence = count; } + // An account's code and the actors bound to it reach every replica a moment + // after the transaction that wrote them lands, so a batch prepared right + // behind the one that deployed the account is validated against a replica + // that has not seen it yet and is rejected with "actor is not bound" before + // it is ever broadcast. Wait for the state to catch up and prepare it again. + // A transaction that expired without landing is definitively dropped, so + // that one can go straight back out. Everything else — a revert, a rejected + // call, a broadcast whose receipt never arrived — is real and propagates. + const attemptBatch = async (send: () => Promise): Promise => { + for (let attempt = 0; ; attempt += 1) { + try { + return await send(); + } catch (error) { + if (attempt >= 3) throw error; + // Broadcast but not included in time. Give it a little longer, then + // ask the node whether it still holds the transaction: one it has + // dropped is never coming back, so the batch is signed and sent again + // on the same nonce. One it still holds must be left alone — a second + // copy would only collide with it. + if (error instanceof TxPendingError) { + const landed = await awaitInclusion(error.txHash, 15_000).catch((err) => { + if (err instanceof TxPendingError) return null; + throw err; + }); + if (landed) return landed; + const known = await makeRpcClient() + .request({ method: 'eth_getTransactionByHash', params: [error.txHash] }) + .catch(() => 'unreadable'); + if (known !== null) throw error; + continue; + } + const message = error instanceof Error ? error.message : String(error); + const expired = /expired before landing/i.test(message); + if (!expired && !/actor is not bound/i.test(message)) throw error; + await new Promise((resolve) => setTimeout(resolve, expired ? 1_000 : 5_000)); + } + } + }; + const results: { hash: Hex; serialized: Hex; mode: 'self' | 'token' }[] = []; const mode: 'self' | 'token' = tokenGas ? 'token' : 'self'; for (const [index, batch] of batches.entries()) { onBatchStart?.(index, batches.length); const first = index === 0; - const { serialized, nextSeq } = await signComposed( - acct, - signer, - batch.calls.map((call) => newCallRow({ ...call, value: '0' })), - first ? presigned : [], - first ? changeSeq : null, - undefined, - undefined, - payerOpt, - { nonceSequence: startSequence + BigInt(index), assumeDeployed: !first || undefined }, - ); - const hash = await broadcast8130(serialized); - if (first) applyLandedBundle(acct, nextSeq, bundle); - const result = { hash, serialized, mode }; + // Written by whichever signing attempt produced the transaction that + // landed — a retry re-signs, so these can't be read from the first one. + let landedSeq: number | null = null; + let landedSerialized: Hex = '0x'; + const hash = await attemptBatch(async () => { + const { serialized, nextSeq } = await signComposed( + acct, + signer, + batch.calls.map((call) => newCallRow({ ...call, value: '0' })), + first ? presigned : [], + first ? changeSeq : null, + undefined, + undefined, + payerOpt, + { nonceSequence: startSequence + BigInt(index), assumeDeployed: !first || undefined }, + ); + landedSeq = nextSeq; + landedSerialized = serialized; + return broadcast8130(serialized); + }); + if (first && landedSeq !== null) applyLandedBundle(acct, landedSeq, bundle); + const result = { hash, serialized: landedSerialized, mode }; results.push(result); onBatchResult?.(index, result); }