/** * Seed Input Screen - Initial screen for wallet seed phrase entry. * * Allows users to enter their BIP39 seed phrase to initialize the wallet, * or select from previously saved mnemonic files on disk. */ import React, { useState, useCallback, useEffect } from 'react'; import { Box, Text } from 'ink'; import TextInput from '../components/TextInput.js'; import { Button } from '../components/Button.js'; import { useNavigation } from '../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { useBlockableInput } from '../hooks/useInputLayer.js'; import { colors, logo } from '../theme.js'; import fs from 'fs'; import path from 'path'; import { createMnemonicFile } from '../../cli/mnemonic.js'; import { getMnemonicsDir } from '../../utils/paths.js'; import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js'; import { encodeBip39Mnemonic } from '@bitauth/libauth'; /** * Status message type. */ type StatusType = 'idle' | 'loading' | 'error' | 'success'; /** * Parsed mnemonic file entry with the derived seed phrase ready for wallet init. */ interface MnemonicFileEntry { filename: string; /** Friendly label derived from filename or the URL comment field. */ label: string; /** The BIP39 mnemonic phrase derived from the file's entropy. */ mnemonic: string; } /** * Focus sections the user can tab between. * When saved wallets exist the file list is shown first. */ type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'button'; /** * Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli), * then from cwd for legacy installs. Parses each as a BCHMnemonicURL. */ function loadMnemonicFiles(): MnemonicFileEntry[] { const dirs = [getMnemonicsDir(), process.cwd()]; const seenBasenames = new Set(); const entries: MnemonicFileEntry[] = []; for (const dir of dirs) { if (!fs.existsSync(dir)) continue; const filenames = fs .readdirSync(dir) .filter((f) => f.startsWith('mnemonic-')); for (const filename of filenames) { if (seenBasenames.has(filename)) continue; try { const content = fs.readFileSync(path.join(dir, filename), 'utf-8').trim(); const parsed = BCHMnemonicURL.fromURL(content); const raw = parsed.toObject(); const mnemonicResult = encodeBip39Mnemonic(raw.entropy); if (typeof mnemonicResult === 'string') continue; /** Use the URL comment as the label, falling back to a cleaned-up filename. */ const label = raw.comment ?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, ''); entries.push({ filename, label, mnemonic: mnemonicResult.phrase }); seenBasenames.add(filename); } catch { // Skip files that can't be parsed } } } return entries; } /** * Seed Input Screen Component. * Provides seed phrase entry for wallet initialization and a selectable * list of previously saved mnemonic files. */ export function SeedInputScreen(): React.ReactElement { const { navigate } = useNavigation(); const { initializeWallet } = useAppContext(); const { setStatus } = useStatus(); // State const [seedPhrase, setSeedPhrase] = useState(''); const [statusMessage, setStatusMessage] = useState(''); const [statusType, setStatusType] = useState('idle'); const [isSubmitting, setIsSubmitting] = useState(false); // Mnemonic file list state const [mnemonicFiles, setMnemonicFiles] = useState([]); const [selectedFileIndex, setSelectedFileIndex] = useState(0); /** When set, manual seed is written to ~/.config/xo-cli/mnemonics/ after a successful unlock. */ const [saveMnemonicChecked, setSaveMnemonicChecked] = useState(false); // Focus: when saved wallets exist default to the file list, otherwise the input. const [focusedSection, setFocusedSection] = useState('input'); useEffect(() => { const entries = loadMnemonicFiles(); setMnemonicFiles(entries); if (entries.length > 0) setFocusedSection('files'); }, []); /** * The ordered list of focusable sections (files section only when entries exist). */ const focusSections: FocusSection[] = mnemonicFiles.length > 0 ? ['files', 'input', 'saveCheckbox', 'button'] : ['input', 'saveCheckbox', 'button']; /** * Shows a status message with the given type. */ const showStatus = useCallback((message: string, type: StatusType) => { setStatusMessage(message); setStatusType(type); }, []); /** * Shared wallet initialization handler used by both manual entry and file selection. */ const doInitialize = useCallback( async (seed: string, options?: { saveMnemonic?: boolean }) => { showStatus('Initializing wallet...', 'loading'); setStatus('Initializing wallet...'); setIsSubmitting(true); try { await initializeWallet(seed); let statusText = 'Wallet initialized successfully!'; if (options?.saveMnemonic) { try { const savedAs = createMnemonicFile(getMnemonicsDir(), seed); setMnemonicFiles(loadMnemonicFiles()); statusText = `Wallet initialized! Mnemonic saved as ${savedAs}`; } catch (saveErr) { const saveMsg = saveErr instanceof Error ? saveErr.message : String(saveErr); statusText = `Wallet initialized, but could not save mnemonic: ${saveMsg}`; } } showStatus(statusText, 'success'); setStatus('Wallet ready'); setSeedPhrase(''); setSaveMnemonicChecked(false); setTimeout(() => { navigate('wallet'); }, 500); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to initialize wallet'; showStatus(message, 'error'); setStatus('Initialization failed'); setIsSubmitting(false); } }, [initializeWallet, navigate, showStatus, setStatus], ); /** * Handles manual seed phrase submission with validation. */ const handleSubmit = useCallback(async () => { const seed = seedPhrase.trim(); if (!seed) { showStatus('Please enter your seed phrase', 'error'); return; } const wordCount = seed.split(/\s+/).length; if (wordCount !== 12 && wordCount !== 24) { showStatus(`Invalid seed phrase. Expected 12 or 24 words, got ${wordCount}`, 'error'); return; } await doInitialize(seed, { saveMnemonic: saveMnemonicChecked }); }, [seedPhrase, saveMnemonicChecked, doInitialize, showStatus]); /** * Handles selecting a mnemonic file from the list. */ const handleFileSelect = useCallback(async (index: number) => { const entry = mnemonicFiles[index]; if (!entry) return; await doInitialize(entry.mnemonic); }, [mnemonicFiles, doInitialize]); // Keyboard navigation useBlockableInput((_input, key) => { if (isSubmitting) return; // Tab / Shift-Tab to cycle focus sections if (key.tab) { setFocusedSection((prev) => { const idx = focusSections.indexOf(prev); const next = key.shift ? (idx - 1 + focusSections.length) % focusSections.length : (idx + 1) % focusSections.length; return focusSections[next]!; }); return; } // Space or Enter toggles "save mnemonic" when that row is focused if (focusedSection === 'saveCheckbox') { if (_input === ' ' || key.return) { setSaveMnemonicChecked((v) => !v); return; } } // Arrow keys inside the file list if (focusedSection === 'files' && mnemonicFiles.length > 0) { if (key.upArrow) { setSelectedFileIndex((prev) => Math.max(0, prev - 1)); return; } if (key.downArrow) { setSelectedFileIndex((prev) => Math.min(mnemonicFiles.length - 1, prev + 1)); return; } if (key.return) { handleFileSelect(selectedFileIndex); return; } } // Enter on button submits manual seed if (key.return && focusedSection === 'button') { handleSubmit(); } }); // Derived style helpers const statusColor = statusType === 'error' ? colors.error : statusType === 'success' ? colors.success : statusType === 'loading' ? colors.info : colors.textMuted; const inputBorderColor = statusType === 'error' ? colors.error : statusType === 'success' ? colors.success : focusedSection === 'input' ? colors.focus : colors.borderMuted; return ( {/* Logo */} {logo} {/* Title */} Welcome to XO Wallet CLI Enter your seed phrase or select a saved wallet {/* ── Saved Wallets ─────────────────────────────────── */} {mnemonicFiles.length > 0 && ( {'▸ '}Saved Wallets ({mnemonicFiles.length}) {mnemonicFiles.map((entry, idx) => { const isHighlighted = focusedSection === 'files' && idx === selectedFileIndex; return ( {isHighlighted ? ' ▶ ' : ' '} {` ${entry.label} `} {` (${entry.filename})`} ); })} {focusedSection === 'files' && ( ↑↓ navigate • Enter: load wallet )} )} {/* ── Divider between sections ──────────────────────── */} {mnemonicFiles.length > 0 && ( {'─'.repeat(20)} or {'─'.repeat(20)} )} {/* ── Manual Seed Entry ─────────────────────────────── */} {'▸ '}Manual Entry Seed Phrase (12 or 24 words): {/* Save mnemonic checkbox (manual entry only; applies on Continue) */} {saveMnemonicChecked ? '[x] ' : '[ ] '} Save this mnemonic (~/.config/xo-cli/mnemonics/) {focusedSection === 'saveCheckbox' && ( Space / Enter: toggle )} {/* Status message */} {statusMessage && ( {statusType === 'loading' && '⏳ '} {statusType === 'error' && '✗ '} {statusType === 'success' && '✓ '} {statusMessage} )} {/* Submit button */}