using NBitcoin; using NBitcoin.Policy; using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Crypto; using PalladiumWallet.Core.Storage; namespace PalladiumWallet.Core.Wallet; /// Result of a transaction build operation. public sealed class BuiltTransaction { public required Transaction Transaction { get; init; } public required Money Fee { get; init; } public required FeeRate FeeRate { get; init; } public required bool Signed { get; init; } /// PSBT for watch-only/air-gapped/multisig flows (§6.5). public required PSBT Psbt { get; init; } public string ToHex() => Transaction.ToHex(); public string Txid => Transaction.GetHash().ToString(); } /// /// Transaction construction and signing (blueprint §6) on top of NBitcoin primitives: /// coin selection (manual or automatic), fixed fee rate, send-all with fee subtracted, /// change on the internal chain, RBF on by default. /// With a watch-only account produces an unsigned PSBT (§6.5). /// public sealed class TransactionFactory(IWalletAccount account) { private const int MaxStandardTransactionVirtualSize = 100_000; private Network Network => PalladiumNetworks.For(account.Profile.Kind); /// /// Builds (and signs if possible) a transaction. /// /// Selected UTXOs (coin control §6.2) or all spendable ones. /// Source transactions for the UTXOs (txid → tx), from sync. /// Recipient address. /// 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, IReadOnlyDictionary transactions, BitcoinAddress destination, long amountSats, decimal feeRateSatPerVByte, int changeIndex, int tipHeight, bool sendAll = false) { var profile = account.Profile; var spendable = utxos.Where(u => u.IsSpendable(profile, tipHeight)).ToList(); if (spendable.Count == 0) { 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 && u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList(); if (immature.Count > 0) { var bestImmatureConf = immature.Max(u => u.Confirmations(tipHeight)); var threshold = profile.CoinbaseMaturity + 1; reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({bestImmatureConf}/{threshold} confirmations). "); } var underConf = utxos.Where(u => !u.Frozen && u.Height > 0 && !u.IsCoinbase && u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList(); if (underConf.Count > 0) { var bestUnderConf = underConf.Max(u => u.Confirmations(tipHeight)); reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({bestUnderConf} so far). "); } var unverified = utxos.Where(u => !u.Frozen && u.Height > 0 && !u.Verified && u.Confirmations(tipHeight) >= u.RequiredConfirmations(profile)).ToList(); if (unverified.Count > 0) reasons.Append($"{unverified.Count} output(s) confirmed but still awaiting Merkle-proof verification " + $"({CoinAmount.Format(unverified.Sum(u => u.ValueSats))}). "); throw new WalletSpendException(reasons.Length > 0 ? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}" : "No spendable UTXOs selected."); } var ordered = spendable .OrderByDescending(u => u.ValueSats) .ThenBy(u => u.Height) .ThenBy(u => u.Txid, StringComparer.Ordinal) .ThenBy(u => u.Vout) .ToList(); var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000); if (sendAll) { try { return BuildWithSelectedUtxos( ordered, transactions, destination, amountSats, feeRate, changeIndex, sendAll: true, totalSpendableCount: ordered.Count); } catch (TransactionTooLargeException ex) { throw new WalletSpendException(ex.Message); } } NotEnoughFundsException? lastInsufficientFunds = null; TransactionTooLargeException? tooLarge = null; BuiltTransaction? best = null; var low = 1; var high = ordered.Count; while (low <= high) { var count = low + ((high - low) / 2); try { best = BuildWithSelectedUtxos( ordered.Take(count).ToList(), transactions, destination, amountSats, feeRate, changeIndex, sendAll: false, totalSpendableCount: ordered.Count); high = count - 1; } catch (NotEnoughFundsException ex) { lastInsufficientFunds = ex; low = count + 1; } catch (TransactionTooLargeException ex) { tooLarge = ex; high = count - 1; } } if (best is not null) return best; if (tooLarge is not null) throw new WalletSpendException(tooLarge.Message); throw new WalletSpendException(lastInsufficientFunds is null ? "Insufficient funds." : $"Insufficient funds: {lastInsufficientFunds.Message}"); } private BuiltTransaction BuildWithSelectedUtxos( IReadOnlyList selectedUtxos, IReadOnlyDictionary transactions, BitcoinAddress destination, long amountSats, FeeRate feeRate, int changeIndex, bool sendAll, int totalSpendableCount) { var coins = selectedUtxos.Select(u => new Coin( new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout), transactions[u.Txid].Outputs[u.Vout])).ToList(); var builder = Network.CreateTransactionBuilder(); builder.SetVersion(2); // RBF sequence to allow fee bumping (§6.6). builder.OptInRBF = true; builder.AddCoins(coins); builder.SetChange(account.GetChangeAddress(changeIndex)); builder.SendEstimatedFees(feeRate); if (sendAll) builder.Send(destination, coins.Sum(c => (Money)c.Amount)).SubtractFees(); else builder.Send(destination, Money.Satoshis(amountSats)); if (!account.IsWatchOnly) { builder.AddKeys(selectedUtxos .Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex)) .OfType() .ToArray()); } Transaction tx; try { tx = builder.BuildTransaction(sign: !account.IsWatchOnly); } catch (NotEnoughFundsException ex) when (ex.Message.Contains("size would be too high", StringComparison.OrdinalIgnoreCase)) { // NBitcoin's coin selector refuses to assemble a combination over the standard size // cap itself and reports it through NotEnoughFundsException rather than ever handing // back an oversized transaction — the GetVirtualSize() check below is unreachable for // this case and exists only as a defense-in-depth net for other NBitcoin versions. throw new TransactionTooLargeException( BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll)); } catch (NotEnoughFundsException) when (!sendAll) { throw; } catch (NotEnoughFundsException ex) { throw new WalletSpendException($"Insufficient funds: {ex.Message}"); } if (tx.GetVirtualSize() > MaxStandardTransactionVirtualSize) throw new TransactionTooLargeException(BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll, tx.GetVirtualSize())); if (!account.IsWatchOnly) { if (!builder.Verify(tx, out TransactionPolicyError[] errors)) throw new WalletSpendException( "Invalid transaction: " + string.Join("; ", errors.Select(e => e.ToString()))); } return new BuiltTransaction { Transaction = tx, Fee = GetFee(tx, coins), FeeRate = feeRate, Signed = !account.IsWatchOnly, Psbt = builder.BuildPSBT(sign: !account.IsWatchOnly), }; } private static string BuildTooLargeMessage( int selectedInputCount, int totalSpendableCount, bool sendAll, int? actualVirtualSize = null) { var prefix = sendAll ? "Send-all cannot fit in one standard transaction" : "Transaction cannot fit in one standard transaction"; var size = actualVirtualSize is { } vsize ? $"{vsize} vB exceeds" : "Estimated size exceeds"; return $"{prefix}: {size} the {MaxStandardTransactionVirtualSize} vB standard relay limit " + $"with {selectedInputCount}/{totalSpendableCount} spendable input(s). Send a smaller amount or consolidate in multiple smaller transactions."; } private static Money GetFee(Transaction tx, IReadOnlyList coins) { var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet(); var inputSum = coins.Where(c => spentOutpoints.Contains(c.Outpoint)) .Sum(c => (Money)c.Amount); return inputSum - tx.Outputs.Sum(o => o.Value); } private sealed class TransactionTooLargeException(string message) : Exception(message); } /// Error during transaction construction/signing (funds, policy, parameters). public sealed class WalletSpendException(string message) : Exception(message);