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); +}