From be52f73e641d378c04dec554a03630b7e0934ea0 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 16 Mar 2026 07:38:22 +0000 Subject: [PATCH] Add saved-wallets to the import screen. Reads from file --- src/tui/screens/SeedInput.tsx | 275 +++++++++++++++++++++++++++------- 1 file changed, 225 insertions(+), 50 deletions(-) diff --git a/src/tui/screens/SeedInput.tsx b/src/tui/screens/SeedInput.tsx index 5898be8..b6b0a88 100644 --- a/src/tui/screens/SeedInput.tsx +++ b/src/tui/screens/SeedInput.tsx @@ -1,10 +1,11 @@ /** * Seed Input Screen - Initial screen for wallet seed phrase entry. * - * Allows users to enter their BIP39 seed phrase to initialize the wallet. + * 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 } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { Box, Text, useInput } from 'ink'; import TextInput from '../components/TextInput.js'; import { Button } from '../components/Button.js'; @@ -12,14 +13,70 @@ import { useNavigation } from '../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { colors, logo } from '../theme.js'; +import fs from 'fs'; +import path from 'path'; +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' | 'button'; + +/** + * Reads mnemonic-* files from cwd, parses each as a BCHMnemonicURL, + * and converts the entropy back to a BIP39 English mnemonic phrase. + */ +function loadMnemonicFiles(): MnemonicFileEntry[] { + const cwd = process.cwd(); + const filenames = fs.readdirSync(cwd).filter((f) => f.startsWith('mnemonic-')); + const entries: MnemonicFileEntry[] = []; + + for (const filename of filenames) { + try { + const content = fs.readFileSync(path.join(cwd, filename), 'utf-8').trim(); + const parsed = BCHMnemonicURL.fromURL(content); + const raw = parsed.toObject(); + + console.log(raw); + + const mnemonicResult = encodeBip39Mnemonic(raw.entropy); + console.log(mnemonicResult); + 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 }); + } catch { + // Skip files that can't be parsed + } + } + return entries; +} + /** * Seed Input Screen Component. - * Provides seed phrase entry for wallet initialization. + * Provides seed phrase entry for wallet initialization and a selectable + * list of previously saved mnemonic files. */ export function SeedInputScreen(): React.ReactElement { const { navigate } = useNavigation(); @@ -30,9 +87,28 @@ export function SeedInputScreen(): React.ReactElement { const [seedPhrase, setSeedPhrase] = useState(''); const [statusMessage, setStatusMessage] = useState(''); const [statusType, setStatusType] = useState('idle'); - const [focusedElement, setFocusedElement] = useState<'input' | 'button'>('input'); const [isSubmitting, setIsSubmitting] = useState(false); + // Mnemonic file list state + const [mnemonicFiles, setMnemonicFiles] = useState([]); + const [selectedFileIndex, setSelectedFileIndex] = useState(0); + + // 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', 'button'] + : ['input', 'button']; + /** * Shows a status message with the given type. */ @@ -42,12 +118,37 @@ export function SeedInputScreen(): React.ReactElement { }, []); /** - * Handles seed phrase submission. + * Shared wallet initialization handler used by both manual entry and file selection. + */ + const doInitialize = useCallback(async (seed: string) => { + showStatus('Initializing wallet...', 'loading'); + setStatus('Initializing wallet...'); + setIsSubmitting(true); + + try { + await initializeWallet(seed); + + showStatus('Wallet initialized successfully!', 'success'); + setStatus('Wallet ready'); + setSeedPhrase(''); + + 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(); - // Basic validation if (!seed) { showStatus('Please enter your seed phrase', 'error'); return; @@ -59,60 +160,66 @@ export function SeedInputScreen(): React.ReactElement { return; } - // Show loading status - showStatus('Initializing wallet...', 'loading'); - setStatus('Initializing wallet...'); - setIsSubmitting(true); + await doInitialize(seed); + }, [seedPhrase, doInitialize, showStatus]); - try { - // Initialize wallet and create AppService - await initializeWallet(seed); + /** + * 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]); - showStatus('Wallet initialized successfully!', 'success'); - setStatus('Wallet ready'); - - // Clear sensitive data before navigating - setSeedPhrase(''); - - // Navigate to wallet state screen - 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); - } - }, [seedPhrase, initializeWallet, navigate, showStatus, setStatus]); - - // Handle keyboard navigation + // Keyboard navigation useInput((input, key) => { if (isSubmitting) return; - // Tab to switch focus + // Tab / Shift-Tab to cycle focus sections if (key.tab) { - setFocusedElement(prev => prev === 'input' ? 'button' : 'input'); + 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; } - // Enter on button submits - if (key.return && focusedElement === 'button') { + // 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(); } }); - // Get status color + // Derived style helpers const statusColor = statusType === 'error' ? colors.error : statusType === 'success' ? colors.success : statusType === 'loading' ? colors.info : colors.textMuted; - // Get border color based on status const inputBorderColor = statusType === 'error' ? colors.error : statusType === 'success' ? colors.success : - focusedElement === 'input' ? colors.focus : - colors.border; + focusedSection === 'input' ? colors.focus : + colors.borderMuted; return ( @@ -123,16 +230,84 @@ export function SeedInputScreen(): React.ReactElement { {/* Title */} Welcome to XO Wallet CLI - Enter your seed phrase to get started + Enter your seed phrase or select a saved wallet - {/* Spacer */} - {/* Input section */} - Seed Phrase (12 or 24 words): - 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): + + @@ -162,7 +337,7 @@ export function SeedInputScreen(): React.ReactElement {