Files
bitcoin-wallet/web/useWalletData.ts
T
officer a4cf939be7 bitcoin-wallet, extracted from the platform as a plugin
Tenth plugin out, and the one whose extraction turned on a permission decision
rather than a mechanical move.

The wallet permission was kind: 'admin' — owner only, not grantable, invisible
to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts
forces it and the manifest has no field for anything else, deliberately, so
that "can a plugin escalate itself" is a question which cannot be asked. So
extraction could not preserve admin, and that was raised as a blocker before
any code moved.

It was the right change anyway, and the objection inverts. Every wallet query
is scoped by user_id, and that id comes from X-Officer-User which the platform
injects from the authenticated session — a caller cannot name someone else's.
So granting this gives an account ITS OWN wallet. Not through the API and not
with root either does it hand over anyone else's, because their seed is sealed
under a passphrase the server never stores. kind: 'admin' was single-user
thinking; grantable is how a second person gets a wallet at all.

Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically,
and the appName drives the route, the API prefix, the table prefix and the
install key together. Tables renamed to bitcoin_wallet_* accordingly — the
prefix guard normalises the hyphen. Free only because no wallet table existed
on any server, which was checked before deciding: db:push treats a rename as a
drop plus a create.

The three custody test files moved with it and still run — 72 tests over
scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened
on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by
nine, so that trap was already closed.

68 palette classes converted, five status vocabularies hoisted into shared.ts.
Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all
read as warning, and the bitcoin and lightning rails no longer differ by hue.

Deferred on purpose: watch-only data (xpubs, balances, history) stays
unencrypted so a locked wallet still shows a balance. Anyone with the database
can therefore SEE any user's balances; they can never move a coin.
2026-08-19 05:18:29 +00:00

