From 2fdb0ab8387a13d8cfd1843d10a1e4615a801272 Mon Sep 17 00:00:00 2001
From: Soheima M
Date: Thu, 20 Aug 2026 15:27:07 +0200
Subject: [PATCH 1/5] added stablecoin gas demo
---
app/vibenet/demos/account/useAccountEngine.ts | 72 +++-
app/vibenet/demos/b20/B20Demo.tsx | 350 ++++++++++++++++--
.../b20/components/AnnouncementModule.tsx | 8 +-
.../demos/b20/components/AttachPolicy.tsx | 6 +-
.../demos/b20/components/CreatePolicy.tsx | 2 +-
.../demos/b20/components/DeployModule.tsx | 140 +++++--
.../demos/b20/components/MemoHistory.tsx | 27 +-
.../demos/b20/components/MemoModule.tsx | 157 +++++++-
.../demos/b20/components/PolicyModule.tsx | 12 +-
app/vibenet/demos/b20/lib/constants.ts | 3 +-
app/vibenet/demos/b20/lib/gasPayer.test.ts | 50 +++
app/vibenet/demos/b20/lib/gasPayer.ts | 126 +++++++
app/vibenet/demos/b20/lib/protocol.ts | 14 +
app/vibenet/demos/catalogue.ts | 8 +-
vitest.config.mts | 6 +
15 files changed, 861 insertions(+), 120 deletions(-)
create mode 100644 app/vibenet/demos/b20/lib/gasPayer.test.ts
create mode 100644 app/vibenet/demos/b20/lib/gasPayer.ts
diff --git a/app/vibenet/demos/account/useAccountEngine.ts b/app/vibenet/demos/account/useAccountEngine.ts
index 2add707..d891c0e 100644
--- a/app/vibenet/demos/account/useAccountEngine.ts
+++ b/app/vibenet/demos/account/useAccountEngine.ts
@@ -27,6 +27,7 @@ import {
ecrecoverAuthenticator,
type Eip8130Deployment,
encodeSessionPolicyConfig,
+ encodeTokenTransfer,
encodeWalletCalls,
estimateGas,
generatePrivateKey,
@@ -1206,7 +1207,11 @@ export function useAccountEngine() {
changeSeq: number | null,
meta: Hex | undefined,
sessionPolicy?: AppPolicy,
- payerOpt?: { address: Address; phase0?: { to: Address; data: Hex }[] },
+ // `localSigner` co-signs `payerAuth` inline with a key this browser holds
+ // (the B20 demo's own faucet-funded payer EOA, which accepts an arbitrary
+ // B20 stablecoin as the fee). Without it the tx is serialized with an empty
+ // `payerAuth` for a hosted payer service to co-sign out of band.
+ payerOpt?: { address: Address; phase0?: { to: Address; data: Hex }[]; localSigner?: Signer },
): Promise<{ serialized: Hex; nextSeq: number }> => {
const signer = await buildSigner(signerWS);
const account = nativeAccountFor(a, signer, signerWS.authenticator);
@@ -1360,18 +1365,22 @@ export function useAccountEngine() {
gasLimit = BigInt(floorGas(true) || 200_000);
}
- const serialized = await account.signTransaction({
- chainId,
- accountChanges,
- calls: wire,
- metadata: meta,
- nonceKey: 0n,
- nonceSequence,
- maxFeePerGas: 1_000_000_000n,
- maxPriorityFeePerGas: 1_000_000n,
- gas: gasLimit,
- ...(payerOpt ? { payer: payerOpt.address, payerAuth: '0x' as Hex } : {}),
- });
+ const serialized = await account.signTransaction(
+ {
+ chainId,
+ accountChanges,
+ calls: wire,
+ metadata: meta,
+ nonceKey: 0n,
+ nonceSequence,
+ maxFeePerGas: 1_000_000_000n,
+ maxPriorityFeePerGas: 1_000_000n,
+ gas: gasLimit,
+ // A local payer signs `payerAuth` here, so don't stub it out.
+ ...(payerOpt ? { payer: payerOpt.address, ...(payerOpt.localSigner ? {} : { payerAuth: '0x' as Hex }) } : {}),
+ },
+ payerOpt?.localSigner ? { payer: { account: payerOpt.localSigner, address: payerOpt.address } } : undefined,
+ );
return { serialized, nextSeq };
};
@@ -1401,8 +1410,22 @@ export function useAccountEngine() {
// transaction builder. It deliberately reuses the full compose/broadcast
// implementation so deployment reconciliation, sub-account delegation, gas
// estimation, and eligible staged account changes behave consistently.
- const sendActiveCall = async ({ to, data }: { to: Address; data: Hex }) => {
+ //
+ // `calls` land as one atomic EIP-8130 transaction, so a demo can pair an
+ // approve with the call that spends it. `tokenGas` routes the transaction
+ // through a caller-supplied ERC-8168 payer: phase 0 pays that payer a flat
+ // fee in the given token and the payer's own ETH covers gas, which is how a
+ // demo lets you pay fees in a token you just created. Without it the account
+ // pays its own gas.
+ const sendActiveCalls = async ({
+ calls,
+ tokenGas,
+ }: {
+ calls: { to: Address; data: Hex }[];
+ tokenGas?: { token: Address; decimals: number; payer: Signer; fee: bigint };
+ }): Promise<{ hash: Hex; serialized: Hex; mode: 'self' | 'token' }> => {
if (!acct) throw new Error('Select an account before you continue.');
+ if (!calls.length) throw new Error('No calls to send.');
const signer =
postChangeOwnerSigners.find((s) => s.id === activeSignerId) ??
postChangeOwnerSigners[0] ??
@@ -1412,18 +1435,34 @@ export function useAccountEngine() {
const bundle = pendingBundleFor({ mode: 'owner-send' });
const presigned = bundle.map((item) => item.change);
const changeSeq = bundle.length ? bundle[bundle.length - 1].sequence : null;
+ const payerOpt = tokenGas
+ ? {
+ address: tokenGas.payer.address,
+ phase0: [
+ (({ to, data }) => ({ to, data }))(
+ encodeTokenTransfer({ token: tokenGas.token, to: tokenGas.payer.address, amount: tokenGas.fee }),
+ ),
+ ],
+ localSigner: tokenGas.payer,
+ }
+ : undefined;
const { serialized, nextSeq } = await signComposed(
acct,
signer,
- [newCallRow({ to, data, value: '0' })],
+ calls.map((call) => newCallRow({ ...call, value: '0' })),
presigned,
changeSeq,
undefined,
undefined,
- undefined,
+ payerOpt,
);
const hash = await broadcast8130(serialized);
applyLandedBundle(acct, nextSeq, bundle);
+ return { hash, serialized, mode: tokenGas ? 'token' : 'self' };
+ };
+
+ const sendActiveCall = async ({ to, data }: { to: Address; data: Hex }) => {
+ const { hash, serialized } = await sendActiveCalls({ calls: [{ to, data }] });
return { hash, serialized };
};
@@ -2341,6 +2380,7 @@ export function useAccountEngine() {
broadcast8130,
signComposed,
sendActiveCall,
+ sendActiveCalls,
applyLandedBundle,
handleSeqMismatch,
pendingBundleFor,
diff --git a/app/vibenet/demos/b20/B20Demo.tsx b/app/vibenet/demos/b20/B20Demo.tsx
index 3efece0..76e8917 100644
--- a/app/vibenet/demos/b20/B20Demo.tsx
+++ b/app/vibenet/demos/b20/B20Demo.tsx
@@ -1,22 +1,25 @@
'use client';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { isAddress, type Address, type Hex } from 'viem';
import { trackB20Action, trackB20ModuleSelect } from '../../../analytics/events';
+import { cn } from '../../../components/ui/cn';
import { Tabs } from '../../../components/ui/Tabs';
import { walletErrorMessage } from '../../library/wallet';
import { ActivityLog } from '../account/components/ActivityLog';
import { useAccountEngine } from '../account/useAccountEngine';
import { AccountDemoShell } from '../_components/AccountDemoShell';
+import { AnimatedAmount } from '../_components/AnimatedAmount';
import { AnnouncementModule, SampleAnnouncementViewer } from './components/AnnouncementModule';
import { DeployModule } from './components/DeployModule';
import { MemoModule } from './components/MemoModule';
import { PolicyModule } from './components/PolicyModule';
-import { client, CHAIN_ID, MODULES } from './lib/constants';
+import { client, MODULES } from './lib/constants';
import {
b20Abi,
b20Variant,
+ formatAmount,
B20_FACTORY,
DEFAULT_ADMIN_ROLE,
factoryAbi,
@@ -28,8 +31,31 @@ import {
} from './lib/protocol';
import { readRecent, readRecentPolicies, writeRecent, writeRecentPolicy } from './lib/recent';
import { sampleTokenForAddress } from './lib/samples';
+import {
+ createPayer,
+ ensurePayerFunded,
+ loadPayer,
+ payerAddress,
+ payerErrorMessage,
+ payerSigner,
+ savePayer,
+ seedWithEth,
+ tokenGasFee,
+ type StoredB20Payer,
+} from './lib/gasPayer';
import type { CreatedToken, Module, RecentPolicy, RecentToken, TokenAccess, TokenInfo } from './lib/types';
+// Retry schedule for reads that race a just-confirmed transaction: the public
+// RPC is load-balanced across replicas whose heads differ, so read at t=0 and
+// again as state settles. Reads are pinned to a fresh block so lagging replicas
+// error instead of answering stale; a success is authoritative and errors never
+// downgrade a previous success.
+const READ_RETRY_MS = [0, 2_500, 6_000];
+
+function annotateMode(label: string, mode: 'self' | 'token', symbol?: string): string {
+ return mode === 'token' && symbol ? `${label} · gas paid in ${symbol}` : label;
+}
+
export function B20Demo() {
const [module, setModule] = useState('policy');
// Local EIP-8130 accounts, shared with the account demo via localStorage. B20
@@ -45,6 +71,14 @@ export function B20Demo() {
const addressBook = engine.addressBook;
+ // The demo's own ERC-8168 payer, minted on demand when fees are switched to a
+ // token. It stays separate from the account: the account spends the token,
+ // the payer spends the ETH that actually buys the gas.
+ const [storedPayer, setStoredPayer] = useState(null);
+ const [gasMode, setGasMode] = useState<'eth' | 'token'>('eth');
+ const [tokenBalance, setTokenBalance] = useState(null);
+ // Which token the shown balance belongs to (lowercased address).
+ const balanceForToken = useRef(null);
const [recent, setRecent] = useState([]);
const [recentPolicies, setRecentPolicies] = useState([]);
const [tokenAddress, setTokenAddress] = useState('');
@@ -53,6 +87,7 @@ export function B20Demo() {
const [checkAddress, setCheckAddress] = useState('');
const [checks, setChecks] = useState | null>(null);
const [busy, setBusy] = useState(null);
+ const [batchProgress, setBatchProgress] = useState<{ label: string; index: number; total: number } | null>(null);
const [isOperator, setIsOperator] = useState(false);
const [isTokenAdmin, setIsTokenAdmin] = useState(false);
const [tokenAdminLoading, setTokenAdminLoading] = useState(false);
@@ -78,6 +113,43 @@ export function B20Demo() {
refreshWallet(wallet);
}, [wallet, refreshWallet]);
+ useEffect(() => {
+ setStoredPayer(loadPayer());
+ }, []);
+
+ // The account's holding of the active token, shown beside the tabs so the
+ // initial mint (and every transfer) is visible. Keyed on the `token` object,
+ // which is re-fetched after every send — so this re-reads automatically.
+ useEffect(() => {
+ let cancelled = false;
+ if (!token || !wallet || sampleTokenForAddress(token.address)) {
+ setTokenBalance(null);
+ balanceForToken.current = null;
+ return;
+ }
+ // Switching to a different token invalidates the shown balance; refreshes
+ // of the same token keep it on screen (no flash) until the new read lands.
+ if (balanceForToken.current !== token.address.toLowerCase()) {
+ balanceForToken.current = token.address.toLowerCase();
+ setTokenBalance((previous) => (previous === 0n ? previous : null));
+ }
+ const read = () =>
+ client
+ .getBlockNumber({ cacheTime: 0 })
+ .then((blockNumber) =>
+ client.readContract({ address: token.address, abi: b20Abi, functionName: 'balanceOf', args: [wallet], blockNumber }),
+ )
+ .then((balance) => {
+ if (!cancelled) setTokenBalance(balance);
+ })
+ .catch(() => {});
+ const timers = READ_RETRY_MS.map((delay) => window.setTimeout(() => void read(), delay));
+ return () => {
+ cancelled = true;
+ timers.forEach((timer) => window.clearTimeout(timer));
+ };
+ }, [token, wallet]);
+
// Operator status is a function of (token address, wallet) only. send()
// re-inspects the token after every tx, which yields a fresh `token` object
// with the same address; keying this effect on the address (not the object)
@@ -88,21 +160,26 @@ export function B20Demo() {
let cancelled = false;
setIsOperator(false);
if (!activeTokenAddress || !wallet || sampleTokenForAddress(activeTokenAddress)) return;
- client
- .readContract({
- address: activeTokenAddress,
- abi: b20Abi,
- functionName: 'hasRole',
- args: [roleId('OPERATOR_ROLE'), wallet],
- })
- .then((allowed) => {
- if (!cancelled) setIsOperator(allowed);
- })
- .catch(() => {
- if (!cancelled) setIsOperator(false);
- });
+ const read = () =>
+ client
+ .getBlockNumber({ cacheTime: 0 })
+ .then((blockNumber) =>
+ client.readContract({
+ address: activeTokenAddress,
+ abi: b20Abi,
+ functionName: 'hasRole',
+ args: [roleId('OPERATOR_ROLE'), wallet],
+ blockNumber,
+ }),
+ )
+ .then((allowed) => {
+ if (!cancelled && allowed) setIsOperator(true);
+ })
+ .catch(() => {});
+ const timers = READ_RETRY_MS.map((delay) => window.setTimeout(() => void read(), delay));
return () => {
cancelled = true;
+ timers.forEach((timer) => window.clearTimeout(timer));
};
}, [activeTokenAddress, wallet]);
@@ -118,30 +195,74 @@ export function B20Demo() {
return;
}
setTokenAdminLoading(true);
- client
- .readContract({
- address: activeTokenAddress,
- abi: b20Abi,
- functionName: 'hasRole',
- args: [DEFAULT_ADMIN_ROLE, wallet],
- })
- .then((allowed) => {
- if (!cancelled) setIsTokenAdmin(allowed);
- })
- .catch(() => {
- if (!cancelled) setIsTokenAdmin(false);
- })
- .finally(() => {
- if (!cancelled) {
+ const lastDelay = READ_RETRY_MS[READ_RETRY_MS.length - 1];
+ const read = (delay: number) =>
+ client
+ .getBlockNumber({ cacheTime: 0 })
+ .then((blockNumber) =>
+ client.readContract({
+ address: activeTokenAddress,
+ abi: b20Abi,
+ functionName: 'hasRole',
+ args: [DEFAULT_ADMIN_ROLE, wallet],
+ blockNumber,
+ }),
+ )
+ .then((allowed) => {
+ if (cancelled) return;
+ if (allowed) setIsTokenAdmin(true);
setTokenAdminLoading(false);
setTokenAdminCheckedFor(checkKey);
- }
- });
+ })
+ .catch(() => {
+ // Keep "checking" until the final attempt fails too.
+ if (!cancelled && delay === lastDelay) {
+ setTokenAdminLoading(false);
+ setTokenAdminCheckedFor(checkKey);
+ }
+ });
+ const timers = READ_RETRY_MS.map((delay) => window.setTimeout(() => void read(delay), delay));
return () => {
cancelled = true;
+ timers.forEach((timer) => window.clearTimeout(timer));
};
}, [activeTokenAddress, wallet]);
+ // Token-paid gas is offered only for a STABLECOIN the account manages —
+ // paying fees in a currency-pegged token is the realistic story; volatile
+ // asset tokens stay on ETH. Stablecoin creators hold DEFAULT_ADMIN (not
+ // OPERATOR_ROLE, which the stablecoin deploy skips), so admin status is the
+ // gate. Drop back to ETH when the active token changes, isn't a stablecoin,
+ // or access is lost.
+ const tokenGasEligible = token?.variant === 'stablecoin' && (isTokenAdmin || isOperator);
+ useEffect(() => {
+ if (!tokenGasEligible) setGasMode('eth');
+ }, [tokenGasEligible]);
+
+ const enableTokenGas = useCallback(() => {
+ let payer = storedPayer;
+ if (!payer) {
+ payer = createPayer();
+ savePayer(payer);
+ setStoredPayer(payer);
+ // Pre-fund the demo payer so the first token-paid send doesn't wait.
+ void seedWithEth(payerAddress(payer));
+ }
+ setGasMode('token');
+ }, [storedPayer]);
+
+ // Guided "first payment" from the token-created screen: flip gas to the new
+ // stablecoin, jump to Memos, and pre-fill an invoice-style payment so the
+ // next click is Submit.
+ const [memoPrefill, setMemoPrefill] = useState<{ to: string; amount: string; memo: string } | null>(null);
+ const startFirstPayment = useCallback(() => {
+ if (token?.variant === 'stablecoin') enableTokenGas();
+ setMemoPrefill({ to: '0xd0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0', amount: '5', memo: 'Invoice-0001' });
+ setModule('memos');
+ trackB20ModuleSelect('memos');
+ }, [enableTokenGas, token]);
+ const clearMemoPrefill = useCallback(() => setMemoPrefill(null), []);
+
const inspect = useCallback(
async (candidate = tokenAddress) => {
const sampleToken = sampleTokenForAddress(candidate);
@@ -247,23 +368,43 @@ export function B20Demo() {
setChecks(Object.fromEntries(result));
}, [checkAddress, token]);
- const send = useCallback(
- async (label: string, to: Address, data: Hex, action: string): Promise => {
+ // The single transaction chokepoint: every module action lands here. Calls go
+ // out as one atomic EIP-8130 transaction through the shared account engine,
+ // with gas paid in ETH or — when fees are switched to a stablecoin the
+ // account manages — by the demo's own ERC-8168 payer.
+ const sendCalls = useCallback(
+ async (label: string, calls: Array<{ to: Address; data: Hex }>, action: string): Promise => {
if (!activeAccount) {
setInspectError('Select an account before you continue.');
return null;
}
setBusy(action);
+ setInspectError('');
trackB20Action(module, action, 'submitted');
try {
+ const tokenGas =
+ gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer
+ ? {
+ token: token.address,
+ decimals: token.decimals,
+ payer: payerSigner(storedPayer),
+ fee: tokenGasFee(token.decimals),
+ }
+ : undefined;
+ // The payer underwrites the gas in ETH, so it has to be funded before
+ // it co-signs — the first token-paid send follows key creation closely.
+ if (storedPayer && tokenGas) await ensurePayerFunded(storedPayer);
// Sign + broadcast through the shared account engine so account deploy,
// sub-account, gas-estimation, and staged-settings behavior stays in one
// implementation across demos. Logging via pushActivity puts this send in
// the same history the account demo reads, so both demos share one trail.
- const { hash, serialized } = await engine.sendActiveCall({ to, data });
+ const { hash, serialized, mode } = await engine.sendActiveCalls({
+ calls,
+ ...(tokenGas ? { tokenGas } : {}),
+ });
engine.pushActivity({
kind: 'transact',
- title: label,
+ title: annotateMode(label, mode, token?.symbol),
txHash: hash,
serialized,
network: engine.chain.name,
@@ -275,15 +416,83 @@ export function B20Demo() {
if (token) await inspect(token.address);
return hash;
} catch (error) {
- const detail = walletErrorMessage(error);
+ const detail = payerErrorMessage(error) ?? walletErrorMessage(error);
+ trackB20Action(module, action, 'error');
+ setInspectError(detail);
+ return null;
+ } finally {
+ setBusy(null);
+ }
+ },
+ [activeAccount, engine, gasMode, inspect, module, refreshWallet, storedPayer, token],
+ );
+
+ const send = useCallback(
+ (label: string, to: Address, data: Hex, action: string): Promise =>
+ sendCalls(label, [{ to, data }], action),
+ [sendCalls],
+ );
+
+ // Multi-transaction flows (token deployment): the work is split into
+ // sequential transactions that each stay well inside a block's gas, so a
+ // heavy create + configure run can't be cut mid-phase. Logs one activity entry
+ // per batch and stops at the first failure — earlier batches stay applied, so
+ // each one is written to be meaningful on its own.
+ const sendBatches = useCallback(
+ async (
+ batches: Array<{ label: string; calls: Array<{ to: Address; data: Hex }> }>,
+ action: string,
+ ): Promise => {
+ if (!activeAccount) {
+ setInspectError('Select an account before you continue.');
+ return null;
+ }
+ setBusy(action);
+ setInspectError('');
+ trackB20Action(module, action, 'submitted');
+ const hashes: Hex[] = [];
+ try {
+ for (const [index, batch] of batches.entries()) {
+ setBatchProgress({ label: batch.label, index, total: batches.length });
+ const tokenGas =
+ gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer
+ ? {
+ token: token.address,
+ decimals: token.decimals,
+ payer: payerSigner(storedPayer),
+ fee: tokenGasFee(token.decimals),
+ }
+ : undefined;
+ if (storedPayer && tokenGas) await ensurePayerFunded(storedPayer);
+ const { hash, serialized, mode } = await engine.sendActiveCalls({
+ calls: batch.calls,
+ ...(tokenGas ? { tokenGas } : {}),
+ });
+ hashes.push(hash);
+ engine.pushActivity({
+ kind: 'transact',
+ title: annotateMode(batch.label, mode, token?.symbol),
+ txHash: hash,
+ serialized,
+ network: engine.chain.name,
+ mode: engine.chain.mode,
+ account: activeAccount.address as Address,
+ });
+ }
+ trackB20Action(module, action, 'success');
+ refreshWallet(activeAccount.address as Address);
+ return hashes;
+ } catch (error) {
+ const detail = payerErrorMessage(error) ?? walletErrorMessage(error);
trackB20Action(module, action, 'error');
setInspectError(detail);
return null;
} finally {
+ setBatchProgress(null);
setBusy(null);
}
},
- [inspect, module, refreshWallet, token, activeAccount, engine],
+ [activeAccount, engine, gasMode, module, refreshWallet, storedPayer, token],
);
useEffect(() => {
@@ -321,7 +530,7 @@ export function B20Demo() {
className="animate-in gap-5 pb-6 dark:text-white"
>
-
+
+
+ {token && tokenBalance !== null ? (
+
+
+ {token.symbol}
+
+ ) : null}
+ {token && tokenGasEligible ? (
+
+ Fees:
+
+ setGasMode('eth')}
+ title="Pay the network fee from the account's own ETH."
+ className={cn(
+ 'px-2 py-1 transition-colors',
+ gasMode === 'eth'
+ ? 'bg-base-blue font-medium text-white dark:text-black'
+ : 'text-bds-gray-60 hover:bg-bds-gray-5 dark:hover:bg-white/10',
+ )}
+ >
+ {gasMode === 'eth' ? '✓ ' : ''}ETH
+
+
+ {gasMode === 'token' ? '✓ ' : ''}Pay in {token.symbol}
+
+
+
+ ) : null}
+
{module === 'policy' ? (
@@ -372,9 +626,20 @@ export function B20Demo() {
token={token}
tokenAccess={tokenAccess}
addressBook={addressBook}
+ wallet={wallet}
onDeploy={() => selectModule('deploy')}
onSend={send}
+ onSendCalls={sendCalls}
busy={busy}
+ refreshKey={engine.activity.length}
+ prefill={memoPrefill}
+ onPrefillConsumed={clearMemoPrefill}
+ feeNote={
+ gasMode === 'token' && token
+ ? `${formatAmount(tokenGasFee(token.decimals), token.decimals)} ${token.symbol}`
+ : null
+ }
+ onEnableTokenGas={tokenGasEligible && gasMode === 'eth' ? enableTokenGas : null}
/>
) : null}
{module === 'announcements' ? (
@@ -395,6 +660,9 @@ export function B20Demo() {
{
@@ -405,6 +673,10 @@ export function B20Demo() {
if (wallet) setRecent(writeRecent(wallet, next));
setTokenAddress(next.address);
setCreated(next);
+ // Mount the chip balance at 0 so the initial deposit rolls up
+ // to the minted amount when the first read lands.
+ balanceForToken.current = next.address.toLowerCase();
+ setTokenBalance(0n);
await inspect(next.address);
}}
onReset={() => setCreated(null)}
diff --git a/app/vibenet/demos/b20/components/AnnouncementModule.tsx b/app/vibenet/demos/b20/components/AnnouncementModule.tsx
index 3a923c0..765f5aa 100644
--- a/app/vibenet/demos/b20/components/AnnouncementModule.tsx
+++ b/app/vibenet/demos/b20/components/AnnouncementModule.tsx
@@ -141,7 +141,7 @@ export function AnnouncementModule({
if (!token || token.variant !== 'asset') return;
setError(null);
try {
- if (!wallet) throw new Error('Connect the wallet that manages this token first.');
+ if (!wallet) throw new Error('Make a wallet before you announce.');
const announcementId = id.trim();
if (!announcementId || !description.trim()) throw new Error('Announcement ID and description are required.');
const [isOperator, idUsed] = await Promise.all([
@@ -260,14 +260,14 @@ export function AnnouncementModule({
) : token.variant !== 'asset' ? (
- Announcements are not available on Stablecoin tokens. They are only available on Asset tokens.
+ Announcements are an Asset token feature. Create an Asset token to publish updates for holders.
) : (
<>
{tokenAccess !== 'operator' ? (
-
This wallet cannot publish announcements for this asset
+
Publishing needs the operator role on this asset
Create your own Asset token to write and publish announcements.
@@ -353,7 +353,7 @@ export function AnnouncementModule({
void submit()} disabled={!!busy || tokenAccess !== 'operator'}>
{busy
- ? 'Waiting for wallet…'
+ ? 'Sending…'
: tokenAccess === 'operator'
? announcementType === 'multiplier'
? 'Publish announcement with scheduled asset split'
diff --git a/app/vibenet/demos/b20/components/AttachPolicy.tsx b/app/vibenet/demos/b20/components/AttachPolicy.tsx
index 7e63aff..00211fa 100644
--- a/app/vibenet/demos/b20/components/AttachPolicy.tsx
+++ b/app/vibenet/demos/b20/components/AttachPolicy.tsx
@@ -100,8 +100,8 @@ export function AttachPolicy({
{adminStatus === 'checking'
? 'Checking whether your wallet is a token admin…'
: adminStatus === 'disconnected'
- ? 'Connect the token admin wallet to attach or replace a policy.'
- : 'The connected wallet does not hold this token’s DEFAULT_ADMIN_ROLE.'}
+ ? 'Use the token admin wallet to attach or replace a policy.'
+ : 'Your demo wallet does not hold this token’s DEFAULT_ADMIN_ROLE.'}
) : (
<>
@@ -171,7 +171,7 @@ export function AttachPolicy({
) : null}
void submit()} disabled={!!busy}>
- {busy ? 'Waiting for wallet…' : 'Attach policy'}
+ {busy ? 'Sending…' : 'Attach policy'}
>
)}
diff --git a/app/vibenet/demos/b20/components/CreatePolicy.tsx b/app/vibenet/demos/b20/components/CreatePolicy.tsx
index 4cec17d..dc45130 100644
--- a/app/vibenet/demos/b20/components/CreatePolicy.tsx
+++ b/app/vibenet/demos/b20/components/CreatePolicy.tsx
@@ -327,7 +327,7 @@ export function CreatePolicy({
>
)}
- void submit()} disabled={pending || !wallet || (mode === 'composite' && !compositeReady)}>{pending ? 'Creating policy…' : !wallet ? 'Connect wallet to create' : mode === 'composite' && !compositeReady ? 'Create at least two child policies first' : `Create ${policyKindLabel(kind)}`}
+ void submit()} disabled={pending || !wallet || (mode === 'composite' && !compositeReady)}>{pending ? 'Creating policy…' : !wallet ? 'Make a wallet to create' : mode === 'composite' && !compositeReady ? 'Create at least two child policies first' : `Create ${policyKindLabel(kind)}`}
);
}
diff --git a/app/vibenet/demos/b20/components/DeployModule.tsx b/app/vibenet/demos/b20/components/DeployModule.tsx
index 3161eee..6949db0 100644
--- a/app/vibenet/demos/b20/components/DeployModule.tsx
+++ b/app/vibenet/demos/b20/components/DeployModule.tsx
@@ -109,6 +109,9 @@ function ConfettiBurst() {
export function DeployModule({
wallet,
onSend,
+ onSendBatches,
+ progress,
+ onFirstPayment,
created,
onCreated,
onReset,
@@ -120,6 +123,14 @@ export function DeployModule({
}: {
wallet: Address | null;
onSend: (label: string, to: Address, data: Hex, action: string) => Promise;
+ onSendBatches: (
+ batches: Array<{ label: string; calls: Array<{ to: Address; data: Hex }> }>,
+ action: string,
+ ) => Promise;
+ /** Live step info while a batched flow runs (null when idle). */
+ progress: { label: string; index: number; total: number } | null;
+ /** Guided flow: flip gas to the new stablecoin and pre-fill a first payment. */
+ onFirstPayment: () => void;
created: CreatedToken | null;
onCreated: (token: CreatedToken) => Promise;
onReset: () => void;
@@ -144,13 +155,13 @@ export function DeployModule({
const [policyError, setPolicyError] = useState(null);
const [resolvingPolicy, setResolvingPolicy] = useState(false);
const [showPolicyCreator, setShowPolicyCreator] = useState(false);
- const [predicted, setPredicted] = useState('Connect a wallet to see the address');
+ const [predicted, setPredicted] = useState('Make a wallet to see the address');
const [finalizing, setFinalizing] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
if (!wallet) {
- setPredicted('Connect a wallet to see the address');
+ setPredicted('Make a wallet to see the address');
return;
}
if (!salt.trim()) {
@@ -217,7 +228,7 @@ export function DeployModule({
const submit = async () => {
if (!wallet) {
- setError('Connect a wallet before you create a token.');
+ setError('Make a wallet before you create a token.');
return;
}
setFinalizing(true);
@@ -292,21 +303,35 @@ export function DeployModule({
);
const policyCount = initialPolicies.length;
if (policyCount) configured.push(`Added ${policyCount} token ${policyCount === 1 ? 'rule' : 'rules'}`);
- const data = encodeFunctionData({
- abi: factoryAbi,
- functionName: 'createB20',
- args: [variant === 'asset' ? 0 : 1, deploySalt, params, initCalls],
- });
const address = await client.readContract({
address: B20_FACTORY,
abi: factoryAbi,
functionName: 'getB20Address',
args: [variant === 'asset' ? 0 : 1, wallet, deploySalt],
});
- const hash = await onSend(`Create ${symbol}`, B20_FACTORY, data, 'create_b20');
- if (hash) {
+ // The payer sponsors only ~300k gas per transaction, so creation can't
+ // carry the init calls: create the bare token first, then apply the same
+ // init calls directly to the token in budget-sized follow-up batches.
+ const createData = encodeFunctionData({
+ abi: factoryAbi,
+ functionName: 'createB20',
+ args: [variant === 'asset' ? 0 : 1, deploySalt, params, []],
+ });
+ // 6 calls ≈ 200k gas — the most that reliably fits under the payer's
+ // ~300k per-transaction sponsorship budget alongside the batch overhead.
+ const chunks: Hex[][] = [];
+ for (let i = 0; i < initCalls.length; i += 6) chunks.push(initCalls.slice(i, i + 6));
+ const batches = [
+ { label: `Create ${symbol}`, calls: [{ to: B20_FACTORY, data: createData }] },
+ ...chunks.map((chunk, i) => ({
+ label: chunks.length > 1 ? `Configure ${symbol} (${i + 1} of ${chunks.length})` : `Configure ${symbol}`,
+ calls: chunk.map((data) => ({ to: address, data })),
+ })),
+ ];
+ const hashes = await onSendBatches(batches, 'create_b20');
+ if (hashes?.length) {
await waitForB20Initialization(address);
- await onCreated({ address, name, symbol, decimals: d, variant, hash, configured });
+ await onCreated({ address, name, symbol, decimals: d, variant, hash: hashes[0], configured });
setSalt('');
}
} catch (error) {
@@ -315,7 +340,8 @@ export function DeployModule({
setFinalizing(false);
}
};
- if (created) return ;
+ if (created)
+ return ;
const pending = !!busy || finalizing;
return (
@@ -344,7 +370,7 @@ export function DeployModule({
{variant === 'asset' ? 'Asset' : 'Stablecoin'}:
{variant === 'asset'
? 'Choose this for flexible decimals, announcements, and displayed-balance changes.'
- : 'Choose this for a currency-linked token. It always uses six decimals and a currency code, helping wallets identify it consistently.'}
+ : 'Choose this for a currency-linked token. It always uses six decimals and a currency code, helping wallets identify it consistently. Once created, it can also be used to pay gas.'}
@@ -534,18 +560,33 @@ export function DeployModule({
{predicted}
- Creating the token gives your wallet the permissions it needs, sets your options, and sends the starting
- amount to you in one transaction.
+ Creating the token runs a short series of gas-sponsored transactions: it deploys the token, gives your
+ wallet the permissions it needs, sets your options, and sends you the starting amount.
void submit()} disabled={pending}>
- {pending ? 'Creating your token…' : 'Create token'}
+ {pending && progress ? `Step ${progress.index + 1} of ${progress.total}…` : pending ? 'Creating your token…' : 'Create token'}
{pending ? (
-
- Confirm in your wallet, then wait a few seconds for your token to be ready.
-
+
+ {progress ? (
+
+
+
+
+ {progress.label}…
+
+ ) : (
+
Preparing your token…
+ )}
+
+ Each step is a real onchain transaction — links appear in Recent Activity as they confirm.
+
+
) : null}
@@ -560,31 +601,46 @@ function CreatedView({
created,
onNavigate,
onReset,
+ onFirstPayment,
}: {
created: CreatedToken;
onNavigate: (module: Module) => void;
onReset: () => void;
+ onFirstPayment: () => void;
}) {
- const nextSteps: Array<{ module: Module; title: string; body: string }> = [
+ // Each variant only lists what it can actually do: stablecoins get the
+ // pay-fees-in-token step (assets can't), assets get announcements
+ // (stablecoins can't).
+ const nextSteps: Array<{ key: string; title: string; body: string; onGo: () => void }> = [
{
- module: 'policy',
- title: 'Explore policies',
- body: 'See who can use each token action and check a wallet before you use it.',
- },
- {
- module: 'memos',
- title: 'View memo history',
- body: 'See your initial memo and add references to future token activity.',
+ key: 'memos',
+ title: 'Send a transfer with a memo',
+ body: `Move some ${created.symbol} to another wallet with a short reference attached — the fastest way to see your token in action.`,
+ onGo: () => onNavigate('memos'),
},
- ...(created.variant === 'asset'
+ ...(created.variant === 'stablecoin'
? [
{
- module: 'announcements' as Module,
+ key: 'token-gas',
+ title: `Pay network fees with ${created.symbol}`,
+ body: `Send a payment where the gas fee is charged in ${created.symbol} itself.`,
+ onGo: onFirstPayment,
+ },
+ ]
+ : [
+ {
+ key: 'announcements',
title: 'Share an update',
body: 'Publish information for token holders or schedule a displayed-balance change.',
+ onGo: () => onNavigate('announcements'),
},
- ]
- : []),
+ ]),
+ {
+ key: 'policy',
+ title: 'Explore policies',
+ body: 'See who can use each token action and check a wallet before you use it.',
+ onGo: () => onNavigate('policy'),
+ },
];
return (
@@ -603,6 +659,20 @@ function CreatedView({
Your {created.variant} token {created.symbol} is ready on Vibenet. Here is what was set up and what you can
try next.
+ {created.variant === 'stablecoin' ? (
+ <>
+
+ Send your first payment in {created.symbol} →
+
+
+ This flips the fee switch so the network fee is paid in {created.symbol} too.
+
+ >
+ ) : (
+
+ Asset tokens use sponsored gas. To try paying network fees with your own token, create a Stablecoin.
+
+ )}
@@ -669,7 +739,7 @@ function CreatedView({
))}
- Everything was applied together, so the token was ready in one transaction.
+ Each step ran as its own gas-sponsored transaction — check Recent Activity for the links.
@@ -680,9 +750,9 @@ function CreatedView({
{nextSteps.map((step) => (
onNavigate(step.module)}
+ onClick={step.onGo}
className="group flex flex-col rounded-xl border border-bds-gray-10 bg-background p-4 text-left transition-colors hover:border-base-blue dark:border-white/10 dark:bg-white/5"
>
{step.title}
diff --git a/app/vibenet/demos/b20/components/MemoHistory.tsx b/app/vibenet/demos/b20/components/MemoHistory.tsx
index 827970f..bdcab91 100644
--- a/app/vibenet/demos/b20/components/MemoHistory.tsx
+++ b/app/vibenet/demos/b20/components/MemoHistory.tsx
@@ -42,14 +42,26 @@ type MemoRow = {
operation: 'mint' | 'transfer';
};
-export function MemoHistory({ address, decimals, symbol }: { address: Address; decimals: number; symbol: string }) {
+export function MemoHistory({
+ address,
+ decimals,
+ symbol,
+ refreshKey = 0,
+}: {
+ address: Address;
+ decimals: number;
+ symbol: string;
+ /** Bump to re-read the log history (e.g. after the demo sends a transaction). */
+ refreshKey?: number;
+}) {
const [rows, setRows] = useState([]);
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
useEffect(() => {
let cancelled = false;
- setState('loading');
- setRows([]);
+ // Only show the loading state on first mount for a token — refreshes after
+ // a send keep the current rows on screen instead of flashing empty.
+ setState((previous) => (previous === 'ready' ? previous : 'loading'));
const loadMemoLogs = async () => {
const latestBlock = await client.getBlockNumber({ cacheTime: 0 });
@@ -63,7 +75,7 @@ export function MemoHistory({ address, decimals, symbol }: { address: Address; d
}
};
- void loadMemoLogs()
+ const load = () => loadMemoLogs()
.then(async (memoLogs) => {
if (cancelled) return;
const nextRows = await Promise.all(
@@ -96,10 +108,15 @@ export function MemoHistory({ address, decimals, symbol }: { address: Address; d
if (!cancelled) setState('error');
});
+ void load();
+ // Log reads lag inclusion by ~1 block, so a refresh right after a send can
+ // miss the newest memo — read again once the state settles.
+ const settle = window.setTimeout(() => void load(), 2_500);
return () => {
cancelled = true;
+ window.clearTimeout(settle);
};
- }, [address]);
+ }, [address, refreshKey]);
return (
diff --git a/app/vibenet/demos/b20/components/MemoModule.tsx b/app/vibenet/demos/b20/components/MemoModule.tsx
index 5f88fe9..c3e0321 100644
--- a/app/vibenet/demos/b20/components/MemoModule.tsx
+++ b/app/vibenet/demos/b20/components/MemoModule.tsx
@@ -1,11 +1,13 @@
'use client';
-import { useState } from 'react';
+import Link from 'next/link';
+import { useEffect, useState } from 'react';
import { encodeFunctionData, isAddress, type Address, type Hex } from 'viem';
import { Button } from '../../../../components/ui/Button';
import { Card } from '../../../../components/ui/Card';
import { Text } from '../../../../components/ui/Text';
+import { VIBENET_EXPLORER_PATH } from '../../../library/config';
import { walletErrorMessage } from '../../../library/wallet';
import { AddressAutocomplete, type AddressBookEntry } from '../../_shared/AddressAutocomplete';
import { B20_HELP } from '../lib/glossary';
@@ -21,31 +23,105 @@ export function MemoModule({
token,
tokenAccess,
addressBook,
+ wallet,
onDeploy,
onSend,
+ onSendCalls,
busy,
+ refreshKey,
+ prefill,
+ onPrefillConsumed,
+ feeNote,
+ onEnableTokenGas,
}: {
token: TokenInfo | null;
tokenAccess: TokenAccess;
addressBook: AddressBookEntry[];
+ wallet: Address | null;
onDeploy: () => void;
onSend: (label: string, to: Address, data: Hex, action: string) => Promise;
+ onSendCalls: (label: string, calls: Array<{ to: Address; data: Hex }>, action: string) => Promise;
busy: string | null;
+ /** Bumped by the parent after each transaction so the memo history re-reads. */
+ refreshKey?: number;
+ /** One-shot prefill for the transfer form (guided "first payment" flow). */
+ prefill?: { to: string; amount: string; memo: string } | null;
+ onPrefillConsumed?: () => void;
+ /** Per-transaction network fee in token terms (e.g. "0.1 ATLN") when token gas is on. */
+ feeNote?: string | null;
+ /** Set when the selected token is an eligible stablecoin still on sponsored gas. */
+ onEnableTokenGas?: (() => void) | null;
}) {
const [to, setTo] = useState('');
const [value, setValue] = useState('');
const [memo, setMemo] = useState('');
const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!prefill) return;
+ setTo(prefill.to);
+ setValue(prefill.amount);
+ setMemo(prefill.memo);
+ onPrefillConsumed?.();
+ }, [prefill, onPrefillConsumed]);
+ // When on, the send runs as approve + transferFromWithMemo in one atomic
+ // 8130 transaction — the delegated-spending pattern (exchanges, payroll,
+ // subscriptions) where a spender you approved moves the tokens. The demo
+ // wallet approves itself as the spender, since an approve can't share a
+ // transaction with another sender's call.
+ const [batchApprove, setBatchApprove] = useState(false);
+ const [sent, setSent] = useState<{ title: string; summary: string; hash: Hex } | null>(null);
const submit = async () => {
if (!token) return;
setError(null);
+ setSent(null);
try {
const m = memoToBytes32(memo);
const v = amount(value, token.decimals);
if (v <= 0n) throw new Error('Enter an amount greater than zero.');
if (!isAddress(to)) throw new Error('Paste a valid wallet address for the recipient.');
- const data = encodeFunctionData({ abi: b20Abi, functionName: 'transferWithMemo', args: [to, v, m] });
- await onSend('Transfer with memo', token.address, data, 'memo_transfer');
+ const hash =
+ batchApprove && wallet
+ ? await onSendCalls(
+ 'Approve + transfer with memo',
+ [
+ {
+ to: token.address,
+ data: encodeFunctionData({ abi: b20Abi, functionName: 'approve', args: [wallet, v] }),
+ },
+ {
+ to: token.address,
+ data: encodeFunctionData({
+ abi: b20Abi,
+ functionName: 'transferFromWithMemo',
+ args: [wallet, to, v, m],
+ }),
+ },
+ ],
+ 'memo_allowance_transfer',
+ )
+ : await onSend(
+ 'Transfer with memo',
+ token.address,
+ encodeFunctionData({ abi: b20Abi, functionName: 'transferWithMemo', args: [to, v, m] }),
+ 'memo_transfer',
+ );
+ if (hash) {
+ setSent({
+ title:
+ batchApprove && wallet
+ ? `Approved and sent ${value} ${token.symbol} to ${shortAddress(to)}`
+ : `Sent ${value} ${token.symbol} to ${shortAddress(to)}`,
+ summary:
+ batchApprove && wallet
+ ? `The approval and the transfer landed in one atomic transaction with the memo “${memo}”.`
+ : `Memo “${memo}” is recorded onchain with the transfer.`,
+ hash,
+ });
+ setTo('');
+ setValue('');
+ setMemo('');
+ }
} catch (error) {
setError(walletErrorMessage(error));
}
@@ -127,6 +203,41 @@ export function MemoModule({
description="Add a short reference to a token transfer so your team can find it later."
action={ }
/>
+ {sent ? (
+
+
+ ✓
+
+
+ {sent.title}
+
+ {sent.summary}
+
+
+ View transaction ↗
+
+
+
setSent(null)}
+ aria-label="Dismiss confirmation"
+ className="-mr-1 -mt-1 shrink-0 rounded-full px-2 py-1 text-[12px] text-bds-gray-50 transition-colors hover:bg-bds-gray-5 hover:text-foreground dark:hover:bg-white/10 dark:hover:text-white"
+ >
+ ×
+
+
+ ) : null}
{!token ? (
@@ -168,14 +279,50 @@ export function MemoModule({
})()
: 'Your memo preview will appear here'}
+
+ setBatchApprove(event.target.checked)}
+ disabled={!wallet}
+ className="mt-0.5 accent-base-blue"
+ />
+
+ Batch as approve + transferFrom
+ Sends the approval and the transfer in a single atomic transaction.
+
+
void submit()} disabled={!!busy}>
- {busy ? 'Waiting for wallet…' : 'Submit transfer with memo'}
+ {busy === 'memo_transfer' || busy === 'memo_allowance_transfer'
+ ? 'Sending…'
+ : batchApprove
+ ? 'Approve + transfer with memo'
+ : 'Submit transfer with memo'}
+
+ {feeNote
+ ? `Network fee: ${feeNote} — paid from your balance.`
+ : 'Network fee: sponsored.'}
+ {!feeNote && onEnableTokenGas && token ? (
+ <>
+ {' '}
+
+ Pay fees in {token.symbol} instead →
+
+ >
+ ) : null}
+
>
)}
- {token ? : null}
+ {token ? (
+
+ ) : null}
);
}
diff --git a/app/vibenet/demos/b20/components/PolicyModule.tsx b/app/vibenet/demos/b20/components/PolicyModule.tsx
index 0898d92..012a6f3 100644
--- a/app/vibenet/demos/b20/components/PolicyModule.tsx
+++ b/app/vibenet/demos/b20/components/PolicyModule.tsx
@@ -98,11 +98,11 @@ export function PolicyModule({
- No wallet required
+ Read-only preview
Explore a sample token
- See how token rules work without connecting a wallet.
+ See how token rules work before making a wallet.
{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/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..b738e4d
--- /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 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/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/catalogue.ts b/app/vibenet/demos/catalogue.ts
index 4d8b93a..833e66d 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.',
+ '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 atomic approve + transferFrom batching',
],
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',
From 840e92f04e00b1111bfb9f7e99aef80a5a88dd84 Mon Sep 17 00:00:00 2001
From: Soheima M
Date: Fri, 21 Aug 2026 16:50:05 +0200
Subject: [PATCH 2/5] removed any approval logic
---
app/vibenet/demos/b20/B20Demo.tsx | 2 -
.../demos/b20/components/MemoModule.tsx | 71 +++----------------
app/vibenet/demos/catalogue.ts | 2 +-
3 files changed, 10 insertions(+), 65 deletions(-)
diff --git a/app/vibenet/demos/b20/B20Demo.tsx b/app/vibenet/demos/b20/B20Demo.tsx
index 76e8917..8ef880b 100644
--- a/app/vibenet/demos/b20/B20Demo.tsx
+++ b/app/vibenet/demos/b20/B20Demo.tsx
@@ -626,10 +626,8 @@ export function B20Demo() {
token={token}
tokenAccess={tokenAccess}
addressBook={addressBook}
- wallet={wallet}
onDeploy={() => selectModule('deploy')}
onSend={send}
- onSendCalls={sendCalls}
busy={busy}
refreshKey={engine.activity.length}
prefill={memoPrefill}
diff --git a/app/vibenet/demos/b20/components/MemoModule.tsx b/app/vibenet/demos/b20/components/MemoModule.tsx
index c3e0321..10fdcb4 100644
--- a/app/vibenet/demos/b20/components/MemoModule.tsx
+++ b/app/vibenet/demos/b20/components/MemoModule.tsx
@@ -23,10 +23,8 @@ export function MemoModule({
token,
tokenAccess,
addressBook,
- wallet,
onDeploy,
onSend,
- onSendCalls,
busy,
refreshKey,
prefill,
@@ -37,10 +35,8 @@ export function MemoModule({
token: TokenInfo | null;
tokenAccess: TokenAccess;
addressBook: AddressBookEntry[];
- wallet: Address | null;
onDeploy: () => void;
onSend: (label: string, to: Address, data: Hex, action: string) => Promise;
- onSendCalls: (label: string, calls: Array<{ to: Address; data: Hex }>, action: string) => Promise;
busy: string | null;
/** Bumped by the parent after each transaction so the memo history re-reads. */
refreshKey?: number;
@@ -64,12 +60,6 @@ export function MemoModule({
setMemo(prefill.memo);
onPrefillConsumed?.();
}, [prefill, onPrefillConsumed]);
- // When on, the send runs as approve + transferFromWithMemo in one atomic
- // 8130 transaction — the delegated-spending pattern (exchanges, payroll,
- // subscriptions) where a spender you approved moves the tokens. The demo
- // wallet approves itself as the spender, since an approve can't share a
- // transaction with another sender's call.
- const [batchApprove, setBatchApprove] = useState(false);
const [sent, setSent] = useState<{ title: string; summary: string; hash: Hex } | null>(null);
const submit = async () => {
if (!token) return;
@@ -80,42 +70,16 @@ export function MemoModule({
const v = amount(value, token.decimals);
if (v <= 0n) throw new Error('Enter an amount greater than zero.');
if (!isAddress(to)) throw new Error('Paste a valid wallet address for the recipient.');
- const hash =
- batchApprove && wallet
- ? await onSendCalls(
- 'Approve + transfer with memo',
- [
- {
- to: token.address,
- data: encodeFunctionData({ abi: b20Abi, functionName: 'approve', args: [wallet, v] }),
- },
- {
- to: token.address,
- data: encodeFunctionData({
- abi: b20Abi,
- functionName: 'transferFromWithMemo',
- args: [wallet, to, v, m],
- }),
- },
- ],
- 'memo_allowance_transfer',
- )
- : await onSend(
- 'Transfer with memo',
- token.address,
- encodeFunctionData({ abi: b20Abi, functionName: 'transferWithMemo', args: [to, v, m] }),
- 'memo_transfer',
- );
+ const hash = await onSend(
+ 'Transfer with memo',
+ token.address,
+ encodeFunctionData({ abi: b20Abi, functionName: 'transferWithMemo', args: [to, v, m] }),
+ 'memo_transfer',
+ );
if (hash) {
setSent({
- title:
- batchApprove && wallet
- ? `Approved and sent ${value} ${token.symbol} to ${shortAddress(to)}`
- : `Sent ${value} ${token.symbol} to ${shortAddress(to)}`,
- summary:
- batchApprove && wallet
- ? `The approval and the transfer landed in one atomic transaction with the memo “${memo}”.`
- : `Memo “${memo}” is recorded onchain with the transfer.`,
+ title: `Sent ${value} ${token.symbol} to ${shortAddress(to)}`,
+ summary: `Memo “${memo}” is recorded onchain with the transfer.`,
hash,
});
setTo('');
@@ -279,26 +243,9 @@ export function MemoModule({
})()
: 'Your memo preview will appear here'}
-
- setBatchApprove(event.target.checked)}
- disabled={!wallet}
- className="mt-0.5 accent-base-blue"
- />
-
- Batch as approve + transferFrom
- Sends the approval and the transfer in a single atomic transaction.
-
-
void submit()} disabled={!!busy}>
- {busy === 'memo_transfer' || busy === 'memo_allowance_transfer'
- ? 'Sending…'
- : batchApprove
- ? 'Approve + transfer with memo'
- : 'Submit transfer with memo'}
+ {busy === 'memo_transfer' ? 'Sending…' : 'Submit transfer with memo'}
{feeNote
diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts
index 833e66d..712a3f6 100644
--- a/app/vibenet/demos/catalogue.ts
+++ b/app/vibenet/demos/catalogue.ts
@@ -42,7 +42,7 @@ export const DEMOS: DemoEntry[] = [
points: [
'One-click 8130 wallet — faucet-seeded, gasless via payer sponsorship',
'Pay gas with your own stablecoin (ERC-8168 token payment)',
- 'Policies, memos, and atomic approve + transferFrom batching',
+ 'Policies, memos, and Asset announcements',
],
available: true,
},
From df31172c5b7461a4d1b563695dad737a31faf9da Mon Sep 17 00:00:00 2001
From: soheima
Date: Thu, 20 Aug 2026 21:58:22 +0200
Subject: [PATCH 3/5] addressed feedback and added logic for more than one
token
---
app/vibenet/demos/b20/B20Demo.tsx | 68 ++++++++++++--
.../demos/b20/components/DeployModule.tsx | 90 +++++++++++++------
.../demos/b20/components/MemoModule.tsx | 14 ++-
.../demos/b20/components/PolicyModule.tsx | 62 +++++++++----
app/vibenet/demos/b20/lib/deployment.test.ts | 41 +++++++++
app/vibenet/demos/b20/lib/deployment.ts | 52 +++++++++++
app/vibenet/demos/b20/lib/gasPayer.ts | 6 +-
app/vibenet/demos/b20/lib/tokenGas.test.ts | 21 +++++
app/vibenet/demos/b20/lib/tokenGas.ts | 7 ++
app/vibenet/demos/catalogue.ts | 4 +-
10 files changed, 309 insertions(+), 56 deletions(-)
create mode 100644 app/vibenet/demos/b20/lib/deployment.test.ts
create mode 100644 app/vibenet/demos/b20/lib/deployment.ts
create mode 100644 app/vibenet/demos/b20/lib/tokenGas.test.ts
create mode 100644 app/vibenet/demos/b20/lib/tokenGas.ts
diff --git a/app/vibenet/demos/b20/B20Demo.tsx b/app/vibenet/demos/b20/B20Demo.tsx
index 8ef880b..ebbfd78 100644
--- a/app/vibenet/demos/b20/B20Demo.tsx
+++ b/app/vibenet/demos/b20/B20Demo.tsx
@@ -11,6 +11,7 @@ import { ActivityLog } from '../account/components/ActivityLog';
import { useAccountEngine } from '../account/useAccountEngine';
import { AccountDemoShell } from '../_components/AccountDemoShell';
import { AnimatedAmount } from '../_components/AnimatedAmount';
+import { Select, type SelectGroup } from '../../../components/ui/Select';
import { AnnouncementModule, SampleAnnouncementViewer } from './components/AnnouncementModule';
import { DeployModule } from './components/DeployModule';
import { MemoModule } from './components/MemoModule';
@@ -31,6 +32,7 @@ import {
} from './lib/protocol';
import { readRecent, readRecentPolicies, writeRecent, writeRecentPolicy } from './lib/recent';
import { sampleTokenForAddress } from './lib/samples';
+import { canUseTokenForGas } from './lib/tokenGas';
import {
createPayer,
ensurePayerFunded,
@@ -87,7 +89,12 @@ export function B20Demo() {
const [checkAddress, setCheckAddress] = useState('');
const [checks, setChecks] = useState | null>(null);
const [busy, setBusy] = useState(null);
- const [batchProgress, setBatchProgress] = useState<{ label: string; index: number; total: number } | null>(null);
+ const [batchProgress, setBatchProgress] = useState<{
+ label: string;
+ detail?: string;
+ index: number;
+ total: number;
+ } | null>(null);
const [isOperator, setIsOperator] = useState(false);
const [isTokenAdmin, setIsTokenAdmin] = useState(false);
const [tokenAdminLoading, setTokenAdminLoading] = useState(false);
@@ -234,7 +241,7 @@ export function B20Demo() {
// OPERATOR_ROLE, which the stablecoin deploy skips), so admin status is the
// gate. Drop back to ETH when the active token changes, isn't a stablecoin,
// or access is lost.
- const tokenGasEligible = token?.variant === 'stablecoin' && (isTokenAdmin || isOperator);
+ const tokenGasEligible = canUseTokenForGas(token?.variant, isTokenAdmin, isOperator);
useEffect(() => {
if (!tokenGasEligible) setGasMode('eth');
}, [tokenGasEligible]);
@@ -440,7 +447,7 @@ export function B20Demo() {
// each one is written to be meaningful on its own.
const sendBatches = useCallback(
async (
- batches: Array<{ label: string; calls: Array<{ to: Address; data: Hex }> }>,
+ batches: Array<{ label: string; detail?: string; calls: Array<{ to: Address; data: Hex }> }>,
action: string,
): Promise => {
if (!activeAccount) {
@@ -453,7 +460,7 @@ export function B20Demo() {
const hashes: Hex[] = [];
try {
for (const [index, batch] of batches.entries()) {
- setBatchProgress({ label: batch.label, index, total: batches.length });
+ setBatchProgress({ label: batch.label, detail: batch.detail, index, total: batches.length });
const tokenGas =
gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer
? {
@@ -472,6 +479,7 @@ export function B20Demo() {
engine.pushActivity({
kind: 'transact',
title: annotateMode(batch.label, mode, token?.symbol),
+ detail: batch.detail,
txHash: hash,
serialized,
network: engine.chain.name,
@@ -521,6 +529,40 @@ export function B20Demo() {
: wallet
? 'external'
: 'disconnected';
+ const selectedCreatedToken = recent.find(
+ (entry) => entry.address.toLowerCase() === tokenAddress.trim().toLowerCase(),
+ );
+ const headerToken = selectedCreatedToken ?? token;
+ const switchingCreatedToken =
+ busy === 'inspect' &&
+ selectedCreatedToken !== undefined &&
+ selectedCreatedToken.address.toLowerCase() !== token?.address.toLowerCase();
+ const headerTokenGroups: SelectGroup[] = [
+ {
+ label: 'Stablecoins · can pay network fees',
+ options: recent
+ .filter((entry) => entry.variant === 'stablecoin')
+ .map((entry) => ({
+ value: entry.address,
+ label:
+ entry.address.toLowerCase() === token?.address.toLowerCase() && tokenBalance !== null
+ ? `${formatAmount(tokenBalance, entry.decimals)} ${entry.symbol} · Stablecoin`
+ : `${entry.symbol} — ${entry.name} · Stablecoin`,
+ })),
+ },
+ {
+ label: 'Assets · fees in ETH only',
+ options: recent
+ .filter((entry) => entry.variant === 'asset')
+ .map((entry) => ({
+ value: entry.address,
+ label:
+ entry.address.toLowerCase() === token?.address.toLowerCase() && tokenBalance !== null
+ ? `${formatAmount(tokenBalance, entry.decimals)} ${entry.symbol} · Asset`
+ : `${entry.symbol} — ${entry.name} · Asset`,
+ })),
+ },
+ ].filter((group) => group.options.length > 0);
return (
- {token && tokenBalance !== null ? (
+ {recent.length > 1 ? (
+
{
+ setTokenAddress(value);
+ void inspect(value);
+ }}
+ groups={headerTokenGroups}
+ placeholder={switchingCreatedToken ? 'Loading token…' : 'Choose token'}
+ ariaLabel="Active token"
+ disabled={busy === 'inspect'}
+ className="h-8 w-auto min-w-[180px] border-0 bg-transparent px-2 text-[13px] dark:bg-transparent"
+ />
+ ) : token && tokenBalance !== null ? (
{token.symbol}
+ · {token.variant}
) : null}
- {token && tokenGasEligible ? (
+ {token && headerToken?.variant === 'stablecoin' && tokenGasEligible ? (
Fees:
diff --git a/app/vibenet/demos/b20/components/DeployModule.tsx b/app/vibenet/demos/b20/components/DeployModule.tsx
index 6949db0..9db7da2 100644
--- a/app/vibenet/demos/b20/components/DeployModule.tsx
+++ b/app/vibenet/demos/b20/components/DeployModule.tsx
@@ -14,6 +14,11 @@ import { CopyableValue } from '../../../components/CopyableValue';
import { VIBENET_EXPLORER_PATH } from '../../../library/config';
import { walletErrorMessage } from '../../../library/wallet';
import { client, INITIAL_ALLOCATION_MAX, INITIAL_ALLOCATION_MEMO } from '../lib/constants';
+import {
+ chunkDeploymentOperations,
+ describeStablecoinOperations,
+ type DeploymentOperation,
+} from '../lib/deployment';
import { B20_HELP, SCOPE_HELP } from '../lib/glossary';
import {
ACTIVATION_REGISTRY,
@@ -124,11 +129,11 @@ export function DeployModule({
wallet: Address | null;
onSend: (label: string, to: Address, data: Hex, action: string) => Promise;
onSendBatches: (
- batches: Array<{ label: string; calls: Array<{ to: Address; data: Hex }> }>,
+ batches: Array<{ label: string; detail?: string; calls: Array<{ to: Address; data: Hex }> }>,
action: string,
) => Promise;
/** Live step info while a batched flow runs (null when idle). */
- progress: { label: string; index: number; total: number } | null;
+ progress: { label: string; detail?: string; index: number; total: number } | null;
/** Guided flow: flip gas to the new stablecoin and pre-fill a first payment. */
onFirstPayment: () => void;
created: CreatedToken | null;
@@ -275,21 +280,41 @@ export function DeployModule({
if (!salt.trim()) setSalt(saltValue);
const deploySalt = saltFor(saltValue);
const params = encodeDeploymentParams(variant, name, symbol, wallet, d, currency);
- const initCalls: Hex[] = ROLES.filter((role) => variant === 'asset' || role !== 'OPERATOR_ROLE').map((role) =>
- encodeRoleGrant(role, wallet),
- );
+ const initCalls: DeploymentOperation[] = ROLES.filter(
+ (role) => variant === 'asset' || role !== 'OPERATOR_ROLE',
+ ).map((role) => ({ data: encodeRoleGrant(role, wallet), kind: 'role', role }));
if (capAmount !== null)
- initCalls.push(encodeFunctionData({ abi: b20Abi, functionName: 'updateSupplyCap', args: [capAmount] }));
- if (uri) initCalls.push(encodeFunctionData({ abi: b20Abi, functionName: 'updateContractURI', args: [uri] }));
+ initCalls.push({
+ data: encodeFunctionData({ abi: b20Abi, functionName: 'updateSupplyCap', args: [capAmount] }),
+ kind: 'cap',
+ amount: formatAmount(capAmount, d),
+ symbol,
+ });
+ if (uri)
+ initCalls.push({
+ data: encodeFunctionData({ abi: b20Abi, functionName: 'updateContractURI', args: [uri] }),
+ kind: 'metadata',
+ });
initCalls.push(
- encodeFunctionData({
- abi: b20Abi,
- functionName: 'mintWithMemo',
- args: [wallet, initialMintAmount, memoToBytes32(INITIAL_ALLOCATION_MEMO)],
- }),
+ {
+ data: encodeFunctionData({
+ abi: b20Abi,
+ functionName: 'mintWithMemo',
+ args: [wallet, initialMintAmount, memoToBytes32(INITIAL_ALLOCATION_MEMO)],
+ }),
+ kind: 'mint',
+ amount: formatAmount(initialMintAmount, d),
+ symbol,
+ memo: INITIAL_ALLOCATION_MEMO,
+ },
);
initialPolicies.forEach(({ scope, id }) => {
- initCalls.push(encodeFunctionData({ abi: b20Abi, functionName: 'updatePolicy', args: [scopeId(scope), id] }));
+ initCalls.push({
+ data: encodeFunctionData({ abi: b20Abi, functionName: 'updatePolicy', args: [scopeId(scope), id] }),
+ kind: 'policy',
+ id,
+ scope,
+ });
});
const configured: string[] = [
variant === 'asset'
@@ -309,23 +334,32 @@ export function DeployModule({
functionName: 'getB20Address',
args: [variant === 'asset' ? 0 : 1, wallet, deploySalt],
});
- // The payer sponsors only ~300k gas per transaction, so creation can't
- // carry the init calls: create the bare token first, then apply the same
- // init calls directly to the token in budget-sized follow-up batches.
+ // Each transaction is kept to roughly 300k gas, so creation can't carry
+ // the init calls: create the bare token first, then apply the same init
+ // calls directly to the token in budget-sized follow-up batches.
const createData = encodeFunctionData({
abi: factoryAbi,
functionName: 'createB20',
args: [variant === 'asset' ? 0 : 1, deploySalt, params, []],
});
- // 6 calls ≈ 200k gas — the most that reliably fits under the payer's
- // ~300k per-transaction sponsorship budget alongside the batch overhead.
- const chunks: Hex[][] = [];
- for (let i = 0; i < initCalls.length; i += 6) chunks.push(initCalls.slice(i, i + 6));
+ // 6 calls ≈ 200k gas — the most that reliably fits in the ~300k
+ // per-transaction budget alongside the batch overhead.
+ const chunks = chunkDeploymentOperations(initCalls);
const batches = [
- { label: `Create ${symbol}`, calls: [{ to: B20_FACTORY, data: createData }] },
+ {
+ label: `Create ${symbol}`,
+ ...(variant === 'stablecoin'
+ ? {
+ detail:
+ 'Call createB20 with the Stablecoin variant and the EIP-8130 account as initial admin.',
+ }
+ : {}),
+ calls: [{ to: B20_FACTORY, data: createData }],
+ },
...chunks.map((chunk, i) => ({
label: chunks.length > 1 ? `Configure ${symbol} (${i + 1} of ${chunks.length})` : `Configure ${symbol}`,
- calls: chunk.map((data) => ({ to: address, data })),
+ ...(variant === 'stablecoin' ? { detail: describeStablecoinOperations(chunk) } : {}),
+ calls: chunk.map(({ data }) => ({ to: address, data })),
})),
];
const hashes = await onSendBatches(batches, 'create_b20');
@@ -560,8 +594,9 @@ export function DeployModule({
{predicted}
- Creating the token runs a short series of gas-sponsored transactions: it deploys the token, gives your
- wallet the permissions it needs, sets your options, and sends you the starting amount.
+ {variant === 'stablecoin'
+ ? 'This demo splits deployment and setup into several transactions to keep each one inside its gas budget. B20Factory also supports these setup calls through initCalls.'
+ : 'Creating the token runs a short series of transactions: it deploys the token, gives your wallet the permissions it needs, sets your options, and sends you the starting amount.'}
@@ -583,6 +618,9 @@ export function DeployModule({
) : (
Preparing your token…
)}
+ {progress?.detail ? (
+
{progress.detail}
+ ) : null}
Each step is a real onchain transaction — links appear in Recent Activity as they confirm.
@@ -670,7 +708,7 @@ function CreatedView({
>
) : (
- Asset tokens use sponsored gas. To try paying network fees with your own token, create a Stablecoin.
+ Asset tokens pay network fees in ETH. To try paying fees with your own token, create a Stablecoin.
)}
@@ -739,7 +777,7 @@ function CreatedView({
))}
- Each step ran as its own gas-sponsored transaction — check Recent Activity for the links.
+ Each step ran as its own transaction — check Recent Activity for the links.
diff --git a/app/vibenet/demos/b20/components/MemoModule.tsx b/app/vibenet/demos/b20/components/MemoModule.tsx
index 10fdcb4..96a090b 100644
--- a/app/vibenet/demos/b20/components/MemoModule.tsx
+++ b/app/vibenet/demos/b20/components/MemoModule.tsx
@@ -45,7 +45,7 @@ export function MemoModule({
onPrefillConsumed?: () => void;
/** Per-transaction network fee in token terms (e.g. "0.1 ATLN") when token gas is on. */
feeNote?: string | null;
- /** Set when the selected token is an eligible stablecoin still on sponsored gas. */
+ /** Set when the selected token is an eligible stablecoin still paying fees in ETH. */
onEnableTokenGas?: (() => void) | null;
}) {
const [to, setTo] = useState('');
@@ -71,7 +71,7 @@ export function MemoModule({
if (v <= 0n) throw new Error('Enter an amount greater than zero.');
if (!isAddress(to)) throw new Error('Paste a valid wallet address for the recipient.');
const hash = await onSend(
- 'Transfer with memo',
+ token.variant === 'stablecoin' ? `Send ${token.symbol} with memo` : 'Transfer with memo',
token.address,
encodeFunctionData({ abi: b20Abi, functionName: 'transferWithMemo', args: [to, v, m] }),
'memo_transfer',
@@ -245,12 +245,18 @@ export function MemoModule({
void submit()} disabled={!!busy}>
- {busy === 'memo_transfer' ? 'Sending…' : 'Submit transfer with memo'}
+ {busy === 'memo_transfer'
+ ? token.variant === 'stablecoin'
+ ? `Sending ${token.symbol}…`
+ : 'Sending…'
+ : token.variant === 'stablecoin'
+ ? `Send ${token.symbol} with memo`
+ : 'Submit transfer with memo'}
{feeNote
? `Network fee: ${feeNote} — paid from your balance.`
- : 'Network fee: sponsored.'}
+ : 'Network fee: paid in ETH from your account.'}
{!feeNote && onEnableTokenGas && token ? (
<>
{' '}
diff --git a/app/vibenet/demos/b20/components/PolicyModule.tsx b/app/vibenet/demos/b20/components/PolicyModule.tsx
index 012a6f3..f2e1434 100644
--- a/app/vibenet/demos/b20/components/PolicyModule.tsx
+++ b/app/vibenet/demos/b20/components/PolicyModule.tsx
@@ -8,6 +8,7 @@ import { Button } from '../../../../components/ui/Button';
import { Card } from '../../../../components/ui/Card';
import { cn } from '../../../../components/ui/cn';
import { InfoTooltip } from '../../../../components/ui/InfoTooltip';
+import { Select, type SelectGroup } from '../../../../components/ui/Select';
import { Text } from '../../../../components/ui/Text';
import { VIBENET_EXPLORER_PATH } from '../../../library/config';
import type { AddressBookEntry } from '../../_shared/AddressAutocomplete';
@@ -63,6 +64,21 @@ export function PolicyModule({
const [showCreator, setShowCreator] = useState(false);
const [suggestedPolicyId, setSuggestedPolicyId] = useState(null);
const isSample = tokenAccess === 'sample';
+ const selectedRecent = recent.find((entry) => entry.address.toLowerCase() === address.trim().toLowerCase());
+ const recentGroups: SelectGroup[] = [
+ {
+ label: 'Stablecoins · eligible for gas',
+ options: recent
+ .filter((entry) => entry.variant === 'stablecoin')
+ .map((entry) => ({ value: entry.address, label: `${entry.symbol} — ${entry.name}` })),
+ },
+ {
+ label: 'Assets · fees in ETH only',
+ options: recent
+ .filter((entry) => entry.variant === 'asset')
+ .map((entry) => ({ value: entry.address, label: `${entry.symbol} — ${entry.name}` })),
+ },
+ ].filter((group) => group.options.length > 0);
return (
@@ -141,22 +157,38 @@ export function PolicyModule({
- {recent.length ? (
+ {recent.length > 1 ? (
<>
- Or choose a token you recently created.
-
- {recent.map((entry) => (
- onInspect(entry.address)}
- className="rounded-lg border border-bds-gray-10 px-3 py-2 text-left text-[12px] hover:border-base-blue dark:border-white/10"
- >
- {entry.symbol}
- {entry.variant}
-
- ))}
-
+ Or switch between tokens created by this wallet.
+ {
+ 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.
+ onInspect(recent[0].address)}
+ className="mt-3 rounded-lg border border-bds-gray-10 px-3 py-2 text-left text-[12px] hover:border-base-blue dark:border-white/10"
+ >
+
+ {recent[0].symbol} — {recent[0].name}
+
+ {recent[0].variant}
+
+ {recent[0].variant === 'stablecoin' ? ' · Eligible for gas' : ' · Fees in ETH only'}
+
+
>
) : (
Tokens you create with this wallet will appear here.
diff --git a/app/vibenet/demos/b20/lib/deployment.test.ts b/app/vibenet/demos/b20/lib/deployment.test.ts
new file mode 100644
index 0000000..54bc85b
--- /dev/null
+++ b/app/vibenet/demos/b20/lib/deployment.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ chunkDeploymentOperations,
+ describeStablecoinOperations,
+ type DeploymentOperation,
+} from './deployment';
+
+const data = '0x1234' as const;
+
+describe('B20 deployment progress', () => {
+ it('keeps configuration batches at six calls', () => {
+ const operations: DeploymentOperation[] = Array.from({ length: 8 }, (_, index) => ({
+ data,
+ kind: 'role' as const,
+ role: `ROLE_${index + 1}`,
+ }));
+
+ const chunks = chunkDeploymentOperations(operations);
+
+ expect(chunks).toHaveLength(2);
+ expect(chunks[0]).toHaveLength(6);
+ expect(chunks[1]).toHaveLength(2);
+ expect(chunks.flat()).toEqual(operations);
+ });
+
+ it('describes the exact Stablecoin operations in a batch', () => {
+ const operations: DeploymentOperation[] = [
+ { data, kind: 'role', role: 'MINT_ROLE' },
+ { data, kind: 'role', role: 'METADATA_ROLE' },
+ { data, kind: 'cap', amount: '10,000,000', symbol: 'USDC' },
+ { data, kind: 'metadata' },
+ { data, kind: 'mint', amount: '100', symbol: 'USDC', memo: 'Initial deposit' },
+ { data, kind: 'policy', id: 42n, scope: 'TRANSFER_RECEIVER_POLICY' },
+ ];
+
+ expect(describeStablecoinOperations(operations)).toBe(
+ 'Grant MINT_ROLE, METADATA_ROLE to the EIP-8130 account; set the supply cap to 10,000,000 USDC; save the token information link; mint 100 USDC to the EIP-8130 account with the “Initial deposit” memo; attach policy 42 to TRANSFER_RECEIVER_POLICY.',
+ );
+ });
+});
diff --git a/app/vibenet/demos/b20/lib/deployment.ts b/app/vibenet/demos/b20/lib/deployment.ts
new file mode 100644
index 0000000..93d2355
--- /dev/null
+++ b/app/vibenet/demos/b20/lib/deployment.ts
@@ -0,0 +1,52 @@
+import type { Hex } from 'viem';
+
+export type DeploymentOperation =
+ | { data: Hex; kind: 'role'; role: string }
+ | { data: Hex; kind: 'cap'; amount: string; symbol: string }
+ | { data: Hex; kind: 'metadata' }
+ | { data: Hex; kind: 'mint'; amount: string; symbol: string; memo: string }
+ | { data: Hex; kind: 'policy'; id: bigint; scope: string };
+
+export function chunkDeploymentOperations(
+ operations: DeploymentOperation[],
+ size = 6,
+): DeploymentOperation[][] {
+ const chunks: DeploymentOperation[][] = [];
+ for (let index = 0; index < operations.length; index += size) {
+ chunks.push(operations.slice(index, index + size));
+ }
+ return chunks;
+}
+
+// Stablecoin creation is split across several transactions in this demo.
+// Keep the description derived from the calls in each transaction so the UI
+// never claims that a setting has been applied in a different batch.
+export function describeStablecoinOperations(operations: DeploymentOperation[]): string {
+ const clauses: string[] = [];
+ const roles = operations.filter((operation) => operation.kind === 'role').map((operation) => operation.role);
+ if (roles.length) {
+ clauses.push(`Grant ${roles.join(', ')} to the EIP-8130 account`);
+ }
+ for (const operation of operations) {
+ switch (operation.kind) {
+ case 'cap':
+ clauses.push(`set the supply cap to ${operation.amount} ${operation.symbol}`);
+ break;
+ case 'metadata':
+ clauses.push('save the token information link');
+ break;
+ case 'mint':
+ clauses.push(
+ `mint ${operation.amount} ${operation.symbol} to the EIP-8130 account with the “${operation.memo}” memo`,
+ );
+ break;
+ case 'policy':
+ clauses.push(`attach policy ${operation.id.toString()} to ${operation.scope}`);
+ break;
+ case 'role':
+ break;
+ }
+ }
+ if (!clauses.length) return '';
+ return `${clauses.join('; ')}.`;
+}
diff --git a/app/vibenet/demos/b20/lib/gasPayer.ts b/app/vibenet/demos/b20/lib/gasPayer.ts
index b738e4d..e79a26e 100644
--- a/app/vibenet/demos/b20/lib/gasPayer.ts
+++ b/app/vibenet/demos/b20/lib/gasPayer.ts
@@ -117,10 +117,10 @@ export function payerErrorMessage(error: unknown): string | null {
switch (rejected.code) {
case 'BUDGET_EXHAUSTED':
case 'SENDER_LIMIT_REACHED':
- return 'The gas sponsorship budget for this demo is used up. Wait a bit, then try again.';
+ return "The demo gas payer's budget is used up. Wait a bit, then try again.";
case 'TEMPORARILY_UNAVAILABLE':
- return 'The gas sponsor is temporarily unavailable. Try again in a moment.';
+ return 'The gas payer is temporarily unavailable. Try again in a moment.';
default:
- return `The gas sponsor declined this transaction${rejected.reason ? `: ${rejected.reason}` : '.'}`;
+ return `The gas payer declined this transaction${rejected.reason ? `: ${rejected.reason}` : '.'}`;
}
}
diff --git a/app/vibenet/demos/b20/lib/tokenGas.test.ts b/app/vibenet/demos/b20/lib/tokenGas.test.ts
new file mode 100644
index 0000000..d575794
--- /dev/null
+++ b/app/vibenet/demos/b20/lib/tokenGas.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+
+import { canUseTokenForGas } from './tokenGas';
+
+describe('B20 token gas eligibility', () => {
+ it('allows a managed Stablecoin to pay gas', () => {
+ expect(canUseTokenForGas('stablecoin', true, false)).toBe(true);
+ expect(canUseTokenForGas('stablecoin', false, true)).toBe(true);
+ });
+
+ it('never allows an Asset token to pay gas', () => {
+ expect(canUseTokenForGas('asset', true, false)).toBe(false);
+ expect(canUseTokenForGas('asset', false, true)).toBe(false);
+ expect(canUseTokenForGas('asset', true, true)).toBe(false);
+ });
+
+ it('requires access to the selected Stablecoin', () => {
+ expect(canUseTokenForGas('stablecoin', false, false)).toBe(false);
+ expect(canUseTokenForGas(undefined, true, true)).toBe(false);
+ });
+});
diff --git a/app/vibenet/demos/b20/lib/tokenGas.ts b/app/vibenet/demos/b20/lib/tokenGas.ts
new file mode 100644
index 0000000..38cf103
--- /dev/null
+++ b/app/vibenet/demos/b20/lib/tokenGas.ts
@@ -0,0 +1,7 @@
+export function canUseTokenForGas(
+ variant: 'asset' | 'stablecoin' | undefined,
+ isAdmin: boolean,
+ isOperator: boolean,
+): boolean {
+ return variant === 'stablecoin' && (isAdmin || isOperator);
+}
diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts
index 712a3f6..6e9d460 100644
--- a/app/vibenet/demos/catalogue.ts
+++ b/app/vibenet/demos/catalogue.ts
@@ -38,9 +38,9 @@ export const DEMOS: DemoEntry[] = [
title: 'Tokens',
shortTitle: 'Tokens',
summary:
- 'Make a gasless EIP-8130 smart wallet in one click, then inspect policy scopes, attach transaction memos, publish Asset announcements, and create Base-native B20 tokens.',
+ 'Create an EIP-8130 account in one click, then inspect policy scopes, attach transaction memos, publish Asset announcements, and create Base-native B20 tokens.',
points: [
- 'One-click 8130 wallet — faucet-seeded, gasless via payer sponsorship',
+ 'One-click 8130 account — faucet-seeded and shared with the Accounts demo',
'Pay gas with your own stablecoin (ERC-8168 token payment)',
'Policies, memos, and Asset announcements',
],
From fb1a8c9ec2816421adf7042de56ed0c50f4b4c2e Mon Sep 17 00:00:00 2001
From: Montana Wong
Date: Tue, 25 Aug 2026 15:37:44 -0400
Subject: [PATCH 4/5] fix(b20): keep the activity log in the page flow, pin the
nonce across batches
Two problems reported after the rebase onto main's account engine.
The activity log moved into the shared bottom drawer, which is sticky and
full-bleed, so it read as a separate panel rather than part of the page. B20
narrates multi-transaction flows and the log has to stay readable next to the
form that started them, so it goes back to an inline card and the drawer in
AccountDemoShell becomes optional.
Creating a token failed at the configure step. sendBatches looped over
sendActiveCalls, and signComposed re-reads the nonce and probes for code on
every call. The public RPC is load-balanced across replicas whose heads can
differ, so the read between two sends can answer from a replica that has not
seen the first one: the second transaction is signed with the same nonce and
dropped as a duplicate. sendActiveCallsBatches reads both once up front and
counts each batch's sequence from there.
---
.../demos/_components/AccountDemoShell.tsx | 20 ++--
app/vibenet/demos/account/useAccountEngine.ts | 107 +++++++++++++++++-
app/vibenet/demos/b20/B20Demo.tsx | 73 ++++++------
app/vibenet/demos/b20/components/Activity.tsx | 34 ++++++
4 files changed, 187 insertions(+), 47 deletions(-)
create mode 100644 app/vibenet/demos/b20/components/Activity.tsx
diff --git a/app/vibenet/demos/_components/AccountDemoShell.tsx b/app/vibenet/demos/_components/AccountDemoShell.tsx
index 00eb206..91a9b66 100644
--- a/app/vibenet/demos/_components/AccountDemoShell.tsx
+++ b/app/vibenet/demos/_components/AccountDemoShell.tsx
@@ -6,7 +6,9 @@
// on mobile (the top bar is hidden there);
// - a full-page DemoGate (empty state until a local account exists);
// - the shared create/details account-management modals;
-// - the collapsible ActivityDrawer pinned to the bottom.
+// - the collapsible ActivityDrawer pinned to the bottom, for demos that hand
+// it activity (B20 keeps its log in the page flow instead, so it passes
+// none and the drawer is skipped).
// Each demo owns one AccountEngine and passes it here, avoiding duplicate store
// instances and repeated account-settings wiring.
@@ -29,9 +31,9 @@ type AccountDemoShellProps = {
// Empty-state copy.
gateTitle?: string;
gateDescription?: string;
- // Activity drawer.
- activity: ReactNode;
- activityCount: number;
+ // Activity drawer. Omit `activity` to render no drawer at all.
+ activity?: ReactNode;
+ activityCount?: number;
activityEmptyMessage?: string;
// Extra classes for the root (gap, demo-specific tweaks).
className?: string;
@@ -44,7 +46,7 @@ export function AccountDemoShell({
gateTitle,
gateDescription,
activity,
- activityCount,
+ activityCount = 0,
activityEmptyMessage,
className,
children,
@@ -87,9 +89,11 @@ export function AccountDemoShell({
{/* Mobile only — desktop uses the top-bar switcher. */}
{switcher}
{children}
-
- {activity}
-
+ {activity ? (
+
+ {activity}
+
+ ) : null}
diff --git a/app/vibenet/demos/account/useAccountEngine.ts b/app/vibenet/demos/account/useAccountEngine.ts
index d891c0e..8afc801 100644
--- a/app/vibenet/demos/account/useAccountEngine.ts
+++ b/app/vibenet/demos/account/useAccountEngine.ts
@@ -1212,6 +1212,12 @@ export function useAccountEngine() {
// B20 stablecoin as the fee). Without it the tx is serialized with an empty
// `payerAuth` for a hosted payer service to co-sign out of band.
payerOpt?: { address: Address; phase0?: { to: Address; data: Hex }[]; localSigner?: Signer },
+ // Set by callers that run several transactions back to back. The public RPC
+ // is served by replicas whose heads can differ, so re-reading the nonce (or
+ // probing for code) between two sends can answer from a replica that hasn't
+ // seen the previous one yet. Such a caller reads both once up front and
+ // pins them here instead.
+ seqOpt?: { nonceSequence?: bigint; assumeDeployed?: boolean },
): Promise<{ serialized: Hex; nextSeq: number }> => {
const signer = await buildSigner(signerWS);
const account = nativeAccountFor(a, signer, signerWS.authenticator);
@@ -1235,7 +1241,12 @@ export function useAccountEngine() {
// Resolve deployment + both config counters once at the composition
// boundary. Lower-level signing never consults the persisted account flags.
- const { deployed: effectivelyDeployed } = await fetchOnChainAccountState(account.address as Address);
+ // A caller running several transactions back to back pins the deployment
+ // state instead: an earlier transaction in that run already deployed the
+ // account, and a code probe can still lag it and wrongly re-attach the
+ // create change.
+ const effectivelyDeployed =
+ seqOpt?.assumeDeployed ?? (await fetchOnChainAccountState(account.address as Address)).deployed;
const bootstrapChange = effectivelyDeployed ? undefined : firstDeployChange(a, account);
if (bootstrapChange) accountChanges.push(bootstrapChange);
if (effectivelyDeployed !== a.deployed) updateAccount(a.id, { deployed: effectivelyDeployed });
@@ -1273,10 +1284,12 @@ export function useAccountEngine() {
const plainCallCount = Math.max(totalCalls - heavyCallCount, 1);
const wire = encodeWalletCalls({ account: account.address, calls: phases });
- const nonceSequence = await getTransactionCount(makeRpcClient(), {
- address: account.address as Address,
- nonceKey: 0n,
- });
+ const nonceSequence =
+ seqOpt?.nonceSequence ??
+ (await getTransactionCount(makeRpcClient(), {
+ address: account.address as Address,
+ nonceKey: 0n,
+ }));
// Authenticator hint so estimateGas shapes the senderAuth stub for the
// actual signer. A delegate-signed sub-account acts via the parent's delegate
@@ -1461,6 +1474,89 @@ export function useAccountEngine() {
return { hash, serialized, mode: tokenGas ? 'token' : 'self' };
};
+ /**
+ * Run several transactions from the active account back to back.
+ *
+ * Not a loop over `sendActiveCalls`: the reads that call depends on — the
+ * account's nonce and whether it has code — are answered by load-balanced RPC
+ * replicas whose heads can differ, so re-reading them between two sends can
+ * return a view that predates the previous one. That drops the second
+ * transaction as a duplicate nonce, or re-attaches the create change to an
+ * account that already exists. Both reads happen once here, and each batch
+ * gets its sequence counted from there.
+ *
+ * Pending owner/session changes ride the first batch only. Returns one result
+ * per batch; throws on the first failure, leaving earlier batches applied
+ * (callers should make each batch meaningful on its own).
+ */
+ const sendActiveCallsBatches = async ({
+ batches,
+ tokenGas,
+ onBatchStart,
+ onBatchResult,
+ }: {
+ batches: { calls: { to: Address; data: Hex }[] }[];
+ tokenGas?: { token: Address; decimals: number; payer: Signer; fee: bigint };
+ onBatchStart?: (index: number, total: number) => void;
+ onBatchResult?: (index: number, result: { hash: Hex; serialized: Hex; mode: 'self' | 'token' }) => void;
+ }): Promise<{ hash: Hex; serialized: Hex; mode: 'self' | 'token' }[]> => {
+ if (!acct) throw new Error('Select an account before you continue.');
+ if (!batches.length) throw new Error('No calls to send.');
+ const signer =
+ postChangeOwnerSigners.find((s) => s.id === activeSignerId) ??
+ postChangeOwnerSigners[0] ??
+ activeSigner;
+ if (!signer) throw new Error('No local owner key found for this account.');
+
+ const bundle = pendingBundleFor({ mode: 'owner-send' });
+ const presigned = bundle.map((item) => item.change);
+ const changeSeq = bundle.length ? bundle[bundle.length - 1].sequence : null;
+ const payerOpt = tokenGas
+ ? {
+ address: tokenGas.payer.address,
+ phase0: [
+ (({ to, data }) => ({ to, data }))(
+ encodeTokenTransfer({ token: tokenGas.token, to: tokenGas.payer.address, amount: tokenGas.fee }),
+ ),
+ ],
+ localSigner: tokenGas.payer,
+ }
+ : undefined;
+
+ // Read the starting nonce a few times and keep the highest: a single read
+ // can land on a replica that is a block behind.
+ const address = acct.address as Address;
+ let startSequence = 0n;
+ for (let i = 0; i < 3; i += 1) {
+ const count = await getTransactionCount(makeRpcClient(), { address, nonceKey: 0n }).catch(() => null);
+ if (count !== null && count > startSequence) startSequence = count;
+ }
+
+ const results: { hash: Hex; serialized: Hex; mode: 'self' | 'token' }[] = [];
+ const mode: 'self' | 'token' = tokenGas ? 'token' : 'self';
+ for (const [index, batch] of batches.entries()) {
+ onBatchStart?.(index, batches.length);
+ const first = index === 0;
+ const { serialized, nextSeq } = await signComposed(
+ acct,
+ signer,
+ batch.calls.map((call) => newCallRow({ ...call, value: '0' })),
+ first ? presigned : [],
+ first ? changeSeq : null,
+ undefined,
+ undefined,
+ payerOpt,
+ { nonceSequence: startSequence + BigInt(index), assumeDeployed: !first || undefined },
+ );
+ const hash = await broadcast8130(serialized);
+ if (first) applyLandedBundle(acct, nextSeq, bundle);
+ const result = { hash, serialized, mode };
+ results.push(result);
+ onBatchResult?.(index, result);
+ }
+ return results;
+ };
+
const sendActiveCall = async ({ to, data }: { to: Address; data: Hex }) => {
const { hash, serialized } = await sendActiveCalls({ calls: [{ to, data }] });
return { hash, serialized };
@@ -2381,6 +2477,7 @@ export function useAccountEngine() {
signComposed,
sendActiveCall,
sendActiveCalls,
+ sendActiveCallsBatches,
applyLandedBundle,
handleSeqMismatch,
pendingBundleFor,
diff --git a/app/vibenet/demos/b20/B20Demo.tsx b/app/vibenet/demos/b20/B20Demo.tsx
index ebbfd78..200ab38 100644
--- a/app/vibenet/demos/b20/B20Demo.tsx
+++ b/app/vibenet/demos/b20/B20Demo.tsx
@@ -7,11 +7,11 @@ import { trackB20Action, trackB20ModuleSelect } from '../../../analytics/events'
import { cn } from '../../../components/ui/cn';
import { Tabs } from '../../../components/ui/Tabs';
import { walletErrorMessage } from '../../library/wallet';
-import { ActivityLog } from '../account/components/ActivityLog';
import { useAccountEngine } from '../account/useAccountEngine';
import { AccountDemoShell } from '../_components/AccountDemoShell';
import { AnimatedAmount } from '../_components/AnimatedAmount';
import { Select, type SelectGroup } from '../../../components/ui/Select';
+import { Activity } from './components/Activity';
import { AnnouncementModule, SampleAnnouncementViewer } from './components/AnnouncementModule';
import { DeployModule } from './components/DeployModule';
import { MemoModule } from './components/MemoModule';
@@ -457,39 +457,46 @@ export function B20Demo() {
setBusy(action);
setInspectError('');
trackB20Action(module, action, 'submitted');
- const hashes: Hex[] = [];
+ const tokenGas =
+ gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer
+ ? {
+ token: token.address,
+ decimals: token.decimals,
+ payer: payerSigner(storedPayer),
+ fee: tokenGasFee(token.decimals),
+ }
+ : undefined;
try {
- for (const [index, batch] of batches.entries()) {
- setBatchProgress({ label: batch.label, detail: batch.detail, index, total: batches.length });
- const tokenGas =
- gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer
- ? {
- token: token.address,
- decimals: token.decimals,
- payer: payerSigner(storedPayer),
- fee: tokenGasFee(token.decimals),
- }
- : undefined;
- if (storedPayer && tokenGas) await ensurePayerFunded(storedPayer);
- const { hash, serialized, mode } = await engine.sendActiveCalls({
- calls: batch.calls,
- ...(tokenGas ? { tokenGas } : {}),
- });
- hashes.push(hash);
- engine.pushActivity({
- kind: 'transact',
- title: annotateMode(batch.label, mode, token?.symbol),
- detail: batch.detail,
- txHash: hash,
- serialized,
- network: engine.chain.name,
- mode: engine.chain.mode,
- account: activeAccount.address as Address,
- });
- }
+ // The payer underwrites the gas in ETH, so it has to be funded before
+ // it co-signs — the first token-paid send follows key creation closely.
+ if (storedPayer && tokenGas) await ensurePayerFunded(storedPayer);
+ // One engine call, not one per batch: it pins the nonce and deployment
+ // state across the whole run so a lagging RPC replica can't make the
+ // second transaction collide with the first.
+ const results = await engine.sendActiveCallsBatches({
+ batches,
+ ...(tokenGas ? { tokenGas } : {}),
+ onBatchStart: (index, total) => {
+ const batch = batches[index];
+ setBatchProgress({ label: batch.label, detail: batch.detail, index, total });
+ },
+ onBatchResult: (index, { hash, serialized, mode }) => {
+ const batch = batches[index];
+ engine.pushActivity({
+ kind: 'transact',
+ title: annotateMode(batch.label, mode, token?.symbol),
+ detail: batch.detail,
+ txHash: hash,
+ serialized,
+ network: engine.chain.name,
+ mode: engine.chain.mode,
+ account: activeAccount.address as Address,
+ });
+ },
+ });
trackB20Action(module, action, 'success');
refreshWallet(activeAccount.address as Address);
- return hashes;
+ return results.map((result) => result.hash);
} catch (error) {
const detail = payerErrorMessage(error) ?? walletErrorMessage(error);
trackB20Action(module, action, 'error');
@@ -566,9 +573,6 @@ export function B20Demo() {
return (
}
- activityCount={engine.activity.length}
- activityEmptyMessage="Nothing has happened yet."
className="animate-in gap-5 pb-6 dark:text-white"
>
@@ -748,6 +752,7 @@ export function B20Demo() {
{inspectError}
) : null}
+
);
}
diff --git a/app/vibenet/demos/b20/components/Activity.tsx b/app/vibenet/demos/b20/components/Activity.tsx
new file mode 100644
index 0000000..50645c6
--- /dev/null
+++ b/app/vibenet/demos/b20/components/Activity.tsx
@@ -0,0 +1,34 @@
+import { Card } from '../../../../components/ui/Card';
+import { Text } from '../../../../components/ui/Text';
+import { ActivityLog } from '../../account/components/ActivityLog';
+import type { ActivityEntry, StoredAccount } from '../../account/library/model';
+
+// The account demo's activity history, shown in the page flow beneath every
+// module rather than in the shared bottom drawer — the B20 modules narrate
+// multi-transaction flows, so the log has to stay readable alongside them
+// without covering the form that started them. The entries themselves come
+// from the account engine, so both demos read one trail.
+export function Activity({ activity, accounts }: { activity: ActivityEntry[]; accounts: StoredAccount[] }) {
+ return (
+
+
+
+ Recent activity
+
+ See what this demo did during your current visit.
+
+
+
+ {activity.length
+ ? `${activity.length} activity item${activity.length === 1 ? '' : 's'}`
+ : '● Your activity will appear here'}
+
+
+ {activity.length ? (
+
+ ) : null}
+
+ );
+}
From 32622fe95a74275c77e835677d923ed0983dbddd Mon Sep 17 00:00:00 2001
From: Montana Wong
Date: Tue, 25 Aug 2026 15:58:34 -0400
Subject: [PATCH 5/5] fix(b20): retry a batch the node rejected or dropped
mid-run
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Creating a token from a new account failed on the second transaction with
"actor is not bound" (surfaced as "Missing or invalid parameters"). An
account's code and the actors bound to it reach every RPC replica a moment
after the transaction that wrote them lands, so the batch prepared right
behind the one that deployed the account was validated against a replica that
had not seen it yet and was rejected before it was ever broadcast.
Occasionally a later batch was broadcast and then dropped instead: the engine
signs at a flat 1 gwei maxFeePerGas, which equals the current base fee, so the
transaction carries no priority fee and is not guaranteed a slot.
sendActiveCallsBatches now retries a batch on both. A rejection before
broadcast waits for the state to propagate and signs again on the same nonce.
A broadcast that has not been included gets a longer wait, then the node is
asked whether it still holds the transaction — one it has dropped is sent
again, one it still holds is left alone so a second copy cannot collide with
it. The receipt-and-phase check that broadcast8130 already did moves into
awaitInclusion so both paths share it.
---
app/vibenet/demos/account/useAccountEngine.ts | 104 +++++++++++++-----
1 file changed, 79 insertions(+), 25 deletions(-)
diff --git a/app/vibenet/demos/account/useAccountEngine.ts b/app/vibenet/demos/account/useAccountEngine.ts
index 8afc801..988f2d8 100644
--- a/app/vibenet/demos/account/useAccountEngine.ts
+++ b/app/vibenet/demos/account/useAccountEngine.ts
@@ -1136,18 +1136,12 @@ export function useAccountEngine() {
? account.delegate(a.delegate ?? chain.deployment.accounts.default)
: (account as ReturnType).create();
- // Broadcast a signed 8130 tx and wait for inclusion. Throws TxPendingError on
- // timeout (submitted but unconfirmed), a plain Error if any phase reverts.
- const broadcast8130 = async (signedTx: Hex, onStatus?: (s: 'submitting' | 'confirming') => void): Promise => {
- const client = makeRpcClient();
- onStatus?.('submitting');
- const txHash = (await client.request({
- method: 'eth_sendRawTransaction',
- params: [signedTx],
- })) as Hex;
- onStatus?.('confirming');
+ // Wait for a broadcast tx to be included and check that it — and every 8130
+ // phase in it — succeeded. Throws TxPendingError if it is still not included
+ // when the timeout runs out, a plain Error if anything reverted.
+ const awaitInclusion = async (txHash: Hex, timeout = 30_000): Promise => {
try {
- const receipt = await waitForTransactionReceipt(client as never, { hash: txHash, timeout: 30_000 });
+ const receipt = await waitForTransactionReceipt(makeRpcClient() as never, { hash: txHash, timeout });
if (receipt.status === '0x0') throw new Error(`Transaction reverted onchain (${txHash}).`);
const phases = receipt.eip8130?.phaseStatuses ?? [];
const failedPhase = phases.findIndex((s: Hex) => s === '0x0');
@@ -1159,6 +1153,19 @@ export function useAccountEngine() {
return txHash;
};
+ // Broadcast a signed 8130 tx and wait for inclusion. Throws TxPendingError on
+ // timeout (submitted but unconfirmed), a plain Error if any phase reverts.
+ const broadcast8130 = async (signedTx: Hex, onStatus?: (s: 'submitting' | 'confirming') => void): Promise => {
+ const client = makeRpcClient();
+ onStatus?.('submitting');
+ const txHash = (await client.request({
+ method: 'eth_sendRawTransaction',
+ params: [signedTx],
+ })) as Hex;
+ onStatus?.('confirming');
+ return awaitInclusion(txHash);
+ };
+
// Live EIP-8130 state used while preparing a transaction. This is the only
// source of truth for deployment and config sequences; the persisted
// `deployed` / `configSeq` fields are display caches and are never consulted
@@ -1532,25 +1539,72 @@ export function useAccountEngine() {
if (count !== null && count > startSequence) startSequence = count;
}
+ // An account's code and the actors bound to it reach every replica a moment
+ // after the transaction that wrote them lands, so a batch prepared right
+ // behind the one that deployed the account is validated against a replica
+ // that has not seen it yet and is rejected with "actor is not bound" before
+ // it is ever broadcast. Wait for the state to catch up and prepare it again.
+ // A transaction that expired without landing is definitively dropped, so
+ // that one can go straight back out. Everything else — a revert, a rejected
+ // call, a broadcast whose receipt never arrived — is real and propagates.
+ const attemptBatch = async (send: () => Promise): Promise => {
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ return await send();
+ } catch (error) {
+ if (attempt >= 3) throw error;
+ // Broadcast but not included in time. Give it a little longer, then
+ // ask the node whether it still holds the transaction: one it has
+ // dropped is never coming back, so the batch is signed and sent again
+ // on the same nonce. One it still holds must be left alone — a second
+ // copy would only collide with it.
+ if (error instanceof TxPendingError) {
+ const landed = await awaitInclusion(error.txHash, 15_000).catch((err) => {
+ if (err instanceof TxPendingError) return null;
+ throw err;
+ });
+ if (landed) return landed;
+ const known = await makeRpcClient()
+ .request({ method: 'eth_getTransactionByHash', params: [error.txHash] })
+ .catch(() => 'unreadable');
+ if (known !== null) throw error;
+ continue;
+ }
+ const message = error instanceof Error ? error.message : String(error);
+ const expired = /expired before landing/i.test(message);
+ if (!expired && !/actor is not bound/i.test(message)) throw error;
+ await new Promise((resolve) => setTimeout(resolve, expired ? 1_000 : 5_000));
+ }
+ }
+ };
+
const results: { hash: Hex; serialized: Hex; mode: 'self' | 'token' }[] = [];
const mode: 'self' | 'token' = tokenGas ? 'token' : 'self';
for (const [index, batch] of batches.entries()) {
onBatchStart?.(index, batches.length);
const first = index === 0;
- const { serialized, nextSeq } = await signComposed(
- acct,
- signer,
- batch.calls.map((call) => newCallRow({ ...call, value: '0' })),
- first ? presigned : [],
- first ? changeSeq : null,
- undefined,
- undefined,
- payerOpt,
- { nonceSequence: startSequence + BigInt(index), assumeDeployed: !first || undefined },
- );
- const hash = await broadcast8130(serialized);
- if (first) applyLandedBundle(acct, nextSeq, bundle);
- const result = { hash, serialized, mode };
+ // Written by whichever signing attempt produced the transaction that
+ // landed — a retry re-signs, so these can't be read from the first one.
+ let landedSeq: number | null = null;
+ let landedSerialized: Hex = '0x';
+ const hash = await attemptBatch(async () => {
+ const { serialized, nextSeq } = await signComposed(
+ acct,
+ signer,
+ batch.calls.map((call) => newCallRow({ ...call, value: '0' })),
+ first ? presigned : [],
+ first ? changeSeq : null,
+ undefined,
+ undefined,
+ payerOpt,
+ { nonceSequence: startSequence + BigInt(index), assumeDeployed: !first || undefined },
+ );
+ landedSeq = nextSeq;
+ landedSerialized = serialized;
+ return broadcast8130(serialized);
+ });
+ if (first && landedSeq !== null) applyLandedBundle(acct, landedSeq, bundle);
+ const result = { hash, serialized: landedSerialized, mode };
results.push(result);
onBatchResult?.(index, result);
}