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('/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(`${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(`${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(`${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; 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('/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(`${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; }