import { useEffect, useMemo, useRef, useState } from 'react'; import { validateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { Check } from 'lucide-react'; import { cn } from '@/lib/utils'; // One input per BIP39 word, with dictionary autocomplete — the same shape Zeus and every other serious // wallet uses for recovery, and for the same reason: a 24-word phrase typed into one freeform box is // impossible to proofread, and a single mistyped word fails the checksum with no clue which one is wrong. // Per-word boxes turn that into an immediately visible red border on word 17. // // SECRET IN FLIGHT. The words live in the parent's state and go straight into the request body. Nothing // here persists, and every field carries the full set of autofill opt-outs (autoComplete="off" plus the // 1Password/LastPass/Bitwarden ignore attributes) — a password manager silently capturing a seed phrase // would be the single worst thing this component could do. const WORDS_PER_ROW_HINT = 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'; // Generous, and the list scrolls. A small cap silently hides real words — "fa" alone has 22 matches and // "fan" is the twelfth, so a cap of 6 makes a perfectly valid word look like it is not in the dictionary. // Truncation here must never be how the owner learns whether their seed word exists. const MAX_SUGGESTIONS = 64; /** BIP39 permits these lengths. 12 and 24 dominate, but a restore must not be blocked by the others. */ export const SEED_LENGTHS = [12, 15, 18, 21, 24] as const; export type SeedLength = (typeof SEED_LENGTHS)[number]; const VALID = new Set(wordlist); /** * Wordlist entries starting with `prefix`, shortest first (alphabetical within a length). Empty prefix * yields nothing — suggestions need intent. * * Shortest-first matters more than it looks. A fully typed word is the shortest thing that can match * itself, so it always lands at position 1 — you never hunt for confirmation that your word exists, and * the default highlight is always the exact match rather than some longer word that merely shares a * prefix. It also front-loads the short words that alphabetical order buries: "fan" is 12th of 22 under * "fa" alphabetically, but 2nd by length. * * Collect-then-sort, never truncate-then-sort: capping during the scan would drop short matches that * happen to sort late. Scanning all 2048 entries per keystroke is free. */ function suggest(prefix: string): string[] { if (!prefix) return []; const matches = wordlist.filter((w) => w.startsWith(prefix)); matches.sort((a, b) => a.length - b.length || a.localeCompare(b)); return matches.slice(0, MAX_SUGGESTIONS); } /** * Split arbitrary pasted text into candidate words. * * Deliberately brutal: BIP39 English words are pure a-z, so everything else is separator. That makes the * paste survive the shapes real backups actually arrive in — numbered lists ("1. fan 2. zoo"), commas, * hard line breaks, non-breaking spaces, smart quotes from a notes app. Without this, a numbered paste * silently becomes twenty-four "words" of digits and the owner is left staring at a wall of red. */ export function normalizePhrase(text: string): string[] { return text .normalize('NFKD') .toLowerCase() .replace(/[^a-z\s]/g, ' ') .split(/\s+/) .filter(Boolean); } export type PhraseProblem = /** Boxes still blank (1-based, for display). */ | { kind: 'incomplete'; boxes: number[] } /** Boxes holding something that is not a BIP39 word at all (1-based). */ | { kind: 'unknown'; boxes: number[] } /** Not a permitted BIP39 length. Only reachable by pasting an odd count. */ | { kind: 'length'; count: number } /** Every word is real and the count is legal, but the checksum says the phrase is not this phrase. */ | { kind: 'checksum' }; /** CRC32-Castagnoli (poly 0x82F63B78) — the polynomial LND's aezeed uses. Not the IEEE one. */ const CRC32C_TABLE = Uint32Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0x82f63b78 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); function crc32c(bytes: Uint8Array): number { let c = 0xffffffff; for (const b of bytes) c = CRC32C_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } /** * Zeus (and anything else running an embedded LND) does NOT hand out BIP39 mnemonics. It hands out * *aezeed* cipher seeds: 24 words drawn from the very same BIP39 English wordlist, so they are visually * indistinguishable, but the trailing bits are a CRC32-Castagnoli checksum instead of a SHA-256 one. * They can never validate as BIP39, no matter how carefully the owner proofreads. * * Officer imports these — the sidecar deciphers the AEZ ciphertext and uses the 16 bytes inside as the * BIP32 master seed directly. But BIP39 validation still fails on them, so without this check the owner * would be told "invalid checksum" for a phrase that is perfectly correct. * * This mirrors `isAezeedPhrase` in the sidecar (`wallet/aezeed/cipher-seed.ts`). It is duplicated rather * than shared because it must run in the browser, where nothing under `src/servers` is reachable — and * it is pure structure, no secret and no key material, so it is safe to ship client-side. The sidecar * still decides independently; this only makes the form honest before the round trip. * * Layout: version(1) + ciphertext(23) + salt(5) + crc32c(4) = 33 bytes = 24 x 11 bits. * Verified against LND's own published test vector. */ export function isAezeed(words: string[]): boolean { if (words.length !== 24) return false; let bits = ''; for (const word of words) { const index = wordlist.indexOf(word); if (index < 0) return false; bits += index.toString(2).padStart(11, '0'); } const bytes = Uint8Array.from(bits.match(/.{8}/g)!.map((b) => parseInt(b, 2))); if (bytes[0] !== 0) return false; // aezeed CipherSeedVersion const view = new DataView(bytes.buffer); return crc32c(bytes.subarray(0, 29)) === view.getUint32(29, false); } /** * What is wrong with the phrase, checked locally so the owner never spends a network round trip — or * risks the form state — to learn they mistyped word 17. This mirrors what the sidecar does (BIP39 * checksum, else aezeed structure), just run earlier; the sidecar remains the authority and still * rejects independently. Purely local: nothing is transmitted to validate. * * An aezeed phrase is NOT a problem — it is a supported second format, so it returns null here and the * caller uses `isAezeed` separately to explain what it is looking at. */ export function phraseProblem(words: string[]): PhraseProblem | null { const trimmed = words.map((w) => w.trim().toLowerCase()); const blank = trimmed.map((w, i) => (w ? -1 : i + 1)).filter((i) => i > 0); if (blank.length) return { kind: 'incomplete', boxes: blank }; const unknown = trimmed.map((w, i) => (VALID.has(w) ? -1 : i + 1)).filter((i) => i > 0); if (unknown.length) return { kind: 'unknown', boxes: unknown }; if (!(SEED_LENGTHS as readonly number[]).includes(trimmed.length)) { return { kind: 'length', count: trimmed.length }; } if (validateMnemonic(trimmed.join(' '), wordlist)) return null; // Checked before the generic failure: an aezeed seed is a different supported format, not a typo. if (isAezeed(trimmed)) return null; return { kind: 'checksum' }; } /** * The words are all real and the checksum still fails — so look for the one mistake that is invisible * when you proofread: right words, wrong order. * * Wallets print backups in a grid, and plenty of them run the phrase DOWN the columns (1-12 left, 13-24 * right). Read ACROSS instead and you get every word present, every word spelled correctly, every box * green, and a phrase that is simply not yours. Nothing in a word-by-word review can catch it, because * every word individually is fine. * * That misreading is exactly a matrix transpose, so it is cheap to test: for each rows x cols factoring * of the length, un-transpose and re-check. A handful of validations, and it either finds the owner's * real phrase or rules the theory out. Returns null when the order is not the problem. * * NOT a brute-force search. Trying every single-word substitution is useless here — the checksum is only * 8 bits, so ~8 different words validate at EVERY position and it can localise nothing. */ export function findOrderFix(words: string[]): { rows: number; cols: number; words: string[] } | null { const n = words.length; for (let rows = 2; rows < n; rows++) { if (n % rows) continue; const cols = n / rows; const fixed = new Array(n); for (let i = 0; i < rows; i++) for (let j = 0; j < cols; j++) fixed[j * rows + i] = words[i * cols + j]!; if (validateMnemonic(fixed.join(' '), wordlist)) return { rows, cols, words: fixed }; } return null; } type SeedPhraseInputProps = { words: string[]; onChange: (words: string[]) => void; /** * Fired when a pasted phrase is itself a valid BIP39 length that differs from the current box count, * so the parent can resize instead of making the owner notice the mismatch and fix the toggle by hand. */ onLengthDetected?: (count: number) => void; disabled?: boolean; }; export const SeedPhraseInput = ({ words, onChange, onLengthDetected, disabled }: SeedPhraseInputProps) => { const refs = useRef<(HTMLInputElement | null)[]>([]); const [focused, setFocused] = useState(null); const [highlight, setHighlight] = useState(0); /** Whether the owner moved the highlight by hand — the difference between "chose that word" and * "happened to have it highlighted". Reset on every keystroke and on leaving the box. */ const [arrowed, setArrowed] = useState(false); const suggestions = useMemo(() => (focused == null ? [] : suggest(words[focused] ?? '')), [focused, words]); // The list shrinks as you type, so a stale index can point past the end. Clamp at read time rather than // trying to keep `highlight` in sync with every keystroke. const safeHighlight = suggestions.length ? Math.min(highlight, suggestions.length - 1) : 0; const highlightRef = useRef(null); useEffect(() => { highlightRef.current?.scrollIntoView({ block: 'nearest' }); }, [safeHighlight, focused]); const setWord = (index: number, value: string) => { const next = [...words]; next[index] = value; onChange(next); }; const focus = (index: number) => { const el = refs.current[index]; if (!el) return; el.focus(); el.select(); }; /** Accept a suggestion (or whatever is typed) and move on. The last box stays put. */ const commit = (index: number, value: string) => { setWord(index, value); setHighlight(0); setArrowed(false); if (index < words.length - 1) requestAnimationFrame(() => focus(index + 1)); }; // Pasting the whole phrase into any box is how people actually restore. Spread it across the boxes // from here on rather than dumping 24 words into one field. const handlePaste = (index: number, ev: React.ClipboardEvent) => { const parts = normalizePhrase(ev.clipboardData.getData('text')); if (parts.length < 2) return; ev.preventDefault(); // A whole phrase pasted into box 1 of the wrong-size grid: resize rather than truncate. Only from the // first box — pasting a fragment mid-phrase should never resize the form. if (index === 0 && parts.length !== words.length && (SEED_LENGTHS as readonly number[]).includes(parts.length)) { onLengthDetected?.(parts.length); onChange(parts); requestAnimationFrame(() => focus(parts.length - 1)); return; } const next = [...words]; for (let i = 0; i < parts.length && index + i < next.length; i++) next[index + i] = parts[i]!; onChange(next); const landed = Math.min(index + parts.length, next.length - 1); requestAnimationFrame(() => focus(landed)); }; const handleKeyDown = (index: number, ev: React.KeyboardEvent) => { const current = words[index] ?? ''; if (ev.key === 'ArrowDown' && suggestions.length) { ev.preventDefault(); setArrowed(true); setHighlight((safeHighlight + 1) % suggestions.length); return; } if (ev.key === 'ArrowUp' && suggestions.length) { ev.preventDefault(); setArrowed(true); setHighlight((safeHighlight - 1 + suggestions.length) % suggestions.length); return; } // Space and Enter advance. Tab does NOT accept — tab is navigation, and hijacking it to mean // "accept" makes it impossible to skip a box you meant to come back to. // // A suggestion is only substituted for what you typed when the choice is UNAMBIGUOUS: you arrowed to // it deliberately, or it is the single remaining match, or you already typed the whole word. Typing // "fa" and hitting space must NOT silently store "fabric" — for a seed phrase, a wrong-but-valid word // is the worst possible outcome, because it passes every check this component can do and only fails // later as an opaque checksum error with no clue which box is at fault. Ambiguous input is left // exactly as typed, where the missing checkmark makes it obvious. if (ev.key === ' ' || ev.key === 'Enter') { if (!current) return; ev.preventDefault(); const unambiguous = arrowed || suggestions.length === 1 || VALID.has(current); commit(index, unambiguous ? (suggestions[safeHighlight] ?? current) : current); return; } // Backspace out of an empty box steps back, so correcting a run of words never needs the mouse. if (ev.key === 'Backspace' && !current && index > 0) { ev.preventDefault(); focus(index - 1); } }; return (
{words.map((word, index) => { const valid = VALID.has(word); const invalid = word.length > 0 && !valid; // Shown whenever the box has focus and anything matches — NOT gated on the word being invalid. // "fan" is both a complete word and a prefix of "fancy"/"fantasy"; hiding the list the moment the // text happens to be valid strands the owner mid-word with no way to see the longer options. const showSuggestions = focused === index && suggestions.length > 0; return (
{index + 1} { refs.current[index] = el; }} value={word} disabled={disabled} onChange={(ev) => { setWord(index, ev.target.value.trim().toLowerCase()); // Typing invalidates a deliberate pick — the list underneath just changed. setHighlight(0); setArrowed(false); }} onFocus={() => { setFocused(index); setHighlight(0); setArrowed(false); }} onBlur={() => setFocused((f) => (f === index ? null : f))} onPaste={(ev) => handlePaste(index, ev)} onKeyDown={(ev) => handleKeyDown(index, ev)} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false} data-1p-ignore data-lpignore="true" data-bwignore className="h-full w-full bg-transparent px-2 font-mono text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50" /> {/* Positive confirmation, not just the absence of red: a typo that happens to be another real word is caught by reading the phrase back, but a typo that is NOT a word should be obvious without having to compare against the dropdown. */} {valid && }
{showSuggestions && ( // onMouseDown, not onClick: blur fires first on click and would unmount the list mid-press.
    {suggestions.map((s, i) => (
  • ))}
)}
); })}
); };