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 2add707..988f2d8 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, @@ -1135,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'); @@ -1158,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 @@ -1206,7 +1214,17 @@ 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 }, + // 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); @@ -1230,7 +1248,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 }); @@ -1268,10 +1291,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 @@ -1360,18 +1385,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 +1430,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 +1455,164 @@ 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' }; + }; + + /** + * 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; + } + + // 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; + // 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); + } + return results; + }; + + const sendActiveCall = async ({ to, data }: { to: Address; data: Hex }) => { + const { hash, serialized } = await sendActiveCalls({ calls: [{ to, data }] }); return { hash, serialized }; }; @@ -2341,6 +2530,8 @@ export function useAccountEngine() { broadcast8130, signComposed, sendActiveCall, + sendActiveCalls, + sendActiveCallsBatches, applyLandedBundle, handleSeqMismatch, pendingBundleFor, diff --git a/app/vibenet/demos/b20/B20Demo.tsx b/app/vibenet/demos/b20/B20Demo.tsx index 3efece0..200ab38 100644 --- a/app/vibenet/demos/b20/B20Demo.tsx +++ b/app/vibenet/demos/b20/B20Demo.tsx @@ -1,22 +1,26 @@ '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 { 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'; 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 +32,32 @@ 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, + 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 +73,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 +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; + detail?: string; + index: number; + total: number; + } | null>(null); const [isOperator, setIsOperator] = useState(false); const [isTokenAdmin, setIsTokenAdmin] = useState(false); const [tokenAdminLoading, setTokenAdminLoading] = useState(false); @@ -78,6 +120,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 +167,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 +202,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 = canUseTokenForGas(token?.variant, 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 +375,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,7 +423,7 @@ 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; @@ -283,7 +431,83 @@ export function B20Demo() { setBusy(null); } }, - [inspect, module, refreshWallet, token, activeAccount, engine], + [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; detail?: 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 tokenGas = + gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer + ? { + token: token.address, + decimals: token.decimals, + payer: payerSigner(storedPayer), + fee: tokenGasFee(token.decimals), + } + : undefined; + try { + // 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 results.map((result) => result.hash); + } catch (error) { + const detail = payerErrorMessage(error) ?? walletErrorMessage(error); + trackB20Action(module, action, 'error'); + setInspectError(detail); + return null; + } finally { + setBatchProgress(null); + setBusy(null); + } + }, + [activeAccount, engine, gasMode, module, refreshWallet, storedPayer, token], ); useEffect(() => { @@ -312,16 +536,47 @@ 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 ( } - activityCount={engine.activity.length} - activityEmptyMessage="Nothing has happened yet." className="animate-in gap-5 pb-6 dark:text-white" >
-
+
+
+ {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.

@@ -179,14 +211,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/vibenet/demos/b20/lib/constants.ts b/app/vibenet/demos/b20/lib/constants.ts index 8dac5d9..0b0d8f1 100644 --- a/app/vibenet/demos/b20/lib/constants.ts +++ b/app/vibenet/demos/b20/lib/constants.ts @@ -3,12 +3,11 @@ import { createPublicClient, http } from 'viem'; import { VIBENET_RPC_URL } from '../../../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 PAYER_STORAGE_KEY = 'vibenet.b20.payer.v1'; export const INITIAL_ALLOCATION_MEMO = 'Initial deposit'; export const INITIAL_ALLOCATION_MAX = 100n; 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.test.ts b/app/vibenet/demos/b20/lib/gasPayer.test.ts new file mode 100644 index 0000000..c4bdc74 --- /dev/null +++ b/app/vibenet/demos/b20/lib/gasPayer.test.ts @@ -0,0 +1,50 @@ +import { isAddress } from 'viem'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { PAYER_STORAGE_KEY } from './constants'; +import { clearPayer, createPayer, loadPayer, payerAddress, savePayer, tokenGasFee } from './gasPayer'; + +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 gas payer', () => { + it('round-trips the demo payer key and derives its EOA address', () => { + const values = installLocalStorage(); + const payer = createPayer(); + expect(payer.v).toBe(1); + expect(payer.privateKey).toMatch(/^0x[0-9a-f]{64}$/); + expect(createPayer().privateKey).not.toBe(payer.privateKey); + 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('rejects corrupt payer payloads', () => { + const values = installLocalStorage(); + values.set(PAYER_STORAGE_KEY, 'not json'); + expect(loadPayer()).toBeNull(); + values.set(PAYER_STORAGE_KEY, JSON.stringify({ v: 1 })); + 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); + }); +}); diff --git a/app/vibenet/demos/b20/lib/gasPayer.ts b/app/vibenet/demos/b20/lib/gasPayer.ts new file mode 100644 index 0000000..e79a26e --- /dev/null +++ b/app/vibenet/demos/b20/lib/gasPayer.ts @@ -0,0 +1,126 @@ +import { createPublicClient, http, type Address, type Hex } from 'viem'; + +import { generatePrivateKey, parsePayerError, privateKeyToAccount, type Signer } from '@aa'; + +import { vibenetApi } from '../../../library/client'; +import { VIBENET_RPC_URL } from '../../../library/config'; +import { PAYER_STORAGE_KEY } from './constants'; + +// 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. The account engine composes the transaction; +// this module owns the payer key, its funding, and the fee schedule. +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; +} + +/** The signer the engine co-signs `payerAuth` with. */ +export function payerSigner(payer: StoredB20Payer): Signer { + return privateKeyToAccount(payer.privateKey) as unknown as Signer; +} + +/** 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; +} + +const client = createPublicClient({ transport: http(VIBENET_RPC_URL) }); + +// 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 + +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 client.getBlockNumber({ cacheTime: 0 }); + return await client.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; +} + +/** + * Make sure the payer EOA can cover the gas it is about to underwrite. The + * first token-paid send happens right after the key is minted, so without this + * the co-signed transaction is rejected for an unfunded payer. + */ +export async function ensurePayerFunded(payer: StoredB20Payer): Promise { + const address = payerAddress(payer); + const balance = await getEthBalance(address); + if (balance !== null && balance >= MIN_PAYER_ETH) return; + const seeded = await seedWithEth(address); + if (!seeded) throw new Error('Could not fund the demo gas payer from the faucet. Try again in a minute.'); +} + +/** 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 demo gas payer's budget is used up. Wait a bit, then try again."; + case 'TEMPORARILY_UNAVAILABLE': + return 'The gas payer is temporarily unavailable. Try again in a moment.'; + default: + return `The gas payer declined this transaction${rejected.reason ? `: ${rejected.reason}` : '.'}`; + } +} diff --git a/app/vibenet/demos/b20/lib/protocol.ts b/app/vibenet/demos/b20/lib/protocol.ts index 5a4874f..d31d9c1 100644 --- a/app/vibenet/demos/b20/lib/protocol.ts +++ b/app/vibenet/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/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 4d8b93a..6e9d460 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -38,11 +38,11 @@ export const DEMOS: DemoEntry[] = [ title: 'Tokens', shortTitle: 'Tokens', summary: - '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: [ - 'Asset and Stablecoin factory flows', - 'Policy Registry inspection and address checks', - 'Memo operations and Asset announcements', + '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', ], available: true, }, diff --git a/vitest.config.mts b/vitest.config.mts index 8e730d5..7b5f19c 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -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',