import lnd aezeed cipher seeds alongside bip39

An LND seed is not a BIP39 mnemonic. It shares the 24-word shape and the English wordlist,
which is exactly why it fails confusingly: the words validate as plausible input, the
checksum does not, and the user is told their own seed is invalid.

aezeed is a different construction — a 19-byte payload (version, birthday, 16-byte entropy)
sealed with AEZ under scrypt(passphrase, salt), with the salt carried in the mnemonic
itself. So it needs its own decipher, not a flag on the BIP39 path. The vendored aez/aezeed
implementation under sidecar/wallet/aezeed does that, and the recovered entropy becomes the
BIP32 root the same way BIP39 output does.

The seed envelope gains a kind ('bip39' | 'aezeed') so an unlock knows which derivation to
run rather than guessing from word count, which cannot distinguish them.

Also note the aezeed passphrase is not a BIP39 passphrase: it decrypts the seed rather than
salting the derivation, so a wrong one fails the checksum outright instead of silently
producing a different wallet. The UI can therefore tell the user they typed it wrong, which
is not possible for BIP39.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:12:35 +00:00
co-authored by Claude Opus 5
parent fb70c83ae7
commit 2647510965
7 changed files with 1524 additions and 47 deletions
@@ -15,10 +15,19 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { KIND_LABELS, walletSectionPath, type WalletSectionId } from '../shared';
import { useWalletConfig, useWalletLifecycle } from '../useWalletData';
import { errorMessage, useWalletConfig, useWalletLifecycle } from '../useWalletData';
import { SeedBackupDialog } from './SeedBackupDialog';
import { Textarea } from '@/components/ui/textarea';
import {
SeedPhraseInput,
SEED_LENGTHS,
findOrderFix,
isAezeed,
normalizePhrase,
phraseProblem,
type SeedLength,
} from './SeedPhraseInput';
// Add a wallet. Two genuinely different shapes behind one form:
//
@@ -67,7 +76,15 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
const [kind, setKind] = useState<BackendKind>('onchain');
const [words, setWords] = useState<12 | 24>(24);
const [importing, setImporting] = useState(false);
const [mnemonicInput, setMnemonicInput] = useState('');
// One entry per word rather than one string: the boxes ARE the state, so a wrong word is visible where
// it is rather than somewhere in a wall of text. Joined back into a phrase only at submit.
const [importLength, setImportLength] = useState<SeedLength>(24);
const [importWords, setImportWords] = useState<string[]>(() => Array(24).fill(''));
// A plain-text escape hatch alongside the boxes. Both write the same `importWords`, so validation and
// submit never care which was used — but when a phrase the owner knows is right will not validate, this
// is what separates "my per-box entry mangled it" from "the phrase really is different".
const [asText, setAsText] = useState(false);
const [importText, setImportText] = useState('');
// Both of these are secrets in flight. They exist in this component's state, go into the request body,
// and are cleared the moment the request settles — never a store, never a query key, never the URL.
const [passphrase, setPassphrase] = useState('');
@@ -84,11 +101,38 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
const storeKeyMissing = config != null && !config.storeKeyConfigured;
const isOnchain = kind === 'onchain';
/** Anything the owner would be upset to retype — guards the incidental-dismiss paths below. */
const hasTypedSecret =
importWords.some((w) => w.trim()) || passphrase.length > 0 || Object.values(remoteConfig).some((v) => v?.trim());
/** Grow or shrink the grid, keeping whatever has already been typed. */
const resizeImport = (n: SeedLength) => {
setImportLength(n);
setImportWords((prev) => Array.from({ length: n }, (_, i) => prev[i] ?? ''));
};
/** Text mode is the source of truth while it is open; the boxes mirror it word for word. */
const applyText = (text: string) => {
setImportText(text);
const parts = normalizePhrase(text);
if ((SEED_LENGTHS as readonly number[]).includes(parts.length)) setImportLength(parts.length as SeedLength);
setImportWords(parts);
};
/** Switching modes must not silently drop or reorder anything the owner already entered. */
const toggleTextMode = (next: boolean) => {
if (next) setImportText(importWords.filter(Boolean).join(' '));
else setImportWords((prev) => Array.from({ length: importLength }, (_, i) => prev[i] ?? ''));
setAsText(next);
};
const clearSecrets = () => {
setPassphrase('');
setConfirmPassphrase('');
setBip39Passphrase('');
setMnemonicInput('');
// Functional, so it blanks whatever length the grid currently is without reading stale state.
setImportWords((prev) => prev.map(() => ''));
setImportText('');
setRemoteConfig({});
};
@@ -99,6 +143,9 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
setImporting(false);
setMakeActive(true);
clearSecrets();
setAsText(false);
setImportLength(24);
setImportWords(Array(24).fill(''));
};
const close = () => {
@@ -109,38 +156,64 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
const passphraseValid = !isOnchain || (passphrase.length >= 8 && passphrase === confirmPassphrase);
const configValid =
isOnchain || CONFIG_FIELDS[kind as Exclude<BackendKind, 'onchain'>].every((f) => remoteConfig[f.key]?.trim());
const canSubmit = !!name.trim() && passphraseValid && configValid && !storeKeyMissing && !create.isPending;
// Checked locally so a bad phrase never costs a round trip. The sidecar still validates independently;
// this only means the owner finds out instantly, and precisely, instead of via a generic 400.
const seedProblem = isOnchain && importing ? phraseProblem(importWords) : null;
// Only worth computing for the one failure a human review cannot catch: right words, wrong order.
const orderFix = seedProblem?.kind === 'checksum' ? findOrderFix(importWords) : null;
// A valid phrase can be either format, and the two are visually identical, so say which one this is.
// It also changes what the passphrase field below means, which the owner has no way to guess.
const aezeed = isOnchain && importing && !seedProblem && isAezeed(importWords.map((w) => w.trim().toLowerCase()));
const canSubmit =
!!name.trim() && passphraseValid && configValid && !seedProblem && !storeKeyMissing && !create.isPending;
// A disabled button with no stated reason is a dead end — especially here, where the blocker is often
// the passphrase fields further down while the owner is staring at the phrase.
const blockReason = !name.trim()
? 'Give the wallet a name.'
: seedProblem
? 'The recovery phrase above is not complete or not valid.'
: !passphraseValid
? 'Set a passphrase of at least 8 characters, twice.'
: !configValid
? 'Fill in the connection details.'
: null;
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!canSubmit) return;
let result;
try {
const result = await create.mutateAsync(
result = await create.mutateAsync(
isOnchain
? {
name: name.trim(),
kind,
passphrase,
words,
mnemonic: importing ? mnemonicInput.trim() : undefined,
mnemonic: importing ? importWords.map((w) => w.trim()).join(' ') : undefined,
bip39Passphrase: bip39Passphrase || undefined,
makeActive,
}
: { name: name.trim(), kind, config: remoteConfig, makeActive },
);
} catch {
// KEEP EVERYTHING TYPED. A rejected phrase is precisely the moment the owner needs their other 23
// words back — clearing here means retyping the whole thing to fix one box, which is both miserable
// and a good way to introduce a second mistake. `create.onError` already toasts, and the error is
// rendered inline below. Nothing lingers regardless: cancel or close still runs clearSecrets().
return;
}
const created = result.wallet;
if (result.mnemonic) {
// Generated seed: hold the words for the backup modal and keep this dialog mounted underneath.
setPendingSeed({ mnemonic: result.mnemonic, walletName: created.name, walletId: created.id });
} else {
navigate(walletSectionPath(section, created.id));
close();
}
} finally {
// Whatever happened, no secret survives the submit.
const created = result.wallet;
if (result.mnemonic) {
// Generated seed: hold the words for the backup modal and keep this dialog mounted underneath.
setPendingSeed({ mnemonic: result.mnemonic, walletName: created.name, walletId: created.id });
clearSecrets();
} else {
navigate(walletSectionPath(section, created.id));
close();
}
};
@@ -154,7 +227,20 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
return (
<>
<Dialog open={open && pendingSeed == null} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
{/* Wider only while importing — 24 word boxes at max-w-lg are two cramped columns. */}
<DialogContent
className={`max-h-[85vh] overflow-y-auto ${isOnchain && importing ? 'max-w-2xl' : 'max-w-lg'}`}
// A half-typed recovery phrase must not be destroyed by a misplaced click on the overlay or a
// stray Escape (dismissing an error toast, say). Those gestures are cheap and reversible for an
// ordinary form; here they cost the owner all 24 words. Cancel and the X still close normally —
// the point is that discarding becomes deliberate, not incidental.
onInteractOutside={(ev) => {
if (hasTypedSecret) ev.preventDefault();
}}
onEscapeKeyDown={(ev) => {
if (hasTypedSecret) ev.preventDefault();
}}
>
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>Add a wallet</DialogTitle>
@@ -215,19 +301,116 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
{importing ? (
<div className="space-y-1.5">
<Label htmlFor="wallet-new-mnemonic">Recovery phrase</Label>
<div className="flex flex-wrap items-center justify-between gap-2">
<Label>Recovery phrase</Label>
<SegButton
active={asText}
onClick={() => toggleTextMode(!asText)}
label={asText ? 'Use word boxes' : 'Paste as text'}
/>
</div>
{!asText && (
<div className="flex flex-wrap gap-1.5">
{SEED_LENGTHS.map((n) => (
<SegButton
key={n}
active={importLength === n}
onClick={() => resizeImport(n)}
label={`${n}`}
/>
))}
</div>
)}
{/* Secret in flight: state → request body → cleared. Never stored on this side, and
never echoed back by the sidecar either. */}
<Textarea
id="wallet-new-mnemonic"
rows={3}
autoComplete="off"
spellCheck={false}
value={mnemonicInput}
placeholder="twelve or twenty-four words, separated by spaces"
onChange={(ev) => setMnemonicInput(ev.target.value)}
className="font-mono text-xs"
/>
{asText ? (
<Textarea
rows={4}
autoComplete="off"
spellCheck={false}
data-1p-ignore
data-lpignore="true"
value={importText}
placeholder="Paste or type the whole phrase. Numbering, commas and line breaks are ignored."
onChange={(ev) => applyText(ev.target.value)}
className="font-mono text-xs"
/>
) : (
<SeedPhraseInput
words={importWords}
onChange={setImportWords}
onLengthDetected={(n) => setImportLength(n as SeedLength)}
/>
)}
{aezeed ? (
<div className="space-y-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/5 p-3">
<p className="text-xs">
<span className="font-medium text-emerald-600 dark:text-emerald-500">
Valid aezeed seed checksum verified in this browser.
</span>{' '}
This is not a BIP39 phrase. It is an <em>aezeed</em> cipher seed, the format Zeus's embedded
LND node produces: the same 2048-word list, so it looks identical, but encrypted and
checksummed differently. Officer imports it.
</p>
<p className="text-xs text-muted-foreground">
This recovers the <span className="font-medium">on-chain</span> wallet. Lightning channels
are not in the seed — those need a channel backup file and a running LND.
</p>
</div>
) : orderFix ? (
<div className="space-y-1.5 rounded-lg border border-amber-500/40 bg-amber-500/5 p-3">
<p className="text-xs">
<span className="font-medium">These are your words — the order is wrong.</span> Read down
the columns, they check out. Your backup is almost certainly printed as {orderFix.rows} rows
of {orderFix.cols}, running top-to-bottom, and was read left-to-right.
</p>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
setImportWords(orderFix.words);
setImportText(orderFix.words.join(' '));
}}
>
Reorder to the valid phrase
</Button>
<p className="text-xs text-muted-foreground">
Check the result against your backup before continuing.
</p>
</div>
) : seedProblem?.kind === 'checksum' ? (
// The hardest case to act on, so it gets the longest explanation: every word is
// real, so nothing is marked red, and the only remaining faults are order or a
// word that is a near-miss for the right one.
<p className="text-xs text-destructive">
All {importWords.length} words are valid BIP39 words, but the phrase does not check out. A
word is in the wrong position, or one is a near-miss for the right one (<em>fan</em> where the
backup says <em>fancy</em>). Re-read it against your backup in order.
</p>
) : seedProblem?.kind === 'unknown' ? (
<p className="text-xs text-destructive">
Not in the BIP39 word list: {seedProblem.boxes.length === 1 ? 'box' : 'boxes'}{' '}
{seedProblem.boxes.join(', ')}.
</p>
) : seedProblem?.kind === 'incomplete' ? (
<p className="text-xs text-muted-foreground">
{seedProblem.boxes.length} {seedProblem.boxes.length === 1 ? 'word' : 'words'} still empty.
</p>
) : seedProblem?.kind === 'length' ? (
<p className="text-xs text-destructive">
{seedProblem.count} words. A BIP39 phrase must be 12, 15, 18, 21 or 24.
</p>
) : (
<p className="text-xs text-emerald-600 dark:text-emerald-500">
Valid phrase — checksum verified in this browser.
</p>
)}
<p className="text-xs text-muted-foreground">
Type a few letters and pick from the list, or paste the whole phrase into the first box.
</p>
</div>
) : (
<div className="space-y-1.5">
@@ -272,8 +455,14 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
<p className="text-xs text-destructive">The two passphrases do not match.</p>
)}
{/* One field, two meanings, because the two seed formats each have exactly one such
secret and which one applies is decided by the phrase itself. Mislabelling it for an
aezeed would be actively harmful: leaving it blank there is correct and normal, while
typing the wallet passphrase into it fails the AEZ tag and looks like a bad seed. */}
<div className="space-y-1.5">
<Label htmlFor="wallet-new-bip39">BIP39 passphrase (optional)</Label>
<Label htmlFor="wallet-new-bip39">
{aezeed ? 'Seed passphrase (optional)' : 'BIP39 passphrase (optional)'}
</Label>
{/* Part of the seed itself — lose it and the coins are gone. Same handling: no store. */}
<Input
id="wallet-new-bip39"
@@ -283,8 +472,9 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
onChange={(ev) => setBip39Passphrase(ev.target.value)}
/>
<p className="text-xs text-muted-foreground">
A 25th word. It is part of the key, not a lock on it without it the phrase alone recovers a
different, empty wallet.
{aezeed
? 'Only if you set one when the aezeed seed was created. Almost nobody does leave it blank.'
: 'A 25th word. It is part of the key, not a lock on it without it the phrase alone recovers a different, empty wallet.'}
</p>
</div>
</>
@@ -315,6 +505,22 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
</label>
</div>
{/* The toast disappears; this does not. With the form no longer clearing on failure, the
owner needs the reason to stay put next to the boxes they are about to correct. */}
{/* Only while the current input still matches what was rejected. Without this the box from a
previous attempt hangs around after the phrase is corrected, which reads as "still broken"
when nothing is broken — and the mutation's error state does not clear itself. */}
{create.isError && !seedProblem && (
<div className="mt-4 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<span>{errorMessage(create.error, 'Could not create the wallet')}</span>
</div>
)}
{blockReason && !storeKeyMissing && (
<p className="mt-4 text-right text-xs text-muted-foreground">{blockReason}</p>
)}
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
@@ -0,0 +1,387 @@
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<string>(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<string>(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<number | null>(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<HTMLButtonElement | null>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className={cn('grid gap-2', WORDS_PER_ROW_HINT)}>
{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 (
<div key={index} className="relative">
<div
className={cn(
'flex h-10 items-center rounded-md border bg-background pl-2 focus-within:ring-2 focus-within:ring-ring',
invalid ? 'border-destructive' : 'border-input',
)}
>
<span className="w-5 shrink-0 select-none text-right font-mono text-[11px] text-muted-foreground">
{index + 1}
</span>
<input
ref={(el) => {
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 && <Check className="mr-2 h-3.5 w-3.5 shrink-0 text-emerald-500" />}
</div>
{showSuggestions && (
// onMouseDown, not onClick: blur fires first on click and would unmount the list mid-press.
<ul className="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-input bg-popover shadow-md">
{suggestions.map((s, i) => (
<li key={s}>
<button
type="button"
tabIndex={-1}
// Keeps the highlighted row visible while arrowing through a long list.
ref={i === safeHighlight ? highlightRef : undefined}
onMouseDown={(ev) => {
ev.preventDefault();
commit(index, s);
}}
onMouseEnter={() => setHighlight(i)}
className={cn(
'flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left font-mono text-sm',
i === safeHighlight ? 'bg-accent text-accent-foreground' : 'text-foreground',
)}
>
<span>{s}</span>
{/* The exact match, called out — this is the "yes, that word really exists" signal
when the typed word is also a prefix of longer ones (fan / fancy / fantasy). */}
{s === word && <Check className="h-3.5 w-3.5 shrink-0 text-emerald-500" />}
</button>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
);
};