@dubsdotapp/expo 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dubsdotapp/expo",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "React Native SDK for the Dubs betting platform",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -24,7 +24,8 @@
24
24
  "clean": "rm -rf dist"
25
25
  },
26
26
  "dependencies": {
27
- "bs58": "^5.0.0"
27
+ "bs58": "^5.0.0",
28
+ "tweetnacl": "^1.0.3"
28
29
  },
29
30
  "peerDependencies": {
30
31
  "@solana/web3.js": "^1.90.0",
package/src/index.ts CHANGED
@@ -67,6 +67,8 @@ export type { DubsProviderProps, DubsContextValue } from './provider';
67
67
  export type { WalletAdapter } from './wallet';
68
68
  export { MwaWalletAdapter } from './wallet';
69
69
  export type { MwaAdapterConfig, MwaTransactFn } from './wallet';
70
+ export { PhantomDeeplinkAdapter } from './wallet';
71
+ export type { PhantomDeeplinkAdapterConfig, PhantomSession } from './wallet';
70
72
 
71
73
  // Hooks
72
74
  export {
@@ -1,10 +1,16 @@
1
1
  import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
2
+ import { Platform } from 'react-native';
2
3
  import { MwaWalletAdapter } from './wallet/mwa-adapter';
4
+ import { PhantomDeeplinkAdapter } from './wallet/phantom-deeplink';
5
+ import type { PhantomSession } from './wallet/phantom-deeplink';
6
+ import type { WalletAdapter } from './wallet/types';
3
7
  import { ConnectWalletScreen } from './ui/ConnectWalletScreen';
4
8
  import type { ConnectWalletScreenProps } from './ui/ConnectWalletScreen';
5
9
  import type { TokenStorage } from './storage';
6
10
  import { STORAGE_KEYS } from './storage';
7
11
 
12
+ const TAG = '[Dubs:ManagedWallet]';
13
+
8
14
  // ── Disconnect Context (internal) ──
9
15
 
10
16
  type DisconnectFn = () => Promise<void>;
@@ -26,7 +32,11 @@ interface ManagedWalletProviderProps {
26
32
  accentColor?: string;
27
33
  appIcon?: string;
28
34
  tagline?: string;
29
- children: (adapter: MwaWalletAdapter) => React.ReactNode;
35
+ /** Deeplink redirect URI for Phantom (required on iOS, optional on Android) */
36
+ redirectUri?: string;
37
+ /** App URL shown in Phantom's connect screen */
38
+ appUrl?: string;
39
+ children: (adapter: WalletAdapter) => React.ReactNode;
30
40
  }
31
41
 
32
42
  // ── Component ──
@@ -39,79 +49,156 @@ export function ManagedWalletProvider({
39
49
  accentColor,
40
50
  appIcon,
41
51
  tagline,
52
+ redirectUri,
53
+ appUrl,
42
54
  children,
43
55
  }: ManagedWalletProviderProps) {
44
56
  const [connected, setConnected] = useState(false);
45
57
  const [connecting, setConnecting] = useState(false);
46
58
  const [isReady, setIsReady] = useState(false);
47
59
  const [error, setError] = useState<string | null>(null);
48
- const adapterRef = useRef<MwaWalletAdapter | null>(null);
60
+
61
+ // Determine which adapter to use:
62
+ // - iOS always uses Phantom deeplinks
63
+ // - Android uses MWA (default) unless redirectUri is provided and MWA is unavailable
64
+ const usePhantom = Platform.OS === 'ios' && !!redirectUri;
65
+
66
+ console.log(TAG, `Platform: ${Platform.OS}, redirectUri: ${redirectUri ? 'provided' : 'not set'}, usePhantom: ${usePhantom}`);
67
+
68
+ const adapterRef = useRef<WalletAdapter | null>(null);
49
69
  const transactRef = useRef<any>(null);
50
70
 
51
71
  // Lazily create adapter
52
72
  if (!adapterRef.current) {
53
- adapterRef.current = new MwaWalletAdapter({
54
- transact: (...args: any[]) => {
55
- if (!transactRef.current) {
56
- throw new Error(
57
- '@dubsdotapp/expo: @solana-mobile/mobile-wallet-adapter-protocol-web3js is required. ' +
58
- 'Install it with: npm install @solana-mobile/mobile-wallet-adapter-protocol-web3js',
59
- );
60
- }
61
- return transactRef.current(...args);
62
- },
63
- appIdentity: { name: appName },
64
- cluster,
65
- onAuthTokenChange: (token) => {
66
- if (token) {
67
- storage.setItem(STORAGE_KEYS.MWA_AUTH_TOKEN, token).catch(() => {});
68
- } else {
69
- storage.deleteItem(STORAGE_KEYS.MWA_AUTH_TOKEN).catch(() => {});
70
- }
71
- },
72
- });
73
+ if (usePhantom) {
74
+ console.log(TAG, 'Creating PhantomDeeplinkAdapter');
75
+ adapterRef.current = new PhantomDeeplinkAdapter({
76
+ redirectUri: redirectUri!,
77
+ appUrl,
78
+ cluster,
79
+ onSessionChange: (session) => {
80
+ if (session) {
81
+ console.log(TAG, 'Phantom session changed — saving to storage, wallet:', session.walletPublicKey);
82
+ storage.setItem(STORAGE_KEYS.PHANTOM_SESSION, JSON.stringify(session)).catch((err) => {
83
+ console.log(TAG, 'Failed to save Phantom session:', err);
84
+ });
85
+ } else {
86
+ console.log(TAG, 'Phantom session cleared — removing from storage');
87
+ storage.deleteItem(STORAGE_KEYS.PHANTOM_SESSION).catch((err) => {
88
+ console.log(TAG, 'Failed to delete Phantom session:', err);
89
+ });
90
+ }
91
+ },
92
+ });
93
+ } else {
94
+ console.log(TAG, 'Creating MwaWalletAdapter');
95
+ adapterRef.current = new MwaWalletAdapter({
96
+ transact: (...args: any[]) => {
97
+ if (!transactRef.current) {
98
+ throw new Error(
99
+ '@dubsdotapp/expo: @solana-mobile/mobile-wallet-adapter-protocol-web3js is required. ' +
100
+ 'Install it with: npm install @solana-mobile/mobile-wallet-adapter-protocol-web3js',
101
+ );
102
+ }
103
+ return transactRef.current(...args);
104
+ },
105
+ appIdentity: { name: appName },
106
+ cluster,
107
+ onAuthTokenChange: (token) => {
108
+ if (token) {
109
+ storage.setItem(STORAGE_KEYS.MWA_AUTH_TOKEN, token).catch(() => {});
110
+ } else {
111
+ storage.deleteItem(STORAGE_KEYS.MWA_AUTH_TOKEN).catch(() => {});
112
+ }
113
+ },
114
+ });
115
+ }
73
116
  }
74
117
  const adapter = adapterRef.current;
75
118
 
76
- // Dynamic-import transact on mount
119
+ // Restore session / dynamic-import on mount
77
120
  useEffect(() => {
78
121
  let cancelled = false;
79
122
 
80
123
  (async () => {
81
- try {
82
- const mwa = await import('@solana-mobile/mobile-wallet-adapter-protocol-web3js');
83
- if (cancelled) return;
84
- transactRef.current = mwa.transact;
85
- } catch {
86
- // MWA not installed — transact calls will throw a clear error
87
- }
124
+ if (usePhantom) {
125
+ console.log(TAG, 'Phantom path checking for saved session...');
126
+ // Attempt to restore a saved Phantom session
127
+ try {
128
+ const savedJson = await storage.getItem(STORAGE_KEYS.PHANTOM_SESSION);
129
+ if (savedJson && !cancelled) {
130
+ console.log(TAG, 'Found saved Phantom session, restoring...');
131
+ const saved: PhantomSession = JSON.parse(savedJson);
132
+ (adapter as PhantomDeeplinkAdapter).restoreSession(saved);
133
+ if (!cancelled) {
134
+ console.log(TAG, 'Session restored, marking connected');
135
+ setConnected(true);
136
+ }
137
+ } else {
138
+ console.log(TAG, 'No saved Phantom session found');
139
+ }
140
+ } catch (err) {
141
+ console.log(TAG, 'Failed to restore Phantom session:', err instanceof Error ? err.message : err);
142
+ // Session expired or corrupt — user will tap Connect manually
143
+ } finally {
144
+ if (!cancelled) {
145
+ console.log(TAG, 'Phantom init complete, marking ready');
146
+ setIsReady(true);
147
+ }
148
+ }
149
+ } else {
150
+ console.log(TAG, 'MWA path — dynamic-importing transact...');
151
+ // MWA path — dynamic-import transact
152
+ try {
153
+ const mwa = await import('@solana-mobile/mobile-wallet-adapter-protocol-web3js');
154
+ if (cancelled) return;
155
+ transactRef.current = mwa.transact;
156
+ console.log(TAG, 'MWA transact loaded');
157
+ } catch {
158
+ console.log(TAG, 'MWA not installed — transact will throw on use');
159
+ }
88
160
 
89
- // Attempt silent reconnect from saved token
90
- try {
91
- const savedToken = await storage.getItem(STORAGE_KEYS.MWA_AUTH_TOKEN);
92
- if (savedToken && !cancelled) {
93
- adapter.setAuthToken(savedToken);
94
- await adapter.connect();
95
- if (!cancelled) setConnected(true);
161
+ // Attempt silent reconnect from saved auth token
162
+ try {
163
+ const savedToken = await storage.getItem(STORAGE_KEYS.MWA_AUTH_TOKEN);
164
+ if (savedToken && !cancelled) {
165
+ console.log(TAG, 'Found saved MWA auth token, reconnecting...');
166
+ (adapter as MwaWalletAdapter).setAuthToken(savedToken);
167
+ await adapter.connect!();
168
+ if (!cancelled) {
169
+ console.log(TAG, 'MWA reconnected from saved token');
170
+ setConnected(true);
171
+ }
172
+ } else {
173
+ console.log(TAG, 'No saved MWA auth token');
174
+ }
175
+ } catch (err) {
176
+ console.log(TAG, 'MWA silent reconnect failed:', err instanceof Error ? err.message : err);
177
+ } finally {
178
+ if (!cancelled) {
179
+ console.log(TAG, 'MWA init complete, marking ready');
180
+ setIsReady(true);
181
+ }
96
182
  }
97
- } catch {
98
- // Token expired or wallet unavailable — user will tap Connect manually
99
- } finally {
100
- if (!cancelled) setIsReady(true);
101
183
  }
102
184
  })();
103
185
 
104
- return () => { cancelled = true; };
105
- }, [adapter, storage]);
186
+ return () => {
187
+ cancelled = true;
188
+ };
189
+ }, [adapter, storage, usePhantom]);
106
190
 
107
191
  const handleConnect = useCallback(async () => {
192
+ console.log(TAG, 'handleConnect() — user tapped connect');
108
193
  setConnecting(true);
109
194
  setError(null);
110
195
  try {
111
- await adapter.connect();
196
+ await adapter.connect!();
197
+ console.log(TAG, 'handleConnect() — success, wallet:', adapter.publicKey?.toBase58());
112
198
  setConnected(true);
113
199
  } catch (err) {
114
200
  const message = err instanceof Error ? err.message : 'Connection failed';
201
+ console.log(TAG, 'handleConnect() — failed:', message);
115
202
  setError(message);
116
203
  } finally {
117
204
  setConnecting(false);
@@ -119,14 +206,30 @@ export function ManagedWalletProvider({
119
206
  }, [adapter]);
120
207
 
121
208
  const disconnect = useCallback(async () => {
122
- adapter.disconnect();
209
+ console.log(TAG, 'disconnect() — clearing all state');
210
+ adapter.disconnect?.();
123
211
  await storage.deleteItem(STORAGE_KEYS.MWA_AUTH_TOKEN).catch(() => {});
212
+ await storage.deleteItem(STORAGE_KEYS.PHANTOM_SESSION).catch(() => {});
124
213
  await storage.deleteItem(STORAGE_KEYS.JWT_TOKEN).catch(() => {});
125
214
  setConnected(false);
215
+ console.log(TAG, 'disconnect() — done');
126
216
  }, [adapter, storage]);
127
217
 
218
+ // Cleanup deeplink handler on unmount
219
+ useEffect(() => {
220
+ return () => {
221
+ if (usePhantom && adapter && 'destroy' in adapter) {
222
+ console.log(TAG, 'Unmounting — destroying Phantom adapter');
223
+ (adapter as PhantomDeeplinkAdapter).destroy();
224
+ }
225
+ };
226
+ }, [adapter, usePhantom]);
227
+
128
228
  // Show nothing until we've attempted silent reconnect
129
- if (!isReady) return null;
229
+ if (!isReady) {
230
+ console.log(TAG, 'Not ready yet — rendering null');
231
+ return null;
232
+ }
130
233
 
131
234
  // Not connected — show connect screen
132
235
  if (!connected) {
@@ -150,6 +253,7 @@ export function ManagedWalletProvider({
150
253
  }
151
254
 
152
255
  // Connected — render children with adapter + disconnect
256
+ console.log(TAG, 'Rendering children — connected with wallet:', adapter.publicKey?.toBase58());
153
257
  return (
154
258
  <DisconnectContext.Provider value={disconnect}>
155
259
  {children(adapter)}
package/src/provider.tsx CHANGED
@@ -51,6 +51,10 @@ export interface DubsProviderProps {
51
51
  renderRegistration?: (props: RegistrationScreenProps) => React.ReactNode;
52
52
  /** Set to false to skip AuthGate and connect screen (headless/BYOA mode). Default: true. */
53
53
  managed?: boolean;
54
+ /** Deeplink redirect URI for Phantom wallet (required for iOS support). */
55
+ redirectUri?: string;
56
+ /** App URL shown in Phantom's connect screen. */
57
+ appUrl?: string;
54
58
  }
55
59
 
56
60
  // ── Provider ──
@@ -69,6 +73,8 @@ export function DubsProvider({
69
73
  renderError,
70
74
  renderRegistration,
71
75
  managed = true,
76
+ redirectUri,
77
+ appUrl,
72
78
  }: DubsProviderProps) {
73
79
  // Resolve network config — explicit props override network defaults
74
80
  const config = NETWORK_CONFIG[network];
@@ -130,6 +136,8 @@ export function DubsProvider({
130
136
  accentColor={uiConfig.accentColor}
131
137
  appIcon={uiConfig.appIcon}
132
138
  tagline={uiConfig.tagline}
139
+ redirectUri={redirectUri}
140
+ appUrl={appUrl}
133
141
  >
134
142
  {(adapter) => (
135
143
  <ManagedInner
package/src/storage.ts CHANGED
@@ -7,6 +7,7 @@ export interface TokenStorage {
7
7
  export const STORAGE_KEYS = {
8
8
  MWA_AUTH_TOKEN: 'dubs_mwa_auth_token',
9
9
  JWT_TOKEN: 'dubs_jwt_token',
10
+ PHANTOM_SESSION: 'dubs_phantom_session',
10
11
  } as const;
11
12
 
12
13
  /**
@@ -1,3 +1,5 @@
1
1
  export type { WalletAdapter } from './types';
2
2
  export { MwaWalletAdapter } from './mwa-adapter';
3
3
  export type { MwaAdapterConfig, MwaTransactFn } from './mwa-adapter';
4
+ export { PhantomDeeplinkAdapter } from './phantom-deeplink';
5
+ export type { PhantomDeeplinkAdapterConfig, PhantomSession } from './phantom-deeplink';
@@ -0,0 +1,49 @@
1
+ import nacl from 'tweetnacl';
2
+ import bs58 from 'bs58';
3
+
4
+ export interface KeyPair {
5
+ publicKey: Uint8Array;
6
+ secretKey: Uint8Array;
7
+ }
8
+
9
+ /** Generate an x25519 keypair for Diffie-Hellman key exchange with Phantom. */
10
+ export function generateKeyPair(): KeyPair {
11
+ const kp = nacl.box.keyPair();
12
+ return { publicKey: kp.publicKey, secretKey: kp.secretKey };
13
+ }
14
+
15
+ /** Derive a shared secret from our secret key and Phantom's public key. */
16
+ export function deriveSharedSecret(
17
+ ourSecretKey: Uint8Array,
18
+ theirPublicKey: Uint8Array,
19
+ ): Uint8Array {
20
+ return nacl.box.before(theirPublicKey, ourSecretKey);
21
+ }
22
+
23
+ /** Encrypt a payload for Phantom using the shared secret. */
24
+ export function encryptPayload(
25
+ payload: object,
26
+ sharedSecret: Uint8Array,
27
+ ): { nonce: string; ciphertext: string } {
28
+ const nonce = nacl.randomBytes(24);
29
+ const message = new TextEncoder().encode(JSON.stringify(payload));
30
+ const encrypted = nacl.box.after(message, nonce, sharedSecret);
31
+ if (!encrypted) throw new Error('Encryption failed');
32
+ return {
33
+ nonce: bs58.encode(nonce),
34
+ ciphertext: bs58.encode(encrypted),
35
+ };
36
+ }
37
+
38
+ /** Decrypt a payload returned by Phantom using the shared secret. */
39
+ export function decryptPayload(
40
+ ciphertextBase58: string,
41
+ nonceBase58: string,
42
+ sharedSecret: Uint8Array,
43
+ ): Record<string, any> {
44
+ const ciphertext = bs58.decode(ciphertextBase58);
45
+ const nonce = bs58.decode(nonceBase58);
46
+ const decrypted = nacl.box.open.after(ciphertext, nonce, sharedSecret);
47
+ if (!decrypted) throw new Error('Decryption failed — invalid shared secret or corrupted data');
48
+ return JSON.parse(new TextDecoder().decode(decrypted));
49
+ }
@@ -0,0 +1,177 @@
1
+ import { Linking } from 'react-native';
2
+
3
+ const TAG = '[Dubs:DeeplinkHandler]';
4
+
5
+ export interface DeeplinkResponse {
6
+ /** All query parameters from the redirect URL */
7
+ params: Record<string, string>;
8
+ }
9
+
10
+ interface PendingRequest {
11
+ resolve: (response: DeeplinkResponse) => void;
12
+ reject: (error: Error) => void;
13
+ timer: ReturnType<typeof setTimeout>;
14
+ }
15
+
16
+ /**
17
+ * Manages the deeplink round-trip with Phantom:
18
+ * 1. Opens a Phantom deeplink URL
19
+ * 2. Waits for Phantom to redirect back to our `redirectBase`
20
+ * 3. Routes the response to the correct pending promise via request ID
21
+ */
22
+ export class DeeplinkHandler {
23
+ private readonly redirectBase: string;
24
+ private readonly pending = new Map<string, PendingRequest>();
25
+ private subscription: ReturnType<typeof Linking.addEventListener> | null = null;
26
+
27
+ constructor(redirectBase: string) {
28
+ // Strip trailing slashes for consistent matching
29
+ this.redirectBase = redirectBase.replace(/\/+$/, '');
30
+ console.log(TAG, 'Created with redirectBase:', this.redirectBase);
31
+ }
32
+
33
+ /** Start listening for incoming deeplinks. Call once on adapter init. */
34
+ start(): void {
35
+ if (this.subscription) {
36
+ console.log(TAG, 'Already listening, skipping start()');
37
+ return;
38
+ }
39
+
40
+ console.log(TAG, 'Starting URL listener');
41
+ this.subscription = Linking.addEventListener('url', ({ url }) => {
42
+ console.log(TAG, 'Received URL event:', url);
43
+ this.handleUrl(url);
44
+ });
45
+
46
+ // Check for cold-start URL (app was launched via deeplink)
47
+ Linking.getInitialURL().then((url) => {
48
+ if (url) {
49
+ console.log(TAG, 'Cold-start URL found:', url);
50
+ this.handleUrl(url);
51
+ } else {
52
+ console.log(TAG, 'No cold-start URL');
53
+ }
54
+ });
55
+ }
56
+
57
+ /** Stop listening and reject all pending requests. */
58
+ destroy(): void {
59
+ console.log(TAG, 'Destroying — pending requests:', this.pending.size);
60
+ this.subscription?.remove();
61
+ this.subscription = null;
62
+
63
+ for (const [id, req] of this.pending) {
64
+ clearTimeout(req.timer);
65
+ req.reject(new Error('DeeplinkHandler destroyed'));
66
+ this.pending.delete(id);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Open a Phantom deeplink and wait for the redirect response.
72
+ * @param url The full Phantom deeplink URL to open
73
+ * @param requestId Unique ID embedded in the redirect_link param
74
+ * @param timeoutMs How long to wait before rejecting (default 120s)
75
+ */
76
+ async send(url: string, requestId: string, timeoutMs = 120_000): Promise<DeeplinkResponse> {
77
+ console.log(TAG, `send() requestId=${requestId} timeout=${timeoutMs}ms`);
78
+ console.log(TAG, 'Opening URL:', url.substring(0, 120) + (url.length > 120 ? '...' : ''));
79
+
80
+ return new Promise<DeeplinkResponse>((resolve, reject) => {
81
+ const timer = setTimeout(() => {
82
+ console.log(TAG, `Timeout reached for requestId=${requestId}`);
83
+ this.pending.delete(requestId);
84
+ reject(new Error(`Phantom deeplink timed out after ${timeoutMs / 1000}s`));
85
+ }, timeoutMs);
86
+
87
+ this.pending.set(requestId, { resolve, reject, timer });
88
+
89
+ Linking.openURL(url).catch((err) => {
90
+ console.log(TAG, `Failed to open URL: ${err.message}`);
91
+ this.pending.delete(requestId);
92
+ clearTimeout(timer);
93
+ reject(new Error(`Failed to open Phantom: ${err.message}`));
94
+ });
95
+ });
96
+ }
97
+
98
+ private handleUrl(url: string): void {
99
+ // Only process URLs that match our redirect base
100
+ if (!url.startsWith(this.redirectBase)) {
101
+ console.log(TAG, 'Ignoring URL (not our redirect base):', url.substring(0, 80));
102
+ return;
103
+ }
104
+
105
+ console.log(TAG, 'Processing redirect URL:', url.substring(0, 120) + (url.length > 120 ? '...' : ''));
106
+
107
+ const parsed = new URL(url);
108
+ const params: Record<string, string> = {};
109
+ parsed.searchParams.forEach((value, key) => {
110
+ params[key] = value;
111
+ });
112
+
113
+ const paramKeys = Object.keys(params);
114
+ console.log(TAG, 'Parsed params keys:', paramKeys.join(', '));
115
+
116
+ // Check for Phantom error
117
+ if (params.errorCode) {
118
+ const errorMessage = params.errorMessage
119
+ ? decodeURIComponent(params.errorMessage)
120
+ : `Phantom error code: ${params.errorCode}`;
121
+ console.log(TAG, `Phantom returned error: code=${params.errorCode} message="${errorMessage}"`);
122
+
123
+ // Route error to the pending request if we can identify it
124
+ const requestId = this.extractRequestId(url);
125
+ if (requestId && this.pending.has(requestId)) {
126
+ console.log(TAG, `Routing error to requestId=${requestId}`);
127
+ const req = this.pending.get(requestId)!;
128
+ clearTimeout(req.timer);
129
+ this.pending.delete(requestId);
130
+ req.reject(new Error(errorMessage));
131
+ return;
132
+ }
133
+
134
+ // If we can't route it, reject all pending (only one is typically active)
135
+ console.log(TAG, `Cannot route error to specific request, rejecting all ${this.pending.size} pending`);
136
+ for (const [id, req] of this.pending) {
137
+ clearTimeout(req.timer);
138
+ req.reject(new Error(errorMessage));
139
+ this.pending.delete(id);
140
+ }
141
+ return;
142
+ }
143
+
144
+ // Route success to the correct pending request
145
+ const requestId = this.extractRequestId(url);
146
+ console.log(TAG, `Extracted requestId=${requestId}, pending requests: [${[...this.pending.keys()].join(', ')}]`);
147
+
148
+ if (requestId && this.pending.has(requestId)) {
149
+ console.log(TAG, `Resolving requestId=${requestId}`);
150
+ const req = this.pending.get(requestId)!;
151
+ clearTimeout(req.timer);
152
+ this.pending.delete(requestId);
153
+ req.resolve({ params });
154
+ return;
155
+ }
156
+
157
+ // Fallback: resolve the first (and usually only) pending request
158
+ const first = this.pending.entries().next().value;
159
+ if (first) {
160
+ const [id, req] = first;
161
+ console.log(TAG, `Fallback: resolving first pending requestId=${id}`);
162
+ clearTimeout(req.timer);
163
+ this.pending.delete(id);
164
+ req.resolve({ params });
165
+ } else {
166
+ console.log(TAG, 'No pending requests to resolve — redirect may have arrived late');
167
+ }
168
+ }
169
+
170
+ /** Try to extract a request ID from the URL path segment after the redirect base. */
171
+ private extractRequestId(url: string): string | null {
172
+ const afterBase = url.slice(this.redirectBase.length);
173
+ // Expected format: /requestId?params... or ?params...
174
+ const match = afterBase.match(/^\/?([a-zA-Z0-9_-]+)/);
175
+ return match?.[1] ?? null;
176
+ }
177
+ }
@@ -0,0 +1,2 @@
1
+ export { PhantomDeeplinkAdapter } from './phantom-deeplink-adapter';
2
+ export type { PhantomDeeplinkAdapterConfig, PhantomSession } from './phantom-deeplink-adapter';