frozen coins are never spendable, and a rescan already running is adopted
four wallet defects, none urgent, all cheap: selectCoins let an explicit coin-control pick override a freeze. naming an outpoint now overrides only the confirmed-only default; frozen is absolute. balances counted frozen coins in onchainConfirmed, so Send showed a figure a max-value spend could not reach. Balances gains onchainFrozen — a component of confirmed, not a deduction — filled at the route layer, because freezing is Officer policy in Postgres and no backend can see it. The route only reads utxos when something is actually frozen. Send subtracts it under "Spendable"; Overview lists it beside unconfirmed. a rescan in flight upstream was invisible after a sidecar restart, and a second POST would have queued behind it (scantxoutset is single-threaded node-wide). adoptRescan polls an existing NBXplorer scan instead of starting one, and the GET route falls back to it when local state is gone. the per-variant scan deadline counted queue time, so a variant that sat behind another wallet's scan timed out without ever having run. the deadline now refreshes while the status reads Queued. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
BIP_ADDRESS_TYPE,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type Balances,
|
||||
type SyncState,
|
||||
type Utxo,
|
||||
type WalletBackend,
|
||||
@@ -252,7 +253,7 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Respon
|
||||
return json({ info: await backend.getInfo() });
|
||||
|
||||
case 'balances':
|
||||
return json({ balances: await backend.getBalances(), sync: syncOf(backend) });
|
||||
return json({ balances: await balancesWithFrozen(walletId, backend), sync: syncOf(backend) });
|
||||
|
||||
case 'transactions': {
|
||||
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
|
||||
@@ -285,7 +286,13 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Respon
|
||||
// rides on balances/transactions/utxos, so the UI has one shape to render and can poll whichever it
|
||||
// was already polling. A backend with nothing to rescan is a clean 501 rather than a silent no-op.
|
||||
case 'rescan': {
|
||||
if (ctx.req.method === 'GET') return json({ rescan: syncOf(backend)?.rescan ?? null });
|
||||
// No local state means either "never rescanned" or "this sidecar restarted while one was running",
|
||||
// and only the upstream can tell those apart. Probing costs a request per account, so it happens
|
||||
// only in that gap — once a scan is adopted or finished, the local state answers.
|
||||
if (ctx.req.method === 'GET') {
|
||||
const local = syncOf(backend)?.rescan ?? null;
|
||||
return json({ rescan: local ?? (await backend.adoptRescan?.()) ?? null });
|
||||
}
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
if (!backend.startRescan) {
|
||||
throw new BackendError('this wallet has nothing to rescan', 501, 'NOT_SUPPORTED');
|
||||
@@ -548,6 +555,27 @@ async function exportSeedRoute(ctx: OfficerContext, walletId: number): Promise<R
|
||||
return json({ mnemonic, hasBip39Passphrase: env.hasBip39Passphrase });
|
||||
}
|
||||
|
||||
/**
|
||||
* Balances, plus how much of the confirmed figure the owner has frozen.
|
||||
*
|
||||
* Only asks the backend for its UTXOs when something is actually frozen, so a wallet that never uses the
|
||||
* feature pays nothing for it — on LND that is a real extra RPC, and this runs on every balance poll.
|
||||
*/
|
||||
async function balancesWithFrozen(walletId: number, backend: WalletBackend): Promise<Balances> {
|
||||
const balances = await backend.getBalances();
|
||||
const frozenList = await getFrozenOutpoints(walletId);
|
||||
if (frozenList.length === 0) return balances;
|
||||
|
||||
const frozen = new Set(frozenList);
|
||||
const utxos = await backend.getUtxos();
|
||||
// Confirmed only, to match what it is a component of. An unconfirmed frozen coin is not spendable for
|
||||
// a reason that has nothing to do with freezing.
|
||||
const sats = utxos
|
||||
.filter((u) => frozen.has(`${u.txid}:${u.vout}`) && u.confirmations > 0)
|
||||
.reduce((sum, u) => sum + u.amountSats, 0);
|
||||
return { ...balances, onchainFrozen: sats };
|
||||
}
|
||||
|
||||
// ── utxos ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function utxosRoute(
|
||||
|
||||
Reference in New Issue
Block a user