diff --git a/app/vibenet/components/useAccountNames.ts b/app/vibenet/components/useAccountNames.ts index 66b82b5b..d238d396 100644 --- a/app/vibenet/components/useAccountNames.ts +++ b/app/vibenet/components/useAccountNames.ts @@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'; -import { deserializeState } from '../demos/account/library/model'; +import { deserializeState, normalizePersistedState } from '../demos/account/library/model'; import type { Persisted } from '../demos/account/shared'; import { ACCOUNTS_STORAGE_KEY } from '../demos/account/useAccounts'; @@ -15,14 +15,9 @@ export function readAccountNames(): Record { try { const raw = localStorage.getItem(ACCOUNTS_STORAGE_KEY); if (!raw) return {}; - const state = deserializeState(raw); + const { accounts } = normalizePersistedState(deserializeState>(raw)); const map: Record = {}; - for (const account of state.accounts ?? []) { - map[account.address.toLowerCase()] = account.label; - for (const sub of account.subAccounts ?? []) { - map[sub.address.toLowerCase()] = sub.label; - } - } + for (const account of accounts) map[account.address.toLowerCase()] = account.label; return map; } catch { return {}; diff --git a/app/vibenet/data/features.ts b/app/vibenet/data/features.ts index 21b15bd2..e6efdca1 100644 --- a/app/vibenet/data/features.ts +++ b/app/vibenet/data/features.ts @@ -13,18 +13,11 @@ export const FEATURES: VibenetFeature[] = [ availabilityLabel: 'Base Denim', availabilityHref: {label: 'Base Denim', href: 'https://docs.base.org/upgrades/denim', external: true}, highlights: [ - { title: 'Authorize How You Want', detail: 'Support for secp256k1, P-256, and WebAuthn passkeys.' }, - { title: 'Portable Everywhere', detail: 'Same account and address on any EVM chain.' }, - { - title: 'Rotate Keys, Keep Your Address', - detail: 'Swap signers without ever migrating accounts.', - }, - { - title: 'Session Keys & Sub-Accounts', - detail: 'Scoped, policy-gated or full account separation.', - }, - { title: 'Sponsored & ERC-20 Gas', detail: 'Native payer support via ERC-8168 contracts.' }, - { title: 'Batch Everything', detail: 'Atomic multicall with top-level call metadata.' }, + { title: 'Superpowered EOAs', detail: 'Any secp256k1 key sends native AA transactions — nothing to deploy.' }, + { title: 'Portable Everywhere', detail: 'Same key and address on any EVM chain.' }, + { title: 'Nonce-Free Sends', detail: 'Short-lived transactions with no nonce to track or collide on.' }, + { title: 'Sponsored & ERC-20 Gas', detail: 'Native payer support via the ERC-8168 payer service.' }, + { title: 'Batch Everything', detail: 'Atomic, phased multicall with per-call value and top-level metadata.' }, ], // `live` hides the "coming-soon" badge. "Tutorial" points at the // EIP-8130 build guide; there's no "try it" CTA since this card already diff --git a/app/vibenet/demos/_shared/AccountSwitcher.tsx b/app/vibenet/demos/_shared/AccountSwitcher.tsx index f9e296a2..a3f17931 100644 --- a/app/vibenet/demos/_shared/AccountSwitcher.tsx +++ b/app/vibenet/demos/_shared/AccountSwitcher.tsx @@ -14,7 +14,7 @@ import { CHECK_MORPH_ICON, CLIPBOARD_MORPH_ICON } from '../../../components/ui/i import { Text } from '../../../components/ui/Text'; import type { StoredAccount } from '../account/library/model'; import { ChevronIcon, CreateRowButton, DeleteConfirmButton } from './dropdown'; -import { AccountAvatar, AccountIdentity, Badge } from './primitives'; +import { AccountAvatar, AccountIdentity } from './primitives'; // Icon-only button that copies an account's address; swaps to a green check for // 2s after copying. Matches the sibling Details button's styling. @@ -89,11 +89,10 @@ export function AccountSwitcher({ const [open, setOpen] = useState(false); const active = accounts.find((a) => a.id === activeAccountId) ?? null; - const topLevel = accounts.filter((a) => !a.parentId); const close = () => setOpen(false); - const row = (a: StoredAccount, nested: boolean) => { + const row = (a: StoredAccount) => { const isActive = a.id === activeAccountId; return (
@@ -165,7 +162,7 @@ export function AccountSwitcher({ > {active ? ( - + {active.label} ) : ( @@ -188,12 +185,7 @@ export function AccountSwitcher({ className="flex max-h-[60vh] w-[max(340px,var(--anchor-width))] max-w-[calc(100vw-2rem)] origin-[var(--transform-origin)] flex-col gap-1 overflow-y-auto rounded-2xl border border-bds-gray-10 bg-background p-2 shadow-lg outline-none [transform:scale(1)] transition-[opacity,transform] duration-150 ease-out data-[ending-style]:opacity-0 data-[ending-style]:[transform:scale(0.97)] data-[starting-style]:opacity-0 data-[starting-style]:[transform:scale(0.97)] dark:border-white/10 dark:bg-[rgb(38,38,38)] motion-reduce:transition-none" > Accounts - {topLevel.map((parent) => ( -
- {row(parent, false)} - {accounts.filter((s) => s.parentId === parent.id).map((sub) => row(sub, true))} -
- ))} + {accounts.map((a) => row(a))} {onCreate ? ( = { k1: 'bg-bds-blue-0 text-bds-blue-70 dark:text-base-blue', - p256: 'bg-bds-purple-0 text-bds-purple-70', - passkey: 'bg-bds-teal-0 text-bds-teal-70', }; export function AccountAvatar({ @@ -159,7 +157,7 @@ export function AccountIdentity({ ); } -// Signer-kind chip (K1 / P-256 / passkey). +// Signer-kind chip (K1). export function KindBadge({ kind }: { kind: SignerKind }) { return ( ) when -// the address is a local account; this demo links there. +// Account demo (EIP-8130): in-browser K1 keys, EOA accounts, balances/assets, +// native transact (batched calls, nonce-free sends, payer-sponsored or +// token-paid gas). Per-account detail (balances, activity) lives on the +// explorer address page +// (/vibenet/explorer/address/) when the address is a local account. // // The shared account engine + transact dialog are consumed from context, so this // demo, B20, and the account page all behave identically. @@ -12,7 +12,6 @@ import { trackAccountAction } from '../../../analytics/events'; import { useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; -import { toast } from 'sonner'; import { Button } from '../../../components/ui/Button'; import { Modal } from '../../../components/ui/Modal'; @@ -21,12 +20,9 @@ import { AccountDemoShell } from '../_components/AccountDemoShell'; import { FeatureCard } from '../../components/FeatureCard'; import { FEATURES } from '../../data/features'; import { ActivityLog } from './components/ActivityLog'; -import { AppCard, AppCardPlaceholder, AppsNetworkNotice } from './components/AppsView'; import { FeatureGridCard, FeatureGridPlaceholder } from '../_shared/FeatureGridCard'; -import { TransactionModal, type ApplyTarget, type TransactPreset } from './components/TransactionModal'; -import { DEMO_APPS, type DemoApp } from './library/apps'; +import { TransactionModal, type TransactPreset } from './components/TransactionModal'; import { encodeUsdvTransfer, isAddressStr, newCallRow } from './library/calls'; -import { EXPIRY_PRESETS, type AppSessionKey, type AppSubAccount } from './library/model'; import { AccountEngineProvider, useAccountEngine } from './useAccountEngine'; import { vibenetApi } from '../../library/client'; import type { Address } from '@aa'; @@ -41,49 +37,20 @@ export function AccountDemo() { function AccountDemoInner() { const engine = useAccountEngine(); - const { - signers, - accounts, - activeAccountId, - activity, - networkShort, - setNetworkShort, - deleteAccount, + const { accounts, activeAccountId, activity, regenesisNotice, setRegenesisNotice, acct } = engine; - activeSigner, - - chain, - regenesisNotice, - setRegenesisNotice, - - acct, - - deleteSigner, - revokeSessionKey, - undoStagedRevoke, - doAuthorizeSession, - doCreateSubAccount, - mintAppKey, - } = engine; - - // Apps directory. - const [appBusy, setAppBusy] = useState(null); const [transactionRequest, setTransactionRequest] = useState<{ preset?: TransactPreset; - applyTarget?: ApplyTarget; contentKey: string; } | null>(null); - // Crossfade key for the dashboard (transact + apps). Capture it in the modal - // request so changing "From" doesn't remount the content behind an open dialog. + // Crossfade key for the dashboard. Capture it in the modal request so + // changing "From" doesn't remount the content behind an open dialog. const activeAccountKey = activeAccountId ?? 'empty'; const contentKey = transactionRequest?.contentKey ?? activeAccountKey; const openTransaction = (preset?: TransactPreset) => { setTransactionRequest({ preset, contentKey: activeAccountKey }); }; - const openApply = (applyTarget: ApplyTarget) => { - setTransactionRequest({ applyTarget, contentKey: activeAccountKey }); - }; const resolveUsdvAddress = async (): Promise
=> { const status = await vibenetApi.faucet.status().catch(() => null); @@ -120,71 +87,6 @@ function AccountDemoInner() { }); }; - // Session-app config changes use the same transaction review popup as every - // other account-demo send. A never-landed authorization is only local, so - // revoking it simply discards it without opening a transaction. - const unsubscribeApp = async (sk: AppSessionKey) => { - const outcome = await revokeSessionKey(sk.id); - if (outcome === 'staged' || outcome === 'noop') openApply({ session: sk.id }); - }; - - const sessionKeyFor = (name: string) => acct?.sessionKeys.find((sk) => sk.label === name); - const subAccountFor = (name: string) => acct?.subAccounts.find((sa) => sa.label === name); - - // "Delete Account" on a connected Spending Account app card. - const deleteVault = (sub: AppSubAccount) => { - const rec = accounts.find((a) => a.address.toLowerCase() === sub.address.toLowerCase()); - if (rec) deleteAccount(rec.id); - }; - - // Connect a session-key app: mint a dedicated key and stage its owner-signed - // authorization, then hand submission to the common transaction popup. - const connectSessionApp = async (app: DemoApp) => { - if (!acct || !activeSigner) return; - setAppBusy(app.id); - let mintedKeyId: string | null = null; - try { - const target = mintAppKey(app.name); - if (!target) { - toast.error("Couldn't mint an app key — try again."); - return; - } - mintedKeyId = target.id; - const expirySecs = EXPIRY_PRESETS.find((p) => p.id === app.expiryId)?.seconds ?? 0; - const sk = await doAuthorizeSession(target, { - expirySecs, - policyLabel: app.policyLabel ?? 'Policy', - spec: app.spec?.(acct.address) ?? {}, - label: app.name, - chainShort: chain.shortName, - }); - if (sk) { - mintedKeyId = null; - openApply({ session: sk.id }); - } - } catch (err) { - const e = err as { message?: string; name?: string }; - toast.error(e.name === 'NotAllowedError' ? 'Signature was dismissed.' : (e.message ?? String(err))); - } finally { - if (mintedKeyId) deleteSigner(mintedKeyId); - setAppBusy(null); - } - }; - - // Connect a sub-account app ("spending account"): derive a delegated account - // with a spare owner key you hold. - const connectVault = (app: DemoApp) => { - if (!acct) return; - setAppBusy(app.id); - try { - doCreateSubAccount(app.name, { withSpareKey: true }); - } catch (err) { - toast.error((err as { message?: string }).message ?? String(err)); - } finally { - setAppBusy(null); - } - }; - return ( <> @@ -224,7 +124,6 @@ function AccountDemoInner() { key={activeAccountId ?? 'no-account'} onClose={() => setTransactionRequest(null)} preset={transactionRequest.preset} - applyTarget={transactionRequest.applyTarget} /> ) : null} @@ -239,45 +138,13 @@ function AccountDemoInner() { } > - The vibenet devnet has been regenesised — its onchain state was wiped. Your accounts and keys are still here - and their addresses are unchanged; they've been marked undeployed and will redeploy on their next - transaction. + The vibenet devnet has been regenesised — its onchain state was wiped. Your + accounts and keys are still here and their addresses are unchanged; top up to transact again. ); - function renderApps() { - if (!acct) { - return DEMO_APPS.map((app) => ); - } - const native = chain.mode === 'eip8130-native'; - return ( - <> - {!native ? setNetworkShort('vibenet')} /> : null} - {DEMO_APPS.map((app) => ( - openApply({ session: sessionKey.id })} - undoSessionRevoke={undoStagedRevoke} - deleteVault={deleteVault} - /> - ))} - - ); - } - function renderTransact() { if (!acct) return ( @@ -346,34 +213,6 @@ function AccountDemoInner() { ); } - function renderOwners() { - if (!acct) { - return ( - - ); - } - return ( - - - - - } - title="Modify Owners" - description="Add or revoke owner keys and rotate signers — swap keys anytime without ever migrating accounts." - > - - - ); - } - function renderBatchedCalls() { if (!acct) { return ( diff --git a/app/vibenet/demos/account/components/ActivityLog.tsx b/app/vibenet/demos/account/components/ActivityLog.tsx index 1f0f6fda..abcabdaf 100644 --- a/app/vibenet/demos/account/components/ActivityLog.tsx +++ b/app/vibenet/demos/account/components/ActivityLog.tsx @@ -101,7 +101,7 @@ export function ActivityLog({ activity, accounts }: { activity: ActivityEntry[]; href={`${VIBENET_EXPLORER_PATH}/address/${e.account}`} className="no-underline transition-colors hover:opacity-80" > - + ) : null} @@ -171,7 +171,7 @@ export function ActivityLog({ activity, accounts }: { activity: ActivityEntry[]; href={`${VIBENET_EXPLORER_PATH}/address/${e.account}`} className="no-underline" > - + ) : null} diff --git a/app/vibenet/demos/account/components/AppsView.tsx b/app/vibenet/demos/account/components/AppsView.tsx deleted file mode 100644 index b57f8062..00000000 --- a/app/vibenet/demos/account/components/AppsView.tsx +++ /dev/null @@ -1,180 +0,0 @@ -'use client'; - -import { Button } from '../../../../components/ui/Button'; -import { CopyableValue } from '../../../components/CopyableValue'; -import type { DemoApp } from '../library/apps'; -import { formatExpiry, type AppSessionKey, type AppSubAccount, type StoredAccount } from '../library/model'; -import { short, type WalletSigner } from '../shared'; -import { Badge } from '../../_shared/primitives'; -import { FeatureGridCard, FeatureGridPlaceholder } from '../../_shared/FeatureGridCard'; - -const CONNECTED_FOOTER = - 'border-t border-bds-gray-10 pt-3 dark:border-white/10'; - -// Banner shown above the app cards when the active network doesn't support -// native EIP-8130 grants yet — apps stay disabled until the user switches. -export function AppsNetworkNotice({ onSwitchToVibenet }: { onSwitchToVibenet: () => void }) { - return ( -
- - Vibenet Only - - Apps use native EIP-8130 grants. - - - -
- ); -} - -type AppCardProps = { - acct: StoredAccount; - native: boolean; - app: DemoApp; - appBusy: string | null; - activeSigner: WalletSigner | null; - signers: WalletSigner[]; - sessionKeyFor: (name: string) => AppSessionKey | undefined; - subAccountFor: (name: string) => AppSubAccount | undefined; - connectSessionApp: (app: DemoApp) => void; - connectVault: (app: DemoApp) => void; - unsubscribeApp: (sk: AppSessionKey) => void; - reviewSessionApp: (sk: AppSessionKey) => void; - undoSessionRevoke: (sessionKeyId: string) => void; - deleteVault: (sub: AppSubAccount) => void; -}; - -function AppIcon({ app }: { app: DemoApp }) { - if (app.id === 'monthly-vibes') { - return ( - - - - - ); - } - if (app.id === 'spending-account') { - return ( - - - - - ); - } - return {app.emoji}; -} - -// A single demo app: connects to the active account through a scoped grant it -// fully controls — a capped session key (subscriptions) or a delegated -// sub-account. Idle chrome matches the other Features tiles; once a grant is -// live the footer becomes a status row with a divider. -export function AppCard(p: AppCardProps) { - const { app } = p; - const sk = app.grant === 'session' ? p.sessionKeyFor(app.name) : undefined; - const sub = app.grant === 'subaccount' ? p.subAccountFor(app.name) : undefined; - const connecting = p.appBusy === app.id; - - let footer = null; - let connected = false; - if (app.id === 'monthly-vibes') { - if (sk) { - connected = true; - const pendingLabel = sk.pendingAuth ? 'Pending authorization' : sk.pendingRevoke ? 'Pending revoke' : null; - footer = ( - <> - {pendingLabel ?? 'Active'} - - {sk.policy?.params ?? 'capped'} · {formatExpiry(sk.expiry)} - - {pendingLabel ? ( - - ) : ( - - )} - {sk.pendingAuth ? ( - - ) : sk.pendingRevoke ? ( - - ) : null} - - ); - } else { - footer = ( - - ); - } - } else if (app.id === 'spending-account') { - if (sub) { - connected = true; - footer = ( - <> - - - - ); - } else { - footer = ( - - ); - } - } - - return ( - } - title={app.name} - description={app.desc} - footerClassName={connected ? CONNECTED_FOOTER : undefined} - > - {footer} - - ); -} - -export function AppCardPlaceholder({ app }: { app: DemoApp }) { - return ( - - ); -} diff --git a/app/vibenet/demos/account/components/CreateAccountModal.tsx b/app/vibenet/demos/account/components/CreateAccountModal.tsx index 37979974..8d72eaeb 100644 --- a/app/vibenet/demos/account/components/CreateAccountModal.tsx +++ b/app/vibenet/demos/account/components/CreateAccountModal.tsx @@ -1,15 +1,13 @@ 'use client'; -// The account-creation drawer. "Account Type" is the top-level choice: -// Default — one-click EOA off your first unused key (mints one if none) -// Passkey — one-click smart account owned by your first unused passkey -// Advanced — hand-pick smart/EOA + initial keys + salt -// Default/Passkey stay minimal (name only); Advanced reveals the full controls. +// The account-creation drawer. An account is a secp256k1 EOA — its key is the +// account. "Key" is the only real choice: +// New key — generate a fresh K1 key in this browser +// Unused key — reuse a K1 key already in the wallet that backs no account // // Owns its own form state; reads the shared store + account-building primitives // from the account-engine context. -import { type Address, computeAddress, type Hex } from '@aa'; import { useEffect, useMemo, useState } from 'react'; import { Button } from '../../../../components/ui/Button'; @@ -17,14 +15,12 @@ import { cn } from '../../../../components/ui/cn'; import { Drawer } from '../../../../components/ui/Drawer'; import { Field } from '../../../../components/ui/Field'; import { Input } from '../../../../components/ui/Input'; -import { InputGroup } from '../../../../components/ui/InputGroup'; import { Radio } from '../../../../components/ui/Radio'; import { RadioGroup } from '../../../../components/ui/RadioGroup'; import { Text } from '../../../../components/ui/Text'; -import { KIND_LABEL, short, signerIdentity, type CreateMode, type WalletSigner } from '../shared'; import { CheckIcon, KindBadge, TrashIcon } from '../../_shared/primitives'; -import { actorPairs, normalizeSalt, randomHex32, sortActors, toStoredActor } from '../library/derive'; -import type { AccountType, SignerKind, StoredAccount } from '../library/model'; +import type { StoredAccount } from '../library/model'; +import { type CreateMode, short, type WalletSigner } from '../shared'; import { useAccountEngine } from '../useAccountEngine'; type CreateAccountModalProps = { @@ -32,24 +28,11 @@ type CreateAccountModalProps = { onClose: () => void; }; -const MODES: ReadonlyArray<{ value: CreateMode; title: string; description: string }> = [ - { value: 'default', title: 'Default', description: 'Simplest account setup' }, - { value: 'passkey', title: 'Passkey', description: 'Smart account · passkey' }, - { value: 'advanced', title: 'Advanced', description: 'Pick type, keys & salt' }, -]; - -const ACCOUNT_MODELS = [ - { value: 'smart' as const, title: 'Smart Account', description: 'Counterfactual · keys + salt → address' }, - { value: 'eoa' as const, title: 'EOA', description: 'Your EOA · delegates to DefaultAccount' }, -]; - export function CreateAccountModal({ open, onClose }: CreateAccountModalProps) { const { signers, accounts, addAccount, - chain, - code, busy, usedSignerIds, deleteSigner, @@ -58,55 +41,34 @@ export function CreateAccountModal({ open, onClose }: CreateAccountModalProps) { autoFundNewAccount, } = useAccountEngine(); - const [createMode, setCreateMode] = useState('default'); - const [modalType, setModalType] = useState('eoa'); + const [createMode, setCreateMode] = useState('generate'); const [modalLabel, setModalLabel] = useState(''); - const [modalSalt, setModalSalt] = useState(() => randomHex32()); - const [modalIds, setModalIds] = useState([]); - const [modalEoaId, setModalEoaId] = useState(null); + const [existingId, setExistingId] = useState(null); + + const unusedSigners = useMemo(() => signers.filter((s) => !usedSignerIds.has(s.id)), [signers, usedSignerIds]); - // Reset to the one-click default each time the drawer opens. + // Reset each time the drawer opens. useEffect(() => { if (!open) return; - setCreateMode('default'); - setModalType('eoa'); + setCreateMode('generate'); setModalLabel(''); - setModalSalt(randomHex32()); - setModalIds([]); - setModalEoaId(null); + setExistingId(null); }, [open]); - const eoaSigners = useMemo(() => signers.filter((s) => s.kind === 'k1'), [signers]); - const defaultModeSigner = useMemo( - () => eoaSigners.find((s) => !usedSignerIds.has(s.id)) ?? null, - [eoaSigners, usedSignerIds], + const modes = useMemo( + () => + [ + { value: 'generate' as const, title: 'New Key', description: 'Generate a K1 key' }, + ...(unusedSigners.length > 0 + ? [{ value: 'existing' as const, title: 'Unused Key', description: 'Reuse a key in your wallet' }] + : []), + ] satisfies ReadonlyArray<{ value: CreateMode; title: string; description: string }>, + [unusedSigners.length], ); - const passkeyModeSigner = useMemo( - () => signers.find((s) => s.kind === 'passkey' && !usedSignerIds.has(s.id)) ?? null, - [signers, usedSignerIds], - ); - const modalEoaSigner = useMemo(() => eoaSigners.find((s) => s.id === modalEoaId) ?? null, [eoaSigners, modalEoaId]); - const modalSigners = useMemo(() => signers.filter((s) => modalIds.includes(s.id)), [signers, modalIds]); - const modalSalt32 = useMemo(() => normalizeSalt(modalSalt), [modalSalt]); - const modalAddress = useMemo
(() => { - if (modalType === 'eoa') return modalEoaSigner?.address ?? null; - if (modalSigners.length === 0) return null; - const ids = new Set(modalSigners.map((s) => s.actorId)); - if (ids.size !== modalSigners.length) return null; - try { - return computeAddress({ userSalt: modalSalt32, code, initialActors: sortActors(actorPairs(modalSigners)) }); - } catch { - return null; - } - }, [modalType, modalEoaSigner, modalSigners, modalSalt32, code]); - const suggestedName = useMemo(() => { - if (createMode === 'default') return 'Default'; - if (createMode === 'passkey') return 'Passkey'; - return modalType === 'eoa' ? 'EOA' : 'Smart Account'; - }, [createMode, modalType]); + const existingSigner = unusedSigners.find((s) => s.id === existingId) ?? null; - // Keep the auto-suggested fallback name unique (Default, Default 2, …). A name + // Keep the auto-suggested fallback name unique (Account, Account 2, …). A name // the user typed by hand is respected as-is, collisions and all. const uniqueAccountName = (base: string): string => { const taken = new Set(accounts.map((a) => a.label)); @@ -117,114 +79,37 @@ export function CreateAccountModal({ open, onClose }: CreateAccountModalProps) { } }; - // Build + persist an EOA account: the signer's own K1 key IS the account and - // its default owner; it delegates to DefaultAccount on first use. const buildEoaAccount = (signer: WalletSigner, label: string) => { - if (!signer.address) return; - const selfActor = toStoredActor(signer); const account: StoredAccount = { id: crypto.randomUUID(), label, type: 'eoa', - saltField: '', - salt: `0x${'00'.repeat(32)}` as Hex, + signerId: signer.id, address: signer.address, - delegate: chain.deployment.accounts.default, - initialActors: [selfActor], - owners: [selfActor], - deployed: false, - configSeq: 0, - sessionKeys: [], - subAccounts: [], createdAt: Date.now(), }; addAccount(account); pushActivity({ kind: 'create', title: `EOA account · ${account.label}`, - detail: 'Delegates to DefaultAccount on first use', + detail: 'Stored locally · ready to transact', account: account.address, }); autoFundNewAccount(account.address); }; - // Build + persist a counterfactual smart account from its initial owner keys - // and salt. Returns false (no-op) if the keys collide or the address won't derive. - const buildSmartAccount = (chosen: WalletSigner[], salt32: Hex, saltField: string, label: string): boolean => { - if (chosen.length === 0) return false; - const ids = new Set(chosen.map((s) => s.actorId)); - if (ids.size !== chosen.length) return false; - let address: Address; - try { - address = computeAddress({ userSalt: salt32, code, initialActors: sortActors(actorPairs(chosen)) }); - } catch { - return false; - } - const initialActors = chosen.map(toStoredActor); - const account: StoredAccount = { - id: crypto.randomUUID(), - label, - type: 'smart', - saltField, - salt: salt32, - address, - initialActors, - owners: [...initialActors], - deployed: false, - configSeq: 0, - sessionKeys: [], - subAccounts: [], - createdAt: Date.now(), - }; - addAccount(account); - pushActivity({ - kind: 'create', - title: `Account created · ${account.label}`, - detail: 'Stored locally · deploys on first use', - changes: initialActors.map((a) => `${a.label} (${KIND_LABEL[a.kind]})`), - account: account.address, - }); - autoFundNewAccount(address); - return true; - }; - - const createAccount = async () => { - const name = modalLabel.trim() || uniqueAccountName(suggestedName); - - // Advanced: honour the hand-picked type, keys, and salt. - if (createMode === 'advanced') { - if (!modalAddress) return; - if (modalType === 'eoa') { - if (!modalEoaSigner) return; - buildEoaAccount(modalEoaSigner, name); - } else if (!buildSmartAccount(modalSigners, modalSalt32, modalSalt, name)) { - return; - } - onClose(); - return; - } - - // Default: an EOA off your first unused K1 key (mint one if you have none). - if (createMode === 'default') { - const signer = defaultModeSigner ?? (await createSigner('k1')); - if (!signer) return; - buildEoaAccount(signer, name); - onClose(); - return; - } - - // Passkey: a smart account owned by your first unused passkey (mint if none). - const signer = passkeyModeSigner ?? (await createSigner('passkey')); + const createAccount = () => { + const name = modalLabel.trim() || uniqueAccountName('Account'); + const signer = createMode === 'generate' ? createSigner() : existingSigner; if (!signer) return; - const saltField = randomHex32(); - if (buildSmartAccount([signer], normalizeSalt(saltField), saltField, name)) onClose(); + buildEoaAccount(signer, name); + onClose(); }; - const busyCreating = busy !== null; - const canCreate = createMode === 'advanced' ? Boolean(modalAddress) : true; + const canCreate = createMode === 'generate' || Boolean(existingSigner); const submit = () => { - if (canCreate && !busyCreating) void createAccount(); + if (canCreate && !busy) createAccount(); }; return ( @@ -241,10 +126,10 @@ export function CreateAccountModal({ open, onClose }: CreateAccountModalProps) { variant="primary" size="sm" onClick={submit} - disabled={!canCreate || busyCreating} + disabled={!canCreate || busy} className="disabled:cursor-not-allowed disabled:opacity-50" > - {busyCreating ? 'Creating…' : 'Create Account'} + {busy ? 'Creating…' : 'Create Account'} } @@ -253,7 +138,7 @@ export function CreateAccountModal({ open, onClose }: CreateAccountModalProps) { Name { if (e.key === 'Enter') { @@ -264,231 +149,101 @@ export function CreateAccountModal({ open, onClose }: CreateAccountModalProps) { /> - - Account type - - {MODES.map((option) => ( - - - {option.title} - - - {option.description} - - - ))} - - - - {createMode === 'advanced' ? ( - <> - - Account model - - {ACCOUNT_MODELS.map((option) => ( - - - {option.title} - - - {option.description} - - - ))} - - - - {modalType === 'smart' ? ( - <> - !usedSignerIds.has(s.id)} - onDelete={deleteSigner} - isOn={(s) => modalIds.includes(s.id)} - onToggle={(s) => - setModalIds((prev) => (prev.includes(s.id) ? prev.filter((x) => x !== s.id) : [...prev, s.id])) - } - onMint={async (kind) => { - const s = await createSigner(kind); - if (s) setModalIds((prev) => [...prev, s.id]); - }} - /> - - Salt - - - - - - - ) : ( - !usedSignerIds.has(s.id)} - onDelete={deleteSigner} - isOn={(s) => modalEoaId === s.id} - onToggle={(s) => setModalEoaId(modalEoaId === s.id ? null : s.id)} - onMint={async (kind) => { - const s = await createSigner(kind); - if (s) setModalEoaId(s.id); - }} - /> - )} - -
- Address - {modalAddress ? ( - {modalAddress} - ) : modalType === 'eoa' ? ( - Pick a K1 key - ) : modalSigners.length === 0 ? ( - Select at least one key - ) : ( - duplicate key — pick distinct actors - )} -
- + {modes.length > 1 ? ( + + Key + + {modes.map((option) => ( + + + {option.title} + + + {option.description} + + + ))} + + ) : null} + + {createMode === 'existing' ? ( + existingId === s.id} + onToggle={(s) => setExistingId(existingId === s.id ? null : s.id)} + onDelete={(id) => { + deleteSigner(id); + if (existingId === id) setExistingId(null); + }} + /> + ) : ( + + A new secp256k1 key is generated and kept in this browser. Its address is the account — no deployment, + no salt. + + )} ); } type KeyPickerProps = { - heading: string; - empty: string; - hint?: string; signers: WalletSigner[]; - busy: SignerKind | null; - mintKinds: readonly SignerKind[]; isOn: (s: WalletSigner) => boolean; onToggle: (s: WalletSigner) => void; - onMint: (kind: SignerKind) => void; - /** A key is deletable when no account references it. */ - canDelete?: (s: WalletSigner) => boolean; - onDelete?: (id: string) => void; + onDelete: (id: string) => void; }; -function KeyPicker({ - heading, - empty, - hint, - signers, - busy, - mintKinds, - isOn, - onToggle, - onMint, - canDelete, - onDelete, -}: KeyPickerProps) { +function KeyPicker({ signers, isOn, onToggle, onDelete }: KeyPickerProps) { return (
- {heading} + Unused keys - {signers.length === 0 ? ( - - {empty} - - ) : ( -
    - {signers.map((s) => { - const on = isOn(s); - const deletable = Boolean(canDelete?.(s) && onDelete); - // A key that can't be deleted is bound to an existing account. - const inUse = Boolean(canDelete && !canDelete(s)); - return ( -
  • - - {deletable ? ( - - ) : inUse ? ( - - in use - - ) : null} -
  • - ); - })} -
- )} - {/* Mint controls sit at the foot of the list, styled like the "+ New - Account" affordance — one dashed button per available key kind. */} -
- {mintKinds.map((kind) => ( - - ))} -
- {hint ? ( - - {hint} - - ) : null} +
    + {signers.map((s) => { + const on = isOn(s); + return ( +
  • + + +
  • + ); + })} +
); } diff --git a/app/vibenet/demos/account/components/SessionKeyEditor.tsx b/app/vibenet/demos/account/components/SessionKeyEditor.tsx deleted file mode 100644 index 687d6f03..00000000 --- a/app/vibenet/demos/account/components/SessionKeyEditor.tsx +++ /dev/null @@ -1,347 +0,0 @@ -'use client'; - -// The "register a session key" form: signer + chain + expiry, spend limits, and -// a target allowlist. Owns its own draft state and turns it into an owner-signed -// authorization via the engine's `doAuthorizeSession` primitive. Extracted from -// ConfigView so both the account page and any future surface reuse it. - -import type { Address } from '@aa'; -import { useState } from 'react'; - -import { Button } from '../../../../components/ui/Button'; -import { cn } from '../../../../components/ui/cn'; -import { Field } from '../../../../components/ui/Field'; -import { CloseIcon } from '../../../../components/ui/icons'; -import { Input } from '../../../../components/ui/Input'; -import { Select } from '../../../../components/ui/Select'; -import { Text } from '../../../../components/ui/Text'; -import { getDemoChain, DEMO_CHAINS } from '../library/chains'; -import { EXPIRY_PRESETS } from '../library/model'; -import { - type LimitDraft, - newLimitDraft, - newScopeDraft, - PERIOD_PRESETS, - type PolicySpec, - type ScopeDraft, - SELECTOR_PRESETS, - stableSymbol, -} from '../library/policy'; -import { short } from '../shared'; -import { useAccountEngine } from '../useAccountEngine'; - -const ADDR_RE = /^0x[0-9a-fA-F]{40}$/; -const CHIP_CLS = - 'rounded-full border border-bds-gray-10 px-2.5 py-1 text-[12px] text-bds-gray-60 transition-colors hover:border-bds-gray-15 dark:border-white/10 dark:text-bds-gray-40'; -const CHIP_ON = 'border-base-blue bg-bds-blue-0 text-base-blue'; - -export function SessionKeyEditor({ - onClose, - onAuthorized, -}: { - onClose?: () => void; - onAuthorized?: (sessionKeyId: string) => void; -}) { - const { acct, signers, activeSigner, networkShort, doAuthorizeSession } = useAccountEngine(); - - const [skSignerId, setSkSignerId] = useState(''); - const [skExpiryId, setSkExpiryId] = useState('7d'); - const [skChainShort, setSkChainShort] = useState(networkShort); - const [skLimits, setSkLimits] = useState(() => [newLimitDraft()]); - const [skScopes, setSkScopes] = useState([]); - const [skBusy, setSkBusy] = useState(false); - const [error, setError] = useState(''); - - if (!acct) return null; - - const patchLimit = (id: string, patch: Partial) => - setSkLimits((ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l))); - const addLimit = () => setSkLimits((ls) => [...ls, newLimitDraft()]); - const removeLimit = (id: string) => setSkLimits((ls) => ls.filter((l) => l.id !== id)); - const patchScope = (id: string, patch: Partial) => - setSkScopes((ss) => ss.map((s) => (s.id === id ? { ...s, ...patch } : s))); - const addScope = () => setSkScopes((ss) => [...ss, newScopeDraft()]); - const removeScope = (id: string) => setSkScopes((ss) => ss.filter((s) => s.id !== id)); - const toggleScopeSelector = (id: string, sel: `0x${string}`) => - setSkScopes((ss) => - ss.map((s) => { - if (s.id !== id) return s; - const selectors = s.selectors.includes(sel) ? s.selectors.filter((x) => x !== sel) : [...s.selectors, sel]; - return { ...s, selectors, all: selectors.length === 0 }; - }), - ); - const setScopeAll = (id: string) => - setSkScopes((ss) => ss.map((s) => (s.id === id ? { ...s, all: true, selectors: [] } : s))); - - const formPolicySpec = (): PolicySpec => ({ - limits: skLimits.map((l) => ({ - token: l.token === 'custom' ? (l.custom.trim() as Address) : l.token, - amount: l.amount, - periodSecs: PERIOD_PRESETS.find((p) => p.id === l.periodId)?.seconds ?? 0, - })), - scopes: skScopes - .filter((s) => s.target.trim()) - .map((s) => ({ target: s.target, selectors: s.all ? undefined : s.selectors })), - }); - const formPolicyLabel = (): string => { - const parts: string[] = []; - if (skLimits.length) parts.push('Spend limit'); - if (skScopes.some((s) => s.target.trim())) parts.push('Allowlist'); - return parts.join(' + ') || 'Policy'; - }; - const formPolicyEmpty = skLimits.length === 0 && !skScopes.some((s) => s.target.trim()); - - const registerSessionKey = async () => { - if (!activeSigner) return; - const target = signers.find((s) => s.id === skSignerId); - if (!target) { - setError('Pick a signer to authorize as a session key.'); - return; - } - if (acct.owners.some((o) => o.actorId === target.actorId)) { - setError(`${target.label} is already an owner of this account — pick a different signer.`); - return; - } - if (acct.sessionKeys.some((sk) => sk.actorId === target.actorId)) { - setError(`${target.label} is already an active session key — revoke it first to change its policy.`); - return; - } - if (formPolicyEmpty) { - setError('Add a spend limit or at least one allowed target.'); - return; - } - setSkBusy(true); - setError(''); - try { - const sessionKey = await doAuthorizeSession(target, { - expirySecs: EXPIRY_PRESETS.find((p) => p.id === skExpiryId)!.seconds, - policyLabel: formPolicyLabel(), - spec: formPolicySpec(), - label: target.label, - chainShort: skChainShort, - }); - if (!sessionKey) return; - setSkSignerId(''); - onClose?.(); - onAuthorized?.(sessionKey.id); - } catch (err) { - const e = err as { message?: string; name?: string }; - setError(e.name === 'NotAllowedError' ? 'Signature was dismissed.' : (e.message ?? String(err))); - } finally { - setSkBusy(false); - } - }; - - const impliedScopes: { key: string; label: string; note: string }[] = []; - for (const l of skLimits) { - if (l.token === 'stable') - impliedScopes.push({ - key: `implied-stable-${l.id}`, - label: `${stableSymbol(skChainShort)} contract`, - note: 'transfer · auto-pinned by spend limit', - }); - else if (l.token === 'custom' && ADDR_RE.test(l.custom.trim())) - impliedScopes.push({ - key: `implied-custom-${l.id}`, - label: short(l.custom.trim()), - note: 'transfer · auto-pinned by spend limit', - }); - } - - return ( -
- - Register a Session Key - -
- - Signer - ({ value: c.shortName, label: c.name }))} - /> - - - Expiry - patchLimit(l.id, { token: value as LimitDraft['token'] })} - options={[ - { value: 'stable', label: stableSymbol(skChainShort) }, - { value: 'eth', label: 'ETH' }, - { value: 'custom', label: 'Custom token...' }, - ]} - /> -
- {l.token === 'custom' ? ( - patchLimit(l.id, { custom })} - /> - ) : null} - patchLimit(l.id, { amount })} - /> -
- patchScope(s.id, { target })} - /> - -
-
- - {SELECTOR_PRESETS.map((sp) => ( - - ))} -
-
- ); - })} -
- -
-
- - {error ?

{error}

: null} - -
-

