bitcoin-wallet, extracted from the platform as a plugin
Tenth plugin out, and the one whose extraction turned on a permission decision rather than a mechanical move. The wallet permission was kind: 'admin' — owner only, not grantable, invisible to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts forces it and the manifest has no field for anything else, deliberately, so that "can a plugin escalate itself" is a question which cannot be asked. So extraction could not preserve admin, and that was raised as a blocker before any code moved. It was the right change anyway, and the objection inverts. Every wallet query is scoped by user_id, and that id comes from X-Officer-User which the platform injects from the authenticated session — a caller cannot name someone else's. So granting this gives an account ITS OWN wallet. Not through the API and not with root either does it hand over anyone else's, because their seed is sealed under a passphrase the server never stores. kind: 'admin' was single-user thinking; grantable is how a second person gets a wallet at all. Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically, and the appName drives the route, the API prefix, the table prefix and the install key together. Tables renamed to bitcoin_wallet_* accordingly — the prefix guard normalises the hyphen. Free only because no wallet table existed on any server, which was checked before deciding: db:push treats a rename as a drop plus a create. The three custody test files moved with it and still run — 72 tests over scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by nine, so that trap was already closed. 68 palette classes converted, five status vocabularies hoisted into shared.ts. Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all read as warning, and the bitcoin and lightning rails no longer differ by hue. Deferred on purpose: watch-only data (xpubs, balances, history) stays unencrypted so a locked wallet still shows a balance. Anyone with the database can therefore SEE any user's balances; they can never move a coin.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLockActions } from '../useWalletData';
|
||||
|
||||
// Re-encrypt the seed under a new passphrase. The sidecar decrypts with the old one and re-seals with the
|
||||
// new one, then re-locks — there is no window where the wallet is left open as a side effect.
|
||||
//
|
||||
// Both passphrases live only in this component's state and are cleared as soon as the request settles.
|
||||
|
||||
type ChangePassphraseDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
|
||||
|
||||
export const ChangePassphraseDialog = ({ open, onOpenChange, walletId }: ChangePassphraseDialogProps) => {
|
||||
const { changePassphrase } = useLockActions(walletId);
|
||||
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
|
||||
const [oldPassphrase, setOldPassphrase] = useState('');
|
||||
const [newPassphrase, setNewPassphrase] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
|
||||
const clearSecrets = () => {
|
||||
setOldPassphrase('');
|
||||
setNewPassphrase('');
|
||||
setConfirm('');
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
clearSecrets();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const mismatch = confirm.length > 0 && confirm !== newPassphrase;
|
||||
const canSubmit =
|
||||
!!oldPassphrase && newPassphrase.length >= 8 && confirm === newPassphrase && !changePassphrase.isPending;
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
await changePassphrase.mutateAsync({ oldPassphrase, newPassphrase });
|
||||
close();
|
||||
} finally {
|
||||
// Cleared on failure too — a rejected passphrase is still a passphrase held in memory.
|
||||
clearSecrets();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change passphrase</DialogTitle>
|
||||
<DialogDescription>
|
||||
The seed is re-sealed under the new passphrase and the wallet is locked again. Lose it and the coins go
|
||||
with it — there is no reset.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pass-old">Current passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-pass-old"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={oldPassphrase}
|
||||
onChange={(ev) => setOldPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pass-new">New passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-pass-new"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassphrase}
|
||||
onChange={(ev) => setNewPassphrase(ev.target.value)}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">At least 8 characters.</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pass-confirm">Confirm new passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-pass-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(ev) => setConfirm(ev.target.value)}
|
||||
/>
|
||||
{mismatch && <p className="text-[11px] text-destructive">These do not match.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{changePassphrase.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Change
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { satsToMsat } from '../format';
|
||||
import { CopyField } from '../CopyField';
|
||||
import { useWalletOperations } from '../useWalletData';
|
||||
|
||||
// Create a BOLT11 invoice. Receiving needs no key material, so this works while the wallet is locked.
|
||||
//
|
||||
// The amount is entered in sats and converted with satsToMsat, which appends three zeros to the digit
|
||||
// string rather than multiplying — the request field is a msat STRING and must stay one.
|
||||
|
||||
type CreateInvoiceDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
|
||||
|
||||
export const CreateInvoiceDialog = ({ open, onOpenChange, walletId }: CreateInvoiceDialogProps) => {
|
||||
const { createInvoice } = useWalletOperations(walletId);
|
||||
const [amountSats, setAmountSats] = useState('');
|
||||
const [memo, setMemo] = useState('');
|
||||
const [expiryMinutes, setExpiryMinutes] = useState('60');
|
||||
const [created, setCreated] = useState<string | null>(null);
|
||||
|
||||
const close = () => {
|
||||
setAmountSats('');
|
||||
setMemo('');
|
||||
setExpiryMinutes('60');
|
||||
setCreated(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
const sats = Number(amountSats);
|
||||
const result = await createInvoice.mutateAsync({
|
||||
// Omitted entirely for a zero-amount invoice, which is a legitimate "payer decides" request.
|
||||
amountMsat: amountSats && Number.isFinite(sats) && sats > 0 ? satsToMsat(sats) : undefined,
|
||||
memo: memo.trim() || undefined,
|
||||
expirySeconds: Number(expiryMinutes) > 0 ? Number(expiryMinutes) * 60 : undefined,
|
||||
});
|
||||
setCreated(result.invoice.bolt11);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
{created ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invoice created</DialogTitle>
|
||||
<DialogDescription>Send this to whoever is paying you.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CopyField value={created} wrap label="BOLT11" />
|
||||
<DialogFooter>
|
||||
<Button onClick={close}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New invoice</DialogTitle>
|
||||
<DialogDescription>Leave the amount blank to let the payer choose.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-inv-amount">Amount (sats)</Label>
|
||||
<Input
|
||||
id="wallet-inv-amount"
|
||||
type="number"
|
||||
min={0}
|
||||
value={amountSats}
|
||||
placeholder="any amount"
|
||||
onChange={(ev) => setAmountSats(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-inv-memo">Description</Label>
|
||||
<Input
|
||||
id="wallet-inv-memo"
|
||||
value={memo}
|
||||
placeholder="what this is for"
|
||||
onChange={(ev) => setMemo(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-inv-expiry">Expires in (minutes)</Label>
|
||||
<Input
|
||||
id="wallet-inv-expiry"
|
||||
type="number"
|
||||
min={1}
|
||||
value={expiryMinutes}
|
||||
onChange={(ev) => setExpiryMinutes(ev.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createInvoice.isPending}>
|
||||
{createInvoice.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,559 @@
|
||||
import type { BackendKind } from '../shared';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { KIND_LABELS, walletSectionPath, type WalletSectionId } from '../shared';
|
||||
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:
|
||||
//
|
||||
// onchain — self-custodial. A seed is generated (or imported) and sealed under a passphrase that only
|
||||
// ever exists in this component's state and the request body. Generating returns the mnemonic
|
||||
// exactly once, which is why this dialog hands straight over to SeedBackupDialog and does not
|
||||
// close until the owner confirms they wrote it down.
|
||||
// everything else — a connection to somebody's node or account, so it is a config blob, no seed at all.
|
||||
//
|
||||
// Wallet creation is refused outright when the wallet's at-rest key is unreachable. The form says so up front
|
||||
// rather than letting the submit fail with a crypto error.
|
||||
|
||||
type CreateWalletDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Where to land after creating — keeps the new wallet's id in the URL. */
|
||||
section: WalletSectionId;
|
||||
};
|
||||
|
||||
const KINDS: BackendKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc'];
|
||||
|
||||
/** The connection fields each remote backend needs, spelled once. */
|
||||
const CONFIG_FIELDS: Record<Exclude<BackendKind, 'onchain'>, { key: string; label: string; placeholder: string }[]> = {
|
||||
lnd: [
|
||||
{ key: 'url', label: 'REST URL', placeholder: 'https://node.local:8080' },
|
||||
{ key: 'macaroon', label: 'Admin macaroon (hex)', placeholder: '0201036c6e64…' },
|
||||
],
|
||||
'cln-rest': [
|
||||
{ key: 'url', label: 'CLNRest URL', placeholder: 'https://node.local:3010' },
|
||||
{ key: 'rune', label: 'Rune', placeholder: 'ZW5jcnlwdGVkOg…' },
|
||||
],
|
||||
lndhub: [
|
||||
{ key: 'url', label: 'LNDHub URL', placeholder: 'https://lndhub.io' },
|
||||
{ key: 'login', label: 'Login', placeholder: 'lndhub login' },
|
||||
{ key: 'password', label: 'Password', placeholder: 'lndhub password' },
|
||||
],
|
||||
nwc: [{ key: 'connectionString', label: 'Connection string', placeholder: 'nostr+walletconnect://…' }],
|
||||
};
|
||||
|
||||
export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWalletDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { config } = useWalletConfig();
|
||||
const { create } = useWalletLifecycle();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [kind, setKind] = useState<BackendKind>('onchain');
|
||||
const [words, setWords] = useState<12 | 24>(24);
|
||||
const [importing, setImporting] = useState(false);
|
||||
// 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('');
|
||||
const [confirmPassphrase, setConfirmPassphrase] = useState('');
|
||||
const [bip39Passphrase, setBip39Passphrase] = useState('');
|
||||
const [makeActive, setMakeActive] = useState(true);
|
||||
const [remoteConfig, setRemoteConfig] = useState<Record<string, string>>({});
|
||||
|
||||
// The one mnemonic the sidecar will ever hand back. Held here only until the backup modal is confirmed.
|
||||
const [pendingSeed, setPendingSeed] = useState<{ mnemonic: string; walletName: string; walletId: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
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('');
|
||||
// Functional, so it blanks whatever length the grid currently is without reading stale state.
|
||||
setImportWords((prev) => prev.map(() => ''));
|
||||
setImportText('');
|
||||
setRemoteConfig({});
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setName('');
|
||||
setKind('onchain');
|
||||
setWords(24);
|
||||
setImporting(false);
|
||||
setMakeActive(true);
|
||||
clearSecrets();
|
||||
setAsText(false);
|
||||
setImportLength(24);
|
||||
setImportWords(Array(24).fill(''));
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const passphraseValid = !isOnchain || (passphrase.length >= 8 && passphrase === confirmPassphrase);
|
||||
const configValid =
|
||||
isOnchain || CONFIG_FIELDS[kind as Exclude<BackendKind, 'onchain'>].every((f) => remoteConfig[f.key]?.trim());
|
||||
// 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 {
|
||||
result = await create.mutateAsync(
|
||||
isOnchain
|
||||
? {
|
||||
name: name.trim(),
|
||||
kind,
|
||||
passphrase,
|
||||
words,
|
||||
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 });
|
||||
clearSecrets();
|
||||
} else {
|
||||
navigate(walletSectionPath(section, created.id));
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const seedConfirmed = () => {
|
||||
const created = pendingSeed;
|
||||
setPendingSeed(null);
|
||||
close();
|
||||
if (created) navigate(walletSectionPath(section, created.walletId));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open && pendingSeed == null} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
{/* 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>
|
||||
<DialogDescription>
|
||||
Self-custodial keys stay in the wallet sidecar; a remote node is only a stored connection.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{storeKeyMissing && (
|
||||
<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>
|
||||
<span className="font-medium">The secret store is unreachable.</span> The sidecar refuses to store
|
||||
wallet secrets without it, so creation is disabled until it is set.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-name">Name</Label>
|
||||
<Input
|
||||
id="wallet-new-name"
|
||||
value={name}
|
||||
autoFocus
|
||||
placeholder="Savings"
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-kind">Type</Label>
|
||||
<Select value={kind} onValueChange={(v) => setKind(v as BackendKind)}>
|
||||
<SelectTrigger id="wallet-new-kind">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{KIND_LABELS[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Network: <span className="font-medium">{config.network}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOnchain ? (
|
||||
<>
|
||||
<div className="flex gap-1.5">
|
||||
<SegButton active={!importing} onClick={() => setImporting(false)} label="Generate a new seed" />
|
||||
<SegButton active={importing} onClick={() => setImporting(true)} label="Import a phrase" />
|
||||
</div>
|
||||
|
||||
{importing ? (
|
||||
<div className="space-y-1.5">
|
||||
<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. */}
|
||||
{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-success/40 bg-success/5 p-3">
|
||||
<p className="text-xs">
|
||||
<span className="font-medium text-success">
|
||||
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-warning/40 bg-warning/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-success">
|
||||
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">
|
||||
<Label>Phrase length</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<SegButton active={words === 12} onClick={() => setWords(12)} label="12 words" />
|
||||
<SegButton active={words === 24} onClick={() => setWords(24)} label="24 words" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The phrase is shown once, immediately after creation, and never again without this passphrase.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-pass">Passphrase</Label>
|
||||
{/* Seals the seed. Straight into the request body, cleared on settle — no store. */}
|
||||
<Input
|
||||
id="wallet-new-pass"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={passphrase}
|
||||
onChange={(ev) => setPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-new-pass2">Confirm</Label>
|
||||
<Input
|
||||
id="wallet-new-pass2"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassphrase}
|
||||
onChange={(ev) => setConfirmPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{passphrase.length > 0 && passphrase.length < 8 && (
|
||||
<p className="text-xs text-destructive">Use at least 8 characters.</p>
|
||||
)}
|
||||
{confirmPassphrase.length > 0 && passphrase !== confirmPassphrase && (
|
||||
<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">
|
||||
{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"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={bip39Passphrase}
|
||||
onChange={(ev) => setBip39Passphrase(ev.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{CONFIG_FIELDS[kind as Exclude<BackendKind, 'onchain'>].map((field) => (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<Label htmlFor={`wallet-new-${field.key}`}>{field.label}</Label>
|
||||
{/* Node credentials are stored server-side, encrypted, and never returned to the
|
||||
browser — so this input is write-only and holds nothing after submit. */}
|
||||
<Input
|
||||
id={`wallet-new-${field.key}`}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={remoteConfig[field.key] ?? ''}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(ev) => setRemoteConfig((prev) => ({ ...prev, [field.key]: ev.target.value }))}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2.5 text-sm">
|
||||
<Checkbox checked={makeActive} onCheckedChange={(v) => setMakeActive(v === true)} />
|
||||
Make this the active wallet
|
||||
</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
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{create.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create wallet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{pendingSeed && (
|
||||
<SeedBackupDialog
|
||||
open
|
||||
mnemonic={pendingSeed.mnemonic}
|
||||
walletName={pendingSeed.walletName}
|
||||
onConfirmed={seedConfirmed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SegButton = ({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
active ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { WalletSummary } from '../shared';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useWalletLifecycle } from '../useWalletData';
|
||||
|
||||
// Delete a wallet. For a seeded wallet this destroys the only copy of the key material Officer holds, so
|
||||
// the sidecar demands the passphrase even when the wallet is currently unlocked — an open session must not
|
||||
// be enough to erase a seed. Typing the wallet's name is the second check against the wrong row, and it is
|
||||
// sent rather than merely enforced here: a wallet with no seed has no passphrase gate, so without it the
|
||||
// only thing standing between a node wallet and deletion was a dialog anything could route around.
|
||||
|
||||
type DeleteWalletDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
wallet: WalletSummary;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export const DeleteWalletDialog = ({ open, onOpenChange, wallet, onDeleted }: DeleteWalletDialogProps) => {
|
||||
const { remove } = useWalletLifecycle();
|
||||
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [confirmName, setConfirmName] = useState('');
|
||||
|
||||
const close = () => {
|
||||
setPassphrase('');
|
||||
setConfirmName('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const nameMatches = confirmName.trim() === wallet.name;
|
||||
const canSubmit = nameMatches && (!wallet.hasSeed || !!passphrase) && !remove.isPending;
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
await remove.mutateAsync({
|
||||
walletId: wallet.id,
|
||||
confirmName: confirmName.trim(),
|
||||
passphrase: wallet.hasSeed ? passphrase : undefined,
|
||||
});
|
||||
close();
|
||||
onDeleted?.();
|
||||
} finally {
|
||||
setPassphrase('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-5 w-5 text-destructive" />
|
||||
Delete {wallet.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{wallet.hasSeed
|
||||
? 'This erases the encrypted seed. Without your written-down recovery phrase the coins are gone for good.'
|
||||
: 'This removes the connection to that node. Nothing on the node itself is touched.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-delete-name">
|
||||
Type <span className="font-mono">{wallet.name}</span> to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="wallet-delete-name"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={confirmName}
|
||||
onChange={(ev) => setConfirmName(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{wallet.hasSeed && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-delete-pass">Passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-delete-pass"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={passphrase}
|
||||
onChange={(ev) => setPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={!canSubmit}>
|
||||
{remove.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Delete wallet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from 'react';
|
||||
import { Eye, Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLockActions } from '../useWalletData';
|
||||
|
||||
// Show the recovery phrase again — the one path back to it after creation, and the reason the create-time
|
||||
// modal can say "not without your passphrase" rather than "never".
|
||||
//
|
||||
// Two things live only in this component and nowhere else: the passphrase (cleared the moment the request
|
||||
// settles) and the returned mnemonic (held in local state, dropped when the dialog closes). The mutation
|
||||
// deliberately has no onSuccess cache write, so the phrase never enters React Query. No copy button, for
|
||||
// the same reason as SeedBackupDialog.
|
||||
|
||||
type ExportSeedDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
|
||||
|
||||
export const ExportSeedDialog = ({ open, onOpenChange, walletId }: ExportSeedDialogProps) => {
|
||||
const { exportSeed } = useLockActions(walletId);
|
||||
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [revealed, setRevealed] = useState<{ mnemonic: string; hasBip39Passphrase: boolean } | null>(null);
|
||||
|
||||
const close = () => {
|
||||
setPassphrase('');
|
||||
setRevealed(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!passphrase) return;
|
||||
try {
|
||||
setRevealed(await exportSeed.mutateAsync({ passphrase }));
|
||||
} finally {
|
||||
setPassphrase('');
|
||||
}
|
||||
};
|
||||
|
||||
const words = revealed ? revealed.mnemonic.trim().split(/\s+/) : [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-lg">
|
||||
{revealed ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-5 w-5 text-warning" />
|
||||
Recovery phrase
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Anyone who reads these {words.length} words can spend this wallet. Close this as soon as you are done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ol className="mt-2 grid grid-cols-3 gap-1.5 rounded-lg border border-warning/30 bg-warning/5 p-3 sm:grid-cols-4">
|
||||
{words.map((word, i) => (
|
||||
<li key={`${i}-${word}`} className="flex items-baseline gap-1.5 font-mono text-xs">
|
||||
<span className="w-5 shrink-0 text-right tabular-nums text-muted-foreground">{i + 1}</span>
|
||||
<span className="font-medium">{word}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{revealed.hasBip39Passphrase && (
|
||||
<p className="text-xs text-warning">
|
||||
This seed also has a BIP39 passphrase. The words alone will not restore the wallet — you need both.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={close}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Show recovery phrase</DialogTitle>
|
||||
<DialogDescription>
|
||||
The phrase is decrypted for display only. It is not stored by the browser and nothing is written
|
||||
anywhere on this side.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<Label htmlFor="wallet-export-pass">Passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-export-pass"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={passphrase}
|
||||
onChange={(ev) => setPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!passphrase || exportSeed.isPending}>
|
||||
{exportSeed.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Show
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { DecodedInvoice } from '../shared';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Zap } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { formatTimestamp, satsToMsat, truncateMiddle } from '../format';
|
||||
import { Amount } from '../Amount';
|
||||
import { useWalletOperations } from '../useWalletData';
|
||||
|
||||
// Pay a BOLT11 invoice: decode first, confirm what you are about to pay, then pay.
|
||||
//
|
||||
// The two steps are deliberate. A BOLT11 string is opaque, so paying one straight from the paste box is
|
||||
// signing something you cannot read. Decoding is a free read — it does not need the wallet unlocked — and
|
||||
// it is the only chance to see the amount and the destination before the money moves.
|
||||
//
|
||||
// Amounts stay msat STRINGS end to end (see format.ts): the decoded amount is rendered as a string and a
|
||||
// zero-amount invoice's manual amount is converted with satsToMsat rather than a multiplication.
|
||||
|
||||
type PayInvoiceDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
|
||||
|
||||
export const PayInvoiceDialog = ({ open, onOpenChange, walletId }: PayInvoiceDialogProps) => {
|
||||
const { decode, pay } = useWalletOperations(walletId);
|
||||
const [bolt11, setBolt11] = useState('');
|
||||
const [decoded, setDecoded] = useState<DecodedInvoice | null>(null);
|
||||
const [amountSats, setAmountSats] = useState('');
|
||||
const [feeLimitSats, setFeeLimitSats] = useState('');
|
||||
|
||||
const close = () => {
|
||||
setBolt11('');
|
||||
setDecoded(null);
|
||||
setAmountSats('');
|
||||
setFeeLimitSats('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const runDecode = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
const trimmed = bolt11.trim();
|
||||
if (!trimmed) return;
|
||||
const result = await decode.mutateAsync(trimmed);
|
||||
setDecoded(result.decoded);
|
||||
};
|
||||
|
||||
// A zero-amount invoice carries no amountMsat, so the payer names the figure.
|
||||
const needsAmount = decoded != null && decoded.amountMsat == null;
|
||||
const manualSats = Number(amountSats);
|
||||
const feeLimit = Number(feeLimitSats);
|
||||
const canPay = decoded != null && (!needsAmount || (Number.isFinite(manualSats) && manualSats > 0)) && !pay.isPending;
|
||||
|
||||
const runPay = async () => {
|
||||
if (!decoded || !canPay) return;
|
||||
await pay.mutateAsync({
|
||||
bolt11: decoded.bolt11,
|
||||
amountMsat: needsAmount ? satsToMsat(manualSats) : undefined,
|
||||
feeLimitMsat: Number.isFinite(feeLimit) && feeLimit > 0 ? satsToMsat(feeLimit) : undefined,
|
||||
});
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pay an invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
{decoded
|
||||
? 'Check this before you pay — a lightning payment cannot be reversed.'
|
||||
: 'Paste a BOLT11 invoice.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{decoded ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
<dl className="space-y-2 rounded-lg border border-border bg-muted/40 p-3 text-xs">
|
||||
<Row label="Amount">
|
||||
{decoded.amountMsat == null ? (
|
||||
<span className="text-muted-foreground">payer chooses</span>
|
||||
) : (
|
||||
<Amount msat={decoded.amountMsat} className="font-semibold" />
|
||||
)}
|
||||
</Row>
|
||||
<Row label="Description">{decoded.description || <span className="text-muted-foreground">—</span>}</Row>
|
||||
<Row label="Destination">
|
||||
<code className="font-mono text-[11px]">{truncateMiddle(decoded.destination, 12, 8)}</code>
|
||||
</Row>
|
||||
<Row label="Expires">{formatTimestamp(decoded.timestamp + decoded.expiry)}</Row>
|
||||
</dl>
|
||||
|
||||
{needsAmount && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pay-amount">Amount to pay (sats)</Label>
|
||||
<Input
|
||||
id="wallet-pay-amount"
|
||||
type="number"
|
||||
min={1}
|
||||
value={amountSats}
|
||||
onChange={(ev) => setAmountSats(ev.target.value)}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pay-fee">Max routing fee (sats, optional)</Label>
|
||||
<Input
|
||||
id="wallet-pay-fee"
|
||||
type="number"
|
||||
min={0}
|
||||
value={feeLimitSats}
|
||||
placeholder="node default"
|
||||
onChange={(ev) => setFeeLimitSats(ev.target.value)}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setDecoded(null)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" onClick={runPay} disabled={!canPay}>
|
||||
{pay.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Zap className="mr-2 h-4 w-4" />}
|
||||
Pay
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={runDecode} className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-pay-bolt11">Invoice</Label>
|
||||
<Input
|
||||
id="wallet-pay-bolt11"
|
||||
value={bolt11}
|
||||
spellCheck={false}
|
||||
placeholder="lnbc…"
|
||||
onChange={(ev) => setBolt11(ev.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!bolt11.trim() || decode.isPending}>
|
||||
{decode.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Decode
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 truncate text-right">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
// The recovery phrase, shown exactly once.
|
||||
//
|
||||
// The sidecar returns `mnemonic` on the create response and only when IT generated the seed. There is no
|
||||
// second chance without the passphrase (settings → export seed), so this modal cannot be dismissed by
|
||||
// clicking away, pressing Escape, or anything short of ticking the box and pressing the button.
|
||||
//
|
||||
// The words arrive as a prop, live in this component's render only, and are never written to React Query,
|
||||
// localStorage, sessionStorage, the URL, or a clipboard helper. Closing the modal is the only exit and it
|
||||
// drops the string on the floor. There is deliberately no "copy" button: a seed on the clipboard is a seed
|
||||
// in every clipboard manager on the machine.
|
||||
|
||||
type SeedBackupDialogProps = {
|
||||
open: boolean;
|
||||
mnemonic: string;
|
||||
walletName: string;
|
||||
onConfirmed: () => void;
|
||||
};
|
||||
|
||||
export const SeedBackupDialog = ({ open, mnemonic, walletName, onConfirmed }: SeedBackupDialogProps) => {
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const words = mnemonic.trim().split(/\s+/);
|
||||
|
||||
const confirm = () => {
|
||||
setAcknowledged(false);
|
||||
onConfirmed();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
{/* `[&>button]:hidden` drops the shared X — with no onOpenChange it would be inert anyway, and an
|
||||
inert close button on the one dialog you must not dismiss is worse than no button. */}
|
||||
<DialogContent
|
||||
className="max-w-lg [&>button]:hidden"
|
||||
onEscapeKeyDown={(ev) => ev.preventDefault()}
|
||||
onPointerDownOutside={(ev) => ev.preventDefault()}
|
||||
onInteractOutside={(ev) => ev.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-5 w-5 text-warning" />
|
||||
Write down your recovery phrase
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
These {words.length} words are the only way to recover <span className="font-medium">{walletName}</span> if
|
||||
this server is lost. Officer cannot show them again without your passphrase.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ol className="mt-2 grid grid-cols-3 gap-1.5 rounded-lg border border-warning/30 bg-warning/5 p-3 sm:grid-cols-4">
|
||||
{words.map((word, i) => (
|
||||
<li key={`${i}-${word}`} className="flex items-baseline gap-1.5 font-mono text-xs">
|
||||
<span className="w-5 shrink-0 text-right tabular-nums text-muted-foreground">{i + 1}</span>
|
||||
<span className="font-medium">{word}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Write them on paper, in order. Anyone who reads them can spend these coins, so do not photograph them, type
|
||||
them into another device, or paste them anywhere.
|
||||
</p>
|
||||
|
||||
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
|
||||
<Checkbox className="mt-0.5" checked={acknowledged} onCheckedChange={(v) => setAcknowledged(v === true)} />
|
||||
<span>
|
||||
I have written this phrase down and stored it somewhere safe.
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
It will not be shown again after this dialog closes.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={confirm} disabled={!acknowledged}>
|
||||
I have written it down
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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-success" />}
|
||||
</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-success" />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, LockOpen } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLockActions } from '../useWalletData';
|
||||
|
||||
// Unlock, and nothing else. The wallet spends most of its life locked and that is the intended state —
|
||||
// this dialog exists for the moment before a signature, not as a gate on the app.
|
||||
//
|
||||
// The unlock window is capped by the deployment's unlockTtlSec; a caller may ask for less but never more,
|
||||
// so the shorter options here are real and the longest is simply "whatever the sidecar allows".
|
||||
|
||||
type UnlockDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
walletId: number;
|
||||
walletName: string;
|
||||
maxTtlSec: number;
|
||||
};
|
||||
|
||||
/** Offered windows, filtered to those the deployment actually permits. */
|
||||
const TTL_CHOICES = [
|
||||
{ sec: 60, label: '1 minute' },
|
||||
{ sec: 300, label: '5 minutes' },
|
||||
{ sec: 900, label: '15 minutes' },
|
||||
{ sec: 3600, label: '1 hour' },
|
||||
];
|
||||
|
||||
export const UnlockDialog = ({ open, onOpenChange, walletId, walletName, maxTtlSec }: UnlockDialogProps) => {
|
||||
const { unlock } = useLockActions(walletId);
|
||||
// The passphrase lives HERE and nowhere else: no localStorage, no sessionStorage, no React Query
|
||||
// cache, no URL. It goes straight into the POST body and this state is cleared the moment the request
|
||||
// settles, on success and on failure alike.
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [ttlSec, setTtlSec] = useState<number>(() => Math.min(300, maxTtlSec));
|
||||
|
||||
const choices = TTL_CHOICES.filter((c) => c.sec <= maxTtlSec);
|
||||
|
||||
const close = () => {
|
||||
setPassphrase('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!passphrase) return;
|
||||
try {
|
||||
await unlock.mutateAsync({ passphrase, ttlSec });
|
||||
close();
|
||||
} finally {
|
||||
// Cleared even when the unlock failed — a wrong passphrase left sitting in an input is still a
|
||||
// passphrase sitting in memory attached to a mounted component.
|
||||
setPassphrase('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-md">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unlock {walletName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
The seed is decrypted in the wallet sidecar for the window you choose, then wiped. Balances and history do
|
||||
not need this — only signing does.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="wallet-unlock-pass">Passphrase</Label>
|
||||
{/* Controlled input → request body → cleared. Never written to any store. */}
|
||||
<Input
|
||||
id="wallet-unlock-pass"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={passphrase}
|
||||
onChange={(ev) => setPassphrase(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{choices.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Keep unlocked for</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{choices.map(({ sec, label }) => (
|
||||
<button
|
||||
key={sec}
|
||||
type="button"
|
||||
onClick={() => setTtlSec(sec)}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
ttlSec === sec
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="ghost" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!passphrase || unlock.isPending}>
|
||||
{unlock.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LockOpen className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Unlock
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user