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:
@@ -550,7 +550,8 @@ export class ClnRestBackend extends BaseBackend {
|
||||
lightningInbound += Math.floor((total - ours) / 1000);
|
||||
}
|
||||
|
||||
return { onchainConfirmed, onchainUnconfirmed, lightningBalance, lightningInbound };
|
||||
// Core Lightning reserves coins itself; Officer freezing is the route's overlay, so 0 here.
|
||||
return { onchainConfirmed, onchainUnconfirmed, onchainFrozen: 0, lightningBalance, lightningInbound };
|
||||
}
|
||||
|
||||
// ── on-chain ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -460,6 +460,7 @@ export class LndBackend extends BaseBackend {
|
||||
]);
|
||||
return {
|
||||
onchainConfirmed: toSats(chain.confirmed_balance),
|
||||
onchainFrozen: 0,
|
||||
onchainUnconfirmed: toSats(chain.unconfirmed_balance),
|
||||
// `balance` is the deprecated flat field; the nested Amount is authoritative on v0.11+.
|
||||
lightningBalance: toSats(channels.local_balance?.sat ?? channels.balance),
|
||||
|
||||
@@ -341,6 +341,7 @@ export class LndHubBackend extends BaseBackend {
|
||||
return {
|
||||
// A custodial account has no on-chain balance of its own; deposits land in the lightning balance.
|
||||
onchainConfirmed: 0,
|
||||
onchainFrozen: 0,
|
||||
onchainUnconfirmed: 0,
|
||||
lightningBalance: num(res?.BTC?.AvailableBalance),
|
||||
// Inbound liquidity is the operator's problem and is never reported.
|
||||
|
||||
@@ -278,6 +278,7 @@ export class NwcBackend extends BaseBackend {
|
||||
return {
|
||||
// NWC is lightning-only; there is no on-chain side to report.
|
||||
onchainConfirmed: 0,
|
||||
onchainFrozen: 0,
|
||||
onchainUnconfirmed: 0,
|
||||
// `balance` is msats (the WebLN shim Zeus uses divides by 1000 before the UI ever sees it).
|
||||
lightningBalance: Math.floor((res.balance ?? 0) / 1000),
|
||||
|
||||
@@ -339,6 +339,23 @@ export class OnchainBackend extends BaseBackend {
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach to a rescan that outlived this process, and put the refresh back on the end of it.
|
||||
*
|
||||
* Read-only with respect to the upstream: it never starts a scan, so calling it when nothing is running
|
||||
* costs one request per account and answers null.
|
||||
*/
|
||||
async adoptRescan(): Promise<RescanState | null> {
|
||||
const adopt = this.chain.adoptRescan?.bind(this.chain);
|
||||
if (!adopt) return null;
|
||||
const handle = await adopt(this.scanContext().accounts);
|
||||
if (!handle) return null;
|
||||
// The same chaining startRescan does, and for the same reason: coins recovered by a scan are invisible
|
||||
// until the snapshot in front of them is rebuilt.
|
||||
void handle.done.then(() => this.refresh()).catch(() => {});
|
||||
return handle.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wallet's view of the chain, stale-while-revalidate.
|
||||
*
|
||||
@@ -528,6 +545,8 @@ export class OnchainBackend extends BaseBackend {
|
||||
}
|
||||
return {
|
||||
onchainConfirmed: confirmed,
|
||||
// Filled in by the route, which is the only layer that knows what the owner froze.
|
||||
onchainFrozen: 0,
|
||||
onchainUnconfirmed: unconfirmed,
|
||||
// No channels exist, and null is the contract's "this backend has no lightning" value.
|
||||
lightningBalance: null,
|
||||
|
||||
@@ -173,6 +173,7 @@ describe('EsploraChainSource through OnchainBackend', () => {
|
||||
expect(await backend.getBalances()).toEqual({
|
||||
onchainConfirmed: 0,
|
||||
onchainUnconfirmed: 0,
|
||||
onchainFrozen: 0,
|
||||
lightningBalance: null,
|
||||
lightningInbound: null,
|
||||
});
|
||||
|
||||
@@ -203,11 +203,51 @@ export class NbxplorerChainSource implements WalletChainSource {
|
||||
return { state, done: this.rescanRun };
|
||||
}
|
||||
|
||||
private async runRescan(accounts: readonly ScanAccount[], state: RescanState): Promise<RescanState> {
|
||||
/**
|
||||
* Pick up a scan that survived a restart of this sidecar.
|
||||
*
|
||||
* `scantxoutset` runs on the node and outlives us; only the handle to it was ever in memory. So the
|
||||
* upstream is asked directly, and if anything is Queued or Pending the run is rebuilt around the same
|
||||
* polling loop with starting suppressed — a duplicate POST would sit behind the real scan for its whole
|
||||
* duration, since the node runs one at a time.
|
||||
*
|
||||
* `found` restarts from zero and a variant that finished before the restart contributes nothing to it:
|
||||
* its result has expired upstream and there is no way to recover the count. The number under-reports
|
||||
* for that one run, which is the honest answer — and the refresh chained on the end is what actually
|
||||
* puts the coins on screen.
|
||||
*/
|
||||
async adoptRescan(accounts: readonly ScanAccount[]): Promise<RescanHandle | null> {
|
||||
if (this.rescan?.running && this.rescanRun) return { state: this.rescan, done: this.rescanRun };
|
||||
|
||||
const live = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
// A probe failing is not an answer either way, and must not be reported as "a scan is running".
|
||||
const status = await this.chain.getUtxoScanStatus(account.accountXpub, account.type).catch(() => null);
|
||||
return status?.status === 'Queued' || status?.status === 'Pending';
|
||||
}),
|
||||
);
|
||||
if (!live.some(Boolean)) return null;
|
||||
|
||||
const state: RescanState = {
|
||||
running: true,
|
||||
done: 0,
|
||||
total: accounts.length,
|
||||
current: accounts[0]?.type ?? null,
|
||||
found: 0,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: null,
|
||||
error: null,
|
||||
};
|
||||
this.rescan = state;
|
||||
this.rescanRun = this.runRescan(accounts, state, false);
|
||||
return { state, done: this.rescanRun };
|
||||
}
|
||||
|
||||
private async runRescan(accounts: readonly ScanAccount[], state: RescanState, start = true): Promise<RescanState> {
|
||||
try {
|
||||
for (const account of accounts) {
|
||||
state.current = account.type;
|
||||
state.found += await this.scanVariant(account);
|
||||
state.found += start ? await this.scanVariant(account) : await this.awaitVariant(account);
|
||||
state.done += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -224,10 +264,25 @@ export class NbxplorerChainSource implements WalletChainSource {
|
||||
|
||||
/** Queue one variant's scan and wait it out. Resolves with how many UTXOs it pulled in. */
|
||||
private async scanVariant(account: ScanAccount): Promise<number> {
|
||||
const { accountXpub, type } = account;
|
||||
await this.chain.startUtxoScan(accountXpub, type);
|
||||
await this.chain.startUtxoScan(account.accountXpub, account.type);
|
||||
return this.awaitVariant(account);
|
||||
}
|
||||
|
||||
const deadline = Date.now() + SCAN_TIMEOUT_MS;
|
||||
/**
|
||||
* Wait out a scan that is already queued upstream, without starting one.
|
||||
*
|
||||
* Split out of scanVariant for adoption: a scan this process did not start must never be re-queued,
|
||||
* because scantxoutset is single-threaded node-wide and a duplicate would sit behind the real one for
|
||||
* its whole duration. An account that already finished answers 404 or Complete and returns at once.
|
||||
*/
|
||||
private async awaitVariant(account: ScanAccount): Promise<number> {
|
||||
const { accountXpub, type } = account;
|
||||
|
||||
// The timeout bounds how long a scan may RUN, not how long it may wait its turn. scantxoutset is
|
||||
// single-threaded node-wide, so four variants queue behind each other and the last one could spend
|
||||
// most of its ten minutes not started — timing out while the node was never asked to do anything
|
||||
// wrong. The deadline is therefore pushed forward for as long as the status reads Queued.
|
||||
let deadline = Date.now() + SCAN_TIMEOUT_MS;
|
||||
let found = 0;
|
||||
let seen = false;
|
||||
let nulls = 0;
|
||||
@@ -249,6 +304,7 @@ export class NbxplorerChainSource implements WalletChainSource {
|
||||
nulls = 0;
|
||||
found = status.progress?.found ?? found;
|
||||
|
||||
if (status.status === 'Queued') deadline = Date.now() + SCAN_TIMEOUT_MS;
|
||||
if (status.status === 'Complete') return found;
|
||||
if (status.status === 'Error') {
|
||||
throw new BackendError(`utxo scan of the ${type} account failed: ${status.error ?? 'unknown'}`, 502, 'SCAN');
|
||||
|
||||
@@ -138,6 +138,15 @@ export interface WalletChainSource {
|
||||
*/
|
||||
startRescan?(accounts: readonly ScanAccount[]): RescanHandle;
|
||||
|
||||
/**
|
||||
* Find a scan already in flight upstream and return a handle to it, without starting one.
|
||||
*
|
||||
* Same handle contract as `startRescan`, so the caller chains its refresh onto `done` identically —
|
||||
* which is the point: an adopted scan has to end in a snapshot rebuild too, or the coins it recovered
|
||||
* stay invisible. Null when nothing is running.
|
||||
*/
|
||||
adoptRescan?(accounts: readonly ScanAccount[]): Promise<RescanHandle | null>;
|
||||
|
||||
/** The rescan in flight, or the last one's outcome. Null when this source has never run one. */
|
||||
rescanState?(): RescanState | null;
|
||||
}
|
||||
|
||||
@@ -268,10 +268,15 @@ export function selectCoins(params: CoinSelectionParams): CoinSelection {
|
||||
const allow = params.outpoints && params.outpoints.length > 0 ? new Set(params.outpoints) : null;
|
||||
|
||||
const eligible = params.utxos.filter((u) => {
|
||||
// An explicit coin-control pick is authoritative: it overrides both the frozen flag and the
|
||||
// confirmed-only default, because the user named this exact outpoint.
|
||||
if (allow) return allow.has(outpointOf(u));
|
||||
// Frozen is never overridable, not even by naming the outpoint. Freezing is the owner saying "this
|
||||
// coin does not exist for spending purposes" — a reservation against an open channel, a coin held
|
||||
// for tax lots, dust being kept out of the way — and a rule that a sufficiently explicit request
|
||||
// can bypass is not a rule. The route refuses a named frozen outpoint before ever reaching here;
|
||||
// this is what makes that true for any future caller too.
|
||||
if (u.frozen) return false;
|
||||
// An explicit coin-control pick is otherwise authoritative: it overrides the confirmed-only
|
||||
// default, because the user named this exact outpoint and can see what it is.
|
||||
if (allow) return allow.has(outpointOf(u));
|
||||
if (!params.spendUnconfirmed && u.confirmations < 1) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -57,9 +57,18 @@ export type NodeInfo = {
|
||||
};
|
||||
|
||||
export type Balances = {
|
||||
/** Spendable on-chain, in sats. */
|
||||
/** Confirmed on-chain, in sats. Includes anything frozen — see `onchainFrozen`. */
|
||||
onchainConfirmed: number;
|
||||
onchainUnconfirmed: number;
|
||||
/**
|
||||
* How much of `onchainConfirmed` sits in UTXOs the owner froze, and so cannot be spent.
|
||||
*
|
||||
* A component of the confirmed figure rather than a deduction from it, because the coins are still
|
||||
* the wallet's — freezing is a spending policy, not a loss. Officer's overlay: freezing lives in
|
||||
* Postgres and no backend knows about it, so every backend reports 0 and the route fills it in.
|
||||
* Without this the UI shows a balance a max-value send then refuses to spend, with no visible reason.
|
||||
*/
|
||||
onchainFrozen: number;
|
||||
/** Sum of local balance across active channels, in sats. Null when the backend has no channels. */
|
||||
lightningBalance: number | null;
|
||||
/** Sum of remote balance — i.e. inbound liquidity — in sats. */
|
||||
@@ -316,6 +325,19 @@ export interface WalletBackend {
|
||||
* rescan (a node backend already knows its own coins) or the source has no such endpoint.
|
||||
*/
|
||||
startRescan?(): Promise<RescanState>;
|
||||
|
||||
/**
|
||||
* Re-attach to a rescan that is running upstream but was not started by this process.
|
||||
*
|
||||
* A deep rescan takes minutes and lives on the indexer, not here — so restarting the sidecar mid-scan
|
||||
* used to lose the wallet's only handle on it. `getSyncState().rescan` read back null, the UI showed no
|
||||
* scan at all, and the refresh chained onto its completion went with it, leaving the balance stale
|
||||
* until the cache TTL happened to expire. This asks the upstream what is actually in flight.
|
||||
*
|
||||
* Resolves null when nothing is running. Costs an upstream call, so callers reach for it only when
|
||||
* there is no local state to report.
|
||||
*/
|
||||
adoptRescan?(): Promise<RescanState | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Balances } from './shared';
|
||||
import { ArrowDownLeft, ArrowUpRight, Bitcoin, Clock, Loader2, Zap } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { KIND_LABELS, walletSectionPath } from './shared';
|
||||
@@ -14,6 +15,21 @@ import { useBalances, useCapabilities, useTransactions, useWalletInfo } from './
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* The sub-line under the on-chain figure. Unconfirmed and frozen are both reasons the headline number is
|
||||
* not what a send can use, and both can be true at once, so they read as a list rather than a choice.
|
||||
*/
|
||||
function onchainHint(balances: Balances | null | undefined): string | undefined {
|
||||
if (!balances) return undefined;
|
||||
const parts: string[] = [];
|
||||
if (balances.onchainUnconfirmed !== 0) {
|
||||
const sign = balances.onchainUnconfirmed > 0 ? '+' : '';
|
||||
parts.push(`${sign}${formatSats(balances.onchainUnconfirmed)} sats unconfirmed`);
|
||||
}
|
||||
if (balances.onchainFrozen > 0) parts.push(`${formatSats(balances.onchainFrozen)} sats frozen`);
|
||||
return parts.length ? parts.join(' · ') : undefined;
|
||||
}
|
||||
|
||||
export const OverviewView = () => {
|
||||
const { wallet, walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
@@ -55,11 +71,7 @@ export const OverviewView = () => {
|
||||
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
|
||||
}
|
||||
hint={onchainHint(balances)}
|
||||
/>
|
||||
{hasLightning && (
|
||||
<Tile
|
||||
|
||||
@@ -149,8 +149,19 @@ const OnchainSendForm = ({ walletId, walletName }: FormProps) => {
|
||||
<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" />
|
||||
{/* Frozen coins are confirmed and are the wallet's, but coin selection will not touch them. This
|
||||
header said "Spendable" over a figure that included them, so a max-value send was rejected as
|
||||
having no spendable UTXOs while the number above it said otherwise. */}
|
||||
<Amount
|
||||
sats={balances ? balances.onchainConfirmed - balances.onchainFrozen : null}
|
||||
className="text-sm font-semibold"
|
||||
/>
|
||||
</div>
|
||||
{!!balances?.onchainFrozen && (
|
||||
<p className="mt-1 text-right text-[11px] text-muted-foreground">
|
||||
{formatSats(balances.onchainFrozen)} sats frozen and excluded
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -106,6 +106,12 @@ export type NodeInfo = {
|
||||
export type Balances = {
|
||||
onchainConfirmed: number;
|
||||
onchainUnconfirmed: number;
|
||||
/**
|
||||
* The part of `onchainConfirmed` the owner has frozen. Already counted in it, not deducted — the coins
|
||||
* are still the wallet's; freezing is a spending policy. Subtract it to get what a send can actually
|
||||
* use, which is what "Spendable" has to mean or a max-value send fails for no visible reason.
|
||||
*/
|
||||
onchainFrozen: number;
|
||||
/** Null when the backend has no channels of its own. */
|
||||
lightningBalance: number | null;
|
||||
lightningInbound: number | null;
|
||||
|
||||
Reference in New Issue
Block a user