diff --git a/src/App/Localization/Loc.cs b/src/App/Localization/Loc.cs
index 0f1206f..af56ffd 100644
--- a/src/App/Localization/Loc.cs
+++ b/src/App/Localization/Loc.cs
@@ -389,6 +389,7 @@ public sealed class Loc
["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.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.certreset"] = [
"Certificati SSL azzerati: riprova la connessione.",
diff --git a/src/App/ViewModels/MainWindowViewModel.Receive.cs b/src/App/ViewModels/MainWindowViewModel.Receive.cs
index 1e38b4d..9408bb3 100644
--- a/src/App/ViewModels/MainWindowViewModel.Receive.cs
+++ b/src/App/ViewModels/MainWindowViewModel.Receive.cs
@@ -25,6 +25,9 @@ public partial class MainWindowViewModel
[ObservableProperty]
private string unconfirmedText = "";
+ [ObservableProperty]
+ private string immatureText = "";
+
[ObservableProperty]
private string networkInfo = "";
@@ -249,6 +252,7 @@ public partial class MainWindowViewModel
{
BalanceText = $"0.00000000 {Profile.CoinUnit}";
UnconfirmedText = "";
+ ImmatureText = "";
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
History.Clear();
Addresses.Clear();
@@ -261,11 +265,14 @@ public partial class MainWindowViewModel
$"m/{_doc!.AccountPath}/0/{i}"));
return;
}
- BalanceText = Fmt(cache.ConfirmedSats);
+ BalanceText = Fmt(cache.ConfirmedSats - cache.ImmatureSats);
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
UnconfirmedText = pending != 0
? $"{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();
History.Clear();
foreach (var tx in cache.History)
diff --git a/src/App/ViewModels/MainWindowViewModel.Sync.cs b/src/App/ViewModels/MainWindowViewModel.Sync.cs
index 5f2b86a..c31ff44 100644
--- a/src/App/ViewModels/MainWindowViewModel.Sync.cs
+++ b/src/App/ViewModels/MainWindowViewModel.Sync.cs
@@ -288,6 +288,7 @@ public partial class MainWindowViewModel
TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats,
+ ImmatureSats = result.ImmatureSats,
NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex,
History = [.. result.History],
diff --git a/src/App/Views/MainView.axaml b/src/App/Views/MainView.axaml
index ff521e5..0317834 100644
--- a/src/App/Views/MainView.axaml
+++ b/src/App/Views/MainView.axaml
@@ -314,6 +314,9 @@
+
diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs
index dbd5787..aede3b5 100644
--- a/src/Cli/Program.cs
+++ b/src/Cli/Program.cs
@@ -104,8 +104,9 @@ static int Info(string[] o)
Console.WriteLine($"xpub: {doc.AccountXpub}");
if (doc.Cache is { } cache)
{
- Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato"
- + (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa (non spendibile)." : ""));
+ Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, account.Profile.CoinUnit)} 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($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
}
@@ -147,6 +148,7 @@ static async Task Sync(string[] o)
TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats,
+ ImmatureSats = result.ImmatureSats,
NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex,
History = [.. result.History],
@@ -158,7 +160,8 @@ static async Task Sync(string[] o)
};
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)" : ""));
Console.WriteLine($"Storico ({result.History.Count}):");
foreach (var tx in result.History)
diff --git a/src/Core/Spv/WalletSynchronizer.cs b/src/Core/Spv/WalletSynchronizer.cs
index e31c83c..2abeea0 100644
--- a/src/Core/Spv/WalletSynchronizer.cs
+++ b/src/Core/Spv/WalletSynchronizer.cs
@@ -5,6 +5,7 @@ using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Storage;
+using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Core.Spv;
@@ -21,6 +22,13 @@ public sealed class SyncResult
public required int TipHeight { get; init; }
public required long ConfirmedSats { get; init; }
public required long UnconfirmedSats { get; init; }
+
+ ///
+ /// Confirmed but not yet spendable: coinbase outputs below maturity or regular
+ /// outputs below . Subset of
+ /// .
+ ///
+ public required long ImmatureSats { get; init; }
public required int NextReceiveIndex { get; init; }
public required int NextChangeIndex { get; init; }
public required IReadOnlyList History { get; init; }
@@ -276,6 +284,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
TipHeight = tip.Height,
ConfirmedSats = 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,
NextChangeIndex = nextChange,
History = history,
diff --git a/src/Core/Storage/WalletDocument.cs b/src/Core/Storage/WalletDocument.cs
index 0163211..793fb57 100644
--- a/src/Core/Storage/WalletDocument.cs
+++ b/src/Core/Storage/WalletDocument.cs
@@ -95,6 +95,9 @@ public sealed class SyncCache
public int TipHeight { get; set; }
public long ConfirmedSats { get; set; }
public long UnconfirmedSats { get; set; }
+
+ /// Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.
+ public long ImmatureSats { get; set; }
public int NextReceiveIndex { get; set; }
public int NextChangeIndex { get; set; }
public List History { get; set; } = [];
diff --git a/src/Core/Wallet/TransactionFactory.cs b/src/Core/Wallet/TransactionFactory.cs
index 1465387..bb25ddc 100644
--- a/src/Core/Wallet/TransactionFactory.cs
+++ b/src/Core/Wallet/TransactionFactory.cs
@@ -52,20 +52,8 @@ public sealed class TransactionFactory(IWalletAccount account)
int tipHeight,
bool sendAll = false)
{
- var coinbaseMaturity = account.Profile.CoinbaseMaturity;
- var minConf = account.Profile.MinConfirmations;
-
- // 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();
+ var profile = account.Profile;
+ var spendable = utxos.Where(u => u.IsSpendable(profile, tipHeight)).ToList();
if (spendable.Count == 0)
{
@@ -77,20 +65,21 @@ public sealed class TransactionFactory(IWalletAccount account)
var immature = utxos.Where(u =>
!u.Frozen && u.Height > 0 && u.IsCoinbase &&
- (tipHeight - u.Height + 1) < coinbaseThreshold).ToList();
+ u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
if (immature.Count > 0)
{
- var best = immature.Max(u => tipHeight - u.Height + 1);
- reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{coinbaseThreshold} confirmations). ");
+ var best = immature.Max(u => u.Confirmations(tipHeight));
+ var threshold = profile.CoinbaseMaturity + 1;
+ reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{threshold} confirmations). ");
}
var underConf = utxos.Where(u =>
!u.Frozen && u.Height > 0 && !u.IsCoinbase &&
- (tipHeight - u.Height + 1) < minConf).ToList();
+ u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
if (underConf.Count > 0)
{
- var best = underConf.Max(u => tipHeight - u.Height + 1);
- reasons.Append($"{underConf.Count} output(s) need {minConf} confirmations ({best} so far). ");
+ var best = underConf.Max(u => u.Confirmations(tipHeight));
+ reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). ");
}
throw new WalletSpendException(reasons.Length > 0
diff --git a/src/Core/Wallet/UtxoSpendability.cs b/src/Core/Wallet/UtxoSpendability.cs
new file mode 100644
index 0000000..2b41d75
--- /dev/null
+++ b/src/Core/Wallet/UtxoSpendability.cs
@@ -0,0 +1,24 @@
+using PalladiumWallet.Core.Chain;
+using PalladiumWallet.Core.Storage;
+
+namespace PalladiumWallet.Core.Wallet;
+
+///
+/// Confirmation-threshold rules shared between coin selection ()
+/// 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 (wallet
+/// policy, no consensus rule).
+///
+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;
+
+ /// True when the UTXO has met its confirmation threshold and is not frozen.
+ public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) =>
+ !utxo.Frozen && utxo.Height > 0 && utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
+}