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:
@@ -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
@@ -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)} " +
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -225,6 +225,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
IsChange = addr.IsChange,
|
||||
AddressIndex = addr.Index,
|
||||
Height = txHeights[txid],
|
||||
IsCoinbase = tx.IsCoinBase,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user