From 6f42bf3e81899eb78b04c4fc9bdadf1327da9547 Mon Sep 17 00:00:00 2001 From: Kaycke Date: Fri, 29 May 2026 19:37:19 -0300 Subject: [PATCH] feat: persist Freighter sessions --- src/components/WalletConnect.tsx | 55 +++++- src/context/StealthKeysContext.tsx | 7 +- src/context/StellarWalletContext.tsx | 269 ++++++++++++++++++++++++--- 3 files changed, 300 insertions(+), 31 deletions(-) diff --git a/src/components/WalletConnect.tsx b/src/components/WalletConnect.tsx index 807db1c..7076a3a 100644 --- a/src/components/WalletConnect.tsx +++ b/src/components/WalletConnect.tsx @@ -40,19 +40,64 @@ function HorizenButton() { } function FreighterButton() { - const { address, isConnected, connect, disconnect } = useStellarWallet(); + const { + address, + isConnected, + status, + statusMessage, + preferredNetwork, + setPreferredNetwork, + connect, + disconnect, + retryInstallDetection, + } = useStellarWallet(); + + const handleInstall = () => { + window.open('https://www.freighter.app/', '_blank', 'noopener,noreferrer'); + retryInstallDetection(); + }; if (isConnected && address) { return ( - + + ); + } + + if (status === 'not_installed') { + return ( + + ); + } + + if (status === 'not_allowed') { + return ( + ); } return ( - ); } diff --git a/src/context/StealthKeysContext.tsx b/src/context/StealthKeysContext.tsx index 3085525..93ce099 100644 --- a/src/context/StealthKeysContext.tsx +++ b/src/context/StealthKeysContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, useCallback } from 'react'; +import { createContext, useContext, useState, useCallback, useEffect } from 'react'; import type { StealthKeys as EVMStealthKeys } from '@wraith-protocol/sdk/chains/evm'; import type { StealthKeys as StellarStealthKeys } from '@wraith-protocol/sdk/chains/stellar'; import type { StealthKeys as SolanaStealthKeys } from '@wraith-protocol/sdk/chains/solana'; @@ -47,6 +47,11 @@ export function StealthKeysProvider({ children }: { children: React.ReactNode }) setStellarKeys(null); setStellarMetaAddress(null); }, []); + + useEffect(() => { + window.addEventListener('stellar:clear-stealth-keys', clearStellar); + return () => window.removeEventListener('stellar:clear-stealth-keys', clearStellar); + }, [clearStellar]); const clearSolana = useCallback(() => { setSolanaKeys(null); setSolanaMetaAddress(null); diff --git a/src/context/StellarWalletContext.tsx b/src/context/StellarWalletContext.tsx index 3ef679f..69675c6 100644 --- a/src/context/StellarWalletContext.tsx +++ b/src/context/StellarWalletContext.tsx @@ -1,41 +1,231 @@ import { createContext, useContext, useState, useCallback, useEffect } from 'react'; import { STELLAR_NETWORK } from '@/config'; +type FreighterStatus = 'checking' | 'ready' | 'not_installed' | 'not_allowed' | 'error'; +type StellarNetworkPreference = 'testnet' | 'futurenet' | 'mainnet'; + interface StellarWalletContextValue { address: string | null; isConnected: boolean; + status: FreighterStatus; + statusMessage: string; + network: string | null; + networkPassphrase: string | null; + preferredNetwork: StellarNetworkPreference; + setPreferredNetwork: (network: StellarNetworkPreference) => void; connect: () => Promise; disconnect: () => void; + retryInstallDetection: () => void; signMessage: (message: string) => Promise; signTransaction: (xdr: string) => Promise; } const StellarWalletContext = createContext(null); +const STORAGE_KEY = 'wraith:stellar:preferred-network'; +const CHANNEL_NAME = 'wraith:stellar-wallet'; + +type BroadcastMessage = + | { + type: 'connected'; + address: string | null; + network: string | null; + networkPassphrase: string | null; + } + | { type: 'disconnected' } + | { type: 'network'; network: string | null; networkPassphrase: string | null } + | { type: 'preferred-network'; preferredNetwork: StellarNetworkPreference }; + +type WalletChangePayload = { + address: string; + network: string; + networkPassphrase: string; +}; + +type WalletWatcher = { + watch: (callback: (payload: WalletChangePayload) => void) => void; + stop: () => void; +}; + +function getInitialPreferredNetwork(): StellarNetworkPreference { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'testnet' || stored === 'futurenet' || stored === 'mainnet') return stored; + return 'testnet'; +} + +function clearStellarStealthKeys() { + window.dispatchEvent(new Event('stellar:clear-stealth-keys')); +} + +function networkToPreference(network: string): StellarNetworkPreference | null { + const normalized = network.toLowerCase(); + if (normalized.includes('test')) return 'testnet'; + if (normalized.includes('future')) return 'futurenet'; + if (normalized.includes('public') || normalized.includes('main')) return 'mainnet'; + return null; +} export function StellarWalletProvider({ children }: { children: React.ReactNode }) { const [address, setAddress] = useState(null); + const [status, setStatus] = useState('checking'); + const [statusMessage, setStatusMessage] = useState(''); + const [network, setNetwork] = useState(null); + const [networkPassphrase, setNetworkPassphrase] = useState(null); + const [preferredNetwork, setPreferredNetworkState] = useState( + getInitialPreferredNetwork, + ); const isConnected = !!address; + const broadcast = useCallback((message: BroadcastMessage) => { + if (!('BroadcastChannel' in window)) return; + const channel = new BroadcastChannel(CHANNEL_NAME); + channel.postMessage(message); + channel.close(); + }, []); + + const setPreferredNetwork = useCallback( + (nextNetwork: StellarNetworkPreference) => { + localStorage.setItem(STORAGE_KEY, nextNetwork); + setPreferredNetworkState(nextNetwork); + broadcast({ type: 'preferred-network', preferredNetwork: nextNetwork }); + }, + [broadcast], + ); + + const restoreIfAllowed = useCallback(async () => { + try { + setStatus('checking'); + const freighter = await import('@stellar/freighter-api'); + const { isConnected: connected } = await freighter.isConnected(); + if (!connected) { + setStatus('not_installed'); + setStatusMessage('Install Freighter to connect a Stellar wallet.'); + return false; + } + + const { isAllowed: allowed } = await freighter.isAllowed(); + if (!allowed) { + setStatus('not_allowed'); + setStatusMessage('Approve this site in Freighter to reconnect.'); + return false; + } + + const { address: addr } = await freighter.getAddress(); + const details = await freighter.getNetworkDetails(); + const nextPreference = details.network ? networkToPreference(details.network) : null; + if (nextPreference) { + localStorage.setItem(STORAGE_KEY, nextPreference); + setPreferredNetworkState(nextPreference); + } + + setNetwork(details.network || null); + setNetworkPassphrase(details.networkPassphrase || null); + + if (!addr) { + setStatus('not_allowed'); + setStatusMessage('Approve this site in Freighter to reconnect.'); + return false; + } + + setAddress(addr); + setStatus('ready'); + setStatusMessage(''); + broadcast({ + type: 'connected', + address: addr, + network: details.network || null, + networkPassphrase: details.networkPassphrase || null, + }); + return true; + } catch { + setStatus('not_installed'); + setStatusMessage('Install Freighter to connect a Stellar wallet.'); + return false; + } + }, [broadcast]); + + useEffect(() => { + void restoreIfAllowed(); + }, [restoreIfAllowed]); + useEffect(() => { + if (!('BroadcastChannel' in window)) return; + const channel = new BroadcastChannel(CHANNEL_NAME); + channel.onmessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === 'connected') { + setAddress(message.address); + setNetwork(message.network); + setNetworkPassphrase(message.networkPassphrase); + setStatus(message.address ? 'ready' : 'not_allowed'); + setStatusMessage(''); + } + if (message.type === 'disconnected') { + setAddress(null); + clearStellarStealthKeys(); + } + if (message.type === 'network') { + setNetwork(message.network); + setNetworkPassphrase(message.networkPassphrase); + clearStellarStealthKeys(); + } + if (message.type === 'preferred-network') { + setPreferredNetworkState(message.preferredNetwork); + } + }; + return () => channel.close(); + }, []); + + useEffect(() => { + let watcher: WalletWatcher | null = null; + (async () => { try { const freighter = await import('@stellar/freighter-api'); - const { isConnected: connected } = await freighter.isConnected(); - if (connected) { - const { address: addr } = await freighter.getAddress(); - if (addr) setAddress(addr); - } + watcher = new freighter.WatchWalletChanges(2000) as WalletWatcher; + watcher.watch( + ({ address: nextAddress, network: nextNetwork, networkPassphrase: nextPassphrase }) => { + if (nextAddress && nextAddress !== address) { + setAddress(nextAddress); + clearStellarStealthKeys(); + broadcast({ + type: 'connected', + address: nextAddress, + network: nextNetwork || null, + networkPassphrase: nextPassphrase || null, + }); + } + if (nextNetwork && nextNetwork !== network) { + setNetwork(nextNetwork); + setNetworkPassphrase(nextPassphrase || null); + const nextPreference = networkToPreference(nextNetwork); + if (nextPreference) { + localStorage.setItem(STORAGE_KEY, nextPreference); + setPreferredNetworkState(nextPreference); + } + clearStellarStealthKeys(); + broadcast({ + type: 'network', + network: nextNetwork, + networkPassphrase: nextPassphrase || null, + }); + } + }, + ); } catch { - // Freighter not available + // Freighter watcher becomes available only after the extension exists. } })(); - }, []); + + return () => watcher?.stop(); + }, [address, network, broadcast]); const connect = useCallback(async () => { const freighter = await import('@stellar/freighter-api'); const { isConnected: connected } = await freighter.isConnected(); if (!connected) { + setStatus('not_installed'); + setStatusMessage('Install Freighter to connect a Stellar wallet.'); throw new Error( 'Freighter wallet not found. Please install the Freighter browser extension.', ); @@ -44,12 +234,37 @@ export function StellarWalletProvider({ children }: { children: React.ReactNode await freighter.requestAccess(); const { address: addr } = await freighter.getAddress(); if (!addr) throw new Error('Failed to get public key from Freighter'); + const details = await freighter.getNetworkDetails(); + const nextPreference = details.network ? networkToPreference(details.network) : null; + if (nextPreference) setPreferredNetwork(nextPreference); + setAddress(addr); - }, []); + setNetwork(details.network || null); + setNetworkPassphrase(details.networkPassphrase || null); + setStatus('ready'); + setStatusMessage(''); + broadcast({ + type: 'connected', + address: addr, + network: details.network || null, + networkPassphrase: details.networkPassphrase || null, + }); + }, [broadcast, setPreferredNetwork]); const disconnect = useCallback(() => { setAddress(null); - }, []); + clearStellarStealthKeys(); + broadcast({ type: 'disconnected' }); + }, [broadcast]); + + const retryInstallDetection = useCallback(() => { + let attempts = 0; + const interval = window.setInterval(async () => { + attempts++; + const restored = await restoreIfAllowed(); + if (restored || attempts >= 15) window.clearInterval(interval); + }, 2000); + }, [restoreIfAllowed]); const signMessage = useCallback( async (message: string): Promise => { @@ -58,23 +273,15 @@ export function StellarWalletProvider({ children }: { children: React.ReactNode const freighter = await import('@stellar/freighter-api'); const { signedMessage } = await freighter.signMessage(message, { address, - networkPassphrase: STELLAR_NETWORK.networkPassphrase, + networkPassphrase: networkPassphrase ?? STELLAR_NETWORK.networkPassphrase, }); if (!signedMessage) throw new Error('Signing failed: no signature returned'); - // Freighter returns different types depending on version: - // - v3: Buffer (may arrive as serialized {type:'Buffer', data:[...]} through extension messaging) - // - v4: base64 string - // - could also be a raw Uint8Array/Buffer instance const msg = signedMessage as unknown; - - if (msg instanceof Uint8Array) { - return msg; - } + if (msg instanceof Uint8Array) return msg; if (typeof msg === 'string') { - // base64 string const binaryString = atob(msg); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { @@ -83,7 +290,6 @@ export function StellarWalletProvider({ children }: { children: React.ReactNode return bytes; } - // Serialized Buffer: {type: 'Buffer', data: [1, 2, 3, ...]} if ( msg && typeof msg === 'object' && @@ -93,12 +299,11 @@ export function StellarWalletProvider({ children }: { children: React.ReactNode return new Uint8Array((msg as { data: number[] }).data); } - // Last resort: try to convert whatever it is throw new Error( `Unexpected signedMessage type: ${typeof msg} — ${JSON.stringify(msg).slice(0, 200)}`, ); }, - [address], + [address, networkPassphrase], ); const signTransaction = useCallback( @@ -108,17 +313,31 @@ export function StellarWalletProvider({ children }: { children: React.ReactNode const freighter = await import('@stellar/freighter-api'); const { signedTxXdr } = await freighter.signTransaction(xdr, { address, - networkPassphrase: STELLAR_NETWORK.networkPassphrase, + networkPassphrase: networkPassphrase ?? STELLAR_NETWORK.networkPassphrase, }); return signedTxXdr; }, - [address], + [address, networkPassphrase], ); return ( {children}