From 9aecdb1aaa6037d334df5774dade6efd7a047f47 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 2 Jul 2026 16:25:38 +0200 Subject: [PATCH] feat(wallet): enforce confirmation thresholds before spending UTXOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coinbase outputs require COINBASE_MATURITY + 1 = 121 confirmations before they can be spent. The consensus rule (palladiumcore tx_verify.cpp) is nSpendHeight - nHeight >= 120; the +1 adds one block of safety margin, matching the Qt wallet (wallet.cpp: COINBASE_MATURITY+1). Regular UTXOs require MinConfirmations (6 on mainnet, 1 on testnet/regtest — wallet policy, no consensus rule). Changes: - ChainProfile: add CoinbaseMaturity and MinConfirmations fields - ChainProfiles: mainnet CoinbaseMaturity=120 / MinConfirmations=6; testnet/regtest inherit 120, override MinConfirmations=1 - PalladiumNetworks: align NBitcoin Consensus.CoinbaseMaturity to 120 - CachedUtxo: add IsCoinbase flag (ElectrumX listunspent does not expose this; derived from Transaction.IsCoinBase on the raw tx) - WalletSynchronizer: set IsCoinbase during local UTXO reconstruction - TransactionFactory.Build: add tipHeight param; filter by threshold; error reports reason (immature coinbase with X/121 counter, under- confirmed regulars with best-seen count, mempool amounts) - All Build() call sites updated (App, Donate, CLI) - Two new tests: coinbase maturity boundary (119→220 tip) and mainnet min-conf boundary (5→6 confirmations) --- .../ViewModels/MainWindowViewModel.Donate.cs | 2 +- .../ViewModels/MainWindowViewModel.Send.cs | 2 +- src/Cli/Program.cs | 2 +- src/Core/Chain/ChainProfile.cs | 6 ++ src/Core/Chain/ChainProfiles.cs | 3 + src/Core/Chain/PalladiumNetworks.cs | 2 +- src/Core/Spv/WalletSynchronizer.cs | 1 + src/Core/Storage/WalletDocument.cs | 1 + src/Core/Wallet/TransactionFactory.cs | 49 +++++++++++-- .../Wallet/TransactionFactoryTests.cs | 69 +++++++++++++++++-- 10 files changed, 120 insertions(+), 17 deletions(-) diff --git a/src/App/ViewModels/MainWindowViewModel.Donate.cs b/src/App/ViewModels/MainWindowViewModel.Donate.cs index b2eb638..beb7f36 100644 --- a/src/App/ViewModels/MainWindowViewModel.Donate.cs +++ b/src/App/ViewModels/MainWindowViewModel.Donate.cs @@ -49,7 +49,7 @@ public partial class MainWindowViewModel _pendingDonate = new TransactionFactory(_account).Build( _doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate, - _doc.Cache.NextChangeIndex, sendAll: false); + _doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, sendAll: false); DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " + $"fee {Fmt(_pendingDonate.Fee.Satoshi)} " + diff --git a/src/App/ViewModels/MainWindowViewModel.Send.cs b/src/App/ViewModels/MainWindowViewModel.Send.cs index 346bcce..38fec61 100644 --- a/src/App/ViewModels/MainWindowViewModel.Send.cs +++ b/src/App/ViewModels/MainWindowViewModel.Send.cs @@ -73,7 +73,7 @@ public partial class MainWindowViewModel _pendingSend = new TransactionFactory(_account).Build( _doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate, - _doc.Cache.NextChangeIndex, SendAll); + _doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, SendAll); SendPreview = $"txid {_pendingSend.Txid[..16]}… · " + $"fee {Fmt(_pendingSend.Fee.Satoshi)} " + diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index e5bed59..dbd5787 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -193,7 +193,7 @@ static async Task Send(string[] o) var built = new TransactionFactory(account).Build( doc.Cache.Utxos, transactions, destination, amount, feeRate, - doc.Cache.NextChangeIndex, sendAll); + doc.Cache.NextChangeIndex, doc.Cache.TipHeight, sendAll); Console.WriteLine($"txid: {built.Txid}"); Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " + diff --git a/src/Core/Chain/ChainProfile.cs b/src/Core/Chain/ChainProfile.cs index e9dd759..6f8674d 100644 --- a/src/Core/Chain/ChainProfile.cs +++ b/src/Core/Chain/ChainProfile.cs @@ -126,6 +126,12 @@ public sealed record ChainProfile /// Target block time in seconds (LWMA v2: 120s). public required int BlockTimeSeconds { get; init; } + /// Blocks a coinbase output must wait before it can be spent. + public required int CoinbaseMaturity { get; init; } + + /// Minimum confirmations required for a regular UTXO to be spendable. + public required int MinConfirmations { get; init; } + /// BIP32/SLIP-132 headers for each script type. public required IReadOnlyDictionary ExtKeyHeaders { get; init; } diff --git a/src/Core/Chain/ChainProfiles.cs b/src/Core/Chain/ChainProfiles.cs index 0ab8399..1d0fda1 100644 --- a/src/Core/Chain/ChainProfiles.cs +++ b/src/Core/Chain/ChainProfiles.cs @@ -26,6 +26,8 @@ public static class ChainProfiles ExplorerUrl = "https://explorer.palladium-coin.com/", SkipPowValidation = true, BlockTimeSeconds = 120, + CoinbaseMaturity = 120, + MinConfirmations = 6, ExtKeyHeaders = new Dictionary { [ScriptKind.Legacy] = new(0x0488ade4, 0x0488b21e), // xprv / xpub @@ -53,6 +55,7 @@ public static class ChainProfiles { Kind = NetKind.Testnet, NetName = "testnet", + MinConfirmations = 1, WifPrefix = 0xff, AddrP2pkh = 127, AddrP2sh = 115, diff --git a/src/Core/Chain/PalladiumNetworks.cs b/src/Core/Chain/PalladiumNetworks.cs index 7117250..a277b46 100644 --- a/src/Core/Chain/PalladiumNetworks.cs +++ b/src/Core/Chain/PalladiumNetworks.cs @@ -114,7 +114,7 @@ public static class PalladiumNetworks MajorityRejectBlockOutdated = 950, MajorityWindow = 1000, MinimumChainWork = uint256.Zero, - CoinbaseMaturity = 100, + CoinbaseMaturity = 120, SupportSegwit = true, SupportTaproot = true, ConsensusFactory = new ConsensusFactory(), diff --git a/src/Core/Spv/WalletSynchronizer.cs b/src/Core/Spv/WalletSynchronizer.cs index 860dfc5..e31c83c 100644 --- a/src/Core/Spv/WalletSynchronizer.cs +++ b/src/Core/Spv/WalletSynchronizer.cs @@ -225,6 +225,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl IsChange = addr.IsChange, AddressIndex = addr.Index, Height = txHeights[txid], + IsCoinbase = tx.IsCoinBase, }); } } diff --git a/src/Core/Storage/WalletDocument.cs b/src/Core/Storage/WalletDocument.cs index 1a1d7bd..0163211 100644 --- a/src/Core/Storage/WalletDocument.cs +++ b/src/Core/Storage/WalletDocument.cs @@ -150,5 +150,6 @@ public sealed class CachedUtxo public bool IsChange { get; set; } public int AddressIndex { get; set; } public int Height { get; set; } + public bool IsCoinbase { get; set; } public bool Frozen { get; set; } } diff --git a/src/Core/Wallet/TransactionFactory.cs b/src/Core/Wallet/TransactionFactory.cs index ca2c191..1465387 100644 --- a/src/Core/Wallet/TransactionFactory.cs +++ b/src/Core/Wallet/TransactionFactory.cs @@ -40,6 +40,7 @@ public sealed class TransactionFactory(IWalletAccount account) /// Amount; ignored when is true. /// Fixed fee rate in sat/vByte (§6.4). /// Index of the next change address (internal chain). + /// Current chain tip height, used to enforce confirmation thresholds. /// Send all: fee subtracted from the amount (§6.1). public BuiltTransaction Build( IReadOnlyList utxos, @@ -48,16 +49,52 @@ public sealed class TransactionFactory(IWalletAccount account) long amountSats, decimal feeRateSatPerVByte, int changeIndex, + int tipHeight, bool sendAll = false) { - // Only confirmed UTXOs are spent: mempool funds appear in the "pending" - // balance but are not spendable until confirmed. - var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList(); + 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(); + if (spendable.Count == 0) { - var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats); - throw new WalletSpendException(pending > 0 - ? $"No confirmed funds: {CoinAmount.Format(pending)} pending confirmation (not spendable)." + var reasons = new System.Text.StringBuilder(); + + var mempool = utxos.Where(u => !u.Frozen && u.Height <= 0).ToList(); + if (mempool.Count > 0) + reasons.Append($"{mempool.Count} output(s) unconfirmed ({CoinAmount.Format(mempool.Sum(u => u.ValueSats))} in mempool). "); + + var immature = utxos.Where(u => + !u.Frozen && u.Height > 0 && u.IsCoinbase && + (tipHeight - u.Height + 1) < coinbaseThreshold).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 underConf = utxos.Where(u => + !u.Frozen && u.Height > 0 && !u.IsCoinbase && + (tipHeight - u.Height + 1) < minConf).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). "); + } + + throw new WalletSpendException(reasons.Length > 0 + ? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}" : "No spendable UTXOs selected."); } diff --git a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs index 0f7c3e7..b6d2160 100644 --- a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs +++ b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs @@ -48,7 +48,7 @@ public class TransactionFactoryTests var built = new TransactionFactory(account).Build( utxos, txs, destination, amountSats: 400_000, - feeRateSatPerVByte: 2, changeIndex: 0); + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100); Assert.True(built.Signed); // Output: recipient + change. @@ -74,7 +74,7 @@ public class TransactionFactoryTests var built = new TransactionFactory(account).Build( utxos, txs, destination, amountSats: 0, - feeRateSatPerVByte: 1, changeIndex: 0, sendAll: true); + feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100, sendAll: true); var output = Assert.Single(built.Transaction.Outputs); Assert.Equal(500_000, output.Value.Satoshi + built.Fee.Satoshi); @@ -88,7 +88,7 @@ public class TransactionFactoryTests Assert.Throws(() => new TransactionFactory(account).Build( utxos, txs, account.GetReceiveAddress(1), amountSats: 900_000, - feeRateSatPerVByte: 2, changeIndex: 0)); + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100)); } [Fact] @@ -100,8 +100,8 @@ public class TransactionFactoryTests var ex = Assert.Throws(() => new TransactionFactory(account).Build( utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000, - feeRateSatPerVByte: 2, changeIndex: 0)); - Assert.Contains("pending confirmation", ex.Message); + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100)); + Assert.Contains("unconfirmed", ex.Message); } [Fact] @@ -113,7 +113,62 @@ public class TransactionFactoryTests Assert.Throws(() => new TransactionFactory(account).Build( utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000, - feeRateSatPerVByte: 2, changeIndex: 0)); + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100)); + } + + [Fact] + public void Gli_utxo_coinbase_immaturi_non_sono_spendibili() + { + var account = Account(); + var (utxos, txs) = Fund(account, 1_000_000); + utxos[0].IsCoinbase = true; + utxos[0].Height = 100; + // Threshold = COINBASE_MATURITY + 1 = 121 (mirrors the Qt wallet: consensus rule is + // nSpendHeight - nHeight >= 120, plus one block of safety margin). + // At height=100, tip=219 → confs = 219-100+1 = 120 < 121 → immature. + var ex = Assert.Throws(() => new TransactionFactory(account).Build( + utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000, + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 219)); + Assert.Contains("coinbase", ex.Message); + Assert.Contains("120/121", ex.Message); + + // tip=220 → confs = 121 ≥ 121 → mature and spendable. + var built = new TransactionFactory(account).Build( + utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000, + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 220); + Assert.True(built.Signed); + } + + [Fact] + public void Gli_utxo_con_meno_di_minconf_non_sono_spendibili_su_mainnet() + { + var mainnetAccount = HdAccount.FromMnemonic( + Bip39.TryParse("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", out var m) ? m! : throw new Exception(), + null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + + var funding = PalladiumNetworks.Mainnet.CreateTransaction(); + funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0))); + funding.Outputs.Add(Money.Satoshis(1_000_000), mainnetAccount.GetReceiveAddress(0)); + var txid = funding.GetHash().ToString(); + var utxos = new List + { + new() { Txid = txid, Vout = 0, ValueSats = 1_000_000, + Address = mainnetAccount.GetReceiveAddress(0).ToString(), + IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false }, + }; + var txs = new Dictionary { [txid] = funding }; + + // 5 confirmations (tipHeight=104): below the mainnet minimum of 6. + var ex = Assert.Throws(() => new TransactionFactory(mainnetAccount).Build( + utxos, txs, mainnetAccount.GetReceiveAddress(1), amountSats: 100_000, + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 104)); + Assert.Contains("5 so far", ex.Message); + + // 6 confirmations (tipHeight=105): exactly at the threshold → spendable. + var built = new TransactionFactory(mainnetAccount).Build( + utxos, txs, mainnetAccount.GetReceiveAddress(1), amountSats: 100_000, + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 105); + Assert.True(built.Signed); } [Fact] @@ -127,7 +182,7 @@ public class TransactionFactoryTests var built = new TransactionFactory(watchOnly).Build( utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000, - feeRateSatPerVByte: 2, changeIndex: 0); + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100); Assert.False(built.Signed); // Air-gapped flow (§6.5): the online-machine PSBT is signed