import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'; import { Platform } from 'react-native'; import { MwaWalletAdapter } from './wallet/mwa-adapter'; import { PhantomDeeplinkAdapter } from './wallet/phantom-deeplink'; import type { PhantomSession } from './wallet/phantom-deeplink'; import type { WalletAdapter } from './wallet/types'; import { ConnectWalletScreen } from './ui/ConnectWalletScreen'; import type { ConnectWalletScreenProps } from './ui/ConnectWalletScreen'; import type { TokenStorage } from './storage'; import { STORAGE_KEYS } from './storage'; const TAG = '[Dubs:ManagedWallet]'; /** Auto-detect redirect URI on iOS using the app's scheme from expo-linking. */ function getDefaultRedirectUri(): string | undefined { if (Platform.OS !== 'ios') return undefined; try { const expoLinking = require('expo-linking'); if (expoLinking.createURL) { const uri = expoLinking.createURL('phantom-callback'); console.log(TAG, 'Auto-detected redirect URI via expo-linking:', uri); return uri; } } catch {} try { const Constants = require('expo-constants').default; const scheme = Constants.expoConfig?.scheme; if (scheme) { const uri = `${scheme}://phantom-callback`; console.log(TAG, 'Auto-detected redirect URI via expo-constants:', uri); return uri; } } catch {} return undefined; } // ── Module-level Phantom adapter singleton ── // Persists across React remounts (e.g. when the app backgrounds to open Phantom). // This prevents the Linking listener from being torn down mid-flow. let phantomSingleton: PhantomDeeplinkAdapter | null = null; function getOrCreatePhantomAdapter(config: { redirectUri: string; appUrl?: string; cluster: string; storage: TokenStorage; }): PhantomDeeplinkAdapter { if (!phantomSingleton) { console.log(TAG, 'Creating PhantomDeeplinkAdapter (singleton)'); phantomSingleton = new PhantomDeeplinkAdapter({ redirectUri: config.redirectUri, appUrl: config.appUrl, cluster: config.cluster, storage: config.storage, onSessionChange: (session) => { if (session) { console.log(TAG, 'Phantom session changed — saving to storage, wallet:', session.walletPublicKey); config.storage.setItem(STORAGE_KEYS.PHANTOM_SESSION, JSON.stringify(session)).catch((err) => { console.log(TAG, 'Failed to save Phantom session:', err); }); } else { console.log(TAG, 'Phantom session cleared — removing from storage'); config.storage.deleteItem(STORAGE_KEYS.PHANTOM_SESSION).catch((err) => { console.log(TAG, 'Failed to delete Phantom session:', err); }); } }, }); } return phantomSingleton; } // ── Disconnect Context (internal) ── type DisconnectFn = () => Promise; export const DisconnectContext = createContext(null); export function useDisconnect(): DisconnectFn | null { return useContext(DisconnectContext); } // ── Props ── interface ManagedWalletProviderProps { appName: string; cluster: string; storage: TokenStorage; renderConnectScreen?: ((props: ConnectWalletScreenProps) => React.ReactNode) | false; /** Developer UI config overrides for the connect screen */ accentColor?: string; appIcon?: string; tagline?: string; /** Deeplink redirect URI for Phantom (required on iOS, optional on Android) */ redirectUri?: string; /** App URL shown in Phantom's connect screen */ appUrl?: string; children: (adapter: WalletAdapter) => React.ReactNode; } // ── Component ── export function ManagedWalletProvider({ appName, cluster, storage, renderConnectScreen, accentColor, appIcon, tagline, redirectUri, appUrl, children, }: ManagedWalletProviderProps) { const [connected, setConnected] = useState(false); const [connecting, setConnecting] = useState(false); const [isReady, setIsReady] = useState(false); const [error, setError] = useState(null); // Determine which adapter to use: // - Android → MWA (Phantom + other wallets support Mobile Wallet Adapter natively) // - iOS → Phantom deeplinks (MWA not available on iOS) const resolvedRedirectUri = redirectUri || getDefaultRedirectUri(); const usePhantom = Platform.OS === 'ios' && !!resolvedRedirectUri; console.log(TAG, `Platform: ${Platform.OS}, redirectUri: ${resolvedRedirectUri ?? 'none'} (explicit: ${!!redirectUri}), usePhantom: ${usePhantom}`); const adapterRef = useRef(null); const transactRef = useRef(null); // Lazily create adapter — Phantom uses a module-level singleton to survive remounts if (!adapterRef.current) { if (usePhantom) { adapterRef.current = getOrCreatePhantomAdapter({ redirectUri: resolvedRedirectUri!, appUrl, cluster, storage, }); } else { console.log(TAG, 'Creating MwaWalletAdapter'); adapterRef.current = new MwaWalletAdapter({ transact: (...args: any[]) => { if (!transactRef.current) { throw new Error( '@dubsdotapp/expo: @solana-mobile/mobile-wallet-adapter-protocol-web3js is required. ' + 'Install it with: npm install @solana-mobile/mobile-wallet-adapter-protocol-web3js', ); } return transactRef.current(...args); }, appIdentity: { name: appName, uri: appUrl }, cluster, onAuthTokenChange: (token) => { if (token) { storage.setItem(STORAGE_KEYS.MWA_AUTH_TOKEN, token).catch(() => {}); } else { storage.deleteItem(STORAGE_KEYS.MWA_AUTH_TOKEN).catch(() => {}); } }, }); } } const adapter = adapterRef.current; // Restore session / dynamic-import on mount useEffect(() => { let cancelled = false; (async () => { if (usePhantom) { const phantom = adapter as PhantomDeeplinkAdapter; // If the singleton is already connected (e.g. after returning from Phantom), skip restore if (phantom.connected) { console.log(TAG, 'Phantom adapter already connected, skipping restore'); if (!cancelled) { setConnected(true); setIsReady(true); } return; } // Check for cold-start recovery (Android killed the app during connect) const coldStartUrl = phantom.consumeColdStartUrl(); if (coldStartUrl) { try { console.log(TAG, 'Cold-start URL detected, attempting recovery'); await phantom.completeConnectFromColdStart(coldStartUrl); if (!cancelled) { console.log(TAG, 'Cold-start recovery succeeded'); setConnected(true); setIsReady(true); } return; } catch (err) { console.log(TAG, 'Cold-start recovery failed:', err instanceof Error ? err.message : err); // Fall through to normal session restore } } try { const savedSession = await storage.getItem(STORAGE_KEYS.PHANTOM_SESSION); if (savedSession && !cancelled) { const session: PhantomSession = JSON.parse(savedSession); console.log(TAG, 'Found saved Phantom session, restoring for wallet:', session.walletPublicKey); phantom.restoreSession(session); if (!cancelled) { console.log(TAG, 'Phantom reconnected from saved session'); setConnected(true); } } else { console.log(TAG, 'No saved Phantom session'); } } catch (err) { console.log(TAG, 'Phantom session restore failed:', err instanceof Error ? err.message : err); await storage.deleteItem(STORAGE_KEYS.PHANTOM_SESSION).catch(() => {}); } finally { if (!cancelled) { console.log(TAG, 'Phantom init complete, marking ready'); setIsReady(true); } } } else { console.log(TAG, 'MWA path — dynamic-importing transact...'); try { const mwa = await import('@solana-mobile/mobile-wallet-adapter-protocol-web3js'); if (cancelled) return; transactRef.current = mwa.transact; console.log(TAG, 'MWA transact loaded'); } catch { console.log(TAG, 'MWA not installed — transact will throw on use'); } try { const savedToken = await storage.getItem(STORAGE_KEYS.MWA_AUTH_TOKEN); const savedAddress = await storage.getItem(STORAGE_KEYS.MWA_WALLET_ADDRESS); if (savedToken && savedAddress && !cancelled) { console.log(TAG, 'Found saved MWA session, restoring silently for wallet:', savedAddress); (adapter as MwaWalletAdapter).restoreSession(savedToken, savedAddress); setConnected(true); } else { console.log(TAG, 'No saved MWA session (token or address missing)'); } } catch (err) { console.log(TAG, 'MWA session restore failed:', err instanceof Error ? err.message : err); await storage.deleteItem(STORAGE_KEYS.MWA_AUTH_TOKEN).catch(() => {}); await storage.deleteItem(STORAGE_KEYS.MWA_WALLET_ADDRESS).catch(() => {}); } finally { if (!cancelled) { console.log(TAG, 'MWA init complete, marking ready'); setIsReady(true); } } } })(); return () => { cancelled = true; }; }, [adapter, storage, usePhantom]); const handleConnect = useCallback(async () => { console.log(TAG, 'handleConnect() — user tapped connect'); setConnecting(true); setError(null); try { await adapter.connect!(); const walletAddress = adapter.publicKey?.toBase58(); console.log(TAG, 'handleConnect() — success, wallet:', walletAddress); // Save wallet address for silent session restore on next launch (MWA only) if (!usePhantom && walletAddress) { storage.setItem(STORAGE_KEYS.MWA_WALLET_ADDRESS, walletAddress).catch(() => {}); } setConnected(true); } catch (err) { const message = err instanceof Error ? err.message : 'Connection failed'; console.log(TAG, 'handleConnect() — failed:', message); setError(message); } finally { setConnecting(false); } }, [adapter, storage, usePhantom]); const disconnect = useCallback(async () => { console.log(TAG, 'disconnect() — clearing all state'); adapter.disconnect?.(); // Destroy and reset the singleton so a fresh adapter is created on next connect if (usePhantom && phantomSingleton) { console.log(TAG, 'Destroying Phantom singleton'); phantomSingleton.destroy(); phantomSingleton = null; adapterRef.current = null; } await storage.deleteItem(STORAGE_KEYS.MWA_AUTH_TOKEN).catch(() => {}); await storage.deleteItem(STORAGE_KEYS.MWA_WALLET_ADDRESS).catch(() => {}); await storage.deleteItem(STORAGE_KEYS.PHANTOM_SESSION).catch(() => {}); await storage.deleteItem(STORAGE_KEYS.JWT_TOKEN).catch(() => {}); await storage.deleteItem(STORAGE_KEYS.PHANTOM_CONNECT_IN_FLIGHT).catch(() => {}); setConnected(false); console.log(TAG, 'disconnect() — done'); }, [adapter, storage, usePhantom]); // Show nothing until we've attempted silent reconnect if (!isReady) { console.log(TAG, 'Not ready yet — rendering null'); return null; } // Not connected — show connect screen if (!connected) { if (renderConnectScreen === false) { return null; } const connectProps: ConnectWalletScreenProps = { onConnect: handleConnect, connecting, error, appName, accentColor, appIcon, tagline, }; if (renderConnectScreen) { return <>{renderConnectScreen(connectProps)}; } return ; } // Connected — render children with adapter + disconnect console.log(TAG, 'Rendering children — connected with wallet:', adapter.publicKey?.toBase58()); return ( {children(adapter)} ); }