@dubsdotapp/expo 0.2.22 → 0.2.23

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.22",
3
+ "version": "0.2.23",
4
4
  "description": "React Native SDK for the Dubs betting platform",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -29,6 +29,7 @@ function getOrCreatePhantomAdapter(config: {
29
29
  redirectUri: config.redirectUri,
30
30
  appUrl: config.appUrl,
31
31
  cluster: config.cluster,
32
+ storage: config.storage,
32
33
  onSessionChange: (session) => {
33
34
  if (session) {
34
35
  console.log(TAG, 'Phantom session changed — saving to storage, wallet:', session.walletPublicKey);
@@ -158,6 +159,24 @@ export function ManagedWalletProvider({
158
159
  return;
159
160
  }
160
161
 
162
+ // Check for cold-start recovery (Android killed the app during connect)
163
+ const coldStartUrl = phantom.consumeColdStartUrl();
164
+ if (coldStartUrl) {
165
+ try {
166
+ console.log(TAG, 'Cold-start URL detected, attempting recovery');
167
+ await phantom.completeConnectFromColdStart(coldStartUrl);
168
+ if (!cancelled) {
169
+ console.log(TAG, 'Cold-start recovery succeeded');
170
+ setConnected(true);
171
+ setIsReady(true);
172
+ }
173
+ return;
174
+ } catch (err) {
175
+ console.log(TAG, 'Cold-start recovery failed:', err instanceof Error ? err.message : err);
176
+ // Fall through to normal session restore
177
+ }
178
+ }
179
+
161
180
  try {
162
181
  const savedSession = await storage.getItem(STORAGE_KEYS.PHANTOM_SESSION);
163
182
  if (savedSession && !cancelled) {
@@ -250,6 +269,7 @@ export function ManagedWalletProvider({
250
269
  await storage.deleteItem(STORAGE_KEYS.MWA_AUTH_TOKEN).catch(() => {});
251
270
  await storage.deleteItem(STORAGE_KEYS.PHANTOM_SESSION).catch(() => {});
252
271
  await storage.deleteItem(STORAGE_KEYS.JWT_TOKEN).catch(() => {});
272
+ await storage.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {});
253
273
  setConnected(false);
254
274
  console.log(TAG, 'disconnect() — done');
255
275
  }, [adapter, storage, usePhantom]);
package/src/storage.ts CHANGED
@@ -8,6 +8,7 @@ export const STORAGE_KEYS = {
8
8
  MWA_AUTH_TOKEN: 'dubs_mwa_auth_token',
9
9
  JWT_TOKEN: 'dubs_jwt_token',
10
10
  PHANTOM_SESSION: 'dubs_phantom_session',
11
+ PHANTOM_CONNECT_IN_FLIGHT: 'dubs_phantom_connect_in_flight',
11
12
  } as const;
12
13
 
13
14
  /**
@@ -23,6 +23,7 @@ export class DeeplinkHandler {
23
23
  private readonly redirectBase: string;
24
24
  private readonly pending = new Map<string, PendingRequest>();
25
25
  private subscription: ReturnType<typeof Linking.addEventListener> | null = null;
26
+ private _coldStartUrl: string | null = null;
26
27
 
27
28
  constructor(redirectBase: string) {
28
29
  // Strip trailing slashes for consistent matching
@@ -163,10 +164,18 @@ export class DeeplinkHandler {
163
164
  this.pending.delete(id);
164
165
  req.resolve({ params });
165
166
  } else {
166
- console.log(TAG, 'No pending requests to resolve — redirect may have arrived late');
167
+ console.log(TAG, 'No pending requests to resolve — stashing as cold-start URL');
168
+ this._coldStartUrl = url;
167
169
  }
168
170
  }
169
171
 
172
+ /** Return and clear the stashed cold-start URL (if any). */
173
+ getColdStartUrl(): string | null {
174
+ const url = this._coldStartUrl;
175
+ this._coldStartUrl = null;
176
+ return url;
177
+ }
178
+
170
179
  /** Extract the request ID from the dubs_rid query parameter. */
171
180
  private extractRequestId(url: string): string | null {
172
181
  try {
@@ -1,6 +1,8 @@
1
1
  import { PublicKey, Transaction } from '@solana/web3.js';
2
2
  import bs58 from 'bs58';
3
3
  import type { WalletAdapter } from '../types';
4
+ import type { TokenStorage } from '../../storage';
5
+ import { STORAGE_KEYS } from '../../storage';
4
6
  import {
5
7
  generateKeyPair,
6
8
  deriveSharedSecret,
@@ -38,6 +40,8 @@ export interface PhantomDeeplinkAdapterConfig {
38
40
  timeout?: number;
39
41
  /** Called when the Phantom session changes (save/clear for persistence) */
40
42
  onSessionChange?: (session: PhantomSession | null) => void;
43
+ /** Storage for persisting in-flight connect state (cold-start recovery on Android) */
44
+ storage?: TokenStorage;
41
45
  }
42
46
 
43
47
  let requestCounter = 0;
@@ -147,6 +151,20 @@ export class PhantomDeeplinkAdapter implements WalletAdapter {
147
151
  app_url: appUrl,
148
152
  });
149
153
 
154
+ // Persist in-flight state so we can recover if the OS kills the app (Android cold-start)
155
+ if (this.config.storage) {
156
+ console.log(TAG, 'Saving in-flight connect state to storage');
157
+ await this.config.storage.setItem(
158
+ STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT,
159
+ JSON.stringify({
160
+ dappPublicKey: dappPubBase58,
161
+ dappSecretKey: bs58.encode(this._dappKeyPair.secretKey),
162
+ requestId,
163
+ createdAt: Date.now(),
164
+ }),
165
+ );
166
+ }
167
+
150
168
  const url = `https://phantom.app/ul/v1/connect?${params.toString()}`;
151
169
  console.log(TAG, 'Opening Phantom connect deeplink...');
152
170
  const response = await this.handler.send(url, requestId, this.timeout);
@@ -183,6 +201,9 @@ export class PhantomDeeplinkAdapter implements WalletAdapter {
183
201
 
184
202
  // Notify consumer of new session
185
203
  this.config.onSessionChange?.(this.getSession());
204
+
205
+ // Clear in-flight state now that connect succeeded
206
+ this.config.storage?.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {});
186
207
  }
187
208
 
188
209
  disconnect(): void {
@@ -197,6 +218,100 @@ export class PhantomDeeplinkAdapter implements WalletAdapter {
197
218
  console.log(TAG, 'Disconnected');
198
219
  }
199
220
 
221
+ /**
222
+ * Return and clear the stashed cold-start URL from the deeplink handler.
223
+ * Used by ManagedWalletProvider to detect an in-flight connect on cold start.
224
+ */
225
+ consumeColdStartUrl(): string | null {
226
+ return this.handler.getColdStartUrl();
227
+ }
228
+
229
+ /**
230
+ * Complete a connect flow that was interrupted by the OS killing the app (Android).
231
+ * Loads the saved in-flight keypair from storage, decrypts the Phantom response
232
+ * from the cold-start URL, and restores the session.
233
+ */
234
+ async completeConnectFromColdStart(url: string): Promise<void> {
235
+ if (!this.config.storage) {
236
+ throw new Error('Cannot recover cold-start connect: no storage configured');
237
+ }
238
+
239
+ console.log(TAG, 'completeConnectFromColdStart() — attempting recovery');
240
+
241
+ const raw = await this.config.storage.getItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT);
242
+ if (!raw) {
243
+ throw new Error('No in-flight connect state found in storage');
244
+ }
245
+
246
+ const inFlight = JSON.parse(raw) as {
247
+ dappPublicKey: string;
248
+ dappSecretKey: string;
249
+ requestId: string;
250
+ createdAt: number;
251
+ };
252
+
253
+ // Check expiry (120s)
254
+ const age = Date.now() - inFlight.createdAt;
255
+ if (age > 120_000) {
256
+ console.log(TAG, `In-flight state expired (${Math.round(age / 1000)}s old), clearing`);
257
+ await this.config.storage.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {});
258
+ throw new Error('In-flight connect state expired');
259
+ }
260
+
261
+ console.log(TAG, `In-flight state age: ${Math.round(age / 1000)}s, requestId: ${inFlight.requestId}`);
262
+
263
+ // Restore keypair from storage
264
+ this._dappKeyPair = {
265
+ publicKey: bs58.decode(inFlight.dappPublicKey),
266
+ secretKey: bs58.decode(inFlight.dappSecretKey),
267
+ };
268
+
269
+ // Parse the Phantom callback URL
270
+ const parsed = new URL(url);
271
+ const params: Record<string, string> = {};
272
+ parsed.searchParams.forEach((value, key) => {
273
+ params[key] = value;
274
+ });
275
+
276
+ // Check for Phantom error
277
+ if (params.errorCode) {
278
+ const errorMessage = params.errorMessage
279
+ ? decodeURIComponent(params.errorMessage)
280
+ : `Phantom error code: ${params.errorCode}`;
281
+ await this.config.storage.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {});
282
+ throw new Error(errorMessage);
283
+ }
284
+
285
+ const phantomPubBase58 = params.phantom_encryption_public_key;
286
+ if (!phantomPubBase58) {
287
+ await this.config.storage.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {});
288
+ throw new Error('Phantom did not return an encryption public key');
289
+ }
290
+
291
+ console.log(TAG, 'Cold-start: Phantom public key:', phantomPubBase58);
292
+
293
+ this._phantomPublicKey = bs58.decode(phantomPubBase58);
294
+ this._sharedSecret = deriveSharedSecret(
295
+ this._dappKeyPair.secretKey,
296
+ this._phantomPublicKey,
297
+ );
298
+
299
+ const data = decryptPayload(params.data, params.nonce, this._sharedSecret);
300
+ console.log(TAG, 'Cold-start: Decrypted connect data — public_key:', data.public_key);
301
+
302
+ this._sessionToken = data.session;
303
+ this._publicKey = new PublicKey(data.public_key);
304
+ this._connected = true;
305
+
306
+ console.log(TAG, 'Cold-start recovery complete! Wallet:', this._publicKey.toBase58());
307
+
308
+ // Notify consumer of new session
309
+ this.config.onSessionChange?.(this.getSession());
310
+
311
+ // Clear in-flight state
312
+ await this.config.storage.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {});
313
+ }
314
+
200
315
  async signTransaction(transaction: Transaction): Promise<Transaction> {
201
316
  this.assertConnected();
202
317
  console.log(TAG, 'signTransaction() — serializing transaction');