10 Commits

Author SHA1 Message Date
davide ece634f42e feat(wallet): show pending mempool balance and exclude unconfirmed UTXOs from spending
TransactionFactory now selects only confirmed UTXOs (height > 0), so
mempool funds are visible as pending balance but never spendable, with
a clear error when only unconfirmed funds are available. GUI and CLI
labels updated to "in attesa di conferma (non spendibile)".
2026-06-11 11:34:21 +02:00
davide af10482e54 feat(cli): servers command, info --addresses, registry fallback for --server 2026-06-11 11:28:00 +02:00
davide 46d8c19784 feat(app): server selector, peer discovery, address view, menu bar, keep-alive 2026-06-11 11:27:43 +02:00
davide 08217e5306 feat(spv): per-address balance and tx count in sync result 2026-06-11 11:27:31 +02:00
davide 1c2dacc27c feat(net): server registry, bootstrap servers, peer discovery 2026-06-11 11:27:16 +02:00
davide 34c588c6f4 test: unit tests for all core modules 2026-06-11 10:48:27 +02:00
davide 0e32b9bc9b feat(cli): command-line interface 2026-06-11 10:48:12 +02:00
davide bb8563580f feat(app): Avalonia UI shell 2026-06-11 10:47:56 +02:00
davide 3d6597a3b2 feat(wallet): coin selection, tx factory, wallet loader 2026-06-11 10:47:43 +02:00
davide 4d2ae1fd13 feat(storage): encrypted wallet file and persistence 2026-06-11 10:47:31 +02:00
33 changed files with 3078 additions and 2 deletions
+15
View File
@@ -0,0 +1,15 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PalladiumWallet.App.App"
xmlns:local="using:PalladiumWallet.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+31
View File
@@ -0,0 +1,31 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System.Linq;
using Avalonia.Markup.Xaml;
using PalladiumWallet.App.ViewModels;
using PalladiumWallet.App.Views;
namespace PalladiumWallet.App;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
}
base.OnFrameworkInitializationCompleted();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

