@dubsdotapp/expo 0.2.21 → 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.21",
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",
package/src/index.ts CHANGED
@@ -107,5 +107,5 @@ export type {
107
107
 
108
108
  // Utils
109
109
  export { signAndSendBase64Transaction } from './utils/transaction';
110
- export { getDeviceInfo } from './utils/device';
110
+ export { getDeviceInfo, isSolanaSeeker } from './utils/device';
111
111
  export type { DeviceInfo } from './utils/device';
@@ -4,6 +4,7 @@ import { MwaWalletAdapter } from './wallet/mwa-adapter';
4
4
  import { PhantomDeeplinkAdapter } from './wallet/phantom-deeplink';
5
5
  import type { PhantomSession } from './wallet/phantom-deeplink';
6
6
  import type { WalletAdapter } from './wallet/types';
7
+ import { isSolanaSeeker } from './utils/device';
7
8
  import { ConnectWalletScreen } from './ui/ConnectWalletScreen';
8
9
  import type { ConnectWalletScreenProps } from './ui/ConnectWalletScreen';
9
10
  import type { TokenStorage } from './storage';
@@ -28,6 +29,7 @@ function getOrCreatePhantomAdapter(config: {
28
29
  redirectUri: config.redirectUri,
29
30
  appUrl: config.appUrl,
30
31
  cluster: config.cluster,
32
+ storage: config.storage,
31
33
  onSessionChange: (session) => {
32
34
  if (session) {
33
35
  console.log(TAG, 'Phantom session changed — saving to storage, wallet:', session.walletPublicKey);
@@ -94,11 +96,12 @@ export function ManagedWalletProvider({
94
96
  const [error, setError] = useState<string | null>(null);
95
97
 
96
98
  // Determine which adapter to use:
97
- // - iOS always uses Phantom deeplinks
98
- // - Android uses MWA (default)
99
- const usePhantom = Platform.OS === 'ios' && !!redirectUri;
99
+ // - Solana Seeker (Android) MWA (native Mobile Wallet Adapter support)
100
+ // - Everything else (iOS + regular Android) Phantom deeplinks
101
+ const seeker = Platform.OS === 'android' && isSolanaSeeker();
102
+ const usePhantom = !seeker && !!redirectUri;
100
103
 
101
- console.log(TAG, `Platform: ${Platform.OS}, redirectUri: ${redirectUri ? 'provided' : 'not set'}, usePhantom: ${usePhantom}`);
104
+ console.log(TAG, `Platform: ${Platform.OS}, seeker: ${seeker}, redirectUri: ${redirectUri ? 'provided' : 'not set'}, usePhantom: ${usePhantom}`);
102
105
 
103
106
  const adapterRef = useRef<WalletAdapter | null>(null);
104
107
  const transactRef = useRef<any>(null);
@@ -156,6 +159,24 @@ export function ManagedWalletProvider({
156
159
  return;
157
160
  }
158
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
+
159
180
  try {
160
181
  const savedSession = await storage.getItem(STORAGE_KEYS.PHANTOM_SESSION);
161
182
  if (savedSession && !cancelled) {
@@ -248,6 +269,7 @@ export function ManagedWalletProvider({
248
269
  await storage.deleteItem(STORAGE_KEYS.MWA_AUTH_TOKEN).catch(() => {});
249
270
  await storage.deleteItem(STORAGE_KEYS.PHANTOM_SESSION).catch(() => {});
250
271
  await storage.deleteItem(STORAGE_KEYS.JWT_TOKEN).catch(() => {});
272
+ await storage.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {});
251
273
  setConnected(false);
252
274
  console.log(TAG, 'disconnect() — done');
253
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
  /**
@@ -16,6 +16,19 @@ export interface DeviceInfo {
16
16
  isDevice: boolean | null;
17
17
  }
18
18
 
19
+ /**
20
+ * Detect Solana Seeker device (synchronous — safe to call during render).
21
+ * Checks brand from expo-device; returns false if expo-device isn't installed.
22
+ */
23
+ export function isSolanaSeeker(): boolean {
24
+ try {
25
+ const Device = require('expo-device');
26
+ return Device.brand?.toLowerCase() === 'solanamobile';
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
19
32
  export async function getDeviceInfo(): Promise<DeviceInfo> {
20
33
  try {
21
34
  const Device = require('expo-device');
@@ -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');