diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a2a341c..504af16 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,6 +12,8 @@ on: env: PASSWORD: ${{ secrets.PASSWORD }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} permissions: contents: read @@ -67,13 +69,17 @@ jobs: - name: Pull Vercel Environment Information run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} - - name: Deploy to Vercel + - name: Build and deploy to Vercel id: deploy + env: + VITE_WEB_WALLET_URL: ${{ vars.VITE_WEB_WALLET_URL }} run: | if [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref }}" == "refs/heads/main" ]; then - DEPLOY_URL=$(vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }} --archive=tgz --yes) + vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} + DEPLOY_URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} --archive=tgz --yes) else - DEPLOY_URL=$(vercel deploy --token=${{ secrets.VERCEL_TOKEN }} --archive=tgz --yes) + vercel build --token=${{ secrets.VERCEL_TOKEN }} + DEPLOY_URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} --archive=tgz --yes) fi echo "url=$DEPLOY_URL" >> $GITHUB_OUTPUT 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/contracts/proof_of_password/Nargo.toml b/contracts/proof_of_password/Nargo.toml index 74ea072..67d2f55 100644 --- a/contracts/proof_of_password/Nargo.toml +++ b/contracts/proof_of_password/Nargo.toml @@ -4,7 +4,7 @@ type = "contract" authors = [""] [dependencies] -aztec = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.0.0-devnet.2-patch.1", directory = "noir-projects/aztec-nr/aztec" } -token = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.0.0-devnet.2-patch.1", directory = "noir-projects/noir-contracts/contracts/app/token_contract" } +aztec = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.0.0-devnet.2-patch.3", directory = "noir-projects/aztec-nr/aztec" } +token = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.0.0-devnet.2-patch.3", directory = "noir-projects/noir-contracts/contracts/app/token_contract" } poseidon = { tag = "v0.1.1", git = "https://github.com/noir-lang/poseidon" } -compressed_string = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.0.0-devnet.2-patch.1", directory = "noir-projects/aztec-nr/compressed-string" } \ No newline at end of file +compressed_string = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.0.0-devnet.2-patch.3", directory = "noir-projects/aztec-nr/compressed-string" } \ No newline at end of file diff --git a/package.json b/package.json index e1b9750..b712ac2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "serve": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "copy:dependencies": "cd contracts && nargo check && WORKDIR=$(pwd) && cd $HOME/nargo/github.com/AztecProtocol/aztec-packages/v4.0.0-devnet.2-patch.1/noir-projects/noir-contracts && aztec compile --package token_contract && mkdir -p $WORKDIR/target && cp $HOME/nargo/github.com/AztecProtocol/aztec-packages/v4.0.0-devnet.2-patch.1/noir-projects/noir-contracts/target/token_contract-Token.json $WORKDIR/target/token_contract-Token.json", + "copy:dependencies": "cd contracts && nargo check && WORKDIR=$(pwd) && cd $HOME/nargo/github.com/AztecProtocol/aztec-packages/v4.0.0-devnet.2-patch.3/noir-projects/noir-contracts && aztec compile --package token_contract && mkdir -p $WORKDIR/target && cp $HOME/nargo/github.com/AztecProtocol/aztec-packages/v4.0.0-devnet.2-patch.3/noir-projects/noir-contracts/target/token_contract-Token.json $WORKDIR/target/token_contract-Token.json", "compile:contracts": "cd contracts && aztec compile --package proof_of_password && aztec codegen ./target/proof_of_password-ProofOfPassword.json", "test": "cd contracts && aztec test", "preview": "vite preview", @@ -23,16 +23,16 @@ "local-aztec:status": "node scripts/toggle-local-aztec.js status" }, "dependencies": { - "@aztec/accounts": "v4.0.0-devnet.2-patch.1", - "@aztec/aztec.js": "v4.0.0-devnet.2-patch.1", - "@aztec/constants": "v4.0.0-devnet.2-patch.1", - "@aztec/entrypoints": "v4.0.0-devnet.2-patch.1", - "@aztec/foundation": "v4.0.0-devnet.2-patch.1", - "@aztec/noir-contracts.js": "v4.0.0-devnet.2-patch.1", - "@aztec/protocol-contracts": "v4.0.0-devnet.2-patch.1", - "@aztec/pxe": "v4.0.0-devnet.2-patch.1", - "@aztec/stdlib": "v4.0.0-devnet.2-patch.1", - "@aztec/wallet-sdk": "v4.0.0-devnet.2-patch.1", + "@aztec/accounts": "v4.0.0-devnet.2-patch.3", + "@aztec/aztec.js": "v4.0.0-devnet.2-patch.3", + "@aztec/constants": "v4.0.0-devnet.2-patch.3", + "@aztec/entrypoints": "v4.0.0-devnet.2-patch.3", + "@aztec/foundation": "v4.0.0-devnet.2-patch.3", + "@aztec/noir-contracts.js": "v4.0.0-devnet.2-patch.3", + "@aztec/protocol-contracts": "v4.0.0-devnet.2-patch.3", + "@aztec/pxe": "v4.0.0-devnet.2-patch.3", + "@aztec/stdlib": "v4.0.0-devnet.2-patch.3", + "@aztec/wallet-sdk": "v4.0.0-devnet.2-patch.3", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mui/icons-material": "^6.3.1", @@ -45,7 +45,7 @@ "zod": "^3.23.8" }, "devDependencies": { - "@aztec/wallets": "v4.0.0-devnet.2-patch.1", + "@aztec/wallets": "v4.0.0-devnet.2-patch.3", "@eslint/js": "^9.18.0", "@playwright/test": "1.49.0", "@types/buffer-json": "^2", 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..cccdfac 100644 --- a/src/embedded_wallet.ts +++ b/src/embedded_wallet.ts @@ -12,13 +12,9 @@ import { txProgress, type PhaseTiming, type TxProgressEvent } from './tx-progres import type { FieldsOf } from '@aztec/foundation/types'; import { GasSettings } from '@aztec/stdlib/gas'; import { getSponsoredFPCData } from './services'; -import { - EmbeddedWallet as EmbeddedWalletBase, - type AccountType, - type EmbeddedWalletOptions, -} from '@aztec/wallets/embedded'; +import { EmbeddedWallet as EmbeddedWalletBase, type EmbeddedWalletOptions } from '@aztec/wallets/embedded'; import { AccountManager } from '@aztec/aztec.js/wallet'; -import { Fq, Fr } from '@aztec/foundation/curves/bn254'; +import { Fr } from '@aztec/foundation/curves/bn254'; export class EmbeddedWallet extends EmbeddedWalletBase { private skipAuthWitExtraction = false; @@ -30,48 +26,6 @@ export class EmbeddedWallet extends EmbeddedWalletBase { return super.create(nodeOrUrl, options); } - // TODO: remove this once the avoiding reregistration optimization lands on aztec-packages - protected override async createAccountInternal( - type: AccountType, - secret: Fr, - salt: Fr, - signingKey: Buffer, - ): Promise { - let contract; - switch (type) { - case 'schnorr': { - contract = await this.accountContracts.getSchnorrAccountContract(Fq.fromBuffer(signingKey)); - break; - } - case 'ecdsasecp256k1': { - contract = await this.accountContracts.getEcdsaKAccountContract(signingKey); - break; - } - case 'ecdsasecp256r1': { - contract = await this.accountContracts.getEcdsaRAccountContract(signingKey); - break; - } - default: { - throw new Error(`Unknown account type ${type}`); - } - } - - const accountManager = await AccountManager.create(this, secret, contract, salt); - - const instance = accountManager.getInstance(); - const existingInstance = await this.pxe.getContractInstance(instance.address); - if (!existingInstance) { - const existingArtifact = await this.pxe.getContractArtifact(instance.currentContractClassId); - await this.registerContract( - instance, - !existingArtifact ? await accountManager.getAccountContract().getContractArtifact() : undefined, - accountManager.getSecretKey(), - ); - } - - return accountManager; - } - /** * Returns the AccountManager for the first stored account, creating a new Schnorr * account (with random credentials) if none exist yet. The account is persisted in @@ -162,7 +116,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 +132,7 @@ export class EmbeddedWallet extends EmbeddedWalletBase { label, phase, startTime, - phaseTimings: { ...phaseTimings }, + phaseStartTime: Date.now(), phases: [...phases], ...extra, }); @@ -219,7 +172,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 +223,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 +259,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 +275,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..1713876 --- /dev/null +++ b/src/wallet/iframe/iframe-discovery.ts @@ -0,0 +1,165 @@ +/** + * 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'; + iframe.allow = 'storage-access; cross-origin-isolated'; + 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..477f971 --- /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