diff --git a/app/vibenet/demos/_components/AccountDemoShell.tsx b/app/vibenet/demos/_components/AccountDemoShell.tsx index 91a9b66..ee6f4f5 100644 --- a/app/vibenet/demos/_components/AccountDemoShell.tsx +++ b/app/vibenet/demos/_components/AccountDemoShell.tsx @@ -9,8 +9,8 @@ // - 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. +// Each demo renders this inside one AccountEngineProvider, avoiding duplicate +// store instances and repeated account-settings wiring. import { useEffect, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; @@ -19,15 +19,10 @@ import { cn } from '../../../components/ui/cn'; import { AccountSwitcher } from '../_shared/AccountSwitcher'; import { ActivityDrawer } from '../_shared/ActivityDrawer'; import { DemoGate } from '../_shared/DemoGate'; -import { AccountDetailsModal } from '../account/components/AccountDetailsModal'; import { CreateAccountModal } from '../account/components/CreateAccountModal'; -import type { AccountEngine } from '../account/useAccountEngine'; +import { useAccountEngine } from '../account/useAccountEngine'; type AccountDemoShellProps = { - engine: AccountEngine; - /** Page-specific navigation from Account Details. Omit when the demo has no - * transaction builder of its own (for example B20). */ - onTransactFromDetails?: () => void; // Empty-state copy. gateTitle?: string; gateDescription?: string; @@ -41,8 +36,6 @@ type AccountDemoShellProps = { }; export function AccountDemoShell({ - engine, - onTransactFromDetails, gateTitle, gateDescription, activity, @@ -51,7 +44,11 @@ export function AccountDemoShell({ className, children, }: AccountDemoShellProps) { + const engine = useAccountEngine(); const [topbarSlot, setTopbarSlot] = useState(null); + // The switcher and the empty-state gate both open the create-account modal. + const [createOpen, setCreateOpen] = useState(false); + const onCreate = () => setCreateOpen(true); useEffect(() => { setTopbarSlot(document.getElementById('topbar-actions-slot')); }, []); @@ -61,9 +58,12 @@ export function AccountDemoShell({ accounts={engine.accounts} activeAccountId={engine.activeAccountId} onSelect={engine.setActiveAccountId} - onCreate={engine.openCreate} - onDelete={engine.removeAccount} - onDetails={engine.openAccountDetails} + onCreate={onCreate} + onDelete={engine.deleteAccount} + onDetails={(id) => { + const addr = engine.accounts.find((a) => a.id === id)?.address; + if (addr) window.open(`/vibenet/explorer/address/${addr}`, '_blank', 'noopener,noreferrer'); + }} /> ); @@ -82,7 +82,7 @@ export function AccountDemoShell({ @@ -97,8 +97,7 @@ export function AccountDemoShell({ - - + setCreateOpen(false)} /> ); } diff --git a/app/vibenet/demos/_shared/ConfirmTrashButton.tsx b/app/vibenet/demos/_shared/ConfirmTrashButton.tsx new file mode 100644 index 0000000..722bdf3 --- /dev/null +++ b/app/vibenet/demos/_shared/ConfirmTrashButton.tsx @@ -0,0 +1,70 @@ +'use client'; + +// Two-click confirm icon button for a destructive row action (revoke an owner, +// revoke a session key, …): first click arms it, a second click within the +// window commits, and clicking anywhere else cancels it. Extracted from the +// inline delete button in AccountSwitcher, which still owns its own copy +// (it's list-indexed by account id rather than a single boolean). + +import { useEffect, useRef, useState } from 'react'; + +import { cn } from '../../../components/ui/cn'; +import { TrashIcon } from './primitives'; + +export function ConfirmTrashButton({ + onConfirm, + label, + size = 15, + className, + disabled = false, + disabledTitle, +}: { + onConfirm: () => void; + /** Used in aria-label / title, e.g. "Revoke Owner 2". */ + label: string; + size?: number; + className?: string; + disabled?: boolean; + /** Title shown while disabled, e.g. "An account needs at least one owner". */ + disabledTitle?: string; +}) { + const [confirming, setConfirming] = useState(false); + const buttonRef = useRef(null); + + useEffect(() => { + if (!confirming) return; + const onDocMouseDown = (e: MouseEvent) => { + if (buttonRef.current?.contains(e.target as Node)) return; + setConfirming(false); + }; + document.addEventListener('mousedown', onDocMouseDown); + return () => document.removeEventListener('mousedown', onDocMouseDown); + }, [confirming]); + + return ( + + ); +} diff --git a/app/vibenet/demos/_shared/TransactionModal.tsx b/app/vibenet/demos/_shared/TransactionModal.tsx index 173dc34..111fa08 100644 --- a/app/vibenet/demos/_shared/TransactionModal.tsx +++ b/app/vibenet/demos/_shared/TransactionModal.tsx @@ -9,13 +9,13 @@ // straight to it from a preset). Callers may fully override the review and // submitted bodies, or supply a custom success renderer for the default one. -import Link from 'next/link'; import type { ReactNode } from 'react'; import { Button } from '../../../components/ui/Button'; import { Modal } from '../../../components/ui/Modal'; import { Spinner } from '../../../components/ui/Spinner'; import { Text } from '../../../components/ui/Text'; +import { ViewTransactionButton } from './ViewTransactionButton'; export type TxStep = 'build' | 'review' | 'submitted'; export type TxResult = { txHash?: string } | null; @@ -202,11 +202,7 @@ export function TransactionModal({ <> {successExtra} {result?.txHash && explorerTxPath ? ( - - - + ) : null} + ); +} diff --git a/app/vibenet/demos/account/AccountDemo.tsx b/app/vibenet/demos/account/AccountDemo.tsx index 8e09a2c..b73b484 100644 --- a/app/vibenet/demos/account/AccountDemo.tsx +++ b/app/vibenet/demos/account/AccountDemo.tsx @@ -1,595 +1,102 @@ 'use client'; -// Account demo (EIP-8130). PR1: in-browser signer keys, portable account -// creation (smart + EOA), balances/assets. PR2: transact — a phased calls -// editor (simple + raw), gas estimation, native EIP-8130 sign + broadcast (own -// ETH gas or ERC-8168 payer-sponsored / USDV), a review step, and an activity -// log. Session keys, policies, sub-accounts, and the apps directory land later. +// Account demo (EIP-8130): in-browser signer keys, portable account creation +// (smart + EOA), balances/assets, native transact, session keys, and the apps +// directory. Account management (owners / session keys / sub-accounts / balances) +// now lives on the explorer address page (/vibenet/explorer/address/) when +// the address is a local account; this demo links there. // -// Adapted from base/vibenet `src/app/(vibenet)/account/page.tsx`. The source's -// three-column app shell + custom CSS is rewritten to omni-ui's single content -// column with Tailwind + bds tokens. The backend (balances, faucet, rpc, payer) -// is consumed cross-origin via the shared vibenet API client / RPC URL; nothing -// is proxied same-origin. +// The shared account engine + transact dialog are consumed from context, so this +// demo, B20, and the account page all behave identically. -import { - type Address, - createPayerClient, - encodeTokenTransfer, - generatePrivateKey, - type Hex, - isDeclinedOffer, - isTokenOffer, - parseUnits, - privateKeyToAccount, - selectPaymentOption, - toHex, -} from '@aa'; -import Link from 'next/link'; -import { useEffect, useMemo, useRef, useState } from 'react'; +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 { cn } from '../../../components/ui/cn'; -import { CloseIcon } from '../../../components/ui/icons'; import { Modal } from '../../../components/ui/Modal'; -import { Select } from '../../../components/ui/Select'; import { Text } from '../../../components/ui/Text'; -import { toast } from 'sonner'; -import { vibenetApi } from '../../library/client'; -import { ACCOUNT_RPC_URL, VIBENET_EXPLORER_PATH } from '../../library/config'; -import { Spinner } from '../../../components/ui/Spinner'; -import { Tabs } from '../../../components/ui/Tabs'; import { AccountDemoShell } from '../_components/AccountDemoShell'; import { FeatureCard } from '../../components/FeatureCard'; import { FEATURES } from '../../data/features'; -import { trackAccountAction } from '../../../analytics/events'; -import { AccountSwitcher } from '../_shared/AccountSwitcher'; -import { AddressAutocomplete, type AddressBookEntry } from '../_shared/AddressAutocomplete'; -import { CallRow as ReviewCallRow, ReviewArrow } from '../_shared/CallRow'; -import { TransactionModal } from '../_shared/TransactionModal'; import { ActivityLog } from './components/ActivityLog'; import { AppCard, AppCardPlaceholder, AppsNetworkNotice } from './components/AppsView'; import { FeatureGridCard, FeatureGridPlaceholder } from '../_shared/FeatureGridCard'; -import { Badge, CheckIcon, KindBadge } from '../_shared/primitives'; +import { TransactionModal, type ApplyTarget, type TransactPreset } from './components/TransactionModal'; import { DEMO_APPS, type DemoApp } from './library/apps'; -import { DEMO_CHAINS, estimateTxGas, PAYER_URL } from './library/chains'; -import { - buildCalls, - type CallRow, - encodeUsdvTransfer, - isAddressStr, - newCallRow, - rowToValid, - tryDecodeUsdvTransfer, - USDV_DECIMALS, - valueBearingCallCount, -} from './library/calls'; -import { - type AppSessionKey, - type AppSubAccount, - EXPIRY_PRESETS, - type SignerKind, - type StoredAccount, -} from './library/model'; -import { scopeLabel } from './library/policy'; -import { formatTokenAmount, KIND_LABEL, short, type WalletSigner } from './shared'; -import { conciseError, TxPendingError, useAccountEngine } from './useAccountEngine'; +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'; export function AccountDemo() { + return ( + + + + ); +} + +function AccountDemoInner() { const engine = useAccountEngine(); const { signers, - setSigners, accounts, activeAccountId, - setActiveAccountId, - addressBook, activity, networkShort, setNetworkShort, deleteAccount, - hydrated, - busy, - error, - setError, - copied, - copy, - activeSignerId, - setActiveSignerId, activeSigner, chain, regenesisNotice, setRegenesisNotice, - setDetailsOpen, acct, - setCfgTab, - openOwnersManager, - - ownerSigners, - sessionSigners, - pendingAuthorize, - pendingRevoke, - pendingScope, - keyChangeCount, - postChangeOwnerSigners, - setConfigTx, - - broadcast8130, - signComposed, - applyLandedBundle, - handleSeqMismatch, - pendingBundleFor, - estimateBlocked, - setEstimateBlocked, - overrideEstimateRef, - blockOnRevertRef, - infoMsg, - setInfoMsg, - seqRecovery, - setSeqRecovery, - submitStatus, - setSubmitStatus, - pushActivity, + deleteSigner, revokeSessionKey, + undoStagedRevoke, doAuthorizeSession, doCreateSubAccount, mintAppKey, } = engine; - const [txSignerId, setTxSignerId] = useState(null); - - // Transact builder. - const [calls, setCalls] = useState(() => [newCallRow()]); - const [callsAdvanced, setCallsAdvanced] = useState(false); - const [usdvRecipientDrafts, setUsdvRecipientDrafts] = useState>({}); - const [usdvAmountDrafts, setUsdvAmountDrafts] = useState>({}); - const [metaField, setMetaField] = useState(''); - const [gasMode, setGasMode] = useState<'eth' | 'free' | 'usdv'>('eth'); - const [signing, setSigning] = useState(false); - // The create-transaction modal is a single popup with three steps; review has - // a Back button to the builder rather than being a second stacked modal, and - // sending moves to a submitted step showing the in-flight/success/error state. - const [txStep, setTxStep] = useState<'build' | 'review' | 'submitted'>('build'); - const [result, setResult] = useState<{ - serialized?: Hex; - txHash?: Hex; - by: string; - kind: SignerKind; - gasNote?: string; - pending?: boolean; - } | null>(null); - // Apps directory. const [appBusy, setAppBusy] = useState(null); - const [transactModalOpen, setTransactModalOpen] = useState(false); + const [transactionRequest, setTransactionRequest] = useState<{ + preset?: TransactPreset; + applyTarget?: ApplyTarget; + contentKey: string; + } | null>(null); - // Crossfade key for the dashboard (transact + apps). While the modal is - // closed it is the live account, so first paint after hydration uses the - // real id (`initial={false}` skips that enter). Snapshotting the key in - // state would freeze the pre-hydration `empty` value and fade the grid - // once the store loads. While the modal is open we hold the last closed - // key in a ref: the "From" switcher changes the active account, and - // remounting this subtree would tear down the open dialog. + // 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. const activeAccountKey = activeAccountId ?? 'empty'; - // Freeze the crossfade key while the transact modal is open (its "From" - // switcher changes the active account, and remounting the crossfaded subtree - // would tear down the open dialog). Otherwise track the active account - // directly: deriving the key rather than mirroring it into state via an effect - // avoids a one-frame stale 'empty' key on hydration, which made the features - // grid crossfade out-and-in on first load. - const frozenContentKey = useRef(activeAccountKey); - if (!transactModalOpen) frozenContentKey.current = activeAccountKey; - const contentKey = frozenContentKey.current; - - 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 can only ride sponsored (EIP-8168 "free") transactions — ETH - // and USDV-payer gas modes aren't a supported combination and will fail - // estimation/submission. Force sponsored mode as soon as a session key - // becomes the selected signer. - useEffect(() => { - if (txIsSession) setGasMode('free'); - }, [txIsSession]); - - const callsValid = useMemo(() => calls.every(rowToValid), [calls]); - const metadataHex = useMemo( - () => (metaField.trim() ? (toHex(metaField.trim()) as Hex) : undefined), - [metaField], - ); - const gasEstimate = useMemo(() => { - if (!acct) return 0; - return estimateTxGas({ - mode: chain.mode, - deploy: !acct.deployed, - calls: calls.length, - keyChanges: keyChangeCount, - valueCalls: valueBearingCallCount(calls), - }); - }, [acct, chain.mode, calls, keyChangeCount]); - - // Reset the Transact-modal-local bits when the active account changes. - // Everything else (owner draft, session-key form, etc.) is reset by the - // engine's own `[activeAccountId]` effect. - useEffect(() => { - setTxSignerId(null); - setResult(null); - setTxStep('build'); - }, [activeAccountId]); - - // Generate a throwaway EVM address and copy it — a convenience for filling a - // recipient field when experimenting in the transaction modal. - const copyRandomAddress = () => copy(privateKeyToAccount(generatePrivateKey()).address, 'randaddr'); - - // Record the outcome of a broadcast tx into the result panel + activity log. - const recordResult = ( - a: StoredAccount, - serialized: Hex, - txHash: Hex, - pending: boolean, - by: WalletSigner, - gasNote?: string, - extraChanges: string[] = [], - ) => { - setResult({ serialized, txHash, by: by.label, kind: by.kind, pending, gasNote }); - toast.success(pending ? 'Submitted — awaiting confirmation' : 'Transaction landed onchain'); - pushActivity({ - kind: a.deployed && !pending ? 'transact' : 'create', - 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', - changes: [ - ...(!a.deployed - ? [a.type === 'eoa' ? 'delegate → DefaultAccount' : `create · ${a.initialActors.length} keys`] - : []), - ...(pending ? ['⚠ pending — not yet included'] : []), - ...(gasNote ? [gasNote] : []), - ...extraChanges, - ], - calls: calls.length, - metadata: metaField.trim() || undefined, - network: chain.name, - mode: chain.mode, - serialized, - account: a.address, - }); - }; - - // Summary of the config changes riding a transact (for the activity log). - 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); - }; - - // Transact: native offline sign, own ETH gas. - const doSignNative = async () => { - if (!acct || !txSigner || !callsValid) return; - const sessionPolicy = activeSessionKey?.policy; - if (txIsSession && gasMode !== 'free') { - surfaceSendError('Session keys can only send sponsored (free) transactions. Switch gas to Sponsored.'); - return; - } - if (txIsSession && !acct.deployed && !activeSessionKey?.pendingAuth) { - surfaceSendError('Authorize this session key with an owner key first (Apply now).'); - return; - } - setSigning(true); - setError(''); - setInfoMsg(''); - setSeqRecovery(null); - // Clear any prior "would revert" block; a fresh estimate re-sets it if needed. - setEstimateBlocked(null); - blockOnRevertRef.current = true; // transact send: surface a reverting estimate - // Captured for sequence-mismatch recovery in the catch (what this tx carried). - let seqCtx: { sessionIds: string[]; hasOwner: boolean } = { sessionIds: [], hasOwner: false }; - 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, - ); - 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); - } catch (err) { - if (handleSeqMismatch(err, seqCtx)) return false; - const e = err as { message?: string; name?: string }; - surfaceSendError(conciseError(e.name === 'NotAllowedError' ? 'Signature was dismissed.' : (e.message ?? String(err)))); - return false; - } finally { - setSigning(false); - setSubmitStatus(''); - overrideEstimateRef.current = false; // one-shot "Send anyway" - blockOnRevertRef.current = false; - } - }; - - // Transact: native sign co-signed by an ERC-8168 payer service. - // - "free": prefer per-account sponsorship, fall back to USDV when spent. - // - "usdv": always pay gas in USDV (phase-0 transfer to the payer). - const doSponsoredSign = async () => { - if (!acct || !txSigner || !callsValid) return; - const sessionPolicy = activeSessionKey?.policy; - if (txIsSession && gasMode !== 'free') { - surfaceSendError('Session keys can only send sponsored (free) transactions. Switch gas to Sponsored.'); - return; - } - if (txIsSession && !acct.deployed && !activeSessionKey?.pendingAuth) { - surfaceSendError('Authorize this session key with an owner key first (Apply now).'); - return; - } - setSigning(true); - setError(''); - setInfoMsg(''); - setSeqRecovery(null); - // Clear any prior "would revert" block; a fresh estimate re-sets it if needed. - setEstimateBlocked(null); - blockOnRevertRef.current = true; // transact send: surface a reverting estimate - // Captured for sequence-mismatch recovery in the catch (what this tx carried). - let seqCtx: { sessionIds: string[]; hasOwner: boolean } = { sessionIds: [], hasOwner: false }; - 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, - })); - const terms = await payerClient.getTerms({ - chainId: toHex(chain.id || 84538453), - from: acct.address, - calls: rpcCalls, - gasLimit: toHex(BigInt(gasEstimate || 200_000)), - context: { flow: 'transact' }, - }); - - let selToken: Address | undefined; - if (gasMode === '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 = gasMode === 'free' ? terms.options.find(isDeclinedOffer) : undefined; - const { option, tokenChoice } = selectPaymentOption(terms, selToken ? { token: selToken } : {}); - - let phase0: { to: Address; data: Hex }[] | undefined; - let gasNote: string; - 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`; - } else { - gasNote = 'Sponsored by vibenet payer · free grant'; - } - - 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 }, - ); - const cosigned = await payerClient.signTransaction({ - signedTransaction: serialized, - context: { flow: 'transact' }, - }); - const finalTx = (cosigned.signedTransaction ?? serialized) as Hex; - - let txHash: Hex; - let pending = false; - try { - txHash = await broadcast8130(finalTx, setSubmitStatus); - } catch (err) { - if (err instanceof TxPendingError) { - txHash = err.txHash; - pending = true; - } else throw err; - } - if (!pending) applyLandedBundle(acct, nextSeq, bundle); - recordResult(acct, finalTx, txHash, pending, txSigner, gasNote, extra); - } catch (err) { - if (handleSeqMismatch(err, seqCtx)) return false; - 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, - ), - ); - return false; - } finally { - setSigning(false); - setSubmitStatus(''); - overrideEstimateRef.current = false; // one-shot "Send anyway" - blockOnRevertRef.current = false; - } + const contentKey = transactionRequest?.contentKey ?? activeAccountKey; + const openTransaction = (preset?: TransactPreset) => { + setTransactionRequest({ preset, contentKey: activeAccountKey }); }; - - const confirmSend = async () => { - setError(''); - setTxStep('submitted'); - await (gasMode === 'eth' ? doSignNative() : doSponsoredSign()); + const openApply = (applyTarget: ApplyTarget) => { + setTransactionRequest({ applyTarget, contentKey: activeAccountKey }); }; - // --- calls editor handlers --------------------------------------------- - const clearResult = () => setResult(null); - const setRow = (id: string, patch: Partial) => { - setCalls((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))); - clearResult(); - }; - const addRow = (partial?: Partial) => { - setCalls((prev) => [...prev, newCallRow(partial)]); - clearResult(); - }; - const addEthRow = () => addRow({ phase: 1 }); const resolveUsdvAddress = async (): Promise
=> { const status = await vibenetApi.faucet.status().catch(() => null); const a = status?.usdv_address; return a && isAddressStr(a) ? (a as Address) : null; }; - const addUsdvRow = async () => { - const USDV = (await resolveUsdvAddress()) ?? '0x9A676e781A523b5d0C0e43731313A708CB607508'; - const PLACEHOLDER = '0x0000000000000000000000000000000000000001'; - addRow({ to: USDV, data: encodeUsdvTransfer(PLACEHOLDER, 1_000_000n), phase: 1 }); - }; - const removeRow = (id: string) => { - setCalls((prev) => (prev.length > 1 ? prev.filter((r) => r.id !== id) : prev)); - clearResult(); - }; - const switchRowToUsdv = async (id: string) => { - const USDV = (await resolveUsdvAddress()) ?? '0x9A676e781A523b5d0C0e43731313A708CB607508'; - const PLACEHOLDER = '0x0000000000000000000000000000000000000001'; - setRow(id, { to: USDV, data: encodeUsdvTransfer(PLACEHOLDER, 1_000_000n), value: '0' }); - }; - const switchRowToEth = (id: string) => { - setUsdvRecipientDrafts((d) => { - const n = { ...d }; - delete n[id]; - return n; - }); - setUsdvAmountDrafts((d) => { - const n = { ...d }; - delete n[id]; - return n; - }); - setRow(id, { to: '', data: '0x', value: '0' }); - }; - const startSend = () => { - if (!callsValid || !txSigner) return; - setError(''); - setResult(null); - setTxStep('review'); - }; - - // Reset the Transact modal's builder/review state to its defaults. Called on - // open, not close: resetting in `onClose` swaps Review back to the builder - // while the dialog is still animating out. Open always rebuilds from a clean - // slate (or a preset), so a prior "Batched Calls" demo cannot bleed into a - // later "Create Transaction". - const resetTransactBuilder = () => { - setCalls([newCallRow()]); - setCallsAdvanced(false); - setUsdvRecipientDrafts({}); - setUsdvAmountDrafts({}); - setMetaField(''); - setGasMode('eth'); - setTxSignerId(null); - setResult(null); - setError(''); - setTxStep('build'); - }; - - // Open the Transact modal. With no `preset`, it opens on the calls builder - // (the normal "Create Transaction" flow). Passing a `preset` — a - // fully-specified transaction — loads it straight into the builder state and - // jumps directly to the Review step, skipping the builder entirely. - const openTransactModal = (preset?: { calls: CallRow[]; gasMode?: 'eth' | 'free' | 'usdv'; metadata?: string }) => { - resetTransactBuilder(); - if (preset) { - setCalls(preset.calls); - if (preset.gasMode) setGasMode(preset.gasMode); - setMetaField(preset.metadata ?? ''); - setTxStep('review'); - } - setTransactModalOpen(true); - }; - - // Pick which key signs the pending send — an owner key (current or - // post-change) or a session key. Selecting an owner key also makes it the - // active signer for config changes. Shared by the builder's Signer field and - // the Review step's signing row. - const selectSigner = (id: string) => { - setTxSignerId(id); - if (ownerSigners.some((s) => s.id === id)) setActiveSignerId(id); - }; - - // Demo for the "Batched Calls" feature card: two unrelated actions — an ETH - // send and a USDV send — atomically in one transaction. + // Demo for the "Batched Calls" feature card: an ETH send and a USDV send, + // atomically in one transaction. const sendBatchedCallsDemo = async () => { trackAccountAction('batched_calls'); const USDV = (await resolveUsdvAddress()) ?? '0x9A676e781A523b5d0C0e43731313A708CB607508'; - openTransactModal({ + openTransaction({ calls: [ newCallRow({ to: '0x0000000000000000000000000000000000000001', value: '0.001', data: '0x' }), newCallRow({ to: USDV, value: '0', data: encodeUsdvTransfer('0x0000000000000000000000000000000000000002', 1_000_000n) }), @@ -598,13 +105,12 @@ export function AccountDemo() { }); }; - // Demo for the "Pay Gas in Any Token" feature card: the same atomic batch as - // above (an ETH send + a USDV send), but with the transaction's own gas paid - // in USDV rather than ETH — no ETH required in the account. + // Demo for the "Pay Gas in Any Token" feature card: the same atomic batch, but + // with the transaction's own gas paid in USDV rather than ETH. const sendGasTokenDemo = async () => { trackAccountAction('gas_token'); const USDV = (await resolveUsdvAddress()) ?? '0x9A676e781A523b5d0C0e43731313A708CB607508'; - openTransactModal({ + openTransaction({ calls: [ newCallRow({ to: '0x0000000000000000000000000000000000000001', value: '0.001', data: '0x' }), newCallRow({ to: USDV, value: '0', data: encodeUsdvTransfer('0x0000000000000000000000000000000000000002', 1_000_000n) }), @@ -614,46 +120,33 @@ export function AccountDemo() { }); }; - // Unsubscribe from a session-key app card. Revoking a landed key is a config - // change that must be signed AND applied on-chain, so once it's staged we open - // the account modal on the Session Keys tab — making the required "Apply now" - // step obvious rather than silently leaving a pending revoke. (A never-landed - // key is just discarded, so there's nothing to apply and no modal to open.) + // 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') { - setCfgTab('session'); - setDetailsOpen(true); - } + if (outcome === 'staged' || outcome === 'noop') openApply({ session: sk.id }); }; - // --- apps directory ---------------------------------------------------- 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: drop the sub's - // own selectable account record (also unlinks it from the parent's - // `subAccounts` — see `deleteAccount`). + // "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, authorize it with the app's - // policy (owner-signed, immediate), and broadcast so it's bound on-chain. + // 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); - setError(''); - // Track the freshly minted app key so we can discard it if the authorize tx - // never lands — `mintAppKey` persists the signer up front, and `commit()` - // (below) is what actually marks the account subscribed/deployed. Cleared - // only once the tx has landed and been committed. let mintedKeyId: string | null = null; try { const target = mintAppKey(app.name); if (!target) { - surfaceSendError("Couldn't mint an app key — try again."); + toast.error("Couldn't mint an app key — try again."); return; } mintedKeyId = target.id; @@ -664,25 +157,17 @@ export function AccountDemo() { spec: app.spec?.(acct.address) ?? {}, label: app.name, chainShort: chain.shortName, - defer: false, }); - if (sk?.serialized) { - // broadcast8130 throws on an on-chain/phase revert or timeout, so we only - // reach commit() when the authorize+install actually landed. - const txHash = await broadcast8130(sk.serialized, setSubmitStatus); - sk.commit?.(); + if (sk) { mintedKeyId = null; - setConfigTx({ hash: txHash, label: `Connected: ${app.name}` }); + openApply({ session: sk.id }); } } catch (err) { const e = err as { message?: string; name?: string }; - surfaceSendError(e.name === 'NotAllowedError' ? 'Signature was dismissed.' : (e.message ?? String(err))); + toast.error(e.name === 'NotAllowedError' ? 'Signature was dismissed.' : (e.message ?? String(err))); } finally { - // Revert/timeout/dismiss (or a null sign result): drop the orphaned app key - // so the card stays on "Subscribe" and no stray signer lingers. - if (mintedKeyId) setSigners((prev) => prev.filter((s) => s.id !== mintedKeyId)); + if (mintedKeyId) deleteSigner(mintedKeyId); setAppBusy(null); - setSubmitStatus(''); } }; @@ -691,11 +176,10 @@ export function AccountDemo() { const connectVault = (app: DemoApp) => { if (!acct) return; setAppBusy(app.id); - setError(''); try { doCreateSubAccount(app.name, { withSpareKey: true }); } catch (err) { - surfaceSendError((err as { message?: string }).message ?? String(err)); + toast.error((err as { message?: string }).message ?? String(err)); } finally { setAppBusy(null); } @@ -703,107 +187,46 @@ export function AccountDemo() { return ( <> - { - setDetailsOpen(false); - openTransactModal(); - }} - activity={} - activityCount={activity.length} - activityEmptyMessage="No activity yet. Transactions and account changes will appear here." - className="gap-10" - > - {FEATURES.map((feature) => ( - - ))} - - Features - - - {/* Transact + the app cards, as peers in one grid. `initial={false}`: - the crossfade is for switching accounts, not first paint. */} - - - {renderSponsorship()} - {renderBatchedCalls()} - {renderGasToken()} - {renderOwners()} - {renderTransact()} - {renderApps()} - - - - {estimateBlocked ? ( -
- {conciseError(estimateBlocked)} - - Estimation reverted, so this will likely fail on-chain. - -
- - -
-
- ) : null} - - {seqRecovery ? ( -
- - This {seqRecovery.what} is out of sequence — the account's config changed since it was - signed, so it can't land as-is. Re-sign it at the current sequence, or drop it. - -
- - -
-
- ) : null} + } + activityCount={activity.length} + activityEmptyMessage="No activity yet. Transactions and account changes will appear here." + className="gap-10" + > + {FEATURES.map((feature) => ( + + ))} + + Features + - {infoMsg ? ( -

- {infoMsg} - -

- ) : null} -
+ + + {renderSponsorship()} + {renderBatchedCalls()} + {renderGasToken()} + {renderOwners()} + {renderTransact()} + {renderApps()} + + +
+ + {transactionRequest ? ( + setTransactionRequest(null)} + preset={transactionRequest.preset} + applyTarget={transactionRequest.applyTarget} + /> + ) : null} - 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; they've been marked undeployed and will redeploy on their next + transaction. @@ -846,6 +269,8 @@ export function AccountDemo() { connectSessionApp={connectSessionApp} connectVault={connectVault} unsubscribeApp={unsubscribeApp} + reviewSessionApp={(sessionKey) => openApply({ session: sessionKey.id })} + undoSessionRevoke={undoStagedRevoke} deleteVault={deleteVault} /> ))} @@ -854,219 +279,33 @@ export function AccountDemo() { } function renderTransact() { - if (!acct) return ( - - ); - return ( - <> - - - - } + if (!acct) + return ( + - - - - { - if (signing) return; // don't let Escape/backdrop abandon an in-flight send - setTransactModalOpen(false); - }} - step={txStep} - busy={signing} - error={error} - result={result} - titles={{ build: 'Create Transaction', review: 'Review Transaction', submitted: 'Submitted' }} - buildInfo={ - - {chain.mode === 'eip8130-native' ? 'native 8130' : 'ERC-4337'} · 1 tx · ~ - {gasEstimate.toLocaleString()} gas - {!acct.deployed - ? acct.type === 'eoa' - ? ' · first use delegates your EOA' - : ' · first use deploys your account' - : ''} - - } - canProceed={callsValid && !!txSigner} - proceedLabel="Review" - onProceed={startSend} - confirmLabel="Send" - onConfirm={confirmSend} - onReviewBack={() => { - setTxStep('build'); - setError(''); - }} - onSubmittedBack={() => { - setTxStep('review'); - setError(''); - }} - onRetry={confirmSend} - onDone={() => { - setTransactModalOpen(false); - resetTransactBuilder(); - }} - explorerTxPath={(hash) => `${VIBENET_EXPLORER_PATH}/tx/${hash}`} - reviewBody={} - reviewInfo={} - submittedBody={ - - } - buildBody={ - <> - {/* From */} -
- From - setActiveAccountId(id)} - triggerClassName="w-full" - /> -
- - {/* Signer */} -
- Signer - {signableSigners.length > 1 ? ( - ({ - value: c.shortName, - label: `${c.name} ${c.mode === 'eip8130-native' ? '· 8130' : '· 4337'}`, - }))} - /> -
- ) : null} - - {/* Calls */} -
- -
- - {/* Metadata */} -
-
- Metadata - Top-Level · Signed -
- setMetaField(e.target.value)} - className="w-full rounded-lg border border-bds-gray-10 bg-background px-3.5 py-2.5 text-[14px] outline-none transition-colors placeholder:text-bds-gray-40 focus:border-base-blue dark:border-white/10 dark:bg-white/5" - /> - {metadataHex ? ( -

- → {short(metadataHex, 14, 8)} -

- ) : null} -
- - {/* Gas */} -
- Gas - { - const val = e.target.value; - setUsdvAmountDrafts((d) => ({ ...d, [r.id]: val })); - try { - const amt = parseUnits(val || '0', USDV_DECIMALS); - const rec = isAddressStr(recipientDisplay) ? recipientDisplay : usdv.recipient; - setRow(r.id, { data: encodeUsdvTransfer(rec, amt) }); - } catch { - /* ignore */ - } - }} - onBlur={() => - setUsdvAmountDrafts((d) => { - const n = { ...d }; - delete n[r.id]; - return n; - }) - } - /> -
- - {calls.length > 1 && removeRow(r.id)} disabled={false} />} - - ); - } - return ( -
  • - setRow(r.id, { to })} - accounts={addressBook} - /> - - {calls.length > 1 && removeRow(r.id)} disabled={false} />} -
  • - ); - })} - -
    - Add Call: - - - -
    - - ) : ( - <> -
      - - {calls.map((r, i) => ( -
    • - - setRow(r.id, { to: e.target.value })} - /> - setRow(r.id, { value: e.target.value })} - /> - setRow(r.id, { data: e.target.value })} - /> - {calls.length > 1 && removeRow(r.id)} disabled={false} />} -
    • - ))} -
    -
    - - {!callsValid ? ( - - Check call fields — “to” must be a 20-byte hex address, calldata must be hex. - - ) : null} -
    - - )} - - ); -} - -function RemoveRowButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) { - return ( - - ); -} - -type ReviewBodyProps = { - acct: StoredAccount; - accounts: StoredAccount[]; - calls: CallRow[]; - metaField: string; -}; - -function gasLabelFor(gasMode: 'eth' | 'free' | 'usdv'): string { - return gasMode === 'eth' ? 'Pay in ETH' : gasMode === 'free' ? 'Sponsored' : 'USDV · payer'; -} - -// Gas estimate + who's sponsoring + signer, shown in the transaction modal's -// review footer row (next to Back / Send). The signer is picked in the build -// step, so this is a compact read-only summary. -function ReviewFooterInfo({ - gasEstimate, - gasMode, - txSigner, -}: { - gasEstimate: number; - gasMode: 'eth' | 'free' | 'usdv'; - txSigner: WalletSigner | null; -}) { - return ( -
    - ~{gasEstimate.toLocaleString()} gas - {gasLabelFor(gasMode)} - {txSigner ? ( - - - {txSigner.label} - - ) : null} -
    - ); -} - -function ReviewBody({ acct, accounts, calls, metaField }: ReviewBodyProps) { - // Resolve a destination address to a locally-known account's label, if any - // (e.g. "your account" or another account you hold), so recipients read as - // more than an opaque hex string. - const addressLabel = (address: string) => - accounts.find((a) => a.address.toLowerCase() === address.toLowerCase())?.label; - const AddressChip = ({ address }: { address: string }) => { - const label = addressLabel(address); - return ( - - {label ? `${label} · ` : ''} - {short(address)} - - ); - }; - return ( -
    - {!acct.deployed ? ( -
    - {acct.type === 'eoa' ? 'Delegate' : 'Deploy'} - - {acct.type === 'eoa' - ? 'First use — this also delegates your EOA to the account contract.' - : 'First use — this also deploys your account on-chain.'} - -
    - ) : null} - -
      - {calls.map((r, i) => { - const usdv = tryDecodeUsdvTransfer(r); - const ethValue = r.value.trim() && r.value.trim() !== '0' ? r.value.trim() : null; - const isPlainCall = !usdv && !ethValue && r.data.trim() && r.data.trim() !== '0x'; - return ( - - {usdv ? ( - <> - Send {formatTokenAmount(usdv.amount, USDV_DECIMALS)} USDV - - - - ) : ethValue ? ( - <> - Send {ethValue} ETH - - - - ) : ( - <> - {isPlainCall ? 'Call' : 'No-op call'} - - - {isPlainCall ? ( - - {short(r.data.trim(), 8, 4)} - - ) : null} - - )} - - ); - })} - {metaField.trim() ? ( -
    • - Metadata - {metaField.trim()} -
    • - ) : null} -
    -
    - ); -} - -type SubmittedResult = { - serialized?: Hex; - txHash?: Hex; - by: string; - kind: SignerKind; - gasNote?: string; - pending?: boolean; -} | null; - -// Third stage of the Transact modal: shown once "Send" is pressed. Renders the -// in-flight signing/broadcast status, then success or error. Outcomes also -// fire a sonner toast so the result is visible if the modal is dismissed. -function SubmittedBody({ - signing, - submitStatus, - error, - result, -}: { - signing: boolean; - submitStatus: '' | 'submitting' | 'confirming'; - error: string; - result: SubmittedResult; -}) { - if (signing) { - return ( -
    - - - {submitStatus === 'confirming' - ? 'Waiting for confirmation…' - : submitStatus === 'submitting' - ? 'Submitting transaction…' - : 'Waiting for signature…'} - -
    - ); - } - - if (error) { - return ( -
    - - - {error} - -
    - ); - } - - return ( -
    - - - - - {result?.pending ? 'Submitted — awaiting confirmation' : 'Transaction landed onchain'} - - {result?.pending ? ( - - Broadcast but not yet included — check the explorer for status. - - ) : null} - {result?.gasNote ? ( - {result.gasNote} - ) : null} - {result?.txHash ? ( - - {short(result.txHash)} - - ) : null} -
    - ); -} diff --git a/app/vibenet/demos/account/components/AccountDetailsModal.tsx b/app/vibenet/demos/account/components/AccountDetailsModal.tsx deleted file mode 100644 index 9ddfe51..0000000 --- a/app/vibenet/demos/account/components/AccountDetailsModal.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -// Account Details modal (owners / session keys / sub-accounts / assets). Shared -// by every demo that manages local EIP-8130 accounts — driven entirely off an -// `AccountEngine` (see `useAccountEngine`), so the same manager UI works -// wherever the account dropdown appears. - -import { Modal } from '../../../../components/ui/Modal'; -import type { AccountEngine } from '../useAccountEngine'; -import { ConfigView } from './ConfigView'; - -type AccountDetailsModalProps = { - engine: AccountEngine; - /** "Jump to Transact" — page-specific navigation, omitted where there is no - * Transact modal (e.g. B20), which hides the button entirely. */ - onTransact?: () => void; -}; - -export function AccountDetailsModal({ engine, onTransact }: AccountDetailsModalProps) { - const e = engine; - return ( - e.setDetailsOpen(false)} title="Account Details" className="max-w-lg"> - {e.acct ? ( - - ) : null} - - ); -} diff --git a/app/vibenet/demos/account/components/ActivityLog.tsx b/app/vibenet/demos/account/components/ActivityLog.tsx index 28133a1..e366135 100644 --- a/app/vibenet/demos/account/components/ActivityLog.tsx +++ b/app/vibenet/demos/account/components/ActivityLog.tsx @@ -9,6 +9,7 @@ import { VIBENET_EXPLORER_PATH } from '../../../library/config'; import { type ActivityEntry, type StoredAccount, formatTime } from '../library/model'; import { short } from '../shared'; import { AccountIdentity, Badge } from '../../_shared/primitives'; +import { ViewTransactionButton } from '../../_shared/ViewTransactionButton'; const TX_HASH_RE = /^0x[0-9a-fA-F]{64}$/; @@ -109,9 +110,7 @@ export function ActivityLog({ activity, accounts }: { activity: ActivityEntry[]; {txHash ? ( - - - + ) : null} @@ -174,9 +173,7 @@ export function ActivityLog({ activity, accounts }: { activity: ActivityEntry[];
    {txHash ? ( - - - + ) : null}
    diff --git a/app/vibenet/demos/account/components/AppsView.tsx b/app/vibenet/demos/account/components/AppsView.tsx index 2c49405..b57f806 100644 --- a/app/vibenet/demos/account/components/AppsView.tsx +++ b/app/vibenet/demos/account/components/AppsView.tsx @@ -41,6 +41,8 @@ type AppCardProps = { connectSessionApp: (app: DemoApp) => void; connectVault: (app: DemoApp) => void; unsubscribeApp: (sk: AppSessionKey) => void; + reviewSessionApp: (sk: AppSessionKey) => void; + undoSessionRevoke: (sessionKeyId: string) => void; deleteVault: (sub: AppSubAccount) => void; }; @@ -79,20 +81,41 @@ export function AppCard(p: AppCardProps) { if (app.id === 'monthly-vibes') { if (sk) { connected = true; + const pendingLabel = sk.pendingAuth ? 'Pending authorization' : sk.pendingRevoke ? 'Pending revoke' : null; footer = ( <> - Active + {pendingLabel ?? 'Active'} {sk.policy?.params ?? 'capped'} · {formatExpiry(sk.expiry)} - + {pendingLabel ? ( + + ) : ( + + )} + {sk.pendingAuth ? ( + + ) : sk.pendingRevoke ? ( + + ) : null} ); } else { diff --git a/app/vibenet/demos/account/components/ConfigView.tsx b/app/vibenet/demos/account/components/ConfigView.tsx deleted file mode 100644 index 243f318..0000000 --- a/app/vibenet/demos/account/components/ConfigView.tsx +++ /dev/null @@ -1,848 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import type { Address, Hex } from '@aa'; -import Link from 'next/link'; -import { AnimatePresence, motion } from 'motion/react'; - -import { Button } from '../../../../components/ui/Button'; -import { Card } from '../../../../components/ui/Card'; -import { cn } from '../../../../components/ui/cn'; -import { CloseIcon } from '../../../../components/ui/icons'; -import { Spinner } from '../../../../components/ui/Spinner'; -import { Select } from '../../../../components/ui/Select'; -import { Text } from '../../../../components/ui/Text'; -import { VIBENET_EXPLORER_PATH } from '../../../library/config'; -import { AnimatedAmount } from '../../_components/AnimatedAmount'; -import { - DEMO_CHAINS, - getDemoChain, -} from '../library/chains'; -import { - EXPIRY_PRESETS, - formatEthWei, - formatExpiry, - scopeChips, -} from '../library/model'; -import { - type LimitDraft, - OWNER_SCOPE_PRESETS, - PERIOD_PRESETS, - periodLabel, - scopeLabel, - SELECTOR_PRESETS, - stableSymbol, -} from '../library/policy'; -import { formatTokenAmount, KIND_LABEL, short, signerIdentity } from '../shared'; -import { AccountAvatar, AccountIdentity, Badge, CheckIcon, KindBadge } from '../../_shared/primitives'; -import type { AccountEngine } from '../useAccountEngine'; - -const ADDR_RE = /^0x[0-9a-fA-F]{40}$/; -const TX_HASH_RE = /^0x[0-9a-fA-F]{64}$/; -const INPUT_CLS = - 'w-full rounded-lg border border-bds-gray-10 bg-background px-3 py-2 text-[13px] outline-none transition-colors placeholder:text-bds-gray-40 focus:border-base-blue dark:border-white/10 dark:bg-white/5'; -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'; - -type CfgTab = 'assets' | 'owners' | 'session' | 'subaccounts'; - -export type ConfigViewProps = { - engine: AccountEngine; - /** Omitted where there is no Transact modal (e.g. B20) — hides the button. */ - onTransact?: () => void; -}; - -type ConfigViewModel = Omit & { - acct: NonNullable; - onTransact?: () => void; -}; - -// Config view for a selected account: hero + tabbed Assets / Owners / Session -// keys / Sub-accounts. The owner-change and session-key flows sign + broadcast -// through the parent's handlers. -export function ConfigView({ engine, onTransact }: ConfigViewProps) { - const { acct } = engine; - if (!acct) return null; - const p: ConfigViewModel = { ...engine, acct, onTransact }; - const tabs: { id: CfgTab; label: string; count: number | null }[] = [ - { id: 'assets', label: 'Assets', count: null }, - { id: 'owners', label: 'Owners', count: acct.owners.length }, - { id: 'session', label: 'Session keys', count: acct.sessionKeys.length }, - { id: 'subaccounts', label: 'Sub-accounts', count: acct.subAccounts.length }, - ]; - return ( -
    - {/* Hero */} -
    - - {acct.type === 'eoa' ? EOA : null} - {acct.deployed ? Deployed : null} - } - onCopy={() => p.copy(acct.address, 'cfg')} - copied={p.copied === 'cfg'} - className="min-w-0 flex-1" - /> -
    - {p.onTransact ? ( - - ) : null} - -
    -
    - - {/* Tab bar */} -
    - {tabs.map((t) => { - const active = p.cfgTab === t.id; - return ( - - ); - })} -
    - - - - {p.cfgTab === 'assets' ? : null} - {p.cfgTab === 'owners' ? : null} - {p.cfgTab === 'session' ? : null} - {p.cfgTab === 'subaccounts' ? : null} - - -
    - ); -} - -function AssetsTab({ p }: { p: ConfigViewModel }) { - const [faucetDone, setFaucetDone] = useState(false); - const prevBusy = useRef(p.faucetBusy); - useEffect(() => { - if (prevBusy.current && !p.faucetBusy) { - setFaucetDone(true); - const t = setTimeout(() => setFaucetDone(false), 700); - return () => clearTimeout(t); - } - prevBusy.current = p.faucetBusy; - }, [p.faucetBusy]); - - const cleanName = (s: string) => s.replace(/\s*devnet\s*$/i, '').trim(); - const rows = DEMO_CHAINS.map((c) => ({ - net: c.shortName, - name: cleanName(c.name), - faucet: c.shortName === 'vibenet', - })); - const assets: { key: string; symbol: string; fullName: string; balance: string; faucet: boolean }[] = []; - for (const r of rows) { - const b = p.assetBals[r.net]; - const stable = b?.usdv_symbol ?? (r.net === 'vibenet' ? 'USDV' : 'USDC'); - assets.push({ - key: `${r.net}-eth`, - symbol: 'ETH', - fullName: 'Ether', - balance: p.assetsLoading ? '…' : formatEthWei(b?.eth_wei), - faucet: r.faucet, - }); - assets.push({ - key: `${r.net}-stable`, - symbol: stable, - fullName: stable === 'USDV' ? 'Vibenet USD' : 'USD Coin', - balance: p.assetsLoading ? '…' : formatTokenAmount(b?.usdv, b?.usdv_decimals), - faucet: r.faucet, - }); - } - const hasFaucet = assets.some((a) => a.faucet); - return ( -
    -
      - {assets.map((a) => ( -
    • -
    • - ))} -
    - {hasFaucet ? ( -
    - -
    - ) : null} -
    - ); -} - -function OwnersTab({ p }: { p: ConfigViewModel }) { - const { acct } = p; - const isEoaSelf = (signerId: string) => - acct.type === 'eoa' && signerId === acct.initialActors[0]?.signerId; - const draftActors = acct.owners - .filter((o) => p.ownerDraft.includes(o.signerId)) - .map((o) => ({ id: o.signerId, kind: o.kind, label: o.label, identity: o.identity, applied: o.scope ?? 0 })) - .concat( - p.pendingAuthorize.map((s) => ({ - id: s.id, - kind: s.kind, - label: s.label, - identity: signerIdentity(s), - applied: 0, - })), - ); - const addable = p.signers.filter((s) => !p.ownerDraft.includes(s.id)); - const showApply = p.ownersEditing || p.keyChangeCount > 0 || p.ownerChangeSigned; - - return ( -
    - {!p.ownersEditing ? ( - <> -
    - {acct.owners.map((o) => ( -
    -
    -
    - {o.label} - {isEoaSelf(o.signerId) ? EOA : null} -
    - {short(o.identity)} -
    -
    - - {(o.scope ?? 0) === 0 ? ( - - - Full Control - - ) : ( - scopeChips(o.scope ?? 0).map((c) => ( - - {c} - - )) - )} -
    -
    - ))} -
    -
    - -
    - - ) : ( - <> -
      - {draftActors.map((o) => { - const isNew = p.pendingAuthorize.some((s) => s.id === o.id); - const canRevoke = draftActors.length > 1; - const eoaSelf = isEoaSelf(o.id); - const curScope = p.scopeDraft[o.id] ?? o.applied; - const curPreset = OWNER_SCOPE_PRESETS.find((pp) => pp.scope === curScope)?.id ?? 'full'; - const scopeChanged = curScope !== o.applied; - return ( -
    • - - {o.label} - {eoaSelf ? EOA : null} - {short(o.identity)} -
      - { - const isOwner = acct.owners.some((o) => o.actorId === s.actorId); - const isSession = acct.sessionKeys.some((sk) => sk.actorId === s.actorId); - return { - value: s.id, - disabled: isOwner || isSession, - label: `${s.label}${isOwner ? ' — owner' : isSession ? ' — session key' : ''}`, - }; - })} - /> - - -
      - - {/* Spend Limits */} -
      - Spend Limits - {p.skLimits.length === 0 ? ( - No spend cap on this key. - ) : null} - {p.skLimits.map((l) => { - const customOk = l.token !== 'custom' || ADDR_RE.test(l.custom.trim()); - return ( -
      -
      - p.patchLimit(l.id, { custom: e.target.value })} - /> - ) : null} - p.patchLimit(l.id, { amount: e.target.value })} - /> -
      - p.patchScope(s.id, { target: e.target.value })} - /> - -
      -
      - - {SELECTOR_PRESETS.map((sp) => ( - - ))} -
      -
      - ); - })} -
      - -
      -
      - -
      -

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

      - - -
      -
      - ); -} - -function SubAccountsTab({ p }: { p: ConfigViewModel }) { - const { acct } = p; - const [creating, setCreating] = useState(false); - return ( -
      - - Spin up a delegated account with its own address, controlled by this one. - - {acct.subAccounts.length > 0 ? ( -
      - {acct.subAccounts.map((sa) => ( -
      - - delegate → {short(sa.delegateTo, 6, 4)} -
      - ))} -
      - ) : null} - - {creating ? ( -
      - - Create a Sub-Account - -

      - A delegated account with its own address, controlled by this account via{' '} - key.delegate(parent). -

      - p.setSaLabel(e.target.value)} - /> -
      - - -
      -
      - ) : ( -
      - -
      - )} -
      - ); -} diff --git a/app/vibenet/demos/account/components/CreateAccountModal.tsx b/app/vibenet/demos/account/components/CreateAccountModal.tsx index 71c0046..4e2e33f 100644 --- a/app/vibenet/demos/account/components/CreateAccountModal.tsx +++ b/app/vibenet/demos/account/components/CreateAccountModal.tsx @@ -5,7 +5,12 @@ // 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. -// Driven off the create-modal slice of `useAccountEngine`'s return value. +// +// 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'; import { cn } from '../../../../components/ui/cn'; @@ -13,11 +18,13 @@ import { Text } from '../../../../components/ui/Text'; import { Modal } from '../../../../components/ui/Modal'; import { KIND_LABEL, short, signerIdentity, type CreateMode, type WalletSigner } from '../shared'; import { CheckIcon, KindBadge, TrashIcon } from '../../_shared/primitives'; -import type { SignerKind } from '../library/model'; -import type { AccountEngine } from '../useAccountEngine'; +import { actorPairs, normalizeSalt, randomHex32, sortActors, toStoredActor } from '../library/derive'; +import type { AccountType, SignerKind, StoredAccount } from '../library/model'; +import { useAccountEngine } from '../useAccountEngine'; type CreateAccountModalProps = { - engine: AccountEngine; + open: boolean; + onClose: () => void; }; const MODES: ReadonlyArray = [ @@ -26,34 +33,182 @@ const MODES: ReadonlyArray = [ ['advanced', 'Advanced', 'Pick type, keys & salt'], ]; -export function CreateAccountModal({ engine }: CreateAccountModalProps) { +export function CreateAccountModal({ open, onClose }: CreateAccountModalProps) { const { - modalOpen, - setModalOpen, - createMode, - setCreateMode, - suggestedName, - modalType, - setModalType, - modalLabel, - setModalLabel, - modalSalt, - setModalSalt, - modalIds, - setModalIds, - modalEoaId, - setModalEoaId, signers, - eoaSigners, - modalSigners, - modalAddress, + accounts, + addAccount, + chain, + code, busy, usedSignerIds, deleteSigner, createSigner, - createAccount, - randomizeCreateSalt, - } = engine; + pushActivity, + autoFundNewAccount, + } = useAccountEngine(); + + const [createMode, setCreateMode] = useState('default'); + const [modalType, setModalType] = useState('eoa'); + const [modalLabel, setModalLabel] = useState(''); + const [modalSalt, setModalSalt] = useState(() => randomHex32()); + const [modalIds, setModalIds] = useState([]); + const [modalEoaId, setModalEoaId] = useState(null); + + // Reset to the one-click default each time the modal opens. + useEffect(() => { + if (!open) return; + setCreateMode('default'); + setModalType('eoa'); + setModalLabel(''); + setModalSalt(randomHex32()); + setModalIds([]); + setModalEoaId(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 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]); + + // Keep the auto-suggested fallback name unique (Default, Default 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)); + if (!taken.has(base)) return base; + for (let n = 2; ; n++) { + const candidate = `${base} ${n}`; + if (!taken.has(candidate)) return candidate; + } + }; + + // 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, + 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', + 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')); + if (!signer) return; + const saltField = randomHex32(); + if (buildSmartAccount([signer], normalizeSalt(saltField), saltField, name)) onClose(); + }; const busyCreating = busy !== null; const canCreate = createMode === 'advanced' ? Boolean(modalAddress) : true; @@ -64,12 +219,12 @@ export function CreateAccountModal({ engine }: CreateAccountModalProps) { return ( setModalOpen(false)} + open={open} + onClose={onClose} title="Create Account" footer={ <> - + +
      + + {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 new file mode 100644 index 0000000..c2bc489 --- /dev/null +++ b/app/vibenet/demos/account/components/TransactionModal.tsx @@ -0,0 +1,1465 @@ +'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. +// +// Driven by declarative open/request 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, + parseUnits, + privateKeyToAccount, + selectPaymentOption, + toHex, +} from '@aa'; +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; + +import { Button } from '../../../../components/ui/Button'; +import { cn } from '../../../../components/ui/cn'; +import { CloseIcon } from '../../../../components/ui/icons'; +import { Modal } from '../../../../components/ui/Modal'; +import { Select } from '../../../../components/ui/Select'; +import { Spinner } from '../../../../components/ui/Spinner'; +import { Tabs } from '../../../../components/ui/Tabs'; +import { Text } from '../../../../components/ui/Text'; +import { vibenetApi } from '../../../library/client'; +import { VIBENET_EXPLORER_PATH } from '../../../library/config'; +import { AccountSwitcher } from '../../_shared/AccountSwitcher'; +import { AddressAutocomplete, type AddressBookEntry } from '../../_shared/AddressAutocomplete'; +import { Badge, CheckIcon, KindBadge } from '../../_shared/primitives'; +import { ViewTransactionButton } from '../../_shared/ViewTransactionButton'; +import { DEMO_CHAINS, estimateTxGas, PAYER_URL } from '../library/chains'; +import { + buildCalls, + type CallRow, + encodeUsdvTransfer, + isAddressStr, + newCallRow, + rowToValid, + tryDecodeUsdvTransfer, + 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'; + +const INPUT_CLS = + 'w-full rounded-lg border border-bds-gray-10 bg-bds-gray-0 px-3 py-2 text-[13px] outline-none transition-colors placeholder:text-bds-gray-40 focus:border-foreground dark:border-white/10 dark:bg-white/5 dark:focus:border-bds-blue-40'; + +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 }; + +type TransactionModalProps = { + onClose: () => void; + preset?: TransactPreset; + applyTarget?: ApplyTarget; +}; + +export function TransactionModal({ onClose, preset, applyTarget }: TransactionModalProps) { + const engine = useAccountEngine(); + const { + acct, + accounts, + activeAccountId, + setActiveAccountId, + addressBook, + networkShort, + setNetworkShort, + chain, + activeSignerId, + setActiveSignerId, + activeSigner, + ownerSigners, + sessionSigners, + postChangeOwnerSigners, + pendingAuthorize, + pendingRevoke, + pendingScope, + keyChangeCount, + broadcast8130, + 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 [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 [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', + ); + const [result, setResult] = useState<{ + serialized?: Hex; + txHash?: Hex; + by: string; + kind: SignerKind; + gasNote?: string; + pending?: boolean; + } | null>(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), + [metaField], + ); + const gasEstimate = useMemo(() => { + if (!acct) return 0; + return estimateTxGas({ + mode: chain.mode, + deploy: !acct.deployed, + calls: calls.length, + keyChanges: keyChangeCount, + valueCalls: valueBearingCallCount(calls), + }); + }, [acct, chain.mode, calls, keyChangeCount]); + + const clearResult = () => setResult(null); + const copy = async (text: string, key: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(key); + setTimeout(() => setCopied(null), 1400); + } catch { + /* Clipboard access is optional in the demo. */ + } + }; + const copyRandomAddress = () => copy(privateKeyToAccount(generatePrivateKey()).address, 'randaddr'); + + const recordResult = ( + a: StoredAccount, + serialized: Hex, + txHash: Hex, + pending: boolean, + by: WalletSigner, + gasNote?: string, + extraChanges: string[] = [], + ) => { + setResult({ serialized, txHash, by: by.label, kind: by.kind, pending, gasNote }); + pushActivity({ + kind: a.deployed && !pending ? 'transact' : 'create', + 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', + changes: [ + ...(!a.deployed + ? [a.type === 'eoa' ? 'delegate → DefaultAccount' : `create · ${a.initialActors.length} keys`] + : []), + ...(pending ? ['⚠ pending — not yet included'] : []), + ...(gasNote ? [gasNote] : []), + ...extraChanges, + ], + calls: calls.length, + metadata: metaField.trim() || undefined, + network: chain.name, + mode: chain.mode, + serialized, + account: a.address, + }); + }; + + 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); + }; + + // 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; + }; + + // 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 }; + 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); + } 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(''); + } + }; + + // 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 }; + 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 })); + const terms = await payerClient.getTerms({ + chainId: toHex(chain.id || 84538453), + from: acct.address, + calls: rpcCalls, + gasLimit: toHex(BigInt(gasEstimate || 200_000)), + context: { flow: 'transact' }, + }); + + let selToken: Address | undefined; + if (effectiveGasMode === '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 { option, tokenChoice } = selectPaymentOption(terms, selToken ? { token: selToken } : {}); + + let phase0: { to: Address; data: Hex }[] | undefined; + let gasNote: string; + 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`; + } else { + gasNote = 'Sponsored by vibenet payer · free grant'; + } + + 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' }, + }); + const finalTx = (cosigned.signedTransaction ?? serialized) as Hex; + + let txHash: Hex; + let pending = false; + try { + txHash = await broadcast8130(finalTx, setSubmitStatus); + } catch (err) { + if (err instanceof TxPendingError) { + txHash = err.txHash; + pending = true; + } else throw err; + } + if (!pending) applyLandedBundle(acct, nextSeq, bundle); + recordResult(acct, finalTx, txHash, pending, txSigner, gasNote, extra); + } 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 confirmSend = async (forceEstimate = false) => { + 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; + 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)))); + } finally { + setSigning(false); + setSubmitStatus(''); + } + }; + + // --- calls editor handlers --------------------------------------------- + const setRow = (id: string, patch: Partial) => { + setCalls((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))); + clearResult(); + }; + const addRow = (partial?: Partial) => { + setCalls((prev) => [...prev, newCallRow(partial)]); + clearResult(); + }; + const addEthRow = () => addRow({ phase: 1 }); + const resolveUsdvAddress = async (): Promise
      => { + const status = await vibenetApi.faucet.status().catch(() => null); + const a = status?.usdv_address; + return a && isAddressStr(a) ? (a as Address) : null; + }; + const addUsdvRow = async () => { + const USDV = (await resolveUsdvAddress()) ?? '0x9A676e781A523b5d0C0e43731313A708CB607508'; + const PLACEHOLDER = '0x0000000000000000000000000000000000000001'; + addRow({ to: USDV, data: encodeUsdvTransfer(PLACEHOLDER, 1_000_000n), phase: 1 }); + }; + const removeRow = (id: string) => { + setCalls((prev) => (prev.length > 1 ? prev.filter((r) => r.id !== id) : prev)); + clearResult(); + }; + + const startSend = () => { + if (!callsValid || !txSigner) 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 } + : 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]); + + return ( + + + + + ) : notice ? ( + <> + + + + ) : error ? ( + <> + + + + ) : ( + <> + {submittedResult?.txHash ? ( + + ) : null} + + + ) + ) : applyTarget ? ( + <> + + + + ) : txStep === 'review' ? ( + <> + + + + ) : ( +
      + + {chain.mode === 'eip8130-native' ? 'native 8130' : 'ERC-4337'} · 1 tx · ~ + {gasEstimate.toLocaleString()} gas + {acct && !acct.deployed + ? acct.type === 'eoa' + ? ' · first use delegates your EOA' + : ' · first use deploys your account' + : ''} + + +
      + ) + } + > + {!acct ? null : txStep === 'submitted' ? ( + seqRecovery ? ( + + ) : notice ? ( + + ) : ( + + ) + ) : applyTarget ? ( + + ) : txStep === 'review' ? ( + + ) : ( + <> + {/* From */} +
      + From + setActiveAccountId(id)} + triggerClassName="w-full" + /> +
      + + {/* Signer */} +
      + Signer + {signableSigners.length > 1 ? ( + ({ + value: c.shortName, + label: `${c.name} ${c.mode === 'eip8130-native' ? '· 8130' : '· 4337'}`, + }))} + /> +
      + ) : null} + + {/* Calls */} +
      + +
      + + {/* Metadata */} +
      +
      + Metadata + Top-Level · Signed +
      + setMetaField(e.target.value)} + className="w-full rounded-lg border border-bds-gray-10 bg-bds-gray-0 px-3.5 py-2.5 text-[14px] outline-none transition-colors placeholder:text-bds-gray-40 focus:border-foreground dark:border-white/10 dark:bg-white/5 dark:focus:border-bds-blue-40" + /> + {metadataHex ? ( +

      + → {short(metadataHex, 14, 8)} +

      + ) : null} +
      + + {/* Gas */} +
      + Gas + { + const val = e.target.value; + setUsdvAmountDrafts((d) => ({ ...d, [r.id]: val })); + try { + const amt = parseUnits(val || '0', USDV_DECIMALS); + const rec = isAddressStr(recipientDisplay) ? recipientDisplay : usdv.recipient; + setRow(r.id, { data: encodeUsdvTransfer(rec, amt) }); + } catch { + /* ignore */ + } + }} + onBlur={() => + setUsdvAmountDrafts((d) => { + const n = { ...d }; + delete n[r.id]; + return n; + }) + } + /> +
      + + {calls.length > 1 && removeRow(r.id)} disabled={false} />} +
    • + ); + } + return ( +
    • + setRow(r.id, { to })} + accounts={addressBook} + /> + + {calls.length > 1 && removeRow(r.id)} disabled={false} />} +
    • + ); + })} +
    +
    + Add Call: + + + +
    + + ) : ( + <> +
      + + {calls.map((r) => ( +
    • + + setRow(r.id, { to: e.target.value })} + /> + setRow(r.id, { value: e.target.value })} + /> + setRow(r.id, { data: e.target.value })} + /> + {calls.length > 1 && removeRow(r.id)} disabled={false} />} +
    • + ))} +
    +
    + + {!callsValid ? ( + + Check call fields — “to” must be a 20-byte hex address, calldata must be hex. + + ) : null} +
    + + )} + + ); +} + +function RemoveRowButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) { + return ( + + ); +} + +type ReviewBodyProps = { + acct: StoredAccount; + accounts: StoredAccount[]; + calls: CallRow[]; + metaField: string; + gasMode: 'eth' | 'free' | 'usdv'; + gasEstimate: number; + txSigner: WalletSigner | null; + signableSigners: WalletSigner[]; + postChangeOwnerSigners: WalletSigner[]; + sessionSigners: WalletSigner[]; + ownerSigners: WalletSigner[]; + onSelectSigner: (id: string) => void; + error: string; +}; + +function ReviewBody({ + acct, + accounts, + calls, + metaField, + gasMode, + gasEstimate, + txSigner, + signableSigners, + postChangeOwnerSigners, + sessionSigners, + ownerSigners, + onSelectSigner, + error, +}: ReviewBodyProps) { + const gasLabel = gasMode === 'eth' ? 'Pay in ETH' : gasMode === 'free' ? 'Sponsored' : 'USDV · payer'; + const addressLabel = (address: string) => + accounts.find((a) => a.address.toLowerCase() === address.toLowerCase())?.label; + const AddressChip = ({ address }: { address: string }) => { + const label = addressLabel(address); + return ( + + {label ? `${label} · ` : ''} + {short(address)} + + ); + }; + return ( +
    + {!acct.deployed ? ( +
    + {acct.type === 'eoa' ? 'Delegate' : 'Deploy'} + + {acct.type === 'eoa' + ? 'First use — this also delegates your EOA to the account contract.' + : 'First use — this also deploys your account on-chain.'} + +
    + ) : null} + +
      + {calls.map((r, i) => { + const usdv = tryDecodeUsdvTransfer(r); + const ethValue = r.value.trim() && r.value.trim() !== '0' ? r.value.trim() : null; + const isPlainCall = !usdv && !ethValue && r.data.trim() && r.data.trim() !== '0x'; + return ( +
    • + + {i + 1} + + {usdv ? ( + <> + Send {formatTokenAmount(usdv.amount, USDV_DECIMALS)} USDV + + + + ) : ethValue ? ( + <> + Send {ethValue} ETH + + + + ) : ( + <> + {isPlainCall ? 'Call' : 'No-op call'} + + + {isPlainCall ? ( + + {short(r.data.trim(), 8, 4)} + + ) : null} + + )} +
    • + ); + })} + {metaField.trim() ? ( +
    • + Metadata + {metaField.trim()} +
    • + ) : null} +
    + +
    + {error ? ( +
    + + {error} +
    + ) : ( +
    +
    + + ~{gasEstimate.toLocaleString()} gas + + {gasLabel} +
    + {txSigner ? ( +
    + Signing with + {signableSigners.length > 1 ? ( +
    + + engine.setOwnerScope(owner.id, OWNER_SCOPE_PRESETS.find((item) => item.id === value)?.scope ?? 0) + } + options={OWNER_SCOPE_PRESETS.map((item) => ({ value: item.id, label: item.label }))} + /> +
    + engine.stageRemoveOwner(owner.id, Boolean(isEoa && !isNew))} + disabled={draftOwners.length <= 1} + disabledTitle="An account needs at least one owner" + /> +
    +
    + ); + })} +
    + + + +
    + Add an owner + + Use a key already in this browser or create a new one. + +
    +
    + {addable.map((signer) => ( + + ))} + + +
    +
    + + {engine.keyChangeCount > 0 ? ( +
    +
    + + {engine.keyChangeCount} pending owner change{engine.keyChangeCount === 1 ? '' : 's'} + + + {[ + ...engine.pendingAuthorize.map((item) => `add ${item.label}`), + ...engine.pendingRevoke.map((item) => `remove ${item.label}`), + ...engine.pendingScope.map((item) => `${item.label} → ${scopeLabel(item.toScope)}`), + ].join(' · ')} + +
    +
    + + +
    +
    + ) : null} +
    + ); +} + +function SessionsSection({ openApply }: { openApply: (target: ApplyTarget) => void }) { + const engine = useAccountEngine(); + const acct = engine.acct!; + const [adding, setAdding] = useState(false); + + const handleRevoke = async (id: string) => { + const outcome = await engine.revokeSessionKey(id); + if (outcome === 'staged' || outcome === 'noop') openApply({ session: id }); + }; + + return ( +
    + {acct.sessionKeys.length > 0 ? ( +
    + {acct.sessionKeys.map((session) => { + const live = Object.entries(engine.policyRemaining[session.id] ?? {}); + const applying = engine.skApplyingId === session.id; + return ( + +
    +
    + +
    + {session.label} + {shortAddress(session.actorId)} +
    +
    + {!session.pendingAuth && !session.pendingRevoke ? ( + void handleRevoke(session.id)} /> + ) : null} +
    +
    + {scopeChips(session.scope).map((scope) => ( + {scope} + ))} + {formatExpiry(session.expiry)} + {session.pendingAuth ? Pending authorize : null} + {session.pendingRevoke ? Pending revoke : null} +
    +
    + + chain.id === session.chainId)?.name ?? String(session.chainId)} + /> + {live.length > 0 + ? live.map(([token, b]) => ( + + )) + : session.policy?.limits?.map((limit) => ( + + ))} +
    + {session.pendingAuth ? ( +
    + + +
    + ) : session.pendingRevoke ? ( +
    + + +
    + ) : null} +
    + ); + })} +
    + ) : !adding ? ( + + No session keys + + Add a policy-gated key for an app, agent, or hot signer. It can be revoked without rotating your owners. + + + ) : null} + + {adding ? ( + + setAdding(false)} + onAuthorized={(sessionKeyId) => openApply({ session: sessionKeyId })} + /> + + ) : ( +
    + +
    + )} +
    + ); +} + +function SubAccountsSection() { + const engine = useAccountEngine(); + const acct = engine.acct!; + const [open, setOpen] = useState(false); + const [label, setLabel] = useState(''); + const [busy, setBusy] = useState(false); + + const create = () => { + setBusy(true); + try { + engine.doCreateSubAccount(label); + setLabel(''); + setOpen(false); + } finally { + setBusy(false); + } + }; + + return ( +
    + {acct.subAccounts.length > 0 ? ( +
    + {acct.subAccounts.map((subAccount) => { + const stored = engine.accounts.find( + (candidate) => candidate.address.toLowerCase() === subAccount.address.toLowerCase(), + ); + return ( + +
    +
    + {subAccount.label} + {shortAddress(subAccount.address)} +
    + {stored ? ( + engine.deleteAccount(stored.id)} + /> + ) : null} +
    +
    + + +
    +
    + +
    +
    + ); + })} +
    + ) : ( + + No sub-accounts + + Separate an app or workflow into its own address without giving up control from this account. + + + )} + +
    + +
    + + setOpen(false)} + title="Create sub-account" + footer={ + <> + + + + } + > + + + A delegated account with its own address, controlled by this account. It deploys on first use. + + +
    + ); +} diff --git a/app/vibenet/explorer/address/[addr]/PublicAddressView.tsx b/app/vibenet/explorer/address/[addr]/PublicAddressView.tsx new file mode 100644 index 0000000..555d8a0 --- /dev/null +++ b/app/vibenet/explorer/address/[addr]/PublicAddressView.tsx @@ -0,0 +1,128 @@ +'use client'; + +// Read-only inspector for an address that is not one of your local accounts: +// assets, actors (owners + session keys as indexed), and activity. No `@aa` — +// this is the light public path. Shares the assets + activity components with +// the owned management view and renders inside the shared AccountShell. + +import { useMemo } from 'react'; + +import { Card } from '../../../../components/ui/Card'; +import { Text } from '../../../../components/ui/Text'; +import { DetailList, DetailRow } from '../../../components/DetailList'; +import { ExplorerLink } from '../../../components/ExplorerLink'; +import { Badge } from '../../../demos/_shared/primitives'; +import type { ActorEntry, ExplorerAddressResponse } from '../../../library/api-types'; +import { authLabel, expiryLabel, K1_AUTHENTICATOR, scopeChips } from '../../../library/explorer'; +import { shortAddress } from '../../../library/format'; +import { AccountShell, useSectionParam, type ShellSection } from './AccountShell'; +import { ActivityTable } from './ActivityTable'; +import { AssetsCard } from './AssetsCard'; + +const CHIP = + 'inline-flex items-center rounded-full border border-bds-gray-10 px-2.5 py-1 text-[11px] leading-none text-bds-gray-60 dark:border-white/10 dark:text-bds-gray-40'; + +const SECTIONS: ShellSection[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'actors', label: 'Actors' }, + { id: 'activity', label: 'Activity' }, +]; + +function ActorCard({ actor }: { actor: ActorEntry }) { + return ( + +
    + + {shortAddress(actor.actorId, 14, 4)} + + {actor.isSelf ? self : null} + {authLabel(actor.authenticator)} +
    +
    + {scopeChips(actor.scope).map((chip) => ( + + {chip} + + ))} + {expiryLabel(actor.expiry)} + {actor.policyType !== 0 ? policy · type {actor.policyType} : null} +
    + {actor.policyManager ? ( + + + + + {actor.policyCommitment ? ( + + + {shortAddress(actor.policyCommitment, 14, 4)} + + + ) : null} + + ) : null} +
    + ); +} + +export function PublicAddressView({ address, data }: { address: string; data: ExplorerAddressResponse }) { + const [section, selectSection] = useSectionParam( + SECTIONS.map((s) => s.id), + 'overview', + ); + + const typeBadge = data.is_contract ? 'Contract' : data.is_aa ? 'Smart account' : 'EOA'; + + // Implicit secp256k1 self key when no AccountConfiguration events are indexed. + const selfActor = useMemo( + () => ({ + actorId: data.self_actor_id ?? '', + authenticator: K1_AUTHENTICATOR, + scope: 0, + expiry: 0, + policyType: 0, + policyManager: null, + policyCommitment: null, + isSelf: true, + }), + [data.self_actor_id], + ); + + return ( + {typeBadge}} + sections={SECTIONS} + activeSection={section} + onSelectSection={selectSection} + > + {section === 'overview' ? ( + + ) : section === 'actors' ? ( + data.actors_indexed ? ( + data.actors.length === 0 ? ( + + No active actors — every registered actor has been revoked. + + ) : ( +
    + {data.actors.map((actor) => ( + + ))} +
    + ) + ) : ( +
    + + + Implicit secp256k1 self key — no AccountConfiguration events indexed for this address yet. + +
    + ) + ) : ( + + )} +
    + ); +} diff --git a/app/vibenet/explorer/address/[addr]/loading.tsx b/app/vibenet/explorer/address/[addr]/loading.tsx index 80f3a07..32a046d 100644 --- a/app/vibenet/explorer/address/[addr]/loading.tsx +++ b/app/vibenet/explorer/address/[addr]/loading.tsx @@ -2,39 +2,34 @@ import { Skeleton } from '../../../../components/ui/Skeleton'; export default function AddressLoading() { return ( -
    -
    - - +
    + {/* Hero header */} +
    +
    + +
    + + +
    +
    -
    -
    + {/* Nav + content */} +
    +
    {[0, 1, 2].map((i) => ( -
    - - -
    + ))}
    -
    - -
    - -
    - {[0, 1].map((i) => ( -
    - -
    - {[0, 1, 2].map((j) => ( - - ))} +
    +
    + {[0, 1, 2].map((i) => ( +
    + +
    -
    - ))} + ))} +
    diff --git a/app/vibenet/explorer/address/[addr]/page.tsx b/app/vibenet/explorer/address/[addr]/page.tsx index 60f0541..16dd39e 100644 --- a/app/vibenet/explorer/address/[addr]/page.tsx +++ b/app/vibenet/explorer/address/[addr]/page.tsx @@ -1,79 +1,34 @@ 'use client'; -import { use, useEffect, useMemo, useState } from 'react'; -import type { ReactNode } from 'react'; +// Canonical account page. For any address it's the read-only explorer inspector; +// when the address is one of your local accounts (found in localStorage) it +// reveals the full management view. The management view is `@aa`-heavy, so it's +// loaded via next/dynamic — the public inspector path stays light. + +import dynamic from 'next/dynamic'; import { notFound } from 'next/navigation'; +import { use, useEffect, useState } from 'react'; import { Card } from '../../../../components/ui/Card'; +import { Spinner } from '../../../../components/ui/Spinner'; import { Text } from '../../../../components/ui/Text'; -import { DetailList, DetailRow } from '../../../components/DetailList'; -import { ExplorerLink } from '../../../components/ExplorerLink'; import { useAccountNames } from '../../../components/useAccountNames'; -import type { ActorEntry, ExplorerAddressResponse } from '../../../library/api-types'; +import type { ExplorerAddressResponse } from '../../../library/api-types'; import { vibenetApi, VibenetApiError } from '../../../library/client'; -import { - authLabel, - expiryLabel, - K1_AUTHENTICATOR, - roleLabel, - scopeChips, - weiToEth, -} from '../../../library/explorer'; -import { shortAddress } from '../../../library/format'; - -const CHIP = - 'inline-flex items-center rounded-full border border-bds-gray-10 px-2.5 py-1 text-[11px] leading-none text-bds-gray-60 dark:border-white/10 dark:text-bds-gray-40'; -const BADGE = - 'inline-flex items-center rounded-full bg-bds-blue-0 px-2 py-1 text-[11px] leading-none text-bds-blue-60 dark:text-base-blue'; -const TH = - 'px-4 py-3 text-left text-[13px] font-normal text-bds-gray-50'; -const TD = 'px-4 py-3 text-[13px]'; +import { PublicAddressView } from './PublicAddressView'; -type ActorCardProps = { - actor: ActorEntry; -}; +// Owned management view: dynamic + client-only so `@aa`/WebAuthn/signing never +// load on the public inspector path. +const OwnedAccountView = dynamic(() => import('./OwnedAccountView').then((m) => m.OwnedAccountView), { + ssr: false, + loading: () => , +}); -function ActorCard({ actor }: ActorCardProps) { +function CenteredSpinner() { return ( - -
    - - {shortAddress(actor.actorId, 14, 4)} - - {actor.isSelf ? self : null} - {authLabel(actor.authenticator)} -
    -
    - {scopeChips(actor.scope).map((chip) => ( - - {chip} - - ))} - {expiryLabel(actor.expiry)} - {actor.policyType !== 0 ? ( - policy · type {actor.policyType} - ) : null} -
    - {actor.policyManager ? ( - - - - - {actor.policyCommitment ? ( - - - {shortAddress(actor.policyCommitment, 14, 4)} - - - ) : null} - - ) : null} -
    +
    + +
    ); } @@ -85,164 +40,56 @@ export default function ExplorerAddressPage({ params }: PageProps) { const { addr } = use(params); const [data, setData] = useState(null); const [is404, setIs404] = useState(false); - const accountName = useAccountNames()[addr.toLowerCase()]; + const [failed, setFailed] = useState(false); + const [mounted, setMounted] = useState(false); + const names = useAccountNames(); + + useEffect(() => setMounted(true), []); useEffect(() => { let cancelled = false; + setData(null); + setIs404(false); + setFailed(false); vibenetApi.explorer .address(addr) .then((next) => { if (!cancelled) setData(next); }) .catch((err) => { - if (!cancelled && err instanceof VibenetApiError && err.status === 404) { - setIs404(true); - } + if (cancelled) return; + if (err instanceof VibenetApiError && err.status === 404) setIs404(true); + else setFailed(true); }); return () => { cancelled = true; }; }, [addr]); - // Implicit secp256k1 self key shown when no AccountConfiguration events are - // indexed yet. Memoized so it's a stable prop for ActorCard. - const selfActor = useMemo( - () => ({ - actorId: data?.self_actor_id ?? '', - authenticator: K1_AUTHENTICATOR, - scope: 0, - expiry: 0, - policyType: 0, - policyManager: null, - policyCommitment: null, - isSelf: true, - }), - [data?.self_actor_id], - ); + const owned = mounted && !!names[addr.toLowerCase()]; - let typeBody: ReactNode = 'EOA'; - if (data?.is_contract) { - typeBody = `Contract (${data.code_size.toLocaleString()} bytes)`; - } else if (data?.is_aa) { - typeBody = ( - <> - EIP-8130 AA Account native AA - - ); - } + // Wait until localStorage ownership is known and the on-chain fetch has settled + // (data, 404, or a request failure) so we neither flash the public view for an + // owned account nor 404 a counterfactual (undeployed) account we own. + const settled = mounted && (data !== null || is404 || failed); + if (!settled) return ; - let actorsBody: ReactNode = null; - if (data) { - if (data.actors_indexed) { - actorsBody = - data.actors.length === 0 ? ( - - No active actors — every registered actor has been revoked. - - ) : ( -
    - {data.actors.map((actor) => ( - - ))} -
    - ); - } else { - actorsBody = ( -
    - - - Implicit secp256k1 self key — no AccountConfiguration events indexed for this address - yet. - -
    - ); - } - } + // Owned accounts render the management view even when the explorer API has + // nothing (undeployed → data null, or an outage → failed) — the account data + // itself lives in localStorage, not behind this request. + if (owned) return ; if (is404) notFound(); - return ( -
    -
    - {accountName ?? 'Address'} - - {addr} - -
    - - {data ? ( - <> - - - {typeBody} - {weiToEth(data.balance_wei)} - {data.nonce.toString()} - - - -
    - Actors - {actorsBody} -
    + if (failed) { + return ( + + + Failed to fetch address. Please try again. + + + ); + } -
    - Activity - {data.activity.length === 0 ? ( - - - No activity indexed yet. - - - ) : ( - - - - - - - - - - - - {data.activity.map((row) => ( - - - - - - - ))} - -
    BlockTxRoleDetail
    - - - - {roleLabel(row.role)} - {row.token ? ( - - via - - ) : ( - - )} -
    -
    - )} -
    - - ) : null} -
    - ); + return ; }