- Send-only key on {getDemoChain(skChainShort).name}. - {skLimits.some((l) => l.token === 'eth') - ? ' An ETH limit needs at least one allowed target to pay.' - : ''} -

- {onClose ? ( - - ) : null} - -
- - ); -} diff --git a/app/vibenet/demos/account/components/TransactionModal.tsx b/app/vibenet/demos/account/components/TransactionModal.tsx index d5edd791..74f5d8a7 100644 --- a/app/vibenet/demos/account/components/TransactionModal.tsx +++ b/app/vibenet/demos/account/components/TransactionModal.tsx @@ -1,24 +1,22 @@ 'use client'; -// The shared "Create Transaction" dialog for EIP-8130 accounts: a single popup -// with three steps — build (calls + gas), review, and submitted (sign → -// broadcast → wait for inclusion). It also doubles as the apply surface for -// staged key changes: `openApply()` skips the builder, reviews the pending -// owner/session-key change, and on Send runs the engine's apply primitives -// through the same submitted/wait step. +// The shared "Create Transaction" dialog for EIP-8130 EOA accounts: a single +// popup with three steps — build (calls, gas), review, and +// submitted (sign → broadcast → wait for inclusion). // -// Driven by declarative open/request props plus the account-engine context, so +// Driven by declarative open/preset props plus the account-engine context, so // every surface gets the same component without an imperative "modal hook". import { type Address, createPayerClient, - encodeTokenTransfer, generatePrivateKey, type Hex, isDeclinedOffer, isTokenOffer, + parsePayerError, parseUnits, + type PayerRejectedData, privateKeyToAccount, selectPaymentOption, toHex, @@ -27,6 +25,7 @@ import { useMemo, useState } from 'react'; import { toast } from 'sonner'; import { Button } from '../../../../components/ui/Button'; +import { Checkbox } from '../../../../components/ui/Checkbox'; import { cn } from '../../../../components/ui/cn'; import { Field } from '../../../../components/ui/Field'; import { CloseIcon } from '../../../../components/ui/icons'; @@ -45,7 +44,7 @@ import type { Inclusion } from '../../_shared/inclusion'; import { InclusionBadge, InclusionTagline } from '../../_shared/InclusionBadge'; import { Badge, CheckIcon, KindBadge } from '../../_shared/primitives'; import { ViewTransactionButton } from '../../_shared/ViewTransactionButton'; -import { DEMO_CHAINS, estimateTxGas, PAYER_URL } from '../library/chains'; +import { estimateTxGas, PAYER_URL } from '../library/chains'; import { buildCalls, type CallRow, @@ -57,22 +56,75 @@ import { USDV_DECIMALS, valueBearingCallCount, } from '../library/calls'; -import { formatExpiry, scopeChips, type SignerKind, type StoredAccount } from '../library/model'; -import { scopeLabel } from '../library/policy'; -import { formatTokenAmount, KIND_LABEL, short, type WalletSigner } from '../shared'; -import { conciseError, EstimateRevertedError, isSeqMismatch, TxPendingError, useAccountEngine } from '../useAccountEngine'; +import type { SignerKind, StoredAccount } from '../library/model'; +import { parseTokenRate } from '../library/payer'; +import { formatTokenAmount, short, type WalletSigner } from '../shared'; +import { + type ComposePayer, + conciseError, + EstimateRevertedError, + TxPendingError, + useAccountEngine, +} from '../useAccountEngine'; + +type GasMode = 'eth' | 'free' | 'usdv'; -export type TransactPreset = { calls: CallRow[]; gasMode?: 'eth' | 'free' | 'usdv'; metadata?: string }; -/** Apply the staged owner change, or a specific session key's change. */ -export type ApplyTarget = 'owner' | { session: string }; +export type TransactPreset = { + calls?: CallRow[]; + gasMode?: GasMode; + metadata?: string; + nonceless?: boolean; +}; type TransactionModalProps = { onClose: () => void; preset?: TransactPreset; - applyTarget?: ApplyTarget; }; -export function TransactionModal({ onClose, preset, applyTarget }: TransactionModalProps) { +const NONCELESS_SECONDS = 15; + +// A payer rejection the dialog can recover from with one more signature. +type PayerRetry = + | { kind: 'requote'; amount: bigint } + | { kind: 'minGas'; gas: bigint } + | { kind: 'usdv' }; + +function payerRejectionMessage(rejected: PayerRejectedData): string { + const data = rejected as PayerRejectedData & { + shortfall?: { required?: Hex; available?: Hex }; + revert?: { phase?: number }; + }; + switch (rejected.code) { + case 'PAYMENT_INSUFFICIENT': + return rejected.requote + ? `The payer now needs ${formatTokenAmount(BigInt(rejected.requote.paymentAmount), USDV_DECIMALS)} USDV for this gas. Retry to sign the new amount.` + : 'This payer requires a USDV payment — switch gas to USDV.'; + case 'SENDER_BALANCE_INSUFFICIENT': + return data.shortfall?.required && data.shortfall.available + ? `Not enough USDV for gas: needs ${formatTokenAmount(BigInt(data.shortfall.required), USDV_DECIMALS)}, account has ${formatTokenAmount(BigInt(data.shortfall.available), USDV_DECIMALS)}. Top up and retry.` + : 'Not enough USDV for gas. Top up and retry.'; + case 'BUDGET_EXHAUSTED': + return 'Free sponsorship for this account is used up. Pay gas in USDV instead.'; + case 'EXECUTION_REVERTED': + return data.revert?.phase === 0 + ? 'The USDV gas payment would revert — check the account’s USDV balance.' + : 'The payer’s simulation says this transaction would revert.'; + case 'GAS_TOO_LOW': + return 'The payer needs a higher gas limit for these calls. Retry to re-sign with more gas.'; + default: + return rejected.reason ?? `Payer rejected the transaction (${rejected.code}).`; + } +} + +function retryFor(rejected: PayerRejectedData): PayerRetry | null { + if (rejected.code === 'PAYMENT_INSUFFICIENT' && rejected.requote) + return { kind: 'requote', amount: BigInt(rejected.requote.paymentAmount) }; + if (rejected.code === 'GAS_TOO_LOW' && rejected.minGasLimit) return { kind: 'minGas', gas: BigInt(rejected.minGasLimit) }; + if (rejected.code === 'BUDGET_EXHAUSTED') return { kind: 'usdv' }; + return null; +} + +export function TransactionModal({ onClose, preset }: TransactionModalProps) { const engine = useAccountEngine(); const { acct, @@ -80,78 +132,35 @@ export function TransactionModal({ onClose, preset, applyTarget }: TransactionMo activeAccountId, setActiveAccountId, addressBook, - networkShort, - setNetworkShort, chain, - activeSignerId, - setActiveSignerId, activeSigner, - ownerSigners, - sessionSigners, - postChangeOwnerSigners, - pendingAuthorize, - pendingRevoke, - pendingScope, - keyChangeCount, + latestAnchor, broadcast8130, + awaitRelayed, inclusionFor, signComposed, - applyLandedBundle, - pendingBundleFor, pushActivity, - applyOwnerNow, - applySessionKeyNow, - signOwnerChange, - resignPendingSessionKeys, - dropPendingSessionKeys, - discardOwnerChanges, } = engine; - const [txSignerId, setTxSignerId] = useState(null); const [calls, setCalls] = useState(() => preset?.calls ?? [newCallRow()]); const [callsAdvanced, setCallsAdvanced] = useState(false); const [usdvRecipientDrafts, setUsdvRecipientDrafts] = useState>({}); const [usdvAmountDrafts, setUsdvAmountDrafts] = useState>({}); const [metaField, setMetaField] = useState(preset?.metadata ?? ''); - const [gasMode, setGasMode] = useState<'eth' | 'free' | 'usdv'>(preset?.gasMode ?? 'eth'); + const [gasMode, setGasMode] = useState(preset?.gasMode ?? 'eth'); + const [nonceless, setNonceless] = useState(preset?.nonceless ?? false); const [signing, setSigning] = useState(false); const [copied, setCopied] = useState(null); const [submitStatus, setSubmitStatus] = useState<'' | 'submitting' | 'confirming'>(''); - const [configTx, setConfigTx] = useState<{ hash: Hex; label: string } | null>(null); const [estimateBlocked, setEstimateBlocked] = useState(null); - // Error + config-sequence-recovery UI is local to this dialog — nothing leaks - // onto the page behind it. `notice` is a transient status line (after a - // re-sign / drop); `seqRecovery` is the "config change sequence mismatch" - // prompt offering to re-sign at the current sequence or drop the change. + const [payerRetry, setPayerRetry] = useState(null); + // Error UI is local to this dialog — nothing leaks onto the page behind it. const [error, setError] = useState(''); - const [notice, setNotice] = useState(''); - const [seqRecovery, setSeqRecovery] = useState<{ - what: string; - resign: () => Promise | void; - drop: () => void; - busy?: boolean; - } | null>(null); const [txStep, setTxStep] = useState<'build' | 'review' | 'submitted'>( - applyTarget || preset ? 'review' : 'build', + preset?.calls ? 'review' : 'build', ); const [result, setResult] = useState(null); - const signableSigners = useMemo( - () => [...postChangeOwnerSigners, ...sessionSigners], - [postChangeOwnerSigners, sessionSigners], - ); - const txSigner = - signableSigners.find((s) => s.id === txSignerId) ?? - postChangeOwnerSigners.find((s) => s.id === activeSignerId) ?? - postChangeOwnerSigners[0] ?? - activeSigner; - const txIsSession = !!txSigner && sessionSigners.some((s) => s.id === txSigner.id); - const activeSessionKey = - txIsSession && txSigner ? (acct?.sessionKeys.find((sk) => sk.signerId === txSigner.id) ?? null) : null; - - // Session keys always use sponsorship without mutating the owner's last gas choice. - const effectiveGasMode = txIsSession ? 'free' : gasMode; - const callsValid = useMemo(() => calls.every(rowToValid), [calls]); const metadataHex = useMemo( () => (metaField.trim() ? (toHex(metaField.trim()) as Hex) : undefined), @@ -160,13 +169,13 @@ export function TransactionModal({ onClose, preset, applyTarget }: TransactionMo const gasEstimate = useMemo(() => { if (!acct) return 0; return estimateTxGas({ - mode: chain.mode, - deploy: !acct.deployed, calls: calls.length, - keyChanges: keyChangeCount, - valueCalls: valueBearingCallCount(calls), + valueCalls: valueBearingCallCount(calls, acct.address), + nonceless, + payer: gasMode !== 'eth', + tokenPayment: gasMode === 'usdv', }); - }, [acct, chain.mode, calls, keyChangeCount]); + }, [acct, calls, nonceless, gasMode]); const clearResult = () => setResult(null); const copy = async (text: string, key: string) => { @@ -187,26 +196,18 @@ export function TransactionModal({ onClose, preset, applyTarget }: TransactionMo pending: boolean, by: WalletSigner, gasNote?: string, - extraChanges: string[] = [], ) => { setResult({ serialized, txHash, by: by.label, kind: by.kind, pending, gasNote, inclusion: inclusionFor(txHash) }); pushActivity({ - kind: a.deployed && !pending ? 'transact' : 'create', + kind: 'transact', txHash, title: pending ? 'Transaction pending · not yet included' - : a.deployed - ? `Transaction landed onchain${gasNote ? ' (payer gas)' : ''}` - : a.type === 'eoa' - ? 'EOA delegated + first action' - : 'Account deployed + first action', + : `Transaction landed onchain${gasNote ? ' (payer gas)' : ''}`, changes: [ - ...(!a.deployed - ? [a.type === 'eoa' ? 'delegate → DefaultAccount' : `create · ${a.initialActors.length} keys`] - : []), + ...(nonceless ? ['nonce-free'] : []), ...(pending ? ['⚠ pending — not yet included'] : []), ...(gasNote ? [gasNote] : []), - ...extraChanges, ], calls: calls.length, metadata: metaField.trim() || undefined, @@ -217,138 +218,49 @@ export function TransactionModal({ onClose, preset, applyTarget }: TransactionMo }); }; - const sendExtraChanges = (): string[] => - txIsSession && txSigner - ? [`via session key · ${txSigner.label}`] - : [ - ...pendingAuthorize.map((s) => `authorize ${s.label}`), - ...pendingRevoke.map((o) => `revoke ${o.label}`), - ...pendingScope.map((o) => `scope ${o.label} → ${scopeLabel(o.toScope)}`), - ]; - const surfaceSendError = (message: string) => { setError(message); toast.error(message); }; - const clearNotices = () => { - setNotice(''); - setSeqRecovery(null); + const sendError = (err: unknown) => { + if (err instanceof EstimateRevertedError) setEstimateBlocked(err.reason); + const e = err as { message?: string; name?: string }; + surfaceSendError(conciseError(e.message ?? String(err))); }; - // Catch a "config change sequence mismatch" from a config-carrying broadcast - // and show a recovery prompt inside this dialog (re-sign at the current - // sequence, or drop the change), scoped to what the failed tx carried. Returns - // true once handled so the caller skips its generic error handling. - const handleSeqMismatch = (err: unknown, ctx: { sessionIds: string[]; hasOwner: boolean }): boolean => { - if (!isSeqMismatch(err)) return false; - if (!ctx.hasOwner && ctx.sessionIds.length === 0) return false; - const parts: string[] = []; - if (ctx.hasOwner) parts.push('owner change'); - if (ctx.sessionIds.length) - parts.push(`${ctx.sessionIds.length} session-key authorization${ctx.sessionIds.length === 1 ? '' : 's'}`); - setError(''); - setNotice(''); - setSeqRecovery({ - what: parts.join(' + ') || 'staged config change', - resign: async () => { - setSeqRecovery((r) => (r ? { ...r, busy: true } : r)); - setError(''); - try { - if (ctx.hasOwner) await signOwnerChange(); - if (ctx.sessionIds.length && !(await resignPendingSessionKeys())) { - setSeqRecovery((r) => (r ? { ...r, busy: false } : r)); - return; - } - setSeqRecovery(null); - setNotice('Re-signed at the current sequence — send again to apply it.'); - } catch (e) { - const m = e as { message?: string; name?: string }; - setSeqRecovery((r) => (r ? { ...r, busy: false } : r)); - setError(m.name === 'NotAllowedError' ? 'Signature was dismissed.' : (m.message ?? String(e))); - } - }, - drop: () => { - if (ctx.hasOwner) discardOwnerChanges(); - if (ctx.sessionIds.length) dropPendingSessionKeys(ctx.sessionIds); - setSeqRecovery(null); - setNotice('Dropped the out-of-sequence config change.'); - }, - }); - return true; + // Broadcast and wait, treating a timeout as "submitted but pending". + const broadcastOrPending = async (serialized: Hex): Promise<{ txHash: Hex; pending: boolean }> => { + try { + return { txHash: await broadcast8130(serialized, setSubmitStatus), pending: false }; + } catch (err) { + if (err instanceof TxPendingError) return { txHash: err.txHash, pending: true }; + throw err; + } }; // Transact: native offline sign, own ETH gas. - const doSignNative = async (forceEstimate = false) => { - if (!acct || !txSigner || !callsValid) return; - const sessionPolicy = activeSessionKey?.policy; - if (txIsSession && !acct.deployed && !activeSessionKey?.pendingAuth) { - surfaceSendError('Authorize this session key with an owner key first (Apply now).'); - return; - } - setSigning(true); - setError(''); - clearNotices(); - setEstimateBlocked(null); - let seqCtx: { sessionIds: string[]; hasOwner: boolean } = { sessionIds: [], hasOwner: false }; + const doSignNative = async (forceEstimate: boolean) => { + if (!acct || !activeSigner) return; try { - const bundle = pendingBundleFor( - txIsSession ? { mode: 'session-send', sessionId: activeSessionKey?.id } : { mode: 'owner-send' }, - ); - seqCtx = { - sessionIds: bundle.flatMap((i) => (i.sessionId ? [i.sessionId] : [])), - hasOwner: bundle.some((i) => i.resultingOwners), - }; - const presigned = bundle.map((i) => i.change); - const changeSeq = bundle.length ? bundle[bundle.length - 1].sequence : null; - const extra = sendExtraChanges(); - const { serialized, nextSeq } = await signComposed( - acct, - txSigner, - calls, - presigned, - changeSeq, - metadataHex, - sessionPolicy, - undefined, - { estimateRevert: forceEstimate ? 'force' : 'throw' }, - ); - let txHash: Hex; - let pending = false; - try { - txHash = await broadcast8130(serialized, setSubmitStatus); - } catch (err) { - if (err instanceof TxPendingError) { - txHash = err.txHash; - pending = true; - } else throw err; - } - if (!pending) applyLandedBundle(acct, nextSeq, bundle); - recordResult(acct, serialized, txHash, pending, txSigner, undefined, extra); + const { serialized } = await signComposed(acct, { + rows: calls, + metadata: metadataHex, + noncelessSeconds: nonceless ? NONCELESS_SECONDS : undefined, + estimateRevert: forceEstimate ? 'force' : 'throw', + }); + const { txHash, pending } = await broadcastOrPending(serialized); + recordResult(acct, serialized, txHash, pending, activeSigner); } catch (err) { - if (handleSeqMismatch(err, seqCtx)) return; - if (err instanceof EstimateRevertedError) setEstimateBlocked(err.reason); - const e = err as { message?: string; name?: string }; - surfaceSendError(conciseError(e.name === 'NotAllowedError' ? 'Signature was dismissed.' : (e.message ?? String(err)))); - } finally { - setSigning(false); - setSubmitStatus(''); + sendError(err); } }; - // Transact: native sign co-signed by an ERC-8168 payer service. - const doSponsoredSign = async (forceEstimate = false) => { - if (!acct || !txSigner || !callsValid) return; - const sessionPolicy = activeSessionKey?.policy; - if (txIsSession && !acct.deployed && !activeSessionKey?.pendingAuth) { - surfaceSendError('Authorize this session key with an owner key first (Apply now).'); - return; - } - setSigning(true); - setError(''); - clearNotices(); - setEstimateBlocked(null); - let seqCtx: { sessionIds: string[]; hasOwner: boolean } = { sessionIds: [], hasOwner: false }; + // Transact: sign with an ERC-8168 payer named on the transaction. The payer + // either co-signs (`payer_signTransaction`) and this page broadcasts, or it + // co-signs and broadcasts itself (`payer_sendTransaction`). + const doSponsoredSign = async (mode: 'free' | 'usdv', forceEstimate: boolean, retry: PayerRetry | null) => { + if (!acct || !activeSigner) return; try { const payerClient = createPayerClient({ url: PAYER_URL }); const rpcCalls = buildCalls(calls, acct.address).map((c) => ({ to: c.to, value: toHex(c.value), data: c.data })); @@ -361,129 +273,104 @@ export function TransactionModal({ onClose, preset, applyTarget }: TransactionMo }); let selToken: Address | undefined; - if (effectiveGasMode === 'usdv') { + if (mode === 'usdv') { const tokenOffer = terms.options.find(isTokenOffer); selToken = tokenOffer?.tokens?.[0]?.token; if (!selToken) throw new Error('This payer does not accept USDV gas payment.'); } - const declinedFree = effectiveGasMode === 'free' ? terms.options.find(isDeclinedOffer) : undefined; + const declinedFree = mode === 'free' ? terms.options.find(isDeclinedOffer) : undefined; const { option, tokenChoice } = selectPaymentOption(terms, selToken ? { token: selToken } : {}); - let phase0: { to: Address; data: Hex }[] | undefined; - let gasNote: string; + let payer: ComposePayer; if (option.kind === 'token' && tokenChoice) { - const amount = BigInt(tokenChoice.paymentAmount); - const transfer = encodeTokenTransfer({ - token: tokenChoice.token, - to: tokenChoice.feeRecipient ?? option.payer, - amount, - }); - phase0 = [{ to: transfer.to, data: transfer.data }]; - const human = `${formatTokenAmount(amount, tokenChoice.decimals)} ${tokenChoice.symbol}`; - gasNote = - declinedFree && isDeclinedOffer(declinedFree) - ? `Free sponsorship spent — paid ${human} gas · co-signed by payer` - : `Paid ${human} gas · co-signed by payer`; + const quoted = BigInt(tokenChoice.paymentAmount); + payer = { + address: option.payer, + tokenPayment: { + token: tokenChoice.token, + to: tokenChoice.feeRecipient ?? option.payer, + amount: retry?.kind === 'requote' && retry.amount > quoted ? retry.amount : quoted, + rate: parseTokenRate(tokenChoice.rate), + }, + }; } else { - gasNote = 'Sponsored by vibenet payer · free grant'; + payer = { address: option.payer }; } - const bundle = pendingBundleFor( - txIsSession ? { mode: 'session-send', sessionId: activeSessionKey?.id } : { mode: 'owner-send' }, - ); - seqCtx = { - sessionIds: bundle.flatMap((i) => (i.sessionId ? [i.sessionId] : [])), - hasOwner: bundle.some((i) => i.resultingOwners), - }; - const presigned = bundle.map((i) => i.change); - const changeSeq = bundle.length ? bundle[bundle.length - 1].sequence : null; - const extra = sendExtraChanges(); - const { serialized, nextSeq } = await signComposed( - acct, - txSigner, - calls, - presigned, - changeSeq, - metadataHex, - sessionPolicy, - { address: option.payer, phase0 }, - { estimateRevert: forceEstimate ? 'force' : 'throw' }, - ); - const cosigned = await payerClient.signTransaction({ - signedTransaction: serialized, - context: { flow: 'transact' }, + // A payer requires an expiry; stay inside its `maxExpiry` (the engine + // clamps a nonce-free send to its own shorter window). + const maxExpiry = option.conditions?.maxExpiry ?? 60; + const validBefore = BigInt(Date.now() + Math.max(maxExpiry - 2, 1) * 1000); + const fees = terms.gasEstimate + ? { + maxFeePerGas: BigInt(terms.gasEstimate.maxFeePerGas), + maxPriorityFeePerGas: BigInt(terms.gasEstimate.maxPriorityFeePerGas), + } + : undefined; + + const { serialized, paymentAmount } = await signComposed(acct, { + rows: calls, + metadata: metadataHex, + noncelessSeconds: nonceless ? NONCELESS_SECONDS : undefined, + payer, + validBefore, + fees, + minGas: retry?.kind === 'minGas' ? retry.gas : undefined, + estimateRevert: forceEstimate ? 'force' : 'throw', }); - const finalTx = (cosigned.signedTransaction ?? serialized) as Hex; + + const gasNote = + paymentAmount !== undefined && tokenChoice + ? `${declinedFree ? 'Free sponsorship spent — paid' : 'Paid'} ${formatTokenAmount(paymentAmount, tokenChoice.decimals)} ${tokenChoice.symbol} gas · co-signed by payer` + : 'Sponsored by vibenet payer · free grant'; let txHash: Hex; let pending = false; - try { - txHash = await broadcast8130(finalTx, setSubmitStatus); - } catch (err) { - if (err instanceof TxPendingError) { - txHash = err.txHash; + let finalTx = serialized; + if (option.methods?.includes('payer_signTransaction')) { + const cosigned = await payerClient.signTransaction({ signedTransaction: serialized, context: { flow: 'transact' } }); + finalTx = cosigned.signedTransaction; + ({ txHash, pending } = await broadcastOrPending(finalTx)); + } else { + setSubmitStatus('submitting'); + const anchor = latestAnchor(); + const sent = await payerClient.sendTransaction({ signedTransaction: serialized, context: { flow: 'transact' } }); + setSubmitStatus('confirming'); + txHash = sent.transactionHash; + try { + await awaitRelayed(txHash, anchor); + } catch (err) { + if (!(err instanceof TxPendingError)) throw err; pending = true; - } else throw err; + } } - if (!pending) applyLandedBundle(acct, nextSeq, bundle); - recordResult(acct, finalTx, txHash, pending, txSigner, gasNote, extra); + recordResult(acct, finalTx, txHash, pending, activeSigner, gasNote); } catch (err) { - if (handleSeqMismatch(err, seqCtx)) return; - if (err instanceof EstimateRevertedError) setEstimateBlocked(err.reason); - const e = err as { message?: string; name?: string }; - const msg = e.message ?? String(err); - surfaceSendError( - conciseError( - e.name === 'NotAllowedError' - ? 'Signature was dismissed.' - : /fetch|ECONNREFUSED|network/i.test(msg) - ? `Couldn't reach the payer service at ${PAYER_URL}.` - : msg, - ), - ); - } finally { - setSigning(false); - setSubmitStatus(''); + const rejected = parsePayerError(err); + if (rejected) { + setPayerRetry(retryFor(rejected)); + surfaceSendError(payerRejectionMessage(rejected)); + return; + } + const msg = (err as { message?: string })?.message ?? String(err); + if (/fetch|ECONNREFUSED|network/i.test(msg) && !(err instanceof EstimateRevertedError)) { + surfaceSendError(`Couldn't reach the payer service at ${PAYER_URL}.`); + return; + } + sendError(err); } }; - const confirmSend = async (forceEstimate = false) => { + const confirmSend = async (forceEstimate = false, retry: PayerRetry | null = null) => { + const mode = retry?.kind === 'usdv' ? 'usdv' : gasMode; + if (retry?.kind === 'usdv') setGasMode('usdv'); setError(''); - setTxStep('submitted'); - await (effectiveGasMode === 'eth' ? doSignNative(forceEstimate) : doSponsoredSign(forceEstimate)); - }; - - // Apply a staged config change (owner or session key) through this dialog's - // submitted/wait step. The engine's apply primitives broadcast + wait and - // return their result while this component owns all modal progress state. - const confirmApply = async () => { - if (!applyTarget) return; + setEstimateBlocked(null); + setPayerRetry(null); setTxStep('submitted'); setSigning(true); - setError(''); - clearNotices(); - // What the carrying tx bundles — so a sequence mismatch prompt names the - // right changes to re-sign or drop. - const seqCtx = - applyTarget === 'owner' - ? { sessionIds: [], hasOwner: true } - : (() => { - const bundle = pendingBundleFor({ mode: 'session-send', sessionId: applyTarget.session }); - return { - sessionIds: bundle.flatMap((i) => (i.sessionId ? [i.sessionId] : [])), - hasOwner: bundle.some((i) => i.resultingOwners), - }; - })(); try { - const tx = - applyTarget === 'owner' - ? await applyOwnerNow(setSubmitStatus) - : await applySessionKeyNow(applyTarget.session, setSubmitStatus); - setConfigTx(tx); - } catch (err) { - if (handleSeqMismatch(err, seqCtx)) return; - const e = err as { message?: string; name?: string }; - surfaceSendError(conciseError(e.name === 'NotAllowedError' ? 'Signature was dismissed.' : (e.message ?? String(err)))); + await (mode === 'eth' ? doSignNative(forceEstimate) : doSponsoredSign(mode, forceEstimate, retry)); } finally { setSigning(false); setSubmitStatus(''); @@ -516,93 +403,33 @@ export function TransactionModal({ onClose, preset, applyTarget }: TransactionMo }; const startSend = () => { - if (!callsValid || !txSigner) return; + if (!callsValid || !activeSigner) return; setError(''); setResult(null); setTxStep('review'); }; - const selectSigner = (id: string) => { - setTxSignerId(id); - if (ownerSigners.some((s) => s.id === id)) setActiveSignerId(id); - }; - const closeModal = () => { if (signing) return; // never abandon an in-flight send onClose(); }; - // Success shown in the submitted step. Apply and normal-send results use the - // same presentational shape even though their execution paths differ. - const applyResult = - applyTarget && configTx && txSigner - ? { txHash: configTx.hash, by: txSigner.label, kind: txSigner.kind, inclusion: inclusionFor(configTx.hash) } - : null; - const submittedResult = applyTarget ? applyResult : result; - - const applyChanges = useMemo(() => { - if (applyTarget === 'owner') { - return [ - ...pendingAuthorize.map((s) => `Authorize ${s.label}`), - ...pendingRevoke.map((o) => `Revoke ${o.label}`), - ...pendingScope.map((o) => `${o.label} → ${scopeLabel(o.toScope)}`), - ]; - } - if (applyTarget && typeof applyTarget === 'object') { - const sk = acct?.sessionKeys.find((k) => k.id === applyTarget.session); - if (!sk) return []; - if (sk.pendingRevoke) return [`Revoke ${sk.label}`]; - return [ - `Authorize ${sk.label}`, - ...(sk.policy ? [`Policy · ${sk.policy.label}`] : []), - ...scopeChips(sk.scope), - formatExpiry(sk.expiry), - ]; - } - return []; - }, [applyTarget, acct, pendingAuthorize, pendingRevoke, pendingScope]); + const retryLabel = estimateBlocked + ? 'Send Anyway' + : payerRetry?.kind === 'requote' + ? 'Sign New Amount' + : payerRetry?.kind === 'usdv' + ? 'Pay in USDV' + : 'Retry'; return ( - - - - ) : notice ? ( - <> - - - - ) : error ? ( + signing ? null : error ? ( <> - ) : ( <> - {submittedResult?.txHash ? ( - - ) : null} + {result?.txHash ? : null} ) - ) : applyTarget ? ( - <> - - - ) : txStep === 'review' ? ( <>