419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
/**
|
|
* 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<string>();
|
|
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<StatusType>('idle');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
// Mnemonic file list state
|
|
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
|
|
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<FocusSection>('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 (
|
|
<Box flexDirection='column' alignItems='center' paddingY={1}>
|
|
{/* Logo */}
|
|
<Box marginBottom={1}>
|
|
<Text color={colors.primary}>{logo}</Text>
|
|
</Box>
|
|
|
|
{/* Title */}
|
|
<Text color={colors.text} bold>Welcome to XO Wallet CLI</Text>
|
|
<Text color={colors.textMuted}>Enter your seed phrase or select a saved wallet</Text>
|
|
|
|
<Box marginY={1} />
|
|
|
|
<Box flexDirection='column' width={64}>
|
|
{/* ── Saved Wallets ─────────────────────────────────── */}
|
|
{mnemonicFiles.length > 0 && (
|
|
<Box flexDirection='column' marginBottom={1}>
|
|
<Box marginBottom={1}>
|
|
<Text color={colors.primary} bold>
|
|
{'▸ '}Saved Wallets
|
|
</Text>
|
|
<Text color={colors.textMuted}> ({mnemonicFiles.length})</Text>
|
|
</Box>
|
|
|
|
<Box
|
|
flexDirection='column'
|
|
borderStyle='single'
|
|
borderColor={focusedSection === 'files' ? colors.focus : colors.borderMuted}
|
|
paddingX={1}
|
|
>
|
|
{mnemonicFiles.map((entry, idx) => {
|
|
const isHighlighted = focusedSection === 'files' && idx === selectedFileIndex;
|
|
|
|
return (
|
|
<Box key={entry.filename} paddingY={0}>
|
|
<Text
|
|
color={isHighlighted ? colors.bg : colors.textMuted}
|
|
backgroundColor={isHighlighted ? colors.focus : undefined}
|
|
bold={isHighlighted}
|
|
>
|
|
{isHighlighted ? ' ▶ ' : ' '}
|
|
</Text>
|
|
<Text
|
|
color={isHighlighted ? colors.bg : colors.text}
|
|
backgroundColor={isHighlighted ? colors.focus : undefined}
|
|
bold={isHighlighted}
|
|
>
|
|
{` ${entry.label} `}
|
|
</Text>
|
|
<Text
|
|
color={isHighlighted ? colors.bg : colors.textMuted}
|
|
backgroundColor={isHighlighted ? colors.focus : undefined}
|
|
dimColor={!isHighlighted}
|
|
>
|
|
{` (${entry.filename})`}
|
|
</Text>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
|
|
{focusedSection === 'files' && (
|
|
<Box marginTop={0} paddingX={1}>
|
|
<Text color={colors.textMuted} dimColor>
|
|
↑↓ navigate • Enter: load wallet
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* ── Divider between sections ──────────────────────── */}
|
|
{mnemonicFiles.length > 0 && (
|
|
<Box marginBottom={1} justifyContent='center'>
|
|
<Text color={colors.borderMuted}>{'─'.repeat(20)} or {'─'.repeat(20)}</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* ── Manual Seed Entry ─────────────────────────────── */}
|
|
<Text color={colors.primary} bold>
|
|
{'▸ '}Manual Entry
|
|
</Text>
|
|
<Box marginTop={1}>
|
|
<Text color={colors.text}>Seed Phrase (12 or 24 words):</Text>
|
|
</Box>
|
|
<Box
|
|
borderStyle='single'
|
|
borderColor={inputBorderColor}
|
|
paddingX={1}
|
|
marginTop={1}
|
|
>
|
|
<TextInput
|
|
value={seedPhrase}
|
|
onChange={setSeedPhrase}
|
|
onSubmit={handleSubmit}
|
|
placeholder='Enter your seed phrase...'
|
|
focus={focusedSection === 'input' && !isSubmitting}
|
|
/>
|
|
</Box>
|
|
|
|
{/* Save mnemonic checkbox (manual entry only; applies on Continue) */}
|
|
<Box
|
|
marginTop={1}
|
|
paddingX={1}
|
|
borderStyle='single'
|
|
borderColor={
|
|
focusedSection === 'saveCheckbox' ? colors.focus : colors.borderMuted
|
|
}
|
|
>
|
|
<Text
|
|
color={focusedSection === 'saveCheckbox' ? colors.focus : colors.text}
|
|
bold={focusedSection === 'saveCheckbox'}
|
|
>
|
|
{saveMnemonicChecked ? '[x] ' : '[ ] '}
|
|
</Text>
|
|
<Text color={colors.text}>Save this mnemonic</Text>
|
|
<Text color={colors.textMuted}> (~/.config/xo-cli/mnemonics/)</Text>
|
|
</Box>
|
|
{focusedSection === 'saveCheckbox' && (
|
|
<Box marginTop={0} paddingX={1}>
|
|
<Text color={colors.textMuted} dimColor>
|
|
Space / Enter: toggle
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Status message */}
|
|
<Box marginTop={1} height={1}>
|
|
{statusMessage && (
|
|
<Text color={statusColor}>
|
|
{statusType === 'loading' && '⏳ '}
|
|
{statusType === 'error' && '✗ '}
|
|
{statusType === 'success' && '✓ '}
|
|
{statusMessage}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Submit button */}
|
|
<Box justifyContent='center' marginTop={1}>
|
|
<Button
|
|
label='Continue'
|
|
focused={focusedSection === 'button'}
|
|
disabled={isSubmitting}
|
|
shortcut='Enter'
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Help text */}
|
|
<Box marginTop={2}>
|
|
<Text color={colors.textMuted} dimColor>
|
|
Tab: navigate • Enter: submit, load wallet, or toggle save • Space: toggle save • Esc: back
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|