Compare commits
10 Commits
bd5e740599
...
ece634f42e
| Author | SHA1 | Date | |
|---|---|---|---|
| ece634f42e | |||
| af10482e54 | |||
| 46d8c19784 | |||
| 08217e5306 | |||
| 1c2dacc27c | |||
| 34c588c6f4 | |||
| 0e32b9bc9b | |||
| bb8563580f | |||
| 3d6597a3b2 | |||
| 4d2ae1fd13 |
@@ -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>
|
||||||
@@ -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 |
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
|
public abstract class ViewModelBase : ObservableObject
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 = [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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.") { }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 _));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user