bitcoin-wallet, extracted into plugins/bitcoin-wallet — the platform half

Tenth extraction, and the first where the blocker was a permission rather than
a path. The wallet was kind: 'admin' — owner only, never grantable — and plugin
permissions are always kind: 'app'. mount.ts forces that and the manifest has
no field for anything else, deliberately, so a plugin cannot ask to be
restricted. Extraction therefore could not preserve admin.

Raised before moving any code, because turning "the server owner's bitcoin"
into a row in the grants list beside Photos and Music is the kind of change
nobody notices until someone ticks it.

Made anyway, and the objection inverts: every wallet query is scoped by
user_id, taken from X-Officer-User which the platform injects from the session,
so granting the permission gives an account its OWN wallet rather than access
to anyone else's — including to the owner, whose bypass covers permission
checks and not query scoping. kind: 'admin' was single-user thinking.

Named bitcoin-wallet, not wallet: appName drives route, API prefix, table
prefix and install key together, so the name should say what it is. Tables are
bitcoin_wallet_*; the prefix guard normalises the hyphen. Verified free before
committing to it — no wallet table exists on any server, and db:push would
treat a rename as a drop plus a create.

registry.test.ts re-anchored: `wallet` was its canonical kind: 'admin' fixture
and its no-selfService fixture. Now user-admin/server-admin and terminal, with
a note that a plugin's permissions are always `app` — which is exactly why this
one needed a decision rather than a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 05:18:44 +00:00
co-authored by Claude Opus 5
parent f2c6ebaa4d
commit bbb16d6ed3
81 changed files with 31 additions and 16386 deletions
@@ -11,7 +11,6 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { appRegistryMetas as davMetas } from '../apps/Dav';
@@ -31,7 +30,6 @@ export const apps = [
...widgetMetas,
...desktopMetas,
...giteaMetas,
...walletMetas,
...monitorMetas,
...qrTransferMetas,
...davMetas,
@@ -1,46 +0,0 @@
import { formatAmount, formatMsat } from './format';
import { useAmountUnit } from './useAmountUnit';
// One place amounts are rendered, so the sats/BTC toggle flips every number on screen at once.
//
// `sats` is a number; `msat` is a decimal STRING and stays one all the way through formatMsat. Passing a
// msat through the `sats` prop would parse it into a double — which is exactly the bug the string type
// exists to prevent, so the two props are deliberately not interchangeable.
type AmountProps = {
sats?: number | null;
msat?: string | null;
className?: string;
/** Colour a negative amount as a spend and a positive one as a receipt. */
signed?: boolean;
};
export const Amount = ({ sats, msat, className = '', signed = false }: AmountProps) => {
const { unit } = useAmountUnit();
const text = msat !== undefined ? formatMsat(msat, unit) : formatAmount(sats, unit);
const tone = !signed || sats == null ? '' : sats < 0 ? 'text-destructive' : sats > 0 ? 'text-emerald-500' : '';
return (
<span className={`tabular-nums ${tone} ${className}`}>
{signed && sats != null && sats > 0 ? '+' : ''}
{text}
</span>
);
};
/** The toggle itself — a button, because it mutates a preference rather than navigating. */
export const UnitToggle = ({ className = '' }: { className?: string }) => {
const { unit, toggle } = useAmountUnit();
return (
<button
type="button"
onClick={toggle}
title="Switch between satoshis and BTC"
className={`rounded-md border border-border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted hover:text-foreground ${className}`}
>
{unit === 'sats' ? 'sats' : 'BTC'}
</button>
);
};
@@ -1,183 +0,0 @@
import type { ServiceConnectionState } from '../../hooks/useServiceConnection';
import { useEffect, useState } from 'react';
import { CheckCircle2, Loader2, RotateCcw, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
serviceErrorMessage,
useServiceConnection,
useServiceConnectionActions,
} from '../../hooks/useServiceConnection';
// Where on-chain wallets read the chain from. One endpoint serves every on-chain wallet the owner has,
// which is why it sits here and not on a wallet.
//
// PRIVACY, NOT CUSTODY. A chain endpoint learns every address you ask it about, so pointing this at your
// own node is a real upgrade — but it never sees a key and cannot move a coin. That framing is on the
// card deliberately: the risk of getting it wrong is being watched, not being robbed.
//
// TWO PROTOCOLS, AND THEY ARE NOT INTERCHANGEABLE. Esplora is the public-explorer API (electrs, esplora,
// mempool.space); NBXplorer is what you run in front of your own bitcoind. The picker is not cosmetic —
// it selects a different client, so a URL saved under the wrong one fails the probe rather than silently
// misbehaving. bitcoin-core's own RPC port is neither.
type ChainKind = 'esplora' | 'nbxplorer';
type ChainState = ServiceConnectionState & {
kind: ChainKind;
esploraUrl: string;
custom: boolean;
defaultUrl: string;
network: string;
};
type SaveResult = ChainState & { probe?: { blockHeight?: number | null; ms?: number } };
type KindMeta = { label: string; placeholder: string; hint: string };
const KINDS: Record<ChainKind, KindMeta> = {
esplora: {
label: 'Esplora',
placeholder: 'https://mempool.space/api',
hint: 'An Esplora REST API — electrs, esplora, or a mempool.space instance.',
},
nbxplorer: {
label: 'NBXplorer',
placeholder: 'http://127.0.0.1:32838',
hint: 'NBXplorer in front of your own bitcoind. Its API root, not bitcoind’s RPC port.',
},
};
export const ChainSourceCard = () => {
const { data, isLoading } = useServiceConnection<ChainState>('wallet');
const { save, forget } = useServiceConnectionActions<{ url: string; kind: ChainKind }, SaveResult>('wallet');
const [url, setUrl] = useState('');
const [kind, setKind] = useState<ChainKind>('esplora');
const [error, setError] = useState<string | null>(null);
const [height, setHeight] = useState<number | null>(null);
// Seeded from whatever is in use, so the form starts as the answer to "what is this pointed at" and the
// owner edits it rather than retyping it. Keyed on both fields: a save or reset should re-seed.
useEffect(() => {
if (!data) return;
setUrl(data.esploraUrl);
setKind(data.kind);
}, [data?.esploraUrl, data?.kind]);
const trimmed = url.trim();
const dirty = !!data && (trimmed !== data.esploraUrl || kind !== data.kind);
const busy = save.isPending || forget.isPending;
const meta = KINDS[kind];
const submit = async () => {
setError(null);
setHeight(null);
try {
const result = await save.mutateAsync({ url: trimmed, kind });
setHeight(result.probe?.blockHeight ?? null);
} catch (err) {
setError(serviceErrorMessage(err));
}
};
const reset = async () => {
setError(null);
setHeight(null);
try {
await forget.mutateAsync();
} catch (err) {
setError(serviceErrorMessage(err));
}
};
/** Switching protocol clears a URL that belongs to the other one — it would only fail the probe. */
const pick = (next: ChainKind) => {
if (next === kind) return;
setKind(next);
setError(null);
setHeight(null);
if (data && trimmed === data.esploraUrl && next !== data.kind) setUrl('');
};
return (
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Chain source</h3>
<p className="mb-3 text-xs text-muted-foreground">
Where on-chain wallets read balances and history. It sees every address this wallet asks about, so your own node
is a privacy upgrade — it never sees a key and cannot spend.
</p>
<div
role="radiogroup"
aria-label="Chain source protocol"
className="mb-2 inline-flex rounded-lg border border-border p-0.5"
>
{(Object.keys(KINDS) as ChainKind[]).map((k) => (
<button
key={k}
type="button"
role="radio"
aria-checked={kind === k}
onClick={() => pick(k)}
disabled={isLoading || busy}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
kind === k ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{KINDS[k].label}
</button>
))}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={url}
onChange={(ev) => setUrl(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && trimmed && dirty && !busy) void submit();
}}
placeholder={meta.placeholder}
disabled={isLoading || busy}
className="h-8 font-mono text-xs"
aria-label={`${meta.label} API URL`}
autoComplete="off"
spellCheck={false}
/>
<div className="flex shrink-0 gap-2">
<Button size="sm" onClick={submit} disabled={!trimmed || !dirty || busy}>
{save.isPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
{save.isPending ? 'Connecting…' : 'Save & connect'}
</Button>
{data?.custom && (
<Button size="sm" variant="outline" onClick={reset} disabled={busy} title="Back to the public default">
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Reset
</Button>
)}
</div>
</div>
{error && (
<p className="mt-2 flex items-start gap-1.5 text-xs text-destructive">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{error}</span>
</p>
)}
{height != null && !error && (
<p className="mt-2 flex items-center gap-1.5 text-xs text-emerald-500">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
Connected — block {height.toLocaleString()}
</p>
)}
<p className="mt-2 text-[11px] text-muted-foreground">
{data?.custom
? `Using your ${KINDS[data.kind].label} endpoint.`
: `Using the built-in default (${data?.defaultUrl ?? '—'}), which rate-limits and can see every address you look up.`}{' '}
{meta.hint}
</p>
</section>
);
};
@@ -1,135 +0,0 @@
import type { Utxo } from './shared';
import { Link } from 'react-router';
import { Loader2, Snowflake, Sun } from 'lucide-react';
import { formatConfirmations, truncateMiddle } from './format';
import { walletSectionPath } from './shared';
import { Amount } from './Amount';
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
import { SyncBadge } from './SyncBadge';
import { useSelectedWallet } from './useSelectedWallet';
import { useCoinSelection } from './useCoinSelection';
import { usePermissions, useUtxos, useWalletOperations } from './useWalletData';
// Coin control. Works while locked: which coins exist and which are frozen is watch-only information, and
// freezing is Officer's own flag rather than anything signed.
//
// The selection lives in `?coins=` so the Send section reads the same set straight from the URL.
export const CoinsView = () => {
const { walletId, isLoading } = useSelectedWallet();
const { capabilities } = usePermissions(walletId);
const supported = capabilities.includes('coinControl');
const { utxos, sync, isLoading: utxosLoading } = useUtxos(walletId, supported);
const { selected, toggle, clear } = useCoinSelection();
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
if (!supported) return <UnsupportedSection what="coin control" />;
const selectedTotal = utxos
.filter((u) => selected.includes(`${u.txid}:${u.vout}`))
.reduce((sum, u) => sum + u.amountSats, 0);
const spendableTotal = utxos.filter((u) => !u.frozen).reduce((sum, u) => sum + u.amountSats, 0);
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 border-b border-border px-4 py-2 text-xs">
<span className="text-muted-foreground">
{utxos.length} coin{utxos.length === 1 ? '' : 's'} · <Amount sats={spendableTotal} /> spendable
</span>
<span className="flex-1" />
<SyncBadge sync={sync} />
{selected.length > 0 && (
<>
<span>
{selected.length} selected · <Amount sats={selectedTotal} className="font-semibold" />
</span>
<button type="button" onClick={clear} className="text-primary hover:underline">
clear
</button>
<Link
to={walletSectionPath('send', walletId)}
className="rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Spend these
</Link>
</>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{utxosLoading && utxos.length === 0 ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Listing coins…
</div>
) : utxos.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
{/* "No coins" is only true if the chain was actually read. Never-synced is a different fact. */}
<p className="text-sm font-medium">{sync && sync.syncedAt === null ? 'Coins unavailable' : 'No coins'}</p>
<p className="text-xs text-muted-foreground">
{sync && sync.syncedAt === null
? (sync.lastError ?? 'The chain source could not be reached.')
: 'Receive something and it will show up here as a UTXO.'}
</p>
</div>
) : (
<ul className="divide-y divide-border">
{utxos.map((utxo) => (
<CoinRow
key={`${utxo.txid}:${utxo.vout}`}
utxo={utxo}
walletId={walletId}
selected={selected.includes(`${utxo.txid}:${utxo.vout}`)}
onToggle={toggle}
/>
))}
</ul>
)}
</div>
</div>
);
};
type CoinRowProps = { utxo: Utxo; walletId: number; selected: boolean; onToggle: (outpoint: string) => void };
const CoinRow = ({ utxo, walletId, selected, onToggle }: CoinRowProps) => {
const { freeze } = useWalletOperations(walletId);
const outpoint = `${utxo.txid}:${utxo.vout}`;
return (
<li className={`flex items-center gap-3 px-4 py-2.5 ${utxo.frozen ? 'opacity-60' : ''}`}>
<input
type="checkbox"
checked={selected}
disabled={utxo.frozen}
onChange={() => onToggle(outpoint)}
aria-label={`Select ${outpoint}`}
className="h-3.5 w-3.5 shrink-0 accent-primary"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{utxo.label || truncateMiddle(utxo.address, 16, 10)}</div>
<div className="mt-0.5 flex items-center gap-2 font-mono text-[10px] text-muted-foreground">
<span>
{truncateMiddle(utxo.txid, 10, 6)}:{utxo.vout}
</span>
<span className="font-sans">{formatConfirmations(utxo.confirmations)}</span>
{utxo.addressType && <span className="font-sans">{utxo.addressType}</span>}
</div>
</div>
<Amount sats={utxo.amountSats} className="shrink-0 text-xs font-semibold" />
<button
type="button"
onClick={() => freeze.mutate({ outpoint, frozen: !utxo.frozen })}
disabled={freeze.isPending}
title={utxo.frozen ? 'Make this coin spendable again' : 'Keep this coin out of automatic selection'}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
>
{utxo.frozen ? <Sun className="h-3.5 w-3.5" /> : <Snowflake className="h-3.5 w-3.5" />}
</button>
</li>
);
};
@@ -1,45 +0,0 @@
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { copyToClipboard } from 'helpers/clipboard';
// A read-only value with a copy button — addresses, invoices, txids, xpubs.
//
// Never used for anything secret. A mnemonic gets its own deliberately awkward treatment in
// SeedBackupDialog rather than a one-tap copy, because "copied to clipboard" is where seeds go to die.
type CopyFieldProps = {
value: string;
label?: string;
/** Wrap rather than truncate — right for a bech32 address, wrong for a table cell. */
wrap?: boolean;
className?: string;
};
export const CopyField = ({ value, label, wrap = false, className = '' }: CopyFieldProps) => {
const [copied, setCopied] = useState(false);
const copy = async () => {
if (!(await copyToClipboard(value))) return;
setCopied(true);
setTimeout(() => setCopied(false), 1_500);
};
return (
<div className={className}>
{label && (
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
)}
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2">
<code className={`min-w-0 flex-1 font-mono text-xs ${wrap ? 'break-all' : 'truncate'}`}>{value}</code>
<button
type="button"
onClick={copy}
aria-label={`Copy ${label ?? 'value'}`}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
</div>
);
};
@@ -1,36 +0,0 @@
import { Bitcoin, Loader2 } from 'lucide-react';
// The "no wallet selected" and "this backend cannot do that" placeholders.
//
// Deliberately not an error state: with no wallets registered there is nothing wrong, and a section a
// backend does not implement is a fact about the backend, not a failure. The nav hides those sections, so
// UnsupportedSection is only reached by a deep link — which should explain itself rather than 404.
export const EmptyWallet = ({ isLoading = false }: { isLoading?: boolean }) => (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
{isLoading ? (
<>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading wallets…</p>
</>
) : (
<>
<Bitcoin className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm font-medium">No wallet yet</p>
<p className="max-w-sm text-xs text-muted-foreground">
Add one from the panel on the left — a self-custodial seed sealed under a passphrase, or a connection to a
node you already run.
</p>
</>
)}
</div>
);
export const UnsupportedSection = ({ what }: { what: string }) => (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<p className="text-sm font-medium">Not available for this wallet</p>
<p className="max-w-sm text-xs text-muted-foreground">
This backend does not support {what}. Pick a different wallet from the panel on the left.
</p>
</div>
);
@@ -1,146 +0,0 @@
import type { Channel } from './shared';
import { Users, Zap } from 'lucide-react';
import { formatSats, formatTimestamp, truncateMiddle, PAYMENT_TONES } from './format';
import { Amount } from './Amount';
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
import { useSelectedWallet } from './useSelectedWallet';
import { usePermissions, useChannels, usePayments, usePeers } from './useWalletData';
// The node's own view of itself: payments it has made, channels it holds, peers it is connected to.
//
// lndhub and nwc have no channels or peers of their own — they are accounts on someone else's node — so
// those blocks are absent from their capability set and never rendered. Nothing here shows a control that
// would come back 501.
export const LightningView = () => {
const { walletId, isLoading } = useSelectedWallet();
const { capabilities } = usePermissions(walletId);
const hasChannels = capabilities.includes('channels');
const hasPeers = capabilities.includes('peers');
const hasPayments = capabilities.includes('lightningSend');
const anyLightning = hasChannels || hasPeers || hasPayments || capabilities.includes('lightningReceive');
const { channels } = useChannels(walletId, hasChannels);
const { peers } = usePeers(walletId, hasPeers);
const { payments } = usePayments(walletId, hasPayments, 25);
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
if (!anyLightning) return <UnsupportedSection what="lightning" />;
return (
<div className="h-full space-y-4 overflow-y-auto p-4">
{hasChannels && (
<Section title="Channels" count={channels.length}>
{channels.length === 0 ? (
<Blank>No channels open.</Blank>
) : (
<ul className="divide-y divide-border">
{channels.map((channel) => (
<ChannelRow key={channel.channelId} channel={channel} />
))}
</ul>
)}
</Section>
)}
{hasPeers && (
<Section title="Peers" count={peers.length}>
{peers.length === 0 ? (
<Blank>Not connected to anyone.</Blank>
) : (
<ul className="divide-y divide-border">
{peers.map((peer) => (
<li key={peer.pubkey} className="flex items-center gap-2 py-2 text-xs">
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{peer.alias || truncateMiddle(peer.pubkey, 14, 8)}</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{peer.address}</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{peer.inbound ? 'inbound' : 'outbound'}
</span>
</li>
))}
</ul>
)}
</Section>
)}
{hasPayments && (
<Section title="Payments" count={payments.length}>
{payments.length === 0 ? (
<Blank>Nothing sent yet.</Blank>
) : (
<ul className="divide-y divide-border">
{payments.map((payment) => (
<li key={payment.paymentHash} className="py-2">
<div className="flex items-center gap-2 text-xs">
<Zap className="h-3.5 w-3.5 shrink-0 text-amber-500" />
<span className="min-w-0 flex-1 truncate">
{payment.memo || truncateMiddle(payment.destination ?? payment.paymentHash, 14, 8)}
</span>
<span
className={`shrink-0 rounded px-1.5 py-px text-[10px] font-medium ${PAYMENT_TONES[payment.status]}`}
>
{payment.status}
</span>
{/* msat stays a string all the way to the DOM — see format.ts. */}
<Amount msat={payment.amountMsat} className="shrink-0 font-semibold" />
</div>
<div className="mt-0.5 pl-5 text-[11px] text-muted-foreground">
{formatTimestamp(payment.createdAt)} · fee <Amount msat={payment.feeMsat} />
{payment.failureReason && <span className="text-destructive"> · {payment.failureReason}</span>}
</div>
</li>
))}
</ul>
)}
</Section>
)}
</div>
);
};
const ChannelRow = ({ channel }: { channel: Channel }) => {
const capacity = channel.capacitySats || 1;
const localPct = Math.max(0, Math.min(100, (channel.localBalanceSats / capacity) * 100));
return (
<li className="py-2.5">
<div className="flex items-center gap-2 text-xs">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${channel.active ? 'bg-emerald-500' : 'bg-muted-foreground'}`}
title={channel.active ? 'active' : channel.status}
/>
<span className="min-w-0 flex-1 truncate">
{channel.remoteAlias || truncateMiddle(channel.remotePubkey, 14, 8)}
</span>
{channel.private && <span className="shrink-0 text-[10px] text-muted-foreground">private</span>}
<Amount sats={channel.capacitySats} className="shrink-0 text-[11px] text-muted-foreground" />
</div>
{/* Local/remote split — the number that decides whether you can send or only receive. */}
<div className="mt-1.5 flex h-1.5 overflow-hidden rounded-full bg-muted">
<div className="bg-primary" style={{ width: `${localPct}%` }} />
</div>
<div className="mt-1 flex justify-between text-[10px] tabular-nums text-muted-foreground">
<span>out {formatSats(channel.localBalanceSats)}</span>
<span>in {formatSats(channel.remoteBalanceSats)}</span>
</div>
</li>
);
};
type SectionProps = { title: string; count: number; children: React.ReactNode };
const Section = ({ title, count, children }: SectionProps) => (
<section className="rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{title}
{count > 0 && <span className="ml-1.5 font-normal">({count})</span>}
</h3>
{children}
</section>
);
const Blank = ({ children }: { children: React.ReactNode }) => (
<p className="text-xs text-muted-foreground">{children}</p>
);
@@ -1,132 +0,0 @@
import { useState } from 'react';
import { Lock, LockOpen, ShieldCheck } from 'lucide-react';
import { formatCountdown } from './format';
import { useLockCountdown } from './useLockCountdown';
import { useLockActions, useWalletConfig } from './useWalletData';
import { UnlockDialog } from './dialogs/UnlockDialog';
// The lock indicator. Locked is the resting state and reads as reassurance, not as an error — a watch-only
// wallet with no key material in memory is the whole point of the design, so it is never styled as a
// warning and never blocks the screen behind it.
//
// The countdown ticks locally (useLockCountdown) and flips to locked at zero without waiting for the poll.
type LockBadgeProps = {
walletId: number | null;
walletName: string;
/** Compact drops the action button — for the panel header, where there is no room. */
compact?: boolean;
};
export const LockBadge = ({ walletId, walletName, compact = false }: LockBadgeProps) => {
const { hasSeed, unlocked, secondsRemaining, isLoading } = useLockCountdown(walletId);
const { config } = useWalletConfig();
const { lock } = useLockActions(walletId);
const [unlockOpen, setUnlockOpen] = useState(false);
// A wallet with no seed — a remote node, a custodial account — has nothing to lock. Showing it a
// padlock would imply a protection it does not have.
if (walletId == null || hasSeed === false) {
if (isLoading || hasSeed == null) return null;
return (
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<ShieldCheck className="h-3 w-3" />
no seed held
</span>
);
}
if (hasSeed == null) return null;
return (
<>
<div className="flex items-center gap-2">
<span
className={`flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums ${
unlocked ? 'bg-emerald-500/10 text-emerald-600' : 'bg-muted text-muted-foreground'
}`}
title={
unlocked
? 'The seed is decrypted in the sidecar until this countdown ends'
: 'Locked — balances and history still work; only signing needs the passphrase'
}
>
{unlocked ? <LockOpen className="h-3 w-3" /> : <Lock className="h-3 w-3" />}
{unlocked ? formatCountdown(secondsRemaining) : 'locked'}
</span>
{!compact &&
(unlocked ? (
<button
type="button"
onClick={() => lock.mutate()}
disabled={lock.isPending}
className="text-[10px] font-medium text-primary hover:underline"
>
lock now
</button>
) : (
<button
type="button"
onClick={() => setUnlockOpen(true)}
className="text-[10px] font-medium text-primary hover:underline"
>
unlock
</button>
))}
</div>
{unlockOpen && (
<UnlockDialog
open={unlockOpen}
onOpenChange={setUnlockOpen}
walletId={walletId}
walletName={walletName}
maxTtlSec={config?.unlockTtlSec ?? 300}
/>
)}
</>
);
};
/**
* The inline "you need to unlock for this" prompt, used by the signing surfaces only. Renders its children
* disabled-in-place rather than replacing the screen, so you can still read the form you are about to
* submit while the wallet is locked.
*/
type UnlockPromptProps = { walletId: number; walletName: string; className?: string };
export const UnlockPrompt = ({ walletId, walletName, className = '' }: UnlockPromptProps) => {
const { config } = useWalletConfig();
const [open, setOpen] = useState(false);
return (
<>
<div
className={`flex items-center gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs ${className}`}
>
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground">
This wallet is locked. Signing needs the passphrase — everything else on this screen already works.
</span>
<button
type="button"
onClick={() => setOpen(true)}
className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Unlock
</button>
</div>
{open && (
<UnlockDialog
open={open}
onOpenChange={setOpen}
walletId={walletId}
walletName={walletName}
maxTtlSec={config?.unlockTtlSec ?? 300}
/>
)}
</>
);
};
@@ -1,180 +0,0 @@
import type { ReactNode } from 'react';
import type { Balances } from './shared';
import { ArrowDownLeft, ArrowUpRight, Bitcoin, Clock, Loader2, Zap } from 'lucide-react';
import { Link } from 'react-router';
import { KIND_LABELS, walletSectionPath } from './shared';
import { formatSats, formatTimestamp, truncateMiddle } from './format';
import { Amount, UnitToggle } from './Amount';
import { EmptyWallet } from './EmptyWallet';
import { SyncBadge } from './SyncBadge';
import { useSelectedWallet } from './useSelectedWallet';
import { useBalances, usePermissions, useTransactions, useWalletInfo } from './useWalletData';
// The at-a-glance section: what you hold, whether the node agrees with the chain, and the last few moves.
//
// Every number here is a read, so the whole screen works against a locked wallet — which is its resting
// state. Nothing on this page prompts for a passphrase.
/**
* The sub-line under the on-chain figure. Unconfirmed and frozen are both reasons the headline number is
* not what a send can use, and both can be true at once, so they read as a list rather than a choice.
*/
function onchainHint(balances: Balances | null | undefined): string | undefined {
if (!balances) return undefined;
const parts: string[] = [];
if (balances.onchainUnconfirmed !== 0) {
const sign = balances.onchainUnconfirmed > 0 ? '+' : '';
parts.push(`${sign}${formatSats(balances.onchainUnconfirmed)} sats unconfirmed`);
}
if (balances.onchainFrozen > 0) parts.push(`${formatSats(balances.onchainFrozen)} sats frozen`);
return parts.length ? parts.join(' · ') : undefined;
}
export const OverviewView = () => {
const { wallet, walletId, isLoading } = useSelectedWallet();
const { capabilities } = usePermissions(walletId);
const { balances, sync, isLoading: balancesLoading } = useBalances(walletId);
const { info } = useWalletInfo(walletId);
const { transactions } = useTransactions(walletId, 5);
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
const hasLightning = balances?.lightningBalance != null;
const canSend = capabilities.includes('onchainSend') || capabilities.includes('lightningSend');
const canReceive = capabilities.includes('onchainReceive') || capabilities.includes('lightningReceive');
return (
<div className="h-full overflow-y-auto p-4">
<div className="mb-3 flex items-center justify-between">
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold">{wallet?.name}</h2>
<p className="truncate text-xs text-muted-foreground">
{wallet ? KIND_LABELS[wallet.kind] : '—'}
{info?.alias && ` · ${info.alias}`}
{info?.version && ` · ${info.version}`}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<SyncBadge sync={sync} />
<UnitToggle />
</div>
</div>
{balancesLoading && !balances ? (
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Reading balances…
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Tile
icon={<Bitcoin className="h-4 w-4 text-orange-500" />}
label="On-chain"
value={<Amount sats={balances?.onchainConfirmed ?? null} className="text-xl font-semibold" />}
hint={onchainHint(balances)}
/>
{hasLightning && (
<Tile
icon={<Zap className="h-4 w-4 text-amber-500" />}
label="Lightning"
value={<Amount sats={balances?.lightningBalance ?? null} className="text-xl font-semibold" />}
hint={
balances?.lightningInbound != null ? `${formatSats(balances.lightningInbound)} sats inbound` : undefined
}
/>
)}
<Tile
label="Block height"
value={
<span className="text-xl font-semibold tabular-nums">{info?.blockHeight?.toLocaleString() ?? '—'}</span>
}
hint={info ? (info.synced ? 'synced' : 'syncing…') : undefined}
/>
<Tile
label="Network"
value={<span className="text-xl font-semibold">{info?.network ?? wallet?.network ?? '—'}</span>}
hint={info?.pubkey ? truncateMiddle(info.pubkey, 8, 6) : undefined}
/>
</div>
)}
{(canReceive || canSend) && (
<div className="mt-4 flex gap-2">
{canReceive && (
<Link
to={walletSectionPath('receive', walletId)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
>
<ArrowDownLeft className="h-3.5 w-3.5 text-emerald-500" />
Receive
</Link>
)}
{canSend && (
<Link
to={walletSectionPath('send', walletId)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
>
<ArrowUpRight className="h-3.5 w-3.5 text-destructive" />
Send
</Link>
)}
</div>
)}
<section className="mt-4 rounded-xl border border-border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Recent activity</h3>
<Link to={walletSectionPath('transactions', walletId)} className="text-[11px] text-primary hover:underline">
all transactions
</Link>
</div>
{transactions.length === 0 ? (
<p className="text-xs text-muted-foreground">Nothing yet.</p>
) : (
<ul className="divide-y divide-border">
{transactions.map((tx) => (
<li key={tx.txid} className="flex items-center gap-3 py-2">
<span
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full ${
tx.amount < 0 ? 'bg-destructive/10' : 'bg-emerald-500/10'
}`}
>
{tx.amount < 0 ? (
<ArrowUpRight className="h-3 w-3 text-destructive" />
) : (
<ArrowDownLeft className="h-3 w-3 text-emerald-500" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{tx.label ?? truncateMiddle(tx.txid)}</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
{tx.confirmations <= 0 && <Clock className="h-3 w-3" />}
{tx.confirmations <= 0 ? 'pending' : formatTimestamp(tx.timestamp)}
</div>
</div>
<Amount sats={tx.amount} signed className="shrink-0 text-xs" />
</li>
))}
</ul>
)}
</section>
</div>
);
};
type TileProps = { label: string; value: ReactNode; hint?: string; icon?: ReactNode };
const Tile = ({ label, value, hint, icon }: TileProps) => (
<div className="rounded-xl border border-border p-4">
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{icon}
{label}
</div>
<div className="mt-1">{value}</div>
{hint && (
<div className="mt-0.5 truncate text-xs text-muted-foreground" title={hint}>
{hint}
</div>
)}
</div>
);
@@ -1,136 +0,0 @@
import { useState } from 'react';
import { Loader2, Plus, RefreshCw, TriangleAlert, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { formatTimestamp, truncateMiddle } from './format';
import { INVOICE_TONES } from './format';
import { Amount } from './Amount';
import { CopyField } from './CopyField';
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
import { useSelectedWallet } from './useSelectedWallet';
import { errorMessage, usePermissions, useInvoices, useReceiveAddress } from './useWalletData';
import { CreateInvoiceDialog } from './dialogs/CreateInvoiceDialog';
// Receiving. Works while locked — deriving an address needs the account xpub, not the seed, which is why
// this whole screen is usable in the wallet's resting state.
//
// The address is fetched with `?peek=true`: it returns the current unused address without advancing the
// derivation index. Rendering must never burn an address, so advancing is an explicit button.
export const ReceiveView = () => {
const { walletId, isLoading } = useSelectedWallet();
const { capabilities } = usePermissions(walletId);
const canOnchain = capabilities.includes('onchainReceive');
const canLightning = capabilities.includes('lightningReceive');
const {
address,
addressType,
isLoading: addressLoading,
error: addressError,
next,
} = useReceiveAddress(walletId, canOnchain);
const { invoices } = useInvoices(walletId, canLightning, 10);
const [invoiceOpen, setInvoiceOpen] = useState(false);
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
if (!canOnchain && !canLightning) return <UnsupportedSection what="receiving" />;
return (
<div className="h-full overflow-y-auto p-4">
{canOnchain && (
<section className="max-w-2xl rounded-xl border border-border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
On-chain address
</h3>
<button
type="button"
onClick={() => next.mutate()}
disabled={next.isPending}
className="flex items-center gap-1 text-[11px] text-primary hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${next.isPending ? 'animate-spin' : ''}`} />
new address
</button>
</div>
{addressLoading && !address ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Deriving an address…
</div>
) : address ? (
<>
<CopyField value={address} wrap />
<p className="mt-2 text-xs text-muted-foreground">
{addressType ? `${addressType} · ` : ''}Reuse costs you privacy, not money — take a fresh one for each
payer.
</p>
</>
) : addressError ? (
// Deriving an address needs the chain source, so this fails whenever the Esplora endpoint is
// down or rate-limiting. Showing "no address available" for that sent the last debugging
// session looking at derivation code when the answer was a 429 in the response body.
<div className="flex items-start gap-2 text-xs text-destructive">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="min-w-0">
<p className="break-words">{errorMessage(addressError, 'Could not derive an address')}</p>
<p className="mt-1 text-muted-foreground">
Addresses are derived locally, but the wallet checks the chain to find an unused one. Check{' '}
<span className="font-medium">Settings → Chain source</span>.
</p>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">No address available.</p>
)}
</section>
)}
{canLightning && (
<section className="mt-4 max-w-2xl rounded-xl border border-border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Lightning invoices
</h3>
<Button size="sm" variant="outline" onClick={() => setInvoiceOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New invoice
</Button>
</div>
{invoices.length === 0 ? (
<p className="text-xs text-muted-foreground">No invoices yet.</p>
) : (
<ul className="divide-y divide-border">
{invoices.map((inv) => (
<li key={inv.paymentHash} className="py-2">
<div className="flex items-center gap-2">
<Zap className="h-3.5 w-3.5 shrink-0 text-amber-500" />
<span className="min-w-0 flex-1 truncate text-xs">
{inv.memo || truncateMiddle(inv.paymentHash, 12, 8)}
</span>
<span
className={`shrink-0 rounded px-1.5 py-px text-[10px] font-medium ${INVOICE_TONES[inv.state]}`}
>
{inv.state}
</span>
{/* msat stays a string all the way to the DOM — see format.ts. */}
<Amount msat={inv.amountMsat} className="shrink-0 text-xs" />
</div>
<div className="mt-0.5 pl-5 text-[11px] text-muted-foreground">
created {formatTimestamp(inv.createdAt)}
{inv.settledAt ? ` · settled ${formatTimestamp(inv.settledAt)}` : ''}
</div>
{inv.state === 'open' && <CopyField className="mt-1.5 pl-5" value={inv.bolt11} wrap />}
</li>
))}
</ul>
)}
</section>
)}
{invoiceOpen && <CreateInvoiceDialog open={invoiceOpen} onOpenChange={setInvoiceOpen} walletId={walletId} />}
</div>
);
};
@@ -1,120 +0,0 @@
import { useState } from 'react';
import { CheckCircle2, Loader2, Search, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ADDRESS_TYPE_LABELS } from './shared';
import { useBalances, useWalletOperations } from './useWalletData';
// Search the chain for coins this wallet already owns.
//
// THIS IS THE FIX FOR AN IMPORTED WALLET THAT READS ZERO, and that symptom is why the card exists at all.
// An indexing chain source (NBXplorer in front of your own node) only watches an account from the moment
// you register it, so a seed with a history arrives at a balance of nothing: every call succeeds, no error
// is raised, the coins are simply not in the index yet. A scan walks the node's current UTXO set directly
// and finds them regardless of when the account was registered or how far back the node is pruned.
//
// It recovers SPENDABLE COINS, not history. Transactions spent before the account was registered stay
// missing — that needs a full block rescan, which is a heavier thing this does not do.
//
// Hidden entirely when the chain source cannot rescan: Esplora asks about every address on every refresh,
// so it has nothing to catch up on, and offering a button that 501s would invent a problem.
type RescanCardProps = { walletId: number };
export const RescanCard = ({ walletId }: RescanCardProps) => {
const { sync } = useBalances(walletId);
const { rescan } = useWalletOperations(walletId);
const [gapLimit, setGapLimit] = useState('');
// `sync` null is a node backend, which owns its own coins and has nothing to look for. `canRescan`
// false is Esplora. Note this cannot key off `sync.rescan` being null — that is equally what NBXplorer
// reports before its first scan, which is exactly when the card is most needed.
if (!sync?.canRescan) return null;
const state = sync.rescan;
const running = state?.running === true;
const parsed = Number(gapLimit);
const depth = gapLimit.trim() && Number.isInteger(parsed) && parsed > 0 ? parsed : null;
return (
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Find missing coins
</h3>
<p className="mb-3 text-xs text-muted-foreground">
Ask the node to search its whole UTXO set for this wallet’s coins. Run this after importing a seed that already
had funds — an indexing node only watches an account from the moment it is added, so an older balance shows as
zero until it has looked. It scans all four address types, one at a time, and takes a few minutes.
</p>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => rescan.mutate(depth ? { gapLimit: depth } : {})}
disabled={running || rescan.isPending}
>
{running || rescan.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Search className="mr-1.5 h-3.5 w-3.5" />
)}
{running ? 'Scanning…' : 'Scan the chain'}
</Button>
{/* The one thing about a scan only the owner can know: how many addresses their old wallet handed
out and never had paid. 1000 covers anyone who clicked "new address" by hand; a wallet that
issued them in bulk needs more, and no amount of looking from here can tell which it was. */}
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
search depth
<Input
type="number"
min={1}
max={100000}
value={gapLimit}
placeholder="1000"
disabled={running}
onChange={(ev) => setGapLimit(ev.target.value)}
className="h-7 w-24 text-xs"
/>
</label>
</div>
{state && <Status state={state} />}
</section>
);
};
type StatusProps = { state: NonNullable<ReturnType<typeof useBalances>['sync']>['rescan'] };
const Status = ({ state }: StatusProps) => {
if (!state) return null;
if (state.running) {
const label = state.current ? ADDRESS_TYPE_LABELS[state.current] : 'queued';
return (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-500">
Scanning {label} — {state.done} of {state.total} done
{state.found > 0 && `, ${state.found} coin${state.found === 1 ? '' : 's'} found so far`}.
</p>
);
}
if (state.error) {
return (
<p className="mt-2 flex items-start gap-1.5 text-xs text-destructive">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Stopped after {state.done} of {state.total}: {state.error}
</span>
</p>
);
}
return (
<p className="mt-2 flex items-center gap-1.5 text-xs text-emerald-500">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
Last scan found {state.found} coin{state.found === 1 ? '' : 's'} across {state.total} address types.
</p>
);
};
@@ -1,305 +0,0 @@
import type { FeeEstimates } from './shared';
import { useState } from 'react';
import { Link } from 'react-router';
import { Bitcoin, Coins, Loader2, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { walletSectionPath } from './shared';
import { formatSats } from './format';
import { Amount } from './Amount';
import { CopyField } from './CopyField';
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
import { UnlockPrompt } from './LockBadge';
import { useSelectedWallet } from './useSelectedWallet';
import { useCoinSelection } from './useCoinSelection';
import { useLockCountdown } from './useLockCountdown';
import { useBalances, usePermissions, useFees, useUtxos, useWalletOperations } from './useWalletData';
import { PayInvoiceDialog } from './dialogs/PayInvoiceDialog';
// Sending — the only part of the app that genuinely needs an unlocked wallet.
//
// The form is fully usable while locked: you can compose the whole spend, see the fee, review the coins.
// Only the submit is blocked, with an inline unlock prompt above it rather than a modal wall across the
// screen. That ordering is deliberate — you should be able to read what you are about to sign before you
// are asked for the passphrase.
//
// The selected coins come from `?coins=` (useCoinSelection), so the Coins section and this one agree
// through the URL rather than a shared store.
const FEE_PRESETS: { key: keyof FeeEstimates; label: string; hint: string }[] = [
{ key: 'fastestFee', label: 'Fastest', hint: 'next block' },
{ key: 'halfHourFee', label: 'Fast', hint: '~30 min' },
{ key: 'hourFee', label: 'Normal', hint: '~1 hour' },
{ key: 'economyFee', label: 'Economy', hint: 'hours' },
{ key: 'minimumFee', label: 'Minimum', hint: 'whenever' },
];
export const SendView = () => {
const { wallet, walletId, isLoading } = useSelectedWallet();
const { capabilities } = usePermissions(walletId);
const canOnchain = capabilities.includes('onchainSend');
const canLightning = capabilities.includes('lightningSend');
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
if (!canOnchain && !canLightning) return <UnsupportedSection what="sending" />;
return (
<div className="h-full overflow-y-auto p-4">
<Tabs defaultValue={canOnchain ? 'onchain' : 'lightning'} className="max-w-2xl">
{canOnchain && canLightning && (
<TabsList className="mb-4 w-fit">
<TabsTrigger value="onchain">
<Bitcoin className="mr-1.5 h-3.5 w-3.5" />
On-chain
</TabsTrigger>
<TabsTrigger value="lightning">
<Zap className="mr-1.5 h-3.5 w-3.5" />
Lightning
</TabsTrigger>
</TabsList>
)}
{canOnchain && (
<TabsContent value="onchain" className="mt-0">
<OnchainSendForm walletId={walletId} walletName={wallet?.name ?? 'this wallet'} />
</TabsContent>
)}
{canLightning && (
<TabsContent value="lightning" className="mt-0">
<LightningSendPanel walletId={walletId} walletName={wallet?.name ?? 'this wallet'} />
</TabsContent>
)}
</Tabs>
</div>
);
};
type FormProps = { walletId: number; walletName: string };
const OnchainSendForm = ({ walletId, walletName }: FormProps) => {
const { balances } = useBalances(walletId);
const { fees } = useFees(walletId);
const { capabilities } = usePermissions(walletId);
const { selected, clear } = useCoinSelection();
const { utxos } = useUtxos(walletId, capabilities.includes('coinControl'));
const { send } = useWalletOperations(walletId);
const { hasSeed, unlocked } = useLockCountdown(walletId);
const [address, setAddress] = useState('');
const [amount, setAmount] = useState('');
const [sendAll, setSendAll] = useState(false);
const [satPerVbyte, setSatPerVbyte] = useState('');
const [label, setLabel] = useState('');
const [broadcast, setBroadcast] = useState<{ txid: string; feeSats: number } | null>(null);
// A seeded wallet must be unlocked to sign. A wallet with no seed of its own (a remote node) signs
// upstream, so there is nothing to unlock and the form is always live.
const needsUnlock = hasSeed === true && !unlocked;
const selectedUtxos = utxos.filter((u) => selected.includes(`${u.txid}:${u.vout}`));
const selectedTotal = selectedUtxos.reduce((sum, u) => sum + u.amountSats, 0);
const feeRate = Number(satPerVbyte);
const amountSats = Number(amount);
const canSubmit =
!!address.trim() &&
(sendAll || (Number.isFinite(amountSats) && amountSats > 0)) &&
Number.isFinite(feeRate) &&
feeRate >= 1 &&
!needsUnlock &&
!send.isPending;
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!canSubmit) return;
const result = await send.mutateAsync({
address: address.trim(),
amountSats: sendAll ? undefined : amountSats,
sendAll: sendAll || undefined,
satPerVbyte: feeRate,
outpoints: selected.length > 0 ? selected : undefined,
label: label.trim() || undefined,
});
setBroadcast({ txid: result.txid, feeSats: result.feeSats });
setAddress('');
setAmount('');
setLabel('');
setSendAll(false);
clear();
};
if (broadcast) {
return (
<section className="rounded-xl border border-emerald-500/40 bg-emerald-500/5 p-4">
<h3 className="text-sm font-semibold">Broadcast</h3>
<p className="mt-1 text-xs text-muted-foreground">
Paid a fee of {formatSats(broadcast.feeSats)} sats. It will confirm when a miner includes it.
</p>
<CopyField className="mt-3" value={broadcast.txid} label="txid" wrap />
<Button size="sm" variant="outline" className="mt-3" onClick={() => setBroadcast(null)}>
Send another
</Button>
</section>
);
}
return (
<form onSubmit={submit} className="space-y-4">
<div className="rounded-xl border border-border p-4">
<div className="flex items-baseline justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Spendable</h3>
{/* Frozen coins are confirmed and are the wallet's, but coin selection will not touch them. This
header said "Spendable" over a figure that included them, so a max-value send was rejected as
having no spendable UTXOs while the number above it said otherwise. */}
<Amount
sats={balances ? balances.onchainConfirmed - balances.onchainFrozen : null}
className="text-sm font-semibold"
/>
</div>
{!!balances?.onchainFrozen && (
<p className="mt-1 text-right text-[11px] text-muted-foreground">
{formatSats(balances.onchainFrozen)} sats frozen and excluded
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-send-address">To address</Label>
<Input
id="wallet-send-address"
value={address}
spellCheck={false}
placeholder="bc1q…"
onChange={(ev) => setAddress(ev.target.value)}
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-send-amount">Amount (sats)</Label>
<div className="flex items-center gap-2">
<Input
id="wallet-send-amount"
type="number"
min={0}
value={sendAll ? '' : amount}
disabled={sendAll}
placeholder={sendAll ? 'everything' : '0'}
onChange={(ev) => setAmount(ev.target.value)}
className="w-48"
/>
<button
type="button"
onClick={() => setSendAll((v) => !v)}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
sendAll ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
>
Send max
</button>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-send-fee">Fee rate (sat/vB)</Label>
<div className="flex flex-wrap gap-1.5">
{fees &&
FEE_PRESETS.map(({ key, label: presetLabel, hint }) => (
<button
key={key}
type="button"
onClick={() => setSatPerVbyte(String(fees[key]))}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
satPerVbyte === String(fees[key])
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
title={hint}
>
{presetLabel} · {fees[key]}
</button>
))}
</div>
<Input
id="wallet-send-fee"
type="number"
min={1}
value={satPerVbyte}
placeholder="1"
onChange={(ev) => setSatPerVbyte(ev.target.value)}
className="w-32"
/>
</div>
{capabilities.includes('coinControl') && (
<div className="rounded-xl border border-border p-3">
<div className="flex items-center gap-2 text-xs">
<Coins className="h-3.5 w-3.5 text-muted-foreground" />
{selected.length === 0 ? (
<span className="flex-1 text-muted-foreground">
Automatic coin selection.{' '}
<Link to={walletSectionPath('coins', walletId)} className="text-primary hover:underline">
Pick coins
</Link>
</span>
) : (
<>
<span className="flex-1">
{selected.length} coin{selected.length === 1 ? '' : 's'} selected · <Amount sats={selectedTotal} />
</span>
<button type="button" onClick={clear} className="text-primary hover:underline">
clear
</button>
</>
)}
</div>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="wallet-send-label">Label (optional)</Label>
<Input
id="wallet-send-label"
value={label}
placeholder="what this payment is for"
onChange={(ev) => setLabel(ev.target.value)}
/>
</div>
{needsUnlock && <UnlockPrompt walletId={walletId} walletName={walletName} />}
<Button type="submit" disabled={!canSubmit}>
{send.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign and broadcast
</Button>
</form>
);
};
const LightningSendPanel = ({ walletId, walletName }: FormProps) => {
const { balances } = useBalances(walletId);
const { hasSeed, unlocked } = useLockCountdown(walletId);
const [payOpen, setPayOpen] = useState(false);
const needsUnlock = hasSeed === true && !unlocked;
return (
<div className="space-y-4">
<div className="rounded-xl border border-border p-4">
<div className="flex items-baseline justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Lightning balance</h3>
<Amount sats={balances?.lightningBalance ?? null} className="text-sm font-semibold" />
</div>
</div>
{needsUnlock && <UnlockPrompt walletId={walletId} walletName={walletName} />}
<Button onClick={() => setPayOpen(true)} disabled={needsUnlock}>
<Zap className="mr-2 h-4 w-4" />
Pay an invoice
</Button>
{payOpen && <PayInvoiceDialog open={payOpen} onOpenChange={setPayOpen} walletId={walletId} />}
</div>
);
};
@@ -1,94 +0,0 @@
import type { SyncState } from './shared';
import { useEffect, useState } from 'react';
import { AlertTriangle, Check, RefreshCw } from 'lucide-react';
// How old the numbers on screen are.
//
// This exists because "empty wallet" and "we could not reach the chain" used to render identically — as a
// zero balance — and the second one is alarming while the first is not. The sidecar now serves the last
// snapshot it managed to read and refreshes behind the response, so the number is almost always real; what
// the owner needs is its age, and a plain statement when the upstream is failing.
//
// Nothing here is a control. Refreshing is the data layer's job and it is already happening.
/** Re-render on a timer so "2 minutes ago" does not sit frozen on an idle screen. */
const TICK_MS = 15_000;
function relativeAge(syncedAt: number, now: number): string {
const seconds = Math.max(0, Math.round((now - syncedAt) / 1000));
if (seconds < 45) return 'just now';
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.round(hours / 24)}d ago`;
}
type SyncBadgeProps = { sync: SyncState | null; className?: string };
export const SyncBadge = ({ sync, className }: SyncBadgeProps) => {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), TICK_MS);
return () => clearInterval(timer);
}, []);
// Null from a node backend, which reads live and has no age to report. Rendering "just now" for it
// would be inventing a fact.
if (!sync) return null;
// A rescan outranks everything else here, including a stale age and an upstream error. While the node
// is searching, the number beside this badge is not "your balance" — it is "your balance so far", and
// that difference is the entire reason an imported wallet showing zero is alarming rather than boring.
const { rescan } = sync;
if (rescan?.running) {
return (
<span
className={`inline-flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-500 ${className ?? ''}`}
title="The node is searching its whole UTXO set for this wallet’s coins. Balances stay incomplete until it finishes."
>
<RefreshCw className="h-3 w-3 animate-spin" />
Scanning the chain · {Math.min(rescan.done + 1, rescan.total)} of {rescan.total}
</span>
);
}
const failing = sync.lastError !== null;
// Never read successfully AND failing: the only case where the numbers beside this are not real. Say so
// loudly, because a zero here means "unknown", not "empty".
if (sync.syncedAt === null) {
if (!failing) return null;
return (
<span
className={`inline-flex items-center gap-1 text-[11px] text-destructive ${className ?? ''}`}
title={sync.lastError ?? undefined}
>
<AlertTriangle className="h-3 w-3" />
Could not reach the chain
</span>
);
}
const age = relativeAge(sync.syncedAt, now);
if (failing) {
return (
<span
className={`inline-flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-500 ${className ?? ''}`}
title={sync.lastError ?? undefined}
>
<AlertTriangle className="h-3 w-3" />
{age} · chain unreachable
</span>
);
}
return (
<span className={`inline-flex items-center gap-1 text-[11px] text-muted-foreground ${className ?? ''}`}>
{sync.stale ? <RefreshCw className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
{sync.stale ? `${age} · refreshing` : age}
</span>
);
};
@@ -1,90 +0,0 @@
import type { OnchainTx } from './shared';
import { ArrowDownLeft, ArrowUpRight, Loader2 } from 'lucide-react';
import { formatConfirmations, formatSats, formatTimestamp, truncateMiddle } from './format';
import { Amount } from './Amount';
import { EmptyWallet } from './EmptyWallet';
import { SyncBadge } from './SyncBadge';
import { useSelectedWallet } from './useSelectedWallet';
import { useTransactions } from './useWalletData';
// On-chain history. Works while locked — the transaction list comes from the account xpub, not the seed.
export const TransactionsView = () => {
const { walletId, isLoading } = useSelectedWallet();
const { transactions, sync, isLoading: txLoading } = useTransactions(walletId);
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
if (txLoading && transactions.length === 0) {
return (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Reading the chain…
</div>
);
}
if (transactions.length === 0) {
// An empty list means "no transactions" only if we actually managed to look. When the chain has never
// been read, the same screen would otherwise assert a fact nobody established.
const neverRead = sync !== null && sync.syncedAt === null;
return (
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
<p className="text-sm font-medium">{neverRead ? 'History unavailable' : 'Nothing here yet'}</p>
<p className="text-xs text-muted-foreground">
{neverRead
? (sync.lastError ?? 'The chain source could not be reached.')
: 'Transactions appear as soon as they hit the mempool.'}
</p>
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center justify-end border-b border-border px-4 py-1.5">
<SyncBadge sync={sync} />
</div>
<div className="flex-1 overflow-y-auto">
<ul className="divide-y divide-border">
{transactions.map((tx) => (
<TransactionRow key={tx.txid} tx={tx} />
))}
</ul>
</div>
</div>
);
};
const TransactionRow = ({ tx }: { tx: OnchainTx }) => {
const incoming = tx.amount >= 0;
const pending = tx.confirmations <= 0;
return (
<li className="flex items-center gap-3 px-4 py-2.5">
<span
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${
incoming ? 'bg-emerald-500/10 text-emerald-500' : 'bg-muted text-muted-foreground'
}`}
>
{incoming ? <ArrowDownLeft className="h-3.5 w-3.5" /> : <ArrowUpRight className="h-3.5 w-3.5" />}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">
{tx.label || truncateMiddle(tx.destAddresses[0] ?? tx.txid, 14, 10)}
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{formatTimestamp(tx.timestamp)}</span>
<span className={pending ? 'text-amber-500' : ''}>{formatConfirmations(tx.confirmations)}</span>
{tx.feeSats != null && tx.feeSats > 0 && <span>fee {formatSats(tx.feeSats)}</span>}
</div>
</div>
<div className="shrink-0 text-right">
<Amount sats={tx.amount} signed className="text-xs font-semibold" />
<div className="mt-0.5 font-mono text-[10px] text-muted-foreground">{truncateMiddle(tx.txid, 8, 6)}</div>
</div>
</li>
);
};
@@ -1,178 +0,0 @@
import type { LucideIcon } from 'lucide-react';
import type { WalletSummary } from './shared';
import { useState } from 'react';
import { Link, NavLink } from 'react-router';
import { ArrowDownLeft, ArrowUpRight, Bitcoin, Coins, Gauge, Plus, Receipt, Settings, Star, Zap } from 'lucide-react';
import {
KIND_LABELS,
NETWORK_TONES,
WALLET_SECTIONS,
sectionAvailable,
walletSectionPath,
type WalletSectionId,
} from './shared';
import { Amount, UnitToggle } from './Amount';
import { LockBadge } from './LockBadge';
import { useWalletSection } from './useWalletSection';
import { useSelectedWallet } from './useSelectedWallet';
import { useBalances, usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData';
import { CreateWalletDialog } from './dialogs/CreateWalletDialog';
// Left panel of /wallet: the balance, the wallets, the sections, the lock state.
//
// Wallet rows are real links carrying `?wallet=<id>` — cmd-click, back button and reload all work, and the
// id is in the DOM rather than an onClick closure (docs/navigation-audit.md). "Make active" is a sibling
// button, not nested inside the anchor, because it mutates rather than navigates.
//
// Sections are filtered by the backend's declared capabilities: an on-chain wallet has no Lightning tab at
// all, an lndhub account has no coin control. Better a shorter nav than a control that answers 501.
const ICONS: Record<WalletSectionId, LucideIcon> = {
overview: Gauge,
receive: ArrowDownLeft,
send: ArrowUpRight,
transactions: Receipt,
coins: Coins,
lightning: Zap,
settings: Settings,
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
export const WalletNav = () => {
const section = useWalletSection();
const { config } = useWalletConfig();
const { wallet, wallets, walletId, isPinned, isLoading } = useSelectedWallet();
const { capabilities } = usePermissions(walletId);
const { balances } = useBalances(walletId);
const [createOpen, setCreateOpen] = useState(false);
// Preserve an explicitly pinned wallet across section changes; leave a bare URL bare so it keeps
// meaning "whichever wallet is active".
const linkWalletId = isPinned ? walletId : null;
const total = balances ? balances.onchainConfirmed + (balances.lightningBalance ?? 0) : null;
const network = wallet?.network ?? config?.network ?? null;
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-orange-500/15 text-orange-500 ring-1 ring-black/5">
<Bitcoin className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-semibold leading-tight">Wallet</span>
{network && network !== 'bitcoin' && (
<span className={`rounded px-1 py-px text-[9px] font-semibold uppercase ${NETWORK_TONES[network] ?? ''}`}>
{network}
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Amount sats={total} />
<UnitToggle />
</div>
</div>
</div>
<div className="flex items-center justify-between px-5 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Wallets</span>
<button
type="button"
onClick={() => setCreateOpen(true)}
className="flex items-center gap-0.5 text-[10px] font-medium text-primary hover:underline"
>
<Plus className="h-3 w-3" />
add
</button>
</div>
<div className="flex flex-col gap-0.5 px-2 pb-3">
{isLoading && wallets.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">Loading wallets…</div>
)}
{!isLoading && wallets.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">
No wallets yet. Add one to get started — a self-custodial seed, or a connection to your node.
</div>
)}
{wallets.map((w) => (
<WalletRow key={w.id} wallet={w} section={section} selected={w.id === walletId} />
))}
</div>
<nav className="flex flex-col gap-0.5 border-t border-border px-2 py-3">
{WALLET_SECTIONS.filter(({ id }) => sectionAvailable(id, capabilities)).map(({ id, label }) => {
const Icon = ICONS[id];
return (
<NavLink
key={id}
to={walletSectionPath(id, linkWalletId)}
className={({ isActive }) =>
`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<Icon
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className="flex-1">{label}</span>
</>
)}
</NavLink>
);
})}
</nav>
<div className="mt-auto flex items-center justify-between gap-2 px-4 py-3">
<LockBadge walletId={walletId} walletName={wallet?.name ?? 'this wallet'} />
{config && <span className="truncate text-[10px] text-muted-foreground">{config.network}</span>}
</div>
{createOpen && <CreateWalletDialog open={createOpen} onOpenChange={setCreateOpen} section={section} />}
</div>
);
};
type WalletRowProps = { wallet: WalletSummary; section: WalletSectionId; selected: boolean };
const WalletRow = ({ wallet, section, selected }: WalletRowProps) => {
const { activate } = useWalletLifecycle();
return (
<div
className={`group flex items-center gap-1 rounded-lg pr-1.5 transition-colors ${
selected ? 'bg-muted' : 'hover:bg-muted/60'
}`}
>
<Link to={walletSectionPath(section, wallet.id)} className="flex min-w-0 flex-1 flex-col px-3 py-1.5">
<span className={`truncate text-xs ${selected ? 'font-medium text-foreground' : 'text-muted-foreground'}`}>
{wallet.name}
</span>
<span className="truncate text-[10px] text-muted-foreground">{KIND_LABELS[wallet.kind]}</span>
</Link>
{/* Sibling of the anchor, never nested inside it — this mutates, it does not navigate. */}
{wallet.isActive ? (
<Star className="h-3 w-3 shrink-0 fill-amber-400 text-amber-400" aria-label="Active wallet" />
) : (
<button
type="button"
onClick={() => activate.mutate(wallet.id)}
disabled={activate.isPending}
title="Make this the active wallet"
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-amber-400 group-hover:opacity-100 focus:opacity-100"
>
<Star className="h-3 w-3" />
</button>
)}
</div>
);
};
@@ -1,279 +0,0 @@
import type { WalletSummary } from './shared';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { Check, Eye, KeyRound, Pencil, Star, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { KIND_LABELS, walletSectionPath } from './shared';
import { ChainSourceCard } from './ChainSourceCard';
import { CopyField } from './CopyField';
import { EmptyWallet } from './EmptyWallet';
import { LockBadge } from './LockBadge';
import { RescanCard } from './RescanCard';
import { useSelectedWallet } from './useSelectedWallet';
import { useLockCountdown } from './useLockCountdown';
import { usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData';
import { ChangePassphraseDialog } from './dialogs/ChangePassphraseDialog';
import { ExportSeedDialog } from './dialogs/ExportSeedDialog';
import { DeleteWalletDialog } from './dialogs/DeleteWalletDialog';
// Wallet-level administration: what this wallet is, what its backend can do, and the three passphrase
// operations. Nothing here needs the wallet unlocked to *read* — each destructive action asks for the
// passphrase itself, because an open unlock window is not consent to change or destroy the key material.
export const WalletSettingsView = () => {
const navigate = useNavigate();
const { wallet, walletId, isLoading } = useSelectedWallet();
const { config } = useWalletConfig();
const { capabilities, kind } = usePermissions(walletId);
const { hasSeed } = useLockCountdown(walletId);
const { activate, rename } = useWalletLifecycle();
const [passphraseOpen, setPassphraseOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
if (!wallet || walletId == null) return <EmptyWallet isLoading={isLoading} />;
const xpubs = Object.entries(wallet.xpubs ?? {});
return (
<div className="h-full space-y-4 overflow-y-auto p-4">
<section className="max-w-2xl rounded-xl border border-border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<WalletNameField
wallet={wallet}
onSave={(name) => rename.mutateAsync({ walletId, name })}
isPending={rename.isPending}
/>
<p className="mt-0.5 text-xs text-muted-foreground">
{KIND_LABELS[wallet.kind]} · {wallet.network} · added {wallet.createdAt.slice(0, 10)}
</p>
</div>
<LockBadge walletId={walletId} walletName={wallet.name} />
</div>
<dl className="mt-4 space-y-1.5 text-xs">
<Row label="Backend">{kind ?? wallet.kind}</Row>
<Row label="Default derivation">BIP{wallet.defaultBip}</Row>
{wallet.fingerprint && (
<Row label="Fingerprint">
<code className="font-mono">{wallet.fingerprint}</code>
</Row>
)}
<Row label="Active">{wallet.isActive ? 'yes' : 'no'}</Row>
<Row label="Holds a seed">{hasSeed === true ? 'yes' : hasSeed === false ? 'no' : '—'}</Row>
</dl>
{!wallet.isActive && (
<Button
size="sm"
variant="outline"
className="mt-3"
onClick={() => activate.mutate(wallet.id)}
disabled={activate.isPending}
>
<Star className="mr-1.5 h-3.5 w-3.5" />
Make this the active wallet
</Button>
)}
</section>
{xpubs.length > 0 && (
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Account keys</h3>
<p className="mb-3 text-xs text-muted-foreground">
Extended public keys. They can watch this wallet but never spend it — safe to hand to a block explorer or an
accounting tool.
</p>
<div className="space-y-2">
{xpubs.map(([path, xpub]) => (
<CopyField key={path} value={xpub} label={path} wrap />
))}
</div>
</section>
)}
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
What this backend can do
</h3>
{capabilities.length === 0 ? (
<p className="text-xs text-muted-foreground">No capabilities reported.</p>
) : (
<div className="flex flex-wrap gap-1.5">
{capabilities.map((cap) => (
<span key={cap} className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{cap}
</span>
))}
</div>
)}
</section>
{hasSeed === true && (
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Key material</h3>
<p className="mb-3 text-xs text-muted-foreground">
Both of these ask for the passphrase on their own, whether or not the wallet is currently unlocked.
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => setPassphraseOpen(true)}>
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
Change passphrase
</Button>
<Button size="sm" variant="outline" onClick={() => setExportOpen(true)}>
<Eye className="mr-1.5 h-3.5 w-3.5" />
Show recovery phrase
</Button>
</div>
</section>
)}
<ChainSourceCard />
<RescanCard walletId={walletId} />
{config && (
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Deployment</h3>
<dl className="space-y-1.5 text-xs">
<Row label="Network">{config.network}</Row>
<Row label="Max unlock window">{formatMinutes(config.unlockTtlSec)}</Row>
<Row label="Store key">
{config.storeKeyConfigured ? 'configured' : <span className="text-destructive">missing</span>}
</Row>
</dl>
</section>
)}
<section className="max-w-2xl rounded-xl border border-destructive/40 p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-destructive">Danger</h3>
<p className="mb-3 text-xs text-muted-foreground">
{wallet.hasSeed
? 'Deleting erases the encrypted seed. Only your written recovery phrase can bring the coins back.'
: 'Deleting removes the connection. The node itself is untouched.'}
</p>
<Button size="sm" variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete wallet
</Button>
</section>
{passphraseOpen && (
<ChangePassphraseDialog open={passphraseOpen} onOpenChange={setPassphraseOpen} walletId={walletId} />
)}
{exportOpen && <ExportSeedDialog open={exportOpen} onOpenChange={setExportOpen} walletId={walletId} />}
{deleteOpen && (
<DeleteWalletDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
wallet={wallet}
// The deleted id must not stay in `?wallet=` — drop back to the bare section, which means
// "whatever is active now".
onDeleted={() => navigate(walletSectionPath('overview'), { replace: true })}
/>
)}
</div>
);
};
/** The wallet's name is the one field here that is purely a label — editing it in place, not in a dialog. */
const MAX_NAME = 64;
type WalletNameFieldProps = {
wallet: WalletSummary;
onSave: (name: string) => Promise<unknown>;
isPending: boolean;
};
const WalletNameField = ({ wallet, onSave, isPending }: WalletNameFieldProps) => {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(wallet.name);
const inputRef = useRef<HTMLInputElement>(null);
// Switching wallets while the editor is open would otherwise rename the NEW wallet to the old one's draft.
useEffect(() => {
setEditing(false);
setDraft(wallet.name);
}, [wallet.id, wallet.name]);
useEffect(() => {
if (editing) inputRef.current?.select();
}, [editing]);
const trimmed = draft.trim();
const dirty = trimmed !== wallet.name;
const commit = async () => {
if (!trimmed || !dirty) {
setDraft(wallet.name);
setEditing(false);
return;
}
try {
await onSave(trimmed);
setEditing(false);
} catch {
// The mutation already toasted; keep the editor open so the typed name isn't lost.
}
};
const cancel = () => {
setDraft(wallet.name);
setEditing(false);
};
if (!editing) {
return (
<div className="flex min-w-0 items-center gap-1.5">
<h3 className="truncate text-sm font-semibold">{wallet.name}</h3>
<button
type="button"
onClick={() => setEditing(true)}
aria-label="Rename wallet"
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Pencil className="h-3 w-3" />
</button>
</div>
);
}
return (
<div className="flex min-w-0 items-center gap-1.5">
<Input
ref={inputRef}
autoFocus
value={draft}
maxLength={MAX_NAME}
disabled={isPending}
onChange={(ev) => setDraft(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') void commit();
if (ev.key === 'Escape') cancel();
}}
className="h-7 max-w-56 text-sm font-semibold"
aria-label="Wallet name"
autoComplete="off"
spellCheck={false}
/>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={commit} disabled={isPending || !trimmed}>
<Check className="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={cancel} disabled={isPending}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
};
const formatMinutes = (seconds: number) => (seconds >= 60 ? `${Math.round(seconds / 60)} min` : `${seconds}s`);
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>
);
@@ -1,34 +0,0 @@
import { useWalletSection } from './useWalletSection';
import { OverviewView } from './OverviewView';
import { ReceiveView } from './ReceiveView';
import { SendView } from './SendView';
import { TransactionsView } from './TransactionsView';
import { CoinsView } from './CoinsView';
import { LightningView } from './LightningView';
import { WalletSettingsView } from './WalletSettingsView';
// Right panel of the /wallet workspace — renders the section named by the URL.
//
// Each section decides for itself whether the backend supports it, so a deep link to a section this wallet
// cannot serve explains itself rather than 404ing. The nav simply hides those entries.
export const WalletView = () => {
const section = useWalletSection();
switch (section) {
case 'receive':
return <ReceiveView />;
case 'send':
return <SendView />;
case 'transactions':
return <TransactionsView />;
case 'coins':
return <CoinsView />;
case 'lightning':
return <LightningView />;
case 'settings':
return <WalletSettingsView />;
default:
return <OverviewView />;
}
};
@@ -1,31 +0,0 @@
import { Bitcoin } from 'lucide-react';
import { WALLET_SECTIONS } from './shared';
import { Amount, UnitToggle } from './Amount';
import { LockBadge } from './LockBadge';
import { useWalletSection } from './useWalletSection';
import { useSelectedWallet } from './useSelectedWallet';
import { useBalances } from './useWalletData';
// Panel header for the right (wallet-view) panel: which section, which wallet, the spendable balance and
// the lock state. The lock indicator is compact here — it reports, it does not act, because the actions
// live in the nav and on the signing surfaces themselves.
export const WalletViewHeader = () => {
const section = useWalletSection();
const { wallet, walletId } = useSelectedWallet();
const { balances } = useBalances(walletId);
const label = WALLET_SECTIONS.find((s) => s.id === section)?.label ?? 'Wallet';
return (
<>
<Bitcoin className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-xs font-medium">
{label}
{wallet && <span className="ml-1.5 font-normal text-black/50">· {wallet.name}</span>}
</span>
{balances && <Amount sats={balances.onchainConfirmed} className="shrink-0 text-[10px] text-black/60" />}
<UnitToggle className="shrink-0" />
<LockBadge walletId={walletId} walletName={wallet?.name ?? 'this wallet'} compact />
</>
);
};
@@ -1,121 +0,0 @@
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>
);
};
@@ -1,121 +0,0 @@
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>
);
};
@@ -1,559 +0,0 @@
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-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">
<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>
);
@@ -1,119 +0,0 @@
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>
);
};
@@ -1,125 +0,0 @@
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-amber-500" />
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-amber-500/30 bg-amber-500/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-amber-600">
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>
);
};
@@ -1,170 +0,0 @@
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>
);
@@ -1,94 +0,0 @@
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-amber-500" />
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-amber-500/30 bg-amber-500/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>
);
};
@@ -1,387 +0,0 @@
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>
);
};
@@ -1,132 +0,0 @@
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>
);
};
@@ -1,151 +0,0 @@
import type { InvoiceState, PaymentStatus } from './shared';
// Display formatting for the Wallet panels.
//
// THE MILLISATOSHI RULE. A msat value arrives as a decimal string because 2.1e18 does not fit in a
// double. Every msat formatter below works on the digit string — no Number(), no parseInt, no
// BigInt round-trip through a float. Widening one to a number would silently corrupt large amounts,
// and the corruption would only show up on the one payment that mattered.
export type AmountUnit = 'sats' | 'btc';
const SATS_PER_BTC = 100_000_000;
/** Thousands separators on a bare digit string, left to right in groups of three from the end. */
function groupDigits(digits: string): string {
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* Split a non-negative digit string by 10^places without ever building a number.
* `'1234', 3` → `{ whole: '1', frac: '234' }`.
*/
function splitByPowerOfTen(digits: string, places: number): { whole: string; frac: string } {
const clean = digits.replace(/^0+(?=\d)/, '');
if (places === 0) return { whole: clean, frac: '' };
const padded = clean.padStart(places + 1, '0');
return { whole: padded.slice(0, -places), frac: padded.slice(-places) };
}
const trimZeros = (frac: string) => frac.replace(/0+$/, '');
// ── satoshis (numbers) ────────────────────────────────────────────────────────────────────────────
/** Sats with thousands separators. Negative amounts keep their sign — a spend should read as one. */
export function formatSats(sats: number | null | undefined): string {
if (sats == null || !Number.isFinite(sats)) return '—';
const neg = sats < 0;
return `${neg ? '-' : ''}${groupDigits(String(Math.abs(Math.trunc(sats))))}`;
}
/** BTC from sats, trailing zeros trimmed but never below two decimals, so amounts stay column-aligned. */
export function formatBtc(sats: number | null | undefined): string {
if (sats == null || !Number.isFinite(sats)) return '—';
const neg = sats < 0;
const abs = Math.abs(Math.trunc(sats));
const whole = Math.floor(abs / SATS_PER_BTC);
const frac = String(abs % SATS_PER_BTC).padStart(8, '0');
const shown = trimZeros(frac).padEnd(2, '0');
return `${neg ? '-' : ''}${groupDigits(String(whole))}.${shown}`;
}
/** The one entry point the views call, so a unit toggle flips every amount on screen at once. */
export function formatAmount(sats: number | null | undefined, unit: AmountUnit): string {
if (sats == null || !Number.isFinite(sats)) return '—';
return unit === 'btc' ? `${formatBtc(sats)} BTC` : `${formatSats(sats)} sats`;
}
// ── millisatoshis (strings) ───────────────────────────────────────────────────────────────────────
/** True when the string is a decimal integer we can format; anything else is passed through verbatim. */
const isDecimal = (value: string) => /^\d+$/.test(value);
/**
* A msat string as sats, keeping the sub-sat remainder when there is one. String arithmetic throughout —
* see the rule at the top of this file.
*/
export function formatMsatAsSats(msat: string | null | undefined): string {
if (msat == null) return '—';
const neg = msat.startsWith('-');
const digits = neg ? msat.slice(1) : msat;
if (!isDecimal(digits)) return msat;
const { whole, frac } = splitByPowerOfTen(digits, 3);
const remainder = trimZeros(frac);
return `${neg ? '-' : ''}${groupDigits(whole)}${remainder ? `.${remainder}` : ''}`;
}
/** A msat string as BTC — 10^11 msat to the bitcoin. */
export function formatMsatAsBtc(msat: string | null | undefined): string {
if (msat == null) return '—';
const neg = msat.startsWith('-');
const digits = neg ? msat.slice(1) : msat;
if (!isDecimal(digits)) return msat;
const { whole, frac } = splitByPowerOfTen(digits, 11);
const shown = trimZeros(frac).padEnd(2, '0');
return `${neg ? '-' : ''}${groupDigits(whole)}.${shown}`;
}
export function formatMsat(msat: string | null | undefined, unit: AmountUnit): string {
if (msat == null) return '—';
return unit === 'btc' ? `${formatMsatAsBtc(msat)} BTC` : `${formatMsatAsSats(msat)} sats`;
}
/** Sats → msat, for request bodies. Multiplying a string by 1000 is three appended zeros. */
export function satsToMsat(sats: number): string {
return `${Math.trunc(Math.abs(sats))}000`;
}
// ── everything else ───────────────────────────────────────────────────────────────────────────────
/** Unix seconds; 0 and null both mean "never" rather than 1970. */
export function formatTimestamp(unixSeconds: number | null | undefined): string {
if (!unixSeconds || unixSeconds <= 0) return '—';
const d = new Date(unixSeconds * 1000);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** A countdown as mm:ss — the unlock window, where the seconds genuinely matter. */
export function formatCountdown(seconds: number | null | undefined): string {
if (seconds == null || seconds <= 0) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
/** txids, addresses and pubkeys are unreadable in full and unmistakable at both ends. */
export function truncateMiddle(value: string | null | undefined, head = 10, tail = 8): string {
if (!value) return '—';
if (value.length <= head + tail + 1) return value;
return `${value.slice(0, head)}…${value.slice(-tail)}`;
}
export function formatConfirmations(confirmations: number): string {
if (confirmations <= 0) return 'unconfirmed';
if (confirmations === 1) return '1 conf';
if (confirmations >= 6) return '6+ confs';
return `${confirmations} confs`;
}
export const INVOICE_TONES: Record<InvoiceState, string> = {
open: 'bg-blue-500/10 text-blue-500',
settled: 'bg-emerald-500/10 text-emerald-500',
accepted: 'bg-amber-500/10 text-amber-500',
canceled: 'bg-muted text-muted-foreground',
expired: 'bg-muted text-muted-foreground',
};
export const PAYMENT_TONES: Record<PaymentStatus, string> = {
pending: 'bg-amber-500/10 text-amber-500',
succeeded: 'bg-emerald-500/10 text-emerald-500',
failed: 'bg-destructive/10 text-destructive',
};
// `copyToClipboard` used to be re-exported from here, wrapping navigator.clipboard with a try/catch. The
// helper in `helpers/clipboard` now does that and more — it falls back to execCommand on an insecure
// origin, where navigator.clipboard does not exist at all — so callers import it from there directly.
//
// Removed rather than left as a pass-through: the wrapper had become `copyToClipboard` calling
// `copyToClipboard`, which is a stack overflow on any Wallet copy button. Every re-export is a chance for
// exactly that, and this file is about formatting.
@@ -1,25 +0,0 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { PanelLeft, LayoutGrid } from 'lucide-react';
import { WalletNav } from './WalletNav';
import { WalletView } from './WalletView';
import { WalletViewHeader } from './WalletViewHeader';
export { WalletNav, WalletView };
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'wallet-nav',
name: 'Wallet',
icon: PanelLeft,
component: WalletNav,
availableOnPanel: false,
},
{
key: 'wallet-view',
name: 'Wallet',
icon: LayoutGrid,
component: WalletView,
header: WalletViewHeader,
availableOnPanel: false,
},
];
@@ -1,304 +0,0 @@
// Shared types/constants for the /wallet workspace panels.
//
// The wire shapes mirror src/servers/sidecar/wallet/types.ts and the WalletSummary projection in
// src/databases/officer_db/src/wallet/queries.ts. They are restated here rather than imported because the
// officerdev workspace has no path into src/servers — pulling the sidecar's module graph into the browser
// bundle would drag bitcoinjs-lib and the key handling along with it, which is exactly what must never
// reach the client. Keep this file in step with those two by hand; the field names are identical on
// purpose so a diff is a grep.
//
// UNITS — the one rule that matters. Satoshis are `number` (2.1e15, comfortably inside 2^53).
// Millisatoshis are decimal STRINGS (2.1e18 overflows a double) and must never be parsed into a JS
// number. format.ts does msat arithmetic on the digit string; use it rather than reaching for Number().
export const WALLET_SECTIONS = [
{ id: 'overview', label: 'Overview' },
{ id: 'receive', label: 'Receive' },
{ id: 'send', label: 'Send' },
{ id: 'transactions', label: 'Transactions' },
{ id: 'coins', label: 'Coins' },
{ id: 'lightning', label: 'Lightning' },
{ id: 'settings', label: 'Settings' },
] as const;
export type WalletSectionId = (typeof WALLET_SECTIONS)[number]['id'];
/** Where /wallet lands, and where an unrecognised section redirects to. */
export const DEFAULT_WALLET_SECTION: WalletSectionId = 'overview';
export const isWalletSection = (value: string | undefined): value is WalletSectionId =>
WALLET_SECTIONS.some((s) => s.id === value);
/**
* The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart.
* Which wallet is open rides along as `?wallet=<id>` — a query param rather than a second path segment,
* because it is orthogonal to the section and every panel reads it independently.
*/
export const walletSectionPath = (id: WalletSectionId, walletId?: number | null) =>
walletId == null ? `/wallet/${id}` : `/wallet/${id}?wallet=${walletId}`;
/** The search-param name holding the open wallet. Never holds anything secret — just a row id. */
export const WALLET_PARAM = 'wallet';
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
export type BackendKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
export type BitcoinNetwork = 'bitcoin' | 'testnet' | 'signet' | 'regtest';
/** Mirrors `Capability` in the sidecar. The UI shows a control only when its capability is present. */
export type Capability =
| 'onchainReceive'
| 'onchainSend'
| 'coinControl'
| 'psbt'
| 'bumpFee'
| 'sweep'
| 'accounts'
| 'lightningReceive'
| 'lightningSend'
| 'keysend'
| 'customPreimages'
| 'offers'
| 'channels'
| 'peers'
| 'routing'
| 'signMessage';
/** The browser-safe wallet projection — no config, no seed envelope, ever. */
export type WalletSummary = {
id: number;
name: string;
kind: BackendKind;
network: string;
fingerprint: string | null;
xpubs: Record<string, string> | null;
defaultBip: number;
isActive: boolean;
/** Whether this wallet holds a seed at all — i.e. whether unlock/lock apply to it. */
hasSeed: boolean;
createdAt: string;
};
export type WalletConfig = {
network: BitcoinNetwork;
unlockTtlSec: number;
/** Wallet creation is refused without it, so the UI blocks the form rather than failing on submit. */
storeKeyConfigured: boolean;
};
export type LockState = {
hasSeed: boolean;
unlocked: boolean;
secondsRemaining: number;
};
export type NodeInfo = {
kind: BackendKind;
pubkey: string | null;
alias: string | null;
version: string | null;
network: BitcoinNetwork;
blockHeight: number | null;
synced: boolean;
};
export type Balances = {
onchainConfirmed: number;
onchainUnconfirmed: number;
/**
* The part of `onchainConfirmed` the owner has frozen. Already counted in it, not deducted — the coins
* are still the wallet's; freezing is a spending policy. Subtract it to get what a send can actually
* use, which is what "Spendable" has to mean or a max-value send fails for no visible reason.
*/
onchainFrozen: number;
/** Null when the backend has no channels of its own. */
lightningBalance: number | null;
lightningInbound: number | null;
};
/**
* How old the numbers alongside this are, from a backend that caches its view of the chain. Null from a
* node backend, which answers from its own live state and has no age to report.
*
* `syncedAt: null` with a `lastError` is the case worth rendering carefully: the wallet has never been
* read successfully, so a zero balance means "unknown", not "empty".
*/
export type SyncState = {
/** Unix ms of the last successful chain read. */
syncedAt: number | null;
stale: boolean;
lastError: string | null;
/**
* Whether the chain source can search the chain from scratch at all — false for Esplora, which reads
* every address on every refresh and has nothing to catch up on. Gate the control on this, NOT on
* `rescan === null`: that is also what a source reports before its first scan.
*/
canRescan: boolean;
/** A deep rescan in flight, or the last one's outcome. Null from a chain source that cannot rescan. */
rescan: RescanState | null;
};
/**
* The node searching its whole UTXO set for this wallet's coins.
*
* Only ever non-null on NBXplorer, and it exists because an imported wallet on a freshly-tracked xpub
* reads as a confident zero: nothing errors, the balance is just empty until the node has looked. Four
* script variants, one at a time, minutes not seconds — which is precisely why it has to be on screen.
*/
export type RescanState = {
running: boolean;
done: number;
total: number;
current: AddressType | null;
found: number;
startedAt: number;
finishedAt: number | null;
error: string | null;
};
export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh';
/** What each script type is actually called out loud, for anything the owner reads rather than greps. */
export const ADDRESS_TYPE_LABELS: Record<AddressType, string> = {
p2wpkh: 'native segwit',
'p2sh-p2wpkh': 'wrapped segwit',
p2pkh: 'legacy',
p2tr: 'taproot',
};
export type OnchainTx = {
txid: string;
/** Net effect on this wallet in sats — negative for a spend. */
amount: number;
feeSats: number | null;
blockHeight: number | null;
timestamp: number | null;
confirmations: number;
label: string | null;
destAddresses: string[];
rawHex: string | null;
};
/** The sidecar overlays Officer's own `frozen` flag and address label onto the backend's UTXO. */
export type Utxo = {
txid: string;
vout: number;
amountSats: number;
address: string;
addressType: AddressType | null;
confirmations: number;
derivationPath: string | null;
frozen: boolean;
label: string | null;
};
export type FeeEstimates = {
fastestFee: number;
halfHourFee: number;
hourFee: number;
economyFee: number;
minimumFee: number;
};
export type SendCoinsResult = { txid: string; feeSats: number; rawHex: string | null };
export type InvoiceState = 'open' | 'settled' | 'canceled' | 'accepted' | 'expired';
export type Invoice = {
paymentHash: string;
bolt11: string;
amountMsat: string | null;
amountPaidMsat: string | null;
memo: string | null;
state: InvoiceState;
createdAt: number;
expiresAt: number | null;
settledAt: number | null;
preimage: string | null;
isKeysend: boolean;
isAmp: boolean;
};
export type DecodedInvoice = {
bolt11: string;
paymentHash: string;
amountMsat: string | null;
description: string | null;
destination: string;
timestamp: number;
expiry: number;
cltvExpiry: number | null;
routeHints: boolean;
features: string[];
};
export type PaymentStatus = 'pending' | 'succeeded' | 'failed';
export type Payment = {
paymentHash: string;
preimage: string | null;
amountMsat: string;
feeMsat: string;
status: PaymentStatus;
createdAt: number;
destination: string | null;
memo: string | null;
failureReason: string | null;
};
export type Channel = {
channelId: string;
channelPoint: string | null;
remotePubkey: string;
remoteAlias: string | null;
capacitySats: number;
localBalanceSats: number;
remoteBalanceSats: number;
active: boolean;
private: boolean;
status: string;
};
export type Peer = { pubkey: string; address: string; alias: string | null; inbound: boolean };
// ── Capability-driven section visibility ──────────────────────────────────────────────────────────
/**
* Which sections a backend can actually serve. Anything not listed here is hidden from the nav rather
* than rendered as a control that returns 501 — see the sidecar's `requireCap`.
*
* `overview`, `transactions` and `settings` are unconditional: getInfo/getBalances/getTransactions are
* on the base interface with no capability guard, and settings is wallet lifecycle, not a backend call.
*/
export function sectionAvailable(section: WalletSectionId, caps: readonly Capability[]): boolean {
const has = (c: Capability) => caps.includes(c);
switch (section) {
case 'receive':
return has('onchainReceive') || has('lightningReceive');
case 'send':
return has('onchainSend') || has('lightningSend');
case 'coins':
return has('coinControl');
case 'lightning':
return has('lightningReceive') || has('lightningSend') || has('channels') || has('peers');
default:
return true;
}
}
export const KIND_LABELS: Record<BackendKind, string> = {
onchain: 'On-chain (self-custodial)',
lnd: 'LND',
'cln-rest': 'Core Lightning',
lndhub: 'LNDHub',
nwc: 'Nostr Wallet Connect',
};
/** Networks other than mainnet get a visible badge — sending testnet coins by mistake is a bad day. */
export const NETWORK_TONES: Record<string, string> = {
bitcoin: 'bg-amber-500/15 text-amber-600',
testnet: 'bg-emerald-500/15 text-emerald-600',
signet: 'bg-violet-500/15 text-violet-600',
regtest: 'bg-slate-500/15 text-slate-600',
};
@@ -1,34 +0,0 @@
import type { AmountUnit } from './format';
import { useGlobal } from 'hooks/useGlobal';
// sats ⇄ BTC, shared across every wallet panel.
//
// A display preference, not a selection — it names no entity and belongs in no URL (see the audit's
// "view toggles over tiny fixed sets"). It lives in useGlobal so the nav and the view agree instantly,
// and is mirrored to localStorage so it survives a reload.
const STORAGE_KEY = 'wallet:amount-unit';
const initial = (): AmountUnit => {
try {
return localStorage.getItem(STORAGE_KEY) === 'btc' ? 'btc' : 'sats';
} catch {
return 'sats';
}
};
export function useAmountUnit() {
const [unit, setUnit] = useGlobal<AmountUnit>('WALLET_AMOUNT_UNIT', initial);
const toggle = () => {
const next: AmountUnit = unit === 'sats' ? 'btc' : 'sats';
setUnit(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
/* private mode — the preference just doesn't persist */
}
};
return { unit, toggle };
}
@@ -1,46 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router';
// Coin control selection, held in `?coins=txid:vout,txid:vout`.
//
// The Coins section picks the inputs and the Send section spends them — two panels that must agree on a
// set. The URL is how they agree (docs/navigation-audit.md): no channel, no shared store, and the
// selection survives a reload and can be handed to someone as a link.
//
// Outpoints are public transaction data, so there is nothing sensitive about putting them in an address
// bar — unlike anything else this app handles.
const PARAM = 'coins';
const OUTPOINT = /^[0-9a-f]{64}:\d+$/i;
export function useCoinSelection() {
const [params, setParams] = useSearchParams();
const raw = params.get(PARAM) ?? '';
const selected = useMemo(() => raw.split(',').filter((o) => OUTPOINT.test(o)), [raw]);
const write = useCallback(
(next: string[]) => {
setParams(
(prev) => {
const updated = new URLSearchParams(prev);
if (next.length === 0) updated.delete(PARAM);
else updated.set(PARAM, next.join(','));
return updated;
},
{ replace: true },
);
},
[setParams],
);
const toggle = useCallback(
(outpoint: string) =>
write(selected.includes(outpoint) ? selected.filter((o) => o !== outpoint) : [...selected, outpoint]),
[selected, write],
);
const clear = useCallback(() => write([]), [write]);
return { selected, toggle, clear, set: write };
}
@@ -1,47 +0,0 @@
import { useEffect, useState } from 'react';
import { useLockState } from './useWalletData';
// The unlock window, as a second-by-second countdown.
//
// The sidecar's `secondsRemaining` is a snapshot taken when the request was served, and re-polling once a
// second to animate a clock would be absurd. So the poll re-syncs the truth every ten seconds and this
// hook interpolates between syncs from React Query's `dataUpdatedAt`, which is the wall time the snapshot
// arrived. Drift is bounded by the poll interval and always errs towards showing LESS time than remains —
// a countdown that overstates the window is the one that surprises you mid-signature.
//
// When it reaches zero the UI flips to locked immediately rather than waiting for the next poll to agree.
// Nothing else is invalidated when that happens: every read in this app works fine against a locked
// wallet, and blowing the caches away would make an expiring timer look like a connection failure.
export type LockCountdown = {
/** Null while the first lock-state request is in flight, or when no wallet is selected. */
hasSeed: boolean | null;
unlocked: boolean;
secondsRemaining: number;
isLoading: boolean;
};
export function useLockCountdown(walletId: number | null): LockCountdown {
const { lock, dataUpdatedAt, isLoading } = useLockState(walletId);
const [now, setNow] = useState(() => Date.now());
const ticking = lock?.unlocked === true;
useEffect(() => {
if (!ticking) return;
const timer = setInterval(() => setNow(Date.now()), 1_000);
return () => clearInterval(timer);
}, [ticking]);
if (!lock) return { hasSeed: null, unlocked: false, secondsRemaining: 0, isLoading };
const elapsed = dataUpdatedAt ? Math.floor((now - dataUpdatedAt) / 1000) : 0;
const secondsRemaining = Math.max(0, lock.secondsRemaining - Math.max(0, elapsed));
return {
hasSeed: lock.hasSeed,
unlocked: lock.unlocked && secondsRemaining > 0,
secondsRemaining,
isLoading,
};
}
@@ -1,39 +0,0 @@
import type { WalletSummary } from './shared';
import { useSearchParams } from 'react-router';
import { WALLET_PARAM } from './shared';
import { useWallets } from './useWalletData';
// Which wallet is open lives in `?wallet=<id>`, read independently by every panel — never passed between
// them over a channel. See docs/navigation-audit.md.
//
// With no param, the sidecar's own `isActive` wallet wins, then the first in the list. That fallback is a
// display default, not a selection: the URL stays bare so a shared /wallet link means "whatever is active
// right now" rather than pinning whichever wallet the sender happened to have open.
export type SelectedWallet = {
walletId: number | null;
wallet: WalletSummary | null;
wallets: WalletSummary[];
/** True when the id came from the URL rather than the active-wallet fallback. */
isPinned: boolean;
isLoading: boolean;
};
export function useSelectedWallet(): SelectedWallet {
const [params] = useSearchParams();
const { wallets, isLoading } = useWallets();
const raw = params.get(WALLET_PARAM);
const pinnedId = raw && /^\d+$/.test(raw) ? Number(raw) : null;
const pinned = pinnedId == null ? null : (wallets.find((w) => w.id === pinnedId) ?? null);
const fallback = wallets.find((w) => w.isActive) ?? wallets[0] ?? null;
const wallet = pinned ?? fallback;
return {
walletId: wallet?.id ?? null,
wallet,
wallets,
isPinned: pinned != null,
isLoading,
};
}
@@ -1,558 +0,0 @@
import type {
Balances,
BackendKind,
Capability,
Channel,
DecodedInvoice,
FeeEstimates,
Invoice,
LockState,
NodeInfo,
OnchainTx,
Payment,
Peer,
RescanState,
SendCoinsResult,
SyncState,
Utxo,
WalletConfig,
WalletSummary,
} from './shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
// Data layer for the /wallet panels. Everything talks to the officer-wallet sidecar through the
// /api/wallet auth proxy, which holds no key material of its own.
//
// THE WALLET IS USUALLY LOCKED, AND THAT IS THE NORMAL STATE. Every read below — balances, history,
// receive addresses, coin control, invoices — works against a locked wallet, because a locked wallet is
// still a watch-only wallet. Only signing needs the seed. So nothing here is gated on the lock, and no
// query is invalidated when the unlock window expires: the numbers on screen do not become wrong.
//
// PASSPHRASES ARE NEVER CACHED. The unlock/export/delete/change mutations take the passphrase as a call
// argument and hand it straight to the POST body. React Query stores mutation *variables* on the
// mutation object, so these are deliberately fire-and-forget: nothing here keeps a reference after the
// request resolves, and no passphrase is ever a query key or a query result.
const ROOT_KEY = ['wallet'] as const;
/** Balances move with the chain; a 20s poll is live enough without hammering Esplora. */
const BALANCE_POLL_MS = 20_000;
/** The lock countdown is rendered locally from `secondsRemaining`; this only re-syncs the truth. */
const LOCK_POLL_MS = 10_000;
/** History and coins are cheaper to refresh on demand than to poll hard. */
const HISTORY_POLL_MS = 60_000;
/** While a rescan runs, the balance response doubles as its progress feed — poll it like one. */
const RESCAN_POLL_MS = 4_000;
const EMPTY_WALLETS: WalletSummary[] = [];
const EMPTY_CAPS: Capability[] = [];
const EMPTY_TXS: OnchainTx[] = [];
const EMPTY_UTXOS: Utxo[] = [];
const EMPTY_INVOICES: Invoice[] = [];
const EMPTY_PAYMENTS: Payment[] = [];
const EMPTY_CHANNELS: Channel[] = [];
const EMPTY_PEERS: Peer[] = [];
const base = (id: number) => `/wallet/_officer/wallets/${id}`;
// ── deployment + wallet list ──────────────────────────────────────────────────────────────────────
export function useWalletConfig() {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'config'] as const,
queryFn: () => get<WalletConfig>('/wallet/_officer/config'),
// Network and store-key config change only with a sidecar restart.
staleTime: 5 * 60_000,
});
return { config: query.data ?? null, isLoading: query.isLoading, error: query.error };
}
/** The wallet list does not poll — it only changes when the owner creates, renames or deletes one. */
export function useWallets() {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'wallets'] as const,
queryFn: () => get<{ wallets: WalletSummary[] }>('/wallet/_officer/wallets'),
staleTime: 30_000,
});
return {
wallets: query.data?.wallets ?? EMPTY_WALLETS,
isLoading: query.isLoading,
error: query.error,
};
}
// ── per-wallet reads ──────────────────────────────────────────────────────────────────────────────
/**
* The lock state, polled. The countdown a user sees is interpolated locally in useLockCountdown — this
* query is the periodic re-sync, not the clock.
*/
export function useLockState(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'lock-state', walletId] as const,
queryFn: () => get<LockState>(`${base(walletId!)}/lock-state`),
enabled: walletId != null,
refetchInterval: LOCK_POLL_MS,
staleTime: LOCK_POLL_MS - 1_000,
});
return { lock: query.data ?? null, dataUpdatedAt: query.dataUpdatedAt, isLoading: query.isLoading };
}
export function usePermissions(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'capabilities', walletId] as const,
queryFn: () => get<{ kind: BackendKind; capabilities: Capability[] }>(`${base(walletId!)}/capabilities`),
enabled: walletId != null,
// A backend's capability set is fixed for the life of the wallet.
staleTime: 10 * 60_000,
});
return {
kind: query.data?.kind ?? null,
capabilities: query.data?.capabilities ?? EMPTY_CAPS,
isLoading: query.isLoading,
error: query.error,
};
}
export function useWalletInfo(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'info', walletId] as const,
queryFn: () => get<{ info: NodeInfo }>(`${base(walletId!)}/info`),
enabled: walletId != null,
refetchInterval: BALANCE_POLL_MS,
staleTime: BALANCE_POLL_MS - 1_000,
});
return { info: query.data?.info ?? null, isLoading: query.isLoading, error: query.error };
}
// The three reads below carry a `sync` block from the sidecar. An on-chain wallet serves the last
// snapshot it managed to read and refreshes behind the response, so `isLoading` no longer means "we know
// nothing" — after the first successful read it is never true again, and the honest signal for "these
// numbers are old" or "we could not reach the chain" is `sync`, not the query state.
export function useBalances(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'balances', walletId] as const,
queryFn: () => get<{ balances: Balances; sync: SyncState | null }>(`${base(walletId!)}/balances`),
enabled: walletId != null,
// Faster while the node is rescanning: this response carries the rescan's progress, and a counter
// that moves once every twenty seconds reads as a hung one. Back to the ordinary poll when it ends.
refetchInterval: (query) => (query.state.data?.sync?.rescan?.running ? RESCAN_POLL_MS : BALANCE_POLL_MS),
staleTime: BALANCE_POLL_MS - 1_000,
});
return {
balances: query.data?.balances ?? null,
sync: query.data?.sync ?? null,
isLoading: query.isLoading,
error: query.error,
};
}
export function useTransactions(walletId: number | null, limit = 50) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'transactions', walletId, limit] as const,
queryFn: () =>
get<{ transactions: OnchainTx[]; sync: SyncState | null }>(`${base(walletId!)}/transactions?limit=${limit}`),
enabled: walletId != null,
refetchInterval: HISTORY_POLL_MS,
staleTime: 15_000,
});
return {
transactions: query.data?.transactions ?? EMPTY_TXS,
sync: query.data?.sync ?? null,
isLoading: query.isLoading,
error: query.error,
};
}
export function useUtxos(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'utxos', walletId] as const,
queryFn: () => get<{ utxos: Utxo[]; sync: SyncState | null }>(`${base(walletId!)}/utxos`),
enabled: walletId != null && enabled,
refetchInterval: HISTORY_POLL_MS,
staleTime: 15_000,
});
return {
utxos: query.data?.utxos ?? EMPTY_UTXOS,
sync: query.data?.sync ?? null,
isLoading: query.isLoading,
error: query.error,
};
}
export function useFees(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'fees', walletId] as const,
queryFn: () => get<{ fees: FeeEstimates }>(`${base(walletId!)}/fees`),
enabled: walletId != null && enabled,
refetchInterval: 60_000,
staleTime: 30_000,
});
return { fees: query.data?.fees ?? null, isLoading: query.isLoading, error: query.error };
}
/**
* A receive address. `peek` returns the current unused address without advancing the derivation index —
* which is what a screen that merely *displays* an address must do, or every render burns an address.
*/
type AddressResponse = { address: string; type: string };
export function useReceiveAddress(walletId: number | null, enabled = true) {
const { get } = useClient();
const qc = useQueryClient();
const key = [...ROOT_KEY, 'address', walletId] as const;
const query = useQuery({
queryKey: key,
queryFn: () => get<AddressResponse>(`${base(walletId!)}/address?peek=true`),
enabled: walletId != null && enabled,
staleTime: 60_000,
});
// `peek=false` CONSUMES: the sidecar advances its issued mark so this address is never handed out
// again. Refetching the peeked query returns the same address forever, which is why this is a separate
// call and not `refetch()` — that was the bug behind a "new address" button that changed nothing.
const next = useMutation({
mutationFn: () => get<AddressResponse>(`${base(walletId!)}/address?peek=false`),
onSuccess: (data) => qc.setQueryData(key, data),
onError: (err) => toast.error(errorMessage(err, 'Could not derive a new address')),
});
return {
address: query.data?.address ?? null,
addressType: query.data?.type ?? null,
isLoading: query.isLoading,
error: query.error,
next,
};
}
export function useInvoices(walletId: number | null, enabled = true, limit = 50) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'invoices', walletId, limit] as const,
queryFn: () => get<{ invoices: Invoice[] }>(`${base(walletId!)}/invoices?limit=${limit}`),
enabled: walletId != null && enabled,
refetchInterval: 30_000,
staleTime: 15_000,
});
return { invoices: query.data?.invoices ?? EMPTY_INVOICES, isLoading: query.isLoading, error: query.error };
}
export function usePayments(walletId: number | null, enabled = true, limit = 50) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'payments', walletId, limit] as const,
queryFn: () => get<{ payments: Payment[] }>(`${base(walletId!)}/payments?limit=${limit}`),
enabled: walletId != null && enabled,
refetchInterval: 30_000,
staleTime: 15_000,
});
return { payments: query.data?.payments ?? EMPTY_PAYMENTS, isLoading: query.isLoading, error: query.error };
}
export function useChannels(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'channels', walletId] as const,
queryFn: () => get<{ channels: Channel[] }>(`${base(walletId!)}/channels`),
enabled: walletId != null && enabled,
refetchInterval: 30_000,
staleTime: 15_000,
});
return { channels: query.data?.channels ?? EMPTY_CHANNELS, isLoading: query.isLoading, error: query.error };
}
export function usePeers(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'peers', walletId] as const,
queryFn: () => get<{ peers: Peer[] }>(`${base(walletId!)}/peers`),
enabled: walletId != null && enabled,
refetchInterval: 30_000,
staleTime: 15_000,
});
return { peers: query.data?.peers ?? EMPTY_PEERS, isLoading: query.isLoading, error: query.error };
}
// ── wallet lifecycle mutations ────────────────────────────────────────────────────────────────────
export type CreateWalletInput = {
name: string;
kind: BackendKind;
network?: string;
/** onchain only. Omit to have the sidecar generate one — that is the only case that returns a mnemonic. */
mnemonic?: string;
words?: 12 | 24;
/** onchain only, and never stored anywhere on this side. */
passphrase?: string;
bip39Passphrase?: string;
defaultBip?: number;
config?: Record<string, unknown>;
makeActive?: boolean;
};
export type CreateWalletResult = { wallet: WalletSummary; mnemonic?: string };
export function useWalletLifecycle() {
const { post, patch, delete: del } = useClient();
const qc = useQueryClient();
const invalidateList = () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'wallets'] });
const create = useMutation({
mutationFn: (input: CreateWalletInput) => post<CreateWalletResult>('/wallet/_officer/wallets', input),
// Deliberately no onSuccess toast/cache write with the response: it may carry the mnemonic, and the
// only place that is allowed to exist is the backup modal's local state. The caller invalidates.
onSuccess: invalidateList,
onError: (err) => toast.error(errorMessage(err, 'Could not create the wallet')),
});
const activate = useMutation({
mutationFn: (walletId: number) => post<{ ok: true }>(`${base(walletId)}/activate`),
onSuccess: () => {
invalidateList();
toast.success('Active wallet changed');
},
onError: (err) => toast.error(errorMessage(err, 'Could not activate the wallet')),
});
const rename = useMutation({
// Only the label. The name is the one wallet field with no bearing on the key material, which is why
// this needs neither the passphrase nor an unlocked wallet.
mutationFn: ({ walletId, name }: { walletId: number; name: string }) =>
patch<{ wallet: WalletSummary }>(`/wallet/_officer/wallets/${walletId}`, { name }),
onSuccess: () => {
invalidateList();
toast.success('Wallet renamed');
},
onError: (err) => toast.error(errorMessage(err, 'Could not rename the wallet')),
});
const remove = useMutation({
// The passphrase is required for a seeded wallet even when it is already unlocked — an open session
// must not be enough to destroy the only copy of the key material.
mutationFn: ({
walletId,
passphrase,
confirmName,
}: {
walletId: number;
passphrase?: string;
confirmName: string;
}) =>
del<{ ok: true }>(`/wallet/_officer/wallets/${walletId}`, { confirmName, ...(passphrase ? { passphrase } : {}) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ROOT_KEY });
toast.success('Wallet deleted');
},
onError: (err) => toast.error(errorMessage(err, 'Could not delete the wallet')),
});
return { create, activate, rename, remove };
}
// ── lock lifecycle ────────────────────────────────────────────────────────────────────────────────
export function useLockActions(walletId: number | null) {
const { post } = useClient();
const qc = useQueryClient();
const refreshLock = () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'lock-state', walletId] });
const unlock = useMutation({
// `passphrase` goes from a controlled input straight into this body and the caller clears its state
// immediately after. It is never persisted, cached or logged on this side.
mutationFn: ({ passphrase, ttlSec }: { passphrase: string; ttlSec?: number }) =>
post<{ ok: true; unlocked: boolean; secondsRemaining: number }>(`${base(walletId!)}/unlock`, {
passphrase,
ttlSec,
}),
onSuccess: () => {
refreshLock();
toast.success('Wallet unlocked');
},
onError: (err) => toast.error(errorMessage(err, 'Could not unlock — check the passphrase')),
});
const lock = useMutation({
mutationFn: () => post<{ ok: true; unlocked: false }>(`${base(walletId!)}/lock`),
onSuccess: () => {
refreshLock();
toast.success('Wallet locked');
},
onError: (err) => toast.error(errorMessage(err, 'Could not lock the wallet')),
});
const changePassphrase = useMutation({
mutationFn: (input: { oldPassphrase: string; newPassphrase: string }) =>
post<{ ok: true }>(`${base(walletId!)}/passphrase`, input),
onSuccess: () => {
refreshLock();
toast.success('Passphrase changed — the wallet was re-locked');
},
onError: (err) => toast.error(errorMessage(err, 'Could not change the passphrase')),
});
const exportSeed = useMutation({
mutationFn: (input: { passphrase: string }) =>
post<{ mnemonic: string; hasBip39Passphrase: boolean }>(`${base(walletId!)}/export-seed`, input),
// No cache write and no toast carrying the result — the mnemonic lives only in the dialog's state.
onError: (err) => toast.error(errorMessage(err, 'Could not export the seed')),
});
return { unlock, lock, changePassphrase, exportSeed };
}
// ── operations ────────────────────────────────────────────────────────────────────────────────────
export type SendInput = {
address: string;
amountSats?: number;
sendAll?: boolean;
satPerVbyte: number;
outpoints?: string[];
label?: string;
};
export function useWalletOperations(walletId: number | null) {
const { post } = useClient();
const qc = useQueryClient();
const invalidateAll = () => qc.invalidateQueries({ queryKey: ROOT_KEY });
const send = useMutation({
mutationFn: (input: SendInput) => post<SendCoinsResult>(`${base(walletId!)}/send`, input),
onSuccess: (result) => {
invalidateAll();
toast.success(`Broadcast ${result.txid.slice(0, 12)}…`);
},
onError: (err) => toast.error(errorMessage(err, 'Could not send')),
});
const freeze = useMutation({
mutationFn: (input: { outpoint: string; frozen: boolean; reason?: string }) =>
post<{ ok: true }>(`${base(walletId!)}/utxos/freeze`, input),
onSuccess: () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'utxos', walletId] }),
onError: (err) => toast.error(errorMessage(err, 'Could not change the freeze flag')),
});
const createInvoice = useMutation({
mutationFn: (input: { amountMsat?: string; memo?: string; expirySeconds?: number; private?: boolean }) =>
post<{ invoice: Invoice }>(`${base(walletId!)}/invoices`, input),
onSuccess: () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'invoices', walletId] }),
onError: (err) => toast.error(errorMessage(err, 'Could not create the invoice')),
});
const decode = useMutation({
mutationFn: (bolt11: string) => post<{ decoded: DecodedInvoice }>(`${base(walletId!)}/decode`, { bolt11 }),
onError: (err) => toast.error(errorMessage(err, 'Could not decode that invoice')),
});
const pay = useMutation({
mutationFn: (input: { bolt11: string; amountMsat?: string; feeLimitMsat?: string }) =>
post<{ payment: Payment }>(`${base(walletId!)}/pay`, input),
onSuccess: (data) => {
invalidateAll();
if (data.payment.status === 'failed') toast.error(data.payment.failureReason ?? 'Payment failed');
else toast.success(data.payment.status === 'succeeded' ? 'Payment sent' : 'Payment in flight');
},
onError: (err) => toast.error(errorMessage(err, 'Could not pay')),
});
const keysend = useMutation({
mutationFn: (input: { destination: string; amountMsat: string; message?: string }) =>
post<{ payment: Payment }>(`${base(walletId!)}/keysend`, input),
onSuccess: () => {
invalidateAll();
toast.success('Keysend sent');
},
onError: (err) => toast.error(errorMessage(err, 'Keysend failed')),
});
const sign = useMutation({
mutationFn: (message: string) => post<{ signature: string }>(`${base(walletId!)}/sign`, { message }),
onError: (err) => toast.error(errorMessage(err, 'Could not sign — the wallet may be locked')),
});
const verify = useMutation({
mutationFn: (input: { message: string; signature: string }) =>
post<{ valid: boolean; pubkey: string | null }>(`${base(walletId!)}/verify`, input),
onError: (err) => toast.error(errorMessage(err, 'Could not verify')),
});
const setLabel = useMutation({
mutationFn: (input: { kind: 'address' | 'tx'; ref: string; label: string }) =>
post<{ ok: true }>(`${base(walletId!)}/labels`, input),
onSuccess: () => invalidateAll(),
onError: (err) => toast.error(errorMessage(err, 'Could not save the label')),
});
// Returns as soon as the scan is queued — it takes minutes. Progress arrives on the `sync.rescan` block
// of the balances poll, which speeds up on its own while one is running, so the invalidation here is
// only to put the first "scanning…" on screen without waiting out the current interval.
const rescan = useMutation({
mutationFn: (input: { gapLimit?: number } = {}) =>
post<{ rescan: RescanState }>(`${base(walletId!)}/rescan`, input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'balances', walletId] });
toast.success('Scanning the chain for this wallet’s coins — this takes a few minutes');
},
onError: (err) => toast.error(errorMessage(err, 'Could not start the scan')),
});
return { send, freeze, createInvoice, decode, pay, keysend, sign, verify, setLabel, rescan };
}
/**
* useClient rejects with `{status, message}` rather than an Error, and the sidecar's message is a JSON
* body — unwrap both so a toast reads "this wallet does not support coin control" rather than
* "[object Object]". Never called with anything that could contain a passphrase: the sidecar's error
* bodies are messages and codes only.
*/
export function errorMessage(err: unknown, fallback: string): string {
const raw = typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : '';
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw) as { error?: string };
if (parsed.error) return parsed.error;
} catch {
/* not JSON — use it as-is */
}
return raw || fallback;
}
@@ -1,10 +0,0 @@
import { useParams } from 'react-router';
import { DEFAULT_WALLET_SECTION, isWalletSection, type WalletSectionId } from './shared';
// The URL names the open section — not a panel channel. See docs/navigation-audit.md. WalletScreen
// redirects anything unrecognised, so the fallback here only covers the instant before that lands.
export function useWalletSection(): WalletSectionId {
const { section } = useParams();
return isWalletSection(section) ? section : DEFAULT_WALLET_SECTION;
}
-4
View File
@@ -66,10 +66,6 @@ export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
// Same for /wallet. WALLET_PARAM is exported too so anything linking into the screen from outside spells
// the wallet query param the same way the panels read it.
export { DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSection, WALLET_PARAM } from './apps/Wallet/shared';
export type { WalletSectionId } from './apps/Wallet/shared';
export {
useFilesAPI,
useTasks,