@dubsdotapp/expo 0.2.13 → 0.2.15

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.13",
3
+ "version": "0.2.15",
4
4
  "description": "React Native SDK for the Dubs betting platform",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -11,6 +11,41 @@ import { STORAGE_KEYS } from './storage';
11
11
 
12
12
  const TAG = '[Dubs:ManagedWallet]';
13
13
 
14
+ // ── Module-level Phantom adapter singleton ──
15
+ // Persists across React remounts (e.g. when the app backgrounds to open Phantom).
16
+ // This prevents the Linking listener from being torn down mid-flow.
17
+ let phantomSingleton: PhantomDeeplinkAdapter | null = null;
18
+
19
+ function getOrCreatePhantomAdapter(config: {
20
+ redirectUri: string;
21
+ appUrl?: string;
22
+ cluster: string;
23
+ storage: TokenStorage;
24
+ }): PhantomDeeplinkAdapter {
25
+ if (!phantomSingleton) {
26
+ console.log(TAG, 'Creating PhantomDeeplinkAdapter (singleton)');
27
+ phantomSingleton = new PhantomDeeplinkAdapter({
28
+ redirectUri: config.redirectUri,
29
+ appUrl: config.appUrl,
30
+ cluster: config.cluster,
31
+ onSessionChange: (session) => {
32
+ if (session) {
33
+ console.log(TAG, 'Phantom session changed — saving to storage, wallet:', session.walletPublicKey);
34
+ config.storage.setItem(STORAGE_KEYS.PHANTOM_SESSION, JSON.stringify(session)).catch((err) => {
35
+ console.log(TAG, 'Failed to save Phantom session:', err);
36
+ });
37
+ } else {
38
+ console.log(TAG, 'Phantom session cleared — removing from storage');
39
+ config.storage.deleteItem(STORAGE_KEYS.PHANTOM_SESSION).catch((err) => {
40
+ console.log(TAG, 'Failed to delete Phantom session:', err);
41
+ });
42
+ }
43
+ },
44
+ });
45
+ }
46
+ return phantomSingleton;
47
+ }
48
+
14
49
  // ── Disconnect Context (internal) ──
15
50
 
16
51
  type DisconnectFn = () => Promise<void>;
@@ -60,7 +95,7 @@ export function ManagedWalletProvider({
60
95
 
61
96
  // Determine which adapter to use:
62
97
  // - iOS always uses Phantom deeplinks
63
- // - Android uses MWA (default) unless redirectUri is provided and MWA is unavailable
98
+ // - Android uses MWA (default)
64
99
  const usePhantom = Platform.OS === 'ios' && !!redirectUri;
65
100
 
66
101
  console.log(TAG, `Platform: ${Platform.OS}, redirectUri: ${redirectUri ? 'provided' : 'not set'}, usePhantom: ${usePhantom}`);
@@ -68,27 +103,14 @@ export function ManagedWalletProvider({
68
103
  const adapterRef = useRef<WalletAdapter | null>(null);
69
104
  const transactRef = useRef<any>(null);
70
105
 
71
- // Lazily create adapter
106
+ // Lazily create adapter — Phantom uses a module-level singleton to survive remounts
72
107
  if (!adapterRef.current) {
73
108
  if (usePhantom) {
74
- console.log(TAG, 'Creating PhantomDeeplinkAdapter');
75
- adapterRef.current = new PhantomDeeplinkAdapter({
109
+ adapterRef.current = getOrCreatePhantomAdapter({
76
110
  redirectUri: redirectUri!,
77
111
  appUrl,
78
112
  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
- },
113
+ storage,
92
114
  });
93
115
  } else {
94
116
  console.log(TAG, 'Creating MwaWalletAdapter');
@@ -122,24 +144,27 @@ export function ManagedWalletProvider({
122
144
 
123
145
  (async () => {
124
146
  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');
147
+ const phantom = adapter as PhantomDeeplinkAdapter;
148
+
149
+ // If the singleton is already connected (e.g. after returning from Phantom), skip restore
150
+ if (phantom.connected) {
151
+ console.log(TAG, 'Phantom adapter already connected, skipping restore');
152
+ if (!cancelled) {
153
+ setConnected(true);
154
+ setIsReady(true);
139
155
  }
156
+ return;
157
+ }
158
+
159
+ // Clear any stale session — Phantom sessions don't survive across app/Phantom restarts.
160
+ // Always require a fresh connect to establish a valid encryption channel.
161
+ console.log(TAG, 'Phantom path — clearing any saved session, will require fresh connect');
162
+ await storage.deleteItem(STORAGE_KEYS.PHANTOM_SESSION).catch(() => {});
163
+
164
+ try {
165
+ // Nothing to restore — fall through to show connect screen
140
166
  } 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
167
+ console.log(TAG, 'Unexpected error during Phantom init:', err instanceof Error ? err.message : err);
143
168
  } finally {
144
169
  if (!cancelled) {
145
170
  console.log(TAG, 'Phantom init complete, marking ready');
@@ -148,7 +173,6 @@ export function ManagedWalletProvider({
148
173
  }
149
174
  } else {
150
175
  console.log(TAG, 'MWA path — dynamic-importing transact...');
151
- // MWA path — dynamic-import transact
152
176
  try {
153
177
  const mwa = await import('@solana-mobile/mobile-wallet-adapter-protocol-web3js');
154
178
  if (cancelled) return;
@@ -158,7 +182,6 @@ export function ManagedWalletProvider({
158
182
  console.log(TAG, 'MWA not installed — transact will throw on use');
159
183
  }
160
184
 
161
- // Attempt silent reconnect from saved auth token
162
185
  try {
163
186
  const savedToken = await storage.getItem(STORAGE_KEYS.MWA_AUTH_TOKEN);
164
187
  if (savedToken && !cancelled) {
@@ -208,22 +231,19 @@ export function ManagedWalletProvider({
208
231
  const disconnect = useCallback(async () => {
209
232
  console.log(TAG, 'disconnect() — clearing all state');
210
233
  adapter.disconnect?.();
234
+ // Destroy and reset the singleton so a fresh adapter is created on next connect
235
+ if (usePhantom && phantomSingleton) {
236
+ console.log(TAG, 'Destroying Phantom singleton');
237
+ phantomSingleton.destroy();
238
+ phantomSingleton = null;
239
+ adapterRef.current = null;
240
+ }
211
241
  await storage.deleteItem(STORAGE_KEYS.MWA_AUTH_TOKEN).catch(() => {});
212
242
  await storage.deleteItem(STORAGE_KEYS.PHANTOM_SESSION).catch(() => {});
213
243
  await storage.deleteItem(STORAGE_KEYS.JWT_TOKEN).catch(() => {});
214
244
  setConnected(false);
215
245
  console.log(TAG, 'disconnect() — done');
216
- }, [adapter, storage]);
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]);
246
+ }, [adapter, storage, usePhantom]);
227
247
 
228
248
  // Show nothing until we've attempted silent reconnect
229
249
  if (!isReady) {
@@ -234,7 +254,6 @@ export function ManagedWalletProvider({
234
254
  // Not connected — show connect screen
235
255
  if (!connected) {
236
256
  if (renderConnectScreen === false) {
237
- // Headless mode — render nothing
238
257
  return null;
239
258
  }
240
259
  const connectProps: ConnectWalletScreenProps = {