feat(wallet): surface immature balance separately from confirmed

Extract confirmation-threshold logic into UtxoSpendability (shared by
TransactionFactory and WalletSynchronizer) and use it to compute
ImmatureSats, so coinbase/under-confirmed amounts are shown separately
from spendable balance in the GUI and CLI instead of being lumped into
"confirmed".
This commit is contained in:
2026-07-02 18:24:26 +02:00
parent 9aecdb1aaa
commit 6c24a8bb46
9 changed files with 66 additions and 24 deletions
+1
View File
@@ -389,6 +389,7 @@ public sealed class Loc
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"], ["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"], ["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"], ["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung"],
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."], ["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
["msg.certreset"] = [ ["msg.certreset"] = [
"Certificati SSL azzerati: riprova la connessione.", "Certificati SSL azzerati: riprova la connessione.",
@@ -25,6 +25,9 @@ public partial class MainWindowViewModel
[ObservableProperty] [ObservableProperty]
private string unconfirmedText = ""; private string unconfirmedText = "";
[ObservableProperty]
private string immatureText = "";
[ObservableProperty] [ObservableProperty]
private string networkInfo = ""; private string networkInfo = "";
@@ -249,6 +252,7 @@ public partial class MainWindowViewModel
{ {
BalanceText = $"0.00000000 {Profile.CoinUnit}"; BalanceText = $"0.00000000 {Profile.CoinUnit}";
UnconfirmedText = ""; UnconfirmedText = "";
ImmatureText = "";
ReceiveAddress = _account.GetReceiveAddress(0).ToString(); ReceiveAddress = _account.GetReceiveAddress(0).ToString();
History.Clear(); History.Clear();
Addresses.Clear(); Addresses.Clear();
@@ -261,11 +265,14 @@ public partial class MainWindowViewModel
$"m/{_doc!.AccountPath}/0/{i}")); $"m/{_doc!.AccountPath}/0/{i}"));
return; return;
} }
BalanceText = Fmt(cache.ConfirmedSats); BalanceText = Fmt(cache.ConfirmedSats - cache.ImmatureSats);
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats); var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
UnconfirmedText = pending != 0 UnconfirmedText = pending != 0
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}" ? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
: ""; : "";
ImmatureText = cache.ImmatureSats != 0
? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}"
: "";
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString(); ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
History.Clear(); History.Clear();
foreach (var tx in cache.History) foreach (var tx in cache.History)
@@ -288,6 +288,7 @@ public partial class MainWindowViewModel
TipHeight = result.TipHeight, TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats, ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats, UnconfirmedSats = result.UnconfirmedSats,
ImmatureSats = result.ImmatureSats,
NextReceiveIndex = result.NextReceiveIndex, NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex, NextChangeIndex = result.NextChangeIndex,
History = [.. result.History], History = [.. result.History],
+3
View File
@@ -314,6 +314,9 @@
<TextBlock Text="{Binding UnconfirmedText}" Foreground="#FCD34D" <TextBlock Text="{Binding UnconfirmedText}" Foreground="#FCD34D"
TextWrapping="Wrap" TextWrapping="Wrap"
IsVisible="{Binding UnconfirmedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding UnconfirmedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding ImmatureText}" Foreground="#FCD34D"
TextWrapping="Wrap"
IsVisible="{Binding ImmatureText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12" <TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12"
Margin="0,2,0,0"/> Margin="0,2,0,0"/>
</StackPanel> </StackPanel>
+6 -3
View File
@@ -104,8 +104,9 @@ static int Info(string[] o)
Console.WriteLine($"xpub: {doc.AccountXpub}"); Console.WriteLine($"xpub: {doc.AccountXpub}");
if (doc.Cache is { } cache) if (doc.Cache is { } cache)
{ {
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato" Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, account.Profile.CoinUnit)} spendibile"
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa (non spendibile)." : "")); + (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} in maturazione (non spendibile)" : "")
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa di conferma (non spendibile)." : ""));
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni"); Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni");
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}"); Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
} }
@@ -147,6 +148,7 @@ static async Task<int> Sync(string[] o)
TipHeight = result.TipHeight, TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats, ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats, UnconfirmedSats = result.UnconfirmedSats,
ImmatureSats = result.ImmatureSats,
NextReceiveIndex = result.NextReceiveIndex, NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex, NextChangeIndex = result.NextChangeIndex,
History = [.. result.History], History = [.. result.History],
@@ -158,7 +160,8 @@ static async Task<int> Sync(string[] o)
}; };
WalletStore.Save(doc, path, Opt(o, "--password")); WalletStore.Save(doc, path, Opt(o, "--password"));
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats, account.Profile.CoinUnit)} confermato" Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats - result.ImmatureSats, account.Profile.CoinUnit)} spendibile"
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} in maturazione (non spendibile)" : "")
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : "")); + (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : ""));
Console.WriteLine($"Storico ({result.History.Count}):"); Console.WriteLine($"Storico ({result.History.Count}):");
foreach (var tx in result.History) foreach (var tx in result.History)
+11
View File
@@ -5,6 +5,7 @@ using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto; using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net; using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Storage; using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Core.Spv; namespace PalladiumWallet.Core.Spv;
@@ -21,6 +22,13 @@ public sealed class SyncResult
public required int TipHeight { get; init; } public required int TipHeight { get; init; }
public required long ConfirmedSats { get; init; } public required long ConfirmedSats { get; init; }
public required long UnconfirmedSats { get; init; } public required long UnconfirmedSats { get; init; }
/// <summary>
/// Confirmed but not yet spendable: coinbase outputs below maturity or regular
/// outputs below <see cref="Chain.ChainProfile.MinConfirmations"/>. Subset of
/// <see cref="ConfirmedSats"/>.
/// </summary>
public required long ImmatureSats { get; init; }
public required int NextReceiveIndex { get; init; } public required int NextReceiveIndex { get; init; }
public required int NextChangeIndex { get; init; } public required int NextChangeIndex { get; init; }
public required IReadOnlyList<CachedTx> History { get; init; } public required IReadOnlyList<CachedTx> History { get; init; }
@@ -276,6 +284,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
TipHeight = tip.Height, TipHeight = tip.Height,
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats), ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats), UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
ImmatureSats = utxos.Where(u =>
u.Height > 0 && u.Confirmations(tip.Height) < u.RequiredConfirmations(account.Profile))
.Sum(u => u.ValueSats),
NextReceiveIndex = nextReceive, NextReceiveIndex = nextReceive,
NextChangeIndex = nextChange, NextChangeIndex = nextChange,
History = history, History = history,
+3
View File
@@ -95,6 +95,9 @@ public sealed class SyncCache
public int TipHeight { get; set; } public int TipHeight { get; set; }
public long ConfirmedSats { get; set; } public long ConfirmedSats { get; set; }
public long UnconfirmedSats { get; set; } public long UnconfirmedSats { get; set; }
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
public long ImmatureSats { get; set; }
public int NextReceiveIndex { get; set; } public int NextReceiveIndex { get; set; }
public int NextChangeIndex { get; set; } public int NextChangeIndex { get; set; }
public List<CachedTx> History { get; set; } = []; public List<CachedTx> History { get; set; } = [];
+9 -20
View File
@@ -52,20 +52,8 @@ public sealed class TransactionFactory(IWalletAccount account)
int tipHeight, int tipHeight,
bool sendAll = false) bool sendAll = false)
{ {
var coinbaseMaturity = account.Profile.CoinbaseMaturity; var profile = account.Profile;
var minConf = account.Profile.MinConfirmations; var spendable = utxos.Where(u => u.IsSpendable(profile, tipHeight)).ToList();
// A UTXO is spendable when it has enough confirmations:
// - coinbase: COINBASE_MATURITY + 1 confirmations (matches the Qt wallet: the consensus
// rule is nSpendHeight - nHeight >= 120, so at the current tip a TX can be mined in
// the next block when tipHeight - height + 1 >= 121)
// - regular: MinConfirmations (wallet policy, no consensus rule)
int coinbaseThreshold = coinbaseMaturity + 1;
var spendable = utxos.Where(u =>
!u.Frozen &&
u.Height > 0 &&
(tipHeight - u.Height + 1) >= (u.IsCoinbase ? coinbaseThreshold : minConf)
).ToList();
if (spendable.Count == 0) if (spendable.Count == 0)
{ {
@@ -77,20 +65,21 @@ public sealed class TransactionFactory(IWalletAccount account)
var immature = utxos.Where(u => var immature = utxos.Where(u =>
!u.Frozen && u.Height > 0 && u.IsCoinbase && !u.Frozen && u.Height > 0 && u.IsCoinbase &&
(tipHeight - u.Height + 1) < coinbaseThreshold).ToList(); u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
if (immature.Count > 0) if (immature.Count > 0)
{ {
var best = immature.Max(u => tipHeight - u.Height + 1); var best = immature.Max(u => u.Confirmations(tipHeight));
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{coinbaseThreshold} confirmations). "); var threshold = profile.CoinbaseMaturity + 1;
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{threshold} confirmations). ");
} }
var underConf = utxos.Where(u => var underConf = utxos.Where(u =>
!u.Frozen && u.Height > 0 && !u.IsCoinbase && !u.Frozen && u.Height > 0 && !u.IsCoinbase &&
(tipHeight - u.Height + 1) < minConf).ToList(); u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
if (underConf.Count > 0) if (underConf.Count > 0)
{ {
var best = underConf.Max(u => tipHeight - u.Height + 1); var best = underConf.Max(u => u.Confirmations(tipHeight));
reasons.Append($"{underConf.Count} output(s) need {minConf} confirmations ({best} so far). "); reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). ");
} }
throw new WalletSpendException(reasons.Length > 0 throw new WalletSpendException(reasons.Length > 0
+24
View File
@@ -0,0 +1,24 @@
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Core.Wallet;
/// <summary>
/// Confirmation-threshold rules shared between coin selection (<see cref="TransactionFactory"/>)
/// and balance reporting: a coinbase output needs COINBASE_MATURITY + 1 confirmations
/// (consensus rule nSpendHeight - nHeight >= 120, plus one block of safety margin, matching
/// the Qt wallet); a regular output needs <see cref="ChainProfile.MinConfirmations"/> (wallet
/// policy, no consensus rule).
/// </summary>
public static class UtxoSpendability
{
public static int RequiredConfirmations(this CachedUtxo utxo, ChainProfile profile) =>
utxo.IsCoinbase ? profile.CoinbaseMaturity + 1 : profile.MinConfirmations;
public static int Confirmations(this CachedUtxo utxo, int tipHeight) =>
utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1;
/// <summary>True when the UTXO has met its confirmation threshold and is not frozen.</summary>
public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) =>
!utxo.Frozen && utxo.Height > 0 && utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
}