import { Linking } from 'react-native'; const TAG = '[Dubs:DeeplinkHandler]'; export interface DeeplinkResponse { /** All query parameters from the redirect URL */ params: Record; } interface PendingRequest { resolve: (response: DeeplinkResponse) => void; reject: (error: Error) => void; timer: ReturnType; } /** * Manages the deeplink round-trip with Phantom: * 1. Opens a Phantom deeplink URL * 2. Waits for Phantom to redirect back to our `redirectBase` * 3. Routes the response to the correct pending promise via request ID */ export class DeeplinkHandler { private readonly redirectBase: string; private readonly pending = new Map(); private subscription: ReturnType | null = null; private _coldStartUrl: string | null = null; constructor(redirectBase: string) { // Strip trailing slashes for consistent matching this.redirectBase = redirectBase.replace(/\/+$/, ''); console.log(TAG, 'Created with redirectBase:', this.redirectBase); } /** Start listening for incoming deeplinks. Call once on adapter init. */ start(): void { if (this.subscription) { console.log(TAG, 'Already listening, skipping start()'); return; } console.log(TAG, 'Starting URL listener'); this.subscription = Linking.addEventListener('url', ({ url }) => { console.log(TAG, 'Received URL event:', url); this.handleUrl(url); }); // Check for cold-start URL (app was launched via deeplink) Linking.getInitialURL().then((url) => { if (url) { console.log(TAG, 'Cold-start URL found:', url); this.handleUrl(url); } else { console.log(TAG, 'No cold-start URL'); } }); } /** Stop listening and reject all pending requests. */ destroy(): void { console.log(TAG, 'Destroying — pending requests:', this.pending.size); this.subscription?.remove(); this.subscription = null; for (const [id, req] of this.pending) { clearTimeout(req.timer); req.reject(new Error('DeeplinkHandler destroyed')); this.pending.delete(id); } } /** * Open a Phantom deeplink and wait for the redirect response. * @param url The full Phantom deeplink URL to open * @param requestId Unique ID embedded in the redirect_link param * @param timeoutMs How long to wait before rejecting (default 120s) */ async send(url: string, requestId: string, timeoutMs = 120_000): Promise { console.log(TAG, `send() requestId=${requestId} timeout=${timeoutMs}ms`); console.log(TAG, 'Opening URL:', url.substring(0, 120) + (url.length > 120 ? '...' : '')); return new Promise((resolve, reject) => { const timer = setTimeout(() => { console.log(TAG, `Timeout reached for requestId=${requestId}`); this.pending.delete(requestId); reject(new Error(`Phantom deeplink timed out after ${timeoutMs / 1000}s`)); }, timeoutMs); this.pending.set(requestId, { resolve, reject, timer }); Linking.openURL(url).catch((err) => { console.log(TAG, `Failed to open URL: ${err.message}`); this.pending.delete(requestId); clearTimeout(timer); reject(new Error(`Failed to open Phantom: ${err.message}`)); }); }); } private handleUrl(url: string): void { // Only process URLs that match our redirect base if (!url.startsWith(this.redirectBase)) { console.log(TAG, 'Ignoring URL (not our redirect base):', url.substring(0, 80)); return; } console.log(TAG, 'Processing redirect URL:', url.substring(0, 120) + (url.length > 120 ? '...' : '')); const parsed = new URL(url); const params: Record = {}; parsed.searchParams.forEach((value, key) => { params[key] = value; }); const paramKeys = Object.keys(params); console.log(TAG, 'Parsed params keys:', paramKeys.join(', ')); // Check for Phantom error if (params.errorCode) { const errorMessage = params.errorMessage ? decodeURIComponent(params.errorMessage) : `Phantom error code: ${params.errorCode}`; console.log(TAG, `Phantom returned error: code=${params.errorCode} message="${errorMessage}"`); // Route error to the pending request if we can identify it const requestId = this.extractRequestId(url); if (requestId && this.pending.has(requestId)) { console.log(TAG, `Routing error to requestId=${requestId}`); const req = this.pending.get(requestId)!; clearTimeout(req.timer); this.pending.delete(requestId); req.reject(new Error(errorMessage)); return; } // If we can't route it, reject all pending (only one is typically active) console.log(TAG, `Cannot route error to specific request, rejecting all ${this.pending.size} pending`); for (const [id, req] of this.pending) { clearTimeout(req.timer); req.reject(new Error(errorMessage)); this.pending.delete(id); } return; } // Route success to the correct pending request const requestId = this.extractRequestId(url); console.log(TAG, `Extracted requestId=${requestId}, pending requests: [${[...this.pending.keys()].join(', ')}]`); if (requestId && this.pending.has(requestId)) { console.log(TAG, `Resolving requestId=${requestId}`); const req = this.pending.get(requestId)!; clearTimeout(req.timer); this.pending.delete(requestId); req.resolve({ params }); return; } // Fallback: resolve the first (and usually only) pending request const first = this.pending.entries().next().value; if (first) { const [id, req] = first; console.log(TAG, `Fallback: resolving first pending requestId=${id}`); clearTimeout(req.timer); this.pending.delete(id); req.resolve({ params }); } else { console.log(TAG, 'No pending requests to resolve — stashing as cold-start URL'); this._coldStartUrl = url; } } /** Return and clear the stashed cold-start URL (if any). */ getColdStartUrl(): string | null { const url = this._coldStartUrl; this._coldStartUrl = null; return url; } /** Extract the request ID from the dubs_rid query parameter. */ private extractRequestId(url: string): string | null { try { const parsed = new URL(url); return parsed.searchParams.get('dubs_rid'); } catch { return null; } } }