@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/dist/index.d.mts +20 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +179 -60
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +162 -44
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/managed-wallet.tsx +26 -4
- package/src/storage.ts +1 -0
- package/src/utils/device.ts +13 -0
- package/src/wallet/phantom-deeplink/deeplink-handler.ts +10 -1
- package/src/wallet/phantom-deeplink/phantom-deeplink-adapter.ts +115 -0
package/package.json
CHANGED
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';
|
package/src/managed-wallet.tsx
CHANGED
|
@@ -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
|
-
// -
|
|
98
|
-
// - Android
|
|
99
|
-
const
|
|
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
package/src/utils/device.ts
CHANGED
|
@@ -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 —
|
|
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');
|