@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/dist/index.d.mts +68 -2
- package/dist/index.d.ts +68 -2
- package/dist/index.js +759 -284
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +521 -47
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +2 -0
- package/src/managed-wallet.tsx +150 -46
- package/src/provider.tsx +8 -0
- package/src/storage.ts +1 -0
- package/src/wallet/index.ts +2 -0
- package/src/wallet/phantom-deeplink/crypto.ts +49 -0
- package/src/wallet/phantom-deeplink/deeplink-handler.ts +177 -0
- package/src/wallet/phantom-deeplink/index.ts +2 -0
- package/src/wallet/phantom-deeplink/phantom-deeplink-adapter.ts +325 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dubsdotapp/expo",
|
|
3
|
-
"version": "0.2.
|
|
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 {
|
package/src/managed-wallet.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
//
|
|
119
|
+
// Restore session / dynamic-import on mount
|
|
77
120
|
useEffect(() => {
|
|
78
121
|
let cancelled = false;
|
|
79
122
|
|
|
80
123
|
(async () => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 () => {
|
|
105
|
-
|
|
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
|
-
|
|
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)
|
|
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
package/src/wallet/index.ts
CHANGED
|
@@ -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
|
+
}
|