+24
View File
@@ -0,0 +1,24 @@
using Avalonia;
using System;
namespace PalladiumWallet.App;
sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using PalladiumWallet.App.ViewModels;
namespace PalladiumWallet.App;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null)
return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
+565
View File
@@ -0,0 +1,565 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.App.ViewModels;
/// <summary>Riga dello storico transazioni per la vista.</summary>
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
/// <summary>Riga della vista indirizzi (stile Electrum): saldo e uso per indirizzo.</summary>
public sealed record AddressRow(string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx);
/// <summary>
/// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard):
/// pannello di setup (crea/ripristina/apri) e pannello wallet
/// (saldo, ricevi, storico, invia, server).
/// </summary>
public partial class MainWindowViewModel : ViewModelBase
{
private WalletDocument? _doc;
private HdAccount? _account;
private string? _walletPath;
private string? _password;
private ElectrumClient? _client;
private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
private BuiltTransaction? _pendingSend;
/// <summary>File in attesa di password (apertura da menu File → Apri).</summary>
private string? _pendingOpenPath;
/// <summary>Dopo la prima connessione riuscita il timer riconnette da solo.</summary>
private bool _autoReconnect;
private readonly DispatcherTimer _keepAliveTimer;
// ---- selezione rete e stato pannelli ----
public string[] Networks { get; } = ["mainnet", "testnet", "regtest"];
[ObservableProperty]
private string selectedNetwork = "mainnet";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsSetupVisible))]
private bool isWalletOpen;
public bool IsSetupVisible => !IsWalletOpen;
[ObservableProperty]
private bool walletFileExists;
[ObservableProperty]
private string statusMessage = "";
// ---- pannello setup ----
[ObservableProperty]
private string mnemonicInput = "";
[ObservableProperty]
private string passphraseInput = "";
[ObservableProperty]
private string passwordInput = "";
// ---- pannello wallet ----
[ObservableProperty]
private string balanceText = "—";
[ObservableProperty]
private string unconfirmedText = "";
[ObservableProperty]
private string networkInfo = "";
[ObservableProperty]
private string receiveAddress = "";
[ObservableProperty]
private string serverInput = "";
[ObservableProperty]
private bool useSsl;
[ObservableProperty]
private string connectionStatus = "non connesso";
[ObservableProperty]
private bool isConnected;
[ObservableProperty]
private bool isSyncing;
public ObservableCollection<KnownServer> KnownServers { get; } = [];
[ObservableProperty]
private KnownServer? selectedKnownServer;
public ObservableCollection<HistoryRow> History { get; } = [];
public ObservableCollection<AddressRow> Addresses { get; } = [];
// ---- pannello invia ----
[ObservableProperty]
private string sendTo = "";
[ObservableProperty]
private string sendAmount = "";
[ObservableProperty]
private string sendFeeRate = "1";
[ObservableProperty]
private bool sendAll;
[ObservableProperty]
private string sendPreview = "";
[ObservableProperty]
private bool hasPendingSend;
public MainWindowViewModel()
{
RefreshSetupState();
// Aggiornamenti continui (§9): ping periodico per tenere viva la
// connessione e accorgersi subito della caduta; se cade, riconnette
// e risincronizza da solo. Le notifiche push restano la via principale.
_keepAliveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(20) };
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
_keepAliveTimer.Start();
}
private async Task KeepAliveTickAsync()
{
if (!IsWalletOpen || IsSyncing)
return;
if (_client is { IsConnected: true })
{
try
{
await _client.PingAsync();
}
catch
{
// La caduta viene gestita dall'evento Disconnected.
}
}
else if (_autoReconnect)
{
ConnectionStatus = "riconnessione…";
await ConnectAndSync();
}
}
private NetKind Net => Enum.Parse<NetKind>(SelectedNetwork, ignoreCase: true);
private ChainProfile Profile => ChainProfiles.For(Net);
partial void OnSelectedNetworkChanged(string value) => RefreshSetupState();
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
private void RefreshSetupState()
{
WalletFileExists = WalletStore.Exists(AppPaths.DefaultWalletPath(Net));
StatusMessage = WalletFileExists
? "Trovato un wallet esistente: inserisci la password (se impostata) e apri."
: "Nessun wallet su questa rete: creane uno nuovo o ripristina da seed.";
RefreshServers();
}
private void RefreshServers()
{
KnownServers.Clear();
foreach (var server in Registry.All)
KnownServers.Add(server);
SelectedKnownServer = KnownServers.FirstOrDefault();
if (SelectedKnownServer is null)
ServerInput = $"127.0.0.1:{Profile.DefaultTcpPort}";
}
partial void OnSelectedKnownServerChanged(KnownServer? value)
{
if (value is not null)
ServerInput = $"{value.Host}:{value.PortFor(UseSsl)}";
}
partial void OnUseSslChanged(bool value)
{
if (SelectedKnownServer is { } server)
ServerInput = $"{server.Host}:{server.PortFor(value)}";
}
// ---------- comandi setup ----------
[RelayCommand]
private void GenerateMnemonic()
{
MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
StatusMessage = "Nuova mnemonica generata: SCRIVILA SU CARTA prima di continuare.";
}
[RelayCommand]
private void CreateOrRestore()
{
try
{
var (doc, account) = WalletLoader.NewFromMnemonic(
MnemonicInput,
string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput,
ScriptKind.NativeSegwit,
Profile);
// Mai sovrascrivere un wallet esistente: si cerca il primo nome libero.
var path = AppPaths.DefaultWalletPath(Net);
for (var n = 2; WalletStore.Exists(path); n++)
path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json");
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
WalletStore.Save(doc, path, password);
OpenLoaded(doc, account, path, password);
}
catch (Exception ex)
{
StatusMessage = $"Errore: {ex.Message}";
}
}
[RelayCommand]
private void OpenExisting()
{
try
{
var path = _pendingOpenPath ?? AppPaths.DefaultWalletPath(Net);
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
var doc = WalletStore.Load(path, password);
_pendingOpenPath = null;
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password);
}
catch (WrongPasswordException)
{
StatusMessage = "Password errata.";
}
catch (Exception ex)
{
StatusMessage = $"Errore: {ex.Message}";
}
}
/// <summary>Apertura di un file wallet qualunque (menu File → Apri, multi-wallet §8).</summary>
public void OpenFromPath(string path)
{
try
{
if (IsWalletOpen)
CloseWallet();
var doc = WalletStore.Load(path);
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password: null);
}
catch (WrongPasswordException)
{
// Cifrato: si chiede la password nel pannello di apertura.
if (IsWalletOpen)
CloseWallet();
_pendingOpenPath = path;
WalletFileExists = true;
StatusMessage = $"Il wallet \"{Path.GetFileName(path)}\" è cifrato: inserisci la password e premi Apri.";
}
catch (Exception ex)
{
StatusMessage = $"Errore: {ex.Message}";
}
}
/// <summary>Torna al pannello di setup per creare/ripristinare un altro wallet.</summary>
[RelayCommand]
private void NewWallet()
{
if (IsWalletOpen)
CloseWallet();
_pendingOpenPath = null;
StatusMessage = "Crea un nuovo wallet o ripristina da seed. ATTENZIONE: sovrascrive il wallet di default della rete selezionata.";
}
private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password)
{
// La rete del wallet comanda (registry, pin TLS, indirizzi).
SelectedNetwork = doc.Network;
_doc = doc;
_account = account;
_walletPath = path;
_password = password;
MnemonicInput = PassphraseInput = PasswordInput = "";
NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}"
+ (doc.IsWatchOnly ? " · watch-only" : "");
ApplyCache(doc.Cache);
IsWalletOpen = true;
StatusMessage = doc.Cache is null
? "Wallet aperto. Connettiti a un server per sincronizzare."
: "Wallet aperto (dati dell'ultima sincronizzazione).";
}
private void ApplyCache(SyncCache? cache)
{
if (_account is null)
return;
if (cache is null)
{
BalanceText = $"0.00000000 {Profile.CoinUnit}";
UnconfirmedText = "";
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
History.Clear();
// Prima della sincronizzazione si mostrano i primi indirizzi derivati.
Addresses.Clear();
for (var i = 0; i < 10; i++)
Addresses.Add(new AddressRow("ricezione", i,
_account.GetReceiveAddress(i).ToString(), "—", "—"));
return;
}
BalanceText = CoinAmount.Format(cache.ConfirmedSats, Profile.CoinUnit);
// Saldo in attesa: somma delle tx in mempool (può essere negativo per
// gli invii in uscita non ancora confermati). Non è spendibile finché
// non conferma: la TransactionFactory usa solo UTXO confermati.
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
UnconfirmedText = pending != 0
? $"in attesa di conferma: {(pending > 0 ? "+" : "")}{CoinAmount.Format(pending)} — non ancora spendibile"
: "";
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
History.Clear();
foreach (var tx in cache.History)
History.Add(new HistoryRow(
tx.Height > 0 ? tx.Height.ToString() : "mempool",
(tx.DeltaSats >= 0 ? "+" : "") + CoinAmount.Format(tx.DeltaSats),
tx.Txid,
tx.Verified ? "✓ SPV" : "—"));
Addresses.Clear();
foreach (var a in cache.Addresses)
Addresses.Add(new AddressRow(
a.IsChange ? "change" : "ricezione",
a.Index,
a.Address,
a.BalanceSats > 0 ? CoinAmount.Format(a.BalanceSats) : (a.TxCount > 0 ? "0" : "—"),
a.TxCount > 0 ? a.TxCount.ToString() : "—"));
}
// ---------- comandi wallet ----------
[RelayCommand]
private async Task ConnectAndSync()
{
if (_account is null || _doc is null)
return;
IsSyncing = true;
StatusMessage = "";
try
{
if (_client is null || !_client.IsConnected)
{
var (host, port) = ParseServer();
ConnectionStatus = $"connessione a {host}:{port}…";
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
_client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins);
_client.NotificationReceived += OnServerNotification;
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
{
IsConnected = false;
ConnectionStatus = "disconnesso";
});
IsConnected = true;
_autoReconnect = true;
ConnectionStatus = $"connesso a {host}:{port}{(UseSsl ? " (TLS)" : "")}";
}
var sync = new WalletSynchronizer(_account, _client, _doc.GapLimit);
sync.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
var result = await sync.SyncOnceAsync();
_lastTransactions = result.Transactions;
_doc.Cache = new SyncCache
{
TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats,
NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex,
History = [.. result.History],
Utxos = [.. result.Utxos],
Addresses = [.. result.AddressRows],
};
WalletStore.Save(_doc, _walletPath!, _password);
ApplyCache(_doc.Cache);
StatusMessage = $"Sincronizzato: altezza {result.TipHeight}, " +
$"{result.History.Count} transazioni verificate SPV.";
}
catch (CertificatePinMismatchException ex)
{
IsConnected = false;
ConnectionStatus = "certificato cambiato";
StatusMessage = ex.Message;
}
catch (Exception ex)
{
IsConnected = _client?.IsConnected == true;
ConnectionStatus = IsConnected ? ConnectionStatus : "errore di connessione";
StatusMessage = $"Errore: {ex.Message}";
}
finally
{
IsSyncing = false;
}
}
/// <summary>Scopre nuovi server dai peer annunciati dal server connesso (§9).</summary>
[RelayCommand]
private async Task DiscoverServers()
{
if (_client is null || !_client.IsConnected)
{
StatusMessage = "Connettiti a un server prima di cercare i peer.";
return;
}
try
{
var added = await Registry.DiscoverAsync(_client);
var selected = SelectedKnownServer;
RefreshServers();
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
?? KnownServers.FirstOrDefault();
StatusMessage = added > 0
? $"Trovati {added} nuovi server dai peer (totale {KnownServers.Count})."
: $"Nessun nuovo server annunciato (totale {KnownServers.Count}).";
}
catch (Exception ex)
{
StatusMessage = $"Errore nella scoperta peer: {ex.Message}";
}
}
private void OnServerNotification(string method, System.Text.Json.JsonElement payload)
{
// Cambiamento su un nostro indirizzo o nuovo blocco: risincronizza.
if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe")
Dispatcher.UIThread.Post(() =>
{
if (!IsSyncing)
_ = ConnectAndSync();
});
}
[RelayCommand]
private void ResetCertificates()
{
new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll();
StatusMessage = "Certificati SSL azzerati: riprova la connessione.";
}
[RelayCommand]
private async Task PrepareSend()
{
if (_account is null || _doc?.Cache is null)
{
SendPreview = "Sincronizza prima di inviare.";
return;
}
try
{
if (_lastTransactions is null)
{
SendPreview = "Connettiti al server e sincronizza prima di inviare.";
return;
}
var destination = BitcoinAddress.Create(SendTo.Trim(), PalladiumNetworks.For(Net));
long amount = 0;
if (!SendAll && !CoinAmount.TryParseCoins(SendAmount, out amount))
{
SendPreview = "Importo non valido.";
return;
}
if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0)
{
SendPreview = "Fee rate non valido.";
return;
}
_pendingSend = new TransactionFactory(_account).Build(
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
_doc.Cache.NextChangeIndex, SendAll);
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
$"fee {CoinAmount.Format(_pendingSend.Fee.Satoshi, Profile.CoinUnit)} " +
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
(_pendingSend.Signed ? "" : " · NON firmata (watch-only)");
HasPendingSend = _pendingSend.Signed;
}
catch (Exception ex)
{
_pendingSend = null;
HasPendingSend = false;
SendPreview = $"Errore: {ex.Message}";
}
await Task.CompletedTask;
}
[RelayCommand]
private async Task ConfirmSend()
{
if (_pendingSend is null || _client is null)
return;
try
{
var txid = await _client.BroadcastAsync(_pendingSend.ToHex());
SendPreview = $"Trasmessa: {txid}";
SendTo = SendAmount = "";
_pendingSend = null;
HasPendingSend = false;
await ConnectAndSync();
}
catch (Exception ex)
{
SendPreview = $"Errore broadcast: {ex.Message}";
}
}
[RelayCommand]
private void CloseWallet()
{
_ = _client?.DisposeAsync().AsTask();
_client = null;
_doc = null;
_account = null;
_lastTransactions = null;
_pendingSend = null;
HasPendingSend = false;
History.Clear();
IsWalletOpen = false;
IsConnected = false;
ConnectionStatus = "non connesso";
RefreshSetupState();
}
private (string Host, int Port) ParseServer()
{
var parts = ServerInput.Trim().Split(':');
var host = parts[0];
var port = parts.Length > 1 && int.TryParse(parts[1], out var p)
? p
: UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort;
return (host, port);
}
}
+7
View File
@@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PalladiumWallet.App.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}
+212
View File
@@ -0,0 +1,212 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PalladiumWallet.App.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="620"
x:Class="PalladiumWallet.App.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Width="900" Height="620"
Title="Palladium Wallet">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid RowDefinitions="Auto,*,Auto">
<!-- ============ MENU (stile Electrum) ============ -->
<Menu Grid.Row="0">
<MenuItem Header="_File">
<MenuItem Header="Nuovo / ripristina wallet…" Command="{Binding NewWalletCommand}"/>
<MenuItem Header="Apri wallet da file…" Click="OnOpenWalletFileClick"/>
<Separator/>
<MenuItem Header="Chiudi wallet" Command="{Binding CloseWalletCommand}"
IsEnabled="{Binding IsWalletOpen}"/>
</MenuItem>
<MenuItem Header="_Rete">
<MenuItem Header="Cerca altri server (peer)" Command="{Binding DiscoverServersCommand}"/>
<MenuItem Header="Reset certificati SSL" Command="{Binding ResetCertificatesCommand}"/>
</MenuItem>
</Menu>
<!-- ============ PANNELLO SETUP: crea / ripristina / apri ============ -->
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
<StackPanel MaxWidth="560" Margin="24" Spacing="14">
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="Rete:" VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding Networks}"
SelectedItem="{Binding SelectedNetwork}"
MinWidth="140"/>
</StackPanel>
<!-- Apri wallet esistente -->
<Border IsVisible="{Binding WalletFileExists}"
BorderBrush="{DynamicResource SystemAccentColor}" BorderThickness="1"
CornerRadius="6" Padding="14">
<StackPanel Spacing="8">
<TextBlock Text="Apri il wallet esistente" FontWeight="Bold"/>
<TextBox PlaceholderText="Password del file (vuoto se non impostata)"
PasswordChar="●" Text="{Binding PasswordInput}"/>
<Button Content="Apri wallet" Command="{Binding OpenExistingCommand}"/>
</StackPanel>
</Border>
<!-- Crea / ripristina -->
<Border BorderBrush="Gray" BorderThickness="1" CornerRadius="6" Padding="14">
<StackPanel Spacing="8">
<TextBlock Text="Crea nuovo o ripristina da seed" FontWeight="Bold"/>
<TextBox PlaceholderText="Mnemonica BIP39 (12 o 24 parole)"
AcceptsReturn="False" Text="{Binding MnemonicInput}"/>
<Button Content="Genera nuova mnemonica" Command="{Binding GenerateMnemonicCommand}"/>
<TextBox PlaceholderText="Passphrase BIP39 opzionale (cambia il wallet! annotala a parte)"
Text="{Binding PassphraseInput}"/>
<TextBox PlaceholderText="Password di cifratura del file wallet (consigliata)"
PasswordChar="●" Text="{Binding PasswordInput}"/>
<Button Content="Crea / ripristina wallet" Command="{Binding CreateOrRestoreCommand}"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- ============ PANNELLO WALLET ============ -->
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*" IsVisible="{Binding IsWalletOpen}" Margin="16">
<!-- Testata: saldo + rete + chiudi -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding BalanceText}" FontSize="30" FontWeight="Bold"/>
<TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"/>
<TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/>
</StackPanel>
<Button Grid.Column="1" Content="Chiudi wallet" VerticalAlignment="Top"
Command="{Binding CloseWalletCommand}"/>
</Grid>
<!-- Server -->
<Border Grid.Row="1" Margin="0,12,0,12" Padding="10"
BorderBrush="Gray" BorderThickness="1" CornerRadius="6">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Server:"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<ComboBox Grid.Row="0" Grid.Column="1"
ItemsSource="{Binding KnownServers}"
SelectedItem="{Binding SelectedKnownServer}"
HorizontalAlignment="Stretch"/>
<CheckBox Grid.Row="0" Grid.Column="2" Content="TLS"
IsChecked="{Binding UseSsl}" Margin="8,0"/>
<Button Grid.Row="0" Grid.Column="3" Content="Connetti e sincronizza"
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal"
Spacing="8" Margin="0,8,0,0">
<TextBox Text="{Binding ServerInput}" MinWidth="220"
PlaceholderText="oppure host:porta manuale"/>
<Button Content="Cerca altri server"
Command="{Binding DiscoverServersCommand}"/>
<Button Content="Reset cert."
Command="{Binding ResetCertificatesCommand}"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="2" Grid.ColumnSpan="2"
Orientation="Horizontal" Spacing="6" Margin="8,8,0,0"
VerticalAlignment="Center">
<Ellipse Width="10" Height="10" Fill="LimeGreen"
IsVisible="{Binding IsConnected}"/>
<Ellipse Width="10" Height="10" Fill="IndianRed"
IsVisible="{Binding !IsConnected}"/>
<TextBlock Text="{Binding ConnectionStatus}" Foreground="Gray"/>
</StackPanel>
</Grid>
</Border>
<!-- Tab: Ricevi / Storico / Invia -->
<TabControl Grid.Row="2">
<TabItem Header="Ricevi">
<StackPanel Spacing="10" Margin="8">
<TextBlock Text="Prossimo indirizzo non usato:"/>
<SelectableTextBlock Text="{Binding ReceiveAddress}"
FontFamily="monospace" FontSize="16"/>
<TextBlock Text="Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione."
Foreground="Gray" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</TabItem>
<TabItem Header="Storico">
<ListBox ItemsSource="{Binding History}" Margin="4">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HistoryRow">
<Grid ColumnDefinitions="90,160,*,70">
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
<SelectableTextBlock Grid.Column="2" Text="{Binding Txid}"
FontFamily="monospace" FontSize="12"/>
<TextBlock Grid.Column="3" Text="{Binding Verificata}" Foreground="Green"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</TabItem>
<TabItem Header="Indirizzi">
<Grid RowDefinitions="Auto,*" Margin="4">
<Grid Grid.Row="0" ColumnDefinitions="90,60,*,140,60" Margin="12,4">
<TextBlock Grid.Column="0" Text="Tipo" FontWeight="Bold"/>
<TextBlock Grid.Column="1" Text="Indice" FontWeight="Bold"/>
<TextBlock Grid.Column="2" Text="Indirizzo" FontWeight="Bold"/>
<TextBlock Grid.Column="3" Text="Saldo" FontWeight="Bold"/>
<TextBlock Grid.Column="4" Text="Tx" FontWeight="Bold"/>
</Grid>
<ListBox Grid.Row="1" ItemsSource="{Binding Addresses}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:AddressRow">
<Grid ColumnDefinitions="90,60,*,140,60">
<TextBlock Grid.Column="0" Text="{Binding Tipo}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Indice}" Foreground="Gray"/>
<SelectableTextBlock Grid.Column="2" Text="{Binding Indirizzo}"
FontFamily="monospace" FontSize="13"/>
<TextBlock Grid.Column="3" Text="{Binding Saldo}" FontFamily="monospace"/>
<TextBlock Grid.Column="4" Text="{Binding NumTx}" Foreground="Gray"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<TabItem Header="Invia">
<StackPanel Spacing="10" Margin="8" MaxWidth="640" HorizontalAlignment="Left">
<TextBox PlaceholderText="Indirizzo destinatario" Text="{Binding SendTo}"
FontFamily="monospace"/>
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBox Grid.Column="0" PlaceholderText="Importo (es. 1.5)"
Text="{Binding SendAmount}" IsEnabled="{Binding !SendAll}"/>
<CheckBox Grid.Column="1" Content="Invia tutto" Margin="10,0"
IsChecked="{Binding SendAll}"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<TextBlock Text="fee sat/vB:" VerticalAlignment="Center"/>
<TextBox Text="{Binding SendFeeRate}" MinWidth="60"/>
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="Prepara transazione" Command="{Binding PrepareSendCommand}"/>
<Button Content="CONFERMA E TRASMETTI" Classes="accent"
Command="{Binding ConfirmSendCommand}"
IsEnabled="{Binding HasPendingSend}"/>
</StackPanel>
<SelectableTextBlock Text="{Binding SendPreview}" TextWrapping="Wrap"
FontSize="13"/>
</StackPanel>
</TabItem>
</TabControl>
</Grid>
<!-- Barra di stato -->
<Border Grid.Row="2" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
Padding="10,6">
<TextBlock Text="{Binding StatusMessage}" FontSize="12" TextWrapping="Wrap"/>
</Border>
</Grid>
</Window>
+35
View File
@@ -0,0 +1,35 @@
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using PalladiumWallet.App.ViewModels;
namespace PalladiumWallet.App.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
/// <summary>File → Apri wallet da file (il picker richiede il TopLevel, da qui).</summary>
private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm)
return;
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Apri file wallet",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("Wallet Palladium") { Patterns = ["*.wallet.json", "*.json"] },
],
});
if (files.FirstOrDefault()?.TryGetLocalPath() is { } path)
vm.OpenFromPath(path);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="PalladiumWallet.App.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+350
View File
@@ -0,0 +1,350 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
// CLI del wallet (blueprint §13): i casi d'uso del Core esposti da riga di
// comando, per scripting, test e confronto col wallet di riferimento.
try
{
return args switch
{
["newseed", .. var rest] => NewSeed(rest),
["addresses", var words, .. var rest] => Addresses(words, rest),
["create", .. var rest] => Create(rest),
["restore", var words, .. var rest] => Restore(words, rest),
["restore-xpub", var xpub, .. var rest] => RestoreXpub(xpub, rest),
["info", .. var rest] => Info(rest),
["sync", .. var rest] => await Sync(rest),
["send", .. var rest] => await Send(rest),
["reset-certs", .. var rest] => ResetCerts(rest),
["servers", .. var rest] => await Servers(rest),
_ => Usage(),
};
}
catch (Exception ex) when (ex is WalletSpendException or WrongPasswordException
or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException)
{
Console.Error.WriteLine($"Errore: {ex.Message}");
return 1;
}
static int NewSeed(string[] o)
{
var length = Opt(o, "--words") == "24" ? MnemonicLength.TwentyFour : MnemonicLength.Twelve;
Console.WriteLine(Bip39.Generate(length));
return 0;
}
static int Addresses(string words, string[] o)
{
var account = AccountFromWords(words, o);
if (account is null)
return 1;
var count = int.TryParse(Opt(o, "--count"), out var n) ? n : 5;
Console.WriteLine($"rete: {account.Profile.NetName} tipo: {account.Kind} path: m/{account.AccountPath}");
Console.WriteLine($"account: {account.ToSlip132()}");
for (var i = 0; i < count; i++)
Console.WriteLine($" receiving/{i}: {account.GetReceiveAddress(i)}");
for (var i = 0; i < Math.Min(count, 2); i++)
Console.WriteLine($" change/{i}: {account.GetChangeAddress(i)}");
return 0;
}
static int Create(string[] o)
{
var mnemonic = Bip39.Generate(Opt(o, "--words") == "24" ? MnemonicLength.TwentyFour : MnemonicLength.Twelve);
Console.WriteLine("Nuova mnemonica (scrivila su carta, NON viene rimostrata):");
Console.WriteLine($" {mnemonic}");
return SaveWallet(mnemonic.ToString(), o);
}
static int Restore(string words, string[] o)
{
if (!Bip39.TryParse(words, out _))
{
Console.Error.WriteLine("Mnemonica non valida (parole o checksum errati).");
return 1;
}
return SaveWallet(words.Trim(), o);
}
static int RestoreXpub(string xpubText, string[] o)
{
var profile = Profile(o);
if (!Slip132.TryDecodePublic(xpubText, profile, out var xpub, out var kind))
{
Console.Error.WriteLine("Chiave estesa non riconosciuta per questa rete.");
return 1;
}
var account = HdAccount.FromAccountXpub(xpub!, kind, profile);
var doc = new WalletDocument
{
Network = profile.NetName,
ScriptKind = kind.ToString(),
AccountPath = account.AccountPath.ToString(),
AccountXpub = account.ToSlip132(),
};
var path = WalletPath(o, profile);
WalletStore.Save(doc, path, Opt(o, "--password"));
Console.WriteLine($"Wallet watch-only salvato in {path}");
return 0;
}
static int Info(string[] o)
{
var (doc, account, path) = OpenWallet(o);
Console.WriteLine($"file: {path}");
Console.WriteLine($"rete: {doc.Network} tipo: {doc.ScriptKind} watch-only: {doc.IsWatchOnly}");
Console.WriteLine($"path: m/{doc.AccountPath}");
Console.WriteLine($"xpub: {doc.AccountXpub}");
if (doc.Cache is { } cache)
{
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato"
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa (non spendibile)." : ""));
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni");
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
}
else
{
Console.WriteLine($"ricezione: {account.GetReceiveAddress(0)} (mai sincronizzato)");
}
if (o.Contains("--addresses") && doc.Cache is { } c)
{
Console.WriteLine("indirizzi:");
foreach (var a in c.Addresses)
Console.WriteLine($" {(a.IsChange ? "change " : "ricev. ")}{a.Index,3} {a.Address} " +
$"{CoinAmount.Format(a.BalanceSats),18} ({a.TxCount} tx)");
}
return 0;
}
static async Task<int> Sync(string[] o)
{
var (doc, account, path) = OpenWallet(o);
await using var client = await Connect(o, account.Profile);
var sync = new WalletSynchronizer(account, client, doc.GapLimit);
sync.Progress += msg => Console.WriteLine($" {msg}");
var result = await sync.SyncOnceAsync();
doc.Cache = new SyncCache
{
TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats,
NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex,
History = [.. result.History],
Utxos = [.. result.Utxos],
Addresses = [.. result.AddressRows],
};
WalletStore.Save(doc, path, Opt(o, "--password"));
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats, account.Profile.CoinUnit)} confermato"
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : ""));
Console.WriteLine($"Storico ({result.History.Count}):");
foreach (var tx in result.History)
Console.WriteLine($" {(tx.Height > 0 ? tx.Height.ToString() : "mempool"),7} " +
$"{(tx.DeltaSats >= 0 ? "+" : "")}{CoinAmount.Format(tx.DeltaSats)} {tx.Txid}" +
(tx.Verified ? "" : " (non verificata)"));
return 0;
}
static async Task<int> Send(string[] o)
{
var (doc, account, path) = OpenWallet(o);
if (doc.Cache is null)
{
Console.Error.WriteLine("Wallet mai sincronizzato: esegui prima 'sync'.");
return 1;
}
var to = Opt(o, "--to") ?? throw new WalletSpendException("--to mancante.");
var destination = BitcoinAddress.Create(to, PalladiumNetworks.For(account.Profile.Kind));
var sendAll = o.Contains("--all");
long amount = 0;
if (!sendAll && !CoinAmount.TryParseCoins(Opt(o, "--amount") ?? "", out amount))
throw new WalletSpendException("--amount mancante o non valido (oppure usa --all).");
var feeRate = decimal.TryParse(Opt(o, "--feerate"), out var fr) ? fr : 1m;
// Tx di provenienza degli UTXO: servono per importi e firma.
await using var client = await Connect(o, account.Profile);
var network = PalladiumNetworks.For(account.Profile.Kind);
var transactions = new Dictionary<string, Transaction>();
foreach (var txid in doc.Cache.Utxos.Select(u => u.Txid).Distinct())
transactions[txid] = Transaction.Parse(await client.GetTransactionAsync(txid), network);
var built = new TransactionFactory(account).Build(
doc.Cache.Utxos, transactions, destination, amount, feeRate,
doc.Cache.NextChangeIndex, sendAll);
Console.WriteLine($"txid: {built.Txid}");
Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " +
$"({feeRate} sat/vB, {built.Transaction.GetVirtualSize()} vB)");
if (!built.Signed)
{
Console.WriteLine("Wallet watch-only: PSBT da firmare offline (§6.5):");
Console.WriteLine(built.Psbt.ToBase64());
return 0;
}
if (!o.Contains("--broadcast"))
{
Console.WriteLine("Transazione firmata (NON trasmessa, aggiungi --broadcast):");
Console.WriteLine(built.ToHex());
return 0;
}
var txid2 = await client.BroadcastAsync(built.ToHex());
Console.WriteLine($"Trasmessa: {txid2}");
return 0;
}
static int ResetCerts(string[] o)
{
var profile = Profile(o);
new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind)).ResetAll();
Console.WriteLine($"Certificati SSL salvati per {profile.NetName} azzerati.");
return 0;
}
static async Task<int> Servers(string[] o)
{
var profile = Profile(o);
var registry = new ServerRegistry(profile, AppPaths.ServersPath(profile.Kind));
if (o.Contains("--discover"))
{
await using var client = await Connect(o, profile);
var added = await registry.DiscoverAsync(client);
Console.WriteLine($"Scoperti {added} nuovi server dai peer.");
}
Console.WriteLine($"Server noti ({profile.NetName}):");
foreach (var server in registry.All)
Console.WriteLine($" {server}");
return 0;
}
// ----- helper comuni -----
static ChainProfile Profile(string[] o) => Opt(o, "--net") switch
{
"testnet" => ChainProfiles.Testnet,
"regtest" => ChainProfiles.Regtest,
_ => ChainProfiles.Mainnet,
};
static ScriptKind Kind(string[] o) => Opt(o, "--kind") switch
{
"legacy" => ScriptKind.Legacy,
"wrapped" => ScriptKind.WrappedSegwit,
_ => ScriptKind.NativeSegwit,
};
static string WalletPath(string[] o, ChainProfile profile) =>
Opt(o, "--file") ?? AppPaths.DefaultWalletPath(profile.Kind);
static HdAccount? AccountFromWords(string words, string[] o)
{
if (!Bip39.TryParse(words, out var mnemonic))
{
Console.Error.WriteLine("Mnemonica non valida (parole o checksum errati).");
return null;
}
var profile = Profile(o);
var kind = Kind(o);
var passphrase = Opt(o, "--passphrase");
return Opt(o, "--path") is { } path
? HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, passphrase), kind, profile, KeyPath.Parse(path))
: HdAccount.FromMnemonic(mnemonic!, passphrase, kind, profile);
}
static int SaveWallet(string words, string[] o)
{
var profile = Profile(o);
var (doc, account) = WalletLoader.NewFromMnemonic(
words, Opt(o, "--passphrase"), Kind(o), profile,
Opt(o, "--path") is { } p ? KeyPath.Parse(p) : null);
var password = Opt(o, "--password");
if (string.IsNullOrEmpty(password))
Console.WriteLine("ATTENZIONE: nessuna --password, il seed sarà in chiaro su disco (§17).");
var path = WalletPath(o, profile);
WalletStore.Save(doc, path, password);
Console.WriteLine($"Wallet salvato in {path}");
Console.WriteLine($"Primo indirizzo: {account.GetReceiveAddress(0)}");
return 0;
}
static (WalletDocument, HdAccount, string) OpenWallet(string[] o)
{
var path = WalletPath(o, Profile(o));
if (!WalletStore.Exists(path))
throw new WalletSpendException($"Nessun wallet in {path}: usa 'create' o 'restore'.");
var doc = WalletStore.Load(path, Opt(o, "--password"));
return (doc, WalletLoader.ToAccount(doc), path);
}
static async Task<ElectrumClient> Connect(string[] o, ChainProfile profile)
{
var useSsl = o.Contains("--ssl");
string host;
int port;
if (Opt(o, "--server") is { } server)
{
var parts = server.Split(':');
host = parts[0];
port = parts.Length > 1 ? int.Parse(parts[1])
: useSsl ? profile.DefaultSslPort : profile.DefaultTcpPort;
}
else
{
// Senza --server si usa il primo server noto (bootstrap §3 o scoperto §9).
var known = new ServerRegistry(profile, AppPaths.ServersPath(profile.Kind)).Default
?? throw new WalletSpendException("Nessun server noto per questa rete: usa --server host:porta.");
host = known.Host;
port = known.PortFor(useSsl);
}
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind));
Console.WriteLine($"Connessione a {host}:{port}{(useSsl ? " (TLS)" : "")}…");
return await ElectrumClient.ConnectAsync(host, port, useSsl, pins);
}
static string? Opt(string[] options, string name)
{
var i = Array.IndexOf(options, name);
return i >= 0 && i + 1 < options.Length ? options[i + 1] : null;
}
static int Usage()
{
Console.WriteLine("""
PalladiumWallet CLI
Wallet:
create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest]
[--passphrase W] [--password P] [--file PATH]
restore "<mnemonica>" [stesse opzioni di create] [--path m/...]
restore-xpub <xpub slip132> [--net ...] [--password P] [--file PATH] (watch-only)
info [--net ...] [--password P] [--file PATH]
Rete (server di indicizzazione; senza --server usa il primo server noto):
sync [--server host[:porta]] [--ssl] [--net ...] [--password P] [--file PATH]
send --to INDIRIZZO (--amount X | --all) [--feerate sat/vB]
[--server host[:porta]] [--ssl] [--broadcast] [...]
servers [--discover] [--server host[:porta]] [--ssl] [--net ...]
reset-certs [--net ...]
Strumenti:
newseed [--words 12|24]
addresses "<mnemonica>" [--kind ...] [--net ...] [--count N] [--passphrase W] [--path m/...]
""");
return 1;
}
+9 -2
View File
@@ -34,8 +34,15 @@ public static class ChainProfiles
[ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub [ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub
[ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub [ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub
}, },
// TODO: popolare con i server reali prima della release (host:tcp:ssl). // Server di indicizzazione noti per il primo contatto (§3/§9); altri
BootstrapServers = [], // peer vengono scoperti via server.peers.subscribe.
BootstrapServers =
[
new ServerEndpoint("173.212.224.67", 50001, 50002),
new ServerEndpoint("144.91.120.225", 50001, 50002),
new ServerEndpoint("66.94.115.80", 50001, 50002),
new ServerEndpoint("89.117.149.130", 50001, 50002),
],
// TODO: popolare con [hash, bits] reali della catena (§7.3) prima della release. // TODO: popolare con [hash, bits] reali della catena (§7.3) prima della release.
Checkpoints = [], Checkpoints = [],
}; };
+49
View File
@@ -14,6 +14,9 @@ public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList
/// <summary>Tip della catena notificato da blockchain.headers.subscribe.</summary> /// <summary>Tip della catena notificato da blockchain.headers.subscribe.</summary>
public readonly record struct ChainTip(int Height, string HeaderHex); public readonly record struct ChainTip(int Height, string HeaderHex);
/// <summary>Peer annunciato da server.peers.subscribe (porte null = non offerte).</summary>
public sealed record PeerInfo(string Host, int? TcpPort, int? SslPort, string? Version);
/// <summary> /// <summary>
/// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il /// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il
/// trasporto JSON-RPC generico di <see cref="ElectrumClient"/>. /// trasporto JSON-RPC generico di <see cref="ElectrumClient"/>.
@@ -108,4 +111,50 @@ public static class ElectrumApi
public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) => public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) =>
c.RequestAsync("server.ping", ct); c.RequestAsync("server.ping", ct);
/// <summary>Peer annunciati dal server (scoperta di altri server, §9).</summary>
public static async Task<IReadOnlyList<PeerInfo>> GetPeersAsync(this ElectrumClient c,
CancellationToken ct = default)
{
var r = await c.RequestAsync("server.peers.subscribe", ct);
return ParsePeers(r);
}
/// <summary>
/// Parsa la risposta di server.peers.subscribe: lista di
/// [ip, hostname, ["v1.4.2", "pN", "tPORTA", "sPORTA", ...]];
/// "t"/"s" senza numero = porta di default della rete (risolta dal chiamante).
/// </summary>
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
{
var peers = new List<PeerInfo>();
foreach (var entry in response.EnumerateArray())
{
if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3)
continue;
var host = entry[1].GetString();
if (string.IsNullOrWhiteSpace(host))
host = entry[0].GetString();
if (string.IsNullOrWhiteSpace(host))
continue;
int? tcp = null, ssl = null;
string? version = null;
foreach (var feature in entry[2].EnumerateArray())
{
var f = feature.GetString();
if (string.IsNullOrEmpty(f))
continue;
switch (f[0])
{
case 'v': version = f[1..]; break;
case 't': tcp = int.TryParse(f[1..], out var t) ? t : 0; break;
case 's': ssl = int.TryParse(f[1..], out var s) ? s : 0; break;
}
}
if (tcp is not null || ssl is not null)
peers.Add(new PeerInfo(host!, tcp, ssl, version));
}
return peers;
}
} }
+106
View File
@@ -0,0 +1,106 @@
using System.Text.Json;
using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Net;
/// <summary>Server di indicizzazione noto (bootstrap o scoperto dai peer).</summary>
public sealed record KnownServer(string Host, int TcpPort, int SslPort, string? Version = null)
{
public int PortFor(bool useSsl) => useSsl ? SslPort : TcpPort;
public override string ToString() =>
$"{Host} · tcp {TcpPort} / ssl {SslPort}" + (Version is null ? "" : $" · v{Version}");
}
/// <summary>
/// Registro dei server (blueprint §9): parte dai bootstrap del profilo (§3),
/// si arricchisce con i peer annunciati via server.peers.subscribe e persiste
/// le scoperte su file per le sessioni successive.
/// </summary>
public sealed class ServerRegistry
{
private readonly ChainProfile _profile;
private readonly string _filePath;
private readonly List<KnownServer> _servers = [];
private readonly object _lock = new();
public ServerRegistry(ChainProfile profile, string filePath)
{
_profile = profile;
_filePath = filePath;
foreach (var s in profile.BootstrapServers)
_servers.Add(new KnownServer(s.Host, s.TcpPort, s.SslPort));
if (File.Exists(filePath))
{
try
{
var saved = JsonSerializer.Deserialize<List<KnownServer>>(File.ReadAllText(filePath)) ?? [];
foreach (var s in saved)
AddIfNew(s);
}
catch (JsonException)
{
// File corrotto: si riparte dai soli bootstrap.
}
}
}
public IReadOnlyList<KnownServer> All
{
get { lock (_lock) return [.. _servers]; }
}
/// <summary>Primo server noto: default quando l'utente non ne indica uno.</summary>
public KnownServer? Default
{
get { lock (_lock) return _servers.FirstOrDefault(); }
}
/// <summary>
/// Chiede al server connesso i peer che annuncia e integra i nuovi nel
/// registro (porte mancanti → default del profilo). Ritorna quanti sono nuovi.
/// </summary>
public async Task<int> DiscoverAsync(ElectrumClient client, CancellationToken ct = default)
{
var peers = await client.GetPeersAsync(ct);
var added = 0;
lock (_lock)
{
foreach (var peer in peers)
{
var server = new KnownServer(
peer.Host,
peer.TcpPort is > 0 ? peer.TcpPort.Value : _profile.DefaultTcpPort,
peer.SslPort is > 0 ? peer.SslPort.Value : _profile.DefaultSslPort,
peer.Version);
if (AddIfNew(server))
added++;
}
if (added > 0)
Save();
}
return added;
}
private bool AddIfNew(KnownServer server)
{
if (_servers.Any(s => string.Equals(s.Host, server.Host, StringComparison.OrdinalIgnoreCase)))
return false;
_servers.Add(server);
return true;
}
private void Save()
{
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
// Si salvano solo gli scoperti: i bootstrap vengono dal profilo.
var discovered = _servers
.Where(s => !_profile.BootstrapServers.Any(b =>
string.Equals(b.Host, s.Host, StringComparison.OrdinalIgnoreCase)))
.ToList();
File.WriteAllText(_filePath, JsonSerializer.Serialize(discovered,
new JsonSerializerOptions { WriteIndented = true }));
}
}
+18
View File
@@ -24,6 +24,7 @@ public sealed class SyncResult
public required IReadOnlyList<CachedTx> History { get; init; } public required IReadOnlyList<CachedTx> History { get; init; }
public required IReadOnlyList<CachedUtxo> Utxos { get; init; } public required IReadOnlyList<CachedUtxo> Utxos { get; init; }
public required IReadOnlyList<TrackedAddress> Addresses { get; init; } public required IReadOnlyList<TrackedAddress> Addresses { get; init; }
public required IReadOnlyList<CachedAddress> AddressRows { get; init; }
public required IReadOnlyDictionary<string, Transaction> Transactions { get; init; } public required IReadOnlyDictionary<string, Transaction> Transactions { get; init; }
} }
@@ -145,6 +146,22 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
return hb.CompareTo(ha); return hb.CompareTo(ha);
}); });
// Saldo e numero di transazioni per singolo indirizzo (vista indirizzi).
var balanceByAddress = utxos
.GroupBy(u => u.Address)
.ToDictionary(g => g.Key, g => g.Sum(u => u.ValueSats));
var addressRows = tracked
.OrderBy(t => t.IsChange).ThenBy(t => t.Index)
.Select(t => new CachedAddress
{
Address = t.Address.ToString(),
IsChange = t.IsChange,
Index = t.Index,
BalanceSats = balanceByAddress.GetValueOrDefault(t.Address.ToString()),
TxCount = historyByAddress.TryGetValue(t.ScriptHash, out var h) ? h.Count : 0,
})
.ToList();
return new SyncResult return new SyncResult
{ {
TipHeight = tip.Height, TipHeight = tip.Height,
@@ -155,6 +172,7 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
History = history, History = history,
Utxos = utxos, Utxos = utxos,
Addresses = tracked, Addresses = tracked,
AddressRows = addressRows,
Transactions = transactions, Transactions = transactions,
}; };
} }
+52
View File
@@ -0,0 +1,52 @@
using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Percorsi dati per piattaforma (blueprint §8): ~/.palladium-wallet (Linux) o
/// %APPDATA%/PalladiumWallet (Windows), con sottocartella per rete. La modalità
/// portable (dati accanto all'eseguibile) si attiva se accanto all'eseguibile
/// esiste una cartella "palladium-data".
/// </summary>
public static class AppPaths
{
public const string PortableDirName = "palladium-data";
public static string DataRoot()
{
var portable = Path.Combine(AppContext.BaseDirectory, PortableDirName);
if (Directory.Exists(portable))
return portable;
return OperatingSystem.IsWindows()
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PalladiumWallet")
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".palladium-wallet");
}
/// <summary>Cartella dati della rete (config, wallet, header, certificati).</summary>
public static string ForNetwork(NetKind net)
{
var dir = Path.Combine(DataRoot(), ChainProfiles.For(net).NetName);
Directory.CreateDirectory(dir);
return dir;
}
public static string WalletsDir(NetKind net)
{
var dir = Path.Combine(ForNetwork(net), "wallets");
Directory.CreateDirectory(dir);
return dir;
}
public static string DefaultWalletPath(NetKind net) =>
Path.Combine(WalletsDir(net), "default.wallet.json");
public static string CertificatePinsPath(NetKind net) =>
Path.Combine(ForNetwork(net), "server-certs.json");
public static string ServersPath(NetKind net) =>
Path.Combine(ForNetwork(net), "servers.json");
public static string ConfigPath() =>
Path.Combine(DataRoot(), "config.json");
}
+95
View File
@@ -0,0 +1,95 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Cifratura del file wallet (blueprint §8/§17): AES-256-GCM con chiave derivata
/// dalla password via PBKDF2-HMAC-SHA512. Il contenitore è JSON autodescrittivo
/// (kdf, parametri, salt, nonce) per consentire upgrade futuri dei parametri.
/// </summary>
public static class EncryptedFile
{
private const string Format = "plm-wallet-aesgcm-v1";
private const int DefaultIterations = 600_000;
private const int SaltSize = 16;
private const int NonceSize = 12;
private const int TagSize = 16;
private const int KeySize = 32;
private sealed record Container(
string Format, int Iterations, string Salt, string Nonce, string Tag, string Data);
public static bool IsEncrypted(string fileContent)
{
try
{
using var doc = JsonDocument.Parse(fileContent);
return doc.RootElement.TryGetProperty("Format", out var f)
&& f.GetString() == Format;
}
catch (JsonException)
{
return false;
}
}
public static string Encrypt(string plaintext, string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var key = DeriveKey(password, salt, DefaultIterations);
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
var cipher = new byte[plainBytes.Length];
var tag = new byte[TagSize];
using (var aes = new AesGcm(key, TagSize))
aes.Encrypt(nonce, plainBytes, cipher, tag);
CryptographicOperations.ZeroMemory(key);
return JsonSerializer.Serialize(new Container(
Format, DefaultIterations,
Convert.ToBase64String(salt), Convert.ToBase64String(nonce),
Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
}
/// <summary>Lancia <see cref="WrongPasswordException"/> se la password è errata o il file è manomesso.</summary>
public static string Decrypt(string fileContent, string password)
{
var container = JsonSerializer.Deserialize<Container>(fileContent)
?? throw new InvalidDataException("Contenitore cifrato non valido.");
if (container.Format != Format)
throw new InvalidDataException($"Formato sconosciuto: {container.Format}");
var key = DeriveKey(password, Convert.FromBase64String(container.Salt), container.Iterations);
var cipher = Convert.FromBase64String(container.Data);
var plain = new byte[cipher.Length];
try
{
using var aes = new AesGcm(key, TagSize);
aes.Decrypt(
Convert.FromBase64String(container.Nonce), cipher,
Convert.FromBase64String(container.Tag), plain);
}
catch (AuthenticationTagMismatchException)
{
// Il tag GCM autentica: password errata e manomissione sono indistinguibili.
throw new WrongPasswordException();
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
return Encoding.UTF8.GetString(plain);
}
private static byte[] DeriveKey(string password, byte[] salt, int iterations) =>
Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA512, KeySize);
}
/// <summary>Password errata (o file wallet manomesso: il tag GCM non distingue i due casi).</summary>
public sealed class WrongPasswordException : Exception
{
public WrongPasswordException() : base("Password errata o file wallet danneggiato.") { }
}
+113
View File
@@ -0,0 +1,113 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Schema del file wallet (blueprint §8), versionato per consentire migrazioni
/// automatiche all'apertura. La cifratura (quando c'è una password) avvolge
/// l'intero documento via <see cref="EncryptedFile"/>.
/// </summary>
public sealed class WalletDocument
{
public const int CurrentVersion = 1;
public int Version { get; set; } = CurrentVersion;
/// <summary>Nome rete (mainnet/testnet/regtest), come ChainProfile.NetName.</summary>
public required string Network { get; set; }
/// <summary>Tipo di script (nome dell'enum ScriptKind).</summary>
public required string ScriptKind { get; set; }
/// <summary>Mnemonica BIP39 in chiaro nel documento (il documento va cifrato!); null se watch-only.</summary>
public string? Mnemonic { get; set; }
/// <summary>Extension word BIP39 (§4.1); null se assente.</summary>
public string? Passphrase { get; set; }
/// <summary>Path di account (es. "84'/746'/0'").</summary>
public required string AccountPath { get; set; }
/// <summary>Xpub di account in SLIP-132: basta da sola per il watch-only.</summary>
public required string AccountXpub { get; set; }
public string? MasterFingerprint { get; set; }
/// <summary>Gap limit per la scansione indirizzi (§5), configurabile.</summary>
public int GapLimit { get; set; } = 20;
/// <summary>Etichette per indirizzo/txid (§12).</summary>
public Dictionary<string, string> Labels { get; set; } = [];
/// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary>
public SyncCache? Cache { get; set; }
public bool IsWatchOnly => Mnemonic is null;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
public static WalletDocument FromJson(string json)
{
var doc = JsonSerializer.Deserialize<WalletDocument>(json, JsonOptions)
?? throw new InvalidDataException("File wallet non valido.");
return Migrate(doc);
}
/// <summary>Migrazioni di schema all'apertura (§8). Per ora esiste solo la v1.</summary>
private static WalletDocument Migrate(WalletDocument doc) => doc.Version switch
{
CurrentVersion => doc,
_ => throw new InvalidDataException(
$"Versione file wallet {doc.Version} non supportata (max {CurrentVersion})."),
};
}
/// <summary>Stato sincronizzato persistito: permette di mostrare saldo/storico offline.</summary>
public sealed class SyncCache
{
public int TipHeight { get; set; }
public long ConfirmedSats { get; set; }
public long UnconfirmedSats { get; set; }
public int NextReceiveIndex { get; set; }
public int NextChangeIndex { get; set; }
public List<CachedTx> History { get; set; } = [];
public List<CachedUtxo> Utxos { get; set; } = [];
public List<CachedAddress> Addresses { get; set; } = [];
}
/// <summary>Indirizzo scansionato con saldo proprio e numero di transazioni (vista indirizzi).</summary>
public sealed class CachedAddress
{
public required string Address { get; set; }
public bool IsChange { get; set; }
public int Index { get; set; }
public long BalanceSats { get; set; }
public int TxCount { get; set; }
}
public sealed class CachedTx
{
public required string Txid { get; set; }
public int Height { get; set; }
public long DeltaSats { get; set; }
public bool Verified { get; set; }
}
public sealed class CachedUtxo
{
public required string Txid { get; set; }
public int Vout { get; set; }
public long ValueSats { get; set; }
public required string Address { get; set; }
public bool IsChange { get; set; }
public int AddressIndex { get; set; }
public int Height { get; set; }
public bool Frozen { get; set; }
}
+38
View File
@@ -0,0 +1,38 @@
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Lettura/scrittura del file wallet su disco (blueprint §8): JSON in chiaro
/// senza password, contenitore AES-GCM con password. Scrittura atomica
/// (file temporaneo + rename) per non corrompere il wallet su crash.
/// </summary>
public static class WalletStore
{
public static bool Exists(string path) => File.Exists(path);
/// <summary>True se il file richiede una password per l'apertura.</summary>
public static bool RequiresPassword(string path) =>
EncryptedFile.IsEncrypted(File.ReadAllText(path));
public static WalletDocument Load(string path, string? password = null)
{
var content = File.ReadAllText(path);
if (EncryptedFile.IsEncrypted(content))
{
if (string.IsNullOrEmpty(password))
throw new WrongPasswordException();
content = EncryptedFile.Decrypt(content, password);
}
return WalletDocument.FromJson(content);
}
public static void Save(WalletDocument doc, string path, string? password = null)
{
var content = doc.ToJson();
if (!string.IsNullOrEmpty(password))
content = EncryptedFile.Encrypt(content, password);
var tmp = path + ".tmp";
File.WriteAllText(tmp, content);
File.Move(tmp, path, overwrite: true);
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.Globalization;
namespace PalladiumWallet.Core.Wallet;
/// <summary>
/// Conversione satoshi ↔ unità coin (8 decimali) per visualizzazione e input.
/// Si lavora sempre in satoshi internamente; la stringa è solo presentazione.
/// </summary>
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 : "");
/// <summary>Parsa un importo in coin (punto o virgola decimale) in satoshi.</summary>
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;
}
}
+126
View File
@@ -0,0 +1,126 @@
using NBitcoin;
using NBitcoin.Policy;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Core.Wallet;
/// <summary>Esito della costruzione di una transazione.</summary>
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; }
/// <summary>PSBT per i flussi watch-only/air-gapped/multisig (§6.5).</summary>
public required PSBT Psbt { get; init; }
public string ToHex() => Transaction.ToHex();
public string Txid => Transaction.GetHash().ToString();
}
/// <summary>
/// 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).
/// </summary>
public sealed class TransactionFactory(HdAccount account)
{
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
/// <summary>
/// Costruisce (e se possibile firma) una transazione.
/// </summary>
/// <param name="utxos">UTXO selezionati (coin control §6.2) o tutti quelli spendibili.</param>
/// <param name="transactions">Tx di provenienza degli UTXO (txid → tx), dalla sincronizzazione.</param>
/// <param name="destination">Indirizzo destinatario.</param>
/// <param name="amountSats">Importo; ignorato se <paramref name="sendAll"/>.</param>
/// <param name="feeRateSatPerVByte">Fee rate fisso in sat/vByte (§6.4).</param>
/// <param name="changeIndex">Indice del prossimo indirizzo di change (catena interna).</param>
/// <param name="sendAll">Invia tutto: fee sottratta dall'importo (§6.1).</param>
public BuiltTransaction Build(
IReadOnlyList<CachedUtxo> utxos,
IReadOnlyDictionary<string, Transaction> transactions,
BitcoinAddress destination,
long amountSats,
decimal feeRateSatPerVByte,
int changeIndex,
bool sendAll = false)
{
// Si spendono solo UTXO confermati: i fondi in mempool si vedono nel
// saldo "in attesa" ma non sono spendibili finché non confermano.
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
? $"Nessun fondo confermato: {CoinAmount.Format(pending)} in attesa di conferma (non spendibile)."
: "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<Coin> 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);
}
}
/// <summary>Errore di costruzione/firma della spesa (fondi, policy, parametri).</summary>
public sealed class WalletSpendException(string message) : Exception(message);
+59
View File
@@ -0,0 +1,59 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Core.Wallet;
/// <summary>
/// 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.
/// </summary>
public static class WalletLoader
{
public static ChainProfile ProfileOf(WalletDocument doc) =>
ChainProfiles.For(Enum.Parse<NetKind>(doc.Network, ignoreCase: true));
public static HdAccount ToAccount(WalletDocument doc)
{
var profile = ProfileOf(doc);
var kind = Enum.Parse<ScriptKind>(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);
}
/// <summary>Crea il documento per un nuovo wallet da seed.</summary>
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);
}
}
@@ -0,0 +1,83 @@
using NBitcoin;
using NBitcoin.DataEncoders;
using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Tests.Chain;
public class ChainProfileTests
{
[Fact]
public void Mainnet_ha_le_costanti_del_blueprint()
{
var p = ChainProfiles.Mainnet;
Assert.Equal("PLM", p.CoinUnit);
Assert.Equal(0x80, p.WifPrefix);
Assert.Equal(55, p.AddrP2pkh);
Assert.Equal(5, p.AddrP2sh);
Assert.Equal("plm", p.SegwitHrp);
Assert.Equal("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", p.GenesisHash);
Assert.Equal(50001, p.DefaultTcpPort);
Assert.Equal(50002, p.DefaultSslPort);
Assert.Equal(746, p.Bip44CoinType);
Assert.Equal("palladium", p.UriScheme);
Assert.True(p.SkipPowValidation); // obbligatorio: la catena usa LWMA (§3)
Assert.Equal(120, p.BlockTimeSeconds);
}
[Fact]
public void Testnet_e_regtest_hanno_i_valori_del_blueprint()
{
var t = ChainProfiles.Testnet;
Assert.Equal(0xff, t.WifPrefix);
Assert.Equal(127, t.AddrP2pkh);
Assert.Equal(115, t.AddrP2sh);
Assert.Equal("tplm", t.SegwitHrp);
Assert.Equal(1, t.Bip44CoinType);
var r = ChainProfiles.Regtest;
Assert.Equal("rplm", r.SegwitHrp);
}
[Fact]
public void Selettore_di_rete_restituisce_il_profilo_giusto()
{
Assert.Same(ChainProfiles.Mainnet, ChainProfiles.For(NetKind.Mainnet));
Assert.Same(ChainProfiles.Testnet, ChainProfiles.For(NetKind.Testnet));
Assert.Same(ChainProfiles.Regtest, ChainProfiles.For(NetKind.Regtest));
}
[Theory]
[InlineData(ScriptKind.Legacy, "xprv", "xpub")]
[InlineData(ScriptKind.WrappedSegwit, "yprv", "ypub")]
[InlineData(ScriptKind.WrappedSegwitMultisig, "Yprv", "Ypub")]
[InlineData(ScriptKind.NativeSegwit, "zprv", "zpub")]
[InlineData(ScriptKind.NativeSegwitMultisig, "Zprv", "Zpub")]
public void Header_estesi_mainnet_producono_i_prefissi_slip132_attesi(
ScriptKind kind, string privPrefix, string pubPrefix)
{
var headers = ChainProfiles.Mainnet.ExtKeyHeaders[kind];
Assert.StartsWith(privPrefix, EncodeWithHeader(headers.Private));
Assert.StartsWith(pubPrefix, EncodeWithHeader(headers.Public));
}
[Fact]
public void Header_estesi_testnet_producono_tprv_tpub()
{
var headers = ChainProfiles.Testnet.ExtKeyHeaders[ScriptKind.Legacy];
Assert.StartsWith("tprv", EncodeWithHeader(headers.Private));
Assert.StartsWith("tpub", EncodeWithHeader(headers.Public));
}
// Serializza header (4 byte BE) + payload BIP32 di 74 byte e codifica Base58Check:
// il prefisso testuale risultante dipende solo dall'header.
private static string EncodeWithHeader(uint header)
{
var data = new byte[78];
data[0] = (byte)(header >> 24);
data[1] = (byte)(header >> 16);
data[2] = (byte)(header >> 8);
data[3] = (byte)header;
return Encoders.Base58Check.EncodeData(data);
}
}
@@ -0,0 +1,78 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Tests.Chain;
public class PalladiumNetworksTests
{
// Chiave privata fissa per test deterministici (solo test, mai usarla davvero).
private static Key TestKey => new(Convert.FromHexString(
"0000000000000000000000000000000000000000000000000000000000000001"));
[Theory]
[InlineData(NetKind.Mainnet)]
[InlineData(NetKind.Testnet)]
[InlineData(NetKind.Regtest)]
public void La_genesi_della_rete_corrisponde_al_profilo(NetKind kind)
{
var network = PalladiumNetworks.For(kind);
var profile = ChainProfiles.For(kind);
Assert.Equal(profile.GenesisHash, network.GetGenesis().GetHash().ToString());
}
[Fact]
public void Indirizzo_p2pkh_mainnet_inizia_con_P()
{
var addr = TestKey.PubKey.GetAddress(ScriptPubKeyType.Legacy, PalladiumNetworks.Mainnet);
Assert.StartsWith("P", addr.ToString());
}
[Fact]
public void Indirizzo_native_segwit_mainnet_inizia_con_plm1()
{
var addr = TestKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, PalladiumNetworks.Mainnet);
Assert.StartsWith("plm1", addr.ToString());
}
[Fact]
public void Indirizzo_segwit_wrapped_mainnet_inizia_con_3()
{
var addr = TestKey.PubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, PalladiumNetworks.Mainnet);
Assert.StartsWith("3", addr.ToString());
}
[Theory]
[InlineData(NetKind.Testnet, "tplm1")]
[InlineData(NetKind.Regtest, "rplm1")]
public void Indirizzi_segwit_test_e_regtest_usano_l_hrp_giusto(NetKind kind, string prefix)
{
var addr = TestKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, PalladiumNetworks.For(kind));
Assert.StartsWith(prefix, addr.ToString());
}
[Fact]
public void Wif_mainnet_fa_roundtrip_con_il_prefisso_del_profilo()
{
var wif = TestKey.GetWif(PalladiumNetworks.Mainnet);
var decoded = Key.Parse(wif.ToString(), PalladiumNetworks.Mainnet);
Assert.Equal(TestKey.ToHex(), decoded.ToHex());
}
[Fact]
public void Chiavi_estese_mainnet_serializzano_come_xprv_xpub()
{
var ext = new ExtKey();
Assert.StartsWith("xprv", ext.ToString(PalladiumNetworks.Mainnet));
Assert.StartsWith("xpub", ext.Neuter().ToString(PalladiumNetworks.Mainnet));
}
[Fact]
public void Le_tre_reti_sono_distinte_e_riutilizzate()
{
Assert.NotSame(PalladiumNetworks.Mainnet, PalladiumNetworks.Testnet);
Assert.NotSame(PalladiumNetworks.Testnet, PalladiumNetworks.Regtest);
Assert.Same(PalladiumNetworks.Mainnet, PalladiumNetworks.For(NetKind.Mainnet));
}
}
@@ -0,0 +1,96 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
namespace PalladiumWallet.Tests.Crypto;
public class AddressDerivationTests
{
private static byte[] AbandonAboutSeed()
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
return Bip39.ToSeed(mnemonic!);
}
[Fact]
public void Il_vettore_bip44_produce_lo_stesso_hash_su_bitcoin_e_plm()
{
// Indirizzo noto di abandon-about su m/44'/0'/0'/0/0 (riferimento pubblico).
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
var pubKey = account.GetPublicKey(isChange: false, 0);
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main);
Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString());
var plmAddr = account.GetReceiveAddress(0);
Assert.StartsWith("P", plmAddr.ToString());
// Stesso hash160 sotto i due prefissi: la derivazione è identica,
// cambia solo la veste di rete.
Assert.Equal(pubKey.Hash, ((BitcoinPubKeyAddress)plmAddr).Hash);
}
[Fact]
public void Il_vettore_bip49_produce_lo_stesso_script_hash_su_bitcoin_e_plm()
{
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit,
ChainProfiles.Mainnet, new KeyPath("49'/0'/0'"));
var pubKey = account.GetPublicKey(isChange: false, 0);
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main);
Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString());
var plmAddr = account.GetReceiveAddress(0);
Assert.StartsWith("3", plmAddr.ToString());
Assert.Equal(((BitcoinScriptAddress)bitcoinAddr).Hash, ((BitcoinScriptAddress)plmAddr).Hash);
}
[Theory]
[InlineData(ScriptKind.Legacy, NetKind.Mainnet)]
[InlineData(ScriptKind.WrappedSegwit, NetKind.Mainnet)]
[InlineData(ScriptKind.NativeSegwit, NetKind.Mainnet)]
[InlineData(ScriptKind.Legacy, NetKind.Testnet)]
[InlineData(ScriptKind.WrappedSegwit, NetKind.Testnet)]
[InlineData(ScriptKind.NativeSegwit, NetKind.Testnet)]
[InlineData(ScriptKind.NativeSegwit, NetKind.Regtest)]
public void Ogni_indirizzo_derivato_fa_roundtrip_sulla_propria_rete(ScriptKind kind, NetKind net)
{
var profile = ChainProfiles.For(net);
var account = HdAccount.FromSeed(AbandonAboutSeed(), kind, profile);
var addr = account.GetReceiveAddress(0);
var parsed = BitcoinAddress.Create(addr.ToString(), PalladiumNetworks.For(net));
Assert.Equal(addr.ScriptPubKey, parsed.ScriptPubKey);
}
[Theory]
[InlineData(NetKind.Mainnet, "tplm1")]
[InlineData(NetKind.Mainnet, "rplm1")]
public void Gli_indirizzi_segwit_mainnet_non_hanno_prefissi_di_altre_reti(NetKind net, string wrongPrefix)
{
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.NativeSegwit, ChainProfiles.For(net));
Assert.DoesNotContain(wrongPrefix, account.GetReceiveAddress(0).ToString());
}
[Fact]
public void Il_path_di_account_usa_il_coin_type_del_profilo()
{
Assert.Equal("84'/746'/0'",
DerivationPaths.AccountPath(ScriptKind.NativeSegwit, ChainProfiles.Mainnet).ToString());
Assert.Equal("84'/1'/0'",
DerivationPaths.AccountPath(ScriptKind.NativeSegwit, ChainProfiles.Testnet).ToString());
Assert.Equal("44'/746'/2'",
DerivationPaths.AccountPath(ScriptKind.Legacy, ChainProfiles.Mainnet, account: 2).ToString());
}
[Fact]
public void Receiving_e_change_derivano_indirizzi_diversi()
{
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.NotEqual(account.GetReceiveAddress(0).ToString(), account.GetChangeAddress(0).ToString());
Assert.NotEqual(account.GetReceiveAddress(0).ToString(), account.GetReceiveAddress(1).ToString());
}
}
@@ -0,0 +1,68 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
namespace PalladiumWallet.Tests.Crypto;
/// <summary>
/// Vettore di test 1 della specifica BIP32 (seed 000102...0e0f). Gli header
/// Legacy della mainnet PLM coincidono con quelli Bitcoin, quindi il confronto
/// con le stringhe della specifica è diretto sulla rete PLM.
/// </summary>
public class Bip32Tests
{
private static ExtKey Root => ExtKey.CreateFromSeed(
Convert.FromHexString("000102030405060708090a0b0c0d0e0f"));
[Fact]
public void La_root_del_vettore_1_serializza_le_stringhe_attese()
{
Assert.Equal(
"xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi",
Root.ToString(PalladiumNetworks.Mainnet));
Assert.Equal(
"xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8",
Root.Neuter().ToString(PalladiumNetworks.Mainnet));
}
[Fact]
public void La_derivazione_hardened_m_0h_produce_le_chiavi_attese()
{
var derived = Root.Derive(new KeyPath("0'"));
Assert.Equal(
"xprv9uHRZZhk6KAJC1avXpDAp4MDc3sQKNxDiPvvkX8Br5ngLNv1TxvUxt4cV1rGL5hj6KCesnDYUhd7oWgT11eZG7XnxHrnYeSvkzY7d2bhkJ7",
derived.ToString(PalladiumNetworks.Mainnet));
Assert.Equal(
"xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw",
derived.Neuter().ToString(PalladiumNetworks.Mainnet));
}
[Fact]
public void I_path_hardened_non_sono_derivabili_da_una_xpub()
{
// Garanzia §17: da sole chiavi pubbliche niente derivazione hardened.
Assert.ThrowsAny<InvalidOperationException>(() =>
Root.Neuter().Derive(new KeyPath("0'")));
}
[Theory]
[InlineData("m/84'/746'/0'", true)]
[InlineData("84h/746h/0h", true)]
[InlineData("0/5", true)]
[InlineData("m/84'/abc", false)]
[InlineData("", false)]
public void TryParse_accetta_path_validi_e_rifiuta_malformati(string path, bool expected)
{
Assert.Equal(expected, DerivationPaths.TryParse(path, out var parsed));
if (expected)
Assert.NotNull(parsed);
}
[Fact]
public void Gli_hardened_marker_apostrofo_e_h_sono_equivalenti()
{
Assert.True(DerivationPaths.TryParse("m/84'/746'/0'", out var a));
Assert.True(DerivationPaths.TryParse("84h/746h/0h", out var b));
Assert.Equal(a!.ToString(), b!.ToString());
}
}
@@ -0,0 +1,93 @@
using PalladiumWallet.Core.Crypto;
namespace PalladiumWallet.Tests.Crypto;
public class Bip39Tests
{
// Vettori ufficiali Trezor (python-mnemonic/vectors.json), passphrase "TREZOR".
[Theory]
[InlineData(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04")]
[InlineData(
"legal winner thank year wave sausage worth useful legal winner thank yellow",
"2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607")]
[InlineData(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
"bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8")]
[InlineData(
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote",
"dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad")]
[InlineData(
"void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold",
"01f5bced59dec48e362f2c45b5de68b9fd6c92c6634f44d6d40aab69056506f0e35524a518034ddc1192e1dacd32c1ed3eaa3c3b131c88ed8e7e54c49a5d0998")]
public void I_vettori_trezor_producono_il_seed_atteso(string words, string expectedSeedHex)
{
Assert.True(Bip39.TryParse(words, out var mnemonic));
Assert.Equal(expectedSeedHex, Convert.ToHexString(Bip39.ToSeed(mnemonic!, "TREZOR")).ToLowerInvariant());
}
[Fact]
public void Il_vettore_giapponese_copre_la_normalizzazione_nfkd()
{
// Vettore ufficiale bip32JP (test_JP_BIP39.json[0]): mnemonica con spazi
// ideografici U+3000 e passphrase con caratteri da normalizzare NFKD.
const string words =
"あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あおぞら";
const string passphrase = "㍍ガバヴァぱばぐゞちぢ十人十色";
const string expectedSeed =
"a262d6fb6122ecf45be09c50492b31f92e9beb7d9a845987a02cefda57a15f9c467a17872029a9e92299b5cbdf306e3a0ee620245cbd508959b6cb7ca637bd55";
Assert.True(Bip39.TryParse(words, out var mnemonic, MnemonicLanguage.Japanese));
Assert.Equal(expectedSeed, Convert.ToHexString(Bip39.ToSeed(mnemonic!, passphrase)).ToLowerInvariant());
}
[Theory]
[InlineData(MnemonicLength.Twelve)]
[InlineData(MnemonicLength.TwentyFour)]
public void Generate_produce_il_numero_di_parole_richiesto_con_checksum_valido(MnemonicLength length)
{
var mnemonic = Bip39.Generate(length);
Assert.Equal((int)length, mnemonic.Words.Length);
Assert.True(Bip39.TryParse(mnemonic.ToString(), out _));
}
[Fact]
public void Checksum_invalido_viene_rifiutato()
{
// 12 × "abandon": parole valide ma checksum errato — il caso che il
// costruttore NBitcoin non controlla da solo.
var words = string.Join(' ', Enumerable.Repeat("abandon", 12));
Assert.False(Bip39.TryParse(words, out _));
}
[Fact]
public void Numero_di_parole_errato_viene_rifiutato()
{
var words = string.Join(' ', Enumerable.Repeat("abandon", 12)) + " about";
Assert.False(Bip39.TryParse(words, out _));
}
[Fact]
public void La_lingua_viene_riconosciuta_automaticamente()
{
var spanish = Bip39.Generate(MnemonicLength.Twelve, MnemonicLanguage.Spanish);
Assert.True(Bip39.TryParse(spanish.ToString(), out var parsed));
Assert.Equal(spanish.ToString(), parsed!.ToString());
}
[Fact]
public void Passphrase_diverse_producono_seed_diversi()
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
var noPass = Bip39.ToSeed(mnemonic!);
var withPass = Bip39.ToSeed(mnemonic!, "TREZOR");
var otherPass = Bip39.ToSeed(mnemonic!, "trezor"); // case-sensitive (§4.1)
Assert.NotEqual(Convert.ToHexString(noPass), Convert.ToHexString(withPass));
Assert.NotEqual(Convert.ToHexString(withPass), Convert.ToHexString(otherPass));
}
}
@@ -0,0 +1,100 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
namespace PalladiumWallet.Tests.Crypto;
/// <summary>
/// Vettore di test della specifica BIP84 (mnemonica abandon-about, senza
/// passphrase). Gli header SLIP-132 della mainnet PLM coincidono con quelli
/// Bitcoin, quindi zprv/zpub si confrontano direttamente; gli indirizzi si
/// confrontano sul witness program (chain-independent) + prefisso PLM.
/// Il path m/84'/0'/0' è volutamente "personalizzato" (coin type 0, non 746):
/// esercita anche l'import con path custom (§4.2).
/// </summary>
public class Bip84Slip132Tests
{
private static HdAccount Account()
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
return HdAccount.FromSeed(
Bip39.ToSeed(mnemonic!),
ScriptKind.NativeSegwit,
ChainProfiles.Mainnet,
new KeyPath("84'/0'/0'"));
}
[Fact]
public void L_account_serializza_la_zprv_e_la_zpub_del_vettore()
{
var account = Account();
Assert.Equal(
"zprvAdG4iTXWBoARxkkzNpNh8r6Qag3irQB8PzEMkAFeTRXxHpbF9z4QgEvBRmfvqWvGp42t42nvgGpNgYSJA9iefm1yYNZKEm7z6qUWCroSQnE",
account.ToSlip132Private());
Assert.Equal(
"zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs",
account.ToSlip132());
}
[Fact]
public void La_zpub_fa_roundtrip_e_viene_riconosciuta_come_native_segwit()
{
var account = Account();
var encoded = account.ToSlip132();
Assert.True(Slip132.TryDecodePublic(encoded, ChainProfiles.Mainnet, out var decoded, out var kind));
Assert.Equal(ScriptKind.NativeSegwit, kind);
Assert.Equal(account.AccountXpub.ToString(PalladiumNetworks.Mainnet),
decoded!.ToString(PalladiumNetworks.Mainnet));
}
[Fact]
public void La_zprv_fa_roundtrip_con_riconoscimento_del_tipo()
{
var account = Account();
Assert.True(Slip132.TryDecodePrivate(account.ToSlip132Private(), ChainProfiles.Mainnet,
out var decoded, out var kind));
Assert.Equal(ScriptKind.NativeSegwit, kind);
Assert.Equal(account.ToSlip132Private(),
Slip132.Encode(decoded!, kind, ChainProfiles.Mainnet));
}
[Fact]
public void Una_xkey_con_header_sconosciuto_viene_rifiutata()
{
// xpub Bitcoin valida ma con header Legacy: non è una zpub.
var account = Account();
var asXpub = account.AccountXpub.ToString(Network.Main);
Assert.True(Slip132.TryDecodePublic(asXpub, ChainProfiles.Mainnet, out _, out var kind));
Assert.Equal(ScriptKind.Legacy, kind); // header xpub → riconosciuto come Legacy
Assert.False(Slip132.TryDecodePublic("non-base58!!!", ChainProfiles.Mainnet, out _, out _));
Assert.False(Slip132.TryDecodePrivate(asXpub, ChainProfiles.Mainnet, out _, out _)); // pub ≠ priv
}
[Theory]
[InlineData(false, 0, "0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c",
"bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu")]
[InlineData(false, 1, null, "bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g")]
[InlineData(true, 0, null, "bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el")]
public void Gli_indirizzi_del_vettore_hanno_lo_stesso_witness_program_su_plm(
bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress)
{
var account = Account();
var pubKey = account.GetPublicKey(isChange, index);
if (expectedPubKeyHex is not null)
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
// Il witness program (hash160 della pubkey) è chain-independent: deve
// coincidere con quello dell'indirizzo bc1 del vettore ufficiale.
var bitcoinExpected = (BitcoinWitPubKeyAddress)BitcoinAddress.Create(bitcoinAddress, Network.Main);
Assert.Equal(bitcoinExpected.Hash, pubKey.WitHash);
var plmAddress = account.GetAddress(isChange, index);
Assert.StartsWith("plm1q", plmAddress.ToString());
Assert.Equal(pubKey.WitHash, ((BitcoinWitPubKeyAddress)plmAddress).Hash);
}
}
@@ -0,0 +1,88 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
namespace PalladiumWallet.Tests.Crypto;
public class HdAccountTests
{
private static Mnemonic AbandonAbout()
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
return mnemonic!;
}
[Fact]
public void Il_watch_only_da_xpub_deriva_gli_stessi_indirizzi_del_seed()
{
var full = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.True(Slip132.TryDecodePublic(full.ToSlip132(), ChainProfiles.Mainnet, out var xpub, out var kind));
var watchOnly = HdAccount.FromAccountXpub(xpub!, kind, ChainProfiles.Mainnet);
Assert.True(watchOnly.IsWatchOnly);
Assert.False(full.IsWatchOnly);
for (var i = 0; i < 3; i++)
{
Assert.Equal(full.GetReceiveAddress(i).ToString(), watchOnly.GetReceiveAddress(i).ToString());
Assert.Equal(full.GetChangeAddress(i).ToString(), watchOnly.GetChangeAddress(i).ToString());
}
}
[Fact]
public void Il_watch_only_non_espone_chiavi_private()
{
var full = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.True(Slip132.TryDecodePublic(full.ToSlip132(), ChainProfiles.Mainnet, out var xpub, out _));
var watchOnly = HdAccount.FromAccountXpub(xpub!, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.Throws<InvalidOperationException>(() => watchOnly.GetExtPrivateKey(false, 0));
Assert.Throws<InvalidOperationException>(() => watchOnly.ToSlip132Private());
}
[Fact]
public void La_master_fingerprint_di_abandon_about_e_quella_nota()
{
// 73c5da0a: fingerprint usata nei vettori PSBT di mezzo ecosistema.
var account = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.Equal("73c5da0a", Convert.ToHexString(account.MasterFingerprint.ToBytes()).ToLowerInvariant());
}
[Fact]
public void La_chiave_privata_derivata_corrisponde_all_indirizzo()
{
var account = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
var priv = account.GetExtPrivateKey(isChange: false, 0);
Assert.Equal(account.GetPublicKey(false, 0), priv.PrivateKey.PubKey);
}
[Fact]
public void La_passphrase_cambia_completamente_l_account()
{
var without = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
var with = HdAccount.FromMnemonic(AbandonAbout(), "extension", ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.NotEqual(without.GetReceiveAddress(0).ToString(), with.GetReceiveAddress(0).ToString());
Assert.NotEqual(without.MasterFingerprint, with.MasterFingerprint);
}
[Theory]
[InlineData(ScriptKind.WrappedSegwitMultisig)]
[InlineData(ScriptKind.NativeSegwitMultisig)]
public void I_tipi_multisig_non_sono_ancora_supportati(ScriptKind kind)
{
Assert.Throws<NotSupportedException>(() =>
HdAccount.FromMnemonic(AbandonAbout(), null, kind, ChainProfiles.Mainnet));
Assert.Throws<NotSupportedException>(() => DerivationPaths.ScriptPubKeyTypeFor(kind));
}
[Fact]
public void L_account_di_default_usa_il_path_standard_del_profilo()
{
var account = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.Equal("84'/746'/0'", account.AccountPath.ToString());
}
}
@@ -0,0 +1,97 @@
using System.Text.Json;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.Tests.Net;
public class PeerParsingTests
{
[Fact]
public void La_risposta_peers_subscribe_si_parsa_nel_formato_electrumx()
{
// Formato reale: [ip, hostname, ["v...", "pN", "tPORTA", "sPORTA"]].
const string json = """
[
["173.212.224.67", "173.212.224.67", ["v1.4.2", "p10000", "t50001", "s50002"]],
["10.0.0.1", "nodo.esempio.org", ["v1.4", "t"]],
["10.0.0.2", "solo-ssl.esempio.org", ["v1.4", "s50002"]],
["10.0.0.3", "senza-porte.esempio.org", ["v1.4"]]
]
""";
var peers = ElectrumApi.ParsePeers(JsonDocument.Parse(json).RootElement);
Assert.Equal(3, peers.Count); // l'ultimo non offre porte → scartato
Assert.Equal(new PeerInfo("173.212.224.67", 50001, 50002, "1.4.2"), peers[0]);
// "t" senza numero = porta di default (0 segnala "da risolvere col profilo").
Assert.Equal(new PeerInfo("nodo.esempio.org", 0, null, "1.4"), peers[1]);
Assert.Equal(new PeerInfo("solo-ssl.esempio.org", null, 50002, "1.4"), peers[2]);
}
}
public class ServerRegistryTests
{
private static string TempPath() =>
Path.Combine(Path.GetTempPath(), $"plm-servers-{Guid.NewGuid()}.json");
[Fact]
public void Il_registro_parte_dai_bootstrap_del_profilo()
{
var path = TempPath();
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
Assert.Equal(ChainProfiles.Mainnet.BootstrapServers.Count, registry.All.Count);
Assert.Equal("173.212.224.67", registry.Default!.Host);
Assert.Equal(50001, registry.Default.PortFor(useSsl: false));
Assert.Equal(50002, registry.Default.PortFor(useSsl: true));
}
[Fact]
public void I_peer_scoperti_si_aggiungono_e_persistono_senza_duplicati()
{
var path = TempPath();
try
{
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
var bootstrapCount = registry.All.Count;
// Merge diretto via DiscoverAsync richiede un client: si testa la
// persistenza simulando il file degli scoperti.
var discovered = new[] { new KnownServer("nuovo.esempio.org", 50001, 50002, "1.4.2") };
File.WriteAllText(path, JsonSerializer.Serialize(discovered));
var reloaded = new ServerRegistry(ChainProfiles.Mainnet, path);
Assert.Equal(bootstrapCount + 1, reloaded.All.Count);
Assert.Contains(reloaded.All, s => s.Host == "nuovo.esempio.org");
// Un bootstrap duplicato nel file non raddoppia.
File.WriteAllText(path, JsonSerializer.Serialize(new[]
{
new KnownServer("173.212.224.67", 50001, 50002),
new KnownServer("nuovo.esempio.org", 50001, 50002),
}));
var deduped = new ServerRegistry(ChainProfiles.Mainnet, path);
Assert.Equal(bootstrapCount + 1, deduped.All.Count);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void Un_file_server_corrotto_non_blocca_l_avvio()
{
var path = TempPath();
try
{
File.WriteAllText(path, "{ non-json ");
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
Assert.Equal(ChainProfiles.Mainnet.BootstrapServers.Count, registry.All.Count);
}
finally
{
File.Delete(path);
}
}
}
+127
View File
@@ -0,0 +1,127 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Spv;
namespace PalladiumWallet.Tests.Spv;
public class ScripthashTests
{
// Vettori calcolati indipendentemente con Python (hashlib), non con NBitcoin.
[Theory]
[InlineData("76a9140102030405060708090a0b0c0d0e0f101112131488ac",
"5546fc69d399ef99854c132abb060381cc159dbec67c496a6f0e0dbf12e83ae8")]
[InlineData("00140102030405060708090a0b0c0d0e0f1011121314",
"8639a8f75edb01f890138755277a84283c26fcba6f3289725d19cead464aa78a")]
public void Lo_scripthash_e_sha256_dello_script_con_byte_invertiti(string scriptHex, string expected)
{
var script = Script.FromHex(scriptHex);
Assert.Equal(expected, Scripthash.FromScript(script));
}
}
public class MerkleProofTests
{
// Blocco Bitcoin 100000: 4 transazioni, merkle root nota — àncora esterna
// per la convenzione di hashing/ordinamento.
private static readonly uint256[] Block100000Txids =
[
uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"),
uint256.Parse("fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4"),
uint256.Parse("6359f0868171b1d194cbee1af2f16ea598ae8fad666d9b012c8ed2b79a236ec4"),
uint256.Parse("e9a66845e05d5abc0ad04ec80f774a7e585c6e8db975962d069a522137b80c1d"),
];
private static readonly uint256 Block100000Root =
uint256.Parse("f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766");
[Fact]
public void La_radice_calcolata_dalle_foglie_coincide_con_quella_del_blocco()
{
Assert.Equal(Block100000Root, MerkleProof.ComputeRootFromLeaves(Block100000Txids));
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void Ogni_prova_di_merkle_del_blocco_verifica_contro_la_radice(int position)
{
var branch = BuildBranch(Block100000Txids, position);
Assert.True(MerkleProof.Verify(Block100000Txids[position], position, branch, Block100000Root));
}
[Fact]
public void Una_prova_per_la_posizione_sbagliata_fallisce()
{
var branch = BuildBranch(Block100000Txids, 0);
Assert.False(MerkleProof.Verify(Block100000Txids[0], 1, branch, Block100000Root));
}
[Fact]
public void Un_txid_estraneo_non_verifica()
{
var branch = BuildBranch(Block100000Txids, 0);
Assert.False(MerkleProof.Verify(uint256.One, 0, branch, Block100000Root));
}
/// <summary>Costruisce il branch per una foglia ricostruendo i livelli dell'albero.</summary>
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
{
var branch = new List<uint256>();
var level = leaves.ToList();
while (level.Count > 1)
{
var sibling = (position ^ 1) < level.Count ? level[position ^ 1] : level[position];
branch.Add(sibling);
var next = new List<uint256>();
for (var i = 0; i < level.Count; i += 2)
{
var pair = new[] { level[i], i + 1 < level.Count ? level[i + 1] : level[i] };
next.Add(MerkleProof.ComputeRootFromLeaves(pair));
}
level = next;
position >>= 1;
}
return branch;
}
}
public class BlockHeaderInfoTests
{
// Header del blocco genesi di Bitcoin (riusato dalla mainnet PLM, §3).
private const string GenesisHeaderHex =
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c";
[Fact]
public void L_header_della_genesi_si_parsa_con_hash_e_merkle_root_attesi()
{
var header = BlockHeaderInfo.Parse(GenesisHeaderHex);
Assert.Equal(ChainProfiles.Mainnet.GenesisHash, header.Hash.ToString());
Assert.Equal(
"4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
header.MerkleRoot.ToString());
Assert.Equal(uint256.Zero, header.PrevHash);
Assert.Equal(1, header.Version);
Assert.Equal(0x1d00ffffu, header.Bits);
}
[Fact]
public void Il_collegamento_prev_hash_viene_verificato()
{
var genesis = BlockHeaderInfo.Parse(GenesisHeaderHex);
Assert.True(genesis.IsValidChild(uint256.Zero, ChainProfiles.Mainnet));
Assert.False(genesis.IsValidChild(uint256.One, ChainProfiles.Mainnet));
}
[Fact]
public void Con_skip_pow_la_validazione_non_controlla_il_target()
{
// La genesi ha PoW valido, ma il punto è che con SkipPowValidation=true
// (LWMA, §3) il check si limita al collegamento.
var header = BlockHeaderInfo.Parse(GenesisHeaderHex);
Assert.True(ChainProfiles.Mainnet.SkipPowValidation);
Assert.True(header.IsValidChild(uint256.Zero, ChainProfiles.Mainnet));
}
}
@@ -0,0 +1,95 @@
using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Tests.Storage;
public class StorageTests
{
private static WalletDocument SampleDoc() => new()
{
Network = "regtest",
ScriptKind = "NativeSegwit",
Mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
AccountPath = "84'/1'/0'",
AccountXpub = "vpub-fittizia-per-test",
Labels = { ["txid123"] = "caffè" },
};
[Fact]
public void La_cifratura_fa_roundtrip_con_la_password_giusta()
{
var cipher = EncryptedFile.Encrypt("contenuto segreto", "pass-forte");
Assert.True(EncryptedFile.IsEncrypted(cipher));
Assert.DoesNotContain("segreto", cipher);
Assert.Equal("contenuto segreto", EncryptedFile.Decrypt(cipher, "pass-forte"));
}
[Fact]
public void La_password_errata_viene_rifiutata()
{
var cipher = EncryptedFile.Encrypt("contenuto", "giusta");
Assert.Throws<WrongPasswordException>(() => EncryptedFile.Decrypt(cipher, "sbagliata"));
}
[Fact]
public void Un_file_manomesso_viene_rifiutato()
{
var cipher = EncryptedFile.Encrypt("contenuto", "pass");
// Corrompe un byte del ciphertext mantenendo base64 e JSON validi.
var node = System.Text.Json.Nodes.JsonNode.Parse(cipher)!;
var data = Convert.FromBase64String(node["Data"]!.GetValue<string>());
data[0] ^= 0xff;
node["Data"] = Convert.ToBase64String(data);
Assert.Throws<WrongPasswordException>(() => EncryptedFile.Decrypt(node.ToJsonString(), "pass"));
}
[Fact]
public void Il_documento_wallet_fa_roundtrip_json()
{
var doc = SampleDoc();
var restored = WalletDocument.FromJson(doc.ToJson());
Assert.Equal(doc.Network, restored.Network);
Assert.Equal(doc.Mnemonic, restored.Mnemonic);
Assert.Equal(doc.AccountXpub, restored.AccountXpub);
Assert.Equal("caffè", restored.Labels["txid123"]);
Assert.False(restored.IsWatchOnly);
}
[Fact]
public void Una_versione_futura_del_file_viene_rifiutata()
{
var doc = SampleDoc();
var json = doc.ToJson().Replace("\"Version\": 1", "\"Version\": 99");
Assert.Throws<InvalidDataException>(() => WalletDocument.FromJson(json));
}
[Fact]
public void Il_wallet_store_salva_e_riapre_con_e_senza_password()
{
var path = Path.Combine(Path.GetTempPath(), $"plm-test-{Guid.NewGuid()}.wallet.json");
try
{
WalletStore.Save(SampleDoc(), path);
Assert.False(WalletStore.RequiresPassword(path));
Assert.Equal("regtest", WalletStore.Load(path).Network);
WalletStore.Save(SampleDoc(), path, "pwd");
Assert.True(WalletStore.RequiresPassword(path));
Assert.Throws<WrongPasswordException>(() => WalletStore.Load(path));
Assert.Throws<WrongPasswordException>(() => WalletStore.Load(path, "altra"));
Assert.Equal("regtest", WalletStore.Load(path, "pwd").Network);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void Il_documento_senza_mnemonica_e_watch_only()
{
var doc = SampleDoc();
doc.Mnemonic = null;
Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly);
}
}
@@ -0,0 +1,159 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Tests.Wallet;
public class TransactionFactoryTests
{
private static readonly ChainProfile Profile = ChainProfiles.Regtest;
private static readonly Network Net = PalladiumNetworks.Regtest;
private static HdAccount Account()
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
}
/// <summary>Tx fittizia che accredita <paramref name="sats"/> sull'indirizzo receiving/0.</summary>
private static (List<CachedUtxo>, Dictionary<string, Transaction>) Fund(HdAccount account, long sats)
{
var funding = Net.CreateTransaction();
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
funding.Outputs.Add(Money.Satoshis(sats), account.GetReceiveAddress(0));
var txid = funding.GetHash().ToString();
var utxos = new List<CachedUtxo>
{
new()
{
Txid = txid, Vout = 0, ValueSats = sats,
Address = account.GetReceiveAddress(0).ToString(),
IsChange = false, AddressIndex = 0, Height = 100,
},
};
return (utxos, new Dictionary<string, Transaction> { [txid] = funding });
}
[Fact]
public void Una_spesa_firmata_verifica_e_paga_la_fee_attesa()
{
var account = Account();
var (utxos, txs) = Fund(account, 1_000_000);
var destination = BitcoinAddress.Create(account.GetReceiveAddress(5).ToString(), Net);
var built = new TransactionFactory(account).Build(
utxos, txs, destination, amountSats: 400_000,
feeRateSatPerVByte: 2, changeIndex: 0);
Assert.True(built.Signed);
// Output: destinatario + change.
Assert.Equal(2, built.Transaction.Outputs.Count);
Assert.Contains(built.Transaction.Outputs,
o => o.ScriptPubKey == destination.ScriptPubKey && o.Value.Satoshi == 400_000);
// Fee coerente col rate richiesto (±20% per gli arrotondamenti di stima).
var vsize = built.Transaction.GetVirtualSize();
var expected = vsize * 2;
Assert.InRange(built.Fee.Satoshi, expected * 0.8, expected * 1.5);
// RBF abilitato (§6.6).
Assert.All(built.Transaction.Inputs, i => Assert.True(i.Sequence < Sequence.Final));
}
[Fact]
public void Invia_tutto_sottrae_la_fee_e_non_produce_change()
{
var account = Account();
var (utxos, txs) = Fund(account, 500_000);
var destination = account.GetReceiveAddress(7);
var built = new TransactionFactory(account).Build(
utxos, txs, destination, amountSats: 0,
feeRateSatPerVByte: 1, changeIndex: 0, sendAll: true);
var output = Assert.Single(built.Transaction.Outputs);
Assert.Equal(500_000, output.Value.Satoshi + built.Fee.Satoshi);
}
[Fact]
public void Fondi_insufficienti_danno_un_errore_chiaro()
{
var account = Account();
var (utxos, txs) = Fund(account, 1_000);
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(1), amountSats: 900_000,
feeRateSatPerVByte: 2, changeIndex: 0));
}
[Fact]
public void Gli_utxo_in_mempool_non_sono_spendibili()
{
var account = Account();
var (utxos, txs) = Fund(account, 1_000_000);
utxos[0].Height = 0; // in mempool: visibile nel saldo pending, non spendibile
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
feeRateSatPerVByte: 2, changeIndex: 0));
Assert.Contains("in attesa di conferma", ex.Message);
}
[Fact]
public void Gli_utxo_congelati_sono_esclusi_dalla_spesa()
{
var account = Account();
var (utxos, txs) = Fund(account, 1_000_000);
utxos[0].Frozen = true; // freeze (§6.2)
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
feeRateSatPerVByte: 2, changeIndex: 0));
}
[Fact]
public void Un_account_watch_only_produce_una_psbt_non_firmata()
{
var full = Account();
var (utxos, txs) = Fund(full, 1_000_000);
Assert.True(Slip132.TryDecodePublic(full.ToSlip132(), Profile, out var xpub, out var kind));
var watchOnly = HdAccount.FromAccountXpub(xpub!, kind, Profile);
var built = new TransactionFactory(watchOnly).Build(
utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
feeRateSatPerVByte: 2, changeIndex: 0);
Assert.False(built.Signed);
// Il flusso air-gapped (§6.5): la PSBT della macchina online si firma
// offline con le chiavi e si finalizza.
var psbt = built.Psbt;
psbt.SignWithKeys(full.GetExtPrivateKey(false, 0));
psbt.Finalize();
var tx = psbt.ExtractTransaction();
Assert.Contains(tx.Outputs, o => o.Value.Satoshi == 400_000);
}
[Theory]
[InlineData("0.00000001", 1L)]
[InlineData("1", 100_000_000L)]
[InlineData("0,5", 50_000_000L)]
[InlineData("21.12345678", 2_112_345_678L)]
public void Gli_importi_si_parsano_in_satoshi(string text, long expected)
{
Assert.True(CoinAmount.TryParseCoins(text, out var sats));
Assert.Equal(expected, sats);
}
[Fact]
public void Gli_importi_invalidi_vengono_rifiutati()
{
Assert.False(CoinAmount.TryParseCoins("abc", out _));
Assert.False(CoinAmount.TryParseCoins("-1", out _));
}
}