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 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).
/// Send all: fee subtracted from the amount (§6.1).
public BuiltTransaction Build(
IReadOnlyList utxos,
IReadOnlyDictionary transactions,
BitcoinAddress destination,
long amountSats,
decimal feeRateSatPerVByte,
int changeIndex,
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();
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)."
: "No spendable UTXOs selected.");
}
var coins = spendable.Select(u => new Coin(
new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout),
transactions[u.Txid].Outputs[u.Vout])).ToList();
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
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(spendable
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
.OfType()
.ToArray());
}
Transaction tx;
try
{
tx = builder.BuildTransaction(sign: !account.IsWatchOnly);
}
catch (NotEnoughFundsException ex)
{
throw new WalletSpendException($"Insufficient funds: {ex.Message}");
}
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 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);
}
}
/// Error during transaction construction/signing (funds, policy, parameters).
public sealed class WalletSpendException(string message) : Exception(message);