From 926ae51f6eb5199c4924f07729a27119eeda73a0 Mon Sep 17 00:00:00 2001 From: thunkar Date: Thu, 5 Mar 2026 21:28:14 +0100 Subject: [PATCH 1/7] iframe test --- README.md | 2 +- src/components/TxNotificationCenter.tsx | 123 +- src/embedded_wallet.ts | 7 +- src/services/walletService.ts | 106 +- src/tx-progress.ts | 13 +- src/wallet/iframe/iframe-discovery.ts | 164 ++ src/wallet/iframe/iframe-message-types.ts | 22 + src/wallet/iframe/iframe-provider.ts | 336 ++++ src/wallet/iframe/iframe-wallet.ts | 178 +++ yarn.lock | 1711 ++++++++++----------- 10 files changed, 1751 insertions(+), 911 deletions(-) create mode 100644 src/wallet/iframe/iframe-discovery.ts create mode 100644 src/wallet/iframe/iframe-message-types.ts create mode 100644 src/wallet/iframe/iframe-provider.ts create mode 100644 src/wallet/iframe/iframe-wallet.ts diff --git a/README.md b/README.md index d629506..47149b1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ VERSION=4.0.0-nightly.20260205 bash -i <(curl -sL https://install.aztec.network/ ### 3. Set Aztec Version -The project uses Aztec version `v4.0.0-devnet.1-patch.0`. Set it using: +The project uses Aztec version `v4.0.0-devnet.2-patch.3`. Set it using: ```bash aztec-up install 4.0.0-nightly.20260205 diff --git a/src/components/TxNotificationCenter.tsx b/src/components/TxNotificationCenter.tsx index d598374..27de1b6 100644 --- a/src/components/TxNotificationCenter.tsx +++ b/src/components/TxNotificationCenter.tsx @@ -26,6 +26,24 @@ import UnfoldLessIcon from '@mui/icons-material/UnfoldLess'; import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; import { txProgress, type TxProgressEvent, type PhaseTiming } from '../tx-progress'; +// ─── Live phase support ─────────────────────────────────────────────────────── + +interface LivePhaseTiming extends PhaseTiming { + isLive?: boolean; +} + +const ACTIVE_PHASE_COLORS: Record = { + simulating: '#ce93d8', + proving: '#f48fb1', + sending: '#2196f3', + mining: '#4caf50', +}; + +const shimmer = keyframes` + 0% { background-position: -400px 0; } + 100% { background-position: 400px 0; } +`; + // ─── Helpers ───────────────────────────────────────────────────────────────── const formatDuration = (ms: number): string => { @@ -60,22 +78,29 @@ const pulse = keyframes` // ─── PhaseTimeline (inline, simplified from demo-wallet) ───────────────────── -function PhaseTimelineBar({ phases }: { phases: PhaseTiming[] }) { - const totalDuration = useMemo(() => phases.reduce((sum, p) => sum + p.duration, 0), [phases]); +function PhaseTimelineBar({ phases }: { phases: LivePhaseTiming[] }) { + const completedPhases = useMemo(() => phases.filter(p => !p.isLive), [phases]); + const livePhase = useMemo(() => phases.find(p => p.isLive), [phases]); + + const completedDuration = useMemo(() => completedPhases.reduce((sum, p) => sum + p.duration, 0), [completedPhases]); + const liveDuration = livePhase?.duration ?? 0; + const totalDuration = completedDuration + liveDuration; + const miningDuration = useMemo( - () => phases.filter(p => p.name === 'Mining').reduce((sum, p) => sum + p.duration, 0), - [phases], + () => completedPhases.filter(p => p.name === 'Mining').reduce((sum, p) => sum + p.duration, 0), + [completedPhases], ); if (phases.length === 0 || totalDuration === 0) return null; const preparingDuration = totalDuration - miningDuration; const hasMining = miningDuration > 0; + const hasLive = !!livePhase; return ( {/* Summary chips */} - + {hasMining ? ( <> ) : ( @@ -114,7 +139,8 @@ function PhaseTimelineBar({ phases }: { phases: PhaseTiming[] }) { bgcolor: 'action.hover', }} > - {phases.map((phase, index) => { + {/* Completed segments (proportional width based on total) */} + {completedPhases.map((phase, index) => { const percentage = (phase.duration / totalDuration) * 100; return ( 0 ? 2 : 0, height: '100%', bgcolor: phase.color, - borderRight: index < phases.length - 1 ? '1px solid rgba(255,255,255,0.3)' : undefined, + borderRight: (index < completedPhases.length - 1 || hasLive) ? '1px solid rgba(255,255,255,0.3)' : undefined, transition: 'filter 0.2s ease', cursor: 'pointer', '&:hover': { filter: 'brightness(1.2)' }, @@ -174,15 +200,43 @@ function PhaseTimelineBar({ phases }: { phases: PhaseTiming[] }) { ); })} + + {/* Live (shimmer) segment — flex: 1 to fill remaining space */} + {livePhase && ( + + + {livePhase.name} + + {formatDurationLong(livePhase.duration)} (in progress) + + } + arrow + placement="top" + > + + + )} {/* Legend */} {phases.map(phase => ( - + - {phase.name} + {phase.name}{phase.isLive ? ' ●' : ''} ))} @@ -201,14 +255,16 @@ interface TxToastProps { function TxToast({ event, onDismiss }: TxToastProps) { const isActive = event.phase !== 'complete' && event.phase !== 'error'; - // For completed events, compute total from recorded phase timings (stable across refreshes) + // For completed events, compute total from recorded phases (stable across refreshes) const computeFinalElapsed = () => { - const t = event.phaseTimings; - const fromTimings = (t.simulation ?? 0) + (t.proving ?? 0) + (t.sending ?? 0) + (t.mining ?? 0); - return fromTimings > 0 ? fromTimings : Date.now() - event.startTime; + const fromPhases = event.phases.reduce((sum, p) => sum + p.duration, 0); + return fromPhases > 0 ? fromPhases : Date.now() - event.startTime; }; + // Total wall-clock elapsed since tx start (for header display) const [elapsed, setElapsed] = useState(() => isActive ? Date.now() - event.startTime : computeFinalElapsed()); + // Live elapsed within the *current* phase (resets when phase changes) + const [phaseElapsed, setPhaseElapsed] = useState(() => isActive ? Date.now() - event.phaseStartTime : 0); const [expanded, setExpanded] = useState(true); const frozen = useRef(!isActive); @@ -218,17 +274,44 @@ function TxToast({ event, onDismiss }: TxToastProps) { if (!frozen.current) { frozen.current = true; setElapsed(computeFinalElapsed()); + setPhaseElapsed(0); } return; } frozen.current = false; - const interval = setInterval(() => setElapsed(Date.now() - event.startTime), 200); + const interval = setInterval(() => { + setElapsed(Date.now() - event.startTime); + setPhaseElapsed(Date.now() - event.phaseStartTime); + }, 200); return () => clearInterval(interval); - }, [isActive, event.startTime]); + }, [isActive, event.startTime, event.phaseStartTime]); + + // Re-initialize elapsed when txId changes (new transaction) + const prevTxIdRef = useRef(event.txId); + useEffect(() => { + if (event.txId !== prevTxIdRef.current) { + prevTxIdRef.current = event.txId; + setElapsed(isActive ? Date.now() - event.startTime : computeFinalElapsed()); + setPhaseElapsed(isActive ? Date.now() - event.phaseStartTime : 0); + frozen.current = !isActive; + } + }, [event.txId]); const isComplete = event.phase === 'complete'; const isError = event.phase === 'error'; + // Build display phases: completed phases + live shimmer phase when active + const displayPhases: LivePhaseTiming[] = useMemo(() => { + if (!isActive) return event.phases; + if (phaseElapsed <= 0 && event.phases.length === 0) return []; + const liveColor = ACTIVE_PHASE_COLORS[event.phase] ?? '#90caf9'; + const liveName = PHASE_LABELS[event.phase] ?? event.phase; + return [ + ...event.phases, + { name: liveName, duration: phaseElapsed > 0 ? phaseElapsed : 100, color: liveColor, isLive: true }, + ]; + }, [isActive, event.phases, event.phase, phaseElapsed]); + return ( {/* Expand/collapse */} - {isComplete && event.phases.length > 0 && ( + {displayPhases.length > 0 && ( setExpanded(prev => !prev)} sx={{ p: 0.25 }}> {expanded ? : } @@ -316,10 +399,10 @@ function TxToast({ event, onDismiss }: TxToastProps) { - {/* Phase timeline breakdown (shown when complete) */} - 0}> + {/* Phase timeline breakdown (shown during execution and when complete) */} + 0}> - + diff --git a/src/embedded_wallet.ts b/src/embedded_wallet.ts index 5e7b13f..6095037 100644 --- a/src/embedded_wallet.ts +++ b/src/embedded_wallet.ts @@ -162,7 +162,6 @@ export class EmbeddedWallet extends EmbeddedWalletBase { ): Promise> { const txId = crypto.randomUUID(); const startTime = Date.now(); - const phaseTimings: TxProgressEvent['phaseTimings'] = {}; const phases: PhaseTiming[] = []; // Derive a human-readable label from the first meaningful call in the payload @@ -179,7 +178,7 @@ export class EmbeddedWallet extends EmbeddedWalletBase { label, phase, startTime, - phaseTimings: { ...phaseTimings }, + phaseStartTime: Date.now(), phases: [...phases], ...extra, }); @@ -219,7 +218,6 @@ export class EmbeddedWallet extends EmbeddedWalletBase { } const simulationDuration = Date.now() - simulationStart; - phaseTimings.simulation = simulationDuration; // Build breakdown and details from simulation stats const simStats = simulationResult.stats; @@ -271,7 +269,6 @@ export class EmbeddedWallet extends EmbeddedWalletBase { const provenTx = await this.pxe.proveTx(txRequest, this.scopesFor(opts.from)); const provingDuration = Date.now() - provingStart; - phaseTimings.proving = provingDuration; // Extract detailed stats from proving result if available const stats = provenTx.stats; @@ -308,7 +305,6 @@ export class EmbeddedWallet extends EmbeddedWalletBase { await this.aztecNode.sendTx(tx); const sendingDuration = Date.now() - sendingStart; - phaseTimings.sending = sendingDuration; phases.push({ name: 'Sending', duration: sendingDuration, color: '#2196f3' }); // NO_WAIT: return txHash immediately @@ -325,7 +321,6 @@ export class EmbeddedWallet extends EmbeddedWalletBase { const receipt = await waitForTx(this.aztecNode, txHash, waitOpts); const miningDuration = Date.now() - miningStart; - phaseTimings.mining = miningDuration; phases.push({ name: 'Mining', duration: miningDuration, color: '#4caf50' }); emit('complete'); diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 91cf27d..825189f 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -13,9 +13,17 @@ import { type PendingConnection, type DiscoverySession, } from '@aztec/wallet-sdk/manager'; +import { promiseWithResolvers } from '@aztec/foundation/promise'; import type { AztecAddress } from '@aztec/aztec.js/addresses'; import { EmbeddedWallet } from '../embedded_wallet'; import type { NetworkConfig } from '../config/networks'; +import { discoverWebWallets } from '../wallet/iframe/iframe-discovery.ts'; + +/** + * Web wallet URLs to probe during discovery. + * Set VITE_WEB_WALLET_URL in .env or CI to override the default dev URL. + */ +const WEB_WALLET_URLS: string[] = [import.meta.env.VITE_WEB_WALLET_URL ?? 'http://localhost:3001']; const APP_ID = 'gregoswap'; @@ -50,16 +58,106 @@ export function getChainInfo(network: NetworkConfig): ChainInfo { } /** - * Starts wallet discovery process - * Returns a DiscoverySession that yields wallets as they are discovered + * Starts wallet discovery process (extension + web wallets in parallel). + * Returns a DiscoverySession that yields providers as they are discovered. */ export function discoverWallets(chainInfo: ChainInfo, timeout?: number): DiscoverySession { - const manager = WalletManager.configure({ extensions: { enabled: true } }); - return manager.getAvailableWallets({ + // Extension wallets + const extensionSession = WalletManager.configure({ extensions: { enabled: true } }).getAvailableWallets({ chainInfo, appId: APP_ID, timeout, }); + + // Web wallets (probed via hidden iframe) + const webSession = discoverWebWallets(WEB_WALLET_URLS, chainInfo); + + // Merge both sessions into one DiscoverySession + return mergeDiscoverySessions([extensionSession, webSession]); +} + +/** + * Merges multiple DiscoverySessions into one. + * Providers from all sessions are emitted as they arrive. + * The merged session completes when all sub-sessions complete. + */ +function mergeDiscoverySessions(sessions: DiscoverySession[]): DiscoverySession { + const { promise: donePromise, resolve: resolveDone } = promiseWithResolvers(); + + let cancelled = false; + const pending: WalletProvider[] = []; + let pendingResolve: ((result: IteratorResult) => void) | null = null; + let remaining = sessions.length; + + function emit(provider: WalletProvider) { + if (pendingResolve) { + const resolve = pendingResolve; + pendingResolve = null; + resolve({ value: provider, done: false }); + } else { + pending.push(provider); + } + } + + function markOneDone() { + remaining--; + if (remaining === 0) { + resolveDone(); + if (pendingResolve) { + const resolve = pendingResolve; + pendingResolve = null; + resolve({ value: undefined as any, done: true }); + } + } + } + + // Drain each session in background + for (const session of sessions) { + (async () => { + try { + for await (const provider of session.wallets) { + if (cancelled) break; + emit(provider); + } + } catch { + // ignore + } finally { + markOneDone(); + } + })(); + } + + const wallets: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + if (remaining === 0 && pending.length === 0) { + return { value: undefined as any, done: true }; + } + if (pending.length > 0) { + return { value: pending.shift()!, done: false }; + } + return new Promise(resolve => { + pendingResolve = resolve; + }); + }, + async return() { + resolveDone(); + return { value: undefined as any, done: true }; + }, + }; + }, + }; + + return { + wallets, + done: donePromise, + cancel: () => { + cancelled = true; + sessions.forEach(s => s.cancel()); + resolveDone(); + }, + }; } /** diff --git a/src/tx-progress.ts b/src/tx-progress.ts index 6c42350..36986b7 100644 --- a/src/tx-progress.ts +++ b/src/tx-progress.ts @@ -22,13 +22,8 @@ export interface TxProgressEvent { phase: TxPhase; /** Wall-clock start time (Date.now()) of this tx */ startTime: number; - /** Per-phase wall-clock durations collected so far */ - phaseTimings: { - simulation?: number; - proving?: number; - sending?: number; - mining?: number; - }; + /** Wall-clock start time of the current phase (Date.now() at emit time) */ + phaseStartTime: number; /** Detailed phase breakdown for the timeline bar */ phases: PhaseTiming[]; /** Error message if phase === 'error' */ @@ -70,7 +65,9 @@ class TxProgressEmitter { try { const raw = localStorage.getItem(this.accountKey); if (!raw) return []; - return JSON.parse(raw) as TxProgressEvent[]; + const events = JSON.parse(raw) as TxProgressEvent[]; + // Backfill phaseStartTime for events persisted before this field existed + return events.map(e => ({ phaseStartTime: e.startTime, ...e })); } catch { return []; } diff --git a/src/wallet/iframe/iframe-discovery.ts b/src/wallet/iframe/iframe-discovery.ts new file mode 100644 index 0000000..174167e --- /dev/null +++ b/src/wallet/iframe/iframe-discovery.ts @@ -0,0 +1,164 @@ +/** + * Web wallet discovery — creates IframeWalletProvider instances from a list of URLs. + * + * For each configured URL we probe the wallet by loading a tiny invisible iframe, + * waiting for WALLET_READY, then sending a DISCOVERY. On a successful + * DISCOVERY_RESPONSE we emit an IframeWalletProvider to the caller. + * + * This is intentionally lightweight (no key exchange yet) — key exchange happens + * later when the user selects the wallet and calls `provider.establishSecureChannel()`. + */ + +import type { ChainInfo } from '@aztec/aztec.js/account'; +import type { DiscoverySession, WalletProvider } from '@aztec/wallet-sdk/manager'; +import { promiseWithResolvers } from '@aztec/foundation/promise'; +import { IframeMessageType } from './iframe-message-types.ts'; +import { IframeWalletProvider } from './iframe-provider.ts'; + +const PROBE_TIMEOUT_MS = 10_000; + +/** + * Probes a list of web wallet URLs and returns a DiscoverySession compatible + * with WalletManager's getAvailableWallets() interface. + * + * Discovered IframeWalletProvider instances are yielded asynchronously as each + * wallet responds to the probe. + */ +export function discoverWebWallets( + walletUrls: string[], + chainInfo: ChainInfo, +): DiscoverySession { + const { promise: donePromise, resolve: resolveDone } = promiseWithResolvers(); + + let cancelled = false; + const pendingProviders: WalletProvider[] = []; + let pendingResolve: ((result: IteratorResult) => void) | null = null; + let completed = false; + + function emit(provider: WalletProvider) { + if (pendingResolve) { + const resolve = pendingResolve; + pendingResolve = null; + resolve({ value: provider, done: false }); + } else { + pendingProviders.push(provider); + } + } + + function markComplete() { + completed = true; + resolveDone(); + if (pendingResolve) { + const resolve = pendingResolve; + pendingResolve = null; + resolve({ value: undefined as any, done: true }); + } + } + + // Probe all URLs in parallel + const probes = walletUrls.map((url) => probeWallet(url, chainInfo, PROBE_TIMEOUT_MS).then( + (provider) => { if (!cancelled && provider) emit(provider); }, + () => {}, // ignore probe errors + )); + + Promise.all(probes).then(() => { + if (!cancelled) markComplete(); + }); + + const wallets: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + if (completed && pendingProviders.length === 0) { + return { value: undefined as any, done: true }; + } + if (pendingProviders.length > 0) { + return { value: pendingProviders.shift()!, done: false }; + } + return new Promise((resolve) => { + pendingResolve = resolve; + }); + }, + async return() { + markComplete(); + return { value: undefined as any, done: true }; + }, + }; + }, + }; + + return { + wallets, + done: donePromise, + cancel: () => { + cancelled = true; + markComplete(); + }, + }; +} + +/** + * Probes a single web wallet URL. + * Creates a temporary hidden iframe, waits for WALLET_READY, sends DISCOVERY_REQUEST. + * Returns an IframeWalletProvider on success, null on timeout/failure. + */ +async function probeWallet( + walletUrl: string, + chainInfo: ChainInfo, + timeoutMs: number, +): Promise { + const walletOrigin = new URL(walletUrl).origin; + const iframe = document.createElement('iframe'); + iframe.src = walletUrl; + iframe.style.display = 'none'; + iframe.style.width = '0'; + iframe.style.height = '0'; + iframe.style.border = 'none'; + iframe.style.position = 'absolute'; + iframe.style.top = '-9999px'; + document.body.appendChild(iframe); + + return new Promise((resolve) => { + let timer: ReturnType; + + const cleanup = () => { + if (iframe.parentNode) iframe.parentNode.removeChild(iframe); + window.removeEventListener('message', handler); + clearTimeout(timer); + }; + + timer = setTimeout(() => { + cleanup(); + resolve(null); + }, timeoutMs); + + let step: 'waiting-ready' | 'waiting-discovery' = 'waiting-ready'; + const requestId = globalThis.crypto.randomUUID(); + + function handler(event: MessageEvent) { + if (event.origin !== walletOrigin) return; + const msg = event.data; + if (!msg || typeof msg !== 'object') return; + + if (step === 'waiting-ready' && msg.type === IframeMessageType.WALLET_READY) { + step = 'waiting-discovery'; + iframe.contentWindow?.postMessage( + { type: IframeMessageType.DISCOVERY, requestId, appId: 'gregoswap-discovery' }, + walletOrigin, + ); + } else if ( + step === 'waiting-discovery' && + msg.type === IframeMessageType.DISCOVERY_RESPONSE && + msg.requestId === requestId + ) { + const info = msg.walletInfo as { id: string; name: string; version: string; icon?: string }; + cleanup(); + resolve( + new IframeWalletProvider(info.id, info.name, info.icon, walletUrl, chainInfo), + ); + } + } + + window.addEventListener('message', handler); + }); +} diff --git a/src/wallet/iframe/iframe-message-types.ts b/src/wallet/iframe/iframe-message-types.ts new file mode 100644 index 0000000..646a6cd --- /dev/null +++ b/src/wallet/iframe/iframe-message-types.ts @@ -0,0 +1,22 @@ +/** + * Extended message types for iframe wallet communication. + * + * Re-exports WalletMessageType from the SDK and adds iframe-specific types + * needed for postMessage transport (where MessagePort is unavailable). + * + * TODO: Upstream these to @aztec/wallet-sdk/types when iframe wallet support + * is fully integrated into the SDK. + */ +import { WalletMessageType } from '@aztec/wallet-sdk/types'; + +export const IframeMessageType = { + ...WalletMessageType, + /** Wallet iframe ready signal (iframe announces it has loaded) */ + WALLET_READY: 'aztec-wallet-ready', + /** Encrypted wallet message wrapper (for postMessage transport) */ + SECURE_MESSAGE: 'aztec-wallet-secure-message', + /** Encrypted wallet response wrapper (for postMessage transport) */ + SECURE_RESPONSE: 'aztec-wallet-secure-response', + /** Session disconnected notification */ + SESSION_DISCONNECTED: 'aztec-wallet-session-disconnected', +} as const; diff --git a/src/wallet/iframe/iframe-provider.ts b/src/wallet/iframe/iframe-provider.ts new file mode 100644 index 0000000..76b13b4 --- /dev/null +++ b/src/wallet/iframe/iframe-provider.ts @@ -0,0 +1,336 @@ +/** + * IframeWalletProvider — implements WalletProvider for web wallets loaded in iframes. + * + * Flow (mirrors ExtensionProvider from @aztec/wallet-sdk): + * 1. Creates an