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:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user