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:
@@ -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