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..29700ff 100644 --- a/app/demos/b20/B20Demo.tsx +++ b/app/demos/b20/B20Demo.tsx @@ -1,34 +1,28 @@ '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 { Select, type SelectGroup } from '../../components/ui/Select'; +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 +35,31 @@ import { } from './lib/protocol'; import { readRecent, readRecentPolicies, writeRecent, writeRecentPolicy } from './lib/recent'; import { sampleTokenForAddress } from './lib/samples'; +import { canUseTokenForGas } from './lib/tokenGas'; +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 +70,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 +102,12 @@ export function B20Demo() { const [checks, setChecks] = useState | null>(null); const [activity, setActivity] = useState([]); const [busy, setBusy] = useState(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); @@ -72,27 +116,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 +204,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 +239,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 = canUseTokenForGas(token?.variant, 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 +467,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 +494,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 +506,75 @@ 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: (batch, index, total) => { + current = batch.label; + setBatchProgress({ label: batch.label, detail: batch.detail, index, total }); + setActivity((rows) => [ + { label: batch.label, detail: batch.detail, state: 'pending' }, + ...rows, + ]); + }, + onBatchResult: (batch, result) => { + setActivity((rows) => + rows.map((row) => + row.label === batch.label && row.state === 'pending' + ? { + ...row, + state: 'success' as const, + hash: result.hash, + label: annotateMode(batch.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(() => { @@ -382,6 +603,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 · sponsored fees 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 (
@@ -389,28 +644,98 @@ 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`} + )} + {headerToken || recent.length > 1 ? ( + + + {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.

@@ -178,14 +210,14 @@ export function PolicyModule({ )} > {token.variant === 'stablecoin' - ? '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.' : tokenAccess === 'sample' ? 'Sample token · Read only' : tokenAccess === 'operator' ? 'Your token · You can publish updates' : tokenAccess === 'external' - ? 'Another token · You cannot publish updates' - : 'Connect a wallet to check access'} + ? 'Another token · Read only' + : 'Make a wallet to check access'}
- {policy.id === 0n ? 'No policy set' : policy.exists ? 'Policy active' : 'Policy unavailable'} + {policy.id === 0n ? 'Open to everyone' : policy.exists ? 'Policy active' : 'Policy unavailable'} {policy.id === 0n ? B20_HELP.statusWideOpen diff --git a/app/demos/b20/lib/constants.ts b/app/demos/b20/lib/constants.ts index ee1840e..7d28517 100644 --- a/app/demos/b20/lib/constants.ts +++ b/app/demos/b20/lib/constants.ts @@ -3,12 +3,12 @@ import { createPublicClient, http } from 'viem'; import { VIBENET_RPC_URL } from '../../../vibenet/library/config'; import type { Module } from './types'; -// The Vibenet demo purposefully uses a raw EIP-1193 wallet rather than adding a -// second provider framework. viem owns ABI correctness and public RPC reads. export const CHAIN_ID = 84538453; export const client = createPublicClient({ transport: http(VIBENET_RPC_URL) }); export const STORAGE_KEY = 'vibenet.b20.recent.v1'; export const POLICY_STORAGE_KEY = 'vibenet.b20.recent-policies.v1'; +export const WALLET_STORAGE_KEY = 'vibenet.b20.wallet.v1'; +export const PAYER_STORAGE_KEY = 'vibenet.b20.payer.v1'; export const INITIAL_ALLOCATION_MEMO = 'Initial deposit'; export const INITIAL_ALLOCATION_MAX = 100n; diff --git a/app/demos/b20/lib/deployment.test.ts b/app/demos/b20/lib/deployment.test.ts new file mode 100644 index 0000000..8b05c28 --- /dev/null +++ b/app/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 sponsored 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/demos/b20/lib/deployment.ts b/app/demos/b20/lib/deployment.ts new file mode 100644 index 0000000..716e1e2 --- /dev/null +++ b/app/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 sponsored 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/demos/b20/lib/memoTransfer.test.ts b/app/demos/b20/lib/memoTransfer.test.ts new file mode 100644 index 0000000..b67f7b3 --- /dev/null +++ b/app/demos/b20/lib/memoTransfer.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldUseDelegatedMemoTransfer } from './memoTransfer'; + +describe('B20 memo transfer selection', () => { + it('always uses a direct transfer for Stablecoins', () => { + expect(shouldUseDelegatedMemoTransfer('stablecoin', false, true)).toBe(false); + expect(shouldUseDelegatedMemoTransfer('stablecoin', true, true)).toBe(false); + }); + + it('preserves the delegated transfer option for Assets', () => { + expect(shouldUseDelegatedMemoTransfer('asset', true, true)).toBe(true); + expect(shouldUseDelegatedMemoTransfer('asset', false, true)).toBe(false); + expect(shouldUseDelegatedMemoTransfer('asset', true, false)).toBe(false); + }); +}); diff --git a/app/demos/b20/lib/memoTransfer.ts b/app/demos/b20/lib/memoTransfer.ts new file mode 100644 index 0000000..ed69bdc --- /dev/null +++ b/app/demos/b20/lib/memoTransfer.ts @@ -0,0 +1,7 @@ +export function shouldUseDelegatedMemoTransfer( + variant: 'asset' | 'stablecoin', + batchApprove: boolean, + hasWallet: boolean, +): boolean { + return variant === 'asset' && batchApprove && hasWallet; +} diff --git a/app/demos/b20/lib/protocol.ts b/app/demos/b20/lib/protocol.ts index f9dde53..38bb187 100644 --- a/app/demos/b20/lib/protocol.ts +++ b/app/demos/b20/lib/protocol.ts @@ -175,6 +175,20 @@ export const b20Abi = [ inputs: [{ type: 'bytes32' }, { type: 'address' }], outputs: [{ type: 'bool' }], }, + { + type: 'function', + name: 'allowance', + stateMutability: 'view', + inputs: [{ type: 'address' }, { type: 'address' }], + outputs: [{ type: 'uint256' }], + }, + { + type: 'function', + name: 'approve', + stateMutability: 'nonpayable', + inputs: [{ type: 'address' }, { type: 'uint256' }], + outputs: [{ type: 'bool' }], + }, { type: 'function', name: 'transferWithMemo', diff --git a/app/demos/b20/lib/tokenGas.test.ts b/app/demos/b20/lib/tokenGas.test.ts new file mode 100644 index 0000000..d575794 --- /dev/null +++ b/app/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/demos/b20/lib/tokenGas.ts b/app/demos/b20/lib/tokenGas.ts new file mode 100644 index 0000000..38cf103 --- /dev/null +++ b/app/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/demos/b20/lib/wallet8130.test.ts b/app/demos/b20/lib/wallet8130.test.ts new file mode 100644 index 0000000..a2e285e --- /dev/null +++ b/app/demos/b20/lib/wallet8130.test.ts @@ -0,0 +1,95 @@ +import { isAddress } from 'viem'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { VIBENET } from '../../account/library/chains'; +import { PAYER_STORAGE_KEY, WALLET_STORAGE_KEY } from './constants'; +import { + clearPayer, + clearWallet, + createPayer, + createWallet, + loadPayer, + loadWallet, + payerAddress, + savePayer, + saveWallet, + tokenGasFee, + walletAddress, + type StoredB20Wallet, +} from './wallet8130'; + +function installLocalStorage() { + const values = new Map(); + vi.stubGlobal('window', { + localStorage: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, + }); + return values; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('b20 demo wallet storage', () => { + it('creates a v1 wallet with 32-byte key material', () => { + const wallet = createWallet(); + expect(wallet.v).toBe(1); + expect(wallet.privateKey).toMatch(/^0x[0-9a-f]{64}$/); + expect(wallet.salt).toMatch(/^0x[0-9a-f]{64}$/); + expect(createWallet().privateKey).not.toBe(wallet.privateKey); + }); + + it('round-trips through localStorage', () => { + installLocalStorage(); + const wallet = createWallet(); + saveWallet(wallet); + expect(loadWallet()).toEqual(wallet); + clearWallet(); + expect(loadWallet()).toBeNull(); + }); + + it('rejects corrupt or versioned-away payloads', () => { + const values = installLocalStorage(); + values.set(WALLET_STORAGE_KEY, 'not json'); + expect(loadWallet()).toBeNull(); + values.set(WALLET_STORAGE_KEY, JSON.stringify({ v: 2, privateKey: '0x1', salt: '0x2', createdAt: 0 })); + expect(loadWallet()).toBeNull(); + values.set(WALLET_STORAGE_KEY, JSON.stringify({ v: 1, createdAt: 0 })); + expect(loadWallet()).toBeNull(); + }); + + it('round-trips the demo payer key and derives its EOA address', () => { + const values = installLocalStorage(); + const payer = createPayer(); + expect(payer.privateKey).toMatch(/^0x[0-9a-f]{64}$/); + savePayer(payer); + expect(loadPayer()).toEqual(payer); + expect(isAddress(payerAddress(payer))).toBe(true); + clearPayer(); + expect(loadPayer()).toBeNull(); + values.set(PAYER_STORAGE_KEY, JSON.stringify({ v: 2, privateKey: '0x1' })); + expect(loadPayer()).toBeNull(); + }); + + it('charges a flat 0.1-token gas fee scaled to decimals', () => { + expect(tokenGasFee(18)).toBe(10n ** 17n); + expect(tokenGasFee(6)).toBe(10n ** 5n); + expect(tokenGasFee(0)).toBe(1n); + }); + + it('derives a deterministic address from key + salt + deployment', () => { + const wallet: StoredB20Wallet = { + v: 1, + privateKey: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', + salt: `0x${'11'.repeat(32)}`, + createdAt: 0, + }; + const address = walletAddress(wallet, VIBENET.deployment); + expect(isAddress(address)).toBe(true); + expect(walletAddress(wallet, VIBENET.deployment)).toBe(address); + // A different salt yields a different account. + expect(walletAddress({ ...wallet, salt: `0x${'22'.repeat(32)}` }, VIBENET.deployment)).not.toBe(address); + }); +}); diff --git a/app/demos/b20/lib/wallet8130.ts b/app/demos/b20/lib/wallet8130.ts new file mode 100644 index 0000000..dd7fdc1 --- /dev/null +++ b/app/demos/b20/lib/wallet8130.ts @@ -0,0 +1,532 @@ +import { useEffect, useState } from 'react'; +import { createPublicClient, http, type Address, type Hex } from 'viem'; + +import { + computeAddress, + createPayerClient, + encodeFunctionData, + encodeWalletCalls, + estimateGas, + generatePrivateKey, + getTransactionCount, + key, + parsePayerError, + privateKeyToAccount, + sendCalls, + sendSponsoredCalls, + toAccount, + upgradeableProxyBytecode, + waitForTransactionReceipt, + type Eip8130Deployment, + type ToAccountReturnType, +} from '@aa'; + +import { vibenetApi } from '../../../vibenet/library/client'; +import { ACCOUNT_PAYER_URL, VIBENET_RPC_URL } from '../../../vibenet/library/config'; +import { deploymentFromContracts, estimateTxGas, VIBENET } from '../../account/library/chains'; +import { CHAIN_ID, PAYER_STORAGE_KEY, WALLET_STORAGE_KEY } from './constants'; + +// The demo wallet is an EIP-8130 smart account made from a throwaway in-browser +// key. Only the key material and CREATE2 salt persist — the address is derived +// at runtime because it also commits to the AccountConfiguration system +// contract, which moves whenever the devnet resets. After a reset the same +// stored key simply yields a fresh (empty) account, which matches the chain: +// the reset wiped its tokens anyway. +export type StoredB20Wallet = { v: 1; privateKey: Hex; salt: Hex; createdAt: number }; + +export function loadWallet(): StoredB20Wallet | null { + if (typeof window === 'undefined') return null; + try { + const stored = JSON.parse(window.localStorage.getItem(WALLET_STORAGE_KEY) ?? 'null') as StoredB20Wallet | null; + if (!stored || stored.v !== 1 || typeof stored.privateKey !== 'string' || typeof stored.salt !== 'string') + return null; + return stored; + } catch { + return null; + } +} + +export function saveWallet(wallet: StoredB20Wallet): void { + try { + window.localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(wallet)); + } catch { + // The wallet still works for this page load; it just won't survive a refresh. + } +} + +export function clearWallet(): void { + try { + window.localStorage.removeItem(WALLET_STORAGE_KEY); + } catch { + /* unavailable */ + } +} + +export function createWallet(): StoredB20Wallet { + const salt = new Uint8Array(32); + crypto.getRandomValues(salt); + return { + v: 1, + privateKey: generatePrivateKey(), + salt: `0x${Array.from(salt, (b) => b.toString(16).padStart(2, '0')).join('')}` as Hex, + createdAt: Date.now(), + }; +} + +// The demo's own ERC-8168 payer: a plain faucet-funded EOA whose key lives in +// the browser. Any funded key can co-sign `payerAuth` (validated like an EOA +// signature), which is what lets the demo charge gas in the user's B20 — the +// hosted payer only accepts USDV. +export type StoredB20Payer = { v: 1; privateKey: Hex; createdAt: number }; + +export function loadPayer(): StoredB20Payer | null { + if (typeof window === 'undefined') return null; + try { + const stored = JSON.parse(window.localStorage.getItem(PAYER_STORAGE_KEY) ?? 'null') as StoredB20Payer | null; + if (!stored || stored.v !== 1 || typeof stored.privateKey !== 'string') return null; + return stored; + } catch { + return null; + } +} + +export function savePayer(payer: StoredB20Payer): void { + try { + window.localStorage.setItem(PAYER_STORAGE_KEY, JSON.stringify(payer)); + } catch { + /* unavailable */ + } +} + +export function clearPayer(): void { + try { + window.localStorage.removeItem(PAYER_STORAGE_KEY); + } catch { + /* unavailable */ + } +} + +export function createPayer(): StoredB20Payer { + return { v: 1, privateKey: generatePrivateKey(), createdAt: Date.now() }; +} + +export function payerAddress(payer: StoredB20Payer): Address { + return privateKeyToAccount(payer.privateKey).address; +} + +/** Flat demo fee for token-paid gas: 0.1 of the token per transaction. */ +export function tokenGasFee(decimals: number): bigint { + return decimals > 0 ? 10n ** BigInt(decimals - 1) : 1n; +} + +// Proxy code must target the deployed DefaultAccount implementation; the +// upgradeable implementation is not deployed on the native vibenet path. +function accountParams(wallet: StoredB20Wallet, deployment: Eip8130Deployment) { + const owner = privateKeyToAccount(wallet.privateKey); + return { + owner, + userSalt: wallet.salt, + code: upgradeableProxyBytecode(deployment.accounts.default), + initialActors: [key.k1(owner.address)], + accountConfigAddress: deployment.accountConfiguration, + }; +} + +export function walletAddress(wallet: StoredB20Wallet, deployment: Eip8130Deployment): Address { + const { userSalt, code, initialActors, accountConfigAddress } = accountParams(wallet, deployment); + return computeAddress({ userSalt, code, initialActors, accountConfigAddress }); +} + +function accountFor(wallet: StoredB20Wallet, deployment: Eip8130Deployment): ToAccountReturnType { + const { owner, ...params } = accountParams(wallet, deployment); + return toAccount({ signer: owner, ...params }); +} + +/** + * Live EIP-8130 system-contract addresses, starting from the static + * last-known-good set. A devnet reset redeploys them at new addresses; fetching + * from the dataplane means the demo survives a reset without a code change. + */ +export function useDeployment(): Eip8130Deployment { + const [deployment, setDeployment] = useState(VIBENET.deployment); + useEffect(() => { + const controller = new AbortController(); + vibenetApi + .contracts(controller.signal) + .then((contracts) => { + const next = deploymentFromContracts(contracts); + if (next) setDeployment(next); + }) + .catch(() => { + /* offline / aborted → keep the static fallback */ + }); + return () => controller.abort(); + }, []); + return deployment; +} + +// Dedicated client for the sponsored path: sendSponsoredCalls requires a +// configured `chain` (the shared read client in constants.ts has none). +const sponsorClient = createPublicClient({ + chain: { + id: CHAIN_ID, + name: 'Vibenet', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [VIBENET_RPC_URL] } }, + }, + transport: http(VIBENET_RPC_URL), +}); + +const payerClient = createPayerClient({ url: ACCOUNT_PAYER_URL }); + +export type SponsoredCall = { to: Address; data: Hex }; +export type TokenGasConfig = { token: Address; symbol: string; decimals: number; payer: StoredB20Payer }; +export type SendMode = 'sponsored' | 'self' | 'token'; +export type SendResult = { hash: Hex; mode: SendMode }; + +// Below this the payer EOA gets a fresh faucet drip before co-signing. +const MIN_PAYER_ETH = 3_000_000_000_000_000n; // 0.003 ETH +// Below this the account can't reliably self-pay a transaction. +const MIN_SELF_PAY_ETH = 2_000_000_000_000_000n; // 0.002 ETH + +const transferAbi = [ + { + type: 'function', + name: 'transfer', + stateMutability: 'nonpayable', + inputs: [{ type: 'address' }, { type: 'uint256' }], + outputs: [{ type: 'bool' }], + }, +] as const; + +export async function getEthBalance(address: Address): Promise { + // Pin to a fresh block: the public RPC is load-balanced across replicas, and + // an unpinned read from a lagging one returns stale balances. A replica that + // doesn't have the block errors instead, which callers treat as "no update". + try { + const blockNumber = await sponsorClient.getBlockNumber({ cacheTime: 0 }); + return await sponsorClient.getBalance({ address, blockNumber }); + } catch { + return null; + } +} + +/** + * Drip 0.1 vibenet ETH to an address and wait for it to land. Retries through + * the faucet's ~10s cooldown; resolves false if funding never shows. + */ +export async function seedWithEth(address: Address): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await vibenetApi.faucet.drip({ address }); + break; + } catch { + if (attempt >= 3) return false; + await new Promise((resolve) => setTimeout(resolve, 11_000)); + } + } + for (let i = 0; i < 30; i += 1) { + const balance = await getEthBalance(address); + if (balance !== null && balance > 0n) return true; + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + return false; +} + +function checkReceipt(receipt: Record & { eip8130?: { phaseStatuses?: readonly Hex[] } }): void { + // Raw JSON-RPC receipt: status is '0x0'/'0x1'. An overall success with a + // reverted phase is still a valid 8130 inclusion, so check both. + if (receipt.status === '0x0') throw new Error('The transaction reverted onchain. Try again.'); + const phases: readonly Hex[] = receipt.eip8130?.phaseStatuses ?? []; + if (phases.some((status) => status === '0x0')) + throw new Error('The transaction was included, but one of its calls reverted. Check the inputs and try again.'); +} + +/** + * Broadcast via `send`, confirm the receipt, and retry transient devnet + * failures: + * - "actor is not bound": the payer/node validate against account config that + * lags the head by ~1 block right after the account's deploy tx. + * - accepted-then-dropped / expired: hosted-payer terms carry a ~15s expiry, so + * a transaction that misses its inclusion window silently disappears. + * Re-sending is safe when the nonce is pinned (a duplicate can't execute + * twice); if a timed-out original mined late, its receipt is used instead. + */ +async function confirmWithRetries(send: () => Promise): Promise { + let retries = 3; + let timedOutHash: Hex | null = null; + for (;;) { + try { + const hash = await send(); + try { + const receipt = await waitForTransactionReceipt(sponsorClient as never, { hash, timeout: 30_000 }); + checkReceipt(receipt); + return hash; + } catch (error) { + if (/timed out/i.test(error instanceof Error ? error.message : '')) timedOutHash = hash; + throw error; + } + } catch (error) { + if (timedOutHash) { + const receipt = await waitForTransactionReceipt(sponsorClient as never, { + hash: timedOutHash, + timeout: 4_000, + }).catch(() => null); + if (receipt) { + checkReceipt(receipt); + return timedOutHash; + } + } + const message = error instanceof Error ? error.message : String(error); + const transient = /actor is not bound|timed out|expired before landing/i.test(message); + if (retries <= 0 || !transient) throw error; + retries -= 1; + // An expired tx is definitively dropped — resend immediately. The other + // transient causes are state-propagation lag, which needs the pause. + await new Promise((resolve) => setTimeout(resolve, /expired before landing/i.test(message) ? 1_000 : 5_000)); + } + } +} + +/** + * Estimate gas for a full 8130 transaction body, padded 20%, floored by the + * structural minimum so a pathological node under-estimate can't OOG-revert an + * otherwise valid inclusion. + */ +async function estimateWithFloor(params: { + sender: Address; + accountChanges?: readonly unknown[]; + phases: Array>; + payer?: Address; +}): Promise { + const wire = encodeWalletCalls({ + account: params.sender, + calls: params.phases.map((phase) => phase.map((call) => ({ ...call, value: 0n }))), + }); + let estimated: bigint | null = null; + try { + estimated = await estimateGas(sponsorClient, { + sender: params.sender, + ...(params.accountChanges ? { accountChanges: params.accountChanges as never } : {}), + calls: wire, + ...(params.payer ? { payer: params.payer } : {}), + }); + } catch { + estimated = null; + } + const floor = BigInt( + estimateTxGas({ + mode: 'eip8130-native', + deploy: Boolean(params.accountChanges), + calls: params.phases.reduce((total, phase) => total + phase.length, 0), + keyChanges: 0, + fallback: estimated === null, + }), + ); + const padded = estimated === null ? floor : (estimated * 120n) / 100n; + return padded > floor ? padded : floor; +} + +/** Whether an error came from the sponsorship layer (worth a self-paid retry). */ +function isPayerFailure(error: unknown): boolean { + if (parsePayerError(error) !== undefined) return true; + const message = error instanceof Error ? error.message : String(error); + return /payer|sponsor/i.test(message); +} + +/** + * The single transaction pipe for the demo's 8130 wallet. Gas is paid by, in + * order of preference: + * - `tokenGas` set → the demo's own in-browser ERC-8168 payer: phase-0 pays the + * payer a flat fee in the user's B20, the payer EOA's faucet ETH covers gas, + * and `payerAuth` is co-signed locally. + * - otherwise the hosted payer sponsors it; if the sponsorship layer fails and + * the account holds seeded ETH, it falls back to paying its own gas. + * The account deploys lazily: when the address has no code yet, the `create` + * account-change rides along with the transaction. + */ +export async function sendSponsored8130(params: { + wallet: StoredB20Wallet; + deployment: Eip8130Deployment; + calls: SponsoredCall[]; + /** + * Explicit 2D nonce sequence. The public RPC is served by replicas whose + * heads can differ, so the library's own nonce read may lag right after a + * previous transaction — sequential flows must pin the nonce themselves. + */ + nonceSequence?: number; + /** + * Skip the `eth_getCode` probe and treat the account as deployed. Needed + * right after the deploying transaction: the code read lags inclusion by a + * block, and re-attaching the create change to the next transaction makes + * validation reject it. + */ + assumeDeployed?: boolean; + /** Pay gas in the user's token via the demo's local payer. */ + tokenGas?: TokenGasConfig; +}): Promise { + const account = accountFor(params.wallet, params.deployment); + // Deployment state must come from the chain, not local state — it decides + // whether this tx carries the create change. + const code = params.assumeDeployed + ? '0x01' + : await sponsorClient.getCode({ address: account.address }).catch(() => undefined); + const deployed = typeof code === 'string' && code !== '0x'; + const accountChanges = deployed ? undefined : [account.create()]; + const calls = params.calls.map((call) => ({ ...call, value: 0n })); + const seq = params.nonceSequence; + + if (params.tokenGas) { + const { token, decimals, payer } = params.tokenGas; + const payerSigner = privateKeyToAccount(payer.privateKey); + const payerBalance = await getEthBalance(payerSigner.address); + if (payerBalance === null || payerBalance < MIN_PAYER_ETH) { + const seeded = await seedWithEth(payerSigner.address); + if (!seeded) throw new Error('Could not fund the demo gas payer from the faucet. Try again in a minute.'); + } + // ERC-8168 token payment: phase-0 pays the payer in the user's token, + // phase-1 runs the real calls — both land atomically or not at all. + const feeCall = { + to: token, + data: encodeFunctionData({ abi: transferAbi, functionName: 'transfer', args: [payerSigner.address, tokenGasFee(decimals)] }), + value: 0n, + }; + const phases = [[feeCall], calls]; + const gas = await estimateWithFloor({ sender: account.address, accountChanges, phases, payer: payerSigner.address }); + const hash = await confirmWithRetries(() => + sendCalls(sponsorClient, { + account, + ...(accountChanges ? { accountChanges: accountChanges as never } : {}), + calls: phases, + gas, + ...(seq === undefined ? {} : { nonceSequence: BigInt(seq) }), + payer: { account: payerSigner as never }, + }), + ); + return { hash, mode: 'token' }; + } + + try { + const hash = await confirmWithRetries(async () => { + const result: unknown = await sendSponsoredCalls(sponsorClient, { + account, + payerClient, + ...(accountChanges ? { accountChanges } : {}), + ...(seq === undefined ? {} : { nonceSequence: seq }), + calls, + context: { flow: 'b20' }, + }); + // mode:"send" resolves with `{ transactionHash }` at runtime even though + // the declared return type is a hex union — without this unwrap the + // receipt poll fails with an opaque "invalid type: map, expected 32 + // bytes" RPC error. + return typeof result === 'string' ? (result as Hex) : (result as { transactionHash: Hex }).transactionHash; + }); + return { hash, mode: 'sponsored' }; + } catch (error) { + // Sponsorship failed (budget, outage, decline). If the wallet holds the + // faucet-seeded ETH, pay for the transaction itself instead. + if (!isPayerFailure(error)) throw error; + const balance = await getEthBalance(account.address); + if (balance === null || balance < MIN_SELF_PAY_ETH) throw error; + const phases = [calls]; + const gas = await estimateWithFloor({ sender: account.address, accountChanges, phases }); + const hash = await confirmWithRetries(() => + sendCalls(sponsorClient, { + account, + ...(accountChanges ? { accountChanges: accountChanges as never } : {}), + calls: phases, + gas, + ...(seq === undefined ? {} : { nonceSequence: BigInt(seq) }), + }), + ); + return { hash, mode: 'self' }; + } +} + +export type SponsoredBatch = { label: string; detail?: string; calls: SponsoredCall[] }; + +/** + * Run several sponsored transactions in sequence, one per batch. + * + * The hosted payer's sponsorship budget covers only ~300k gas of execution per + * transaction (its `maxCost` divided by the gas price) — heavier work has its + * gas cut mid-phase and reverts, so flows like token deployment must be split + * into transactions that each fit the budget. The wallet address and key are + * created locally first; when that address has not been activated onchain yet, + * a no-op transaction deploys it separately so account activation never shares + * a sponsorship budget with the token deployment. + * + * Returns one tx hash per batch. Throws on the first failing batch; earlier + * batches stay applied (callers should make batches individually meaningful). + */ +export async function sendSponsoredBatches(params: { + wallet: StoredB20Wallet; + deployment: Eip8130Deployment; + batches: SponsoredBatch[]; + onProgress?: (batch: SponsoredBatch, index: number, total: number) => void; + /** Fires as each batch confirms, with its tx result. */ + onBatchResult?: (batch: SponsoredBatch, result: SendResult) => void; +}): Promise { + const { wallet, deployment, batches, onProgress, onBatchResult } = params; + const address = walletAddress(wallet, deployment); + const code = await sponsorClient.getCode({ address }).catch(() => undefined); + const deployed = typeof code === 'string' && code !== '0x'; + const all: SponsoredBatch[] = deployed + ? batches + : [ + { + label: 'Registering wallet', + calls: [{ to: address, data: '0x' }], + }, + ...batches, + ]; + + // The public RPC is served by replicas whose heads can differ, so a nonce + // read taken right after a transaction may lag behind it. Read a few times, + // keep the highest value, and assign each batch an explicit sequence from + // there — never re-read mid-flow. + let startCount = 0n; + for (let i = 0; i < 3; i += 1) { + const count = await getTransactionCount(sponsorClient, { address }).catch(() => null); + if (count !== null && count > startCount) startCount = count; + } + + const results: SendResult[] = []; + for (const [index, batch] of all.entries()) { + onProgress?.(batch, index, all.length); + try { + const result = await sendSponsored8130({ + wallet, + deployment, + calls: batch.calls, + nonceSequence: Number(startCount) + index, + // Once any batch confirmed, the account exists — the code probe + // would lag a block and wrongly re-attach the create change. + assumeDeployed: deployed || index > 0, + }); + results.push(result); + onBatchResult?.(batch, result); + } catch (error) { + throw new Error( + `${batch.label} failed: ${payerErrorMessage(error) ?? (error instanceof Error ? error.message : String(error))}`, + ); + } + } + return deployed ? results : results.slice(1); +} + +/** Friendly message for payer rejections; `null` when the error is not one. */ +export function payerErrorMessage(error: unknown): string | null { + const rejected = parsePayerError(error); + if (!rejected) return 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.'; + case 'TEMPORARILY_UNAVAILABLE': + return 'The gas sponsor is temporarily unavailable. Try again in a moment.'; + default: + return `The gas sponsor declined this transaction${rejected.reason ? `: ${rejected.reason}` : '.'}`; + } +} diff --git a/app/demos/catalogue.ts b/app/demos/catalogue.ts index 3848192..eb099e2 100644 --- a/app/demos/catalogue.ts +++ b/app/demos/catalogue.ts @@ -28,11 +28,11 @@ export const DEMOS: DemoEntry[] = [ title: 'B20 Playground', shortTitle: 'B20 Playground', summary: - 'Inspect policy scopes, attach transaction memos, publish Asset announcements, and create Base-native B20 tokens.', + '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.', points: [ - 'Asset and Stablecoin factory flows', - 'Policy Registry inspection and address checks', - 'Memo operations and Asset announcements', + 'One-click 8130 wallet — faucet-seeded, gasless via payer sponsorship', + 'Pay gas with your own stablecoin (ERC-8168 token payment)', + 'Policies, memos, and Asset announcements', ], available: true, }, diff --git a/vitest.config.ts b/vitest.config.ts index 8e730d5..7b5f19c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ +import path from 'node:path'; + import { defineConfig } from 'vitest/config'; export default defineConfig({ + resolve: { + // Mirror the `@aa` path alias from tsconfig.json (vendored EIP-8130 viem build). + alias: { '@aa': path.resolve(__dirname, 'vendor/aa/index.js') }, + }, test: { globals: true, environment: 'node',