add the bitcoin wallet sidecar and ui

the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.

officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.

no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.

not reviewed line by line; assembled and verified to build, not audited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 06:48:06 +00:00
co-authored by Claude Opus 5
parent 5ee56e736b
commit f8826e4c24
69 changed files with 11867 additions and 0 deletions
@@ -14,6 +14,7 @@ import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { useAppRegistry } from './useAppRegistry';
import { useUserApps } from 'state/useUserApps';
@@ -37,6 +38,7 @@ const apps = [
...headscaleMetas,
...transmissionMetas,
...invoicesMetas,
...walletMetas,
...monitorMetas,
];
@@ -0,0 +1,46 @@
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>
);
};
@@ -0,0 +1,128 @@
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 { useSelectedWallet } from './useSelectedWallet';
import { useCoinSelection } from './useCoinSelection';
import { useCapabilities, 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 } = useCapabilities(walletId);
const supported = capabilities.includes('coinControl');
const { utxos, 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" />
{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">
<p className="text-sm font-medium">No coins</p>
<p className="text-xs text-muted-foreground">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>
);
};
@@ -0,0 +1,45 @@
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { copyToClipboard } from './format';
// 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>
);
};
@@ -0,0 +1,36 @@
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>
);
@@ -0,0 +1,146 @@
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 { useCapabilities, 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 } = useCapabilities(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>
);
@@ -0,0 +1,132 @@
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}
/>
)}
</>
);
};
@@ -0,0 +1,164 @@
import type { ReactNode } from 'react';
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 { useSelectedWallet } from './useSelectedWallet';
import { useBalances, useCapabilities, 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.
export const OverviewView = () => {
const { wallet, walletId, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId);
const { balances, 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>
<UnitToggle />
</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={
balances && balances.onchainUnconfirmed !== 0
? `${balances.onchainUnconfirmed > 0 ? '+' : ''}${formatSats(balances.onchainUnconfirmed)} sats unconfirmed`
: undefined
}
/>
{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>
);
@@ -0,0 +1,115 @@
import { useState } from 'react';
import { Loader2, Plus, RefreshCw, 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 { useCapabilities, 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 } = useCapabilities(walletId);
const canOnchain = capabilities.includes('onchainReceive');
const canLightning = capabilities.includes('lightningReceive');
const { address, addressType, isLoading: addressLoading, refetch } = 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={() => refetch()}
className="flex items-center gap-1 text-[11px] text-primary hover:underline"
>
<RefreshCw className="h-3 w-3" />
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>
</>
) : (
<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>
);
};
@@ -0,0 +1,294 @@
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, useCapabilities, 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 } = useCapabilities(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 } = useCapabilities(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>
<Amount sats={balances?.onchainConfirmed ?? null} className="text-sm font-semibold" />
</div>
</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>
);
};
@@ -0,0 +1,77 @@
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 { 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, 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) {
return (
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
<p className="text-sm font-medium">Nothing here yet</p>
<p className="text-xs text-muted-foreground">Transactions appear as soon as they hit the mempool.</p>
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<ul className="divide-y divide-border">
{transactions.map((tx) => (
<TransactionRow key={tx.txid} tx={tx} />
))}
</ul>
</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>
);
};
@@ -0,0 +1,178 @@
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, useCapabilities, 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 } = useCapabilities(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>
);
};
@@ -0,0 +1,181 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Eye, KeyRound, Star, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { KIND_LABELS, walletSectionPath } from './shared';
import { truncateMiddle } from './format';
import { CopyField } from './CopyField';
import { EmptyWallet } from './EmptyWallet';
import { LockBadge } from './LockBadge';
import { useSelectedWallet } from './useSelectedWallet';
import { useLockCountdown } from './useLockCountdown';
import { useCapabilities, 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 } = useCapabilities(walletId);
const { hasSeed } = useLockCountdown(walletId);
const { activate } = 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">
<h3 className="truncate text-sm font-semibold">{wallet.name}</h3>
<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>
)}
{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="Esplora">
<span className="font-mono text-[11px]">{truncateMiddle(config.esploraUrl, 28, 12)}</span>
</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>
);
};
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>
);
@@ -0,0 +1,34 @@
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 />;
}
};
@@ -0,0 +1,31 @@
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 />
</>
);
};
@@ -0,0 +1,121 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLockActions } from '../useWalletData';
// Re-encrypt the seed under a new passphrase. The sidecar decrypts with the old one and re-seals with the
// new one, then re-locks — there is no window where the wallet is left open as a side effect.
//
// Both passphrases live only in this component's state and are cleared as soon as the request settles.
type ChangePassphraseDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
export const ChangePassphraseDialog = ({ open, onOpenChange, walletId }: ChangePassphraseDialogProps) => {
const { changePassphrase } = useLockActions(walletId);
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
const [oldPassphrase, setOldPassphrase] = useState('');
const [newPassphrase, setNewPassphrase] = useState('');
const [confirm, setConfirm] = useState('');
const clearSecrets = () => {
setOldPassphrase('');
setNewPassphrase('');
setConfirm('');
};
const close = () => {
clearSecrets();
onOpenChange(false);
};
const mismatch = confirm.length > 0 && confirm !== newPassphrase;
const canSubmit =
!!oldPassphrase && newPassphrase.length >= 8 && confirm === newPassphrase && !changePassphrase.isPending;
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!canSubmit) return;
try {
await changePassphrase.mutateAsync({ oldPassphrase, newPassphrase });
close();
} finally {
// Cleared on failure too — a rejected passphrase is still a passphrase held in memory.
clearSecrets();
}
};
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-w-md">
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>Change passphrase</DialogTitle>
<DialogDescription>
The seed is re-sealed under the new passphrase and the wallet is locked again. Lose it and the coins go
with it there is no reset.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="wallet-pass-old">Current passphrase</Label>
{/* Controlled input → request body → cleared. Never written to any store. */}
<Input
id="wallet-pass-old"
type="password"
autoFocus
autoComplete="off"
value={oldPassphrase}
onChange={(ev) => setOldPassphrase(ev.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-pass-new">New passphrase</Label>
{/* Controlled input → request body → cleared. Never written to any store. */}
<Input
id="wallet-pass-new"
type="password"
autoComplete="new-password"
value={newPassphrase}
onChange={(ev) => setNewPassphrase(ev.target.value)}
/>
<p className="text-[11px] text-muted-foreground">At least 8 characters.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-pass-confirm">Confirm new passphrase</Label>
{/* Controlled input → request body → cleared. Never written to any store. */}
<Input
id="wallet-pass-confirm"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(ev) => setConfirm(ev.target.value)}
/>
{mismatch && <p className="text-[11px] text-destructive">These do not match.</p>}
</div>
</div>
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{changePassphrase.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Change
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,121 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { satsToMsat } from '../format';
import { CopyField } from '../CopyField';
import { useWalletOperations } from '../useWalletData';
// Create a BOLT11 invoice. Receiving needs no key material, so this works while the wallet is locked.
//
// The amount is entered in sats and converted with satsToMsat, which appends three zeros to the digit
// string rather than multiplying — the request field is a msat STRING and must stay one.
type CreateInvoiceDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
export const CreateInvoiceDialog = ({ open, onOpenChange, walletId }: CreateInvoiceDialogProps) => {
const { createInvoice } = useWalletOperations(walletId);
const [amountSats, setAmountSats] = useState('');
const [memo, setMemo] = useState('');
const [expiryMinutes, setExpiryMinutes] = useState('60');
const [created, setCreated] = useState<string | null>(null);
const close = () => {
setAmountSats('');
setMemo('');
setExpiryMinutes('60');
setCreated(null);
onOpenChange(false);
};
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
const sats = Number(amountSats);
const result = await createInvoice.mutateAsync({
// Omitted entirely for a zero-amount invoice, which is a legitimate "payer decides" request.
amountMsat: amountSats && Number.isFinite(sats) && sats > 0 ? satsToMsat(sats) : undefined,
memo: memo.trim() || undefined,
expirySeconds: Number(expiryMinutes) > 0 ? Number(expiryMinutes) * 60 : undefined,
});
setCreated(result.invoice.bolt11);
};
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-w-md">
{created ? (
<>
<DialogHeader>
<DialogTitle>Invoice created</DialogTitle>
<DialogDescription>Send this to whoever is paying you.</DialogDescription>
</DialogHeader>
<CopyField value={created} wrap label="BOLT11" />
<DialogFooter>
<Button onClick={close}>Done</Button>
</DialogFooter>
</>
) : (
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>New invoice</DialogTitle>
<DialogDescription>Leave the amount blank to let the payer choose.</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="wallet-inv-amount">Amount (sats)</Label>
<Input
id="wallet-inv-amount"
type="number"
min={0}
value={amountSats}
placeholder="any amount"
onChange={(ev) => setAmountSats(ev.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-inv-memo">Description</Label>
<Input
id="wallet-inv-memo"
value={memo}
placeholder="what this is for"
onChange={(ev) => setMemo(ev.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wallet-inv-expiry">Expires in (minutes)</Label>
<Input
id="wallet-inv-expiry"
type="number"
min={1}
value={expiryMinutes}
onChange={(ev) => setExpiryMinutes(ev.target.value)}
className="w-32"
/>
</div>
</div>
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={createInvoice.isPending}>
{createInvoice.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,353 @@
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 { Textarea } from '@/components/ui/textarea';
import { KIND_LABELS, walletSectionPath, type WalletSectionId } from '../shared';
import { useWalletConfig, useWalletLifecycle } from '../useWalletData';
import { SeedBackupDialog } from './SeedBackupDialog';
// 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 VAULT_STORE_KEY is unconfigured. 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);
const [mnemonicInput, setMnemonicInput] = 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';
const clearSecrets = () => {
setPassphrase('');
setConfirmPassphrase('');
setBip39Passphrase('');
setMnemonicInput('');
setRemoteConfig({});
};
const reset = () => {
setName('');
setKind('onchain');
setWords(24);
setImporting(false);
setMakeActive(true);
clearSecrets();
};
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());
const canSubmit = !!name.trim() && passphraseValid && configValid && !storeKeyMissing && !create.isPending;
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!canSubmit) return;
try {
const result = await create.mutateAsync(
isOnchain
? {
name: name.trim(),
kind,
passphrase,
words,
mnemonic: importing ? mnemonicInput.trim() : undefined,
bip39Passphrase: bip39Passphrase || undefined,
makeActive,
}
: { name: name.trim(), kind, config: remoteConfig, makeActive },
);
const created = result.wallet;
if (result.mnemonic) {
// Generated seed: hold the words for the backup modal and keep this dialog mounted underneath.
setPendingSeed({ mnemonic: result.mnemonic, walletName: created.name, walletId: created.id });
} else {
navigate(walletSectionPath(section, created.id));
close();
}
} finally {
// Whatever happened, no secret survives the submit.
clearSecrets();
}
};
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())}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
<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">VAULT_STORE_KEY is not configured.</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">
<Label htmlFor="wallet-new-mnemonic">Recovery phrase</Label>
{/* Secret in flight: state → request body → cleared. Never stored on this side, and
never echoed back by the sidecar either. */}
<Textarea
id="wallet-new-mnemonic"
rows={3}
autoComplete="off"
spellCheck={false}
value={mnemonicInput}
placeholder="twelve or twenty-four words, separated by spaces"
onChange={(ev) => setMnemonicInput(ev.target.value)}
className="font-mono text-xs"
/>
</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>
)}
<div className="space-y-1.5">
<Label htmlFor="wallet-new-bip39">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">
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>
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{create.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create wallet
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{pendingSeed && (
<SeedBackupDialog
open
mnemonic={pendingSeed.mnemonic}
walletName={pendingSeed.walletName}
onConfirmed={seedConfirmed}
/>
)}
</>
);
};
const SegButton = ({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) => (
<button
type="button"
onClick={onClick}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
active ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
>
{label}
</button>
);
@@ -0,0 +1,113 @@
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, local check against the wrong row.
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, passphrase: wallet.hasSeed ? passphrase : undefined });
close();
onDeleted?.();
} finally {
setPassphrase('');
}
};
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-w-md">
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TriangleAlert className="h-5 w-5 text-destructive" />
Delete {wallet.name}
</DialogTitle>
<DialogDescription>
{wallet.hasSeed
? 'This erases the encrypted seed. Without your written-down recovery phrase the coins are gone for good.'
: 'This removes the connection to that node. Nothing on the node itself is touched.'}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="wallet-delete-name">
Type <span className="font-mono">{wallet.name}</span> to confirm
</Label>
<Input
id="wallet-delete-name"
autoFocus
autoComplete="off"
value={confirmName}
onChange={(ev) => setConfirmName(ev.target.value)}
/>
</div>
{wallet.hasSeed && (
<div className="space-y-1.5">
<Label htmlFor="wallet-delete-pass">Passphrase</Label>
{/* Controlled input → request body → cleared. Never written to any store. */}
<Input
id="wallet-delete-pass"
type="password"
autoComplete="off"
value={passphrase}
onChange={(ev) => setPassphrase(ev.target.value)}
/>
</div>
)}
</div>
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" variant="destructive" disabled={!canSubmit}>
{remove.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Delete wallet
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,125 @@
import { useState } from 'react';
import { Eye, Loader2, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLockActions } from '../useWalletData';
// Show the recovery phrase again — the one path back to it after creation, and the reason the create-time
// modal can say "not without your passphrase" rather than "never".
//
// Two things live only in this component and nowhere else: the passphrase (cleared the moment the request
// settles) and the returned mnemonic (held in local state, dropped when the dialog closes). The mutation
// deliberately has no onSuccess cache write, so the phrase never enters React Query. No copy button, for
// the same reason as SeedBackupDialog.
type ExportSeedDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
export const ExportSeedDialog = ({ open, onOpenChange, walletId }: ExportSeedDialogProps) => {
const { exportSeed } = useLockActions(walletId);
// Never persisted: no localStorage, no sessionStorage, no React Query cache, no URL.
const [passphrase, setPassphrase] = useState('');
const [revealed, setRevealed] = useState<{ mnemonic: string; hasBip39Passphrase: boolean } | null>(null);
const close = () => {
setPassphrase('');
setRevealed(null);
onOpenChange(false);
};
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!passphrase) return;
try {
setRevealed(await exportSeed.mutateAsync({ passphrase }));
} finally {
setPassphrase('');
}
};
const words = revealed ? revealed.mnemonic.trim().split(/\s+/) : [];
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-w-lg">
{revealed ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TriangleAlert className="h-5 w-5 text-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>
);
};
@@ -0,0 +1,170 @@
import type { DecodedInvoice } from '../shared';
import { useState } from 'react';
import { Loader2, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { formatTimestamp, satsToMsat, truncateMiddle } from '../format';
import { Amount } from '../Amount';
import { useWalletOperations } from '../useWalletData';
// Pay a BOLT11 invoice: decode first, confirm what you are about to pay, then pay.
//
// The two steps are deliberate. A BOLT11 string is opaque, so paying one straight from the paste box is
// signing something you cannot read. Decoding is a free read — it does not need the wallet unlocked — and
// it is the only chance to see the amount and the destination before the money moves.
//
// Amounts stay msat STRINGS end to end (see format.ts): the decoded amount is rendered as a string and a
// zero-amount invoice's manual amount is converted with satsToMsat rather than a multiplication.
type PayInvoiceDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; walletId: number };
export const PayInvoiceDialog = ({ open, onOpenChange, walletId }: PayInvoiceDialogProps) => {
const { decode, pay } = useWalletOperations(walletId);
const [bolt11, setBolt11] = useState('');
const [decoded, setDecoded] = useState<DecodedInvoice | null>(null);
const [amountSats, setAmountSats] = useState('');
const [feeLimitSats, setFeeLimitSats] = useState('');
const close = () => {
setBolt11('');
setDecoded(null);
setAmountSats('');
setFeeLimitSats('');
onOpenChange(false);
};
const runDecode = async (ev: React.FormEvent) => {
ev.preventDefault();
const trimmed = bolt11.trim();
if (!trimmed) return;
const result = await decode.mutateAsync(trimmed);
setDecoded(result.decoded);
};
// A zero-amount invoice carries no amountMsat, so the payer names the figure.
const needsAmount = decoded != null && decoded.amountMsat == null;
const manualSats = Number(amountSats);
const feeLimit = Number(feeLimitSats);
const canPay = decoded != null && (!needsAmount || (Number.isFinite(manualSats) && manualSats > 0)) && !pay.isPending;
const runPay = async () => {
if (!decoded || !canPay) return;
await pay.mutateAsync({
bolt11: decoded.bolt11,
amountMsat: needsAmount ? satsToMsat(manualSats) : undefined,
feeLimitMsat: Number.isFinite(feeLimit) && feeLimit > 0 ? satsToMsat(feeLimit) : undefined,
});
close();
};
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Pay an invoice</DialogTitle>
<DialogDescription>
{decoded
? 'Check this before you pay — a lightning payment cannot be reversed.'
: 'Paste a BOLT11 invoice.'}
</DialogDescription>
</DialogHeader>
{decoded ? (
<div className="mt-4 space-y-4">
<dl className="space-y-2 rounded-lg border border-border bg-muted/40 p-3 text-xs">
<Row label="Amount">
{decoded.amountMsat == null ? (
<span className="text-muted-foreground">payer chooses</span>
) : (
<Amount msat={decoded.amountMsat} className="font-semibold" />
)}
</Row>
<Row label="Description">{decoded.description || <span className="text-muted-foreground"></span>}</Row>
<Row label="Destination">
<code className="font-mono text-[11px]">{truncateMiddle(decoded.destination, 12, 8)}</code>
</Row>
<Row label="Expires">{formatTimestamp(decoded.timestamp + decoded.expiry)}</Row>
</dl>
{needsAmount && (
<div className="space-y-1.5">
<Label htmlFor="wallet-pay-amount">Amount to pay (sats)</Label>
<Input
id="wallet-pay-amount"
type="number"
min={1}
value={amountSats}
onChange={(ev) => setAmountSats(ev.target.value)}
className="w-40"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="wallet-pay-fee">Max routing fee (sats, optional)</Label>
<Input
id="wallet-pay-fee"
type="number"
min={0}
value={feeLimitSats}
placeholder="node default"
onChange={(ev) => setFeeLimitSats(ev.target.value)}
className="w-40"
/>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setDecoded(null)}>
Back
</Button>
<Button type="button" onClick={runPay} disabled={!canPay}>
{pay.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Zap className="mr-2 h-4 w-4" />}
Pay
</Button>
</DialogFooter>
</div>
) : (
<form onSubmit={runDecode} className="mt-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="wallet-pay-bolt11">Invoice</Label>
<Input
id="wallet-pay-bolt11"
value={bolt11}
spellCheck={false}
placeholder="lnbc…"
onChange={(ev) => setBolt11(ev.target.value)}
className="font-mono text-xs"
/>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={!bolt11.trim() || decode.isPending}>
{decode.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Decode
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
};
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-muted-foreground">{label}</dt>
<dd className="min-w-0 truncate text-right">{children}</dd>
</div>
);
@@ -0,0 +1,94 @@
import { useState } from 'react';
import { TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
// The recovery phrase, shown exactly once.
//
// The sidecar returns `mnemonic` on the create response and only when IT generated the seed. There is no
// second chance without the passphrase (settings → export seed), so this modal cannot be dismissed by
// clicking away, pressing Escape, or anything short of ticking the box and pressing the button.
//
// The words arrive as a prop, live in this component's render only, and are never written to React Query,
// localStorage, sessionStorage, the URL, or a clipboard helper. Closing the modal is the only exit and it
// drops the string on the floor. There is deliberately no "copy" button: a seed on the clipboard is a seed
// in every clipboard manager on the machine.
type SeedBackupDialogProps = {
open: boolean;
mnemonic: string;
walletName: string;
onConfirmed: () => void;
};
export const SeedBackupDialog = ({ open, mnemonic, walletName, onConfirmed }: SeedBackupDialogProps) => {
const [acknowledged, setAcknowledged] = useState(false);
const words = mnemonic.trim().split(/\s+/);
const confirm = () => {
setAcknowledged(false);
onConfirmed();
};
return (
<Dialog open={open}>
{/* `[&>button]:hidden` drops the shared X — with no onOpenChange it would be inert anyway, and an
inert close button on the one dialog you must not dismiss is worse than no button. */}
<DialogContent
className="max-w-lg [&>button]:hidden"
onEscapeKeyDown={(ev) => ev.preventDefault()}
onPointerDownOutside={(ev) => ev.preventDefault()}
onInteractOutside={(ev) => ev.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TriangleAlert className="h-5 w-5 text-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>
);
};
@@ -0,0 +1,132 @@
import { useState } from 'react';
import { Loader2, LockOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLockActions } from '../useWalletData';
// Unlock, and nothing else. The wallet spends most of its life locked and that is the intended state —
// this dialog exists for the moment before a signature, not as a gate on the app.
//
// The unlock window is capped by the deployment's unlockTtlSec; a caller may ask for less but never more,
// so the shorter options here are real and the longest is simply "whatever the sidecar allows".
type UnlockDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
walletId: number;
walletName: string;
maxTtlSec: number;
};
/** Offered windows, filtered to those the deployment actually permits. */
const TTL_CHOICES = [
{ sec: 60, label: '1 minute' },
{ sec: 300, label: '5 minutes' },
{ sec: 900, label: '15 minutes' },
{ sec: 3600, label: '1 hour' },
];
export const UnlockDialog = ({ open, onOpenChange, walletId, walletName, maxTtlSec }: UnlockDialogProps) => {
const { unlock } = useLockActions(walletId);
// The passphrase lives HERE and nowhere else: no localStorage, no sessionStorage, no React Query
// cache, no URL. It goes straight into the POST body and this state is cleared the moment the request
// settles, on success and on failure alike.
const [passphrase, setPassphrase] = useState('');
const [ttlSec, setTtlSec] = useState<number>(() => Math.min(300, maxTtlSec));
const choices = TTL_CHOICES.filter((c) => c.sec <= maxTtlSec);
const close = () => {
setPassphrase('');
onOpenChange(false);
};
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!passphrase) return;
try {
await unlock.mutateAsync({ passphrase, ttlSec });
close();
} finally {
// Cleared even when the unlock failed — a wrong passphrase left sitting in an input is still a
// passphrase sitting in memory attached to a mounted component.
setPassphrase('');
}
};
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<DialogContent className="max-w-md">
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>Unlock {walletName}</DialogTitle>
<DialogDescription>
The seed is decrypted in the wallet sidecar for the window you choose, then wiped. Balances and history do
not need this only signing does.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="wallet-unlock-pass">Passphrase</Label>
{/* Controlled input → request body → cleared. Never written to any store. */}
<Input
id="wallet-unlock-pass"
type="password"
autoFocus
autoComplete="off"
value={passphrase}
onChange={(ev) => setPassphrase(ev.target.value)}
/>
</div>
{choices.length > 0 && (
<div className="space-y-1.5">
<Label>Keep unlocked for</Label>
<div className="flex flex-wrap gap-1.5">
{choices.map(({ sec, label }) => (
<button
key={sec}
type="button"
onClick={() => setTtlSec(sec)}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
ttlSec === sec
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
</div>
<DialogFooter className="mt-6">
<Button type="button" variant="ghost" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={!passphrase || unlock.isPending}>
{unlock.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LockOpen className="mr-2 h-4 w-4" />
)}
Unlock
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,152 @@
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',
};
/** Clipboard with a graceful failure — an insecure origin has no navigator.clipboard at all. */
export async function copyToClipboard(value: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {
return false;
}
}
@@ -0,0 +1,25 @@
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,
},
];
@@ -0,0 +1,251 @@
// 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/queries/wallet.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;
esploraUrl: string;
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;
/** Null when the backend has no channels of its own. */
lightningBalance: number | null;
lightningInbound: number | null;
};
export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh';
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',
};
@@ -0,0 +1,34 @@
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 };
}
@@ -0,0 +1,46 @@
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 };
}
@@ -0,0 +1,47 @@
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,
};
}
@@ -0,0 +1,39 @@
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,
};
}
@@ -0,0 +1,485 @@
import type {
Balances,
BackendKind,
Capability,
Channel,
DecodedInvoice,
FeeEstimates,
Invoice,
LockState,
NodeInfo,
OnchainTx,
Payment,
Peer,
SendCoinsResult,
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;
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 useCapabilities(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 };
}
export function useBalances(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'balances', walletId] as const,
queryFn: () => get<{ balances: Balances }>(`${base(walletId!)}/balances`),
enabled: walletId != null,
refetchInterval: BALANCE_POLL_MS,
staleTime: BALANCE_POLL_MS - 1_000,
});
return { balances: query.data?.balances ?? 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[] }>(`${base(walletId!)}/transactions?limit=${limit}`),
enabled: walletId != null,
refetchInterval: HISTORY_POLL_MS,
staleTime: 15_000,
});
return { transactions: query.data?.transactions ?? EMPTY_TXS, 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[] }>(`${base(walletId!)}/utxos`),
enabled: walletId != null && enabled,
refetchInterval: HISTORY_POLL_MS,
staleTime: 15_000,
});
return { utxos: query.data?.utxos ?? EMPTY_UTXOS, 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.
*/
export function useReceiveAddress(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'address', walletId] as const,
queryFn: () => get<{ address: string; type: string }>(`${base(walletId!)}/address?peek=true`),
enabled: walletId != null && enabled,
staleTime: 60_000,
});
return {
address: query.data?.address ?? null,
addressType: query.data?.type ?? null,
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
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, 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 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 }: { walletId: number; passphrase?: string }) =>
del<{ ok: true }>(`/wallet/_officer/wallets/${walletId}`, 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, 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')),
});
return { send, freeze, createInvoice, decode, pay, keysend, sign, verify, setLabel };
}
/**
* 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.
*/
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;
}
@@ -0,0 +1,10 @@
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
@@ -43,6 +43,10 @@ export type { TransmissionSectionId } from './apps/Transmission/shared';
export { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection } from './apps/Invoices/shared';
export type { InvoicesSectionId } from './apps/Invoices/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,