diff --git a/app/vibenet/demos/_components/AccountDemoShell.tsx b/app/vibenet/demos/_components/AccountDemoShell.tsx
index 00eb206..91a9b66 100644
--- a/app/vibenet/demos/_components/AccountDemoShell.tsx
+++ b/app/vibenet/demos/_components/AccountDemoShell.tsx
@@ -6,7 +6,9 @@
// on mobile (the top bar is hidden there);
// - a full-page DemoGate (empty state until a local account exists);
// - the shared create/details account-management modals;
-// - the collapsible ActivityDrawer pinned to the bottom.
+// - the collapsible ActivityDrawer pinned to the bottom, for demos that hand
+// it activity (B20 keeps its log in the page flow instead, so it passes
+// none and the drawer is skipped).
// Each demo owns one AccountEngine and passes it here, avoiding duplicate store
// instances and repeated account-settings wiring.
@@ -29,9 +31,9 @@ type AccountDemoShellProps = {
// Empty-state copy.
gateTitle?: string;
gateDescription?: string;
- // Activity drawer.
- activity: ReactNode;
- activityCount: number;
+ // Activity drawer. Omit `activity` to render no drawer at all.
+ activity?: ReactNode;
+ activityCount?: number;
activityEmptyMessage?: string;
// Extra classes for the root (gap, demo-specific tweaks).
className?: string;
@@ -44,7 +46,7 @@ export function AccountDemoShell({
gateTitle,
gateDescription,
activity,
- activityCount,
+ activityCount = 0,
activityEmptyMessage,
className,
children,
@@ -87,9 +89,11 @@ export function AccountDemoShell({
{/* Mobile only — desktop uses the top-bar switcher. */}
{switcher}
{children}
-
- {activity}
-
+ {activity ? (
+
+ {activity}
+
+ ) : null}
diff --git a/app/vibenet/demos/account/useAccountEngine.ts b/app/vibenet/demos/account/useAccountEngine.ts
index 2add707..988f2d8 100644
--- a/app/vibenet/demos/account/useAccountEngine.ts
+++ b/app/vibenet/demos/account/useAccountEngine.ts
@@ -27,6 +27,7 @@ import {
ecrecoverAuthenticator,
type Eip8130Deployment,
encodeSessionPolicyConfig,
+ encodeTokenTransfer,
encodeWalletCalls,
estimateGas,
generatePrivateKey,
@@ -1135,18 +1136,12 @@ export function useAccountEngine() {
? account.delegate(a.delegate ?? chain.deployment.accounts.default)
: (account as ReturnType).create();
- // Broadcast a signed 8130 tx and wait for inclusion. Throws TxPendingError on
- // timeout (submitted but unconfirmed), a plain Error if any phase reverts.
- const broadcast8130 = async (signedTx: Hex, onStatus?: (s: 'submitting' | 'confirming') => void): Promise => {
- const client = makeRpcClient();
- onStatus?.('submitting');
- const txHash = (await client.request({
- method: 'eth_sendRawTransaction',
- params: [signedTx],
- })) as Hex;
- onStatus?.('confirming');
+ // Wait for a broadcast tx to be included and check that it — and every 8130
+ // phase in it — succeeded. Throws TxPendingError if it is still not included
+ // when the timeout runs out, a plain Error if anything reverted.
+ const awaitInclusion = async (txHash: Hex, timeout = 30_000): Promise => {
try {
- const receipt = await waitForTransactionReceipt(client as never, { hash: txHash, timeout: 30_000 });
+ const receipt = await waitForTransactionReceipt(makeRpcClient() as never, { hash: txHash, timeout });
if (receipt.status === '0x0') throw new Error(`Transaction reverted onchain (${txHash}).`);
const phases = receipt.eip8130?.phaseStatuses ?? [];
const failedPhase = phases.findIndex((s: Hex) => s === '0x0');
@@ -1158,6 +1153,19 @@ export function useAccountEngine() {
return txHash;
};
+ // Broadcast a signed 8130 tx and wait for inclusion. Throws TxPendingError on
+ // timeout (submitted but unconfirmed), a plain Error if any phase reverts.
+ const broadcast8130 = async (signedTx: Hex, onStatus?: (s: 'submitting' | 'confirming') => void): Promise => {
+ const client = makeRpcClient();
+ onStatus?.('submitting');
+ const txHash = (await client.request({
+ method: 'eth_sendRawTransaction',
+ params: [signedTx],
+ })) as Hex;
+ onStatus?.('confirming');
+ return awaitInclusion(txHash);
+ };
+
// Live EIP-8130 state used while preparing a transaction. This is the only
// source of truth for deployment and config sequences; the persisted
// `deployed` / `configSeq` fields are display caches and are never consulted
@@ -1206,7 +1214,17 @@ export function useAccountEngine() {
changeSeq: number | null,
meta: Hex | undefined,
sessionPolicy?: AppPolicy,
- payerOpt?: { address: Address; phase0?: { to: Address; data: Hex }[] },
+ // `localSigner` co-signs `payerAuth` inline with a key this browser holds
+ // (the B20 demo's own faucet-funded payer EOA, which accepts an arbitrary
+ // B20 stablecoin as the fee). Without it the tx is serialized with an empty
+ // `payerAuth` for a hosted payer service to co-sign out of band.
+ payerOpt?: { address: Address; phase0?: { to: Address; data: Hex }[]; localSigner?: Signer },
+ // Set by callers that run several transactions back to back. The public RPC
+ // is served by replicas whose heads can differ, so re-reading the nonce (or
+ // probing for code) between two sends can answer from a replica that hasn't
+ // seen the previous one yet. Such a caller reads both once up front and
+ // pins them here instead.
+ seqOpt?: { nonceSequence?: bigint; assumeDeployed?: boolean },
): Promise<{ serialized: Hex; nextSeq: number }> => {
const signer = await buildSigner(signerWS);
const account = nativeAccountFor(a, signer, signerWS.authenticator);
@@ -1230,7 +1248,12 @@ export function useAccountEngine() {
// Resolve deployment + both config counters once at the composition
// boundary. Lower-level signing never consults the persisted account flags.
- const { deployed: effectivelyDeployed } = await fetchOnChainAccountState(account.address as Address);
+ // A caller running several transactions back to back pins the deployment
+ // state instead: an earlier transaction in that run already deployed the
+ // account, and a code probe can still lag it and wrongly re-attach the
+ // create change.
+ const effectivelyDeployed =
+ seqOpt?.assumeDeployed ?? (await fetchOnChainAccountState(account.address as Address)).deployed;
const bootstrapChange = effectivelyDeployed ? undefined : firstDeployChange(a, account);
if (bootstrapChange) accountChanges.push(bootstrapChange);
if (effectivelyDeployed !== a.deployed) updateAccount(a.id, { deployed: effectivelyDeployed });
@@ -1268,10 +1291,12 @@ export function useAccountEngine() {
const plainCallCount = Math.max(totalCalls - heavyCallCount, 1);
const wire = encodeWalletCalls({ account: account.address, calls: phases });
- const nonceSequence = await getTransactionCount(makeRpcClient(), {
- address: account.address as Address,
- nonceKey: 0n,
- });
+ const nonceSequence =
+ seqOpt?.nonceSequence ??
+ (await getTransactionCount(makeRpcClient(), {
+ address: account.address as Address,
+ nonceKey: 0n,
+ }));
// Authenticator hint so estimateGas shapes the senderAuth stub for the
// actual signer. A delegate-signed sub-account acts via the parent's delegate
@@ -1360,18 +1385,22 @@ export function useAccountEngine() {
gasLimit = BigInt(floorGas(true) || 200_000);
}
- const serialized = await account.signTransaction({
- chainId,
- accountChanges,
- calls: wire,
- metadata: meta,
- nonceKey: 0n,
- nonceSequence,
- maxFeePerGas: 1_000_000_000n,
- maxPriorityFeePerGas: 1_000_000n,
- gas: gasLimit,
- ...(payerOpt ? { payer: payerOpt.address, payerAuth: '0x' as Hex } : {}),
- });
+ const serialized = await account.signTransaction(
+ {
+ chainId,
+ accountChanges,
+ calls: wire,
+ metadata: meta,
+ nonceKey: 0n,
+ nonceSequence,
+ maxFeePerGas: 1_000_000_000n,
+ maxPriorityFeePerGas: 1_000_000n,
+ gas: gasLimit,
+ // A local payer signs `payerAuth` here, so don't stub it out.
+ ...(payerOpt ? { payer: payerOpt.address, ...(payerOpt.localSigner ? {} : { payerAuth: '0x' as Hex }) } : {}),
+ },
+ payerOpt?.localSigner ? { payer: { account: payerOpt.localSigner, address: payerOpt.address } } : undefined,
+ );
return { serialized, nextSeq };
};
@@ -1401,8 +1430,22 @@ export function useAccountEngine() {
// transaction builder. It deliberately reuses the full compose/broadcast
// implementation so deployment reconciliation, sub-account delegation, gas
// estimation, and eligible staged account changes behave consistently.
- const sendActiveCall = async ({ to, data }: { to: Address; data: Hex }) => {
+ //
+ // `calls` land as one atomic EIP-8130 transaction, so a demo can pair an
+ // approve with the call that spends it. `tokenGas` routes the transaction
+ // through a caller-supplied ERC-8168 payer: phase 0 pays that payer a flat
+ // fee in the given token and the payer's own ETH covers gas, which is how a
+ // demo lets you pay fees in a token you just created. Without it the account
+ // pays its own gas.
+ const sendActiveCalls = async ({
+ calls,
+ tokenGas,
+ }: {
+ calls: { to: Address; data: Hex }[];
+ tokenGas?: { token: Address; decimals: number; payer: Signer; fee: bigint };
+ }): Promise<{ hash: Hex; serialized: Hex; mode: 'self' | 'token' }> => {
if (!acct) throw new Error('Select an account before you continue.');
+ if (!calls.length) throw new Error('No calls to send.');
const signer =
postChangeOwnerSigners.find((s) => s.id === activeSignerId) ??
postChangeOwnerSigners[0] ??
@@ -1412,18 +1455,164 @@ export function useAccountEngine() {
const bundle = pendingBundleFor({ mode: 'owner-send' });
const presigned = bundle.map((item) => item.change);
const changeSeq = bundle.length ? bundle[bundle.length - 1].sequence : null;
+ const payerOpt = tokenGas
+ ? {
+ address: tokenGas.payer.address,
+ phase0: [
+ (({ to, data }) => ({ to, data }))(
+ encodeTokenTransfer({ token: tokenGas.token, to: tokenGas.payer.address, amount: tokenGas.fee }),
+ ),
+ ],
+ localSigner: tokenGas.payer,
+ }
+ : undefined;
const { serialized, nextSeq } = await signComposed(
acct,
signer,
- [newCallRow({ to, data, value: '0' })],
+ calls.map((call) => newCallRow({ ...call, value: '0' })),
presigned,
changeSeq,
undefined,
undefined,
- undefined,
+ payerOpt,
);
const hash = await broadcast8130(serialized);
applyLandedBundle(acct, nextSeq, bundle);
+ return { hash, serialized, mode: tokenGas ? 'token' : 'self' };
+ };
+
+ /**
+ * Run several transactions from the active account back to back.
+ *
+ * Not a loop over `sendActiveCalls`: the reads that call depends on — the
+ * account's nonce and whether it has code — are answered by load-balanced RPC
+ * replicas whose heads can differ, so re-reading them between two sends can
+ * return a view that predates the previous one. That drops the second
+ * transaction as a duplicate nonce, or re-attaches the create change to an
+ * account that already exists. Both reads happen once here, and each batch
+ * gets its sequence counted from there.
+ *
+ * Pending owner/session changes ride the first batch only. Returns one result
+ * per batch; throws on the first failure, leaving earlier batches applied
+ * (callers should make each batch meaningful on its own).
+ */
+ const sendActiveCallsBatches = async ({
+ batches,
+ tokenGas,
+ onBatchStart,
+ onBatchResult,
+ }: {
+ batches: { calls: { to: Address; data: Hex }[] }[];
+ tokenGas?: { token: Address; decimals: number; payer: Signer; fee: bigint };
+ onBatchStart?: (index: number, total: number) => void;
+ onBatchResult?: (index: number, result: { hash: Hex; serialized: Hex; mode: 'self' | 'token' }) => void;
+ }): Promise<{ hash: Hex; serialized: Hex; mode: 'self' | 'token' }[]> => {
+ if (!acct) throw new Error('Select an account before you continue.');
+ if (!batches.length) throw new Error('No calls to send.');
+ const signer =
+ postChangeOwnerSigners.find((s) => s.id === activeSignerId) ??
+ postChangeOwnerSigners[0] ??
+ activeSigner;
+ if (!signer) throw new Error('No local owner key found for this account.');
+
+ const bundle = pendingBundleFor({ mode: 'owner-send' });
+ const presigned = bundle.map((item) => item.change);
+ const changeSeq = bundle.length ? bundle[bundle.length - 1].sequence : null;
+ const payerOpt = tokenGas
+ ? {
+ address: tokenGas.payer.address,
+ phase0: [
+ (({ to, data }) => ({ to, data }))(
+ encodeTokenTransfer({ token: tokenGas.token, to: tokenGas.payer.address, amount: tokenGas.fee }),
+ ),
+ ],
+ localSigner: tokenGas.payer,
+ }
+ : undefined;
+
+ // Read the starting nonce a few times and keep the highest: a single read
+ // can land on a replica that is a block behind.
+ const address = acct.address as Address;
+ let startSequence = 0n;
+ for (let i = 0; i < 3; i += 1) {
+ const count = await getTransactionCount(makeRpcClient(), { address, nonceKey: 0n }).catch(() => null);
+ if (count !== null && count > startSequence) startSequence = count;
+ }
+
+ // An account's code and the actors bound to it reach every replica a moment
+ // after the transaction that wrote them lands, so a batch prepared right
+ // behind the one that deployed the account is validated against a replica
+ // that has not seen it yet and is rejected with "actor is not bound" before
+ // it is ever broadcast. Wait for the state to catch up and prepare it again.
+ // A transaction that expired without landing is definitively dropped, so
+ // that one can go straight back out. Everything else — a revert, a rejected
+ // call, a broadcast whose receipt never arrived — is real and propagates.
+ const attemptBatch = async (send: () => Promise): Promise => {
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ return await send();
+ } catch (error) {
+ if (attempt >= 3) throw error;
+ // Broadcast but not included in time. Give it a little longer, then
+ // ask the node whether it still holds the transaction: one it has
+ // dropped is never coming back, so the batch is signed and sent again
+ // on the same nonce. One it still holds must be left alone — a second
+ // copy would only collide with it.
+ if (error instanceof TxPendingError) {
+ const landed = await awaitInclusion(error.txHash, 15_000).catch((err) => {
+ if (err instanceof TxPendingError) return null;
+ throw err;
+ });
+ if (landed) return landed;
+ const known = await makeRpcClient()
+ .request({ method: 'eth_getTransactionByHash', params: [error.txHash] })
+ .catch(() => 'unreadable');
+ if (known !== null) throw error;
+ continue;
+ }
+ const message = error instanceof Error ? error.message : String(error);
+ const expired = /expired before landing/i.test(message);
+ if (!expired && !/actor is not bound/i.test(message)) throw error;
+ await new Promise((resolve) => setTimeout(resolve, expired ? 1_000 : 5_000));
+ }
+ }
+ };
+
+ const results: { hash: Hex; serialized: Hex; mode: 'self' | 'token' }[] = [];
+ const mode: 'self' | 'token' = tokenGas ? 'token' : 'self';
+ for (const [index, batch] of batches.entries()) {
+ onBatchStart?.(index, batches.length);
+ const first = index === 0;
+ // Written by whichever signing attempt produced the transaction that
+ // landed — a retry re-signs, so these can't be read from the first one.
+ let landedSeq: number | null = null;
+ let landedSerialized: Hex = '0x';
+ const hash = await attemptBatch(async () => {
+ const { serialized, nextSeq } = await signComposed(
+ acct,
+ signer,
+ batch.calls.map((call) => newCallRow({ ...call, value: '0' })),
+ first ? presigned : [],
+ first ? changeSeq : null,
+ undefined,
+ undefined,
+ payerOpt,
+ { nonceSequence: startSequence + BigInt(index), assumeDeployed: !first || undefined },
+ );
+ landedSeq = nextSeq;
+ landedSerialized = serialized;
+ return broadcast8130(serialized);
+ });
+ if (first && landedSeq !== null) applyLandedBundle(acct, landedSeq, bundle);
+ const result = { hash, serialized: landedSerialized, mode };
+ results.push(result);
+ onBatchResult?.(index, result);
+ }
+ return results;
+ };
+
+ const sendActiveCall = async ({ to, data }: { to: Address; data: Hex }) => {
+ const { hash, serialized } = await sendActiveCalls({ calls: [{ to, data }] });
return { hash, serialized };
};
@@ -2341,6 +2530,8 @@ export function useAccountEngine() {
broadcast8130,
signComposed,
sendActiveCall,
+ sendActiveCalls,
+ sendActiveCallsBatches,
applyLandedBundle,
handleSeqMismatch,
pendingBundleFor,
diff --git a/app/vibenet/demos/b20/B20Demo.tsx b/app/vibenet/demos/b20/B20Demo.tsx
index 3efece0..200ab38 100644
--- a/app/vibenet/demos/b20/B20Demo.tsx
+++ b/app/vibenet/demos/b20/B20Demo.tsx
@@ -1,22 +1,26 @@
'use client';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { isAddress, type Address, type Hex } from 'viem';
import { trackB20Action, trackB20ModuleSelect } from '../../../analytics/events';
+import { cn } from '../../../components/ui/cn';
import { Tabs } from '../../../components/ui/Tabs';
import { walletErrorMessage } from '../../library/wallet';
-import { ActivityLog } from '../account/components/ActivityLog';
import { useAccountEngine } from '../account/useAccountEngine';
import { AccountDemoShell } from '../_components/AccountDemoShell';
+import { AnimatedAmount } from '../_components/AnimatedAmount';
+import { Select, type SelectGroup } from '../../../components/ui/Select';
+import { Activity } from './components/Activity';
import { AnnouncementModule, SampleAnnouncementViewer } from './components/AnnouncementModule';
import { DeployModule } from './components/DeployModule';
import { MemoModule } from './components/MemoModule';
import { PolicyModule } from './components/PolicyModule';
-import { client, CHAIN_ID, MODULES } from './lib/constants';
+import { client, MODULES } from './lib/constants';
import {
b20Abi,
b20Variant,
+ formatAmount,
B20_FACTORY,
DEFAULT_ADMIN_ROLE,
factoryAbi,
@@ -28,8 +32,32 @@ import {
} from './lib/protocol';
import { readRecent, readRecentPolicies, writeRecent, writeRecentPolicy } from './lib/recent';
import { sampleTokenForAddress } from './lib/samples';
+import { canUseTokenForGas } from './lib/tokenGas';
+import {
+ createPayer,
+ ensurePayerFunded,
+ loadPayer,
+ payerAddress,
+ payerErrorMessage,
+ payerSigner,
+ savePayer,
+ seedWithEth,
+ tokenGasFee,
+ type StoredB20Payer,
+} from './lib/gasPayer';
import type { CreatedToken, Module, RecentPolicy, RecentToken, TokenAccess, TokenInfo } from './lib/types';
+// Retry schedule for reads that race a just-confirmed transaction: the public
+// RPC is load-balanced across replicas whose heads differ, so read at t=0 and
+// again as state settles. Reads are pinned to a fresh block so lagging replicas
+// error instead of answering stale; a success is authoritative and errors never
+// downgrade a previous success.
+const READ_RETRY_MS = [0, 2_500, 6_000];
+
+function annotateMode(label: string, mode: 'self' | 'token', symbol?: string): string {
+ return mode === 'token' && symbol ? `${label} · gas paid in ${symbol}` : label;
+}
+
export function B20Demo() {
const [module, setModule] = useState('policy');
// Local EIP-8130 accounts, shared with the account demo via localStorage. B20
@@ -45,6 +73,14 @@ export function B20Demo() {
const addressBook = engine.addressBook;
+ // The demo's own ERC-8168 payer, minted on demand when fees are switched to a
+ // token. It stays separate from the account: the account spends the token,
+ // the payer spends the ETH that actually buys the gas.
+ const [storedPayer, setStoredPayer] = useState(null);
+ const [gasMode, setGasMode] = useState<'eth' | 'token'>('eth');
+ const [tokenBalance, setTokenBalance] = useState(null);
+ // Which token the shown balance belongs to (lowercased address).
+ const balanceForToken = useRef(null);
const [recent, setRecent] = useState([]);
const [recentPolicies, setRecentPolicies] = useState([]);
const [tokenAddress, setTokenAddress] = useState('');
@@ -53,6 +89,12 @@ export function B20Demo() {
const [checkAddress, setCheckAddress] = useState('');
const [checks, setChecks] = useState | null>(null);
const [busy, setBusy] = useState(null);
+ const [batchProgress, setBatchProgress] = useState<{
+ label: string;
+ detail?: string;
+ index: number;
+ total: number;
+ } | null>(null);
const [isOperator, setIsOperator] = useState(false);
const [isTokenAdmin, setIsTokenAdmin] = useState(false);
const [tokenAdminLoading, setTokenAdminLoading] = useState(false);
@@ -78,6 +120,43 @@ export function B20Demo() {
refreshWallet(wallet);
}, [wallet, refreshWallet]);
+ useEffect(() => {
+ setStoredPayer(loadPayer());
+ }, []);
+
+ // The account's holding of the active token, shown beside the tabs so the
+ // initial mint (and every transfer) is visible. Keyed on the `token` object,
+ // which is re-fetched after every send — so this re-reads automatically.
+ useEffect(() => {
+ let cancelled = false;
+ if (!token || !wallet || sampleTokenForAddress(token.address)) {
+ setTokenBalance(null);
+ balanceForToken.current = null;
+ return;
+ }
+ // Switching to a different token invalidates the shown balance; refreshes
+ // of the same token keep it on screen (no flash) until the new read lands.
+ if (balanceForToken.current !== token.address.toLowerCase()) {
+ balanceForToken.current = token.address.toLowerCase();
+ setTokenBalance((previous) => (previous === 0n ? previous : null));
+ }
+ const read = () =>
+ client
+ .getBlockNumber({ cacheTime: 0 })
+ .then((blockNumber) =>
+ client.readContract({ address: token.address, abi: b20Abi, functionName: 'balanceOf', args: [wallet], blockNumber }),
+ )
+ .then((balance) => {
+ if (!cancelled) setTokenBalance(balance);
+ })
+ .catch(() => {});
+ const timers = READ_RETRY_MS.map((delay) => window.setTimeout(() => void read(), delay));
+ return () => {
+ cancelled = true;
+ timers.forEach((timer) => window.clearTimeout(timer));
+ };
+ }, [token, wallet]);
+
// Operator status is a function of (token address, wallet) only. send()
// re-inspects the token after every tx, which yields a fresh `token` object
// with the same address; keying this effect on the address (not the object)
@@ -88,21 +167,26 @@ export function B20Demo() {
let cancelled = false;
setIsOperator(false);
if (!activeTokenAddress || !wallet || sampleTokenForAddress(activeTokenAddress)) return;
- client
- .readContract({
- address: activeTokenAddress,
- abi: b20Abi,
- functionName: 'hasRole',
- args: [roleId('OPERATOR_ROLE'), wallet],
- })
- .then((allowed) => {
- if (!cancelled) setIsOperator(allowed);
- })
- .catch(() => {
- if (!cancelled) setIsOperator(false);
- });
+ const read = () =>
+ client
+ .getBlockNumber({ cacheTime: 0 })
+ .then((blockNumber) =>
+ client.readContract({
+ address: activeTokenAddress,
+ abi: b20Abi,
+ functionName: 'hasRole',
+ args: [roleId('OPERATOR_ROLE'), wallet],
+ blockNumber,
+ }),
+ )
+ .then((allowed) => {
+ if (!cancelled && allowed) setIsOperator(true);
+ })
+ .catch(() => {});
+ const timers = READ_RETRY_MS.map((delay) => window.setTimeout(() => void read(), delay));
return () => {
cancelled = true;
+ timers.forEach((timer) => window.clearTimeout(timer));
};
}, [activeTokenAddress, wallet]);
@@ -118,30 +202,74 @@ export function B20Demo() {
return;
}
setTokenAdminLoading(true);
- client
- .readContract({
- address: activeTokenAddress,
- abi: b20Abi,
- functionName: 'hasRole',
- args: [DEFAULT_ADMIN_ROLE, wallet],
- })
- .then((allowed) => {
- if (!cancelled) setIsTokenAdmin(allowed);
- })
- .catch(() => {
- if (!cancelled) setIsTokenAdmin(false);
- })
- .finally(() => {
- if (!cancelled) {
+ const lastDelay = READ_RETRY_MS[READ_RETRY_MS.length - 1];
+ const read = (delay: number) =>
+ client
+ .getBlockNumber({ cacheTime: 0 })
+ .then((blockNumber) =>
+ client.readContract({
+ address: activeTokenAddress,
+ abi: b20Abi,
+ functionName: 'hasRole',
+ args: [DEFAULT_ADMIN_ROLE, wallet],
+ blockNumber,
+ }),
+ )
+ .then((allowed) => {
+ if (cancelled) return;
+ if (allowed) setIsTokenAdmin(true);
setTokenAdminLoading(false);
setTokenAdminCheckedFor(checkKey);
- }
- });
+ })
+ .catch(() => {
+ // Keep "checking" until the final attempt fails too.
+ if (!cancelled && delay === lastDelay) {
+ setTokenAdminLoading(false);
+ setTokenAdminCheckedFor(checkKey);
+ }
+ });
+ const timers = READ_RETRY_MS.map((delay) => window.setTimeout(() => void read(delay), delay));
return () => {
cancelled = true;
+ timers.forEach((timer) => window.clearTimeout(timer));
};
}, [activeTokenAddress, wallet]);
+ // Token-paid gas is offered only for a STABLECOIN the account manages —
+ // paying fees in a currency-pegged token is the realistic story; volatile
+ // asset tokens stay on ETH. Stablecoin creators hold DEFAULT_ADMIN (not
+ // OPERATOR_ROLE, which the stablecoin deploy skips), so admin status is the
+ // gate. Drop back to ETH when the active token changes, isn't a stablecoin,
+ // or access is lost.
+ const tokenGasEligible = canUseTokenForGas(token?.variant, isTokenAdmin, isOperator);
+ useEffect(() => {
+ if (!tokenGasEligible) setGasMode('eth');
+ }, [tokenGasEligible]);
+
+ const enableTokenGas = useCallback(() => {
+ let payer = storedPayer;
+ if (!payer) {
+ payer = createPayer();
+ savePayer(payer);
+ setStoredPayer(payer);
+ // Pre-fund the demo payer so the first token-paid send doesn't wait.
+ void seedWithEth(payerAddress(payer));
+ }
+ setGasMode('token');
+ }, [storedPayer]);
+
+ // Guided "first payment" from the token-created screen: flip gas to the new
+ // stablecoin, jump to Memos, and pre-fill an invoice-style payment so the
+ // next click is Submit.
+ const [memoPrefill, setMemoPrefill] = useState<{ to: string; amount: string; memo: string } | null>(null);
+ const startFirstPayment = useCallback(() => {
+ if (token?.variant === 'stablecoin') enableTokenGas();
+ setMemoPrefill({ to: '0xd0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0', amount: '5', memo: 'Invoice-0001' });
+ setModule('memos');
+ trackB20ModuleSelect('memos');
+ }, [enableTokenGas, token]);
+ const clearMemoPrefill = useCallback(() => setMemoPrefill(null), []);
+
const inspect = useCallback(
async (candidate = tokenAddress) => {
const sampleToken = sampleTokenForAddress(candidate);
@@ -247,23 +375,43 @@ export function B20Demo() {
setChecks(Object.fromEntries(result));
}, [checkAddress, token]);
- const send = useCallback(
- async (label: string, to: Address, data: Hex, action: string): Promise => {
+ // The single transaction chokepoint: every module action lands here. Calls go
+ // out as one atomic EIP-8130 transaction through the shared account engine,
+ // with gas paid in ETH or — when fees are switched to a stablecoin the
+ // account manages — by the demo's own ERC-8168 payer.
+ const sendCalls = useCallback(
+ async (label: string, calls: Array<{ to: Address; data: Hex }>, action: string): Promise => {
if (!activeAccount) {
setInspectError('Select an account before you continue.');
return null;
}
setBusy(action);
+ setInspectError('');
trackB20Action(module, action, 'submitted');
try {
+ const tokenGas =
+ gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer
+ ? {
+ token: token.address,
+ decimals: token.decimals,
+ payer: payerSigner(storedPayer),
+ fee: tokenGasFee(token.decimals),
+ }
+ : undefined;
+ // The payer underwrites the gas in ETH, so it has to be funded before
+ // it co-signs — the first token-paid send follows key creation closely.
+ if (storedPayer && tokenGas) await ensurePayerFunded(storedPayer);
// Sign + broadcast through the shared account engine so account deploy,
// sub-account, gas-estimation, and staged-settings behavior stays in one
// implementation across demos. Logging via pushActivity puts this send in
// the same history the account demo reads, so both demos share one trail.
- const { hash, serialized } = await engine.sendActiveCall({ to, data });
+ const { hash, serialized, mode } = await engine.sendActiveCalls({
+ calls,
+ ...(tokenGas ? { tokenGas } : {}),
+ });
engine.pushActivity({
kind: 'transact',
- title: label,
+ title: annotateMode(label, mode, token?.symbol),
txHash: hash,
serialized,
network: engine.chain.name,
@@ -275,7 +423,7 @@ export function B20Demo() {
if (token) await inspect(token.address);
return hash;
} catch (error) {
- const detail = walletErrorMessage(error);
+ const detail = payerErrorMessage(error) ?? walletErrorMessage(error);
trackB20Action(module, action, 'error');
setInspectError(detail);
return null;
@@ -283,7 +431,83 @@ export function B20Demo() {
setBusy(null);
}
},
- [inspect, module, refreshWallet, token, activeAccount, engine],
+ [activeAccount, engine, gasMode, inspect, module, refreshWallet, storedPayer, token],
+ );
+
+ const send = useCallback(
+ (label: string, to: Address, data: Hex, action: string): Promise =>
+ sendCalls(label, [{ to, data }], action),
+ [sendCalls],
+ );
+
+ // Multi-transaction flows (token deployment): the work is split into
+ // sequential transactions that each stay well inside a block's gas, so a
+ // heavy create + configure run can't be cut mid-phase. Logs one activity entry
+ // per batch and stops at the first failure — earlier batches stay applied, so
+ // each one is written to be meaningful on its own.
+ const sendBatches = useCallback(
+ async (
+ batches: Array<{ label: string; detail?: string; calls: Array<{ to: Address; data: Hex }> }>,
+ action: string,
+ ): Promise => {
+ if (!activeAccount) {
+ setInspectError('Select an account before you continue.');
+ return null;
+ }
+ setBusy(action);
+ setInspectError('');
+ trackB20Action(module, action, 'submitted');
+ const tokenGas =
+ gasMode === 'token' && token?.variant === 'stablecoin' && storedPayer
+ ? {
+ token: token.address,
+ decimals: token.decimals,
+ payer: payerSigner(storedPayer),
+ fee: tokenGasFee(token.decimals),
+ }
+ : undefined;
+ try {
+ // The payer underwrites the gas in ETH, so it has to be funded before
+ // it co-signs — the first token-paid send follows key creation closely.
+ if (storedPayer && tokenGas) await ensurePayerFunded(storedPayer);
+ // One engine call, not one per batch: it pins the nonce and deployment
+ // state across the whole run so a lagging RPC replica can't make the
+ // second transaction collide with the first.
+ const results = await engine.sendActiveCallsBatches({
+ batches,
+ ...(tokenGas ? { tokenGas } : {}),
+ onBatchStart: (index, total) => {
+ const batch = batches[index];
+ setBatchProgress({ label: batch.label, detail: batch.detail, index, total });
+ },
+ onBatchResult: (index, { hash, serialized, mode }) => {
+ const batch = batches[index];
+ engine.pushActivity({
+ kind: 'transact',
+ title: annotateMode(batch.label, mode, token?.symbol),
+ detail: batch.detail,
+ txHash: hash,
+ serialized,
+ network: engine.chain.name,
+ mode: engine.chain.mode,
+ account: activeAccount.address as Address,
+ });
+ },
+ });
+ trackB20Action(module, action, 'success');
+ refreshWallet(activeAccount.address as Address);
+ return results.map((result) => result.hash);
+ } catch (error) {
+ const detail = payerErrorMessage(error) ?? walletErrorMessage(error);
+ trackB20Action(module, action, 'error');
+ setInspectError(detail);
+ return null;
+ } finally {
+ setBatchProgress(null);
+ setBusy(null);
+ }
+ },
+ [activeAccount, engine, gasMode, module, refreshWallet, storedPayer, token],
);
useEffect(() => {
@@ -312,16 +536,47 @@ export function B20Demo() {
: wallet
? 'external'
: 'disconnected';
+ const selectedCreatedToken = recent.find(
+ (entry) => entry.address.toLowerCase() === tokenAddress.trim().toLowerCase(),
+ );
+ const headerToken = selectedCreatedToken ?? token;
+ const switchingCreatedToken =
+ busy === 'inspect' &&
+ selectedCreatedToken !== undefined &&
+ selectedCreatedToken.address.toLowerCase() !== token?.address.toLowerCase();
+ const headerTokenGroups: SelectGroup[] = [
+ {
+ label: 'Stablecoins · can pay network fees',
+ options: recent
+ .filter((entry) => entry.variant === 'stablecoin')
+ .map((entry) => ({
+ value: entry.address,
+ label:
+ entry.address.toLowerCase() === token?.address.toLowerCase() && tokenBalance !== null
+ ? `${formatAmount(tokenBalance, entry.decimals)} ${entry.symbol} · Stablecoin`
+ : `${entry.symbol} — ${entry.name} · Stablecoin`,
+ })),
+ },
+ {
+ label: 'Assets · fees in ETH only',
+ options: recent
+ .filter((entry) => entry.variant === 'asset')
+ .map((entry) => ({
+ value: entry.address,
+ label:
+ entry.address.toLowerCase() === token?.address.toLowerCase() && tokenBalance !== null
+ ? `${formatAmount(tokenBalance, entry.decimals)} ${entry.symbol} · Asset`
+ : `${entry.symbol} — ${entry.name} · Asset`,
+ })),
+ },
+ ].filter((group) => group.options.length > 0);
return (
}
- activityCount={engine.activity.length}
- activityEmptyMessage="Nothing has happened yet."
className="animate-in gap-5 pb-6 dark:text-white"
>
-
+
+
+ {recent.length > 1 ? (
+
{module === 'policy' ? (
@@ -375,6 +689,15 @@ export function B20Demo() {
onDeploy={() => selectModule('deploy')}
onSend={send}
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 +718,9 @@ export function B20Demo() {
{
@@ -405,6 +731,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)}
@@ -422,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.
+
+
+ ) : 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({
- Confirm in your wallet, then wait a few seconds for your token to be ready.
-
+
+ {progress ? (
+
+
+
+
+ {progress.label}…
+
+ ) : (
+
Preparing your token…
+ )}
+ {progress?.detail ? (
+
{progress.detail}
+ ) : null}
+
+ Each step is a real onchain transaction — links appear in Recent Activity as they confirm.
+
+
) : null}
@@ -560,31 +639,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 }> = [
- {
- module: 'policy',
- title: 'Explore policies',
- body: 'See who can use each token action and check a wallet before you use it.',
- },
+ // 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: '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 +697,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 pay network fees in ETH. To try paying fees with your own token, create a Stablecoin.
+
+ )}
@@ -669,7 +777,7 @@ function CreatedView({
))}
- Everything was applied together, so the token was ready in one transaction.
+ Each step ran as its own transaction — check Recent Activity for the links.
@@ -680,9 +788,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..96a090b 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';
@@ -24,6 +26,11 @@ export function MemoModule({
onDeploy,
onSend,
busy,
+ refreshKey,
+ prefill,
+ onPrefillConsumed,
+ feeNote,
+ onEnableTokenGas,
}: {
token: TokenInfo | null;
tokenAccess: TokenAccess;
@@ -31,21 +38,54 @@ export function MemoModule({
onDeploy: () => void;
onSend: (label: string, 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 paying fees in ETH. */
+ 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]);
+ 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 = await onSend(
+ token.variant === 'stablecoin' ? `Send ${token.symbol} with memo` : 'Transfer with memo',
+ token.address,
+ encodeFunctionData({ abi: b20Abi, functionName: 'transferWithMemo', args: [to, v, m] }),
+ 'memo_transfer',
+ );
+ if (hash) {
+ setSent({
+ title: `Sent ${value} ${token.symbol} to ${shortAddress(to)}`,
+ summary: `Memo “${memo}” is recorded onchain with the transfer.`,
+ hash,
+ });
+ setTo('');
+ setValue('');
+ setMemo('');
+ }
} catch (error) {
setError(walletErrorMessage(error));
}
@@ -127,6 +167,41 @@ export function MemoModule({
description="Add a short reference to a token transfer so your team can find it later."
action={}
/>
+ {sent ? (
+
@@ -98,11 +114,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.