559 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
Balances,
BackendKind,
Capability,
Channel,
DecodedInvoice,
FeeEstimates,
Invoice,
LockState,
NodeInfo,
OnchainTx,
Payment,
Peer,
RescanState,
SendCoinsResult,
SyncState,
Utxo,
WalletConfig,
WalletSummary,
} from './shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
// Data layer for the /wallet panels. Everything talks to the officer-wallet sidecar through the
// /api/wallet auth proxy, which holds no key material of its own.
//
// THE WALLET IS USUALLY LOCKED, AND THAT IS THE NORMAL STATE. Every read below — balances, history,
// receive addresses, coin control, invoices — works against a locked wallet, because a locked wallet is
// still a watch-only wallet. Only signing needs the seed. So nothing here is gated on the lock, and no
// query is invalidated when the unlock window expires: the numbers on screen do not become wrong.
//
// PASSPHRASES ARE NEVER CACHED. The unlock/export/delete/change mutations take the passphrase as a call
// argument and hand it straight to the POST body. React Query stores mutation *variables* on the
// mutation object, so these are deliberately fire-and-forget: nothing here keeps a reference after the
// request resolves, and no passphrase is ever a query key or a query result.
const ROOT_KEY = ['wallet'] as const;
/** Balances move with the chain; a 20s poll is live enough without hammering Esplora. */
const BALANCE_POLL_MS = 20_000;
/** The lock countdown is rendered locally from `secondsRemaining`; this only re-syncs the truth. */
const LOCK_POLL_MS = 10_000;
/** History and coins are cheaper to refresh on demand than to poll hard. */
const HISTORY_POLL_MS = 60_000;
/** While a rescan runs, the balance response doubles as its progress feed — poll it like one. */
const RESCAN_POLL_MS = 4_000;
const EMPTY_WALLETS: WalletSummary[] = [];
const EMPTY_CAPS: Capability[] = [];
const EMPTY_TXS: OnchainTx[] = [];
const EMPTY_UTXOS: Utxo[] = [];
const EMPTY_INVOICES: Invoice[] = [];
const EMPTY_PAYMENTS: Payment[] = [];
const EMPTY_CHANNELS: Channel[] = [];
const EMPTY_PEERS: Peer[] = [];
const base = (id: number) => `/bitcoin-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>('/bitcoin-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[] }>('/bitcoin-wallet/_officer/wallets'),
staleTime: 30_000,
});
return {
wallets: query.data?.wallets ?? EMPTY_WALLETS,
isLoading: query.isLoading,
error: query.error,
};
}
// ── per-wallet reads ──────────────────────────────────────────────────────────────────────────────
/**
* The lock state, polled. The countdown a user sees is interpolated locally in useLockCountdown — this
* query is the periodic re-sync, not the clock.
*/
export function useLockState(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'lock-state', walletId] as const,
queryFn: () => get<LockState>(`${base(walletId!)}/lock-state`),
enabled: walletId != null,
refetchInterval: LOCK_POLL_MS,
staleTime: LOCK_POLL_MS - 1_000,
});
return { lock: query.data ?? null, dataUpdatedAt: query.dataUpdatedAt, isLoading: query.isLoading };
}
export function usePermissions(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'capabilities', walletId] as const,
queryFn: () => get<{ kind: BackendKind; capabilities: Capability[] }>(`${base(walletId!)}/capabilities`),
enabled: walletId != null,
// A backend's capability set is fixed for the life of the wallet.
staleTime: 10 * 60_000,
});
return {
kind: query.data?.kind ?? null,
capabilities: query.data?.capabilities ?? EMPTY_CAPS,
isLoading: query.isLoading,
error: query.error,
};
}
export function useWalletInfo(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'info', walletId] as const,
queryFn: () => get<{ info: NodeInfo }>(`${base(walletId!)}/info`),
enabled: walletId != null,
refetchInterval: BALANCE_POLL_MS,
staleTime: BALANCE_POLL_MS - 1_000,
});
return { info: query.data?.info ?? null, isLoading: query.isLoading, error: query.error };
}
// The three reads below carry a `sync` block from the sidecar. An on-chain wallet serves the last
// snapshot it managed to read and refreshes behind the response, so `isLoading` no longer means "we know
// nothing" — after the first successful read it is never true again, and the honest signal for "these
// numbers are old" or "we could not reach the chain" is `sync`, not the query state.
export function useBalances(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'balances', walletId] as const,
queryFn: () => get<{ balances: Balances; sync: SyncState | null }>(`${base(walletId!)}/balances`),
enabled: walletId != null,
// Faster while the node is rescanning: this response carries the rescan's progress, and a counter
// that moves once every twenty seconds reads as a hung one. Back to the ordinary poll when it ends.
refetchInterval: (query) => (query.state.data?.sync?.rescan?.running ? RESCAN_POLL_MS : BALANCE_POLL_MS),
staleTime: BALANCE_POLL_MS - 1_000,
});
return {
balances: query.data?.balances ?? null,
sync: query.data?.sync ?? null,
isLoading: query.isLoading,
error: query.error,
};
}
export function useTransactions(walletId: number | null, limit = 50) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'transactions', walletId, limit] as const,
queryFn: () =>
get<{ transactions: OnchainTx[]; sync: SyncState | null }>(`${base(walletId!)}/transactions?limit=${limit}`),
enabled: walletId != null,
refetchInterval: HISTORY_POLL_MS,
staleTime: 15_000,
});
return {
transactions: query.data?.transactions ?? EMPTY_TXS,
sync: query.data?.sync ?? null,
isLoading: query.isLoading,
error: query.error,
};
}
export function useUtxos(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'utxos', walletId] as const,
queryFn: () => get<{ utxos: Utxo[]; sync: SyncState | null }>(`${base(walletId!)}/utxos`),
enabled: walletId != null && enabled,
refetchInterval: HISTORY_POLL_MS,
staleTime: 15_000,
});
return {
utxos: query.data?.utxos ?? EMPTY_UTXOS,
sync: query.data?.sync ?? null,
isLoading: query.isLoading,
error: query.error,
};
}
export function useFees(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'fees', walletId] as const,
queryFn: () => get<{ fees: FeeEstimates }>(`${base(walletId!)}/fees`),
enabled: walletId != null && enabled,
refetchInterval: 60_000,
staleTime: 30_000,
});
return { fees: query.data?.fees ?? null, isLoading: query.isLoading, error: query.error };
}
/**
* A receive address. `peek` returns the current unused address without advancing the derivation index —
* which is what a screen that merely *displays* an address must do, or every render burns an address.
*/
type AddressResponse = { address: string; type: string };
export function useReceiveAddress(walletId: number | null, enabled = true) {
const { get } = useClient();
const qc = useQueryClient();
const key = [...ROOT_KEY, 'address', walletId] as const;
const query = useQuery({
queryKey: key,
queryFn: () => get<AddressResponse>(`${base(walletId!)}/address?peek=true`),
enabled: walletId != null && enabled,
staleTime: 60_000,
});
// `peek=false` CONSUMES: the sidecar advances its issued mark so this address is never handed out
// again. Refetching the peeked query returns the same address forever, which is why this is a separate
// call and not `refetch()` — that was the bug behind a "new address" button that changed nothing.
const next = useMutation({
mutationFn: () => get<AddressResponse>(`${base(walletId!)}/address?peek=false`),
onSuccess: (data) => qc.setQueryData(key, data),
onError: (err) => toast.error(errorMessage(err, 'Could not derive a new address')),
});
return {
address: query.data?.address ?? null,
addressType: query.data?.type ?? null,
isLoading: query.isLoading,
error: query.error,
next,
};
}
export function useInvoices(walletId: number | null, enabled = true, limit = 50) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'invoices', walletId, limit] as const,
queryFn: () => get<{ invoices: Invoice[] }>(`${base(walletId!)}/invoices?limit=${limit}`),
enabled: walletId != null && enabled,
refetchInterval: 30_000,
staleTime: 15_000,
});
return { invoices: query.data?.invoices ?? EMPTY_INVOICES, isLoading: query.isLoading, error: query.error };
}
export function usePayments(walletId: number | null, enabled = true, limit = 50) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'payments', walletId, limit] as const,
queryFn: () => get<{ payments: Payment[] }>(`${base(walletId!)}/payments?limit=${limit}`),
enabled: walletId != null && enabled,
refetchInterval: 30_000,
staleTime: 15_000,
});
return { payments: query.data?.payments ?? EMPTY_PAYMENTS, isLoading: query.isLoading, error: query.error };
}
export function useChannels(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'channels', walletId] as const,
queryFn: () => get<{ channels: Channel[] }>(`${base(walletId!)}/channels`),
enabled: walletId != null && enabled,
refetchInterval: 30_000,
staleTime: 15_000,
});
return { channels: query.data?.channels ?? EMPTY_CHANNELS, isLoading: query.isLoading, error: query.error };
}
export function usePeers(walletId: number | null, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [...ROOT_KEY, 'peers', walletId] as const,
queryFn: () => get<{ peers: Peer[] }>(`${base(walletId!)}/peers`),
enabled: walletId != null && enabled,
refetchInterval: 30_000,
staleTime: 15_000,
});
return { peers: query.data?.peers ?? EMPTY_PEERS, isLoading: query.isLoading, error: query.error };
}
// ── wallet lifecycle mutations ────────────────────────────────────────────────────────────────────
export type CreateWalletInput = {
name: string;
kind: BackendKind;
network?: string;
/** onchain only. Omit to have the sidecar generate one — that is the only case that returns a mnemonic. */
mnemonic?: string;
words?: 12 | 24;
/** onchain only, and never stored anywhere on this side. */
passphrase?: string;
bip39Passphrase?: string;
defaultBip?: number;
config?: Record<string, unknown>;
makeActive?: boolean;
};
export type CreateWalletResult = { wallet: WalletSummary; mnemonic?: string };
export function useWalletLifecycle() {
const { post, patch, delete: del } = useClient();
const qc = useQueryClient();
const invalidateList = () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'wallets'] });
const create = useMutation({
mutationFn: (input: CreateWalletInput) => post<CreateWalletResult>('/bitcoin-wallet/_officer/wallets', input),
// Deliberately no onSuccess toast/cache write with the response: it may carry the mnemonic, and the
// only place that is allowed to exist is the backup modal's local state. The caller invalidates.
onSuccess: invalidateList,
onError: (err) => toast.error(errorMessage(err, 'Could not create the wallet')),
});
const activate = useMutation({
mutationFn: (walletId: number) => post<{ ok: true }>(`${base(walletId)}/activate`),
onSuccess: () => {
invalidateList();
toast.success('Active wallet changed');
},
onError: (err) => toast.error(errorMessage(err, 'Could not activate the wallet')),
});
const rename = useMutation({
// Only the label. The name is the one wallet field with no bearing on the key material, which is why
// this needs neither the passphrase nor an unlocked wallet.
mutationFn: ({ walletId, name }: { walletId: number; name: string }) =>
patch<{ wallet: WalletSummary }>(`/bitcoin-wallet/_officer/wallets/${walletId}`, { name }),
onSuccess: () => {
invalidateList();
toast.success('Wallet renamed');
},
onError: (err) => toast.error(errorMessage(err, 'Could not rename the wallet')),
});
const remove = useMutation({
// The passphrase is required for a seeded wallet even when it is already unlocked — an open session
// must not be enough to destroy the only copy of the key material.
mutationFn: ({
walletId,
passphrase,
confirmName,
}: {
walletId: number;
passphrase?: string;
confirmName: string;
}) =>
del<{ ok: true }>(`/bitcoin-wallet/_officer/wallets/${walletId}`, { confirmName, ...(passphrase ? { passphrase } : {}) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ROOT_KEY });
toast.success('Wallet deleted');
},
onError: (err) => toast.error(errorMessage(err, 'Could not delete the wallet')),
});
return { create, activate, rename, remove };
}
// ── lock lifecycle ────────────────────────────────────────────────────────────────────────────────
export function useLockActions(walletId: number | null) {
const { post } = useClient();
const qc = useQueryClient();
const refreshLock = () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'lock-state', walletId] });
const unlock = useMutation({
// `passphrase` goes from a controlled input straight into this body and the caller clears its state
// immediately after. It is never persisted, cached or logged on this side.
mutationFn: ({ passphrase, ttlSec }: { passphrase: string; ttlSec?: number }) =>
post<{ ok: true; unlocked: boolean; secondsRemaining: number }>(`${base(walletId!)}/unlock`, {
passphrase,
ttlSec,
}),
onSuccess: () => {
refreshLock();
toast.success('Wallet unlocked');
},
onError: (err) => toast.error(errorMessage(err, 'Could not unlock — check the passphrase')),
});
const lock = useMutation({
mutationFn: () => post<{ ok: true; unlocked: false }>(`${base(walletId!)}/lock`),
onSuccess: () => {
refreshLock();
toast.success('Wallet locked');
},
onError: (err) => toast.error(errorMessage(err, 'Could not lock the wallet')),
});
const changePassphrase = useMutation({
mutationFn: (input: { oldPassphrase: string; newPassphrase: string }) =>
post<{ ok: true }>(`${base(walletId!)}/passphrase`, input),
onSuccess: () => {
refreshLock();
toast.success('Passphrase changed — the wallet was re-locked');
},
onError: (err) => toast.error(errorMessage(err, 'Could not change the passphrase')),
});
const exportSeed = useMutation({
mutationFn: (input: { passphrase: string }) =>
post<{ mnemonic: string; hasBip39Passphrase: boolean }>(`${base(walletId!)}/export-seed`, input),
// No cache write and no toast carrying the result — the mnemonic lives only in the dialog's state.
onError: (err) => toast.error(errorMessage(err, 'Could not export the seed')),
});
return { unlock, lock, changePassphrase, exportSeed };
}
// ── operations ────────────────────────────────────────────────────────────────────────────────────
export type SendInput = {
address: string;
amountSats?: number;
sendAll?: boolean;
satPerVbyte: number;
outpoints?: string[];
label?: string;
};
export function useWalletOperations(walletId: number | null) {
const { post } = useClient();
const qc = useQueryClient();
const invalidateAll = () => qc.invalidateQueries({ queryKey: ROOT_KEY });
const send = useMutation({
mutationFn: (input: SendInput) => post<SendCoinsResult>(`${base(walletId!)}/send`, input),
onSuccess: (result) => {
invalidateAll();
toast.success(`Broadcast ${result.txid.slice(0, 12)}…`);
},
onError: (err) => toast.error(errorMessage(err, 'Could not send')),
});
const freeze = useMutation({
mutationFn: (input: { outpoint: string; frozen: boolean; reason?: string }) =>
post<{ ok: true }>(`${base(walletId!)}/utxos/freeze`, input),
onSuccess: () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'utxos', walletId] }),
onError: (err) => toast.error(errorMessage(err, 'Could not change the freeze flag')),
});
const createInvoice = useMutation({
mutationFn: (input: { amountMsat?: string; memo?: string; expirySeconds?: number; private?: boolean }) =>
post<{ invoice: Invoice }>(`${base(walletId!)}/invoices`, input),
onSuccess: () => qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'invoices', walletId] }),
onError: (err) => toast.error(errorMessage(err, 'Could not create the invoice')),
});
const decode = useMutation({
mutationFn: (bolt11: string) => post<{ decoded: DecodedInvoice }>(`${base(walletId!)}/decode`, { bolt11 }),
onError: (err) => toast.error(errorMessage(err, 'Could not decode that invoice')),
});
const pay = useMutation({
mutationFn: (input: { bolt11: string; amountMsat?: string; feeLimitMsat?: string }) =>
post<{ payment: Payment }>(`${base(walletId!)}/pay`, input),
onSuccess: (data) => {
invalidateAll();
if (data.payment.status === 'failed') toast.error(data.payment.failureReason ?? 'Payment failed');
else toast.success(data.payment.status === 'succeeded' ? 'Payment sent' : 'Payment in flight');
},
onError: (err) => toast.error(errorMessage(err, 'Could not pay')),
});
const keysend = useMutation({
mutationFn: (input: { destination: string; amountMsat: string; message?: string }) =>
post<{ payment: Payment }>(`${base(walletId!)}/keysend`, input),
onSuccess: () => {
invalidateAll();
toast.success('Keysend sent');
},
onError: (err) => toast.error(errorMessage(err, 'Keysend failed')),
});
const sign = useMutation({
mutationFn: (message: string) => post<{ signature: string }>(`${base(walletId!)}/sign`, { message }),
onError: (err) => toast.error(errorMessage(err, 'Could not sign — the wallet may be locked')),
});
const verify = useMutation({
mutationFn: (input: { message: string; signature: string }) =>
post<{ valid: boolean; pubkey: string | null }>(`${base(walletId!)}/verify`, input),
onError: (err) => toast.error(errorMessage(err, 'Could not verify')),
});
const setLabel = useMutation({
mutationFn: (input: { kind: 'address' | 'tx'; ref: string; label: string }) =>
post<{ ok: true }>(`${base(walletId!)}/labels`, input),
onSuccess: () => invalidateAll(),
onError: (err) => toast.error(errorMessage(err, 'Could not save the label')),
});
// Returns as soon as the scan is queued — it takes minutes. Progress arrives on the `sync.rescan` block
// of the balances poll, which speeds up on its own while one is running, so the invalidation here is
// only to put the first "scanning…" on screen without waiting out the current interval.
const rescan = useMutation({
mutationFn: (input: { gapLimit?: number } = {}) =>
post<{ rescan: RescanState }>(`${base(walletId!)}/rescan`, input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'balances', walletId] });
toast.success('Scanning the chain for this wallet’s coins — this takes a few minutes');
},
onError: (err) => toast.error(errorMessage(err, 'Could not start the scan')),
});
return { send, freeze, createInvoice, decode, pay, keysend, sign, verify, setLabel, rescan };
}
/**
* useClient rejects with `{status, message}` rather than an Error, and the sidecar's message is a JSON
* body — unwrap both so a toast reads "this wallet does not support coin control" rather than
* "[object Object]". Never called with anything that could contain a passphrase: the sidecar's error
* bodies are messages and codes only.
*/
export function errorMessage(err: unknown, fallback: string): string {
const raw = typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : '';
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw) as { error?: string };
if (parsed.error) return parsed.error;
} catch {
/* not JSON — use it as-is */
}
return raw || fallback;
}