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:
2026-08-04 20:56:37 +00:00
co-authored by Claude Opus 5
parent e0f6a469aa
commit a61d5c3a91
14 changed files with 191 additions and 18 deletions
@@ -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;