/** * Wallet State Screen - Displays wallet balances and history. * * Shows: * - Total balance * - Wallet history (invitations, reservations) * - Navigation to other actions */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text } from 'ink'; import { ScrollableList, type ListItemData } from '../components/List.js'; import { QRCode } from '../components/QRCode.js'; import { useNavigation } from '../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js'; import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js'; import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js'; import type { HistoryItem } from '../../services/history.js'; import { generateTemplateIdentifier } from '@xo-cash/engine'; import { hexToBin, lockingBytecodeToCashAddress } from '@bitauth/libauth'; // Import utility functions import { buildHistoryDisplayRows, getHistoryItemColorName, formatHistoryDate, type HistoryDisplayRow, type HistoryColorName, } from '../../utils/history-utils.js'; /** * Map history color name to theme color. */ function getHistoryColor(colorName: HistoryColorName): string { switch (colorName) { case 'info': return colors.info as string; case 'warning': return colors.warning as string; case 'success': return colors.success as string; case 'error': return colors.error as string; case 'muted': return colors.textMuted as string; case 'text': default: return colors.text as string; } } /** * Menu action items. */ const menuItems: ListItemData[] = [ { key: 'new-tx', label: 'New Transaction (from template)', value: 'new-tx' }, { key: 'import', label: 'Import Invitation', value: 'import' }, { key: 'invitations', label: 'View Invitations', value: 'invitations' }, { key: 'new-address', label: 'Generate New Address', value: 'new-address' }, { key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' }, { key: 'refresh', label: 'Refresh', value: 'refresh' }, ]; /** * History list item with display row value. */ type HistoryListItem = ListItemData; /** * QR code dialog overlay — auto-captures input via the layer system. * Rendered only while a QR address is visible; closes on Enter/Esc. */ function QRDialogOverlay({ address, onClose }: { address: string; onClose: () => void }): React.ReactElement { useInputLayer('qr-dialog'); useLayeredInput('qr-dialog', (_input, key) => { if (key.escape || key.return) { onClose(); } }); return ( Press Enter or Esc to close ); } /** * Wallet State Screen Component. * Displays wallet balance, history, and action menu. */ export function WalletStateScreen(): React.ReactElement { const { navigate } = useNavigation(); const { appService, showError, showInfo } = useAppContext(); const { setStatus } = useStatus(); const { currencyCode, fiatPerBchRate, formattedFiatPerBchRate, formatSatoshisToFiat, } = useSatoshisConversion('USD'); // State const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null); const [history, setHistory] = useState([]); const [focusedPanel, setFocusedPanel] = useState<'menu' | 'history'>('menu'); const [selectedMenuIndex, setSelectedMenuIndex] = useState(0); const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0); const [isLoading, setIsLoading] = useState(true); /** Cash address to display in the QR code dialog (null when dialog is hidden). */ const [qrAddress, setQrAddress] = useState(null); /** * Refreshes wallet state. */ const refresh = useCallback(async () => { if (!appService) { showError('AppService not initialized'); return; } try { setIsLoading(true); setStatus('Loading wallet state...'); // Get UTXOs for balance calculation const utxoData = await appService.engine.listUnspentOutputsData(); // Calculate balance const selectableUtxos = utxoData.filter(utxo => utxo.selectable); const balanceData = selectableUtxos.reduce((acc, utxo) => acc + BigInt(utxo.valueSatoshis), BigInt(0)); setBalance({ totalSatoshis: balanceData, utxoCount: selectableUtxos.length, }); // Get wallet history from the history service const historyData = await appService.history.getHistory(); setHistory(historyData); setStatus('Wallet ready'); setIsLoading(false); } catch (error) { showError(`Failed to load wallet state: ${error instanceof Error ? error.message : String(error)}`); setIsLoading(false); } }, [appService, setStatus, showError]); // Load wallet state on mount useEffect(() => { refresh(); }, [refresh]); // Keep wallet state in sync with invitation lifecycle and updates. useEffect(() => { if (!appService) return; const onWalletStateChanged = () => { void refresh(); }; appService.on('wallet-state-changed', onWalletStateChanged); return () => { appService.off('wallet-state-changed', onWalletStateChanged); }; }, [appService, refresh]); /** * Generates a new receiving address and displays it as a QR code. */ const generateNewAddress = useCallback(async () => { if (!appService) { showError('AppService not initialized'); return; } try { setStatus('Generating new address...'); // Get the default P2PKH template const templates = await appService.engine.listImportedTemplates(); const p2pkhTemplate = templates.find(t => t.name?.includes('P2PKH')); if (!p2pkhTemplate) { showError('P2PKH template not found'); return; } // Generate the template identifier const templateId = generateTemplateIdentifier(p2pkhTemplate); // Generate the locking bytecode (returned as a hex string) const lockingBytecodeHex = await appService.engine.generateLockingBytecode( templateId, 'receiveOutput', 'receiver', ); // Convert the locking bytecode to a BCH cash address for display and QR encoding. const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' }); if (typeof result === 'string') { showError(`Failed to encode address: ${result}`); return; } console.log(result); setQrAddress(result.address); setStatus('Address generated'); // Refresh to show updated state await refresh(); } catch (error) { showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`); } }, [appService, setStatus, showError, refresh]); /** * Unreserves all reserved UTXOs and refreshes the wallet state. */ const unreserveAll = useCallback(async () => { if (!appService) { showError('AppService not initialized'); return; } try { setStatus('Unreserving all resources...'); const count = await appService.unreserveAllResources(); showInfo(`Unreserved ${count} resource(s)`); await refresh(); } catch (error) { showError(`Failed to unreserve resources: ${error instanceof Error ? error.message : String(error)}`); } }, [appService, setStatus, showError, showInfo, refresh]); /** * Handles menu action. */ const handleMenuAction = useCallback((action: string) => { switch (action) { case 'new-tx': navigate('templates'); break; case 'import': navigate('invitations', { mode: 'import' }); break; case 'invitations': navigate('invitations', { mode: 'list' }); break; case 'new-address': generateNewAddress(); break; case 'unreserve-all': unreserveAll(); break; case 'refresh': refresh(); break; } }, [navigate, generateNewAddress, unreserveAll, refresh]); /** * Handle menu item activation. */ const handleMenuItemActivate = useCallback((item: ListItemData, index: number) => { if (item.value) { handleMenuAction(item.value); } }, [handleMenuAction]); /** * Build history list items for ScrollableList. */ const historyListItems = useMemo((): HistoryListItem[] => { return buildHistoryDisplayRows(history).map(row => { return { key: row.id, label: row.label, description: row.description, value: row, color: getHistoryItemColorName(row, false), hidden: false, }; }); }, [history]); /** * Fiat values are memoized so we only recompute when balance or rate changes. */ const formattedUsdPerBchRate = useMemo(() => { return formattedFiatPerBchRate; }, [formattedFiatPerBchRate]); const formattedUsdBalance = useMemo(() => { if (!balance || fiatPerBchRate === null) { return null; } return formatSatoshisToFiat(balance.totalSatoshis); }, [balance, fiatPerBchRate, formatSatoshisToFiat]); const getFiatSuffix = useCallback((satoshis: bigint): string => { const fiatValue = formatSatoshisToFiat(satoshis); return fiatValue ? ` (~${fiatValue})` : ''; }, [formatSatoshisToFiat]); // Screen input — automatically blocked when any dialog/overlay is capturing. const isCaptured = useIsInputCaptured(); useBlockableInput((_input, key) => { if (key.tab) { setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu'); } }); /** * Render custom history list item. */ const renderHistoryItem = useCallback(( item: HistoryListItem, isSelected: boolean, isFocused: boolean ): React.ReactNode => { const row = item.value; if (!row) return null; const colorName = getHistoryItemColorName(row, isFocused); const itemColor = isFocused ? colors.focus : getHistoryColor(colorName); const dateStr = formatHistoryDate(row.timestamp); const indicator = isFocused ? '▸ ' : ' '; const groupingPrefix = row.isNested ? ' -> ' : ''; if (row.type === 'invitation') { return ( {indicator}[Invitation] {row.label} {dateStr && {dateStr}} ); } if (row.type === 'invitation_input') { const inputSatoshis = row.utxo?.valueSatoshis; const inputFiatSuffix = inputSatoshis !== undefined ? getFiatSuffix(inputSatoshis) : ''; return ( {indicator}{groupingPrefix}[Input] {row.label} {inputFiatSuffix} {row.description && {row.description}} {dateStr && {dateStr}} ); } if (row.type === 'invitation_output') { const sats = row.utxo?.valueSatoshis ?? 0n; return ( {indicator}{groupingPrefix}[Output] {formatSatoshis(sats)} {getFiatSuffix(sats)} {row.description && {row.description}} {dateStr && {dateStr}} ); } if (row.type === 'utxo') { const sats = row.utxo?.valueSatoshis ?? 0n; const reservedTag = row.utxo?.reserved ? ' [Reserved]' : ''; return ( {indicator}{formatSatoshis(sats)} {getFiatSuffix(sats)} {row.description && {row.description}{reservedTag}} {dateStr && {dateStr}} ); } // Fallback for other types return ( {indicator}{row.label} {dateStr && {dateStr}} ); }, [getFiatSuffix]); return ( {/* Header */} {logoSmall} - Wallet Overview {/* Main content */} {/* Left column: Balance */} Balance Total Balance: {balance ? ( <> {formatSatoshis(balance.totalSatoshis)} {formattedUsdBalance ? ( Approx. Fiat ({currencyCode}): {formattedUsdBalance} ) : ( Approx. Fiat ({currencyCode}): Waiting for BCH/{currencyCode} rate... )} {formattedUsdPerBchRate && ( 1 BCH = {formattedUsdPerBchRate} )} UTXOs: {balance.utxoCount} ) : ( Loading... )} {/* Right column: Actions menu */} Actions {/* Wallet History */} Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''} {isLoading ? ( Loading... ) : ( )} {/* Help text */} Tab: Switch focus • Enter: Select • ↑↓: Navigate • Esc: Back {/* QR Code dialog overlay for generated addresses */} {qrAddress && ( setQrAddress(null)} /> )} ); }