feat(wallet): enforce confirmation thresholds before spending UTXOs

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)
This commit is contained in:
2026-07-02 16:25:38 +02:00
parent 2be43d3177
commit 9aecdb1aaa
10 changed files with 120 additions and 17 deletions
@@ -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)} " +
@@ -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)} " +
+1 -1
View File
@@ -193,7 +193,7 @@ static async Task<int> 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)} " +
+6
View File
@@ -126,6 +126,12 @@ public sealed record ChainProfile
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
public required int BlockTimeSeconds { get; init; }
/// <summary>Blocks a coinbase output must wait before it can be spent.</summary>
public required int CoinbaseMaturity { get; init; }
/// <summary>Minimum confirmations required for a regular UTXO to be spendable.</summary>
public required int MinConfirmations { get; init; }
/// <summary>BIP32/SLIP-132 headers for each script type.</summary>
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
+3
View File
@@ -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, ExtKeyHeaders>
{
[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,
+1 -1
View File
@@ -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(),
+1
View File
@@ -225,6 +225,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
IsChange = addr.IsChange,
AddressIndex = addr.Index,
Height = txHeights[txid],
IsCoinbase = tx.IsCoinBase,
});
}
}
+1
View File
@@ -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; }
}
+43 -6
View File
@@ -40,6 +40,7 @@ public sealed class TransactionFactory(IWalletAccount account)
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param>
/// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
/// <param name="changeIndex">Index of the next change address (internal chain).</param>
/// <param name="tipHeight">Current chain tip height, used to enforce confirmation thresholds.</param>
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
public BuiltTransaction Build(
IReadOnlyList<CachedUtxo> 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.");
}
@@ -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<WalletSpendException>(() => 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<WalletSpendException>(() => 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<WalletSpendException>(() => 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<WalletSpendException>(() => 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<CachedUtxo>
{
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<string, Transaction> { [txid] = funding };
// 5 confirmations (tipHeight=104): below the mainnet minimum of 6.
var ex = Assert.Throws<WalletSpendException>(() => 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