diff --git a/.husky/pre-commit b/.husky/pre-commit index e548872..9c9f69b 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm format:check +npm run format:check diff --git a/src/components/AutoSign.tsx b/src/components/AutoSign.tsx index 82222d8..8a6b46f 100644 --- a/src/components/AutoSign.tsx +++ b/src/components/AutoSign.tsx @@ -81,9 +81,11 @@ function HorizenAutoSign() { } function StellarAutoSign() { - const { isConnected, address, signMessage } = useStellarWallet(); + const { isConnected, address, network, signMessage } = useStellarWallet(); const { stellarKeys, setStellarKeys, setStellarMetaAddress, clearStellar } = useStealthKeys(); const prompted = useRef(null); + const signature = useRef(null); + const lastAddress = useRef(null); const [ready, setReady] = useState(false); const isLoading = useRef(false); @@ -99,15 +101,22 @@ function StellarAutoSign() { if (!ready || !address) return; if (stellarKeys) return; if (isLoading.current) return; - if (prompted.current === address) return; + if (lastAddress.current !== address) { + prompted.current = null; + signature.current = null; + lastAddress.current = address; + } + const identity = `${address}:${network.id}`; + if (prompted.current === identity) return; - prompted.current = address; + prompted.current = identity; isLoading.current = true; (async () => { try { - const signature = await signMessage(STELLAR_SIGNING_MESSAGE); - const keys = deriveStellarKeys(signature); + const signedMessage = signature.current || (await signMessage(STELLAR_SIGNING_MESSAGE)); + signature.current = signedMessage; + const keys = deriveStellarKeys(signedMessage); const meta = encodeStellarMeta(keys.spendingPubKey, keys.viewingPubKey); setStellarKeys(keys); setStellarMetaAddress(meta); @@ -117,15 +126,17 @@ function StellarAutoSign() { isLoading.current = false; } })(); - }, [ready, address, stellarKeys, signMessage, setStellarKeys, setStellarMetaAddress]); + }, [ready, address, network.id, stellarKeys, signMessage, setStellarKeys, setStellarMetaAddress]); useEffect(() => { if (!isConnected) { prompted.current = null; + signature.current = null; + lastAddress.current = null; setReady(false); clearStellar(); } - }, [isConnected, clearStellar]); + }, [isConnected, address, clearStellar]); return null; } diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index d279a52..d7d682d 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -23,7 +23,6 @@ import { useStealthKeys } from '@/context/StealthKeysContext'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { CopyButton } from '@/components/CopyButton'; import { stellarTxUrl, stellarAddrUrl } from '@/lib/explorer'; -import { STELLAR_NETWORK } from '@/config'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; const REGISTRY_CONTRACT = 'CC2LAUCXYOPJ4DV4CYXNXYAXRDVOTMAWFF76W4WFD5OVQBD6TN4PYYJ5'; @@ -144,6 +143,7 @@ function StellarStealthRow({ match: MatchedAnnouncement; onWithdrawn: () => void; }) { + const { network } = useStellarWallet(); const [balance, setBalance] = useState(null); const [loadingBal, setLoadingBal] = useState(true); const [dest, setDest] = useState(''); @@ -157,7 +157,7 @@ function StellarStealthRow({ useEffect(() => { (async () => { try { - const res = await fetch(`${STELLAR_NETWORK.horizonUrl}/accounts/${match.stealthAddress}`); + const res = await fetch(`${network.horizonUrl}/accounts/${match.stealthAddress}`); if (!res.ok) { setBalance('0'); return; @@ -171,7 +171,7 @@ function StellarStealthRow({ setLoadingBal(false); } })(); - }, [match.stealthAddress]); + }, [match.stealthAddress, network.horizonUrl]); const handleWithdraw = async () => { if (!dest) return; @@ -179,8 +179,8 @@ function StellarStealthRow({ setWithdrawing(true); try { - const horizonUrl = STELLAR_NETWORK.horizonUrl; - const networkPassphrase = STELLAR_NETWORK.networkPassphrase; + const horizonUrl = network.horizonUrl; + const networkPassphrase = network.networkPassphrase; const res = await fetch(`${horizonUrl}/accounts/${match.stealthAddress}`); if (!res.ok) throw new Error('Account not found'); @@ -244,7 +244,7 @@ function StellarStealthRow({
Withdrawn —{' '} { try { const { rpc: rpcMod } = await import('@stellar/stellar-sdk'); - const soroban = new rpcMod.Server(STELLAR_NETWORK.rpcUrl); - const networkPassphrase = STELLAR_NETWORK.networkPassphrase; + const soroban = new rpcMod.Server(network.rpcUrl); + const networkPassphrase = network.networkPassphrase; const accountResponse = await soroban.getAccount(address); const sourceAccount = new Account( @@ -386,7 +386,7 @@ export function StellarReceive() { // Not registered or contract not available } })(); - }, [address]); + }, [address, network]); const registered = isAlreadyRegistered || isRegSuccess; @@ -412,8 +412,8 @@ export function StellarReceive() { setError(''); try { const { rpc: rpcMod } = await import('@stellar/stellar-sdk'); - const soroban = new rpcMod.Server(STELLAR_NETWORK.rpcUrl); - const networkPassphrase = STELLAR_NETWORK.networkPassphrase; + const soroban = new rpcMod.Server(network.rpcUrl); + const networkPassphrase = network.networkPassphrase; const accountResponse = await soroban.getAccount(address); const sourceAccount = new Account( @@ -482,17 +482,14 @@ export function StellarReceive() { } finally { setIsRegistering(false); } - }, [stellarKeys, address, signTransaction]); + }, [stellarKeys, address, network, signTransaction]); const scanPayments = useCallback(async () => { if (!stellarKeys) return; setIsScanning(true); setError(''); try { - const announcements = await fetchAnnouncementEvents( - STELLAR_NETWORK.rpcUrl, - ANNOUNCER_CONTRACT, - ); + const announcements = await fetchAnnouncementEvents(network.rpcUrl, ANNOUNCER_CONTRACT); const results = scanAnnouncements( announcements, stellarKeys.viewingKey, @@ -506,13 +503,13 @@ export function StellarReceive() { } finally { setIsScanning(false); } - }, [stellarKeys]); + }, [stellarKeys, network.rpcUrl]); if (!isConnected) { return (
- Stellar Testnet / XLM + {network.name} / XLM

Receive @@ -528,7 +525,7 @@ export function StellarReceive() {
- Stellar Testnet / XLM + {network.name} / XLM

Receive @@ -578,7 +575,7 @@ export function StellarReceive() { <> {' — '} { setRecipient(''); @@ -180,7 +179,7 @@ export function StellarSend() { return (
- Stellar Testnet / XLM + {network.name} / XLM

Send @@ -196,7 +195,7 @@ export function StellarSend() {
- Stellar Testnet / XLM + {network.name} / XLM

Send @@ -295,7 +294,7 @@ export function StellarSend() {
+ Install Freighter + + ); + } + return ( - ); } diff --git a/src/config.ts b/src/config.ts index f5b7a6c..23e5bea 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,14 +27,48 @@ export const wagmiConfig = getDefaultConfig({ }, }); -export const STELLAR_NETWORK = { - name: 'Stellar Testnet', - networkPassphrase: 'Test SDF Network ; September 2015', - rpcUrl: 'https://soroban-testnet.stellar.org', - horizonUrl: 'https://horizon-testnet.stellar.org', - explorerUrl: 'https://stellar.expert/explorer/testnet', +export const STELLAR_NETWORKS = { + mainnet: { + id: 'mainnet', + freighterName: 'PUBLIC', + name: 'Stellar Mainnet', + networkPassphrase: 'Public Global Stellar Network ; September 2015', + rpcUrl: 'https://soroban-rpc.mainnet.stellar.gateway.fm', + horizonUrl: 'https://horizon.stellar.org', + explorerUrl: 'https://stellar.expert/explorer/public', + }, + futurenet: { + id: 'futurenet', + freighterName: 'FUTURENET', + name: 'Stellar Futurenet', + networkPassphrase: 'Test SDF Future Network ; October 2022', + rpcUrl: 'https://rpc-futurenet.stellar.org', + horizonUrl: 'https://horizon-futurenet.stellar.org', + explorerUrl: 'https://stellar.expert/explorer/futurenet', + }, + testnet: { + id: 'testnet', + freighterName: 'TESTNET', + name: 'Stellar Testnet', + networkPassphrase: 'Test SDF Network ; September 2015', + rpcUrl: 'https://soroban-testnet.stellar.org', + horizonUrl: 'https://horizon-testnet.stellar.org', + explorerUrl: 'https://stellar.expert/explorer/testnet', + }, } as const; +export type StellarNetworkId = keyof typeof STELLAR_NETWORKS; +export type StellarNetwork = (typeof STELLAR_NETWORKS)[StellarNetworkId]; + +export const STELLAR_NETWORK = STELLAR_NETWORKS.testnet; + +export function getStellarNetwork(network: string): StellarNetwork { + const normalized = network.toUpperCase(); + if (normalized === 'PUBLIC' || normalized === 'MAINNET') return STELLAR_NETWORKS.mainnet; + if (normalized === 'FUTURENET') return STELLAR_NETWORKS.futurenet; + return STELLAR_NETWORKS.testnet; +} + export const SOLANA_NETWORK = { name: 'Solana Devnet', rpcUrl: 'https://api.devnet.solana.com', diff --git a/src/context/StellarWalletContext.tsx b/src/context/StellarWalletContext.tsx index 3ef679f..1dcfe08 100644 --- a/src/context/StellarWalletContext.tsx +++ b/src/context/StellarWalletContext.tsx @@ -1,55 +1,207 @@ -import { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import { STELLAR_NETWORK } from '@/config'; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { getStellarNetwork, STELLAR_NETWORKS } from '@/config'; +import type { StellarNetwork } from '@/config'; +import { useStealthKeys } from '@/context/StealthKeysContext'; + +const CHANNEL_NAME = 'wraith-stellar-wallet'; +const NETWORK_STORAGE_KEY = 'wraith:stellar-network'; +const FREIGHTER_INSTALL_URL = 'https://www.freighter.app/'; + +type WalletStatus = 'checking' | 'not-installed' | 'needs-approval' | 'disconnected' | 'connected'; + +interface WalletSyncMessage { + type: 'session' | 'disconnect'; + address?: string; + network?: string; +} interface StellarWalletContextValue { address: string | null; isConnected: boolean; + status: WalletStatus; + network: StellarNetwork; + installUrl: string; connect: () => Promise; disconnect: () => void; + retryInstall: () => void; signMessage: (message: string) => Promise; signTransaction: (xdr: string) => Promise; } const StellarWalletContext = createContext(null); +function getStoredNetwork() { + const stored = localStorage.getItem(NETWORK_STORAGE_KEY); + return stored ? getStellarNetwork(stored) : STELLAR_NETWORKS.testnet; +} + export function StellarWalletProvider({ children }: { children: React.ReactNode }) { + const { clearStellar } = useStealthKeys(); const [address, setAddress] = useState(null); + const [status, setStatus] = useState('checking'); + const [network, setNetwork] = useState(getStoredNetwork); + const channelRef = useRef(null); + const addressRef = useRef(null); + const networkRef = useRef(network); + const suppressReconnectRef = useRef(false); + const installPollRef = useRef | null>(null); + const watcherRef = useRef<{ stop: () => void } | null>(null); - const isConnected = !!address; + const clearInstallPoll = useCallback(() => { + if (installPollRef.current) { + clearInterval(installPollRef.current); + installPollRef.current = null; + } + }, []); + + const updateSession = useCallback( + (nextAddress: string | null, freighterNetwork?: string, broadcast = true) => { + const nextNetwork = freighterNetwork + ? getStellarNetwork(freighterNetwork) + : networkRef.current; + const walletChanged = addressRef.current !== nextAddress; + const networkChanged = networkRef.current.id !== nextNetwork.id; + + if (walletChanged || networkChanged) clearStellar(); + + addressRef.current = nextAddress; + networkRef.current = nextNetwork; + setAddress(nextAddress); + setNetwork(nextNetwork); + localStorage.setItem(NETWORK_STORAGE_KEY, nextNetwork.freighterName); + + if (nextAddress) { + setStatus('connected'); + } else { + setStatus('disconnected'); + } + + if (broadcast) { + channelRef.current?.postMessage({ + type: nextAddress ? 'session' : 'disconnect', + address: nextAddress || undefined, + network: nextNetwork.freighterName, + } satisfies WalletSyncMessage); + } + }, + [clearStellar], + ); + + const checkFreighter = useCallback( + async (restore: boolean) => { + const freighter = await import('@stellar/freighter-api'); + const { isConnected } = await freighter.isConnected(); + if (!isConnected) { + setStatus('not-installed'); + return false; + } + + clearInstallPoll(); + const { isAllowed } = await freighter.isAllowed(); + if (!isAllowed) { + setStatus('needs-approval'); + return true; + } + + const { network: freighterNetwork } = await freighter.getNetwork(); + if (restore && !suppressReconnectRef.current) { + const { address: nextAddress } = await freighter.getAddress(); + updateSession(nextAddress || null, freighterNetwork); + } else if (!addressRef.current) { + setStatus('disconnected'); + } + return true; + }, + [clearInstallPoll, updateSession], + ); + + const startWatcher = useCallback(async () => { + if (watcherRef.current) return; + const freighter = await import('@stellar/freighter-api'); + const watcher = new freighter.WatchWalletChanges(1000); + watcher.watch(({ address: nextAddress, network: freighterNetwork }) => { + if (suppressReconnectRef.current) return; + updateSession(nextAddress || null, freighterNetwork); + }); + watcherRef.current = watcher; + }, [updateSession]); useEffect(() => { + const channel = new BroadcastChannel(CHANNEL_NAME); + channelRef.current = channel; + channel.onmessage = ({ data }: MessageEvent) => { + if (data.type === 'disconnect') { + suppressReconnectRef.current = true; + updateSession(null, data.network, false); + } else if (data.address) { + suppressReconnectRef.current = false; + updateSession(data.address, data.network, false); + } + }; + (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); - } + if (!(await checkFreighter(true))) return; + await startWatcher(); } catch { - // Freighter not available + setStatus('not-installed'); } })(); - }, []); + + return () => { + watcherRef.current?.stop(); + watcherRef.current = null; + clearInstallPoll(); + channel.close(); + channelRef.current = null; + }; + }, [checkFreighter, clearInstallPoll, startWatcher, updateSession]); const connect = useCallback(async () => { const freighter = await import('@stellar/freighter-api'); - const { isConnected: connected } = await freighter.isConnected(); - if (!connected) { - throw new Error( - 'Freighter wallet not found. Please install the Freighter browser extension.', - ); + const { isConnected } = await freighter.isConnected(); + if (!isConnected) { + setStatus('not-installed'); + throw new Error('Freighter wallet not found. Install Freighter, then retry.'); } - await freighter.requestAccess(); - const { address: addr } = await freighter.getAddress(); - if (!addr) throw new Error('Failed to get public key from Freighter'); - setAddress(addr); - }, []); + const { address: nextAddress, error } = await freighter.requestAccess(); + if (!nextAddress) { + setStatus('needs-approval'); + throw new Error(error || 'Approve this app in Freighter to connect your wallet.'); + } + + const { network: freighterNetwork } = await freighter.getNetwork(); + suppressReconnectRef.current = false; + updateSession(nextAddress, freighterNetwork); + await startWatcher(); + }, [startWatcher, updateSession]); const disconnect = useCallback(() => { - setAddress(null); - }, []); + suppressReconnectRef.current = true; + updateSession(null); + }, [updateSession]); + + const retryInstall = useCallback(() => { + clearInstallPoll(); + const startedAt = Date.now(); + const poll = async () => { + if (Date.now() - startedAt >= 30_000) { + clearInstallPoll(); + return; + } + try { + if (await checkFreighter(false)) { + clearInstallPoll(); + await startWatcher(); + } + } catch { + // The extension injects its bridge asynchronously after installation. + } + }; + void poll(); + installPollRef.current = setInterval(poll, 2000); + }, [checkFreighter, clearInstallPoll, startWatcher]); const signMessage = useCallback( async (message: string): Promise => { @@ -58,32 +210,19 @@ 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: 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++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; + return Uint8Array.from(binaryString, (char) => char.charCodeAt(0)); } - // Serialized Buffer: {type: 'Buffer', data: [1, 2, 3, ...]} if ( msg && typeof msg === 'object' && @@ -93,12 +232,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)}`, + `Unexpected signedMessage type: ${typeof msg} - ${JSON.stringify(msg).slice(0, 200)}`, ); }, - [address], + [address, network.networkPassphrase], ); const signTransaction = useCallback( @@ -108,17 +246,28 @@ 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: network.networkPassphrase, }); return signedTxXdr; }, - [address], + [address, network.networkPassphrase], ); return ( {children} diff --git a/src/lib/explorer.ts b/src/lib/explorer.ts index 305000e..ee176fd 100644 --- a/src/lib/explorer.ts +++ b/src/lib/explorer.ts @@ -1,7 +1,7 @@ import { horizenTestnet, STELLAR_NETWORK, SOLANA_NETWORK, CKB_NETWORK } from '@/config'; +import type { StellarNetwork } from '@/config'; const HORIZEN_EXPLORER = horizenTestnet.blockExplorers.default.url; -const STELLAR_EXPLORER = STELLAR_NETWORK.explorerUrl; const SOLANA_EXPLORER = SOLANA_NETWORK.explorerUrl; const CKB_EXPLORER = CKB_NETWORK.explorerUrl; @@ -13,12 +13,12 @@ export function horizenAddrUrl(addr: string) { return `${HORIZEN_EXPLORER}/address/${addr}`; } -export function stellarTxUrl(hash: string) { - return `${STELLAR_EXPLORER}/tx/${hash}`; +export function stellarTxUrl(hash: string, network: StellarNetwork = STELLAR_NETWORK) { + return `${network.explorerUrl}/tx/${hash}`; } -export function stellarAddrUrl(addr: string) { - return `${STELLAR_EXPLORER}/account/${addr}`; +export function stellarAddrUrl(addr: string, network: StellarNetwork = STELLAR_NETWORK) { + return `${network.explorerUrl}/account/${addr}`; } export function solanaTxUrl(hash: string) { diff --git a/src/main.tsx b/src/main.tsx index ee2c8c6..31ce846 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -60,9 +60,9 @@ function Providers({ children }: { children: React.ReactNode }) { }} > - - {children} - + + {children} +