diff --git a/next.config.ts b/next.config.ts index d3b1d62..d39fed8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,19 @@ const API_URL = const nextConfig: NextConfig = { output: 'standalone', reactStrictMode: true, + experimental: { + optimizePackageImports: ['@noble/ed25519', '@noble/hashes', '@noble/curves'], + }, + async headers() { + return [ + { + source: '/_next/static/:path*', + headers: [ + { key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }, + ], + }, + ]; + }, async rewrites() { return [ { diff --git a/src/components/pool-detail/PriceChart.tsx b/src/components/pool-detail/PriceChart.tsx index a55eeec..f3c2a46 100644 --- a/src/components/pool-detail/PriceChart.tsx +++ b/src/components/pool-detail/PriceChart.tsx @@ -7,6 +7,11 @@ import { useTwap } from '@/hooks/useTwap'; import { usePriceHistory } from '@/hooks/usePriceHistory'; import { cn } from '@/lib/utils'; +// Hoist dynamic import to module level so it's cached across renders +const chartModulePromise = typeof window !== 'undefined' + ? import('lightweight-charts') + : null; + interface PriceChartProps { /** TWAP spot price as string (fallback when no history). */ spotPrice: string | null; @@ -100,7 +105,8 @@ export function PriceChart({ spotPrice, poolId, className }: PriceChartProps) { async function initChart() { try { - const { createChart, LineStyle } = await import('lightweight-charts'); + if (!chartModulePromise) return; + const { createChart, LineStyle } = await chartModulePromise; if (disposed || !chartRef.current) return; const chart = createChart(chartRef.current, { diff --git a/src/components/wallet/WalletButton.tsx b/src/components/wallet/WalletButton.tsx index 584ec26..8529009 100644 --- a/src/components/wallet/WalletButton.tsx +++ b/src/components/wallet/WalletButton.tsx @@ -8,7 +8,7 @@ import { WalletModal } from '@/components/wallet/WalletModal'; export function WalletButton() { const [modalOpen, setModalOpen] = useState(false); const [mounted, setMounted] = useState(false); - const { address, isConnected, isLocked, hydrate } = useWalletStore(); + const { address, isConnected, isLocked, source, extensionDetected, hydrate } = useWalletStore(); useEffect(() => { hydrate(); @@ -24,6 +24,8 @@ export function WalletButton() { ? 'Unlock Wallet' : 'Connect Wallet'; + const showExtensionDot = mounted && isConnected && source === 'extension'; + return ( <> diff --git a/src/components/wallet/WalletModal.tsx b/src/components/wallet/WalletModal.tsx index a5fcab1..03d5a28 100644 --- a/src/components/wallet/WalletModal.tsx +++ b/src/components/wallet/WalletModal.tsx @@ -13,23 +13,31 @@ interface WalletModalProps { } export function WalletModal({ open, onOpenChange }: WalletModalProps) { - const { isConnected, address } = useWalletStore(); + const { isConnected, address, source, extensionDetected } = useWalletStore(); if (isConnected && address) { return ( - onOpenChange(false)} /> + onOpenChange(false)} /> ); } const keystoreExists = typeof window !== 'undefined' && hasKeystore(); - const defaultTab = keystoreExists ? 'unlock' : 'create'; + const defaultTab = extensionDetected ? 'extension' : keystoreExists ? 'unlock' : 'create'; return ( + {extensionDetected && ( + + Extension + + )} + {extensionDetected && ( + + onOpenChange(false)} /> + + )} onOpenChange(false)} /> @@ -74,9 +87,11 @@ export function WalletModal({ open, onOpenChange }: WalletModalProps) { function ConnectedView({ address, + source, onClose, }: { address: string; + source: 'local' | 'extension' | null; onClose: () => void; }) { const { lock, disconnect } = useWalletStore(); @@ -100,6 +115,14 @@ function ConnectedView({ return (
+ {/* Source badge */} +
+ + + {source === 'extension' ? 'Basalt Wallet Extension' : 'Local Wallet'} + +
+ {/* Address display */}
@@ -123,13 +146,15 @@ function ConnectedView({ {/* Actions */}
- + {source === 'local' && ( + + )} +
+ ); +} + // --------------------------------------------------------------------------- // Tab forms // --------------------------------------------------------------------------- diff --git a/src/hooks/useTokenBalances.ts b/src/hooks/useTokenBalances.ts index 23158b3..8b183fd 100644 --- a/src/hooks/useTokenBalances.ts +++ b/src/hooks/useTokenBalances.ts @@ -179,7 +179,7 @@ export function useTokenBalances( const { data, error, isLoading, mutate } = useSWR( userAddress && tokens.length > 0 - ? `token-balances:${userAddress}:${tokens.sort().join(',')}` + ? `token-balances:${userAddress}:${[...tokens].sort().join(',')}` : null, () => fetchAllBalances(tokens, userAddress!), { diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts index 522cb97..397e042 100644 --- a/src/hooks/useTransaction.ts +++ b/src/hooks/useTransaction.ts @@ -9,6 +9,7 @@ import { type TransactionRequest, } from '@/lib/api/transactions'; import type { ReceiptResponse } from '@/lib/types/api'; +import { useWalletStore } from '@/stores/wallet'; export type TransactionStatus = | 'idle' @@ -19,6 +20,7 @@ export type TransactionStatus = | 'failed'; interface UseTransactionReturn { + /** Submit a transaction. If connected via extension, privateKey is ignored (pass any Uint8Array). */ submit: ( tx: UnsignedTransaction, privateKey: Uint8Array, @@ -36,6 +38,7 @@ const POLL_TIMEOUT_MS = 30_000; /** * Hook for building, signing, submitting, and polling for transaction confirmation. + * Automatically routes through the Basalt Wallet extension when connected via extension. */ export function useTransaction(): UseTransactionReturn { const [status, setStatus] = useState('idle'); @@ -60,6 +63,7 @@ export function useTransaction(): UseTransactionReturn { const controller = new AbortController(); abortRef.current = controller; const deadline = Date.now() + POLL_TIMEOUT_MS; + let delay = POLL_INTERVAL_MS; while (Date.now() < deadline) { if (controller.signal.aborted) return null; @@ -68,16 +72,17 @@ export function useTransaction(): UseTransactionReturn { const result = await getReceipt(hash); return result; } catch { - // Receipt not yet available, wait and retry + // Receipt not yet available, wait and retry with backoff } await new Promise((resolve) => { - const timer = setTimeout(resolve, POLL_INTERVAL_MS); + const timer = setTimeout(resolve, delay); controller.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }); }); + delay = Math.min(delay * 2, 8_000); // Exponential backoff, cap at 8s } return null; @@ -85,68 +90,148 @@ export function useTransaction(): UseTransactionReturn { [], ); + const submitViaExtension = useCallback( + async (tx: UnsignedTransaction): Promise => { + const provider = window.basalt; + if (!provider) throw new Error('Basalt Wallet extension not available'); + + // Send the transaction through the extension. + // The extension handles signing, nonce management, and broadcasting. + setStatus('signing'); + setError(null); + setReceipt(null); + setTxHash(null); + + const txPayload: Record = { + to: tx.to, + value: tx.value.toString(), + gasLimit: tx.gasLimit, + data: tx.data.length > 0 ? bytesToHex(tx.data, true) : undefined, + }; + + // Route by transaction type + // Types 7-20 are DEX operations → use tx_dex + // Type 2 is ContractCall → use tx_contract_call + // Default → use tx_send + let method: string; + if (tx.type >= 7 && tx.type <= 20) { + method = 'tx_dex'; + // For DEX ops, the extension expects the raw data + operation metadata + txPayload.type = tx.type; + } else if (tx.type === 2) { + method = 'tx_contract_call'; + txPayload.data = tx.data.length > 0 ? Array.from(tx.data) : undefined; + } else { + method = 'tx_send'; + } + + setStatus('submitting'); + const result = await provider.sendTransaction({ + method, + ...txPayload, + }); + + const hash = result.hash; + setTxHash(hash); + + // Poll for receipt + setStatus('confirming'); + const confirmedReceipt = await pollForReceipt(hash); + + if (confirmedReceipt) { + setReceipt(confirmedReceipt); + setStatus(confirmedReceipt.success ? 'confirmed' : 'failed'); + if (!confirmedReceipt.success) { + setError(confirmedReceipt.errorCode || 'Transaction execution failed'); + } + return confirmedReceipt; + } else { + setStatus('failed'); + setError('Transaction confirmation timed out'); + return null; + } + }, + [pollForReceipt], + ); + + const submitLocal = useCallback( + async ( + tx: UnsignedTransaction, + privateKey: Uint8Array, + ): Promise => { + // Sign + setStatus('signing'); + setError(null); + setReceipt(null); + setTxHash(null); + + const { signature, publicKey } = signTransaction(tx, privateKey); + + // Submit + setStatus('submitting'); + + const request: TransactionRequest = { + type: tx.type, + nonce: tx.nonce, + sender: tx.sender, + to: tx.to, + value: tx.value.toString(), + gasLimit: tx.gasLimit, + gasPrice: tx.gasPrice.toString(), + maxFeePerGas: + tx.maxFeePerGas > 0n ? tx.maxFeePerGas.toString() : undefined, + maxPriorityFeePerGas: + tx.maxPriorityFeePerGas > 0n + ? tx.maxPriorityFeePerGas.toString() + : undefined, + data: + tx.data.length > 0 + ? bytesToHex(tx.data, true) + : undefined, + priority: tx.priority, + chainId: tx.chainId, + signature, + senderPublicKey: publicKey, + }; + + const response = await submitTransaction(request); + setTxHash(response.hash); + + // Poll for receipt + setStatus('confirming'); + const confirmedReceipt = await pollForReceipt(response.hash); + + if (confirmedReceipt) { + setReceipt(confirmedReceipt); + setStatus(confirmedReceipt.success ? 'confirmed' : 'failed'); + if (!confirmedReceipt.success) { + setError( + confirmedReceipt.errorCode || 'Transaction execution failed', + ); + } + return confirmedReceipt; + } else { + setStatus('failed'); + setError('Transaction confirmation timed out'); + return null; + } + }, + [pollForReceipt], + ); + const submit = useCallback( async ( tx: UnsignedTransaction, privateKey: Uint8Array, ): Promise => { try { - // Sign - setStatus('signing'); - setError(null); - setReceipt(null); - setTxHash(null); - - const { signature, publicKey } = signTransaction(tx, privateKey); - - // Submit - setStatus('submitting'); - - const request: TransactionRequest = { - type: tx.type, - nonce: tx.nonce, - sender: tx.sender, - to: tx.to, - value: tx.value.toString(), - gasLimit: tx.gasLimit, - gasPrice: tx.gasPrice.toString(), - maxFeePerGas: - tx.maxFeePerGas > 0n ? tx.maxFeePerGas.toString() : undefined, - maxPriorityFeePerGas: - tx.maxPriorityFeePerGas > 0n - ? tx.maxPriorityFeePerGas.toString() - : undefined, - data: - tx.data.length > 0 - ? bytesToHex(tx.data, true) - : undefined, - priority: tx.priority, - chainId: tx.chainId, - signature, - senderPublicKey: publicKey, - }; - - const response = await submitTransaction(request); - setTxHash(response.hash); - - // Poll for receipt - setStatus('confirming'); - const confirmedReceipt = await pollForReceipt(response.hash); - - if (confirmedReceipt) { - setReceipt(confirmedReceipt); - setStatus(confirmedReceipt.success ? 'confirmed' : 'failed'); - if (!confirmedReceipt.success) { - setError( - confirmedReceipt.errorCode || 'Transaction execution failed', - ); - } - return confirmedReceipt; - } else { - setStatus('failed'); - setError('Transaction confirmation timed out'); - return null; + const { source } = useWalletStore.getState(); + + if (source === 'extension') { + return await submitViaExtension(tx); } + + return await submitLocal(tx, privateKey); } catch (err) { setStatus('failed'); const message = @@ -155,7 +240,7 @@ export function useTransaction(): UseTransactionReturn { return null; } }, - [pollForReceipt], + [submitViaExtension, submitLocal], ); return { diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index e373ea9..0e0f19e 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -1,30 +1,25 @@ import { useWalletStore } from '@/stores/wallet'; +import { useShallow } from 'zustand/react/shallow'; /** * Hook that provides wallet state and actions. + * Uses a single compound selector to minimize re-renders. */ export function useWallet() { - const address = useWalletStore((s) => s.address); - const publicKey = useWalletStore((s) => s.publicKey); - const privateKey = useWalletStore((s) => s.privateKey); - const isLocked = useWalletStore((s) => s.isLocked); - const isConnected = useWalletStore((s) => s.isConnected); - const create = useWalletStore((s) => s.create); - const importKey = useWalletStore((s) => s.importKey); - const unlock = useWalletStore((s) => s.unlock); - const lock = useWalletStore((s) => s.lock); - const disconnect = useWalletStore((s) => s.disconnect); - - return { - address, - publicKey, - privateKey, - isLocked, - isConnected, - create, - importKey, - unlock, - lock, - disconnect, - }; + return useWalletStore( + useShallow((s) => ({ + address: s.address, + publicKey: s.publicKey, + privateKey: s.privateKey, + isLocked: s.isLocked, + isConnected: s.isConnected, + source: s.source, + create: s.create, + importKey: s.importKey, + unlock: s.unlock, + lock: s.lock, + disconnect: s.disconnect, + connectExtension: s.connectExtension, + })), + ); } diff --git a/src/lib/types/basalt-provider.ts b/src/lib/types/basalt-provider.ts new file mode 100644 index 0000000..a97536d --- /dev/null +++ b/src/lib/types/basalt-provider.ts @@ -0,0 +1,26 @@ +/** Type declarations for the window.basalt provider injected by the Basalt Wallet extension. */ + +export interface BasaltProvider { + readonly isBasalt: true; + connected: boolean; + accounts: string[]; + chainId: number | null; + + connect(): Promise<{ accounts: string[]; chainId: number }>; + disconnect(): Promise; + signTransaction(tx: Record): Promise<{ signature: string; hash: string }>; + sendTransaction(tx: Record): Promise<{ hash: string }>; + signMessage(message: string | Uint8Array): Promise<{ signature: string }>; + getBalance(address?: string): Promise; + getChainId(): Promise; + request(args: { method: string; params?: unknown }): Promise; + + on(event: string, callback: (...args: unknown[]) => void): void; + off(event: string, callback: (...args: unknown[]) => void): void; +} + +declare global { + interface Window { + basalt?: BasaltProvider; + } +} diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index 706d8f6..dbe5943 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -12,6 +12,9 @@ import { hasKeystore, clearKeystore, } from '@/lib/crypto/keystore'; +import type { BasaltProvider } from '@/lib/types/basalt-provider'; + +export type WalletSource = 'local' | 'extension' | null; interface WalletState { address: string | null; @@ -19,11 +22,19 @@ interface WalletState { privateKey: Uint8Array | null; isLocked: boolean; isConnected: boolean; + /** How the wallet is connected: 'local' (in-browser keystore) or 'extension' (Basalt Wallet). */ + source: WalletSource; + /** Whether the Basalt Wallet extension is detected on the page. */ + extensionDetected: boolean; } interface WalletActions { /** Check localStorage for existing keystore (call after mount). */ hydrate: () => void; + /** Detect if window.basalt extension is available. */ + detectExtension: () => void; + /** Connect via Basalt Wallet browser extension. */ + connectExtension: () => Promise; /** Generate a new key pair, encrypt, and save to localStorage. */ create: (password: string) => Promise; /** Import a hex-encoded private key, encrypt, and save. */ @@ -32,7 +43,7 @@ interface WalletActions { unlock: (password: string) => Promise; /** Lock the wallet (clear in-memory keys). */ lock: () => void; - /** Disconnect and clear the keystore entirely. */ + /** Disconnect and clear the keystore / extension connection. */ disconnect: () => void; } @@ -45,18 +56,100 @@ function hexToBytes(hex: string): Uint8Array { return bytes; } -export const useWalletStore = create((set) => ({ +let _hydrated = false; + +function getProvider(): BasaltProvider | undefined { + return typeof window !== 'undefined' ? window.basalt : undefined; +} + +export const useWalletStore = create((set, get) => ({ // Initial state address: null, publicKey: null, privateKey: null, isLocked: false, isConnected: false, + source: null, + extensionDetected: false, hydrate: () => { - if (typeof window !== 'undefined' && hasKeystore()) { + if (typeof window === 'undefined') return; + if (_hydrated) return; // Prevent duplicate event listener registration + _hydrated = true; + + // Detect extension + get().detectExtension(); + + // Check for local keystore + if (hasKeystore()) { set({ isLocked: true }); } + + // Listen for extension events + const provider = getProvider(); + if (provider) { + provider.on('accountsChanged', (accounts) => { + const { source } = get(); + if (source === 'extension') { + const accs = accounts as string[]; + set({ address: accs[0] ?? null, isConnected: accs.length > 0 }); + } + }); + provider.on('disconnect', () => { + const { source } = get(); + if (source === 'extension') { + set({ address: null, isConnected: false, source: null }); + } + }); + } + }, + + detectExtension: () => { + if (typeof window === 'undefined') return; + + const tryDetect = () => { + const provider = getProvider(); + if (provider?.isBasalt) { + set({ extensionDetected: true }); + if (provider.connected && provider.accounts.length > 0) { + set({ address: provider.accounts[0], isConnected: true, source: 'extension', isLocked: false }); + } + return true; + } + return false; + }; + + // Immediate check + if (tryDetect()) return; + + // Listen for the initialization event (fires when inpage.js loads) + const handler = () => { tryDetect(); }; + window.addEventListener('basalt#initialized', handler, { once: true }); + + // Poll as fallback — the event may have fired before this listener was registered + let attempts = 0; + const poll = setInterval(() => { + attempts++; + if (tryDetect() || attempts >= 10) { + clearInterval(poll); + window.removeEventListener('basalt#initialized', handler); + } + }, 500); + }, + + connectExtension: async () => { + const provider = getProvider(); + if (!provider) throw new Error('Basalt Wallet extension not found'); + + const result = await provider.connect(); + set({ + address: result.accounts[0] ?? null, + publicKey: null, + privateKey: null, + isLocked: false, + isConnected: result.accounts.length > 0, + source: 'extension', + }); }, create: async (password: string) => { @@ -70,6 +163,7 @@ export const useWalletStore = create((set) => ({ privateKey, isLocked: false, isConnected: true, + source: 'local', }); }, @@ -85,6 +179,7 @@ export const useWalletStore = create((set) => ({ privateKey, isLocked: false, isConnected: true, + source: 'local', }); }, @@ -102,24 +197,34 @@ export const useWalletStore = create((set) => ({ privateKey, isLocked: false, isConnected: true, + source: 'local', }); }, lock: () => { - set({ - privateKey: null, - isLocked: true, - }); + const { source } = get(); + if (source === 'extension') { + // Extension manages its own lock state + return; + } + set({ privateKey: null, isLocked: true }); }, disconnect: () => { - clearKeystore(); + const { source } = get(); + if (source === 'extension') { + const provider = getProvider(); + provider?.disconnect().catch(() => {}); + } else { + clearKeystore(); + } set({ address: null, publicKey: null, privateKey: null, isLocked: false, isConnected: false, + source: null, }); }, }));