From 3d6597a3b23b6bed41db91c3c09690fb2d885f82 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 11 Jun 2026 10:47:40 +0200 Subject: [PATCH] feat(wallet): coin selection, tx factory, wallet loader --- src/Core/Wallet/CoinAmount.cs | 35 ++++++++ src/Core/Wallet/TransactionFactory.cs | 119 ++++++++++++++++++++++++++ src/Core/Wallet/WalletLoader.cs | 59 +++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 src/Core/Wallet/CoinAmount.cs create mode 100644 src/Core/Wallet/TransactionFactory.cs create mode 100644 src/Core/Wallet/WalletLoader.cs diff --git a/src/Core/Wallet/CoinAmount.cs b/src/Core/Wallet/CoinAmount.cs new file mode 100644 index 0000000..35f7006 --- /dev/null +++ b/src/Core/Wallet/CoinAmount.cs @@ -0,0 +1,35 @@ +using System.Globalization; + +namespace PalladiumWallet.Core.Wallet; + +/// +/// Conversione satoshi ↔ unità coin (8 decimali) per visualizzazione e input. +/// Si lavora sempre in satoshi internamente; la stringa è solo presentazione. +/// +public static class CoinAmount +{ + public const long SatsPerCoin = 100_000_000; + + public static string Format(long sats, string unit = "") => + (sats / (decimal)SatsPerCoin).ToString("0.00000000", CultureInfo.InvariantCulture) + + (unit.Length > 0 ? " " + unit : ""); + + /// Parsa un importo in coin (punto o virgola decimale) in satoshi. + public static bool TryParseCoins(string text, out long sats) + { + sats = 0; + text = text.Trim().Replace(',', '.'); + if (!decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var coins) + || coins < 0) + return false; + try + { + sats = (long)(coins * SatsPerCoin); + } + catch (OverflowException) + { + return false; + } + return true; + } +} diff --git a/src/Core/Wallet/TransactionFactory.cs b/src/Core/Wallet/TransactionFactory.cs new file mode 100644 index 0000000..a5ac913 --- /dev/null +++ b/src/Core/Wallet/TransactionFactory.cs @@ -0,0 +1,119 @@ +using NBitcoin; +using NBitcoin.Policy; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; +using PalladiumWallet.Core.Storage; + +namespace PalladiumWallet.Core.Wallet; + +/// Esito della costruzione di una transazione. +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 per i flussi watch-only/air-gapped/multisig (§6.5). + public required PSBT Psbt { get; init; } + + public string ToHex() => Transaction.ToHex(); + public string Txid => Transaction.GetHash().ToString(); +} + +/// +/// Costruzione e firma delle transazioni (blueprint §6) sopra le primitive +/// NBitcoin: selezione monete (manuale o automatica), fee a rate fisso, +/// invia-tutto con fee sottratta, change sulla catena interna, RBF di default. +/// Con un account watch-only produce la PSBT non firmata (§6.5). +/// +public sealed class TransactionFactory(HdAccount account) +{ + private Network Network => PalladiumNetworks.For(account.Profile.Kind); + + /// + /// Costruisce (e se possibile firma) una transazione. + /// + /// UTXO selezionati (coin control §6.2) o tutti quelli spendibili. + /// Tx di provenienza degli UTXO (txid → tx), dalla sincronizzazione. + /// Indirizzo destinatario. + /// Importo; ignorato se . + /// Fee rate fisso in sat/vByte (§6.4). + /// Indice del prossimo indirizzo di change (catena interna). + /// Invia tutto: fee sottratta dall'importo (§6.1). + public BuiltTransaction Build( + IReadOnlyList utxos, + IReadOnlyDictionary transactions, + BitcoinAddress destination, + long amountSats, + decimal feeRateSatPerVByte, + int changeIndex, + bool sendAll = false) + { + var spendable = utxos.Where(u => !u.Frozen).ToList(); + if (spendable.Count == 0) + throw new WalletSpendException("Nessun UTXO spendibile selezionato."); + + 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); + // Sequence RBF per consentire il bump della fee (§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.GetExtPrivateKey(u.IsChange, u.AddressIndex)) + .ToArray()); + } + + Transaction tx; + try + { + tx = builder.BuildTransaction(sign: !account.IsWatchOnly); + } + catch (NotEnoughFundsException ex) + { + throw new WalletSpendException($"Fondi insufficienti: {ex.Message}"); + } + + if (!account.IsWatchOnly) + { + if (!builder.Verify(tx, out TransactionPolicyError[] errors)) + throw new WalletSpendException( + "Transazione non valida: " + 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); + } +} + +/// Errore di costruzione/firma della spesa (fondi, policy, parametri). +public sealed class WalletSpendException(string message) : Exception(message); diff --git a/src/Core/Wallet/WalletLoader.cs b/src/Core/Wallet/WalletLoader.cs new file mode 100644 index 0000000..2395b83 --- /dev/null +++ b/src/Core/Wallet/WalletLoader.cs @@ -0,0 +1,59 @@ +using NBitcoin; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; +using PalladiumWallet.Core.Storage; + +namespace PalladiumWallet.Core.Wallet; + +/// +/// Ponte documento wallet ↔ dominio (blueprint §4.5): dal file ricostruisce +/// l'HdAccount giusto (da seed o watch-only da xpub). È l'embrione della +/// factory dei tipi di wallet; crescerà con multisig e importati. +/// +public static class WalletLoader +{ + public static ChainProfile ProfileOf(WalletDocument doc) => + ChainProfiles.For(Enum.Parse(doc.Network, ignoreCase: true)); + + public static HdAccount ToAccount(WalletDocument doc) + { + var profile = ProfileOf(doc); + var kind = Enum.Parse(doc.ScriptKind); + var path = KeyPath.Parse(doc.AccountPath); + + if (doc.Mnemonic is { } words) + { + if (!Bip39.TryParse(words, out var mnemonic)) + throw new InvalidDataException("Mnemonica del file wallet non valida."); + return HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, doc.Passphrase), kind, profile, path); + } + + if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _)) + throw new InvalidDataException("Xpub del file wallet non valida per questa rete."); + return HdAccount.FromAccountXpub(xpub!, kind, profile, path); + } + + /// Crea il documento per un nuovo wallet da seed. + public static (WalletDocument Doc, HdAccount Account) NewFromMnemonic( + string words, string? passphrase, ScriptKind kind, ChainProfile profile, KeyPath? customPath = null) + { + if (!Bip39.TryParse(words, out var mnemonic)) + throw new InvalidDataException("Mnemonica non valida (parole o checksum errati)."); + + var account = customPath is null + ? HdAccount.FromMnemonic(mnemonic!, passphrase, kind, profile) + : HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, passphrase), kind, profile, customPath); + + var doc = new WalletDocument + { + Network = profile.NetName, + ScriptKind = kind.ToString(), + Mnemonic = words.Trim(), + Passphrase = passphrase, + AccountPath = account.AccountPath.ToString(), + AccountXpub = account.ToSlip132(), + MasterFingerprint = Convert.ToHexString(account.MasterFingerprint.ToBytes()).ToLowerInvariant(), + }; + return (doc, account); + } +}