Compare commits
6 Commits
14ae39c5aa
...
5d2d0ad312
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d2d0ad312 | |||
| 3d5a226a5a | |||
| 4b82a0852c | |||
| 41eb1bb788 | |||
| 5ff2075a45 | |||
| 38e0f0a52e |
@@ -6,6 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with the domain's rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks and pitfalls (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
Operate as an **expert in cryptocurrencies and cryptography**: reason with the domain's rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks and pitfalls (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||||
|
|
||||||
|
## Language policy
|
||||||
|
|
||||||
|
- **Conversation with the user**: Italian.
|
||||||
|
- **All code, comments, commit messages, and documentation files**: English only.
|
||||||
|
|
||||||
## How to assist
|
## How to assist
|
||||||
|
|
||||||
On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ using PalladiumWallet.Core.Storage;
|
|||||||
|
|
||||||
namespace PalladiumWallet.Mobile;
|
namespace PalladiumWallet.Mobile;
|
||||||
|
|
||||||
// In Avalonia 12 l'AppBuilder Android si configura nella sottoclasse Application
|
// In Avalonia 12 the Android AppBuilder is configured in the Application subclass
|
||||||
// (AvaloniaAndroidApplication<TApp>), non più nell'Activity. allowBackup=false:
|
// (AvaloniaAndroidApplication<TApp>), no longer in the Activity. allowBackup=false:
|
||||||
// il file wallet cifrato/seed non deve finire nei backup cloud automatici.
|
// the encrypted wallet file/seed must not end up in automatic cloud backups.
|
||||||
[Application(Label = "Palladium Wallet", AllowBackup = false,
|
[Application(Label = "Palladium Wallet", AllowBackup = false,
|
||||||
Icon = "@mipmap/ic_launcher", RoundIcon = "@mipmap/ic_launcher_round")]
|
Icon = "@mipmap/ic_launcher", RoundIcon = "@mipmap/ic_launcher_round")]
|
||||||
public class MainApplication : AvaloniaAndroidApplication<global::PalladiumWallet.App.App>
|
public class MainApplication : AvaloniaAndroidApplication<global::PalladiumWallet.App.App>
|
||||||
@@ -26,7 +26,7 @@ public class MainApplication : AvaloniaAndroidApplication<global::PalladiumWalle
|
|||||||
{
|
{
|
||||||
AppPaths.OverrideDataRoot = FilesDir?.AbsolutePath;
|
AppPaths.OverrideDataRoot = FilesDir?.AbsolutePath;
|
||||||
|
|
||||||
// Registra lo scanner QR: apre ScannerActivity e ne attende il risultato.
|
// Registers the QR scanner: opens ScannerActivity and waits for its result.
|
||||||
PlatformServices.ScanQrAsync = async () =>
|
PlatformServices.ScanQrAsync = async () =>
|
||||||
{
|
{
|
||||||
var activity = MainActivity.Current;
|
var activity = MainActivity.Current;
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ using AndroidResult = Android.App.Result;
|
|||||||
namespace PalladiumWallet.Mobile;
|
namespace PalladiumWallet.Mobile;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Activity full-screen per la scansione QR via Camera2 + ZXing.Net.
|
/// Full-screen activity for QR scanning via Camera2 + ZXing.Net.
|
||||||
/// Torna a MainActivity via SetResult con l'extra "qr" = testo del codice.
|
/// Returns to MainActivity via SetResult with the extra "qr" = code text.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Activity(Theme = "@style/MyTheme.NoActionBar",
|
[Activity(Theme = "@style/MyTheme.NoActionBar",
|
||||||
ScreenOrientation = Android.Content.PM.ScreenOrientation.Portrait)]
|
ScreenOrientation = Android.Content.PM.ScreenOrientation.Portrait)]
|
||||||
@@ -162,7 +162,7 @@ internal sealed class ScannerActivity : Activity, TextureView.ISurfaceTextureLis
|
|||||||
bmp.GetPixels(pixels, 0, w, 0, 0, w, h);
|
bmp.GetPixels(pixels, 0, w, 0, 0, w, h);
|
||||||
bmp.Recycle();
|
bmp.Recycle();
|
||||||
|
|
||||||
// ARGB int[] → RGB byte[] per ZXing.RGBLuminanceSource
|
// ARGB int[] → RGB byte[] for ZXing.RGBLuminanceSource
|
||||||
var rgb = new byte[pixels.Length * 3];
|
var rgb = new byte[pixels.Length * 3];
|
||||||
for (int i = 0; i < pixels.Length; i++)
|
for (int i = 0; i < pixels.Length; i++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,19 @@
|
|||||||
<local:ViewLocator/>
|
<local:ViewLocator/>
|
||||||
</Application.DataTemplates>
|
</Application.DataTemplates>
|
||||||
|
|
||||||
|
<!-- Semantic design tokens (colors, radii, brand accent): defined for
|
||||||
|
both light/dark variants. See Styles/Theme.axaml. -->
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceInclude Source="/Styles/Theme.axaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
|
<!-- Control refinements and reusable classes (on top of Fluent) -->
|
||||||
|
<StyleInclude Source="/Styles/Controls.axaml"/>
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
@@ -17,8 +17,8 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
var vm = new MainWindowViewModel();
|
var vm = new MainWindowViewModel();
|
||||||
|
|
||||||
// Desktop (Windows/Linux): finestra classica. Mobile (Android): vista
|
// Desktop (Windows/Linux): classic window. Mobile (Android): single
|
||||||
// singola. Stessa UI condivisa (MainView) e stesso ViewModel.
|
// view. Same shared UI (MainView) and same ViewModel.
|
||||||
switch (ApplicationLifetime)
|
switch (ApplicationLifetime)
|
||||||
{
|
{
|
||||||
case IClassicDesktopStyleApplicationLifetime desktop:
|
case IClassicDesktopStyleApplicationLifetime desktop:
|
||||||
|
|||||||
+24
-15
@@ -3,9 +3,9 @@ using System.Collections.Generic;
|
|||||||
namespace PalladiumWallet.App.Localization;
|
namespace PalladiumWallet.App.Localization;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Localizzazione UI: dizionario chiave → traduzioni per lingua, con
|
/// UI localisation: key → per-language translations dictionary, with an
|
||||||
/// indicizzatore bindabile da XAML ({Binding Loc[chiave]}). Al cambio lingua
|
/// indexer bindable from XAML ({Binding Loc[key]}). On language change the
|
||||||
/// il ViewModel sostituisce l'istanza così Avalonia rivaluta tutte le binding.
|
/// ViewModel replaces the instance so Avalonia re-evaluates all bindings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class Loc
|
public sealed class Loc
|
||||||
{
|
{
|
||||||
@@ -20,10 +20,10 @@ public sealed class Loc
|
|||||||
private Loc(string language) { Language = language; }
|
private Loc(string language) { Language = language; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Crea una nuova istanza con la lingua specificata e aggiorna il singleton
|
/// Creates a new instance for the specified language and updates the singleton
|
||||||
/// usato da <see cref="Tr"/>. Il ViewModel assegna questa istanza alla
|
/// used by <see cref="Tr"/>. The ViewModel assigns this instance to its Loc
|
||||||
/// propria property Loc così Avalonia vede un riferimento diverso e
|
/// property so Avalonia sees a different reference and re-evaluates all
|
||||||
/// rivaluta tutte le binding {Binding Loc[chiave]}.
|
/// {Binding Loc[key]} bindings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static Loc SwitchTo(string language)
|
internal static Loc SwitchTo(string language)
|
||||||
{
|
{
|
||||||
@@ -146,13 +146,13 @@ public sealed class Loc
|
|||||||
"Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten."],
|
"Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten."],
|
||||||
["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)"],
|
["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)"],
|
||||||
|
|
||||||
// Messaggi di errore import
|
// Import error messages
|
||||||
["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante)."],
|
["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante)."],
|
||||||
["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt."],
|
["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt."],
|
||||||
["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein."],
|
["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein."],
|
||||||
["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig."],
|
["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig."],
|
||||||
|
|
||||||
// Pannello wallet
|
// Wallet panel
|
||||||
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
||||||
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
|
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
|
||||||
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync", "Conectar y sincronizar", "Connecter et synchroniser", "Conectar e sincronizar", "Verbinden und synchronisieren"],
|
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync", "Conectar y sincronizar", "Connecter et synchroniser", "Conectar e sincronizar", "Verbinden und synchronisieren"],
|
||||||
@@ -188,7 +188,7 @@ public sealed class Loc
|
|||||||
["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen"],
|
["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen"],
|
||||||
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
||||||
|
|
||||||
// Storico → dettaglio transazione
|
// History → transaction detail
|
||||||
["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details."],
|
["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details."],
|
||||||
["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails"],
|
["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails"],
|
||||||
["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
||||||
@@ -216,6 +216,11 @@ public sealed class Loc
|
|||||||
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"],
|
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"],
|
||||||
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein"],
|
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein"],
|
||||||
["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen."],
|
["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen."],
|
||||||
|
["tx.sect.overview"] = ["Panoramica", "Overview", "Resumen", "Aperçu", "Visão geral", "Übersicht"],
|
||||||
|
["tx.sect.amounts"] = ["Importi e commissioni", "Amounts & fees", "Importes y comisiones", "Montants et frais", "Valores e taxas", "Beträge & Gebühren"],
|
||||||
|
["tx.sect.tech"] = ["Dettagli tecnici", "Technical details", "Detalles técnicos", "Détails techniques", "Detalhes técnicos", "Technische Details"],
|
||||||
|
["tx.coinbase"] = ["Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase"],
|
||||||
|
["tx.coinbase.newcoins"] = ["Nuova emissione (mining)", "Newly generated (mining)", "Nueva emisión (minería)", "Nouvelle émission (minage)", "Nova emissão (mineração)", "Neu erzeugt (Mining)"],
|
||||||
["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:"],
|
["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:"],
|
||||||
["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen"],
|
["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen"],
|
||||||
["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse"],
|
["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse"],
|
||||||
@@ -225,8 +230,12 @@ public sealed class Loc
|
|||||||
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
|
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
|
||||||
["send.scan"] = ["Scansiona QR", "Scan QR", "Escanear QR", "Scanner QR", "Escanear QR", "QR scannen"],
|
["send.scan"] = ["Scansiona QR", "Scan QR", "Escanear QR", "Scanner QR", "Escanear QR", "QR scannen"],
|
||||||
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"],
|
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"],
|
||||||
|
["send.sect.recipient"] = ["Destinatario", "Recipient", "Destinatario", "Destinataire", "Destinatário", "Empfänger"],
|
||||||
|
["send.sect.amount"] = ["Importo e commissione", "Amount & fee", "Importe y comisión", "Montant et frais", "Valor e taxa", "Betrag & Gebühr"],
|
||||||
|
["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung"],
|
||||||
|
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse"],
|
||||||
|
|
||||||
// Stato connessione
|
// Connection status
|
||||||
["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden"],
|
["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden"],
|
||||||
["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt"],
|
["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt"],
|
||||||
["conn.reconnecting"] = ["riconnessione…", "reconnecting…", "reconectando…", "reconnexion…", "reconectando…", "Verbindung wird wiederhergestellt…"],
|
["conn.reconnecting"] = ["riconnessione…", "reconnecting…", "reconectando…", "reconnexion…", "reconectando…", "Verbindung wird wiederhergestellt…"],
|
||||||
@@ -235,7 +244,7 @@ public sealed class Loc
|
|||||||
["conn.connectedto"] = ["connesso", "connected", "conectado", "connecté", "conectado", "verbunden"],
|
["conn.connectedto"] = ["connesso", "connected", "conectado", "connecté", "conectado", "verbunden"],
|
||||||
["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu"],
|
["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu"],
|
||||||
|
|
||||||
// Messaggi di stato principali
|
// Main status messages
|
||||||
["msg.welcome.existing"] = [
|
["msg.welcome.existing"] = [
|
||||||
"Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro.",
|
"Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro.",
|
||||||
"Found an existing wallet on this network: open it, or create another one.",
|
"Found an existing wallet on this network: open it, or create another one.",
|
||||||
@@ -346,7 +355,7 @@ public sealed class Loc
|
|||||||
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
|
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
|
||||||
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler"],
|
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler"],
|
||||||
|
|
||||||
// Contatti
|
// Contacts
|
||||||
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"],
|
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"],
|
||||||
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
|
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
|
||||||
["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname"],
|
["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname"],
|
||||||
@@ -355,7 +364,7 @@ public sealed class Loc
|
|||||||
["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen"],
|
["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen"],
|
||||||
["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte."],
|
["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte."],
|
||||||
|
|
||||||
// Finestra impostazioni
|
// Settings window
|
||||||
["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen"],
|
["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen"],
|
||||||
["settings.language"] = ["Lingua", "Language", "Idioma", "Langue", "Idioma", "Sprache"],
|
["settings.language"] = ["Lingua", "Language", "Idioma", "Langue", "Idioma", "Sprache"],
|
||||||
["settings.unit"] = ["Unità degli importi", "Amount unit", "Unidad de importes", "Unité des montants", "Unidade dos valores", "Betrageinheit"],
|
["settings.unit"] = ["Unità degli importi", "Amount unit", "Unidad de importes", "Unité des montants", "Unidade dos valores", "Betrageinheit"],
|
||||||
@@ -363,7 +372,7 @@ public sealed class Loc
|
|||||||
["settings.cancel"] = ["Annulla", "Cancel", "Cancelar", "Annuler", "Cancelar", "Abbrechen"],
|
["settings.cancel"] = ["Annulla", "Cancel", "Cancelar", "Annuler", "Cancelar", "Abbrechen"],
|
||||||
["settings.server"] = ["Server di indicizzazione…", "Indexing server…", "Servidor de indexación…", "Serveur d'indexation…", "Servidor de indexação…", "Indexierungsserver…"],
|
["settings.server"] = ["Server di indicizzazione…", "Indexing server…", "Servidor de indexación…", "Serveur d'indexation…", "Servidor de indexação…", "Indexierungsserver…"],
|
||||||
|
|
||||||
// Finestra server
|
// Server window
|
||||||
["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver"],
|
["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver"],
|
||||||
["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host"],
|
["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host"],
|
||||||
["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port"],
|
["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port"],
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ using System.Threading.Tasks;
|
|||||||
namespace PalladiumWallet.App.Services;
|
namespace PalladiumWallet.App.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seam per servizi specifici della piattaforma (Android/desktop).
|
/// Seam for platform-specific services (Android/desktop).
|
||||||
/// Il head Android imposta i delegate in OnCreate; desktop li lascia null.
|
/// The Android head sets the delegates in OnCreate; desktop leaves them null.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class PlatformServices
|
public static class PlatformServices
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Apre lo scanner QR nativo e restituisce il testo raw del codice,
|
/// Opens the native QR scanner and returns the raw code text,
|
||||||
/// oppure null se l'utente annulla o lo scanner non è disponibile.
|
/// or null if the user cancels or the scanner is unavailable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Func<Task<string?>>? ScanQrAsync { get; set; }
|
public static Func<Task<string?>>? ScanQrAsync { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<!-- ============================================================
|
||||||
|
Palladium wallet control styles ("clean fintech").
|
||||||
|
Applied on top of FluentTheme: refine spacing, states and
|
||||||
|
reusable classes without re-templating controls. The "accent"
|
||||||
|
buttons remain managed by Fluent but inherit the brand blue
|
||||||
|
via SystemAccentColor (see Theme.axaml).
|
||||||
|
============================================================ -->
|
||||||
|
|
||||||
|
<Design.PreviewWith>
|
||||||
|
<Border Padding="20" Width="320">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Classes="h1" Text="Saldo disponibile"/>
|
||||||
|
<TextBlock Classes="amount" Text="1 234,56 PLM"/>
|
||||||
|
<Button Classes="accent" Content="Invia"/>
|
||||||
|
<Button Content="Annulla"/>
|
||||||
|
<Border Classes="card" Padding="16">
|
||||||
|
<TextBlock Text="Card di esempio"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Design.PreviewWith>
|
||||||
|
|
||||||
|
<!-- ===== Typography ===== -->
|
||||||
|
<Style Selector="TextBlock.h1">
|
||||||
|
<Setter Property="FontSize" Value="20"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.label">
|
||||||
|
<Setter Property="FontSize" Value="11"/>
|
||||||
|
<Setter Property="FontWeight" Value="Medium"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.muted">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<!-- Amounts and numeric data: tabular figures to prevent layout jumps -->
|
||||||
|
<Style Selector="TextBlock.amount">
|
||||||
|
<Setter Property="FontSize" Value="34"/>
|
||||||
|
<Setter Property="FontWeight" Value="Bold"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||||
|
<Setter Property="FontFeatures" Value="+tnum"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.mono, SelectableTextBlock.mono">
|
||||||
|
<Setter Property="FontFamily" Value="monospace"/>
|
||||||
|
<Setter Property="FontFeatures" Value="+tnum"/>
|
||||||
|
</Style>
|
||||||
|
<!-- Section heading (e.g. transaction detail) -->
|
||||||
|
<Style Selector="TextBlock.section">
|
||||||
|
<Setter Property="FontSize" Value="11"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}"/>
|
||||||
|
<Setter Property="LetterSpacing" Value="0.8"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ===== Buttons ===== -->
|
||||||
|
<Style Selector="Button">
|
||||||
|
<Setter Property="MinHeight" Value="36"/>
|
||||||
|
<Setter Property="Padding" Value="14,8"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="FontWeight" Value="Medium"/>
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
<!-- Accent (CTA) buttons are already blue via Fluent+SystemAccentColor.
|
||||||
|
Ensure readable white text in both light and dark variants. -->
|
||||||
|
<Style Selector="Button.accent">
|
||||||
|
<Setter Property="Foreground" Value="#FFFFFF"/>
|
||||||
|
</Style>
|
||||||
|
<!-- "ghost" variant: subtle secondary action, no fill -->
|
||||||
|
<Style Selector="Button.ghost">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ===== Inputs ===== -->
|
||||||
|
<Style Selector="TextBox">
|
||||||
|
<Setter Property="MinHeight" Value="36"/>
|
||||||
|
<Setter Property="Padding" Value="10,7"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ComboBox">
|
||||||
|
<Setter Property="MinHeight" Value="36"/>
|
||||||
|
<Setter Property="Padding" Value="10,6"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ===== Lists ===== -->
|
||||||
|
<Style Selector="ListBox">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="0"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBoxItem">
|
||||||
|
<Setter Property="Padding" Value="12,10"/>
|
||||||
|
<Setter Property="CornerRadius" Value="8"/>
|
||||||
|
<Setter Property="Margin" Value="0,1"/>
|
||||||
|
</Style>
|
||||||
|
<!-- Hover: neutral tint; selected: subtle brand-blue tint -->
|
||||||
|
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SurfaceAltBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PrimarySoftBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ===== Tabs (dashboard) ===== -->
|
||||||
|
<!-- Selected item: brand blue + bold; unselected: muted -->
|
||||||
|
<Style Selector="TabItem">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<Setter Property="FontWeight" Value="Medium"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TabItem:selected">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TabItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="TextElement.Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ===== Reusable classes ===== -->
|
||||||
|
<!-- Card: elevated surface with a subtle border and soft corners -->
|
||||||
|
<Style Selector="Border.card">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtleBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="CornerRadius" Value="14"/>
|
||||||
|
<Setter Property="BoxShadow" Value="0 1 3 0 #14000000"/>
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BoxShadowsTransition Property="BoxShadow" Duration="0:0:0.18"/>
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.card:pointerover">
|
||||||
|
<Setter Property="BoxShadow" Value="0 6 16 -4 #26000000"/>
|
||||||
|
</Style>
|
||||||
|
<!-- Balance card: subtle blue accent, slightly more prominent -->
|
||||||
|
<Style Selector="Border.balance-card">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtleBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="CornerRadius" Value="14"/>
|
||||||
|
<Setter Property="Padding" Value="20,16"/>
|
||||||
|
<Setter Property="BoxShadow" Value="0 2 8 0 #1A000000"/>
|
||||||
|
</Style>
|
||||||
|
<!-- Hero balance card: brand gradient + blue glow. Signature dashboard
|
||||||
|
element; overlaid text is white (on-hero class). -->
|
||||||
|
<Style Selector="Border.balance-hero">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource BalanceGradientBrush}"/>
|
||||||
|
<Setter Property="CornerRadius" Value="16"/>
|
||||||
|
<Setter Property="Padding" Value="24,20"/>
|
||||||
|
<Setter Property="BoxShadow" Value="0 8 24 -6 #663B82F6"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.balance-hero TextBlock.amount">
|
||||||
|
<Setter Property="Foreground" Value="#FFFFFF"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.balance-hero TextBlock.on-hero">
|
||||||
|
<Setter Property="Foreground" Value="#D6E2FF"/>
|
||||||
|
</Style>
|
||||||
|
<!-- Status pill / badge -->
|
||||||
|
<Style Selector="Border.chip">
|
||||||
|
<Setter Property="CornerRadius" Value="20"/>
|
||||||
|
<Setter Property="Padding" Value="10,5"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SurfaceAltBrush}"/>
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<!-- ============================================================
|
||||||
|
Palladium wallet design tokens.
|
||||||
|
Identity: cobalt blue (trust) + green (money/positive),
|
||||||
|
derived from the logo. "Clean fintech" style.
|
||||||
|
|
||||||
|
All colors are SEMANTIC (role, not name): components
|
||||||
|
must never use inline hex but these brushes. Defined for
|
||||||
|
both variants (light/dark) via ThemeDictionaries, so
|
||||||
|
the app follows the system theme with no further configuration.
|
||||||
|
============================================================ -->
|
||||||
|
|
||||||
|
<ResourceDictionary.ThemeDictionaries>
|
||||||
|
|
||||||
|
<!-- ============ LIGHT THEME ============ -->
|
||||||
|
<ResourceDictionary x:Key="Light">
|
||||||
|
<!-- Surfaces -->
|
||||||
|
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#F1F5F9"/> <!-- slate-100 -->
|
||||||
|
<SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF"/>
|
||||||
|
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#F8FAFC"/> <!-- slate-50 -->
|
||||||
|
<SolidColorBrush x:Key="OverlayCardBrush" Color="#FFFFFF"/>
|
||||||
|
<SolidColorBrush x:Key="ScrimBrush" Color="#66000000"/> <!-- 40% -->
|
||||||
|
<!-- Borders and separators -->
|
||||||
|
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#E2E8F0"/> <!-- slate-200 -->
|
||||||
|
<SolidColorBrush x:Key="BorderStrongBrush" Color="#CBD5E1"/> <!-- slate-300 -->
|
||||||
|
<!-- Text -->
|
||||||
|
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#0F172A"/> <!-- slate-900 -->
|
||||||
|
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#475569"/> <!-- slate-600 -->
|
||||||
|
<SolidColorBrush x:Key="TextMutedBrush" Color="#94A3B8"/> <!-- slate-400 -->
|
||||||
|
<!-- Brand / state -->
|
||||||
|
<SolidColorBrush x:Key="PrimaryBrush" Color="#2563EB"/> <!-- blue-600 -->
|
||||||
|
<SolidColorBrush x:Key="PrimaryHoverBrush" Color="#1D4ED8"/> <!-- blue-700 -->
|
||||||
|
<SolidColorBrush x:Key="OnPrimaryBrush" Color="#FFFFFF"/>
|
||||||
|
<SolidColorBrush x:Key="PrimarySoftBrush" Color="#EFF6FF"/> <!-- blue-50 -->
|
||||||
|
<SolidColorBrush x:Key="SuccessBrush" Color="#16A34A"/> <!-- green-600 -->
|
||||||
|
<SolidColorBrush x:Key="WarningBrush" Color="#D97706"/> <!-- amber-600 -->
|
||||||
|
<SolidColorBrush x:Key="DangerBrush" Color="#DC2626"/> <!-- red-600 -->
|
||||||
|
<!-- Hero gradient of the balance card: blue → indigo -->
|
||||||
|
<LinearGradientBrush x:Key="BalanceGradientBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||||
|
<GradientStop Offset="0" Color="#2563EB"/>
|
||||||
|
<GradientStop Offset="1" Color="#4F46E5"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</ResourceDictionary>
|
||||||
|
|
||||||
|
<!-- ============ DARK THEME ============ -->
|
||||||
|
<ResourceDictionary x:Key="Dark">
|
||||||
|
<!-- Surfaces -->
|
||||||
|
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#0F172A"/> <!-- slate-900 -->
|
||||||
|
<SolidColorBrush x:Key="SurfaceBrush" Color="#1E293B"/> <!-- slate-800 -->
|
||||||
|
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#273449"/>
|
||||||
|
<SolidColorBrush x:Key="OverlayCardBrush" Color="#1E293B"/>
|
||||||
|
<SolidColorBrush x:Key="ScrimBrush" Color="#99000000"/> <!-- 60% -->
|
||||||
|
<!-- Borders and separators -->
|
||||||
|
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#334155"/> <!-- slate-700 -->
|
||||||
|
<SolidColorBrush x:Key="BorderStrongBrush" Color="#475569"/> <!-- slate-600 -->
|
||||||
|
<!-- Text -->
|
||||||
|
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#F8FAFC"/> <!-- slate-50 -->
|
||||||
|
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#94A3B8"/> <!-- slate-400 -->
|
||||||
|
<SolidColorBrush x:Key="TextMutedBrush" Color="#64748B"/> <!-- slate-500 -->
|
||||||
|
<!-- Brand / state (brighter tones on dark background) -->
|
||||||
|
<SolidColorBrush x:Key="PrimaryBrush" Color="#3B82F6"/> <!-- blue-500 -->
|
||||||
|
<SolidColorBrush x:Key="PrimaryHoverBrush" Color="#60A5FA"/> <!-- blue-400 -->
|
||||||
|
<SolidColorBrush x:Key="OnPrimaryBrush" Color="#0B1120"/>
|
||||||
|
<SolidColorBrush x:Key="PrimarySoftBrush" Color="#1E3A8A"/> <!-- blue-900 -->
|
||||||
|
<SolidColorBrush x:Key="SuccessBrush" Color="#22C55E"/> <!-- green-500 -->
|
||||||
|
<SolidColorBrush x:Key="WarningBrush" Color="#F59E0B"/> <!-- amber-500 -->
|
||||||
|
<SolidColorBrush x:Key="DangerBrush" Color="#EF4444"/> <!-- red-500 -->
|
||||||
|
<!-- Hero gradient of the balance card: blue → indigo (deeper tones) -->
|
||||||
|
<LinearGradientBrush x:Key="BalanceGradientBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||||
|
<GradientStop Offset="0" Color="#1D4ED8"/>
|
||||||
|
<GradientStop Offset="1" Color="#4338CA"/>
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</ResourceDictionary>
|
||||||
|
|
||||||
|
</ResourceDictionary.ThemeDictionaries>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
Override of the Fluent accent → brand blue. Recolors
|
||||||
|
consistently the controls that read SystemAccentColor (accent
|
||||||
|
buttons, selections, tab indicator, focus) without retemplating anything.
|
||||||
|
Variant-independent: #2563EB works on light and dark.
|
||||||
|
============================================================ -->
|
||||||
|
<Color x:Key="SystemAccentColor">#2563EB</Color>
|
||||||
|
<Color x:Key="SystemAccentColorDark1">#1D4ED8</Color>
|
||||||
|
<Color x:Key="SystemAccentColorDark2">#1E40AF</Color>
|
||||||
|
<Color x:Key="SystemAccentColorDark3">#1E3A8A</Color>
|
||||||
|
<Color x:Key="SystemAccentColorLight1">#3B82F6</Color>
|
||||||
|
<Color x:Key="SystemAccentColorLight2">#60A5FA</Color>
|
||||||
|
<Color x:Key="SystemAccentColorLight3">#93C5FD</Color>
|
||||||
|
|
||||||
|
<!-- Global Fluent control corner radii: rounds buttons, inputs, combos,
|
||||||
|
and lists uniformly without retemplating each control. -->
|
||||||
|
<CornerRadius x:Key="ControlCornerRadius">8</CornerRadius>
|
||||||
|
<CornerRadius x:Key="OverlayCornerRadius">12</CornerRadius>
|
||||||
|
</ResourceDictionary>
|
||||||
@@ -6,8 +6,8 @@ using Avalonia.Data.Converters;
|
|||||||
namespace PalladiumWallet.App.ViewModels;
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// true (mobile) → Dock.Bottom — tab strip in basso, standard Android.
|
/// true (mobile) → Dock.Bottom — tab strip at the bottom, Android standard.
|
||||||
/// false (desktop) → Dock.Top — comportamento predefinito Avalonia.
|
/// false (desktop) → Dock.Top — Avalonia default behavior.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BoolToTabPlacementConverter : IValueConverter
|
public sealed class BoolToTabPlacementConverter : IValueConverter
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ContactEntry? selectedContactInList;
|
private ContactEntry? selectedContactInList;
|
||||||
|
|
||||||
/// <summary>Contatto selezionato nella ComboBox del pannello Invia: riempie SendTo.</summary>
|
/// <summary>Contact selected in the Send panel's ComboBox: fills SendTo.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ContactEntry? sendToContact;
|
private ContactEntry? sendToContact;
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ namespace PalladiumWallet.App.ViewModels;
|
|||||||
|
|
||||||
public partial class MainWindowViewModel
|
public partial class MainWindowViewModel
|
||||||
{
|
{
|
||||||
// ---- saldo e info wallet ----
|
// ---- balance and wallet info ----
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string balanceText = "—";
|
private string balanceText = "—";
|
||||||
@@ -28,7 +28,7 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string networkInfo = "";
|
private string networkInfo = "";
|
||||||
|
|
||||||
// ---- indirizzo di ricezione e QR ----
|
// ---- receive address and QR ----
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string receiveAddress = "";
|
private string receiveAddress = "";
|
||||||
@@ -60,7 +60,7 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- tab indirizzi ----
|
// ---- addresses tab ----
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private AddressRow? selectedAddressRow;
|
private AddressRow? selectedAddressRow;
|
||||||
@@ -74,7 +74,7 @@ public partial class MainWindowViewModel
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CloseAddressInfo() => AddressInfo = null;
|
private void CloseAddressInfo() => AddressInfo = null;
|
||||||
|
|
||||||
// ---- overlay dettaglio transazione ----
|
// ---- transaction detail overlay ----
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isTxDetailsOpen;
|
private bool isTxDetailsOpen;
|
||||||
@@ -169,7 +169,7 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- aggiorna display dal risultato della sync ----
|
// ---- update display from the sync result ----
|
||||||
|
|
||||||
private void ApplyCache(SyncCache? cache)
|
private void ApplyCache(SyncCache? cache)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public partial class MainWindowViewModel
|
|||||||
if (PlatformServices.ScanQrAsync is not { } scan) return;
|
if (PlatformServices.ScanQrAsync is not { } scan) return;
|
||||||
var raw = await scan();
|
var raw = await scan();
|
||||||
if (string.IsNullOrWhiteSpace(raw)) return;
|
if (string.IsNullOrWhiteSpace(raw)) return;
|
||||||
// Gestisce URI tipo "palladium:ADDRESS?amount=X" estraendo solo l'indirizzo
|
// Handle URIs like "palladium:ADDRESS?amount=X" — extract address only
|
||||||
var address = raw.Contains(':') ? raw.Split(':')[1] : raw;
|
var address = raw.Contains(':') ? raw.Split(':')[1] : raw;
|
||||||
if (address.Contains('?')) address = address.Split('?')[0];
|
if (address.Contains('?')) address = address.Split('?')[0];
|
||||||
SendTo = address.Trim();
|
SendTo = address.Trim();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace PalladiumWallet.App.ViewModels;
|
|||||||
|
|
||||||
public partial class MainWindowViewModel
|
public partial class MainWindowViewModel
|
||||||
{
|
{
|
||||||
// ---- server e connessione ----
|
// ---- server and connection ----
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string serverHost = "";
|
private string serverHost = "";
|
||||||
@@ -118,20 +118,20 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Restituisce i server da provare in ordine: prima quello selezionato nella UI
|
/// Returns servers to try in order: first the one selected in the UI
|
||||||
/// (o digitato manualmente), poi gli altri in KnownServers, evitando duplicati.
|
/// (or manually typed), then the remaining KnownServers, with duplicates skipped.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private IEnumerable<(string Host, int Port)> BuildServerCandidates(string selectedHost, int selectedPort)
|
private IEnumerable<(string Host, int Port)> BuildServerCandidates(string selectedHost, int selectedPort)
|
||||||
{
|
{
|
||||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
// 1. Server corrente (selezionato o digitato manualmente).
|
// 1. Current server (selected or manually typed).
|
||||||
if (!string.IsNullOrWhiteSpace(selectedHost))
|
if (!string.IsNullOrWhiteSpace(selectedHost))
|
||||||
{
|
{
|
||||||
var key = $"{selectedHost}:{selectedPort}";
|
var key = $"{selectedHost}:{selectedPort}";
|
||||||
if (seen.Add(key))
|
if (seen.Add(key))
|
||||||
yield return (selectedHost, selectedPort);
|
yield return (selectedHost, selectedPort);
|
||||||
}
|
}
|
||||||
// 2. Altri server noti, in ordine di lista.
|
// 2. Other known servers, in list order.
|
||||||
foreach (var s in KnownServers)
|
foreach (var s in KnownServers)
|
||||||
{
|
{
|
||||||
var p = s.PortFor(UseSsl);
|
var p = s.PortFor(UseSsl);
|
||||||
@@ -163,14 +163,14 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
if (_client is null || !_client.IsConnected)
|
if (_client is null || !_client.IsConnected)
|
||||||
{
|
{
|
||||||
// Salva le cache prima di distruggere il synchronizer: il nuovo
|
// Persist caches before destroying the synchronizer: the new
|
||||||
// synchronizer userà un client diverso e deve essere ricreato,
|
// synchronizer will use a different client and must be recreated,
|
||||||
// ma i dati già scaricati vengono preservati via _doc.Cache.
|
// but already-downloaded data is preserved via _doc.Cache.
|
||||||
PersistPartialTxCache();
|
PersistPartialTxCache();
|
||||||
_synchronizer = null;
|
_synchronizer = null;
|
||||||
|
|
||||||
// Prova tutti i server noti in ordine; parte da quello selezionato
|
// Try all known servers in order; starts with the selected one
|
||||||
// e scorre la lista fino al primo che risponde.
|
// and walks the list until the first that responds.
|
||||||
var candidates = BuildServerCandidates(host, port);
|
var candidates = BuildServerCandidates(host, port);
|
||||||
Exception? lastError = null;
|
Exception? lastError = null;
|
||||||
foreach (var (h, p) in candidates)
|
foreach (var (h, p) in candidates)
|
||||||
@@ -189,7 +189,7 @@ public partial class MainWindowViewModel
|
|||||||
IsConnected = true;
|
IsConnected = true;
|
||||||
_autoReconnect = true;
|
_autoReconnect = true;
|
||||||
ConnectionStatus = Loc.Tr("conn.connectedto");
|
ConnectionStatus = Loc.Tr("conn.connectedto");
|
||||||
// Aggiorna la UI per riflettere il server effettivamente connesso.
|
// Update the UI to reflect the server actually connected.
|
||||||
_syncingServerFields = true;
|
_syncingServerFields = true;
|
||||||
ServerHost = h;
|
ServerHost = h;
|
||||||
ServerPort = p.ToString();
|
ServerPort = p.ToString();
|
||||||
@@ -215,12 +215,15 @@ public partial class MainWindowViewModel
|
|||||||
if (_synchronizer is null)
|
if (_synchronizer is null)
|
||||||
{
|
{
|
||||||
_synchronizer = new WalletSynchronizer(_account, _client!, _doc.GapLimit);
|
_synchronizer = new WalletSynchronizer(_account, _client!, _doc.GapLimit);
|
||||||
// Ricarica dalla cache su disco: evita di riscaricale le tx già note
|
// Reload from disk cache: avoids re-downloading already known transactions
|
||||||
// (fondamentale per wallet con migliaia di tx storiche — previene -101).
|
// (essential for wallets with thousands of historical txs — prevents -101).
|
||||||
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
||||||
_synchronizer.PreloadCaches(
|
_synchronizer.PreloadCaches(
|
||||||
_doc.Cache?.RawTxHex ?? [],
|
_doc.Cache?.RawTxHex ?? [],
|
||||||
_doc.Cache?.VerifiedAt ?? [],
|
_doc.Cache?.VerifiedAt ?? [],
|
||||||
|
_doc.Cache?.BlockHeaders,
|
||||||
|
_doc.Cache?.NextReceiveIndex ?? 0,
|
||||||
|
_doc.Cache?.NextChangeIndex ?? 0,
|
||||||
net);
|
net);
|
||||||
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
||||||
}
|
}
|
||||||
@@ -231,7 +234,7 @@ public partial class MainWindowViewModel
|
|||||||
var result = await _synchronizer.SyncOnceAsync();
|
var result = await _synchronizer.SyncOnceAsync();
|
||||||
_lastTransactions = result.Transactions;
|
_lastTransactions = result.Transactions;
|
||||||
|
|
||||||
var (rawHex, verifiedAt) = _synchronizer.ExportCaches(
|
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
||||||
PalladiumNetworks.For(_account.Profile.Kind));
|
PalladiumNetworks.For(_account.Profile.Kind));
|
||||||
_doc.Cache = new SyncCache
|
_doc.Cache = new SyncCache
|
||||||
{
|
{
|
||||||
@@ -245,6 +248,7 @@ public partial class MainWindowViewModel
|
|||||||
Addresses = [.. result.AddressRows],
|
Addresses = [.. result.AddressRows],
|
||||||
RawTxHex = rawHex,
|
RawTxHex = rawHex,
|
||||||
VerifiedAt = verifiedAt,
|
VerifiedAt = verifiedAt,
|
||||||
|
BlockHeaders = blockHeaders,
|
||||||
};
|
};
|
||||||
WalletStore.Save(_doc, _walletPath!, _password);
|
WalletStore.Save(_doc, _walletPath!, _password);
|
||||||
ApplyCache(_doc.Cache);
|
ApplyCache(_doc.Cache);
|
||||||
@@ -267,8 +271,8 @@ public partial class MainWindowViewModel
|
|||||||
if (_account is not null)
|
if (_account is not null)
|
||||||
{
|
{
|
||||||
_syncFailed = true;
|
_syncFailed = true;
|
||||||
// Salva le tx già scaricate: al retry il synchronizer riparte
|
// Persist already-downloaded transactions: on retry the synchronizer
|
||||||
// da qui invece di ricominciare da zero (es. dopo -101).
|
// resumes from here instead of starting from scratch (e.g. after -101).
|
||||||
PersistPartialTxCache();
|
PersistPartialTxCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,9 +327,9 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Salva nel SyncCache le tx e le prove di Merkle già accumulate dal synchronizer,
|
/// Persists the transactions and Merkle proofs already accumulated by the synchronizer
|
||||||
/// anche se la sync non è ancora completa. Consente al retry successivo
|
/// into SyncCache, even if sync is not yet complete. Allows the next retry
|
||||||
/// (o al riavvio dell'app) di riprendere senza riscaricale da zero.
|
/// (or app restart) to resume without re-downloading from scratch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void PersistPartialTxCache()
|
private void PersistPartialTxCache()
|
||||||
{
|
{
|
||||||
@@ -334,14 +338,15 @@ public partial class MainWindowViewModel
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
||||||
var (rawHex, verifiedAt) = _synchronizer.ExportCaches(net);
|
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(net);
|
||||||
if (rawHex.Count == 0 && verifiedAt.Count == 0)
|
if (rawHex.Count == 0 && verifiedAt.Count == 0)
|
||||||
return;
|
return;
|
||||||
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
|
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
|
||||||
_doc.Cache.VerifiedAt = verifiedAt;
|
_doc.Cache.VerifiedAt = verifiedAt;
|
||||||
|
_doc.Cache.BlockHeaders = blockHeaders;
|
||||||
WalletStore.Save(_doc, _walletPath, _password);
|
WalletStore.Save(_doc, _walletPath, _password);
|
||||||
}
|
}
|
||||||
catch { /* non fatale: il prossimo salvataggio completo recupererà */ }
|
catch { /* non-fatal: the next full save will recover */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DisconnectAsync()
|
private async Task DisconnectAsync()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ namespace PalladiumWallet.App.ViewModels;
|
|||||||
|
|
||||||
public partial class MainWindowViewModel
|
public partial class MainWindowViewModel
|
||||||
{
|
{
|
||||||
// ---- wizard di setup
|
// ---- setup wizard
|
||||||
|
|
||||||
public const string StepDataLocation = "data-location";
|
public const string StepDataLocation = "data-location";
|
||||||
public const string StepStart = "start";
|
public const string StepStart = "start";
|
||||||
@@ -85,7 +85,7 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string importWifInput = "";
|
private string importWifInput = "";
|
||||||
|
|
||||||
// Tipo di script rilevato durante la decodifica dell'xkey (per mostrarlo all'utente)
|
// Script type detected during xkey decoding (to display to the user)
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string importXkeyDetectedKind = "";
|
private string importXkeyDetectedKind = "";
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ public partial class MainWindowViewModel
|
|||||||
StatusMessage = Loc.Tr("msg.xkey.required");
|
StatusMessage = Loc.Tr("msg.xkey.required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Valida e rileva il tipo: prova prima come xpub, poi come xprv.
|
// Validate and detect the type: try as xpub first, then as xprv.
|
||||||
if (Slip132.TryDecodePublic(ImportXkeyInput.Trim(), Profile, out _, out var pubKind))
|
if (Slip132.TryDecodePublic(ImportXkeyInput.Trim(), Profile, out _, out var pubKind))
|
||||||
{
|
{
|
||||||
SelectedScriptKind = pubKind;
|
SelectedScriptKind = pubKind;
|
||||||
@@ -298,7 +298,7 @@ public partial class MainWindowViewModel
|
|||||||
StatusMessage = Loc.Tr("msg.wif.required");
|
StatusMessage = Loc.Tr("msg.wif.required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Valida il WIF con un parsing anticipato.
|
// Validate the WIF with an early parse.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_ = new NBitcoin.BitcoinSecret(ImportWifInput.Trim(), PalladiumNetworks.For(Net));
|
_ = new NBitcoin.BitcoinSecret(ImportWifInput.Trim(), PalladiumNetworks.For(Net));
|
||||||
@@ -421,9 +421,9 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Costruisce il percorso file dal nome inserito dall'utente.
|
/// Builds the file path from the name entered by the user.
|
||||||
/// Se il nome è vuoto genera un nome automatico (default, wallet-2, …).
|
/// If the name is empty, generates an automatic name (default, wallet-2, …).
|
||||||
/// Rimuove i caratteri non validi per il filesystem.
|
/// Removes characters that are invalid for the filesystem.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private string WalletPathFromName(string name)
|
private string WalletPathFromName(string name)
|
||||||
{
|
{
|
||||||
@@ -435,7 +435,7 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(clean))
|
if (string.IsNullOrEmpty(clean))
|
||||||
{
|
{
|
||||||
// Nessun nome → nome automatico
|
// No name provided → auto-generated name
|
||||||
var def = AppPaths.DefaultWalletPath(Net);
|
var def = AppPaths.DefaultWalletPath(Net);
|
||||||
for (var n = 2; WalletStore.Exists(def); n++)
|
for (var n = 2; WalletStore.Exists(def); n++)
|
||||||
def = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json");
|
def = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json");
|
||||||
@@ -547,8 +547,8 @@ public partial class MainWindowViewModel
|
|||||||
ApplyCache(doc.Cache);
|
ApplyCache(doc.Cache);
|
||||||
IsWalletOpen = true;
|
IsWalletOpen = true;
|
||||||
StatusMessage = Loc.Tr("msg.opened");
|
StatusMessage = Loc.Tr("msg.opened");
|
||||||
_autoReconnect = true; // keepalive riprova la connessione anche se il primo tentativo fallisce
|
_autoReconnect = true; // keepalive retries the connection even if the first attempt fails
|
||||||
_syncFailed = true; // forza la prima sync automatica
|
_syncFailed = true; // force the first automatic sync
|
||||||
_ = ConnectAndSync();
|
_ = ConnectAndSync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,10 +16,10 @@ using PalladiumWallet.Core.Wallet;
|
|||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
/// <summary>Riga dello storico transazioni per la vista.</summary>
|
/// <summary>Transaction history row for the view.</summary>
|
||||||
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
|
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
|
||||||
|
|
||||||
/// <summary>Riga della vista indirizzi con chiavi e derivation path pre-calcolati.</summary>
|
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
|
||||||
public sealed record AddressRow(
|
public sealed record AddressRow(
|
||||||
string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx,
|
string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx,
|
||||||
bool IsChange = false, string PubKey = "", string PrivKey = "", string DerivPath = "")
|
bool IsChange = false, string PubKey = "", string PrivKey = "", string DerivPath = "")
|
||||||
@@ -27,7 +27,7 @@ public sealed record AddressRow(
|
|||||||
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
|
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Dati completi di un indirizzo passati alla finestra di dettaglio.</summary>
|
/// <summary>Full address data passed to the address detail overlay.</summary>
|
||||||
public sealed record AddressInfo(
|
public sealed record AddressInfo(
|
||||||
Loc Loc,
|
Loc Loc,
|
||||||
string Address, string DerivPath, string PubKey, string PrivKey)
|
string Address, string DerivPath, string PubKey, string PrivKey)
|
||||||
@@ -35,38 +35,38 @@ public sealed record AddressInfo(
|
|||||||
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
|
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Contatto in rubrica: nome + indirizzo blockchain.</summary>
|
/// <summary>Address book contact: name + blockchain address.</summary>
|
||||||
public sealed record ContactEntry(string Name, string Address);
|
public sealed record ContactEntry(string Name, string Address);
|
||||||
|
|
||||||
/// <summary>Voce della lista di scelta wallet: nome file + percorso completo.</summary>
|
/// <summary>Wallet list entry: display name + full file path.</summary>
|
||||||
public sealed record WalletFileEntry(string Name, string Path);
|
public sealed record WalletFileEntry(string Name, string Path);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ViewModel unico dell'applicazione (wizard + dashboard). Suddiviso in file
|
/// Single application ViewModel (wizard + dashboard). Split into partial files
|
||||||
/// partial per area: Wizard, Settings, Sync, Send, Contacts, Receive.
|
/// by area: Wizard, Settings, Sync, Send, Contacts, Receive.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MainWindowViewModel : ViewModelBase
|
public partial class MainWindowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
// ---- stato sessione wallet ----
|
// ---- wallet session state ----
|
||||||
private WalletDocument? _doc;
|
private WalletDocument? _doc;
|
||||||
private IWalletAccount? _account;
|
private IWalletAccount? _account;
|
||||||
private string? _walletPath;
|
private string? _walletPath;
|
||||||
private string? _password;
|
private string? _password;
|
||||||
private WalletLock? _walletLock;
|
private WalletLock? _walletLock;
|
||||||
|
|
||||||
// ---- rete ----
|
// ---- network ----
|
||||||
private ElectrumClient? _client;
|
private ElectrumClient? _client;
|
||||||
private WalletSynchronizer? _synchronizer;
|
private WalletSynchronizer? _synchronizer;
|
||||||
private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
|
private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
|
||||||
private bool _resyncRequested;
|
private bool _resyncRequested;
|
||||||
|
|
||||||
// ---- invio ----
|
// ---- send ----
|
||||||
private BuiltTransaction? _pendingSend;
|
private BuiltTransaction? _pendingSend;
|
||||||
|
|
||||||
// ---- dettaglio transazione ----
|
// ---- transaction detail ----
|
||||||
private CancellationTokenSource? _txDetailsCts;
|
private CancellationTokenSource? _txDetailsCts;
|
||||||
|
|
||||||
// ---- configurazione e localizzazione ----
|
// ---- configuration and localisation ----
|
||||||
private AppConfig _config = AppConfig.Load();
|
private AppConfig _config = AppConfig.Load();
|
||||||
private Loc _loc = Loc.Instance;
|
private Loc _loc = Loc.Instance;
|
||||||
public Loc Loc => _loc;
|
public Loc Loc => _loc;
|
||||||
@@ -82,7 +82,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
// ---- server UI sync ----
|
// ---- server UI sync ----
|
||||||
private bool _syncingServerFields;
|
private bool _syncingServerFields;
|
||||||
|
|
||||||
// ---- proprietà di app ----
|
// ---- app properties ----
|
||||||
|
|
||||||
public static string AppVersion =>
|
public static string AppVersion =>
|
||||||
typeof(MainWindowViewModel).Assembly.GetName().Version is { } v
|
typeof(MainWindowViewModel).Assembly.GetName().Version is { } v
|
||||||
@@ -91,7 +91,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
public string WindowTitle => $"Palladium Wallet {AppVersion}";
|
public string WindowTitle => $"Palladium Wallet {AppVersion}";
|
||||||
|
|
||||||
/// <summary>true su desktop; false su Android/iOS — nasconde le funzioni legate al filesystem libero.</summary>
|
/// <summary>True on desktop; false on Android/iOS — hides filesystem-dependent features.</summary>
|
||||||
public bool IsDesktop => !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS();
|
public bool IsDesktop => !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS();
|
||||||
|
|
||||||
public bool IsMobile => !IsDesktop;
|
public bool IsMobile => !IsDesktop;
|
||||||
@@ -99,7 +99,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
public string UnitLabel => _config.Unit;
|
public string UnitLabel => _config.Unit;
|
||||||
public AppConfig CurrentConfig => _config;
|
public AppConfig CurrentConfig => _config;
|
||||||
|
|
||||||
// ---- stato pannelli ----
|
// ---- panel state ----
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsSetupVisible))]
|
[NotifyPropertyChangedFor(nameof(IsSetupVisible))]
|
||||||
@@ -116,7 +116,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SelectTab(string index) => SelectedTabIndex = int.Parse(index);
|
private void SelectTab(string index) => SelectedTabIndex = int.Parse(index);
|
||||||
|
|
||||||
// ---- collections per la dashboard ----
|
// ---- dashboard collections ----
|
||||||
|
|
||||||
public ObservableCollection<HistoryRow> History { get; } = [];
|
public ObservableCollection<HistoryRow> History { get; } = [];
|
||||||
public ObservableCollection<AddressRow> Addresses { get; } = [];
|
public ObservableCollection<AddressRow> Addresses { get; } = [];
|
||||||
@@ -141,7 +141,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
catch { return ""; }
|
catch { return ""; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- costruttore ----
|
// ---- constructor ----
|
||||||
|
|
||||||
public MainWindowViewModel()
|
public MainWindowViewModel()
|
||||||
{
|
{
|
||||||
@@ -161,7 +161,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
if (_client is { IsConnected: true })
|
if (_client is { IsConnected: true })
|
||||||
{
|
{
|
||||||
// Se il wallet è aperto e l'ultima sync è fallita, riprova automaticamente.
|
// If the wallet is open and the last sync failed, retry automatically.
|
||||||
if (_syncFailed && _account is not null)
|
if (_syncFailed && _account is not null)
|
||||||
{
|
{
|
||||||
await ConnectAndSync();
|
await ConnectAndSync();
|
||||||
@@ -177,7 +177,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ciclo di vita wallet ----
|
// ---- wallet lifecycle ----
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CloseWallet()
|
private void CloseWallet()
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ using Avalonia.Media;
|
|||||||
namespace PalladiumWallet.App.ViewModels;
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// bool → pennello: gli indirizzi del wallet (input/output "nostri") sono
|
/// bool → brush: the wallet's own addresses ("our" inputs/outputs) are
|
||||||
/// evidenziati in verde, gli altri usano il colore di testo predefinito.
|
/// highlighted in green, the others use the default text color.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MineColorConverter : IValueConverter
|
public sealed class MineColorConverter : IValueConverter
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ using PalladiumWallet.Core.Wallet;
|
|||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
/// <summary>Riga input/output per le tabelle della finestra di dettaglio.</summary>
|
/// <summary>Input/output row for the transaction detail tables.</summary>
|
||||||
public sealed record TxIoRow(string Position, string Address, string Amount, bool IsMine);
|
public sealed record TxIoRow(string Position, string Address, string Amount, bool IsMine);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ViewModel della finestra di dettaglio transazione: prende un
|
/// ViewModel for the transaction detail view: takes a
|
||||||
/// <see cref="TransactionDetails"/> (assemblato dal server) e ne ricava tutte
|
/// <see cref="TransactionDetails"/> (assembled from server data) and produces
|
||||||
/// le stringhe già formattate e localizzate per la vista. È di sola lettura.
|
/// all strings already formatted and localised for the view. Read-only.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TransactionDetailsViewModel
|
public sealed class TransactionDetailsViewModel
|
||||||
{
|
{
|
||||||
@@ -27,6 +27,7 @@ public sealed class TransactionDetailsViewModel
|
|||||||
_unit = unit;
|
_unit = unit;
|
||||||
|
|
||||||
Txid = d.Txid;
|
Txid = d.Txid;
|
||||||
|
IsCoinbase = d.IsCoinbase;
|
||||||
StatusText = BuildStatus(d, loc);
|
StatusText = BuildStatus(d, loc);
|
||||||
DateText = d.BlockTime is { } t
|
DateText = d.BlockTime is { } t
|
||||||
? t.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
|
? t.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
|
||||||
@@ -34,11 +35,14 @@ public sealed class TransactionDetailsViewModel
|
|||||||
|
|
||||||
var counterparties = d.CounterpartyAddresses;
|
var counterparties = d.CounterpartyAddresses;
|
||||||
CounterpartyHeader = d.IsIncoming ? loc["tx.from"] : loc["tx.to"];
|
CounterpartyHeader = d.IsIncoming ? loc["tx.from"] : loc["tx.to"];
|
||||||
CounterpartyText = counterparties.Count > 0
|
// Coinbase has no sender: coins are newly generated by mining.
|
||||||
|
CounterpartyText = d.IsCoinbase
|
||||||
|
? loc["tx.coinbase.newcoins"]
|
||||||
|
: counterparties.Count > 0
|
||||||
? string.Join(Environment.NewLine, counterparties)
|
? string.Join(Environment.NewLine, counterparties)
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
// Debito (uscita verso terzi) o Credito (entrata netta sui nostri output).
|
// Debit (outflow to third parties) or Credit (net inflow on our outputs).
|
||||||
AmountHeader = d.IsIncoming ? loc["tx.credit"] : loc["tx.debit"];
|
AmountHeader = d.IsIncoming ? loc["tx.credit"] : loc["tx.debit"];
|
||||||
AmountText = d.IsIncoming
|
AmountText = d.IsIncoming
|
||||||
? Signed(d.ReceivedSats)
|
? Signed(d.ReceivedSats)
|
||||||
@@ -61,8 +65,8 @@ public sealed class TransactionDetailsViewModel
|
|||||||
VerifiedText = d.Verified ? "✓ SPV" : "—";
|
VerifiedText = d.Verified ? "✓ SPV" : "—";
|
||||||
|
|
||||||
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
||||||
i.IsCoinbase ? "coinbase" : $"{Shorten(i.PrevTxid)}:{i.PrevIndex}",
|
i.IsCoinbase ? loc["tx.coinbase"] : $"{Shorten(i.PrevTxid)}:{i.PrevIndex}",
|
||||||
i.Address ?? "—",
|
i.IsCoinbase ? loc["tx.coinbase.newcoins"] : i.Address ?? "—",
|
||||||
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
|
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
|
||||||
i.IsMine)));
|
i.IsMine)));
|
||||||
|
|
||||||
@@ -74,6 +78,7 @@ public sealed class TransactionDetailsViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
public string Txid { get; }
|
public string Txid { get; }
|
||||||
|
public bool IsCoinbase { get; }
|
||||||
public string StatusText { get; }
|
public string StatusText { get; }
|
||||||
public string DateText { get; }
|
public string DateText { get; }
|
||||||
public string CounterpartyHeader { get; }
|
public string CounterpartyHeader { get; }
|
||||||
|
|||||||
+527
-279
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,10 @@ using PalladiumWallet.App.ViewModels;
|
|||||||
namespace PalladiumWallet.App.Views;
|
namespace PalladiumWallet.App.Views;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Vista radice dell'app, condivisa tra desktop (ospitata in <see cref="MainWindow"/>)
|
/// App root view, shared between desktop (hosted in <see cref="MainWindow"/>)
|
||||||
/// e mobile (root single-view). Tutti gli overlay sono in-app, quindi non servono
|
/// and mobile (single-view root). All overlays are in-app so no separate windows
|
||||||
/// finestre separate. Le API legate al top-level (file picker, clipboard) si
|
/// are needed. Top-level APIs (file picker, clipboard) are reached via
|
||||||
/// raggiungono via <see cref="TopLevel.GetTopLevel"/> perché un UserControl non le espone.
|
/// <see cref="TopLevel.GetTopLevel"/> because a UserControl does not expose them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MainView : UserControl
|
public partial class MainView : UserControl
|
||||||
{
|
{
|
||||||
@@ -47,8 +47,8 @@ public partial class MainView : UserControl
|
|||||||
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
|
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
|
||||||
if (lb.SelectedItem is not HistoryRow row) return;
|
if (lb.SelectedItem is not HistoryRow row) return;
|
||||||
|
|
||||||
// Overlay in-app: appare subito con lo spinner, i dati arrivano dal
|
// In-app overlay: appears immediately with a spinner; data arrives from
|
||||||
// server in background. Niente top-level window (lenta da aprire/chiudere).
|
// the server in the background. No top-level window (slow to open/close).
|
||||||
_ = vm.ShowTransactionDetailsAsync(row.Txid);
|
_ = vm.ShowTransactionDetailsAsync(row.Txid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,8 +78,8 @@ public partial class MainView : UserControl
|
|||||||
vm.ShowAddressInfo(row);
|
vm.ShowAddressInfo(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chiusura dell'overlay dettaglio indirizzo: click sullo sfondo scuro
|
// Close the address-detail overlay: click on the dark backdrop
|
||||||
// (solo sullo sfondo, non sulla scheda) o tasto Esc.
|
// (backdrop only, not the card itself) or press Esc.
|
||||||
private void OnAddressOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
private void OnAddressOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||||
{
|
{
|
||||||
if (!ReferenceEquals(e.Source, sender)) return;
|
if (!ReferenceEquals(e.Source, sender)) return;
|
||||||
@@ -140,7 +140,7 @@ public partial class MainView : UserControl
|
|||||||
vm.IsHelpOpen = false;
|
vm.IsHelpOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Esc (desktop) o tasto Back (Android) chiudono l'overlay in primo piano.
|
// Esc (desktop) or Back (Android) closes the topmost open overlay.
|
||||||
protected override void OnKeyDown(KeyEventArgs e)
|
protected override void OnKeyDown(KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if ((e.Key == Key.Escape || e.Key == Key.Back) && DataContext is MainWindowViewModel vm)
|
if ((e.Key == Key.Escape || e.Key == Key.Back) && DataContext is MainWindowViewModel vm)
|
||||||
|
|||||||
@@ -4,11 +4,14 @@
|
|||||||
xmlns:views="using:PalladiumWallet.App.Views"
|
xmlns:views="using:PalladiumWallet.App.Views"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="620"
|
mc:Ignorable="d" d:DesignWidth="1020" d:DesignHeight="680"
|
||||||
x:Class="PalladiumWallet.App.Views.MainWindow"
|
x:Class="PalladiumWallet.App.Views.MainWindow"
|
||||||
x:DataType="vm:MainWindowViewModel"
|
x:DataType="vm:MainWindowViewModel"
|
||||||
Icon="/Assets/logo.ico"
|
Icon="/Assets/logo.ico"
|
||||||
Width="900" Height="620"
|
Width="1020" Height="680"
|
||||||
|
MinWidth="1020" MinHeight="680"
|
||||||
|
MaxWidth="1020" MaxHeight="680"
|
||||||
|
CanResize="False"
|
||||||
Title="{Binding WindowTitle}">
|
Title="{Binding WindowTitle}">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
|
|||||||
@@ -131,8 +131,17 @@ static async Task<int> Sync(string[] o)
|
|||||||
|
|
||||||
var sync = new WalletSynchronizer(account, client, doc.GapLimit);
|
var sync = new WalletSynchronizer(account, client, doc.GapLimit);
|
||||||
sync.Progress += msg => Console.WriteLine($" {msg}");
|
sync.Progress += msg => Console.WriteLine($" {msg}");
|
||||||
|
var net = PalladiumNetworks.For(account.Profile.Kind);
|
||||||
|
sync.PreloadCaches(
|
||||||
|
doc.Cache?.RawTxHex ?? [],
|
||||||
|
doc.Cache?.VerifiedAt ?? [],
|
||||||
|
doc.Cache?.BlockHeaders,
|
||||||
|
doc.Cache?.NextReceiveIndex ?? 0,
|
||||||
|
doc.Cache?.NextChangeIndex ?? 0,
|
||||||
|
net);
|
||||||
var result = await sync.SyncOnceAsync();
|
var result = await sync.SyncOnceAsync();
|
||||||
|
|
||||||
|
var (rawHex, verifiedAt, blockHeaders) = sync.ExportCaches(net);
|
||||||
doc.Cache = new SyncCache
|
doc.Cache = new SyncCache
|
||||||
{
|
{
|
||||||
TipHeight = result.TipHeight,
|
TipHeight = result.TipHeight,
|
||||||
@@ -143,6 +152,9 @@ static async Task<int> Sync(string[] o)
|
|||||||
History = [.. result.History],
|
History = [.. result.History],
|
||||||
Utxos = [.. result.Utxos],
|
Utxos = [.. result.Utxos],
|
||||||
Addresses = [.. result.AddressRows],
|
Addresses = [.. result.AddressRows],
|
||||||
|
RawTxHex = rawHex,
|
||||||
|
VerifiedAt = verifiedAt,
|
||||||
|
BlockHeaders = blockHeaders,
|
||||||
};
|
};
|
||||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace PalladiumWallet.Core.Chain;
|
namespace PalladiumWallet.Core.Chain;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tipo di rete supportato (selettore unico per tutto il wallet, blueprint §3).
|
/// Supported network type (single selector for the whole wallet, blueprint §3).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum NetKind
|
public enum NetKind
|
||||||
{
|
{
|
||||||
@@ -11,7 +11,7 @@ public enum NetKind
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tipo di script/indirizzo supportato (blueprint §4.3).
|
/// Supported script/address type (blueprint §4.3).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum ScriptKind
|
public enum ScriptKind
|
||||||
{
|
{
|
||||||
@@ -21,7 +21,7 @@ public enum ScriptKind
|
|||||||
/// <summary>P2SH-P2WPKH (segwit wrapped).</summary>
|
/// <summary>P2SH-P2WPKH (segwit wrapped).</summary>
|
||||||
WrappedSegwit,
|
WrappedSegwit,
|
||||||
|
|
||||||
/// <summary>P2WPKH (native segwit) — default consigliato.</summary>
|
/// <summary>P2WPKH (native segwit) — recommended default.</summary>
|
||||||
NativeSegwit,
|
NativeSegwit,
|
||||||
|
|
||||||
/// <summary>P2SH-P2WSH (multisig wrapped).</summary>
|
/// <summary>P2SH-P2WSH (multisig wrapped).</summary>
|
||||||
@@ -35,15 +35,15 @@ public enum ScriptKind
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Coppia di header di versione BIP32 (4 byte big-endian) per serializzare
|
/// Pair of BIP32 version headers (4-byte big-endian) to serialize
|
||||||
/// xprv/xpub e varianti SLIP-132 (y/z/Y/Z) — blueprint §3.
|
/// xprv/xpub and SLIP-132 variants (y/z/Y/Z) — blueprint §3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct ExtKeyHeaders(uint Private, uint Public)
|
public readonly record struct ExtKeyHeaders(uint Private, uint Public)
|
||||||
{
|
{
|
||||||
/// <summary>Header privato come 4 byte big-endian (da anteporre al payload BIP32).</summary>
|
/// <summary>Private header as 4 big-endian bytes (to prepend to the BIP32 payload).</summary>
|
||||||
public byte[] PrivateBytes() => ToBytes(Private);
|
public byte[] PrivateBytes() => ToBytes(Private);
|
||||||
|
|
||||||
/// <summary>Header pubblico come 4 byte big-endian.</summary>
|
/// <summary>Public header as 4 big-endian bytes.</summary>
|
||||||
public byte[] PublicBytes() => ToBytes(Public);
|
public byte[] PublicBytes() => ToBytes(Public);
|
||||||
|
|
||||||
internal static byte[] ToBytes(uint header) =>
|
internal static byte[] ToBytes(uint header) =>
|
||||||
@@ -56,82 +56,82 @@ public readonly record struct ExtKeyHeaders(uint Private, uint Public)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server di indicizzazione di bootstrap: host + porta TCP + porta SSL (blueprint §3/§9).
|
/// Bootstrap indexing server: host + TCP port + SSL port (blueprint §3/§9).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct ServerEndpoint(string Host, int TcpPort, int SslPort);
|
public readonly record struct ServerEndpoint(string Host, int TcpPort, int SslPort);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checkpoint di catena: ancora la fiducia quando la verifica PoW è disattivata
|
/// Chain checkpoint: anchors trust when PoW validation is disabled
|
||||||
/// (blueprint §7.3). Target serializzato come "bits" compatti.
|
/// (blueprint §7.3). Target serialized as compact "bits".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct Checkpoint(int Height, string BlockHash, uint Bits);
|
public readonly record struct Checkpoint(int Height, string BlockHash, uint Bits);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Profilo di rete/catena (blueprint §3): tutte le costanti specifiche della catena,
|
/// Network/chain profile (blueprint §3): all chain-specific constants,
|
||||||
/// centralizzate in un solo punto. Nessun magic number altrove nel codice.
|
/// centralized in one place. No magic numbers elsewhere in the code.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record ChainProfile
|
public sealed record ChainProfile
|
||||||
{
|
{
|
||||||
public required NetKind Kind { get; init; }
|
public required NetKind Kind { get; init; }
|
||||||
|
|
||||||
/// <summary>Nome rete, usato anche come sottocartella dati.</summary>
|
/// <summary>Network name, also used as the data subfolder.</summary>
|
||||||
public required string NetName { get; init; }
|
public required string NetName { get; init; }
|
||||||
|
|
||||||
/// <summary>Simbolo dell'unità (es. PLM).</summary>
|
/// <summary>Unit symbol (e.g. PLM).</summary>
|
||||||
public required string CoinUnit { get; init; }
|
public required string CoinUnit { get; init; }
|
||||||
|
|
||||||
/// <summary>Prefisso WIF delle chiavi private.</summary>
|
/// <summary>WIF prefix for private keys.</summary>
|
||||||
public required byte WifPrefix { get; init; }
|
public required byte WifPrefix { get; init; }
|
||||||
|
|
||||||
/// <summary>Byte di versione indirizzi P2PKH.</summary>
|
/// <summary>Version byte for P2PKH addresses.</summary>
|
||||||
public required byte AddrP2pkh { get; init; }
|
public required byte AddrP2pkh { get; init; }
|
||||||
|
|
||||||
/// <summary>Byte di versione indirizzi P2SH.</summary>
|
/// <summary>Version byte for P2SH addresses.</summary>
|
||||||
public required byte AddrP2sh { get; init; }
|
public required byte AddrP2sh { get; init; }
|
||||||
|
|
||||||
/// <summary>Human-readable part bech32/bech32m.</summary>
|
/// <summary>Human-readable part bech32/bech32m.</summary>
|
||||||
public required string SegwitHrp { get; init; }
|
public required string SegwitHrp { get; init; }
|
||||||
|
|
||||||
/// <summary>HRP fatture BOLT11 (Lightning, fase successiva — §11).</summary>
|
/// <summary>HRP for BOLT11 invoices (Lightning, later phase — §11).</summary>
|
||||||
public required string Bolt11Hrp { get; init; }
|
public required string Bolt11Hrp { get; init; }
|
||||||
|
|
||||||
/// <summary>Hash del blocco genesi (hex, byte order da explorer).</summary>
|
/// <summary>Genesis block hash (hex, explorer byte order).</summary>
|
||||||
public required string GenesisHash { get; init; }
|
public required string GenesisHash { get; init; }
|
||||||
|
|
||||||
/// <summary>Porta TCP del server di indicizzazione (non è la porta P2P del nodo).</summary>
|
/// <summary>TCP port of the indexing server (not the node's P2P port).</summary>
|
||||||
public required int DefaultTcpPort { get; init; }
|
public required int DefaultTcpPort { get; init; }
|
||||||
|
|
||||||
/// <summary>Porta SSL del server di indicizzazione.</summary>
|
/// <summary>SSL port of the indexing server.</summary>
|
||||||
public required int DefaultSslPort { get; init; }
|
public required int DefaultSslPort { get; init; }
|
||||||
|
|
||||||
/// <summary>Porta P2P del nodo — solo informativa: il wallet SPV non la usa.</summary>
|
/// <summary>Node's P2P port — informational only: the SPV wallet does not use it.</summary>
|
||||||
public required int NodeP2pPort { get; init; }
|
public required int NodeP2pPort { get; init; }
|
||||||
|
|
||||||
/// <summary>Coin type SLIP-0044 nei derivation path (convenzione wallet).</summary>
|
/// <summary>SLIP-0044 coin type in derivation paths (wallet convention).</summary>
|
||||||
public required int Bip44CoinType { get; init; }
|
public required int Bip44CoinType { get; init; }
|
||||||
|
|
||||||
/// <summary>Schema URI pagamenti BIP21 (senza i due punti).</summary>
|
/// <summary>BIP21 payment URI scheme (without the colon).</summary>
|
||||||
public required string UriScheme { get; init; }
|
public required string UriScheme { get; init; }
|
||||||
|
|
||||||
/// <summary>Block explorer di default.</summary>
|
/// <summary>Default block explorer.</summary>
|
||||||
public required string ExplorerUrl { get; init; }
|
public required string ExplorerUrl { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// La catena usa LWMA (retargeting per-blocco, 2 minuti): un client SPV non può
|
/// The chain uses LWMA (per-block retargeting, 2 minutes): an SPV client cannot
|
||||||
/// ricalcolarla, quindi la verifica bits/target va saltata e la fiducia ancorata
|
/// recompute it, so bits/target validation must be skipped and trust anchored
|
||||||
/// ai checkpoint (§3/§7). Per la catena di riferimento è sempre true.
|
/// to checkpoints (§3/§7). For the reference chain it is always true.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required bool SkipPowValidation { get; init; }
|
public required bool SkipPowValidation { get; init; }
|
||||||
|
|
||||||
/// <summary>Tempo di blocco target in secondi (LWMA v2: 120s).</summary>
|
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
|
||||||
public required int BlockTimeSeconds { get; init; }
|
public required int BlockTimeSeconds { get; init; }
|
||||||
|
|
||||||
/// <summary>Header BIP32/SLIP-132 per ciascun tipo di script.</summary>
|
/// <summary>BIP32/SLIP-132 headers for each script type.</summary>
|
||||||
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
||||||
|
|
||||||
/// <summary>Server di indicizzazione per il primo contatto (bootstrap).</summary>
|
/// <summary>Indexing servers for first contact (bootstrap).</summary>
|
||||||
public required IReadOnlyList<ServerEndpoint> BootstrapServers { get; init; }
|
public required IReadOnlyList<ServerEndpoint> BootstrapServers { get; init; }
|
||||||
|
|
||||||
/// <summary>Checkpoint hardcoded, aggiornati a ogni release (§7.3).</summary>
|
/// <summary>Hardcoded checkpoints, updated at every release (§7.3).</summary>
|
||||||
public required IReadOnlyList<Checkpoint> Checkpoints { get; init; }
|
public required IReadOnlyList<Checkpoint> Checkpoints { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
namespace PalladiumWallet.Core.Chain;
|
namespace PalladiumWallet.Core.Chain;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// I tre profili di rete del wallet (blueprint §3). Valori mainnet verificati
|
/// The wallet's three network profiles (blueprint §3). Mainnet values verified
|
||||||
/// contro il sorgente del nodo (chainparams.cpp / pow.cpp) secondo il blueprint.
|
/// against the node source (chainparams.cpp / pow.cpp) per the blueprint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ChainProfiles
|
public static class ChainProfiles
|
||||||
{
|
{
|
||||||
@@ -12,11 +12,11 @@ public static class ChainProfiles
|
|||||||
NetName = "mainnet",
|
NetName = "mainnet",
|
||||||
CoinUnit = "PLM",
|
CoinUnit = "PLM",
|
||||||
WifPrefix = 0x80,
|
WifPrefix = 0x80,
|
||||||
AddrP2pkh = 55, // indirizzi che iniziano con 'P'
|
AddrP2pkh = 55, // addresses starting with 'P'
|
||||||
AddrP2sh = 5, // indirizzi che iniziano con '3'
|
AddrP2sh = 5, // addresses starting with '3'
|
||||||
SegwitHrp = "plm",
|
SegwitHrp = "plm",
|
||||||
Bolt11Hrp = "plm",
|
Bolt11Hrp = "plm",
|
||||||
// La mainnet riusa la genesi di Bitcoin (blueprint §3).
|
// Mainnet reuses Bitcoin's genesis (blueprint §3).
|
||||||
GenesisHash = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
|
GenesisHash = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
|
||||||
DefaultTcpPort = 50001,
|
DefaultTcpPort = 50001,
|
||||||
DefaultSslPort = 50002,
|
DefaultSslPort = 50002,
|
||||||
@@ -33,11 +33,11 @@ public static class ChainProfiles
|
|||||||
[ScriptKind.WrappedSegwitMultisig] = new(0x0295b005, 0x0295b43f), // Yprv / Ypub
|
[ScriptKind.WrappedSegwitMultisig] = new(0x0295b005, 0x0295b43f), // Yprv / Ypub
|
||||||
[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
|
||||||
// Nessun SLIP-132 per P2TR: il contesto è dato dal path m/86'/…
|
// No SLIP-132 for P2TR: the context is given by the m/86'/… path
|
||||||
[ScriptKind.Taproot] = new(0x0488ade4, 0x0488b21e), // xprv / xpub (BIP32 standard)
|
[ScriptKind.Taproot] = new(0x0488ade4, 0x0488b21e), // xprv / xpub (BIP32 standard)
|
||||||
},
|
},
|
||||||
// Server di indicizzazione noti per il primo contatto (§3/§9); altri
|
// Known indexing servers for first contact (§3/§9); other
|
||||||
// peer vengono scoperti via server.peers.subscribe.
|
// peers are discovered via server.peers.subscribe.
|
||||||
BootstrapServers =
|
BootstrapServers =
|
||||||
[
|
[
|
||||||
new ServerEndpoint("173.212.224.67", 50001, 50002),
|
new ServerEndpoint("173.212.224.67", 50001, 50002),
|
||||||
@@ -45,7 +45,7 @@ public static class ChainProfiles
|
|||||||
new ServerEndpoint("66.94.115.80", 50001, 50002),
|
new ServerEndpoint("66.94.115.80", 50001, 50002),
|
||||||
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
||||||
],
|
],
|
||||||
// TODO: popolare con [hash, bits] reali della catena (§7.3) prima della release.
|
// TODO: populate with the chain's real [hash, bits] (§7.3) before release.
|
||||||
Checkpoints = [],
|
Checkpoints = [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,15 +58,15 @@ public static class ChainProfiles
|
|||||||
AddrP2sh = 115,
|
AddrP2sh = 115,
|
||||||
SegwitHrp = "tplm",
|
SegwitHrp = "tplm",
|
||||||
Bolt11Hrp = "tplm",
|
Bolt11Hrp = "tplm",
|
||||||
// TODO: verificare contro chainparams.cpp del nodo (il blueprint non la riporta;
|
// TODO: verify against the node's chainparams.cpp (the blueprint does not report it;
|
||||||
// qui si assume la genesi di Bitcoin testnet3, da confermare).
|
// here Bitcoin testnet3 genesis is assumed, to be confirmed).
|
||||||
GenesisHash = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943",
|
GenesisHash = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943",
|
||||||
NodeP2pPort = 12333,
|
NodeP2pPort = 12333,
|
||||||
Bip44CoinType = 1,
|
Bip44CoinType = 1,
|
||||||
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
||||||
{
|
{
|
||||||
// tprv/tpub dal blueprint; le varianti SLIP-132 testnet (u/v/U/V) sono
|
// tprv/tpub from the blueprint; the testnet SLIP-132 variants (u/v/U/V) are
|
||||||
// i valori standard Electrum.
|
// the standard Electrum values.
|
||||||
[ScriptKind.Legacy] = new(0x04358394, 0x043587cf), // tprv / tpub
|
[ScriptKind.Legacy] = new(0x04358394, 0x043587cf), // tprv / tpub
|
||||||
[ScriptKind.WrappedSegwit] = new(0x044a4e28, 0x044a5262), // uprv / upub
|
[ScriptKind.WrappedSegwit] = new(0x044a4e28, 0x044a5262), // uprv / upub
|
||||||
[ScriptKind.WrappedSegwitMultisig] = new(0x024285b5, 0x024289ef), // Uprv / Upub
|
[ScriptKind.WrappedSegwitMultisig] = new(0x024285b5, 0x024289ef), // Uprv / Upub
|
||||||
@@ -84,7 +84,7 @@ public static class ChainProfiles
|
|||||||
NetName = "regtest",
|
NetName = "regtest",
|
||||||
SegwitHrp = "rplm",
|
SegwitHrp = "rplm",
|
||||||
Bolt11Hrp = "rplm",
|
Bolt11Hrp = "rplm",
|
||||||
// TODO: verificare contro chainparams.cpp del nodo (assunta genesi regtest Bitcoin).
|
// TODO: verify against the node's chainparams.cpp (Bitcoin regtest genesis assumed).
|
||||||
GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206",
|
GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206",
|
||||||
NodeP2pPort = 28444,
|
NodeP2pPort = 28444,
|
||||||
Checkpoints = [],
|
Checkpoints = [],
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ using NBitcoin.DataEncoders;
|
|||||||
namespace PalladiumWallet.Core.Chain;
|
namespace PalladiumWallet.Core.Chain;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Costruisce e registra le <see cref="Network"/> NBitcoin di Palladium a partire dai
|
/// Builds and registers the Palladium NBitcoin <see cref="Network"/> instances from the
|
||||||
/// <see cref="ChainProfiles"/> (blueprint §19.3). Tutto il resto del codice ottiene la
|
/// <see cref="ChainProfiles"/> (blueprint §19.3). All other code obtains the network
|
||||||
/// rete da qui, mai da costanti sparse.
|
/// from here — never from scattered constants.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Identità della moneta per NBitcoin: raggruppa le tre reti sotto il codice PLM.
|
/// Coin identity for NBitcoin: groups the three networks under the PLM code.
|
||||||
/// I getter sono lazy, quindi nessuna ricorsione durante la costruzione.
|
/// Getters are lazy, so no recursion occurs during construction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PalladiumNetworkSet : INetworkSet
|
public sealed class PalladiumNetworkSet : INetworkSet
|
||||||
{
|
{
|
||||||
@@ -34,13 +34,13 @@ public sealed class PalladiumNetworkSet : INetworkSet
|
|||||||
|
|
||||||
public static class PalladiumNetworks
|
public static class PalladiumNetworks
|
||||||
{
|
{
|
||||||
// Blocco genesi di Bitcoin (la mainnet Palladium lo riusa, blueprint §3).
|
// Bitcoin genesis block (Palladium mainnet reuses it, blueprint §3).
|
||||||
private const string MainGenesisHex =
|
private const string MainGenesisHex =
|
||||||
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c" +
|
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c" +
|
||||||
Coinbase;
|
Coinbase;
|
||||||
|
|
||||||
// Genesi testnet3/regtest di Bitcoin: stessa coinbase, cambiano time/bits/nonce.
|
// Bitcoin testnet3/regtest genesis: same coinbase, different time/bits/nonce.
|
||||||
// TODO: confermare contro chainparams.cpp del nodo (vedi ChainProfiles).
|
// TODO: confirm against the node's chainparams.cpp (see ChainProfiles).
|
||||||
private const string TestGenesisHex =
|
private const string TestGenesisHex =
|
||||||
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff001d1aa4ae18" +
|
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff001d1aa4ae18" +
|
||||||
Coinbase;
|
Coinbase;
|
||||||
@@ -81,9 +81,9 @@ public static class PalladiumNetworks
|
|||||||
NetKind.Testnet => ChainName.Testnet,
|
NetKind.Testnet => ChainName.Testnet,
|
||||||
_ => ChainName.Regtest,
|
_ => ChainName.Regtest,
|
||||||
})
|
})
|
||||||
// Magic P2P solo segnaposto: il wallet SPV non apre mai connessioni P2P
|
// P2P magic is a placeholder only: the SPV wallet never opens P2P connections
|
||||||
// (parla solo col server di indicizzazione, §3). Serve a NBitcoin per
|
// (it only talks to the indexing server, §3). Required by NBitcoin to
|
||||||
// distinguere le reti registrate.
|
// distinguish registered networks.
|
||||||
.SetMagic(magic)
|
.SetMagic(magic)
|
||||||
.SetPort(p.NodeP2pPort)
|
.SetPort(p.NodeP2pPort)
|
||||||
.SetRPCPort(p.NodeP2pPort + 1)
|
.SetRPCPort(p.NodeP2pPort + 1)
|
||||||
@@ -91,9 +91,9 @@ public static class PalladiumNetworks
|
|||||||
.SetBase58Bytes(Base58Type.PUBKEY_ADDRESS, [p.AddrP2pkh])
|
.SetBase58Bytes(Base58Type.PUBKEY_ADDRESS, [p.AddrP2pkh])
|
||||||
.SetBase58Bytes(Base58Type.SCRIPT_ADDRESS, [p.AddrP2sh])
|
.SetBase58Bytes(Base58Type.SCRIPT_ADDRESS, [p.AddrP2sh])
|
||||||
.SetBase58Bytes(Base58Type.SECRET_KEY, [p.WifPrefix])
|
.SetBase58Bytes(Base58Type.SECRET_KEY, [p.WifPrefix])
|
||||||
// NBitcoin gestisce una sola coppia di header BIP32 per rete: quella
|
// NBitcoin handles only one BIP32 header pair per network: the standard
|
||||||
// standard xprv/xpub. Le varianti SLIP-132 (y/z/Y/Z) si serializzano
|
// xprv/xpub. SLIP-132 variants (y/z/Y/Z) are serialised manually via
|
||||||
// a mano con ChainProfile.ExtKeyHeaders quando servono.
|
// ChainProfile.ExtKeyHeaders when needed.
|
||||||
.SetBase58Bytes(Base58Type.EXT_SECRET_KEY, legacy.PrivateBytes())
|
.SetBase58Bytes(Base58Type.EXT_SECRET_KEY, legacy.PrivateBytes())
|
||||||
.SetBase58Bytes(Base58Type.EXT_PUBLIC_KEY, legacy.PublicBytes())
|
.SetBase58Bytes(Base58Type.EXT_PUBLIC_KEY, legacy.PublicBytes())
|
||||||
.SetBech32(Bech32Type.WITNESS_PUBKEY_ADDRESS, Encoders.Bech32(p.SegwitHrp))
|
.SetBech32(Bech32Type.WITNESS_PUBKEY_ADDRESS, Encoders.Bech32(p.SegwitHrp))
|
||||||
@@ -102,8 +102,8 @@ public static class PalladiumNetworks
|
|||||||
.SetUriScheme(p.UriScheme)
|
.SetUriScheme(p.UriScheme)
|
||||||
.SetConsensus(new Consensus
|
.SetConsensus(new Consensus
|
||||||
{
|
{
|
||||||
// Valori usati solo dalle API NBitcoin che li richiedono: la validazione
|
// Values used only by NBitcoin APIs that require them: real header
|
||||||
// header reale è custom (Core/Spv) con skip PoW + checkpoint (§7).
|
// validation is custom (Core/Spv) with PoW skip + checkpoints (§7).
|
||||||
PowLimit = new Target(new uint256("00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff")),
|
PowLimit = new Target(new uint256("00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff")),
|
||||||
PowTargetSpacing = TimeSpan.FromSeconds(p.BlockTimeSeconds),
|
PowTargetSpacing = TimeSpan.FromSeconds(p.BlockTimeSeconds),
|
||||||
PowTargetTimespan = TimeSpan.FromDays(14),
|
PowTargetTimespan = TimeSpan.FromDays(14),
|
||||||
|
|||||||
+21
-21
@@ -3,8 +3,8 @@ using NBitcoin;
|
|||||||
namespace PalladiumWallet.Core.Crypto;
|
namespace PalladiumWallet.Core.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lingue wordlist BIP39 supportate (blueprint §4.1). Le liste sono incorporate
|
/// Supported BIP39 wordlist languages (blueprint §4.1). The lists are embedded
|
||||||
/// in NBitcoin: nessun accesso a rete o filesystem.
|
/// in NBitcoin: no network or filesystem access required.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum MnemonicLanguage
|
public enum MnemonicLanguage
|
||||||
{
|
{
|
||||||
@@ -18,7 +18,7 @@ public enum MnemonicLanguage
|
|||||||
Czech,
|
Czech,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Numero di parole supportato in creazione (blueprint §4.1: 12 o 24).</summary>
|
/// <summary>Supported word counts for mnemonic creation (blueprint §4.1: 12 or 24).</summary>
|
||||||
public enum MnemonicLength
|
public enum MnemonicLength
|
||||||
{
|
{
|
||||||
Twelve = 12,
|
Twelve = 12,
|
||||||
@@ -26,16 +26,16 @@ public enum MnemonicLength
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Facciata BIP39 su NBitcoin.Mnemonic (blueprint §4.1). NBitcoin copre già
|
/// BIP39 facade over NBitcoin.Mnemonic (blueprint §4.1). NBitcoin already handles
|
||||||
/// entropia→parole, checksum, normalizzazione NFKD e PBKDF2-HMAC-SHA512 a 2048
|
/// entropy→words, checksum, NFKD normalisation, and PBKDF2-HMAC-SHA512 with 2048
|
||||||
/// round con salt "mnemonic"+passphrase: qui si restringe l'API ai casi del
|
/// rounds using salt "mnemonic"+passphrase. This class narrows the API to the
|
||||||
/// blueprint e si centralizza la mappa delle lingue. Quando arriverà il seed
|
/// blueprint use-cases and centralises the language map. When the native versioned
|
||||||
/// nativo versionato (§4.1 punto 1), il riconoscimento multi-schema sarà una
|
/// seed arrives (§4.1 point 1), multi-scheme recognition will be a chain of
|
||||||
/// catena di TryParse per schema.
|
/// TryParse calls per scheme.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Bip39
|
public static class Bip39
|
||||||
{
|
{
|
||||||
/// <summary>Genera una nuova mnemonica con entropia dal CSPRNG di sistema.</summary>
|
/// <summary>Generates a new mnemonic using entropy from the system CSPRNG.</summary>
|
||||||
public static Mnemonic Generate(MnemonicLength length, MnemonicLanguage language = MnemonicLanguage.English)
|
public static Mnemonic Generate(MnemonicLength length, MnemonicLanguage language = MnemonicLanguage.English)
|
||||||
{
|
{
|
||||||
var wordCount = length == MnemonicLength.Twelve ? WordCount.Twelve : WordCount.TwentyFour;
|
var wordCount = length == MnemonicLength.Twelve ? WordCount.Twelve : WordCount.TwentyFour;
|
||||||
@@ -43,10 +43,10 @@ public static class Bip39
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Riconosce e valida una mnemonica BIP39: numero di parole, appartenenza alla
|
/// Recognises and validates a BIP39 mnemonic: word count, wordlist membership,
|
||||||
/// wordlist e checksum. Se <paramref name="language"/> è indicata viene provata
|
/// and checksum. If <paramref name="language"/> is specified it is tried first;
|
||||||
/// per prima; altrimenti la lingua è auto-rilevata (parole condivise tra liste
|
/// otherwise the language is auto-detected (words shared between lists can make
|
||||||
/// possono rendere ambiguo l'autodetect: in import l'utente può forzarla).
|
/// autodetect ambiguous — the user can override it on import).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool TryParse(string text, out Mnemonic? mnemonic, MnemonicLanguage? language = null)
|
public static bool TryParse(string text, out Mnemonic? mnemonic, MnemonicLanguage? language = null)
|
||||||
{
|
{
|
||||||
@@ -54,8 +54,8 @@ public static class Bip39
|
|||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Solo trim esterno: la normalizzazione NFKD e lo spazio ideografico
|
// Outer trim only: NBitcoin handles NFKD normalisation and Japanese
|
||||||
// giapponese li gestisce NBitcoin, il testo non va alterato (§4.1).
|
// ideographic spaces — the text must not be altered further (§4.1).
|
||||||
text = text.Trim();
|
text = text.Trim();
|
||||||
|
|
||||||
IEnumerable<Wordlist> candidates = language is not null
|
IEnumerable<Wordlist> candidates = language is not null
|
||||||
@@ -67,7 +67,7 @@ public static class Bip39
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var parsed = new Mnemonic(text, wordlist);
|
var parsed = new Mnemonic(text, wordlist);
|
||||||
// Il costruttore NON verifica il checksum: controllo esplicito obbligatorio.
|
// The constructor does NOT verify the checksum — explicit check is mandatory.
|
||||||
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
|
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
|
||||||
{
|
{
|
||||||
mnemonic = parsed;
|
mnemonic = parsed;
|
||||||
@@ -76,7 +76,7 @@ public static class Bip39
|
|||||||
}
|
}
|
||||||
catch (FormatException)
|
catch (FormatException)
|
||||||
{
|
{
|
||||||
// Parole fuori wordlist o conteggio non valido: si prova oltre.
|
// Words outside the wordlist or invalid count — try the next candidate.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +84,9 @@ public static class Bip39
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mnemonica → seed di 64 byte (PBKDF2-HMAC-SHA512, 2048 round, salt
|
/// Mnemonic → 64-byte seed (PBKDF2-HMAC-SHA512, 2048 rounds, salt
|
||||||
/// "mnemonic"+passphrase). La passphrase cambia completamente il wallet
|
/// "mnemonic"+passphrase). The passphrase completely changes the derived
|
||||||
/// derivato: avvisi UI obbligatori (§4.1).
|
/// wallet — mandatory UI warnings required (§4.1).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static byte[] ToSeed(Mnemonic mnemonic, string? passphrase = null) =>
|
public static byte[] ToSeed(Mnemonic mnemonic, string? passphrase = null) =>
|
||||||
mnemonic.DeriveSeed(passphrase);
|
mnemonic.DeriveSeed(passphrase);
|
||||||
|
|||||||
@@ -4,25 +4,25 @@ using PalladiumWallet.Core.Chain;
|
|||||||
namespace PalladiumWallet.Core.Crypto;
|
namespace PalladiumWallet.Core.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Costruzione dei derivation path BIP44/49/84 (blueprint §4.2) e mappatura
|
/// BIP44/49/84 derivation path construction (blueprint §4.2) and mapping of
|
||||||
/// ScriptKind → purpose / ScriptPubKeyType. I purpose sono costanti di protocollo
|
/// ScriptKind → purpose / ScriptPubKeyType. Purposes are BIP protocol constants
|
||||||
/// BIP (chain-independent); il coin_type viene sempre dal ChainProfile.
|
/// (chain-independent); coin_type always comes from the ChainProfile.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DerivationPaths
|
public static class DerivationPaths
|
||||||
{
|
{
|
||||||
/// <summary>Purpose BIP44 (P2PKH legacy).</summary>
|
/// <summary>BIP44 purpose (P2PKH legacy).</summary>
|
||||||
public const int PurposeLegacy = 44;
|
public const int PurposeLegacy = 44;
|
||||||
|
|
||||||
/// <summary>Purpose BIP49 (P2SH-P2WPKH).</summary>
|
/// <summary>BIP49 purpose (P2SH-P2WPKH).</summary>
|
||||||
public const int PurposeWrappedSegwit = 49;
|
public const int PurposeWrappedSegwit = 49;
|
||||||
|
|
||||||
/// <summary>Purpose BIP84 (P2WPKH nativo).</summary>
|
/// <summary>BIP84 purpose (native P2WPKH).</summary>
|
||||||
public const int PurposeNativeSegwit = 84;
|
public const int PurposeNativeSegwit = 84;
|
||||||
|
|
||||||
/// <summary>Purpose BIP48 (multisig — fase successiva, §16 passo 8).</summary>
|
/// <summary>BIP48 purpose (multisig — next phase, §16 step 8).</summary>
|
||||||
public const int PurposeMultisig = 48;
|
public const int PurposeMultisig = 48;
|
||||||
|
|
||||||
/// <summary>Purpose BIP86 (P2TR key-path, Taproot).</summary>
|
/// <summary>BIP86 purpose (P2TR key-path, Taproot).</summary>
|
||||||
public const int PurposeTaproot = 86;
|
public const int PurposeTaproot = 86;
|
||||||
|
|
||||||
public static int PurposeFor(ScriptKind kind) => kind switch
|
public static int PurposeFor(ScriptKind kind) => kind switch
|
||||||
@@ -36,9 +36,9 @@ public static class DerivationPaths
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tipo di scriptPubKey NBitcoin per la derivazione da pubkey singola.
|
/// NBitcoin scriptPubKey type for single-pubkey derivation.
|
||||||
/// I tipi multisig derivano da redeem script, non da una pubkey: arriveranno
|
/// Multisig types are derived from a redeem script, not a pubkey — they will
|
||||||
/// con i wallet M-di-N (§4.5).
|
/// arrive with M-of-N wallet support (§4.5).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ScriptPubKeyType ScriptPubKeyTypeFor(ScriptKind kind) => kind switch
|
public static ScriptPubKeyType ScriptPubKeyTypeFor(ScriptKind kind) => kind switch
|
||||||
{
|
{
|
||||||
@@ -48,13 +48,13 @@ public static class DerivationPaths
|
|||||||
ScriptKind.Taproot => ScriptPubKeyType.TaprootBIP86,
|
ScriptKind.Taproot => ScriptPubKeyType.TaprootBIP86,
|
||||||
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig =>
|
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig =>
|
||||||
throw new NotSupportedException(
|
throw new NotSupportedException(
|
||||||
"I tipi multisig derivano da redeem script: supporto previsto con i wallet M-di-N (§4.5)."),
|
"Multisig types are derived from a redeem script: support planned with M-of-N wallets (§4.5)."),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Path di account relativo alla root: purpose'/coin'/account' (§4.2).
|
/// Account path relative to the root: purpose'/coin'/account' (§4.2).
|
||||||
/// Il coin_type è quello del profilo (746 mainnet, 1 testnet).
|
/// coin_type is taken from the profile (746 mainnet, 1 testnet).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static KeyPath AccountPath(ScriptKind kind, ChainProfile profile, int account = 0)
|
public static KeyPath AccountPath(ScriptKind kind, ChainProfile profile, int account = 0)
|
||||||
{
|
{
|
||||||
@@ -64,7 +64,7 @@ public static class DerivationPaths
|
|||||||
$"{PurposeFor(kind)}'/{profile.Bip44CoinType}'/{account}'");
|
$"{PurposeFor(kind)}'/{profile.Bip44CoinType}'/{account}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Sottopath non-hardened change/index sotto l'account (change=0 receiving, change=1 change).</summary>
|
/// <summary>Non-hardened change/index sub-path under the account (change=0 receiving, change=1 change).</summary>
|
||||||
public static KeyPath AddressSubPath(bool isChange, int index)
|
public static KeyPath AddressSubPath(bool isChange, int index)
|
||||||
{
|
{
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
@@ -73,8 +73,8 @@ public static class DerivationPaths
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parsing di derivation path personalizzati in import (Sparrow-like, §4.2):
|
/// Parsing of custom derivation paths on import (Sparrow-like, §4.2):
|
||||||
/// accetta il prefisso "m/" opzionale e gli hardened marker ' oppure h.
|
/// accepts the optional "m/" prefix and hardened markers ' or h.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool TryParse(string path, out KeyPath? keyPath)
|
public static bool TryParse(string path, out KeyPath? keyPath)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ using PalladiumWallet.Core.Chain;
|
|||||||
namespace PalladiumWallet.Core.Crypto;
|
namespace PalladiumWallet.Core.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Account HD single-sig (blueprint §4.2/§4.4): chiave estesa di account (xprv,
|
/// Single-sig HD account (blueprint §4.2/§4.4): extended account key (xprv,
|
||||||
/// oppure solo xpub = watch-only) + tipo di script + profilo di rete. Deriva
|
/// or xpub-only = watch-only) + script type + network profile. Derives receiving
|
||||||
/// indirizzi receiving (change=0) e change (change=1) on-demand per indice.
|
/// (change=0) and change (change=1) addresses on-demand by index.
|
||||||
/// Gli indirizzi si derivano sempre dall'xpub di account (sottopath non-hardened),
|
/// Addresses are always derived from the account xpub (non-hardened sub-path),
|
||||||
/// quindi il watch-only funziona per costruzione. Il keystore completo
|
/// so watch-only works by construction. The full keystore
|
||||||
/// (cifratura, factory dal file wallet, §4.5) arriva con la persistenza (§8).
|
/// (encryption, factory from wallet file, §4.5) arrives with persistence (§8).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HdAccount : IWalletAccount
|
public sealed class HdAccount : IWalletAccount
|
||||||
{
|
{
|
||||||
@@ -18,25 +18,25 @@ public sealed class HdAccount : IWalletAccount
|
|||||||
public ScriptKind Kind { get; }
|
public ScriptKind Kind { get; }
|
||||||
public ChainProfile Profile { get; }
|
public ChainProfile Profile { get; }
|
||||||
|
|
||||||
/// <summary>Path dell'account relativo alla root (es. 84'/746'/0', o personalizzato).</summary>
|
/// <summary>Account path relative to the root (e.g. 84'/746'/0', or custom).</summary>
|
||||||
public KeyPath AccountPath { get; }
|
public KeyPath AccountPath { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fingerprint della chiave master: con <see cref="AccountPath"/> forma le
|
/// Master key fingerprint: together with <see cref="AccountPath"/> forms the
|
||||||
/// origin info richieste dalle PSBT (§6.5). Zero se ignota (xpub importata
|
/// origin info required by PSBTs (§6.5). Zero if unknown (imported xpub
|
||||||
/// senza metadati).
|
/// without metadata).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public HDFingerprint MasterFingerprint { get; }
|
public HDFingerprint MasterFingerprint { get; }
|
||||||
|
|
||||||
public ExtPubKey AccountXpub { get; }
|
public ExtPubKey AccountXpub { get; }
|
||||||
|
|
||||||
/// <summary>True se l'account conosce solo la xpub: costruisce ma non firma (§4.5).</summary>
|
/// <summary>True if the account only knows the xpub: can build but not sign (§4.5).</summary>
|
||||||
public bool IsWatchOnly => _accountXprv is null;
|
public bool IsWatchOnly => _accountXprv is null;
|
||||||
|
|
||||||
private HdAccount(ExtKey? accountXprv, ExtPubKey accountXpub, ScriptKind kind,
|
private HdAccount(ExtKey? accountXprv, ExtPubKey accountXpub, ScriptKind kind,
|
||||||
ChainProfile profile, KeyPath accountPath, HDFingerprint masterFingerprint)
|
ChainProfile profile, KeyPath accountPath, HDFingerprint masterFingerprint)
|
||||||
{
|
{
|
||||||
// Valida subito la mappatura (i tipi multisig non sono supportati qui).
|
// Validate the mapping immediately (multisig types are not supported here).
|
||||||
DerivationPaths.ScriptPubKeyTypeFor(kind);
|
DerivationPaths.ScriptPubKeyTypeFor(kind);
|
||||||
_accountXprv = accountXprv;
|
_accountXprv = accountXprv;
|
||||||
AccountXpub = accountXpub;
|
AccountXpub = accountXpub;
|
||||||
@@ -46,7 +46,7 @@ public sealed class HdAccount : IWalletAccount
|
|||||||
MasterFingerprint = masterFingerprint;
|
MasterFingerprint = masterFingerprint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Caso principale: mnemonica BIP39 (+ passphrase opzionale) → account standard.</summary>
|
/// <summary>Main case: BIP39 mnemonic (+ optional passphrase) → standard account.</summary>
|
||||||
public static HdAccount FromMnemonic(Mnemonic mnemonic, string? passphrase,
|
public static HdAccount FromMnemonic(Mnemonic mnemonic, string? passphrase,
|
||||||
ScriptKind kind, ChainProfile profile, int account = 0) =>
|
ScriptKind kind, ChainProfile profile, int account = 0) =>
|
||||||
FromSeed(Bip39.ToSeed(mnemonic, passphrase), kind, profile, account);
|
FromSeed(Bip39.ToSeed(mnemonic, passphrase), kind, profile, account);
|
||||||
@@ -55,22 +55,22 @@ public sealed class HdAccount : IWalletAccount
|
|||||||
FromSeed(seed, kind, profile, DerivationPaths.AccountPath(kind, profile, account));
|
FromSeed(seed, kind, profile, DerivationPaths.AccountPath(kind, profile, account));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Import con derivation path personalizzato (§4.2, Sparrow-like):
|
/// Import with a custom derivation path (§4.2, Sparrow-like):
|
||||||
/// <paramref name="kind"/> determina solo il tipo di indirizzo generato.
|
/// <paramref name="kind"/> only determines the generated address type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static HdAccount FromSeed(byte[] seed, ScriptKind kind, ChainProfile profile, KeyPath accountPath)
|
public static HdAccount FromSeed(byte[] seed, ScriptKind kind, ChainProfile profile, KeyPath accountPath)
|
||||||
{
|
{
|
||||||
var root = ExtKey.CreateFromSeed(seed);
|
var root = ExtKey.CreateFromSeed(seed);
|
||||||
// L'account si deriva sempre dalla xprv: i livelli hardened del path
|
// The account is always derived from the xprv: hardened path levels
|
||||||
// non sono derivabili da una xpub.
|
// cannot be derived from an xpub alone.
|
||||||
var accountXprv = root.Derive(accountPath);
|
var accountXprv = root.Derive(accountPath);
|
||||||
return new HdAccount(accountXprv, accountXprv.Neuter(), kind, profile,
|
return new HdAccount(accountXprv, accountXprv.Neuter(), kind, profile,
|
||||||
accountPath, root.Neuter().PubKey.GetHDFingerPrint());
|
accountPath, root.Neuter().PubKey.GetHDFingerPrint());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Watch-only da xpub di account (§4.4): fingerprint e path sono noti solo
|
/// Watch-only from account xpub (§4.4): fingerprint and path are known only
|
||||||
/// se forniti da chi importa (servono per le origin info PSBT).
|
/// if supplied by the importer (required for PSBT origin info).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static HdAccount FromAccountXpub(ExtPubKey accountXpub, ScriptKind kind,
|
public static HdAccount FromAccountXpub(ExtPubKey accountXpub, ScriptKind kind,
|
||||||
ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) =>
|
ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) =>
|
||||||
@@ -78,7 +78,7 @@ public sealed class HdAccount : IWalletAccount
|
|||||||
accountPath ?? DerivationPaths.AccountPath(kind, profile),
|
accountPath ?? DerivationPaths.AccountPath(kind, profile),
|
||||||
masterFingerprint ?? default);
|
masterFingerprint ?? default);
|
||||||
|
|
||||||
/// <summary>Import spendibile da xprv di account.</summary>
|
/// <summary>Spendable import from account xprv.</summary>
|
||||||
public static HdAccount FromAccountXprv(ExtKey accountXprv, ScriptKind kind,
|
public static HdAccount FromAccountXprv(ExtKey accountXprv, ScriptKind kind,
|
||||||
ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) =>
|
ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) =>
|
||||||
new(accountXprv, accountXprv.Neuter(), kind, profile,
|
new(accountXprv, accountXprv.Neuter(), kind, profile,
|
||||||
@@ -97,27 +97,27 @@ public sealed class HdAccount : IWalletAccount
|
|||||||
public PubKey? GetPublicKey(bool isChange, int index) =>
|
public PubKey? GetPublicKey(bool isChange, int index) =>
|
||||||
AccountXpub.Derive(DerivationPaths.AddressSubPath(isChange, index)).PubKey;
|
AccountXpub.Derive(DerivationPaths.AddressSubPath(isChange, index)).PubKey;
|
||||||
|
|
||||||
/// <summary>Chiave privata di un indirizzo; null se watch-only (§17).</summary>
|
/// <summary>Private key for an address; null if watch-only (§17).</summary>
|
||||||
public Key? GetPrivateKey(bool isChange, int index) =>
|
public Key? GetPrivateKey(bool isChange, int index) =>
|
||||||
IsWatchOnly ? null : GetExtPrivateKey(isChange, index).PrivateKey;
|
IsWatchOnly ? null : GetExtPrivateKey(isChange, index).PrivateKey;
|
||||||
|
|
||||||
/// <summary>Gli account HD usano il gap limit: nessuna lista fissa di indirizzi.</summary>
|
/// <summary>HD accounts use the gap limit: no fixed address list.</summary>
|
||||||
public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses => null;
|
public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses => null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Chiave privata estesa di un indirizzo. Lancia se watch-only: nessuna
|
/// Extended private key for an address. Throws if watch-only: no private key
|
||||||
/// chiave privata è derivabile dalle sole pubbliche (§17).
|
/// can be derived from public keys alone (§17).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ExtKey GetExtPrivateKey(bool isChange, int index) =>
|
public ExtKey GetExtPrivateKey(bool isChange, int index) =>
|
||||||
(_accountXprv ?? throw new InvalidOperationException("Account watch-only: non può firmare."))
|
(_accountXprv ?? throw new InvalidOperationException("Watch-only account: cannot sign."))
|
||||||
.Derive(DerivationPaths.AddressSubPath(isChange, index));
|
.Derive(DerivationPaths.AddressSubPath(isChange, index));
|
||||||
|
|
||||||
/// <summary>Xpub di account in formato SLIP-132 (xpub/ypub/zpub secondo Kind).</summary>
|
/// <summary>Account xpub in SLIP-132 format (xpub/ypub/zpub according to Kind).</summary>
|
||||||
public string ToSlip132() => Slip132.Encode(AccountXpub, Kind, Profile);
|
public string ToSlip132() => Slip132.Encode(AccountXpub, Kind, Profile);
|
||||||
|
|
||||||
/// <summary>Xprv di account in formato SLIP-132. Lancia se watch-only.</summary>
|
/// <summary>Account xprv in SLIP-132 format. Throws if watch-only.</summary>
|
||||||
public string ToSlip132Private() =>
|
public string ToSlip132Private() =>
|
||||||
Slip132.Encode(
|
Slip132.Encode(
|
||||||
_accountXprv ?? throw new InvalidOperationException("Account watch-only: nessuna chiave privata."),
|
_accountXprv ?? throw new InvalidOperationException("Watch-only account: no private key."),
|
||||||
Kind, Profile);
|
Kind, Profile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,31 +4,31 @@ using PalladiumWallet.Core.Chain;
|
|||||||
namespace PalladiumWallet.Core.Crypto;
|
namespace PalladiumWallet.Core.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Astrazione su tutti i tipi di account wallet (HD da seed, HD da xpub/xprv importata,
|
/// Abstraction over all wallet account types (HD from seed, HD from imported xpub/xprv,
|
||||||
/// chiavi WIF importate). Consente a WalletSynchronizer e TransactionFactory di operare
|
/// imported WIF keys). Allows WalletSynchronizer and TransactionFactory to operate
|
||||||
/// indipendentemente dal tipo di keystore sottostante (blueprint §4.4–§4.5).
|
/// independently of the underlying keystore type (blueprint §4.4–§4.5).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IWalletAccount
|
public interface IWalletAccount
|
||||||
{
|
{
|
||||||
ScriptKind Kind { get; }
|
ScriptKind Kind { get; }
|
||||||
ChainProfile Profile { get; }
|
ChainProfile Profile { get; }
|
||||||
|
|
||||||
/// <summary>True se l'account non può firmare (assenza di chiavi private).</summary>
|
/// <summary>True if the account cannot sign (no private keys present).</summary>
|
||||||
bool IsWatchOnly { get; }
|
bool IsWatchOnly { get; }
|
||||||
|
|
||||||
BitcoinAddress GetAddress(bool isChange, int index);
|
BitcoinAddress GetAddress(bool isChange, int index);
|
||||||
BitcoinAddress GetReceiveAddress(int index);
|
BitcoinAddress GetReceiveAddress(int index);
|
||||||
BitcoinAddress GetChangeAddress(int index);
|
BitcoinAddress GetChangeAddress(int index);
|
||||||
|
|
||||||
/// <summary>Null se la chiave pubblica non è ricavabile dall'account (indirizzi puri watch-only).</summary>
|
/// <summary>Null if the public key cannot be derived from the account (pure watch-only addresses).</summary>
|
||||||
PubKey? GetPublicKey(bool isChange, int index);
|
PubKey? GetPublicKey(bool isChange, int index);
|
||||||
|
|
||||||
/// <summary>Null se l'account è watch-only o l'indice è fuori range.</summary>
|
/// <summary>Null if the account is watch-only or the index is out of range.</summary>
|
||||||
Key? GetPrivateKey(bool isChange, int index);
|
Key? GetPrivateKey(bool isChange, int index);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per gli account con indirizzi fissi (WIF importati) restituisce la lista
|
/// For accounts with fixed addresses (imported WIF keys) returns the full list
|
||||||
/// completa da scansionare; null per gli account HD che usano il gap limit.
|
/// to scan; null for HD accounts that use the gap limit.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses { get; }
|
IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ using PalladiumWallet.Core.Chain;
|
|||||||
namespace PalladiumWallet.Core.Crypto;
|
namespace PalladiumWallet.Core.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Account da chiavi WIF singole importate (blueprint §4.4 — "Imported"):
|
/// Account built from individually imported WIF keys (blueprint §4.4 — "Imported"):
|
||||||
/// lista fissa di indirizzi, nessuna derivazione HD, nessuna catena di change.
|
/// fixed list of addresses, no HD derivation, no change chain.
|
||||||
/// Il change va sempre al primo indirizzo importato.
|
/// Change always goes back to the first imported address.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ImportedKeyAccount : IWalletAccount
|
public sealed class ImportedKeyAccount : IWalletAccount
|
||||||
{
|
{
|
||||||
@@ -24,7 +24,7 @@ public sealed class ImportedKeyAccount : IWalletAccount
|
|||||||
ScriptKind kind, ChainProfile profile)
|
ScriptKind kind, ChainProfile profile)
|
||||||
{
|
{
|
||||||
if (entries.Count == 0)
|
if (entries.Count == 0)
|
||||||
throw new ArgumentException("Almeno un indirizzo richiesto.", nameof(entries));
|
throw new ArgumentException("At least one address is required.", nameof(entries));
|
||||||
_entries = [.. entries];
|
_entries = [.. entries];
|
||||||
Kind = kind;
|
Kind = kind;
|
||||||
Profile = profile;
|
Profile = profile;
|
||||||
@@ -39,7 +39,7 @@ public sealed class ImportedKeyAccount : IWalletAccount
|
|||||||
|
|
||||||
public BitcoinAddress GetReceiveAddress(int index) => GetAddress(false, index);
|
public BitcoinAddress GetReceiveAddress(int index) => GetAddress(false, index);
|
||||||
|
|
||||||
/// <summary>Il change torna sempre al primo indirizzo importato.</summary>
|
/// <summary>Change always returns to the first imported address.</summary>
|
||||||
public BitcoinAddress GetChangeAddress(int index) => _entries[0].Address;
|
public BitcoinAddress GetChangeAddress(int index) => _entries[0].Address;
|
||||||
|
|
||||||
public PubKey? GetPublicKey(bool isChange, int index)
|
public PubKey? GetPublicKey(bool isChange, int index)
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ public static class Slip132
|
|||||||
Encoders.Base58Check.EncodeData([.. profile.ExtKeyHeaders[kind].PrivateBytes(), .. key.ToBytes()]);
|
Encoders.Base58Check.EncodeData([.. profile.ExtKeyHeaders[kind].PrivateBytes(), .. key.ToBytes()]);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Riconosce l'header (→ ScriptKind) e decodifica una chiave pubblica estesa:
|
/// Recognises the header (→ ScriptKind) and decodes an extended public key:
|
||||||
/// è la via dell'import watch-only (§4.4).
|
/// this is the watch-only import path (§4.4).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool TryDecodePublic(string encoded, ChainProfile profile,
|
public static bool TryDecodePublic(string encoded, ChainProfile profile,
|
||||||
out ExtPubKey? key, out ScriptKind kind)
|
out ExtPubKey? key, out ScriptKind kind)
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ using System.Text.Json;
|
|||||||
namespace PalladiumWallet.Core.Net;
|
namespace PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pinning TLS "trust on first use" (blueprint §9): al primo contatto la
|
/// TLS "trust on first use" pinning (blueprint §9): on the first contact the
|
||||||
/// fingerprint SHA-256 del certificato del server viene salvata; ai successivi
|
/// server certificate's SHA-256 fingerprint is saved; on subsequent ones it is
|
||||||
/// viene confrontata. Se cambia, la connessione va rifiutata e l'utente può
|
/// compared. If it changes, the connection must be rejected and the user can
|
||||||
/// sbloccare con un reset esplicito (caso tipico: server self-signed rinnovato).
|
/// unlock with an explicit reset (typical case: renewed self-signed server).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CertificatePinStore(string filePath)
|
public sealed class CertificatePinStore(string filePath)
|
||||||
{
|
{
|
||||||
@@ -18,8 +18,8 @@ public sealed class CertificatePinStore(string filePath)
|
|||||||
Convert.ToHexString(SHA256.HashData(certificate.GetRawCertData())).ToLowerInvariant();
|
Convert.ToHexString(SHA256.HashData(certificate.GetRawCertData())).ToLowerInvariant();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifica TOFU: true se il certificato è quello già visto (o se è il primo
|
/// TOFU check: true if the certificate is the one already seen (or if it is the
|
||||||
/// contatto, nel qual caso viene salvato). False = certificato cambiato.
|
/// first contact, in which case it is saved). False = certificate changed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool VerifyOrPin(string host, int port, X509Certificate certificate)
|
public bool VerifyOrPin(string host, int port, X509Certificate certificate)
|
||||||
{
|
{
|
||||||
@@ -36,7 +36,7 @@ public sealed class CertificatePinStore(string filePath)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reset del certificato salvato per un server ("reset certificati SSL", §9).</summary>
|
/// <summary>Reset the saved certificate for a server ("reset SSL certificates", §9).</summary>
|
||||||
public bool Reset(string host, int port)
|
public bool Reset(string host, int port)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
|
|||||||
+13
-13
@@ -2,24 +2,24 @@ using System.Text.Json;
|
|||||||
|
|
||||||
namespace PalladiumWallet.Core.Net;
|
namespace PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
/// <summary>Voce di storico di uno scripthash (blockchain.scripthash.get_history).</summary>
|
/// <summary>History entry for a scripthash (blockchain.scripthash.get_history).</summary>
|
||||||
public readonly record struct HistoryItem(string TxHash, int Height, long FeeSats);
|
public readonly record struct HistoryItem(string TxHash, int Height, long FeeSats);
|
||||||
|
|
||||||
/// <summary>UTXO riportato dal server (blockchain.scripthash.listunspent).</summary>
|
/// <summary>UTXO reported by the server (blockchain.scripthash.listunspent).</summary>
|
||||||
public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSats, int Height);
|
public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSats, int Height);
|
||||||
|
|
||||||
/// <summary>Prova di Merkle (blockchain.transaction.get_merkle).</summary>
|
/// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary>
|
||||||
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
|
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
|
||||||
|
|
||||||
/// <summary>Tip della catena notificato da blockchain.headers.subscribe.</summary>
|
/// <summary>Chain tip notified by 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>
|
/// <summary>Peer announced by server.peers.subscribe (null ports = not offered).</summary>
|
||||||
public sealed record PeerInfo(string Host, int? TcpPort, int? SslPort, string? Version);
|
public sealed record PeerInfo(string Host, int? TcpPort, int? SslPort, string? Version);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il
|
/// Typed helpers over the protocol methods (blueprint §10), on top of the
|
||||||
/// trasporto JSON-RPC generico di <see cref="ElectrumClient"/>.
|
/// generic JSON-RPC transport of <see cref="ElectrumClient"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ElectrumApi
|
public static class ElectrumApi
|
||||||
{
|
{
|
||||||
@@ -29,7 +29,7 @@ public static class ElectrumApi
|
|||||||
return new ChainTip(r.GetProperty("height").GetInt32(), r.GetProperty("hex").GetString()!);
|
return new ChainTip(r.GetProperty("height").GetInt32(), r.GetProperty("hex").GetString()!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Status corrente dello scripthash (null = mai usato).</summary>
|
/// <summary>Current status of the scripthash (null = never used).</summary>
|
||||||
public static async Task<string?> SubscribeScripthashAsync(this ElectrumClient c, string scripthash,
|
public static async Task<string?> SubscribeScripthashAsync(this ElectrumClient c, string scripthash,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -89,7 +89,7 @@ public static class ElectrumApi
|
|||||||
return r.GetString()!;
|
return r.GetString()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Fee stimata in coin/kB per il target dato; -1 se il server non sa stimare.</summary>
|
/// <summary>Estimated fee in coin/kB for the given target; -1 if the server cannot estimate.</summary>
|
||||||
public static async Task<decimal> EstimateFeeAsync(this ElectrumClient c, int targetBlocks,
|
public static async Task<decimal> EstimateFeeAsync(this ElectrumClient c, int targetBlocks,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -112,7 +112,7 @@ 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>
|
/// <summary>Peers announced by the server (discovery of other servers, §9).</summary>
|
||||||
public static async Task<IReadOnlyList<PeerInfo>> GetPeersAsync(this ElectrumClient c,
|
public static async Task<IReadOnlyList<PeerInfo>> GetPeersAsync(this ElectrumClient c,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -121,9 +121,9 @@ public static class ElectrumApi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parsa la risposta di server.peers.subscribe: lista di
|
/// Parses the server.peers.subscribe response: a list of
|
||||||
/// [ip, hostname, ["v1.4.2", "pN", "tPORTA", "sPORTA", ...]];
|
/// [ip, hostname, ["v1.4.2", "pN", "tPORT", "sPORT", ...]];
|
||||||
/// "t"/"s" senza numero = porta di default della rete (risolta dal chiamante).
|
/// "t"/"s" without a number = network default port (resolved by the caller).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
|
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
|
||||||
{
|
{
|
||||||
|
|||||||
+150
-58
@@ -1,15 +1,17 @@
|
|||||||
|
using System.Buffers;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.IO.Pipelines;
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
namespace PalladiumWallet.Core.Net;
|
namespace PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Client del protocollo del server di indicizzazione (blueprint §10):
|
/// Client for the indexing server protocol (blueprint §10):
|
||||||
/// JSON-RPC 2.0 newline-delimited su TCP, opzionalmente TLS con pinning TOFU.
|
/// newline-delimited JSON-RPC 2.0 over TCP, optionally TLS with TOFU pinning.
|
||||||
/// Le notifiche (subscription) arrivano sull'evento <see cref="NotificationReceived"/>.
|
/// Notifications (subscriptions) arrive on the <see cref="NotificationReceived"/> event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ElectrumClient : IAsyncDisposable
|
public sealed class ElectrumClient : IAsyncDisposable
|
||||||
{
|
{
|
||||||
@@ -18,10 +20,25 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
|
|
||||||
private readonly TcpClient _tcp;
|
private readonly TcpClient _tcp;
|
||||||
private readonly Stream _stream;
|
private readonly Stream _stream;
|
||||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
|
||||||
private readonly ConcurrentDictionary<long, TaskCompletionSource<JsonElement>> _pending = new();
|
private readonly ConcurrentDictionary<long, TaskCompletionSource<JsonElement>> _pending = new();
|
||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
private readonly Task _readLoop;
|
private readonly Task _readLoop;
|
||||||
|
private readonly Task _writeLoop;
|
||||||
|
|
||||||
|
// Single-reader channel: tasks write the payloads without a lock;
|
||||||
|
// the write loop drains everything in a single WriteAsync+FlushAsync —
|
||||||
|
// identical to Electrum's asyncio buffered writer.
|
||||||
|
private readonly Channel<byte[]> _outgoing = Channel.CreateUnbounded<byte[]>(
|
||||||
|
new UnboundedChannelOptions { SingleReader = true, AllowSynchronousContinuations = false });
|
||||||
|
|
||||||
|
// Cap on in-flight requests (not in the write queue, but awaiting a
|
||||||
|
// response from the server). The write loop already batches the send into a
|
||||||
|
// single segment; this gate avoids flooding the server with thousands of
|
||||||
|
// simultaneous requests on large wallets → no bursts of -101/-102 nor
|
||||||
|
// connection drops. Writes still stay pipelined up to this degree.
|
||||||
|
private const int MaxInFlight = 32;
|
||||||
|
private readonly SemaphoreSlim _inFlight = new(MaxInFlight, MaxInFlight);
|
||||||
|
|
||||||
private long _nextId;
|
private long _nextId;
|
||||||
|
|
||||||
public string Host { get; }
|
public string Host { get; }
|
||||||
@@ -29,10 +46,7 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
public bool UseSsl { get; }
|
public bool UseSsl { get; }
|
||||||
public bool IsConnected => _tcp.Connected && !_cts.IsCancellationRequested;
|
public bool IsConnected => _tcp.Connected && !_cts.IsCancellationRequested;
|
||||||
|
|
||||||
/// <summary>(metodo, parametri) per le notifiche di subscription.</summary>
|
|
||||||
public event Action<string, JsonElement>? NotificationReceived;
|
public event Action<string, JsonElement>? NotificationReceived;
|
||||||
|
|
||||||
/// <summary>Scatta quando la connessione cade (errore di lettura o chiusura remota).</summary>
|
|
||||||
public event Action<Exception?>? Disconnected;
|
public event Action<Exception?>? Disconnected;
|
||||||
|
|
||||||
private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl)
|
private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl)
|
||||||
@@ -43,17 +57,13 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
Port = port;
|
Port = port;
|
||||||
UseSsl = useSsl;
|
UseSsl = useSsl;
|
||||||
_readLoop = Task.Run(ReadLoopAsync);
|
_readLoop = Task.Run(ReadLoopAsync);
|
||||||
|
_writeLoop = Task.Run(WriteLoopAsync);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Connette al server. Con <paramref name="useSsl"/> la validazione del
|
|
||||||
/// certificato è TOFU tramite <paramref name="pins"/> (§9): primo contatto
|
|
||||||
/// salva, contatti successivi confrontano; mismatch ⇒ <see cref="CertificatePinMismatchException"/>.
|
|
||||||
/// </summary>
|
|
||||||
public static async Task<ElectrumClient> ConnectAsync(string host, int port, bool useSsl,
|
public static async Task<ElectrumClient> ConnectAsync(string host, int port, bool useSsl,
|
||||||
CertificatePinStore? pins = null, CancellationToken ct = default)
|
CertificatePinStore? pins = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var tcp = new TcpClient();
|
var tcp = new TcpClient { NoDelay = true };
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await tcp.ConnectAsync(host, port, ct);
|
await tcp.ConnectAsync(host, port, ct);
|
||||||
@@ -64,10 +74,7 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
var ssl = new SslStream(stream, leaveInnerStreamOpen: false,
|
var ssl = new SslStream(stream, leaveInnerStreamOpen: false,
|
||||||
(_, cert, _, _) =>
|
(_, cert, _, _) =>
|
||||||
{
|
{
|
||||||
// I server Electrum sono tipicamente self-signed: la
|
if (cert is null) return false;
|
||||||
// fiducia è il pin TOFU, non la catena CA (§9).
|
|
||||||
if (cert is null)
|
|
||||||
return false;
|
|
||||||
pinOk = pins is null || pins.VerifyOrPin(host, port, cert);
|
pinOk = pins is null || pins.VerifyOrPin(host, port, cert);
|
||||||
return pinOk;
|
return pinOk;
|
||||||
});
|
});
|
||||||
@@ -84,7 +91,6 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var client = new ElectrumClient(tcp, stream, host, port, useSsl);
|
var client = new ElectrumClient(tcp, stream, host, port, useSsl);
|
||||||
// Negoziazione obbligatoria prima di ogni altra richiesta.
|
|
||||||
await client.RequestAsync("server.version", ct, ClientName, ProtocolVersion);
|
await client.RequestAsync("server.version", ct, ClientName, ProtocolVersion);
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
@@ -97,6 +103,11 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task<JsonElement> RequestAsync(string method, CancellationToken ct = default,
|
public async Task<JsonElement> RequestAsync(string method, CancellationToken ct = default,
|
||||||
params object?[] parameters)
|
params object?[] parameters)
|
||||||
|
{
|
||||||
|
// Gate before going in-flight: beyond MaxInFlight pending requests
|
||||||
|
// we wait for a response to free a slot, instead of flooding the server.
|
||||||
|
await _inFlight.WaitAsync(ct);
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var id = Interlocked.Increment(ref _nextId);
|
var id = Interlocked.Increment(ref _nextId);
|
||||||
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
@@ -110,45 +121,138 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
@params = parameters,
|
@params = parameters,
|
||||||
});
|
});
|
||||||
|
|
||||||
await _writeLock.WaitAsync(ct);
|
_outgoing.Writer.TryWrite(payload);
|
||||||
try
|
|
||||||
|
await using var registration = ct.Register(() =>
|
||||||
{
|
{
|
||||||
await _stream.WriteAsync(payload, ct);
|
_pending.TryRemove(id, out _);
|
||||||
await _stream.WriteAsync("\n"u8.ToArray(), ct);
|
tcs.TrySetCanceled(ct);
|
||||||
await _stream.FlushAsync(ct);
|
});
|
||||||
|
return await tcs.Task;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_writeLock.Release();
|
_inFlight.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var registration = ct.Register(() => tcs.TrySetCanceled(ct));
|
/// <summary>
|
||||||
return await tcs.Task;
|
/// Drain loop: empties the channel into a single buffer → one WriteAsync+FlushAsync
|
||||||
|
/// for all queued messages. When N requests are queued, they are
|
||||||
|
/// transmitted in a single TCP segment instead of N serial flushes.
|
||||||
|
/// </summary>
|
||||||
|
private async Task WriteLoopAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await _outgoing.Reader.WaitToReadAsync(_cts.Token))
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(512);
|
||||||
|
while (_outgoing.Reader.TryRead(out var data))
|
||||||
|
{
|
||||||
|
ms.Write(data);
|
||||||
|
ms.WriteByte((byte)'\n');
|
||||||
|
}
|
||||||
|
await _stream.WriteAsync(ms.GetBuffer().AsMemory(0, (int)ms.Length), _cts.Token);
|
||||||
|
await _stream.FlushAsync(_cts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
foreach (var (_, pending) in _pending)
|
||||||
|
pending.TrySetException(ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read loop with PipeReader: pooled buffers, zero per-response allocations
|
||||||
|
/// (no intermediate string), JSON parsing directly from a byte span.
|
||||||
|
/// </summary>
|
||||||
private async Task ReadLoopAsync()
|
private async Task ReadLoopAsync()
|
||||||
{
|
{
|
||||||
Exception? failure = null;
|
Exception? failure = null;
|
||||||
|
var pipe = PipeReader.Create(_stream, new StreamPipeReaderOptions(leaveOpen: true));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var reader = new StreamReader(_stream, Encoding.UTF8, leaveOpen: true);
|
while (true)
|
||||||
while (!_cts.IsCancellationRequested)
|
|
||||||
{
|
{
|
||||||
var line = await reader.ReadLineAsync(_cts.Token);
|
ReadResult result;
|
||||||
if (line is null)
|
try { result = await pipe.ReadAsync(_cts.Token); }
|
||||||
break; // chiusura remota
|
catch (OperationCanceledException) { break; }
|
||||||
if (string.IsNullOrWhiteSpace(line))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(line);
|
var buffer = result.Buffer;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (TrySliceLine(ref buffer, out var line))
|
||||||
|
if (!line.IsEmpty)
|
||||||
|
DispatchLine(line);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// consumed = everything we have consumed (up to the last \n)
|
||||||
|
// examined = everything we have looked at (up to the end of the buffer)
|
||||||
|
pipe.AdvanceTo(buffer.Start, buffer.End);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.IsCompleted) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failure = ex;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await pipe.CompleteAsync();
|
||||||
|
foreach (var (_, tcs) in _pending)
|
||||||
|
tcs.TrySetException(failure ?? new IOException("Connection to the server closed."));
|
||||||
|
_pending.Clear();
|
||||||
|
Disconnected?.Invoke(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TrySliceLine(ref ReadOnlySequence<byte> buffer,
|
||||||
|
out ReadOnlySequence<byte> line)
|
||||||
|
{
|
||||||
|
var pos = buffer.PositionOf((byte)'\n');
|
||||||
|
if (pos is null) { line = default; return false; }
|
||||||
|
line = buffer.Slice(0, pos.Value);
|
||||||
|
buffer = buffer.Slice(buffer.GetPosition(1, pos.Value));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DispatchLine(ReadOnlySequence<byte> line)
|
||||||
|
{
|
||||||
|
if (line.IsSingleSegment)
|
||||||
|
{
|
||||||
|
DispatchSpan(line.FirstSpan);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Multi-segment (very long response): copy to ArrayPool then parse.
|
||||||
|
var len = (int)line.Length;
|
||||||
|
var buf = ArrayPool<byte>.Shared.Rent(len);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
line.CopyTo(buf);
|
||||||
|
DispatchSpan(buf.AsSpan(0, len));
|
||||||
|
}
|
||||||
|
finally { ArrayPool<byte>.Shared.Return(buf); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DispatchSpan(ReadOnlySpan<byte> utf8)
|
||||||
|
{
|
||||||
|
// JsonDocument.Parse via Utf8JsonReader: no intermediate string,
|
||||||
|
// parsing directly from the pooled span.
|
||||||
|
var reader = new Utf8JsonReader(utf8);
|
||||||
|
using var doc = JsonDocument.ParseValue(ref reader);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
if (root.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number)
|
if (root.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number)
|
||||||
{
|
{
|
||||||
if (!_pending.TryRemove(idEl.GetInt64(), out var tcs))
|
if (!_pending.TryRemove(idEl.GetInt64(), out var tcs)) return;
|
||||||
continue;
|
if (root.TryGetProperty("error", out var err) && err.ValueKind != JsonValueKind.Null)
|
||||||
if (root.TryGetProperty("error", out var error) && error.ValueKind != JsonValueKind.Null)
|
tcs.TrySetException(new ElectrumServerException(err.ToString()));
|
||||||
tcs.TrySetException(new ElectrumServerException(error.ToString()));
|
|
||||||
else
|
else
|
||||||
tcs.TrySetResult(root.GetProperty("result").Clone());
|
tcs.TrySetResult(root.GetProperty("result").Clone());
|
||||||
}
|
}
|
||||||
@@ -159,37 +263,25 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
root.GetProperty("params").Clone());
|
root.GetProperty("params").Clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (OperationCanceledException) { }
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
failure = ex;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
foreach (var (_, tcs) in _pending)
|
|
||||||
tcs.TrySetException(failure ?? new IOException("Connessione al server chiusa."));
|
|
||||||
_pending.Clear();
|
|
||||||
Disconnected?.Invoke(failure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await _cts.CancelAsync();
|
await _cts.CancelAsync();
|
||||||
|
_outgoing.Writer.Complete();
|
||||||
_tcp.Close();
|
_tcp.Close();
|
||||||
try { await _readLoop; } catch { /* in chiusura */ }
|
try { await _readLoop; } catch { }
|
||||||
|
try { await _writeLoop; } catch { }
|
||||||
_cts.Dispose();
|
_cts.Dispose();
|
||||||
_writeLock.Dispose();
|
_inFlight.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Errore restituito dal server (campo "error" della risposta JSON-RPC).</summary>
|
/// <summary>Error returned by the server ("error" field of the JSON-RPC response).</summary>
|
||||||
public sealed class ElectrumServerException(string error) : Exception(error);
|
public sealed class ElectrumServerException(string error) : Exception(error);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Il certificato del server è cambiato rispetto a quello salvato (TOFU, §9).
|
/// The server certificate changed with respect to the saved one (TOFU, §9).
|
||||||
/// Si sblocca con il reset esplicito dei certificati.
|
/// It is unlocked with an explicit reset of the certificates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CertificatePinMismatchException(string host, int port) : Exception(
|
public sealed class CertificatePinMismatchException(string host, int port) : Exception(
|
||||||
$"Il certificato TLS di {host}:{port} è cambiato rispetto a quello salvato. " +
|
$"Il certificato TLS di {host}:{port} è cambiato rispetto a quello salvato. " +
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using PalladiumWallet.Core.Chain;
|
|||||||
|
|
||||||
namespace PalladiumWallet.Core.Net;
|
namespace PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
/// <summary>Server di indicizzazione noto (bootstrap o scoperto dai peer).</summary>
|
/// <summary>Known indexing server (bootstrap or discovered from peers).</summary>
|
||||||
public sealed record KnownServer(string Host, int TcpPort, int SslPort, string? Version = null)
|
public sealed record KnownServer(string Host, int TcpPort, int SslPort, string? Version = null)
|
||||||
{
|
{
|
||||||
public int PortFor(bool useSsl) => useSsl ? SslPort : TcpPort;
|
public int PortFor(bool useSsl) => useSsl ? SslPort : TcpPort;
|
||||||
@@ -13,9 +13,9 @@ public sealed record KnownServer(string Host, int TcpPort, int SslPort, string?
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registro dei server (blueprint §9): parte dai bootstrap del profilo (§3),
|
/// Server registry (blueprint §9): starts from the profile bootstrap list (§3),
|
||||||
/// si arricchisce con i peer annunciati via server.peers.subscribe e persiste
|
/// is enriched with peers announced via server.peers.subscribe, and persists
|
||||||
/// le scoperte su file per le sessioni successive.
|
/// discovered servers to file for subsequent sessions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ServerRegistry
|
public sealed class ServerRegistry
|
||||||
{
|
{
|
||||||
@@ -42,7 +42,7 @@ public sealed class ServerRegistry
|
|||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
// File corrotto: si riparte dai soli bootstrap.
|
// Corrupted file: fall back to bootstrap servers only.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,15 +52,15 @@ public sealed class ServerRegistry
|
|||||||
get { lock (_lock) return [.. _servers]; }
|
get { lock (_lock) return [.. _servers]; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Primo server noto: default quando l'utente non ne indica uno.</summary>
|
/// <summary>First known server: default when the user does not specify one.</summary>
|
||||||
public KnownServer? Default
|
public KnownServer? Default
|
||||||
{
|
{
|
||||||
get { lock (_lock) return _servers.FirstOrDefault(); }
|
get { lock (_lock) return _servers.FirstOrDefault(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Chiede al server connesso i peer che annuncia e integra i nuovi nel
|
/// Queries the connected server for its announced peers and adds new ones to the
|
||||||
/// registro (porte mancanti → default del profilo). Ritorna quanti sono nuovi.
|
/// registry (missing ports fall back to the profile defaults). Returns the count of new entries.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<int> DiscoverAsync(ElectrumClient client, CancellationToken ct = default)
|
public async Task<int> DiscoverAsync(ElectrumClient client, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -95,7 +95,7 @@ public sealed class ServerRegistry
|
|||||||
private void Save()
|
private void Save()
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
|
||||||
// Si salvano solo gli scoperti: i bootstrap vengono dal profilo.
|
// Only discovered servers are saved; bootstrap servers come from the profile.
|
||||||
var discovered = _servers
|
var discovered = _servers
|
||||||
.Where(s => !_profile.BootstrapServers.Any(b =>
|
.Where(s => !_profile.BootstrapServers.Any(b =>
|
||||||
string.Equals(b.Host, s.Host, StringComparison.OrdinalIgnoreCase)))
|
string.Equals(b.Host, s.Host, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ using PalladiumWallet.Core.Chain;
|
|||||||
namespace PalladiumWallet.Core.Spv;
|
namespace PalladiumWallet.Core.Spv;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Header di blocco parsato dagli 80 byte canonici (blueprint §7.2):
|
/// Block header parsed from the canonical 80 bytes (blueprint §7.2):
|
||||||
/// versione, prev_hash, merkle_root, timestamp, bits, nonce.
|
/// version, prev_hash, merkle_root, timestamp, bits, nonce.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record BlockHeaderInfo(
|
public sealed record BlockHeaderInfo(
|
||||||
int Version, uint256 PrevHash, uint256 MerkleRoot, uint Timestamp, uint Bits, uint Nonce, uint256 Hash)
|
int Version, uint256 PrevHash, uint256 MerkleRoot, uint Timestamp, uint Bits, uint Nonce, uint256 Hash)
|
||||||
@@ -18,7 +18,7 @@ public sealed record BlockHeaderInfo(
|
|||||||
public static BlockHeaderInfo Parse(byte[] raw)
|
public static BlockHeaderInfo Parse(byte[] raw)
|
||||||
{
|
{
|
||||||
if (raw.Length != Size)
|
if (raw.Length != Size)
|
||||||
throw new ArgumentException($"Header di {raw.Length} byte (attesi {Size}).", nameof(raw));
|
throw new ArgumentException($"Header is {raw.Length} bytes (expected {Size}).", nameof(raw));
|
||||||
|
|
||||||
return new BlockHeaderInfo(
|
return new BlockHeaderInfo(
|
||||||
Version: BitConverter.ToInt32(raw, 0),
|
Version: BitConverter.ToInt32(raw, 0),
|
||||||
@@ -31,11 +31,10 @@ public sealed record BlockHeaderInfo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validazione SPV dell'header (§7.2): collegamento al precedente e,
|
/// SPV header validation (§7.2): linkage to the previous header and,
|
||||||
/// se il profilo non impone lo skip (catena LWMA), verifica PoW
|
/// if the profile does not mandate skip (LWMA chain), PoW verification
|
||||||
/// hash <= target dai bits. Il controllo di coerenza dei bits col
|
/// hash <= target from bits. Full bits/retargeting consistency requires
|
||||||
/// retargeting completo richiede la storia: per la catena di riferimento
|
/// history — for this chain it is replaced by checkpoints (§7.3).
|
||||||
/// è sostituito dai checkpoint (§7.3).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsValidChild(uint256 expectedPrevHash, ChainProfile profile)
|
public bool IsValidChild(uint256 expectedPrevHash, ChainProfile profile)
|
||||||
{
|
{
|
||||||
@@ -47,7 +46,7 @@ public sealed record BlockHeaderInfo(
|
|||||||
return Hash <= target;
|
return Hash <= target;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Confronto con un checkpoint hardcoded (§7.3).</summary>
|
/// <summary>Comparison against a hardcoded checkpoint (§7.3).</summary>
|
||||||
public bool MatchesCheckpoint(Checkpoint checkpoint) =>
|
public bool MatchesCheckpoint(Checkpoint checkpoint) =>
|
||||||
Hash == uint256.Parse(checkpoint.BlockHash);
|
Hash == uint256.Parse(checkpoint.BlockHash);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ using NBitcoin;
|
|||||||
namespace PalladiumWallet.Core.Spv;
|
namespace PalladiumWallet.Core.Spv;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scripthash del protocollo di indicizzazione (blueprint §0/§10): SHA-256
|
/// Script hash for the indexing protocol (blueprint §0/§10): SHA-256 of the
|
||||||
/// dello scriptPubKey con i byte in ordine inverso, esadecimale.
|
/// scriptPubKey with bytes reversed, encoded as lowercase hex.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Scripthash
|
public static class Scripthash
|
||||||
{
|
{
|
||||||
|
|||||||
+172
-117
@@ -8,14 +8,14 @@ using PalladiumWallet.Core.Storage;
|
|||||||
|
|
||||||
namespace PalladiumWallet.Core.Spv;
|
namespace PalladiumWallet.Core.Spv;
|
||||||
|
|
||||||
/// <summary>Indirizzo derivato e tracciato durante la sincronizzazione.</summary>
|
/// <summary>Address derived and tracked during synchronisation.</summary>
|
||||||
public sealed record TrackedAddress(
|
public sealed record TrackedAddress(
|
||||||
BitcoinAddress Address, string ScriptHash, bool IsChange, int Index)
|
BitcoinAddress Address, string ScriptHash, bool IsChange, int Index)
|
||||||
{
|
{
|
||||||
public Script ScriptPubKey => Address.ScriptPubKey;
|
public Script ScriptPubKey => Address.ScriptPubKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Esito di una passata di sincronizzazione.</summary>
|
/// <summary>Result of a synchronisation pass.</summary>
|
||||||
public sealed class SyncResult
|
public sealed class SyncResult
|
||||||
{
|
{
|
||||||
public required int TipHeight { get; init; }
|
public required int TipHeight { get; init; }
|
||||||
@@ -31,158 +31,151 @@ public sealed class SyncResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sincronizzazione del wallet (blueprint §7.4): per ogni indirizzo calcola lo
|
/// Wallet synchronisation (blueprint §7.4).
|
||||||
/// scripthash e si sottoscrive; scarica storico e transazioni; verifica ogni tx
|
|
||||||
/// confermata con la prova di Merkle contro l'header del suo blocco (le risposte
|
|
||||||
/// del server non sono fidate, §17); ricostruisce localmente UTXO e saldo;
|
|
||||||
/// estende la scansione fino al gap limit (§5).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient client, int gapLimit = 20)
|
public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient client, int gapLimit = 20)
|
||||||
{
|
{
|
||||||
/// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary>
|
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
|
||||||
public event Action<string>? Progress;
|
public event Action<string>? Progress;
|
||||||
|
|
||||||
// Richieste contemporanee verso il server. Troppo alte → -102 "server busy";
|
private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
|
||||||
// troppo basse → throughput scarso su storie grandi.
|
|
||||||
private const int MaxConcurrent = 20;
|
|
||||||
|
|
||||||
// Cache tra le passate (stesso synchronizer per tutta la vita della
|
|
||||||
// connessione): le tx già scaricate e le prove di Merkle già verificate a
|
|
||||||
// una data altezza non si rifanno — le risincronizzazioni da notifica
|
|
||||||
// costano solo ciò che è cambiato (modello Electrum).
|
|
||||||
private readonly Dictionary<string, Transaction> _txCache = [];
|
|
||||||
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
||||||
|
|
||||||
// Header grezzi per altezza: una Task<string> per altezza, condivisa tra
|
|
||||||
// tutte le tx dello stesso blocco → ogni blocco viene scaricato una sola
|
|
||||||
// volta anche con centinaia di tx confermate nello stesso blocco.
|
|
||||||
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
||||||
|
|
||||||
|
// Indices known from the previous sync: used by ScanChainAsync for incremental
|
||||||
|
// discovery — already-used addresses are fetched in a single burst instead of
|
||||||
|
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
|
||||||
|
private int _knownReceiveIndex;
|
||||||
|
private int _knownChangeIndex;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pre-popola le cache interne da dati salvati su disco (SyncCache).
|
/// Pre-populates internal caches from data saved on disk.
|
||||||
/// Chiamare prima di SyncOnceAsync per evitare di riscaricale le tx già note.
|
/// Call before SyncOnceAsync to avoid re-downloading already known transactions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void PreloadCaches(Dictionary<string, string> rawTxHex,
|
public void PreloadCaches(
|
||||||
Dictionary<string, int> verifiedAt, Network network)
|
Dictionary<string, string> rawTxHex,
|
||||||
|
Dictionary<string, int> verifiedAt,
|
||||||
|
Dictionary<int, string>? blockHeaders,
|
||||||
|
int knownReceiveIndex,
|
||||||
|
int knownChangeIndex,
|
||||||
|
Network network)
|
||||||
{
|
{
|
||||||
foreach (var (txid, hex) in rawTxHex)
|
foreach (var (txid, hex) in rawTxHex)
|
||||||
if (!_txCache.ContainsKey(txid))
|
_txCache.TryAdd(txid, Transaction.Parse(hex, network));
|
||||||
_txCache[txid] = Transaction.Parse(hex, network);
|
|
||||||
foreach (var (txid, height) in verifiedAt)
|
foreach (var (txid, height) in verifiedAt)
|
||||||
if (!_verifiedAtHeight.ContainsKey(txid))
|
if (!_verifiedAtHeight.ContainsKey(txid))
|
||||||
_verifiedAtHeight[txid] = height;
|
_verifiedAtHeight[txid] = height;
|
||||||
|
if (blockHeaders is not null)
|
||||||
|
foreach (var (height, hex) in blockHeaders)
|
||||||
|
_headerFetches.TryAdd(height, Task.FromResult(hex));
|
||||||
|
_knownReceiveIndex = knownReceiveIndex;
|
||||||
|
_knownChangeIndex = knownChangeIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Esporta le cache correnti in forma serializzabile su disco.
|
/// Exports the current caches in a serialisable form for disk storage.
|
||||||
/// Solo le tx confermate (height > 0) vengono incluse: le non confermate
|
/// Only confirmed transactions (height > 0) are included: unconfirmed ones
|
||||||
/// possono cambiare (RBF) e vanno sempre riscaricate.
|
/// may change (RBF) and must always be re-downloaded.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public (Dictionary<string, string> RawTxHex, Dictionary<string, int> VerifiedAt)
|
public (Dictionary<string, string> RawTxHex,
|
||||||
|
Dictionary<string, int> VerifiedAt,
|
||||||
|
Dictionary<int, string> BlockHeaders)
|
||||||
ExportCaches(Network network)
|
ExportCaches(Network network)
|
||||||
{
|
{
|
||||||
// Includi solo le tx associate a una prova di Merkle verificata
|
|
||||||
// (cioè confermate e verificate): sono le uniche immutabili.
|
|
||||||
var rawHex = _verifiedAtHeight.Keys
|
var rawHex = _verifiedAtHeight.Keys
|
||||||
.Where(_txCache.ContainsKey)
|
.Where(_txCache.ContainsKey)
|
||||||
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
|
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
|
||||||
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight));
|
|
||||||
|
// Only already-completed headers: in-progress Task<string> instances
|
||||||
|
// are not persisted (they will be re-fetched on the next sync if needed).
|
||||||
|
var headers = new Dictionary<int, string>();
|
||||||
|
foreach (var (height, task) in _headerFetches)
|
||||||
|
if (task.IsCompletedSuccessfully)
|
||||||
|
headers[height] = task.Result;
|
||||||
|
|
||||||
|
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var tip = await client.SubscribeHeadersAsync(ct);
|
var tip = await client.SubscribeHeadersAsync(ct);
|
||||||
Progress?.Invoke($"tip della catena: {tip.Height}");
|
Progress?.Invoke($"chain tip: {tip.Height}");
|
||||||
|
|
||||||
// 1-2. Scansione indirizzi.
|
// 1-2. Address scanning.
|
||||||
var tracked = new List<TrackedAddress>();
|
var tracked = new List<TrackedAddress>();
|
||||||
var historyByAddress = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
var historyByAddress = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
||||||
int nextReceive, nextChange;
|
int nextReceive, nextChange;
|
||||||
|
|
||||||
if (account.FixedAddresses is { } fixedAddresses)
|
if (account.FixedAddresses is { } fixedAddresses)
|
||||||
{
|
{
|
||||||
// Importati WIF: lista fissa, nessun gap limit.
|
|
||||||
// Pochi indirizzi → subscribe diretto per notifiche push.
|
|
||||||
foreach (var (addr, isChange, idx) in fixedAddresses)
|
foreach (var (addr, isChange, idx) in fixedAddresses)
|
||||||
tracked.Add(new TrackedAddress(addr, Scripthash.FromAddress(addr), isChange, idx));
|
tracked.Add(new TrackedAddress(addr, Scripthash.FromAddress(addr), isChange, idx));
|
||||||
nextReceive = tracked.Count(t => !t.IsChange);
|
nextReceive = tracked.Count(t => !t.IsChange);
|
||||||
nextChange = 0;
|
nextChange = 0;
|
||||||
|
|
||||||
var histories = await Task.WhenAll(
|
await Task.WhenAll(tracked.Select(t => RetryOnBusyAsync(async () =>
|
||||||
tracked.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
|
||||||
for (var i = 0; i < tracked.Count; i++)
|
|
||||||
{
|
{
|
||||||
if (histories[i].Count > 0)
|
var h = await client.GetHistoryAsync(t.ScriptHash, ct);
|
||||||
historyByAddress[tracked[i].ScriptHash] = histories[i];
|
if (h.Count > 0) historyByAddress[t.ScriptHash] = h;
|
||||||
}
|
}, ct)).Concat(tracked.Select(t =>
|
||||||
// Subscribe a tutti (pochi): notifiche push per ogni indirizzo importato.
|
RetryOnBusyAsync(() => client.SubscribeScripthashAsync(t.ScriptHash, ct), ct))));
|
||||||
await Task.WhenAll(tracked.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// HD: discovery con GetHistoryAsync (senza subscription → no -101 su wallet grandi);
|
// Receive and change chains in parallel (independent by definition).
|
||||||
// subscribe solo al gap window per ricevere notifiche push di nuove tx.
|
// ScanChainAsync uses _knownReceiveIndex/_knownChangeIndex for incremental
|
||||||
nextReceive = await ScanChainAsync(isChange: false, tracked, historyByAddress, ct);
|
// discovery: already-used addresses are fetched in a single burst.
|
||||||
nextChange = await ScanChainAsync(isChange: true, tracked, historyByAddress, ct);
|
var receiveTask = ScanChainAsync(isChange: false, _knownReceiveIndex, ct);
|
||||||
|
var changeTask = ScanChainAsync(isChange: true, _knownChangeIndex, ct);
|
||||||
|
var rxScan = await receiveTask;
|
||||||
|
var chScan = await changeTask;
|
||||||
|
|
||||||
|
tracked.AddRange(rxScan.Tracked);
|
||||||
|
tracked.AddRange(chScan.Tracked);
|
||||||
|
foreach (var (k, v) in rxScan.History) historyByAddress[k] = v;
|
||||||
|
foreach (var (k, v) in chScan.History) historyByAddress[k] = v;
|
||||||
|
nextReceive = rxScan.NextIndex;
|
||||||
|
nextChange = chScan.NextIndex;
|
||||||
|
|
||||||
// Iscriviti al gap window (prossimi indirizzi attesi) per notifiche push.
|
|
||||||
// In questo modo il numero di subscription è sempre ≤ 2×gapLimit, indipendentemente
|
|
||||||
// dalla dimensione dello storico — nessun rischio di -101.
|
|
||||||
var gapAddresses = tracked.Where(t =>
|
var gapAddresses = tracked.Where(t =>
|
||||||
(!t.IsChange && t.Index >= nextReceive && t.Index < nextReceive + gapLimit) ||
|
(!t.IsChange && t.Index >= nextReceive && t.Index < nextReceive + gapLimit) ||
|
||||||
( t.IsChange && t.Index >= nextChange && t.Index < nextChange + gapLimit)).ToList();
|
( t.IsChange && t.Index >= nextChange && t.Index < nextChange + gapLimit)).ToList();
|
||||||
if (gapAddresses.Count > 0)
|
if (gapAddresses.Count > 0)
|
||||||
await Task.WhenAll(gapAddresses.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
|
await Task.WhenAll(gapAddresses.Select(t =>
|
||||||
|
RetryOnBusyAsync(() => client.SubscribeScripthashAsync(t.ScriptHash, ct), ct)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Storico unico (txid → altezza massima riportata).
|
// 3. Merged history (txid → highest reported height).
|
||||||
var txHeights = new Dictionary<string, int>();
|
var txHeights = new Dictionary<string, int>();
|
||||||
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
||||||
txHeights[item.TxHash] = item.Height;
|
txHeights[item.TxHash] = item.Height;
|
||||||
|
|
||||||
// 4. Scarica le transazioni nuove: semaforo MaxConcurrent per non saturare
|
// 4+5. Download missing transactions and verify Merkle proofs in parallel.
|
||||||
// il server, con aggiornamento progresso in tempo reale.
|
|
||||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||||
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
||||||
if (missing.Count > 0)
|
|
||||||
{
|
|
||||||
var dlSem = new SemaphoreSlim(MaxConcurrent, MaxConcurrent);
|
|
||||||
var dlDone = 0;
|
|
||||||
Progress?.Invoke($"scarico 0/{missing.Count} transazioni…");
|
|
||||||
await Task.WhenAll(missing.Select(async txid =>
|
|
||||||
{
|
|
||||||
await dlSem.WaitAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var raw = await client.GetTransactionAsync(txid, ct);
|
|
||||||
_txCache[txid] = Transaction.Parse(raw, network);
|
|
||||||
var n = Interlocked.Increment(ref dlDone);
|
|
||||||
Progress?.Invoke($"scarico {n}/{missing.Count} transazioni…");
|
|
||||||
}
|
|
||||||
finally { dlSem.Release(); }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
|
||||||
|
|
||||||
// 5. Verifica Merkle delle confermate (§7.4 punto 4).
|
|
||||||
// Gli header per altezza sono condivisi via _headerFetches: se 500 tx
|
|
||||||
// stanno nello stesso blocco, l'header viene scaricato una sola volta.
|
|
||||||
var toVerify = txHeights
|
var toVerify = txHeights
|
||||||
.Where(kv => kv.Value > 0
|
.Where(kv => kv.Value > 0
|
||||||
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
||||||
.ToList();
|
.ToList();
|
||||||
if (toVerify.Count > 0)
|
|
||||||
|
if (missing.Count > 0 || toVerify.Count > 0)
|
||||||
{
|
{
|
||||||
var merkSem = new SemaphoreSlim(MaxConcurrent, MaxConcurrent);
|
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…");
|
||||||
|
var dlDone = 0;
|
||||||
var merkDone = 0;
|
var merkDone = 0;
|
||||||
Progress?.Invoke($"verifico 0/{toVerify.Count} prove di Merkle…");
|
|
||||||
await Task.WhenAll(toVerify.Select(async kv =>
|
var dlTasks = missing.Select(txid => RetryOnBusyAsync(async () =>
|
||||||
{
|
{
|
||||||
await merkSem.WaitAsync(ct);
|
var raw = await client.GetTransactionAsync(txid, ct);
|
||||||
try
|
_txCache[txid] = Transaction.Parse(raw, network);
|
||||||
|
var n = Interlocked.Increment(ref dlDone);
|
||||||
|
if (n % 50 == 0 || n == missing.Count)
|
||||||
|
Progress?.Invoke($"tx {n}/{missing.Count}, proofs {merkDone}/{toVerify.Count}…");
|
||||||
|
}, ct));
|
||||||
|
|
||||||
|
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
||||||
{
|
{
|
||||||
var (txid, height) = kv;
|
var (txid, height) = kv;
|
||||||
// Proof e header in parallelo; l'header è condiviso per altezza.
|
|
||||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||||
var headerTask = _headerFetches.GetOrAdd(height,
|
var headerTask = _headerFetches.GetOrAdd(height,
|
||||||
h => client.GetBlockHeaderAsync(h, ct));
|
h => client.GetBlockHeaderAsync(h, ct));
|
||||||
@@ -192,19 +185,21 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
uint256.Parse(txid), proof.Pos,
|
uint256.Parse(txid), proof.Pos,
|
||||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||||
throw new SpvVerificationException(
|
throw new SpvVerificationException(
|
||||||
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
|
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
||||||
var n = Interlocked.Increment(ref merkDone);
|
var n = Interlocked.Increment(ref merkDone);
|
||||||
Progress?.Invoke($"verifico {n}/{toVerify.Count} prove di Merkle…");
|
if (n % 50 == 0 || n == toVerify.Count)
|
||||||
}
|
Progress?.Invoke($"tx {dlDone}/{missing.Count}, proofs {n}/{toVerify.Count}…");
|
||||||
finally { merkSem.Release(); }
|
}, ct));
|
||||||
}));
|
|
||||||
|
await Task.WhenAll(dlTasks.Concat(merkTasks));
|
||||||
foreach (var (txid, height) in toVerify)
|
foreach (var (txid, height) in toVerify)
|
||||||
_verifiedAtHeight[txid] = height;
|
_verifiedAtHeight[txid] = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
||||||
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
|
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
|
||||||
|
|
||||||
// 6. Ricostruzione locale degli UTXO: accrediti = output verso nostri
|
// 6. Local UTXO reconstruction.
|
||||||
// script; spesi = outpoint consumati da una qualunque tx del wallet.
|
|
||||||
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
|
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
|
||||||
var spent = transactions.Values
|
var spent = transactions.Values
|
||||||
.SelectMany(tx => tx.Inputs)
|
.SelectMany(tx => tx.Inputs)
|
||||||
@@ -234,7 +229,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Delta per voce di storico (entrate - uscite del wallet).
|
// 7. Delta per history entry.
|
||||||
var history = new List<CachedTx>();
|
var history = new List<CachedTx>();
|
||||||
foreach (var (txid, tx) in transactions)
|
foreach (var (txid, tx) in transactions)
|
||||||
{
|
{
|
||||||
@@ -255,13 +250,11 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
}
|
}
|
||||||
history.Sort((a, b) =>
|
history.Sort((a, b) =>
|
||||||
{
|
{
|
||||||
// Non confermate (height<=0) in cima, poi per altezza decrescente.
|
|
||||||
var ha = a.Height <= 0 ? int.MaxValue : a.Height;
|
var ha = a.Height <= 0 ? int.MaxValue : a.Height;
|
||||||
var hb = b.Height <= 0 ? int.MaxValue : b.Height;
|
var hb = b.Height <= 0 ? int.MaxValue : b.Height;
|
||||||
return hb.CompareTo(ha);
|
return hb.CompareTo(ha);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Saldo e numero di transazioni per singolo indirizzo (vista indirizzi).
|
|
||||||
var balanceByAddress = utxos
|
var balanceByAddress = utxos
|
||||||
.GroupBy(u => u.Address)
|
.GroupBy(u => u.Address)
|
||||||
.ToDictionary(g => g.Key, g => g.Sum(u => u.ValueSats));
|
.ToDictionary(g => g.Key, g => g.Sum(u => u.ValueSats));
|
||||||
@@ -293,38 +286,63 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
|
/// Scans one chain (receiving or change).
|
||||||
/// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit per volta.
|
///
|
||||||
/// Usa GetHistoryAsync per la discovery — senza subscription → nessun rischio di
|
/// Phase 1 — known addresses (0..fromIndex-1): all GetHistoryAsync calls are
|
||||||
/// -101 "excessive resource usage" su wallet con molti indirizzi storici.
|
/// fired in a single parallel burst, with no sequential batching. A wallet
|
||||||
/// Le subscription per notifiche push vengono gestite dal chiamante (solo gap window).
|
/// with 100 used addresses costs 1 RTT instead of 5 sequential gap-limit rounds.
|
||||||
/// Ritorna il primo indice non usato.
|
///
|
||||||
|
/// Phase 2 — discovery from fromIndex onwards: gap-limit batching as before,
|
||||||
|
/// required to know when to stop.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked,
|
private async Task<(int NextIndex,
|
||||||
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress, CancellationToken ct)
|
List<TrackedAddress> Tracked,
|
||||||
|
Dictionary<string, IReadOnlyList<HistoryItem>> History)>
|
||||||
|
ScanChainAsync(bool isChange, int fromIndex, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var tracked = new List<TrackedAddress>();
|
||||||
|
var history = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
||||||
|
|
||||||
|
// Phase 1: single burst for all already-known addresses.
|
||||||
|
if (fromIndex > 0)
|
||||||
|
{
|
||||||
|
var known = Enumerable.Range(0, fromIndex).Select(i =>
|
||||||
|
{
|
||||||
|
var addr = account.GetAddress(isChange, i);
|
||||||
|
return new TrackedAddress(addr, Scripthash.FromAddress(addr), isChange, i);
|
||||||
|
}).ToList();
|
||||||
|
tracked.AddRange(known);
|
||||||
|
|
||||||
|
var knownHistories = await Task.WhenAll(
|
||||||
|
known.Select(t => RetryOnBusyAsync(
|
||||||
|
() => client.GetHistoryAsync(t.ScriptHash, ct), ct)));
|
||||||
|
for (var i = 0; i < known.Count; i++)
|
||||||
|
if (knownHistories[i].Count > 0)
|
||||||
|
history[known[i].ScriptHash] = knownHistories[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: gap-limit discovery from fromIndex onwards.
|
||||||
var consecutiveEmpty = 0;
|
var consecutiveEmpty = 0;
|
||||||
var index = 0;
|
var index = fromIndex;
|
||||||
var firstUnused = 0;
|
var firstUnused = fromIndex;
|
||||||
|
|
||||||
while (consecutiveEmpty < gapLimit)
|
while (consecutiveEmpty < gapLimit)
|
||||||
{
|
{
|
||||||
var batch = Enumerable.Range(index, gapLimit).Select(i =>
|
var batch = Enumerable.Range(index, gapLimit).Select(i =>
|
||||||
{
|
{
|
||||||
var address = account.GetAddress(isChange, i);
|
var addr = account.GetAddress(isChange, i);
|
||||||
return new TrackedAddress(address, Scripthash.FromAddress(address), isChange, i);
|
return new TrackedAddress(addr, Scripthash.FromAddress(addr), isChange, i);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
index += batch.Count;
|
index += batch.Count;
|
||||||
tracked.AddRange(batch);
|
tracked.AddRange(batch);
|
||||||
|
|
||||||
// GetHistoryAsync per discovery: risposta vuota [] se inutilizzato,
|
|
||||||
// lista di tx se usato — un solo round-trip per indirizzo.
|
|
||||||
var histories = await Task.WhenAll(
|
var histories = await Task.WhenAll(
|
||||||
batch.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
batch.Select(t => RetryOnBusyAsync(
|
||||||
|
() => client.GetHistoryAsync(t.ScriptHash, ct), ct)));
|
||||||
|
|
||||||
for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++)
|
for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++)
|
||||||
{
|
{
|
||||||
var history = histories[i];
|
if (histories[i].Count == 0)
|
||||||
if (history.Count == 0)
|
|
||||||
{
|
{
|
||||||
consecutiveEmpty++;
|
consecutiveEmpty++;
|
||||||
}
|
}
|
||||||
@@ -332,13 +350,50 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
{
|
{
|
||||||
consecutiveEmpty = 0;
|
consecutiveEmpty = 0;
|
||||||
firstUnused = batch[i].Index + 1;
|
firstUnused = batch[i].Index + 1;
|
||||||
historyByAddress[batch[i].ScriptHash] = history;
|
history[batch[i].ScriptHash] = histories[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return firstUnused;
|
|
||||||
|
return (firstUnused, tracked, history);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var delay = 200;
|
||||||
|
for (var attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
try { await op(); return; }
|
||||||
|
catch (ElectrumServerException ex)
|
||||||
|
when (IsBusy(ex) && attempt < 7)
|
||||||
|
{
|
||||||
|
await Task.Delay(delay, ct);
|
||||||
|
delay = Math.Min(delay * 2, 5_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var delay = 200;
|
||||||
|
for (var attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
try { return await op(); }
|
||||||
|
catch (ElectrumServerException ex)
|
||||||
|
when (IsBusy(ex) && attempt < 7)
|
||||||
|
{
|
||||||
|
await Task.Delay(delay, ct);
|
||||||
|
delay = Math.Min(delay * 2, 5_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBusy(ElectrumServerException ex) =>
|
||||||
|
ex.Message.Contains("-102") ||
|
||||||
|
ex.Message.Contains("-101") ||
|
||||||
|
ex.Message.Contains("server busy", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
ex.Message.Contains("excessive resource usage", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>La verifica SPV è fallita: i dati del server contraddicono le prove (§17).</summary>
|
/// <summary>SPV verification failed: server data contradicts the proofs (§17).</summary>
|
||||||
public sealed class SpvVerificationException(string message) : Exception(message);
|
public sealed class SpvVerificationException(string message) : Exception(message);
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ using System.Text.Json;
|
|||||||
namespace PalladiumWallet.Core.Storage;
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configurazione globale dell'applicazione (blueprint §8), separata dai file
|
/// Global application configuration (blueprint §8), separate from wallet
|
||||||
/// wallet: lingua, unità di visualizzazione, ecc. Persistita in config.json
|
/// files: language, display unit, etc. Persisted in config.json
|
||||||
/// nella radice dei dati (vale per tutte le reti).
|
/// in the data root (applies to all networks).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AppConfig
|
public sealed class AppConfig
|
||||||
{
|
{
|
||||||
/// <summary>Codice lingua UI.</summary>
|
/// <summary>UI language code.</summary>
|
||||||
public string Language { get; set; } = "en";
|
public string Language { get; set; } = "en";
|
||||||
|
|
||||||
/// <summary>Unità di visualizzazione degli importi (vedi <see cref="Wallet.CoinAmount.Units"/>).</summary>
|
/// <summary>Display unit for amounts (see <see cref="Wallet.CoinAmount.Units"/>).</summary>
|
||||||
public string Unit { get; set; } = "PLM";
|
public string Unit { get; set; } = "PLM";
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||||
@@ -28,7 +28,7 @@ public sealed class AppConfig
|
|||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
// Config corrotta: si riparte dai default senza bloccare l'avvio.
|
// Corrupted config: fall back to defaults without blocking startup.
|
||||||
return new AppConfig();
|
return new AppConfig();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,29 @@ using PalladiumWallet.Core.Chain;
|
|||||||
namespace PalladiumWallet.Core.Storage;
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Percorsi dati per piattaforma (blueprint §8). La radice dati può essere:
|
/// Per-platform data paths (blueprint §8). The data root can be:
|
||||||
/// 1. <b>portable</b>: cartella "palladium-data" accanto all'eseguibile;
|
/// 1. <b>portable</b>: "palladium-data" folder next to the executable;
|
||||||
/// 2. <b>personalizzata</b>: scelta dall'utente al primo avvio e memorizzata in
|
/// 2. <b>custom</b>: chosen by the user at first launch and stored in
|
||||||
/// un piccolo file "puntatore" in una posizione di bootstrap fissa;
|
/// a small "pointer" file at a fixed bootstrap location;
|
||||||
/// 3. <b>legacy</b>: vecchia posizione (%APPDATA%/PalladiumWallet) se contiene già dati;
|
/// 3. <b>legacy</b>: old location (%APPDATA%/PalladiumWallet) if it already holds data;
|
||||||
/// 4. <b>default</b>: ~/.PalladiumWallet (Linux/macOS) o %ProgramFiles%\PalladiumWallet (Windows).
|
/// 4. <b>default</b>: ~/.PalladiumWallet (Linux/macOS) or %ProgramFiles%\PalladiumWallet (Windows).
|
||||||
/// Sotto la radice c'è una sottocartella per rete (config, wallet, header, certificati).
|
/// Under the root there is a per-network subfolder (config, wallet, header, certificates).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AppPaths
|
public static class AppPaths
|
||||||
{
|
{
|
||||||
public const string PortableDirName = "palladium-data";
|
public const string PortableDirName = "palladium-data";
|
||||||
|
|
||||||
/// <summary>Nome cartella applicazione, usato nei vari percorsi.</summary>
|
/// <summary>Application folder name, used in the various paths.</summary>
|
||||||
public const string AppDirName = "PalladiumWallet";
|
public const string AppDirName = "PalladiumWallet";
|
||||||
|
|
||||||
/// <summary>Override esplicito della radice dati (es. CLI --data-dir). Ha priorità su tutto.</summary>
|
/// <summary>Explicit override of the data root (e.g. CLI --data-dir). Takes priority over everything.</summary>
|
||||||
public static string? OverrideDataRoot { get; set; }
|
public static string? OverrideDataRoot { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Radice dati predefinita, secondo la convenzione di ogni piattaforma:
|
/// Default data root, following each platform's convention:
|
||||||
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, come Electrum/Bitcoin);
|
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, like Electrum/Bitcoin);
|
||||||
/// Linux/macOS → ~/.palladium-wallet (dotfolder minuscolo, come ~/.bitcoin).
|
/// Linux/macOS → ~/.palladium-wallet (lowercase dotfolder, like ~/.bitcoin).
|
||||||
/// Per-utente e sempre scrivibile, senza privilegi di amministratore.
|
/// Per-user and always writable, without administrator privileges.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string DefaultDataRoot()
|
public static string DefaultDataRoot()
|
||||||
{
|
{
|
||||||
@@ -37,8 +37,8 @@ public static class AppPaths
|
|||||||
return Path.Combine(home, ".palladium-wallet");
|
return Path.Combine(home, ".palladium-wallet");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>File puntatore alla radice dati scelta dall'utente. Vive in una
|
/// <summary>Pointer file to the user-chosen data root. Lives at a
|
||||||
/// posizione di bootstrap sempre scrivibile e indipendente dalla radice dati.</summary>
|
/// bootstrap location that is always writable and independent of the data root.</summary>
|
||||||
private static string LocationPointerPath() =>
|
private static string LocationPointerPath() =>
|
||||||
Path.Combine(
|
Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
@@ -50,7 +50,7 @@ public static class AppPaths
|
|||||||
private static bool HasData(string root) =>
|
private static bool HasData(string root) =>
|
||||||
Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any();
|
Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any();
|
||||||
|
|
||||||
/// <summary>Radice dati effettiva, secondo l'ordine di precedenza documentato in classe.</summary>
|
/// <summary>Effective data root, following the precedence order documented on the class.</summary>
|
||||||
public static string DataRoot()
|
public static string DataRoot()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(OverrideDataRoot))
|
if (!string.IsNullOrEmpty(OverrideDataRoot))
|
||||||
@@ -67,9 +67,9 @@ public static class AppPaths
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// true se la posizione dei dati è già determinata e non serve chiederla
|
/// true if the data location is already determined and need not be asked
|
||||||
/// all'utente: modalità portable, override, puntatore già scritto, oppure
|
/// of the user: portable mode, override, pointer already written, or
|
||||||
/// dati già presenti nella posizione predefinita.
|
/// data already present at the default location.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsDataLocationConfigured() =>
|
public static bool IsDataLocationConfigured() =>
|
||||||
!string.IsNullOrEmpty(OverrideDataRoot)
|
!string.IsNullOrEmpty(OverrideDataRoot)
|
||||||
@@ -77,7 +77,7 @@ public static class AppPaths
|
|||||||
|| ReadPointer() is not null
|
|| ReadPointer() is not null
|
||||||
|| HasData(DefaultDataRoot());
|
|| HasData(DefaultDataRoot());
|
||||||
|
|
||||||
/// <summary>Memorizza la radice dati scelta dall'utente e la crea su disco.</summary>
|
/// <summary>Stores the user-chosen data root and creates it on disk.</summary>
|
||||||
public static void ConfigureDataLocation(string root)
|
public static void ConfigureDataLocation(string root)
|
||||||
{
|
{
|
||||||
root = Path.GetFullPath(root.Trim());
|
root = Path.GetFullPath(root.Trim());
|
||||||
@@ -96,7 +96,7 @@ public static class AppPaths
|
|||||||
return string.IsNullOrEmpty(path) ? null : path;
|
return string.IsNullOrEmpty(path) ? null : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Cartella dati della rete (config, wallet, header, certificati).</summary>
|
/// <summary>Network data folder (config, wallet, header, certificates).</summary>
|
||||||
public static string ForNetwork(NetKind net)
|
public static string ForNetwork(NetKind net)
|
||||||
{
|
{
|
||||||
var dir = Path.Combine(DataRoot(), ChainProfiles.For(net).NetName);
|
var dir = Path.Combine(DataRoot(), ChainProfiles.For(net).NetName);
|
||||||
@@ -114,7 +114,7 @@ public static class AppPaths
|
|||||||
public static string DefaultWalletPath(NetKind net) =>
|
public static string DefaultWalletPath(NetKind net) =>
|
||||||
Path.Combine(WalletsDir(net), "default.wallet.json");
|
Path.Combine(WalletsDir(net), "default.wallet.json");
|
||||||
|
|
||||||
/// <summary>Tutti i file wallet della rete, ordinati per nome (multi-wallet §8).</summary>
|
/// <summary>All wallet files for the network, ordered by name (multi-wallet §8).</summary>
|
||||||
public static IReadOnlyList<string> WalletFiles(NetKind net)
|
public static IReadOnlyList<string> WalletFiles(NetKind net)
|
||||||
{
|
{
|
||||||
var dir = WalletsDir(net);
|
var dir = WalletsDir(net);
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ using System.Text.Json;
|
|||||||
namespace PalladiumWallet.Core.Storage;
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cifratura del file wallet (blueprint §8/§17): AES-256-GCM con chiave derivata
|
/// Wallet file encryption (blueprint §8/§17): AES-256-GCM with a key derived
|
||||||
/// dalla password via PBKDF2-HMAC-SHA512. Il contenitore è JSON autodescrittivo
|
/// from the password via PBKDF2-HMAC-SHA512. The container is self-describing JSON
|
||||||
/// (kdf, parametri, salt, nonce) per consentire upgrade futuri dei parametri.
|
/// (kdf, parameters, salt, nonce) to allow future parameter upgrades.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class EncryptedFile
|
public static class EncryptedFile
|
||||||
{
|
{
|
||||||
@@ -54,7 +54,7 @@ public static class EncryptedFile
|
|||||||
Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
|
Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Lancia <see cref="WrongPasswordException"/> se la password è errata o il file è manomesso.</summary>
|
/// <summary>Throws <see cref="WrongPasswordException"/> if the password is wrong or the file is tampered with.</summary>
|
||||||
public static string Decrypt(string fileContent, string password)
|
public static string Decrypt(string fileContent, string password)
|
||||||
{
|
{
|
||||||
var container = JsonSerializer.Deserialize<Container>(fileContent)
|
var container = JsonSerializer.Deserialize<Container>(fileContent)
|
||||||
@@ -74,7 +74,7 @@ public static class EncryptedFile
|
|||||||
}
|
}
|
||||||
catch (AuthenticationTagMismatchException)
|
catch (AuthenticationTagMismatchException)
|
||||||
{
|
{
|
||||||
// Il tag GCM autentica: password errata e manomissione sono indistinguibili.
|
// The GCM tag authenticates: a wrong password and tampering are indistinguishable.
|
||||||
throw new WrongPasswordException();
|
throw new WrongPasswordException();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -88,7 +88,7 @@ public static class EncryptedFile
|
|||||||
Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA512, KeySize);
|
Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA512, KeySize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Password errata (o file wallet manomesso: il tag GCM non distingue i due casi).</summary>
|
/// <summary>Wrong password (or tampered wallet file: the GCM tag does not distinguish the two cases).</summary>
|
||||||
public sealed class WrongPasswordException : Exception
|
public sealed class WrongPasswordException : Exception
|
||||||
{
|
{
|
||||||
public WrongPasswordException() : base("Password errata o file wallet danneggiato.") { }
|
public WrongPasswordException() : base("Password errata o file wallet danneggiato.") { }
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ using System.Text.Json.Serialization;
|
|||||||
namespace PalladiumWallet.Core.Storage;
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Schema del file wallet (blueprint §8), versionato per consentire migrazioni
|
/// Wallet file schema (blueprint §8), versioned to allow automatic
|
||||||
/// automatiche all'apertura. La cifratura (quando c'è una password) avvolge
|
/// migrations on opening. Encryption (when there is a password) wraps
|
||||||
/// l'intero documento via <see cref="EncryptedFile"/>.
|
/// the entire document via <see cref="EncryptedFile"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WalletDocument
|
public sealed class WalletDocument
|
||||||
{
|
{
|
||||||
@@ -14,42 +14,42 @@ public sealed class WalletDocument
|
|||||||
|
|
||||||
public int Version { get; set; } = CurrentVersion;
|
public int Version { get; set; } = CurrentVersion;
|
||||||
|
|
||||||
/// <summary>Nome rete (mainnet/testnet/regtest), come ChainProfile.NetName.</summary>
|
/// <summary>Network name (mainnet/testnet/regtest), as ChainProfile.NetName.</summary>
|
||||||
public required string Network { get; set; }
|
public required string Network { get; set; }
|
||||||
|
|
||||||
/// <summary>Tipo di script (nome dell'enum ScriptKind).</summary>
|
/// <summary>Script type (name of the ScriptKind enum).</summary>
|
||||||
public required string ScriptKind { get; set; }
|
public required string ScriptKind { get; set; }
|
||||||
|
|
||||||
/// <summary>Mnemonica BIP39 in chiaro nel documento (il documento va cifrato!); null se watch-only.</summary>
|
/// <summary>BIP39 mnemonic in plaintext in the document (the document must be encrypted!); null if watch-only.</summary>
|
||||||
public string? Mnemonic { get; set; }
|
public string? Mnemonic { get; set; }
|
||||||
|
|
||||||
/// <summary>Extension word BIP39 (§4.1); null se assente.</summary>
|
/// <summary>BIP39 extension word (§4.1); null if absent.</summary>
|
||||||
public string? Passphrase { get; set; }
|
public string? Passphrase { get; set; }
|
||||||
|
|
||||||
/// <summary>Path di account (es. "84'/746'/0'"); null per importati WIF.</summary>
|
/// <summary>Account path (e.g. "84'/746'/0'"); null for imported WIF.</summary>
|
||||||
public string? AccountPath { get; set; }
|
public string? AccountPath { get; set; }
|
||||||
|
|
||||||
/// <summary>Xpub di account in SLIP-132 per i wallet HD; null per importati WIF.</summary>
|
/// <summary>Account xpub in SLIP-132 for HD wallets; null for imported WIF.</summary>
|
||||||
public string? AccountXpub { get; set; }
|
public string? AccountXpub { get; set; }
|
||||||
|
|
||||||
/// <summary>Xprv di account in SLIP-132; presente solo per import da xprv senza seed.</summary>
|
/// <summary>Account xprv in SLIP-132; present only for import from xprv without seed.</summary>
|
||||||
public string? AccountXprv { get; set; }
|
public string? AccountXprv { get; set; }
|
||||||
|
|
||||||
public string? MasterFingerprint { get; set; }
|
public string? MasterFingerprint { get; set; }
|
||||||
|
|
||||||
/// <summary>Chiavi WIF importate (in chiaro nel documento — va cifrato!).</summary>
|
/// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary>
|
||||||
public List<string>? WifKeys { get; set; }
|
public List<string>? WifKeys { get; set; }
|
||||||
|
|
||||||
/// <summary>Gap limit per la scansione indirizzi (§5), configurabile.</summary>
|
/// <summary>Gap limit for address scanning (§5), configurable.</summary>
|
||||||
public int GapLimit { get; set; } = 20;
|
public int GapLimit { get; set; } = 20;
|
||||||
|
|
||||||
/// <summary>Etichette per indirizzo/txid (§12).</summary>
|
/// <summary>Labels by address/txid (§12).</summary>
|
||||||
public Dictionary<string, string> Labels { get; set; } = [];
|
public Dictionary<string, string> Labels { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>Rubrica contatti (nome + indirizzo blockchain).</summary>
|
/// <summary>Contact address book (name + blockchain address).</summary>
|
||||||
public List<StoredContact> Contacts { get; set; } = [];
|
public List<StoredContact> Contacts { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary>
|
/// <summary>Cache of the last synced state (balance/history viewable offline).</summary>
|
||||||
public SyncCache? Cache { get; set; }
|
public SyncCache? Cache { get; set; }
|
||||||
|
|
||||||
public bool IsWatchOnly =>
|
public bool IsWatchOnly =>
|
||||||
@@ -69,6 +69,7 @@ public sealed class WalletDocument
|
|||||||
{
|
{
|
||||||
var doc = JsonSerializer.Deserialize<WalletDocument>(json, JsonOptions)
|
var doc = JsonSerializer.Deserialize<WalletDocument>(json, JsonOptions)
|
||||||
?? throw new InvalidDataException("File wallet non valido.");
|
?? throw new InvalidDataException("File wallet non valido.");
|
||||||
|
// string literal above intentionally left untranslated
|
||||||
return Migrate(doc);
|
return Migrate(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,21 +102,28 @@ public sealed class SyncCache
|
|||||||
public List<CachedAddress> Addresses { get; set; } = [];
|
public List<CachedAddress> Addresses { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cache raw delle transazioni confermate (txid → hex). Evita di riscaricale
|
/// Raw transaction cache (txid → hex). Avoids re-downloading confirmed
|
||||||
/// ad ogni avvio dell'app: le tx confermate sono immutabili per definizione.
|
/// transactions on every launch: confirmed txs are immutable by definition.
|
||||||
/// Popolata anche parzialmente in caso di sync interrotta (es. -101):
|
/// May be partially populated after an interrupted sync (e.g. -101 error);
|
||||||
/// il synchronizer riprende dal punto in cui si era fermato.
|
/// the synchronizer resumes from where it left off.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, string>? RawTxHex { get; set; }
|
public Dictionary<string, string>? RawTxHex { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Prove di Merkle già verificate (txid → altezza blocco). Evita di
|
/// Already-verified Merkle proofs (txid → block height). Avoids re-verifying
|
||||||
/// riverificare le stesse prove ad ogni avvio: le conferme sono immutabili.
|
/// the same proofs on every launch: confirmations are immutable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, int>? VerifiedAt { get; set; }
|
public Dictionary<string, int>? VerifiedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raw headers by height (height → hex). Immutable: never re-fetched once saved.
|
||||||
|
/// Eliminates GetBlockHeader calls for Merkle proofs already verified in
|
||||||
|
/// subsequent syncs.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<int, string>? BlockHeaders { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Indirizzo scansionato con saldo proprio e numero di transazioni (vista indirizzi).</summary>
|
/// <summary>Scanned address with its own balance and transaction count (address view).</summary>
|
||||||
public sealed class CachedAddress
|
public sealed class CachedAddress
|
||||||
{
|
{
|
||||||
public required string Address { get; set; }
|
public required string Address { get; set; }
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ using System.Globalization;
|
|||||||
namespace PalladiumWallet.Core.Wallet;
|
namespace PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Conversione satoshi ↔ unità coin (8 decimali) per visualizzazione e input.
|
/// Satoshi ↔ coin unit conversion (8 decimal places) for display and input.
|
||||||
/// Si lavora sempre in satoshi internamente; la stringa è solo presentazione.
|
/// All internal computation uses satoshis; the formatted string is presentation only.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class CoinAmount
|
public static class CoinAmount
|
||||||
{
|
{
|
||||||
public const long SatsPerCoin = 100_000_000;
|
public const long SatsPerCoin = 100_000_000;
|
||||||
|
|
||||||
/// <summary>Unità di visualizzazione selezionabili (config §8).</summary>
|
/// <summary>Selectable display units (config §8).</summary>
|
||||||
public static readonly string[] Units = ["PLM", "mPLM", "µPLM", "sat"];
|
public static readonly string[] Units = ["PLM", "mPLM", "µPLM", "sat"];
|
||||||
|
|
||||||
/// <summary>(satoshi per unità, decimali mostrati) di ciascuna unità.</summary>
|
/// <summary>(satoshis per unit, displayed decimals) for each unit.</summary>
|
||||||
private static (long Factor, int Decimals) Of(string unit) => unit switch
|
private static (long Factor, int Decimals) Of(string unit) => unit switch
|
||||||
{
|
{
|
||||||
"PLM" => (SatsPerCoin, 8),
|
"PLM" => (SatsPerCoin, 8),
|
||||||
@@ -27,7 +27,7 @@ public static class CoinAmount
|
|||||||
(sats / (decimal)SatsPerCoin).ToString("0.00000000", CultureInfo.InvariantCulture)
|
(sats / (decimal)SatsPerCoin).ToString("0.00000000", CultureInfo.InvariantCulture)
|
||||||
+ (unit.Length > 0 ? " " + unit : "");
|
+ (unit.Length > 0 ? " " + unit : "");
|
||||||
|
|
||||||
/// <summary>Formatta nell'unità scelta (es. 150000 sat → "1.50000 mPLM").</summary>
|
/// <summary>Formats in the chosen unit (e.g. 150000 sat → "1.50000 mPLM").</summary>
|
||||||
public static string FormatIn(long sats, string unit, bool withLabel = true)
|
public static string FormatIn(long sats, string unit, bool withLabel = true)
|
||||||
{
|
{
|
||||||
var (factor, decimals) = Of(unit);
|
var (factor, decimals) = Of(unit);
|
||||||
@@ -36,7 +36,7 @@ public static class CoinAmount
|
|||||||
return withLabel ? $"{value} {unit}" : value;
|
return withLabel ? $"{value} {unit}" : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Parsa un importo espresso nell'unità scelta in satoshi.</summary>
|
/// <summary>Parses an amount expressed in the chosen unit into satoshis.</summary>
|
||||||
public static bool TryParseIn(string text, string unit, out long sats)
|
public static bool TryParseIn(string text, string unit, out long sats)
|
||||||
{
|
{
|
||||||
sats = 0;
|
sats = 0;
|
||||||
@@ -59,7 +59,7 @@ public static class CoinAmount
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Parsa un importo in coin (punto o virgola decimale) in satoshi.</summary>
|
/// <summary>Parses a coin amount (decimal point or comma) into satoshis.</summary>
|
||||||
public static bool TryParseCoins(string text, out long sats)
|
public static bool TryParseCoins(string text, out long sats)
|
||||||
{
|
{
|
||||||
sats = 0;
|
sats = 0;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using PalladiumWallet.Core.Storage;
|
|||||||
|
|
||||||
namespace PalladiumWallet.Core.Wallet;
|
namespace PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
/// <summary>Esito della costruzione di una transazione.</summary>
|
/// <summary>Result of a transaction build operation.</summary>
|
||||||
public sealed class BuiltTransaction
|
public sealed class BuiltTransaction
|
||||||
{
|
{
|
||||||
public required Transaction Transaction { get; init; }
|
public required Transaction Transaction { get; init; }
|
||||||
@@ -14,7 +14,7 @@ public sealed class BuiltTransaction
|
|||||||
public required FeeRate FeeRate { get; init; }
|
public required FeeRate FeeRate { get; init; }
|
||||||
public required bool Signed { get; init; }
|
public required bool Signed { get; init; }
|
||||||
|
|
||||||
/// <summary>PSBT per i flussi watch-only/air-gapped/multisig (§6.5).</summary>
|
/// <summary>PSBT for watch-only/air-gapped/multisig flows (§6.5).</summary>
|
||||||
public required PSBT Psbt { get; init; }
|
public required PSBT Psbt { get; init; }
|
||||||
|
|
||||||
public string ToHex() => Transaction.ToHex();
|
public string ToHex() => Transaction.ToHex();
|
||||||
@@ -22,25 +22,25 @@ public sealed class BuiltTransaction
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Costruzione e firma delle transazioni (blueprint §6) sopra le primitive
|
/// Transaction construction and signing (blueprint §6) on top of NBitcoin primitives:
|
||||||
/// NBitcoin: selezione monete (manuale o automatica), fee a rate fisso,
|
/// coin selection (manual or automatic), fixed fee rate, send-all with fee subtracted,
|
||||||
/// invia-tutto con fee sottratta, change sulla catena interna, RBF di default.
|
/// change on the internal chain, RBF on by default.
|
||||||
/// Con un account watch-only produce la PSBT non firmata (§6.5).
|
/// With a watch-only account produces an unsigned PSBT (§6.5).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TransactionFactory(IWalletAccount account)
|
public sealed class TransactionFactory(IWalletAccount account)
|
||||||
{
|
{
|
||||||
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
|
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Costruisce (e se possibile firma) una transazione.
|
/// Builds (and signs if possible) a transaction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="utxos">UTXO selezionati (coin control §6.2) o tutti quelli spendibili.</param>
|
/// <param name="utxos">Selected UTXOs (coin control §6.2) or all spendable ones.</param>
|
||||||
/// <param name="transactions">Tx di provenienza degli UTXO (txid → tx), dalla sincronizzazione.</param>
|
/// <param name="transactions">Source transactions for the UTXOs (txid → tx), from sync.</param>
|
||||||
/// <param name="destination">Indirizzo destinatario.</param>
|
/// <param name="destination">Recipient address.</param>
|
||||||
/// <param name="amountSats">Importo; ignorato se <paramref name="sendAll"/>.</param>
|
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param>
|
||||||
/// <param name="feeRateSatPerVByte">Fee rate fisso in sat/vByte (§6.4).</param>
|
/// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
|
||||||
/// <param name="changeIndex">Indice del prossimo indirizzo di change (catena interna).</param>
|
/// <param name="changeIndex">Index of the next change address (internal chain).</param>
|
||||||
/// <param name="sendAll">Invia tutto: fee sottratta dall'importo (§6.1).</param>
|
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
|
||||||
public BuiltTransaction Build(
|
public BuiltTransaction Build(
|
||||||
IReadOnlyList<CachedUtxo> utxos,
|
IReadOnlyList<CachedUtxo> utxos,
|
||||||
IReadOnlyDictionary<string, Transaction> transactions,
|
IReadOnlyDictionary<string, Transaction> transactions,
|
||||||
@@ -50,15 +50,15 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
int changeIndex,
|
int changeIndex,
|
||||||
bool sendAll = false)
|
bool sendAll = false)
|
||||||
{
|
{
|
||||||
// Si spendono solo UTXO confermati: i fondi in mempool si vedono nel
|
// Only confirmed UTXOs are spent: mempool funds appear in the "pending"
|
||||||
// saldo "in attesa" ma non sono spendibili finché non confermano.
|
// balance but are not spendable until confirmed.
|
||||||
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
|
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
|
||||||
if (spendable.Count == 0)
|
if (spendable.Count == 0)
|
||||||
{
|
{
|
||||||
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
|
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
|
||||||
throw new WalletSpendException(pending > 0
|
throw new WalletSpendException(pending > 0
|
||||||
? $"Nessun fondo confermato: {CoinAmount.Format(pending)} in attesa di conferma (non spendibile)."
|
? $"No confirmed funds: {CoinAmount.Format(pending)} pending confirmation (not spendable)."
|
||||||
: "Nessun UTXO spendibile selezionato.");
|
: "No spendable UTXOs selected.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var coins = spendable.Select(u => new Coin(
|
var coins = spendable.Select(u => new Coin(
|
||||||
@@ -68,7 +68,7 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
|
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
|
||||||
var builder = Network.CreateTransactionBuilder();
|
var builder = Network.CreateTransactionBuilder();
|
||||||
builder.SetVersion(2);
|
builder.SetVersion(2);
|
||||||
// Sequence RBF per consentire il bump della fee (§6.6).
|
// RBF sequence to allow fee bumping (§6.6).
|
||||||
builder.OptInRBF = true;
|
builder.OptInRBF = true;
|
||||||
builder.AddCoins(coins);
|
builder.AddCoins(coins);
|
||||||
builder.SetChange(account.GetChangeAddress(changeIndex));
|
builder.SetChange(account.GetChangeAddress(changeIndex));
|
||||||
@@ -94,14 +94,14 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
}
|
}
|
||||||
catch (NotEnoughFundsException ex)
|
catch (NotEnoughFundsException ex)
|
||||||
{
|
{
|
||||||
throw new WalletSpendException($"Fondi insufficienti: {ex.Message}");
|
throw new WalletSpendException($"Insufficient funds: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!account.IsWatchOnly)
|
if (!account.IsWatchOnly)
|
||||||
{
|
{
|
||||||
if (!builder.Verify(tx, out TransactionPolicyError[] errors))
|
if (!builder.Verify(tx, out TransactionPolicyError[] errors))
|
||||||
throw new WalletSpendException(
|
throw new WalletSpendException(
|
||||||
"Transazione non valida: " + string.Join("; ", errors.Select(e => e.ToString())));
|
"Invalid transaction: " + string.Join("; ", errors.Select(e => e.ToString())));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new BuiltTransaction
|
return new BuiltTransaction
|
||||||
@@ -123,5 +123,5 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Errore di costruzione/firma della spesa (fondi, policy, parametri).</summary>
|
/// <summary>Error during transaction construction/signing (funds, policy, parameters).</summary>
|
||||||
public sealed class WalletSpendException(string message) : Exception(message);
|
public sealed class WalletSpendException(string message) : Exception(message);
|
||||||
|
|||||||
@@ -4,36 +4,36 @@ using PalladiumWallet.Core.Spv;
|
|||||||
|
|
||||||
namespace PalladiumWallet.Core.Wallet;
|
namespace PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
/// <summary>Un input di una transazione, con l'output speso risolto dal server.</summary>
|
/// <summary>An input of a transaction, with the spent output resolved from the server.</summary>
|
||||||
public sealed record TxInputInfo(
|
public sealed record TxInputInfo(
|
||||||
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase);
|
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase);
|
||||||
|
|
||||||
/// <summary>Un output di una transazione.</summary>
|
/// <summary>An output of a transaction.</summary>
|
||||||
public sealed record TxOutputInfo(
|
public sealed record TxOutputInfo(
|
||||||
uint Index, long AmountSats, string? Address, string ScriptType, bool IsMine);
|
uint Index, long AmountSats, string? Address, string ScriptType, bool IsMine);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dati completi di una transazione, assemblati interrogando il server: la tx
|
/// Complete transaction data assembled by querying the server: the raw transaction
|
||||||
/// grezza più gli output spesi dagli input (per ricavare importi, indirizzi e
|
/// plus the outputs spent by each input (to derive amounts, addresses, and fees)
|
||||||
/// fee) e l'header del blocco (per la data). Tutto ciò che il protocollo
|
/// and the block header (for the timestamp). Everything the ElectrumX-like protocol
|
||||||
/// ElectrumX-like (§10) permette di sapere su una transazione.
|
/// (§10) can tell us about a transaction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TransactionDetails
|
public sealed class TransactionDetails
|
||||||
{
|
{
|
||||||
public required string Txid { get; init; }
|
public required string Txid { get; init; }
|
||||||
/// <summary>Altezza del blocco; ≤0 = ancora in mempool.</summary>
|
/// <summary>Block height; ≤0 = still in mempool.</summary>
|
||||||
public required int Height { get; init; }
|
public required int Height { get; init; }
|
||||||
public required int Confirmations { get; init; }
|
public required int Confirmations { get; init; }
|
||||||
/// <summary>Effetto netto sul saldo del wallet (delta calcolato in sincronizzazione).</summary>
|
/// <summary>Net effect on the wallet balance (delta computed during sync).</summary>
|
||||||
public required long NetSats { get; init; }
|
public required long NetSats { get; init; }
|
||||||
/// <summary>Fee della transazione; null se un input ha importo non risolvibile (es. coinbase).</summary>
|
/// <summary>Transaction fee; null if any input amount cannot be resolved (e.g. coinbase).</summary>
|
||||||
public required long? FeeSats { get; init; }
|
public required long? FeeSats { get; init; }
|
||||||
public required int TotalSize { get; init; }
|
public required int TotalSize { get; init; }
|
||||||
public required int VirtualSize { get; init; }
|
public required int VirtualSize { get; init; }
|
||||||
public required uint Version { get; init; }
|
public required uint Version { get; init; }
|
||||||
public required uint LockTime { get; init; }
|
public required uint LockTime { get; init; }
|
||||||
public required bool RbfSignaled { get; init; }
|
public required bool RbfSignaled { get; init; }
|
||||||
/// <summary>Merkle proof verificata in sincronizzazione (§7.4).</summary>
|
/// <summary>Merkle proof verified during sync (§7.4).</summary>
|
||||||
public required bool Verified { get; init; }
|
public required bool Verified { get; init; }
|
||||||
public required DateTimeOffset? BlockTime { get; init; }
|
public required DateTimeOffset? BlockTime { get; init; }
|
||||||
public required long TotalOutSats { get; init; }
|
public required long TotalOutSats { get; init; }
|
||||||
@@ -43,13 +43,13 @@ public sealed class TransactionDetails
|
|||||||
|
|
||||||
public bool IsCoinbase => Inputs.Count > 0 && Inputs[0].IsCoinbase;
|
public bool IsCoinbase => Inputs.Count > 0 && Inputs[0].IsCoinbase;
|
||||||
public bool IsIncoming => NetSats >= 0;
|
public bool IsIncoming => NetSats >= 0;
|
||||||
/// <summary>Importo verso destinatari esterni (output non nostri): l'importo "inviato".</summary>
|
/// <summary>Amount sent to external recipients (outputs not ours): the "sent" amount.</summary>
|
||||||
public long SentToOthersSats => Outputs.Where(o => !o.IsMine).Sum(o => o.AmountSats);
|
public long SentToOthersSats => Outputs.Where(o => !o.IsMine).Sum(o => o.AmountSats);
|
||||||
public long ReceivedSats => Outputs.Where(o => o.IsMine).Sum(o => o.AmountSats);
|
public long ReceivedSats => Outputs.Where(o => o.IsMine).Sum(o => o.AmountSats);
|
||||||
public double? FeeRateSatPerVb => FeeSats is { } f && VirtualSize > 0 ? (double)f / VirtualSize : null;
|
public double? FeeRateSatPerVb => FeeSats is { } f && VirtualSize > 0 ? (double)f / VirtualSize : null;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Indirizzi della controparte: i destinatari esterni per un invio (output non
|
/// Counterparty addresses: external recipients for a send (outputs not ours),
|
||||||
/// nostri), i mittenti esterni per una ricezione (input non nostri).
|
/// external senders for a receive (inputs not ours).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<string> CounterpartyAddresses => IsIncoming
|
public IReadOnlyList<string> CounterpartyAddresses => IsIncoming
|
||||||
? [.. Inputs.Where(i => !i.IsMine && i.Address is not null).Select(i => i.Address!).Distinct()]
|
? [.. Inputs.Where(i => !i.IsMine && i.Address is not null).Select(i => i.Address!).Distinct()]
|
||||||
@@ -57,9 +57,9 @@ public sealed class TransactionDetails
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recupera dal server tutti i dati di una singola transazione (blueprint §10):
|
/// Fetches all data for a single transaction from the server (blueprint §10):
|
||||||
/// la transazione grezza e gli output spesi dai suoi input, per ricostruire
|
/// the raw transaction and the outputs spent by its inputs, to reconstruct
|
||||||
/// importi, fee e indirizzi che il server non riassume.
|
/// amounts, fees, and addresses that the server does not summarise.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class TransactionInspector
|
public static class TransactionInspector
|
||||||
{
|
{
|
||||||
@@ -112,10 +112,10 @@ public static class TransactionInspector
|
|||||||
|
|
||||||
var rbf = tx.Inputs.Any(i => i.Sequence.IsRBF);
|
var rbf = tx.Inputs.Any(i => i.Sequence.IsRBF);
|
||||||
|
|
||||||
// Le transazioni degli input servono per importi/indirizzi/fee. Si
|
// Input transactions are needed for amounts/addresses/fees. Fetched in
|
||||||
// scaricano in parallelo (id univoci, richieste concorrenti supportate
|
// parallel (unique ids, concurrent requests supported by ElectrumClient):
|
||||||
// da ElectrumClient): in sequenza la finestra impiegava un round-trip
|
// sequential fetching costs one round-trip per input. The block header
|
||||||
// per input. Anche l'header del blocco è recuperato in parallelo.
|
// is also fetched in parallel.
|
||||||
var prevTxids = tx.IsCoinBase
|
var prevTxids = tx.IsCoinBase
|
||||||
? []
|
? []
|
||||||
: tx.Inputs.Select(i => i.PrevOut.Hash.ToString()).Distinct().ToList();
|
: tx.Inputs.Select(i => i.PrevOut.Hash.ToString()).Distinct().ToList();
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ using PalladiumWallet.Core.Storage;
|
|||||||
namespace PalladiumWallet.Core.Wallet;
|
namespace PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Factory dei tipi di wallet (blueprint §4.5): dal documento ricostruisce il tipo
|
/// Wallet type factory (blueprint §4.5): reconstructs the correct account type
|
||||||
/// di account corretto (HD da seed, HD da xprv importata, importato WIF, watch-only).
|
/// from a document (HD from seed, HD from imported xprv, imported WIF, watch-only).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class WalletLoader
|
public static class WalletLoader
|
||||||
{
|
{
|
||||||
@@ -20,25 +20,25 @@ public static class WalletLoader
|
|||||||
var kind = Enum.Parse<ScriptKind>(doc.ScriptKind);
|
var kind = Enum.Parse<ScriptKind>(doc.ScriptKind);
|
||||||
var network = PalladiumNetworks.For(profile.Kind);
|
var network = PalladiumNetworks.For(profile.Kind);
|
||||||
|
|
||||||
// 1. HD da seed (caso più comune)
|
// 1. HD from seed (most common case)
|
||||||
if (doc.Mnemonic is { } words)
|
if (doc.Mnemonic is { } words)
|
||||||
{
|
{
|
||||||
if (!Bip39.TryParse(words, out var mnemonic))
|
if (!Bip39.TryParse(words, out var mnemonic))
|
||||||
throw new InvalidDataException("Mnemonica del file wallet non valida.");
|
throw new InvalidDataException("Invalid mnemonic in wallet file.");
|
||||||
var path = KeyPath.Parse(doc.AccountPath ?? DerivationPaths.AccountPath(kind, profile).ToString());
|
var path = KeyPath.Parse(doc.AccountPath ?? DerivationPaths.AccountPath(kind, profile).ToString());
|
||||||
return HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, doc.Passphrase), kind, profile, path);
|
return HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, doc.Passphrase), kind, profile, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. HD da xprv importata (spendibile, senza seed)
|
// 2. HD from imported xprv (spendable, no seed)
|
||||||
if (doc.AccountXprv is { } xprvStr)
|
if (doc.AccountXprv is { } xprvStr)
|
||||||
{
|
{
|
||||||
if (!Slip132.TryDecodePrivate(xprvStr, profile, out var xprv, out _))
|
if (!Slip132.TryDecodePrivate(xprvStr, profile, out var xprv, out _))
|
||||||
throw new InvalidDataException("Xprv del file wallet non valida per questa rete.");
|
throw new InvalidDataException("Xprv in wallet file is invalid for this network.");
|
||||||
var path = doc.AccountPath is { Length: > 0 } p ? KeyPath.Parse(p) : null;
|
var path = doc.AccountPath is { Length: > 0 } p ? KeyPath.Parse(p) : null;
|
||||||
return HdAccount.FromAccountXprv(xprv!, kind, profile, path);
|
return HdAccount.FromAccountXprv(xprv!, kind, profile, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Chiavi WIF importate
|
// 3. Imported WIF keys
|
||||||
if (doc.WifKeys is { Count: > 0 } wifKeys)
|
if (doc.WifKeys is { Count: > 0 } wifKeys)
|
||||||
{
|
{
|
||||||
var entries = wifKeys.Select(wif =>
|
var entries = wifKeys.Select(wif =>
|
||||||
@@ -51,21 +51,21 @@ public static class WalletLoader
|
|||||||
return new ImportedKeyAccount(entries, kind, profile);
|
return new ImportedKeyAccount(entries, kind, profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Watch-only da xpub
|
// 4. Watch-only from xpub
|
||||||
if (doc.AccountXpub is null)
|
if (doc.AccountXpub is null)
|
||||||
throw new InvalidDataException("File wallet senza xpub e senza seed.");
|
throw new InvalidDataException("Wallet file has no xpub and no seed.");
|
||||||
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
||||||
throw new InvalidDataException("Xpub del file wallet non valida per questa rete.");
|
throw new InvalidDataException("Xpub in wallet file is invalid for this network.");
|
||||||
var accountPath = doc.AccountPath is { Length: > 0 } ap ? KeyPath.Parse(ap) : null;
|
var accountPath = doc.AccountPath is { Length: > 0 } ap ? KeyPath.Parse(ap) : null;
|
||||||
return HdAccount.FromAccountXpub(xpub!, kind, profile, accountPath);
|
return HdAccount.FromAccountXpub(xpub!, kind, profile, accountPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Crea il documento per un nuovo wallet da seed.</summary>
|
/// <summary>Creates the document for a new seed wallet.</summary>
|
||||||
public static (WalletDocument Doc, HdAccount Account) NewFromMnemonic(
|
public static (WalletDocument Doc, HdAccount Account) NewFromMnemonic(
|
||||||
string words, string? passphrase, ScriptKind kind, ChainProfile profile, KeyPath? customPath = null)
|
string words, string? passphrase, ScriptKind kind, ChainProfile profile, KeyPath? customPath = null)
|
||||||
{
|
{
|
||||||
if (!Bip39.TryParse(words, out var mnemonic))
|
if (!Bip39.TryParse(words, out var mnemonic))
|
||||||
throw new InvalidDataException("Mnemonica non valida (parole o checksum errati).");
|
throw new InvalidDataException("Invalid mnemonic (wrong words or checksum).");
|
||||||
|
|
||||||
var account = customPath is null
|
var account = customPath is null
|
||||||
? HdAccount.FromMnemonic(mnemonic!, passphrase, kind, profile)
|
? HdAccount.FromMnemonic(mnemonic!, passphrase, kind, profile)
|
||||||
@@ -85,15 +85,15 @@ public static class WalletLoader
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Crea il documento da una xpub SLIP-132 importata (watch-only).
|
/// Creates the document from an imported SLIP-132 xpub (watch-only).
|
||||||
/// Rileva il ScriptKind dagli header SLIP-132; <paramref name="kindOverride"/> lo sovrascrive
|
/// Detects the ScriptKind from the SLIP-132 header; <paramref name="kindOverride"/> overrides
|
||||||
/// per i prefissi ambigui (xpub può essere Legacy o Taproot).
|
/// it for ambiguous prefixes (xpub can be Legacy or Taproot).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static (WalletDocument Doc, HdAccount Account) NewFromXpub(
|
public static (WalletDocument Doc, HdAccount Account) NewFromXpub(
|
||||||
string slip132, ChainProfile profile, ScriptKind? kindOverride = null)
|
string slip132, ChainProfile profile, ScriptKind? kindOverride = null)
|
||||||
{
|
{
|
||||||
if (!Slip132.TryDecodePublic(slip132.Trim(), profile, out var xpub, out var detectedKind))
|
if (!Slip132.TryDecodePublic(slip132.Trim(), profile, out var xpub, out var detectedKind))
|
||||||
throw new InvalidDataException("Chiave pubblica estesa non valida o non riconosciuta per questa rete.");
|
throw new InvalidDataException("Extended public key is invalid or unrecognised for this network.");
|
||||||
var kind = kindOverride ?? detectedKind;
|
var kind = kindOverride ?? detectedKind;
|
||||||
var account = HdAccount.FromAccountXpub(xpub!, kind, profile);
|
var account = HdAccount.FromAccountXpub(xpub!, kind, profile);
|
||||||
|
|
||||||
@@ -108,14 +108,14 @@ public static class WalletLoader
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Crea il documento da una xprv SLIP-132 importata (spendibile senza seed).
|
/// Creates the document from an imported SLIP-132 xprv (spendable without seed).
|
||||||
/// Il documento contiene la xprv in chiaro: deve essere obbligatoriamente cifrato.
|
/// The document contains the xprv in plaintext — it must be encrypted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static (WalletDocument Doc, HdAccount Account) NewFromXprv(
|
public static (WalletDocument Doc, HdAccount Account) NewFromXprv(
|
||||||
string slip132, ChainProfile profile, ScriptKind? kindOverride = null)
|
string slip132, ChainProfile profile, ScriptKind? kindOverride = null)
|
||||||
{
|
{
|
||||||
if (!Slip132.TryDecodePrivate(slip132.Trim(), profile, out var xprv, out var detectedKind))
|
if (!Slip132.TryDecodePrivate(slip132.Trim(), profile, out var xprv, out var detectedKind))
|
||||||
throw new InvalidDataException("Chiave privata estesa non valida o non riconosciuta per questa rete.");
|
throw new InvalidDataException("Extended private key is invalid or unrecognised for this network.");
|
||||||
var kind = kindOverride ?? detectedKind;
|
var kind = kindOverride ?? detectedKind;
|
||||||
var account = HdAccount.FromAccountXprv(xprv!, kind, profile);
|
var account = HdAccount.FromAccountXprv(xprv!, kind, profile);
|
||||||
|
|
||||||
@@ -131,14 +131,14 @@ public static class WalletLoader
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Crea il documento da una o più chiavi WIF importate.
|
/// Creates the document from one or more imported WIF keys.
|
||||||
/// Il documento contiene le chiavi WIF in chiaro: deve essere obbligatoriamente cifrato.
|
/// The document contains the WIF keys in plaintext — it must be encrypted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromWif(
|
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromWif(
|
||||||
IReadOnlyList<string> wifKeys, ScriptKind kind, ChainProfile profile)
|
IReadOnlyList<string> wifKeys, ScriptKind kind, ChainProfile profile)
|
||||||
{
|
{
|
||||||
if (wifKeys.Count == 0)
|
if (wifKeys.Count == 0)
|
||||||
throw new InvalidDataException("Almeno una chiave WIF richiesta.");
|
throw new InvalidDataException("At least one WIF key is required.");
|
||||||
|
|
||||||
var network = PalladiumNetworks.For(profile.Kind);
|
var network = PalladiumNetworks.For(profile.Kind);
|
||||||
var entries = new List<(BitcoinAddress, Key?)>();
|
var entries = new List<(BitcoinAddress, Key?)>();
|
||||||
@@ -153,7 +153,7 @@ public static class WalletLoader
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
throw new InvalidDataException($"Chiave WIF non valida: {ex.Message}");
|
throw new InvalidDataException($"Invalid WIF key: {ex.Message}");
|
||||||
}
|
}
|
||||||
var addr = secret.PrivateKey.PubKey
|
var addr = secret.PrivateKey.PubKey
|
||||||
.GetAddress(DerivationPaths.ScriptPubKeyTypeFor(kind), network);
|
.GetAddress(DerivationPaths.ScriptPubKeyTypeFor(kind), network);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class ChainProfileTests
|
|||||||
Assert.Equal(50002, p.DefaultSslPort);
|
Assert.Equal(50002, p.DefaultSslPort);
|
||||||
Assert.Equal(746, p.Bip44CoinType);
|
Assert.Equal(746, p.Bip44CoinType);
|
||||||
Assert.Equal("palladium", p.UriScheme);
|
Assert.Equal("palladium", p.UriScheme);
|
||||||
Assert.True(p.SkipPowValidation); // obbligatorio: la catena usa LWMA (§3)
|
Assert.True(p.SkipPowValidation); // mandatory: the chain uses LWMA (§3)
|
||||||
Assert.Equal(120, p.BlockTimeSeconds);
|
Assert.Equal(120, p.BlockTimeSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +105,8 @@ public class ChainProfileTests
|
|||||||
Assert.Equal(1, ChainProfiles.Testnet.Bip44CoinType);
|
Assert.Equal(1, ChainProfiles.Testnet.Bip44CoinType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serializza header (4 byte BE) + payload BIP32 di 74 byte e codifica Base58Check:
|
// Serialize header (4-byte BE) + 74-byte BIP32 payload and encode Base58Check:
|
||||||
// il prefisso testuale risultante dipende solo dall'header.
|
// the resulting textual prefix depends only on the header.
|
||||||
private static string EncodeWithHeader(uint header)
|
private static string EncodeWithHeader(uint header)
|
||||||
{
|
{
|
||||||
var data = new byte[78];
|
var data = new byte[78];
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace PalladiumWallet.Tests.Chain;
|
|||||||
|
|
||||||
public class PalladiumNetworksTests
|
public class PalladiumNetworksTests
|
||||||
{
|
{
|
||||||
// Chiave privata fissa per test deterministici (solo test, mai usarla davvero).
|
// Fixed private key for deterministic tests (test-only, never use it for real).
|
||||||
private static Key TestKey => new(Convert.FromHexString(
|
private static Key TestKey => new(Convert.FromHexString(
|
||||||
"0000000000000000000000000000000000000000000000000000000000000001"));
|
"0000000000000000000000000000000000000000000000000000000000000001"));
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public class AddressDerivationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Il_vettore_bip44_produce_lo_stesso_hash_su_bitcoin_e_plm()
|
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).
|
// Known abandon-about address at m/44'/0'/0'/0/0 (public reference).
|
||||||
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
|
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
|
||||||
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
|
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
|
||||||
var pubKey = account.GetPublicKey(isChange: false, 0);
|
var pubKey = account.GetPublicKey(isChange: false, 0);
|
||||||
@@ -27,8 +27,8 @@ public class AddressDerivationTests
|
|||||||
|
|
||||||
var plmAddr = account.GetReceiveAddress(0);
|
var plmAddr = account.GetReceiveAddress(0);
|
||||||
Assert.StartsWith("P", plmAddr.ToString());
|
Assert.StartsWith("P", plmAddr.ToString());
|
||||||
// Stesso hash160 sotto i due prefissi: la derivazione è identica,
|
// Same hash160 under the two prefixes: the derivation is identical,
|
||||||
// cambia solo la veste di rete.
|
// only the network dress changes.
|
||||||
Assert.Equal(pubKey.Hash, ((BitcoinPubKeyAddress)plmAddr).Hash);
|
Assert.Equal(pubKey.Hash, ((BitcoinPubKeyAddress)plmAddr).Hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ using PalladiumWallet.Core.Crypto;
|
|||||||
namespace PalladiumWallet.Tests.Crypto;
|
namespace PalladiumWallet.Tests.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Vettore di test 1 della specifica BIP32 (seed 000102...0e0f). Gli header
|
/// BIP32 specification test vector 1 (seed 000102...0e0f). The Legacy headers
|
||||||
/// Legacy della mainnet PLM coincidono con quelli Bitcoin, quindi il confronto
|
/// of the PLM mainnet coincide with the Bitcoin ones, so the comparison
|
||||||
/// con le stringhe della specifica è diretto sulla rete PLM.
|
/// with the specification strings is direct on the PLM network.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Bip32Tests
|
public class Bip32Tests
|
||||||
{
|
{
|
||||||
@@ -40,7 +40,7 @@ public class Bip32Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void I_path_hardened_non_sono_derivabili_da_una_xpub()
|
public void I_path_hardened_non_sono_derivabili_da_una_xpub()
|
||||||
{
|
{
|
||||||
// Garanzia §17: da sole chiavi pubbliche niente derivazione hardened.
|
// §17 guarantee: from public keys alone, no hardened derivation.
|
||||||
Assert.ThrowsAny<InvalidOperationException>(() =>
|
Assert.ThrowsAny<InvalidOperationException>(() =>
|
||||||
Root.Neuter().Derive(new KeyPath("0'")));
|
Root.Neuter().Derive(new KeyPath("0'")));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace PalladiumWallet.Tests.Crypto;
|
|||||||
|
|
||||||
public class Bip39Tests
|
public class Bip39Tests
|
||||||
{
|
{
|
||||||
// Vettori ufficiali Trezor (python-mnemonic/vectors.json), passphrase "TREZOR".
|
// Official Trezor vectors (python-mnemonic/vectors.json), passphrase "TREZOR".
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(
|
[InlineData(
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
@@ -30,8 +30,8 @@ public class Bip39Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Il_vettore_giapponese_copre_la_normalizzazione_nfkd()
|
public void Il_vettore_giapponese_copre_la_normalizzazione_nfkd()
|
||||||
{
|
{
|
||||||
// Vettore ufficiale bip32JP (test_JP_BIP39.json[0]): mnemonica con spazi
|
// Official bip32JP vector (test_JP_BIP39.json[0]): mnemonic with U+3000
|
||||||
// ideografici U+3000 e passphrase con caratteri da normalizzare NFKD.
|
// ideographic spaces and a passphrase with characters to normalize NFKD.
|
||||||
const string words =
|
const string words =
|
||||||
"あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あおぞら";
|
"あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あおぞら";
|
||||||
const string passphrase = "㍍ガバヴァぱばぐゞちぢ十人十色";
|
const string passphrase = "㍍ガバヴァぱばぐゞちぢ十人十色";
|
||||||
@@ -55,8 +55,8 @@ public class Bip39Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Checksum_invalido_viene_rifiutato()
|
public void Checksum_invalido_viene_rifiutato()
|
||||||
{
|
{
|
||||||
// 12 × "abandon": parole valide ma checksum errato — il caso che il
|
// 12 × "abandon": valid words but wrong checksum — the case that the
|
||||||
// costruttore NBitcoin non controlla da solo.
|
// NBitcoin constructor does not check on its own.
|
||||||
var words = string.Join(' ', Enumerable.Repeat("abandon", 12));
|
var words = string.Join(' ', Enumerable.Repeat("abandon", 12));
|
||||||
Assert.False(Bip39.TryParse(words, out _));
|
Assert.False(Bip39.TryParse(words, out _));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ using PalladiumWallet.Core.Crypto;
|
|||||||
namespace PalladiumWallet.Tests.Crypto;
|
namespace PalladiumWallet.Tests.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Vettore di test della specifica BIP84 (mnemonica abandon-about, senza
|
/// BIP84 specification test vector (abandon-about mnemonic, without
|
||||||
/// passphrase). Gli header SLIP-132 della mainnet PLM coincidono con quelli
|
/// passphrase). The SLIP-132 headers of the PLM mainnet coincide with the
|
||||||
/// Bitcoin, quindi zprv/zpub si confrontano direttamente; gli indirizzi si
|
/// Bitcoin ones, so zprv/zpub are compared directly; addresses are
|
||||||
/// confrontano sul witness program (chain-independent) + prefisso PLM.
|
/// compared on the witness program (chain-independent) + PLM prefix.
|
||||||
/// Il path m/84'/0'/0' è volutamente "personalizzato" (coin type 0, non 746):
|
/// The path m/84'/0'/0' is deliberately "customized" (coin type 0, not 746):
|
||||||
/// esercita anche l'import con path custom (§4.2).
|
/// it also exercises the import with a custom path (§4.2).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Bip84Slip132Tests
|
public class Bip84Slip132Tests
|
||||||
{
|
{
|
||||||
@@ -64,12 +64,12 @@ public class Bip84Slip132Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Una_xkey_con_header_sconosciuto_viene_rifiutata()
|
public void Una_xkey_con_header_sconosciuto_viene_rifiutata()
|
||||||
{
|
{
|
||||||
// xpub Bitcoin valida ma con header Legacy: non è una zpub.
|
// Valid Bitcoin xpub but with Legacy header: it is not a zpub.
|
||||||
var account = Account();
|
var account = Account();
|
||||||
var asXpub = account.AccountXpub.ToString(Network.Main);
|
var asXpub = account.AccountXpub.ToString(Network.Main);
|
||||||
|
|
||||||
Assert.True(Slip132.TryDecodePublic(asXpub, ChainProfiles.Mainnet, out _, out var kind));
|
Assert.True(Slip132.TryDecodePublic(asXpub, ChainProfiles.Mainnet, out _, out var kind));
|
||||||
Assert.Equal(ScriptKind.Legacy, kind); // header xpub → riconosciuto come Legacy
|
Assert.Equal(ScriptKind.Legacy, kind); // xpub header → recognized as Legacy
|
||||||
Assert.False(Slip132.TryDecodePublic("non-base58!!!", ChainProfiles.Mainnet, out _, out _));
|
Assert.False(Slip132.TryDecodePublic("non-base58!!!", ChainProfiles.Mainnet, out _, out _));
|
||||||
Assert.False(Slip132.TryDecodePrivate(asXpub, ChainProfiles.Mainnet, out _, out _)); // pub ≠ priv
|
Assert.False(Slip132.TryDecodePrivate(asXpub, ChainProfiles.Mainnet, out _, out _)); // pub ≠ priv
|
||||||
}
|
}
|
||||||
@@ -88,8 +88,8 @@ public class Bip84Slip132Tests
|
|||||||
if (expectedPubKeyHex is not null)
|
if (expectedPubKeyHex is not null)
|
||||||
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
|
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
|
||||||
|
|
||||||
// Il witness program (hash160 della pubkey) è chain-independent: deve
|
// The witness program (hash160 of the pubkey) is chain-independent: it must
|
||||||
// coincidere con quello dell'indirizzo bc1 del vettore ufficiale.
|
// coincide with that of the bc1 address from the official vector.
|
||||||
var bitcoinExpected = (BitcoinWitPubKeyAddress)BitcoinAddress.Create(bitcoinAddress, Network.Main);
|
var bitcoinExpected = (BitcoinWitPubKeyAddress)BitcoinAddress.Create(bitcoinAddress, Network.Main);
|
||||||
Assert.Equal(bitcoinExpected.Hash, pubKey.WitHash);
|
Assert.Equal(bitcoinExpected.Hash, pubKey.WitHash);
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ using PalladiumWallet.Core.Crypto;
|
|||||||
namespace PalladiumWallet.Tests.Crypto;
|
namespace PalladiumWallet.Tests.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Vettori di test BIP86 (mnemonica abandon-about, senza passphrase).
|
/// BIP86 test vectors (abandon-about mnemonic, no passphrase).
|
||||||
/// La chiave pubblica tweakizzata (output key, 32 byte x-only) è chain-independent:
|
/// The tweaked public key (output key, 32-byte x-only) is chain-independent:
|
||||||
/// viene verificata contro i vettori ufficiali Bitcoin, poi si controlla che
|
/// verified against the official Bitcoin vectors, then checked that the PLM
|
||||||
/// l'indirizzo PLM abbia il prefisso plm1p (witness v1, bech32m).
|
/// address starts with plm1p (witness v1, bech32m).
|
||||||
/// Il path m/86'/0'/0' usa coin_type=0 (non 746) per aderire ai vettori BIP86.
|
/// The path m/86'/0'/0' uses coin_type=0 (not 746) to match the BIP86 vectors.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Bip86TaprootTests
|
public class Bip86TaprootTests
|
||||||
{
|
{
|
||||||
@@ -46,9 +46,9 @@ public class Bip86TaprootTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// L'output key (chiave tweakizzata x-only, 32 byte) è identica al vettore BIP86
|
/// The output key (tweaked x-only pubkey, 32 bytes) matches the BIP86 vector
|
||||||
/// indipendentemente dalla rete: la rete cambia solo HRP e checksum, non il programma.
|
/// regardless of network: the network changes only HRP and checksum, not the program.
|
||||||
/// Indirizzi Bitcoin da https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki
|
/// Bitcoin addresses from https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(false, 0, "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr")]
|
[InlineData(false, 0, "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr")]
|
||||||
@@ -60,7 +60,7 @@ public class Bip86TaprootTests
|
|||||||
var account = Account();
|
var account = Account();
|
||||||
var plmAddr = account.GetAddress(isChange, index);
|
var plmAddr = account.GetAddress(isChange, index);
|
||||||
|
|
||||||
// Il witness program (output key tweakizzata) è chain-independent
|
// The witness program (tweaked output key) is chain-independent.
|
||||||
var btcAddr = (TaprootAddress)BitcoinAddress.Create(bitcoinAddress, Network.Main);
|
var btcAddr = (TaprootAddress)BitcoinAddress.Create(bitcoinAddress, Network.Main);
|
||||||
var plmTaproot = (TaprootAddress)plmAddr;
|
var plmTaproot = (TaprootAddress)plmAddr;
|
||||||
Assert.Equal(btcAddr.PubKey, plmTaproot.PubKey);
|
Assert.Equal(btcAddr.PubKey, plmTaproot.PubKey);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public class PeerParsingTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void La_risposta_peers_subscribe_si_parsa_nel_formato_electrumx()
|
public void La_risposta_peers_subscribe_si_parsa_nel_formato_electrumx()
|
||||||
{
|
{
|
||||||
// Formato reale: [ip, hostname, ["v...", "pN", "tPORTA", "sPORTA"]].
|
// Real format: [ip, hostname, ["v...", "pN", "tPORT", "sPORT"]].
|
||||||
const string json = """
|
const string json = """
|
||||||
[
|
[
|
||||||
["173.212.224.67", "173.212.224.67", ["v1.4.2", "p10000", "t50001", "s50002"]],
|
["173.212.224.67", "173.212.224.67", ["v1.4.2", "p10000", "t50001", "s50002"]],
|
||||||
@@ -20,10 +20,10 @@ public class PeerParsingTests
|
|||||||
""";
|
""";
|
||||||
var peers = ElectrumApi.ParsePeers(JsonDocument.Parse(json).RootElement);
|
var peers = ElectrumApi.ParsePeers(JsonDocument.Parse(json).RootElement);
|
||||||
|
|
||||||
Assert.Equal(3, peers.Count); // l'ultimo non offre porte → scartato
|
Assert.Equal(3, peers.Count); // last entry has no ports → discarded
|
||||||
|
|
||||||
Assert.Equal(new PeerInfo("173.212.224.67", 50001, 50002, "1.4.2"), peers[0]);
|
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").
|
// "t" without a number = default port (0 signals "resolve from profile").
|
||||||
Assert.Equal(new PeerInfo("nodo.esempio.org", 0, null, "1.4"), peers[1]);
|
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]);
|
Assert.Equal(new PeerInfo("solo-ssl.esempio.org", null, 50002, "1.4"), peers[2]);
|
||||||
}
|
}
|
||||||
@@ -55,8 +55,8 @@ public class ServerRegistryTests
|
|||||||
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
|
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
|
||||||
var bootstrapCount = registry.All.Count;
|
var bootstrapCount = registry.All.Count;
|
||||||
|
|
||||||
// Merge diretto via DiscoverAsync richiede un client: si testa la
|
// Direct merge via DiscoverAsync requires a client: test persistence
|
||||||
// persistenza simulando il file degli scoperti.
|
// by simulating the discovered servers file.
|
||||||
var discovered = new[] { new KnownServer("nuovo.esempio.org", 50001, 50002, "1.4.2") };
|
var discovered = new[] { new KnownServer("nuovo.esempio.org", 50001, 50002, "1.4.2") };
|
||||||
File.WriteAllText(path, JsonSerializer.Serialize(discovered));
|
File.WriteAllText(path, JsonSerializer.Serialize(discovered));
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ public class ServerRegistryTests
|
|||||||
Assert.Equal(bootstrapCount + 1, reloaded.All.Count);
|
Assert.Equal(bootstrapCount + 1, reloaded.All.Count);
|
||||||
Assert.Contains(reloaded.All, s => s.Host == "nuovo.esempio.org");
|
Assert.Contains(reloaded.All, s => s.Host == "nuovo.esempio.org");
|
||||||
|
|
||||||
// Un bootstrap duplicato nel file non raddoppia.
|
// A bootstrap duplicate in the file must not double-count.
|
||||||
File.WriteAllText(path, JsonSerializer.Serialize(new[]
|
File.WriteAllText(path, JsonSerializer.Serialize(new[]
|
||||||
{
|
{
|
||||||
new KnownServer("173.212.224.67", 50001, 50002),
|
new KnownServer("173.212.224.67", 50001, 50002),
|
||||||
|
|||||||
@@ -9,18 +9,18 @@ using PalladiumWallet.Core.Wallet;
|
|||||||
namespace PalladiumWallet.Tests;
|
namespace PalladiumWallet.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Property-based tests (CsCheck). Ogni test genera centinaia di input casuali e
|
/// Property-based tests (CsCheck). Each test generates hundreds of random inputs and
|
||||||
/// verifica che le proprietà invarianti reggano — crash, eccezioni non attese, o
|
/// verifies that invariant properties hold — crashes, unexpected exceptions, or
|
||||||
/// violazioni di roundtrip sono failures.
|
/// roundtrip violations are failures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PropertyTests
|
public class PropertyTests
|
||||||
{
|
{
|
||||||
// ── generatori riutilizzabili ─────────────────────────────────────────────
|
// ── reusable generators ───────────────────────────────────────────────────
|
||||||
|
|
||||||
private static readonly Gen<string> GenUnit = Gen.OneOf(
|
private static readonly Gen<string> GenUnit = Gen.OneOf(
|
||||||
Gen.Const("PLM"), Gen.Const("mPLM"), Gen.Const("µPLM"), Gen.Const("sat"));
|
Gen.Const("PLM"), Gen.Const("mPLM"), Gen.Const("µPLM"), Gen.Const("sat"));
|
||||||
|
|
||||||
// uint256 casuale costruito da 4 ulong
|
// random uint256 built from 4 ulongs
|
||||||
private static readonly Gen<uint256> GenTxid =
|
private static readonly Gen<uint256> GenTxid =
|
||||||
Gen.Select(Gen.ULong, Gen.ULong, Gen.ULong, Gen.ULong, (a, b, c, d) =>
|
Gen.Select(Gen.ULong, Gen.ULong, Gen.ULong, Gen.ULong, (a, b, c, d) =>
|
||||||
{
|
{
|
||||||
@@ -32,9 +32,9 @@ public class PropertyTests
|
|||||||
return new uint256(bytes);
|
return new uint256(bytes);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── CoinAmount ──────────────────────────────────────────────────────────
|
// ── CoinAmount ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// TryParseIn non deve mai lanciare eccezioni su input arbitrario con unità valide.
|
/// TryParseIn must never throw on arbitrary input with valid units.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CoinAmount_TryParseIn_non_lancia_mai_su_input_arbitrario()
|
public void CoinAmount_TryParseIn_non_lancia_mai_su_input_arbitrario()
|
||||||
{
|
{
|
||||||
@@ -46,17 +46,17 @@ public class PropertyTests
|
|||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (ArgumentException)
|
||||||
{
|
{
|
||||||
// unità sconosciuta: impossibile qui perché usiamo solo unità note
|
// unknown unit: impossible here because we only use known units
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Assert.Fail($"TryParseIn ha lanciato {ex.GetType().Name} per unit={unit}");
|
Assert.Fail($"TryParseIn threw {ex.GetType().Name} for unit={unit}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// FormatIn → TryParseIn: qualsiasi satoshi [0, MaxSupply] deve fare roundtrip esatto.
|
/// FormatIn → TryParseIn: any satoshi value in [0, MaxSupply] must roundtrip exactly.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CoinAmount_roundtrip_FormatIn_TryParseIn_per_ogni_unita()
|
public void CoinAmount_roundtrip_FormatIn_TryParseIn_per_ogni_unita()
|
||||||
{
|
{
|
||||||
@@ -66,12 +66,12 @@ public class PropertyTests
|
|||||||
var formatted = CoinAmount.FormatIn(sats, unit, withLabel: false);
|
var formatted = CoinAmount.FormatIn(sats, unit, withLabel: false);
|
||||||
Assert.True(
|
Assert.True(
|
||||||
CoinAmount.TryParseIn(formatted, unit, out var parsed),
|
CoinAmount.TryParseIn(formatted, unit, out var parsed),
|
||||||
$"FormatIn={formatted} unit={unit} non si riparsa");
|
$"FormatIn={formatted} unit={unit} failed to re-parse");
|
||||||
Assert.Equal(sats, parsed);
|
Assert.Equal(sats, parsed);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TryParseCoins non deve mai lanciare su input arbitrario.
|
/// TryParseCoins must never throw on arbitrary input.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CoinAmount_TryParseCoins_non_lancia_mai_su_input_arbitrario()
|
public void CoinAmount_TryParseCoins_non_lancia_mai_su_input_arbitrario()
|
||||||
{
|
{
|
||||||
@@ -83,25 +83,25 @@ public class PropertyTests
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Assert.Fail($"TryParseCoins ha lanciato {ex.GetType().Name}");
|
Assert.Fail($"TryParseCoins threw {ex.GetType().Name}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Qualsiasi valore accettato da TryParseCoins deve essere ≥ 0.
|
/// Any value accepted by TryParseCoins must be ≥ 0.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CoinAmount_TryParseCoins_accetta_solo_valori_non_negativi()
|
public void CoinAmount_TryParseCoins_accetta_solo_valori_non_negativi()
|
||||||
{
|
{
|
||||||
Gen.String.Sample(text =>
|
Gen.String.Sample(text =>
|
||||||
{
|
{
|
||||||
if (CoinAmount.TryParseCoins(text, out var sats))
|
if (CoinAmount.TryParseCoins(text, out var sats))
|
||||||
Assert.True(sats >= 0, $"TryParseCoins ha restituito {sats} per '{text}'");
|
Assert.True(sats >= 0, $"TryParseCoins returned {sats} for '{text}'");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── EncryptedFile ────────────────────────────────────────────────────────
|
// ── EncryptedFile ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Encrypt → Decrypt con la stessa password deve restituire il testo originale.
|
/// Encrypt → Decrypt with the same password must return the original plaintext.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EncryptedFile_roundtrip_su_contenuto_e_password_arbitrari()
|
public void EncryptedFile_roundtrip_su_contenuto_e_password_arbitrari()
|
||||||
{
|
{
|
||||||
@@ -113,29 +113,29 @@ public class PropertyTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt con password sbagliata deve lanciare WrongPasswordException, mai altro.
|
/// Decrypt with the wrong password must throw WrongPasswordException, never anything else.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EncryptedFile_password_sbagliata_lancia_solo_WrongPasswordException()
|
public void EncryptedFile_password_sbagliata_lancia_solo_WrongPasswordException()
|
||||||
{
|
{
|
||||||
Gen.Select(Gen.String, Gen.String[1, 32], Gen.String[1, 32]).Sample((plaintext, pwd1, pwd2) =>
|
Gen.Select(Gen.String, Gen.String[1, 32], Gen.String[1, 32]).Sample((plaintext, pwd1, pwd2) =>
|
||||||
{
|
{
|
||||||
if (pwd1 == pwd2) return; // stessa password: roundtrip valido, salta
|
if (pwd1 == pwd2) return; // same password: valid roundtrip, skip
|
||||||
|
|
||||||
var cipher = EncryptedFile.Encrypt(plaintext, pwd1);
|
var cipher = EncryptedFile.Encrypt(plaintext, pwd1);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
EncryptedFile.Decrypt(cipher, pwd2);
|
EncryptedFile.Decrypt(cipher, pwd2);
|
||||||
Assert.Fail("Decrypt con password sbagliata non ha lanciato");
|
Assert.Fail("Decrypt with wrong password did not throw");
|
||||||
}
|
}
|
||||||
catch (WrongPasswordException) { /* atteso */ }
|
catch (WrongPasswordException) { /* expected */ }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Assert.Fail($"Decrypt ha lanciato {ex.GetType().Name} invece di WrongPasswordException");
|
Assert.Fail($"Decrypt threw {ex.GetType().Name} instead of WrongPasswordException");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// IsEncrypted non deve mai lanciare su input arbitrario.
|
/// IsEncrypted must never throw on arbitrary input.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EncryptedFile_IsEncrypted_non_lancia_mai()
|
public void EncryptedFile_IsEncrypted_non_lancia_mai()
|
||||||
{
|
{
|
||||||
@@ -144,14 +144,14 @@ public class PropertyTests
|
|||||||
try { EncryptedFile.IsEncrypted(s); }
|
try { EncryptedFile.IsEncrypted(s); }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Assert.Fail($"IsEncrypted ha lanciato {ex.GetType().Name}");
|
Assert.Fail($"IsEncrypted threw {ex.GetType().Name}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MerkleProof ──────────────────────────────────────────────────────────
|
// ── MerkleProof ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Ogni foglia di un albero Merkle generato casualmente deve verificare contro la radice.
|
/// Every leaf of a randomly generated Merkle tree must verify against the root.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MerkleProof_ogni_foglia_verifica_contro_la_sua_radice()
|
public void MerkleProof_ogni_foglia_verifica_contro_la_sua_radice()
|
||||||
{
|
{
|
||||||
@@ -163,18 +163,18 @@ public class PropertyTests
|
|||||||
var branch = BuildBranch(txids, pos);
|
var branch = BuildBranch(txids, pos);
|
||||||
Assert.True(
|
Assert.True(
|
||||||
MerkleProof.Verify(txids[pos], pos, branch, root),
|
MerkleProof.Verify(txids[pos], pos, branch, root),
|
||||||
$"Verify fallita per pos={pos} su {txids.Length} foglie");
|
$"Verify failed for pos={pos} over {txids.Length} leaves");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Un txid non presente nelle foglie non deve verificare (e non deve crashare).
|
/// A txid not present in the leaves must not verify (and must not crash).
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MerkleProof_txid_estraneo_non_verifica_e_non_crasha()
|
public void MerkleProof_txid_estraneo_non_verifica_e_non_crasha()
|
||||||
{
|
{
|
||||||
Gen.Select(GenTxid.Array[2, 8], GenTxid).Sample((txids, extra) =>
|
Gen.Select(GenTxid.Array[2, 8], GenTxid).Sample((txids, extra) =>
|
||||||
{
|
{
|
||||||
if (txids.Contains(extra)) return; // collisione casuale: salta
|
if (txids.Contains(extra)) return; // random collision: skip
|
||||||
|
|
||||||
var root = MerkleProof.ComputeRootFromLeaves(txids);
|
var root = MerkleProof.ComputeRootFromLeaves(txids);
|
||||||
var branch = BuildBranch(txids, 0);
|
var branch = BuildBranch(txids, 0);
|
||||||
@@ -182,7 +182,7 @@ public class PropertyTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// helper: costruisce il branch per la posizione data
|
// helper: builds the branch for the given position
|
||||||
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
||||||
{
|
{
|
||||||
var branch = new List<uint256>();
|
var branch = new List<uint256>();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace PalladiumWallet.Tests.Spv;
|
|||||||
|
|
||||||
public class ScripthashTests
|
public class ScripthashTests
|
||||||
{
|
{
|
||||||
// Vettori calcolati indipendentemente con Python (hashlib), non con NBitcoin.
|
// Vectors computed independently with Python (hashlib), not with NBitcoin.
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("76a9140102030405060708090a0b0c0d0e0f101112131488ac",
|
[InlineData("76a9140102030405060708090a0b0c0d0e0f101112131488ac",
|
||||||
"5546fc69d399ef99854c132abb060381cc159dbec67c496a6f0e0dbf12e83ae8")]
|
"5546fc69d399ef99854c132abb060381cc159dbec67c496a6f0e0dbf12e83ae8")]
|
||||||
@@ -39,7 +39,7 @@ public class ScripthashTests
|
|||||||
|
|
||||||
public class MerkleProofTests
|
public class MerkleProofTests
|
||||||
{
|
{
|
||||||
// Blocco Bitcoin 100000: 4 transazioni (pari), merkle root nota.
|
// Bitcoin block 100000: 4 transactions (even), known merkle root.
|
||||||
private static readonly uint256[] Block100000Txids =
|
private static readonly uint256[] Block100000Txids =
|
||||||
[
|
[
|
||||||
uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"),
|
uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"),
|
||||||
@@ -51,7 +51,7 @@ public class MerkleProofTests
|
|||||||
private static readonly uint256 Block100000Root =
|
private static readonly uint256 Block100000Root =
|
||||||
uint256.Parse("f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766");
|
uint256.Parse("f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766");
|
||||||
|
|
||||||
// ---- numero pari di transazioni (4 tx) ----
|
// ---- even number of transactions (4 tx) ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void La_radice_calcolata_dalle_foglie_coincide_con_quella_del_blocco()
|
public void La_radice_calcolata_dalle_foglie_coincide_con_quella_del_blocco()
|
||||||
@@ -88,13 +88,13 @@ public class MerkleProofTests
|
|||||||
Assert.True(MerkleProof.Verify(txid, 0, [], root));
|
Assert.True(MerkleProof.Verify(txid, 0, [], root));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- numero dispari di transazioni (duplicazione dell'ultimo) ----
|
// ---- odd number of transactions (last one duplicated) ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tre_tx_dispari_la_terza_viene_duplicata()
|
public void Tre_tx_dispari_la_terza_viene_duplicata()
|
||||||
{
|
{
|
||||||
// Con 3 tx: livello 1 = [SHA256d(tx0||tx1), SHA256d(tx2||tx2)]
|
// With 3 tx: level 1 = [SHA256d(tx0||tx1), SHA256d(tx2||tx2)]
|
||||||
// Verifica che la radice sia deterministica e corretta.
|
// Verify the root is deterministic and correct.
|
||||||
var txids = Block100000Txids.Take(3).ToArray();
|
var txids = Block100000Txids.Take(3).ToArray();
|
||||||
var root = MerkleProof.ComputeRootFromLeaves(txids);
|
var root = MerkleProof.ComputeRootFromLeaves(txids);
|
||||||
var branch2 = BuildBranch(txids, 2);
|
var branch2 = BuildBranch(txids, 2);
|
||||||
@@ -104,7 +104,7 @@ public class MerkleProofTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Cinque_tx_dispari_verifica_tutte_le_posizioni()
|
public void Cinque_tx_dispari_verifica_tutte_le_posizioni()
|
||||||
{
|
{
|
||||||
// 5 tx → livello pari (4) → livello pari (2) → radice
|
// 5 tx → even level (4) → even level (2) → root
|
||||||
var txids = new uint256[5];
|
var txids = new uint256[5];
|
||||||
for (var i = 0; i < 5; i++)
|
for (var i = 0; i < 5; i++)
|
||||||
txids[i] = new uint256(new byte[32].Select((_, j) => (byte)(i * 17 + j)).ToArray());
|
txids[i] = new uint256(new byte[32].Select((_, j) => (byte)(i * 17 + j)).ToArray());
|
||||||
@@ -118,7 +118,7 @@ public class MerkleProofTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- due transazioni (pari minimo) ----
|
// ---- two transactions (minimum even) ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Due_tx_verifica_entrambe_le_posizioni()
|
public void Due_tx_verifica_entrambe_le_posizioni()
|
||||||
@@ -132,7 +132,7 @@ public class MerkleProofTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- proof errate ----
|
// ---- invalid proofs ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Una_prova_per_la_posizione_sbagliata_fallisce()
|
public void Una_prova_per_la_posizione_sbagliata_fallisce()
|
||||||
@@ -152,7 +152,7 @@ public class MerkleProofTests
|
|||||||
public void Branch_alterato_non_verifica()
|
public void Branch_alterato_non_verifica()
|
||||||
{
|
{
|
||||||
var branch = BuildBranch(Block100000Txids, 0);
|
var branch = BuildBranch(Block100000Txids, 0);
|
||||||
branch[0] = uint256.One; // corrompe il primo elemento del branch
|
branch[0] = uint256.One; // corrupt the first branch element
|
||||||
Assert.False(MerkleProof.Verify(Block100000Txids[0], 0, branch, Block100000Root));
|
Assert.False(MerkleProof.Verify(Block100000Txids[0], 0, branch, Block100000Root));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ public class MerkleProofTests
|
|||||||
Assert.Throws<ArgumentException>(() => MerkleProof.ComputeRootFromLeaves([]));
|
Assert.Throws<ArgumentException>(() => MerkleProof.ComputeRootFromLeaves([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Costruisce il branch per una foglia ricostruendo i livelli dell'albero.</summary>
|
/// <summary>Builds the Merkle branch for a leaf by reconstructing the tree levels.</summary>
|
||||||
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
||||||
{
|
{
|
||||||
var branch = new List<uint256>();
|
var branch = new List<uint256>();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class StorageTests
|
|||||||
private static string TempPath() =>
|
private static string TempPath() =>
|
||||||
Path.Combine(Path.GetTempPath(), $"plm-test-{Guid.NewGuid()}.wallet.json");
|
Path.Combine(Path.GetTempPath(), $"plm-test-{Guid.NewGuid()}.wallet.json");
|
||||||
|
|
||||||
// ---- cifratura AES-GCM ----
|
// ---- AES-GCM encryption ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void La_cifratura_fa_roundtrip_con_la_password_giusta()
|
public void La_cifratura_fa_roundtrip_con_la_password_giusta()
|
||||||
@@ -257,8 +257,8 @@ public class StorageTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void WalletLock_file_lock_preesistente_ma_non_bloccato_viene_acquisito()
|
public void WalletLock_file_lock_preesistente_ma_non_bloccato_viene_acquisito()
|
||||||
{
|
{
|
||||||
// Un .lock rimasto da un crash precedente (file esiste ma nessuno lo tiene)
|
// A stale .lock left from a previous crash (file exists but nobody holds it)
|
||||||
// non deve bloccare l'apertura del wallet.
|
// must not block wallet opening.
|
||||||
var path = TempPath();
|
var path = TempPath();
|
||||||
var lockPath = path + ".lock";
|
var lockPath = path + ".lock";
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace PalladiumWallet.Tests.Wallet;
|
|||||||
|
|
||||||
public class CoinAmountTests
|
public class CoinAmountTests
|
||||||
{
|
{
|
||||||
// ---- importi validi: tutte le unità ----
|
// ---- valid amounts: all units ----
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("1", "sat", 1)]
|
[InlineData("1", "sat", 1)]
|
||||||
@@ -96,7 +96,7 @@ public class CoinAmountTests
|
|||||||
Assert.False(CoinAmount.TryParseIn("99999999999", "PLM", out _));
|
Assert.False(CoinAmount.TryParseIn("99999999999", "PLM", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- unità sconosciuta lancia ArgumentException ----
|
// ---- unknown unit throws ArgumentException ----
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("banana")]
|
[InlineData("banana")]
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ using PalladiumWallet.Core.Wallet;
|
|||||||
namespace PalladiumWallet.Tests.Wallet;
|
namespace PalladiumWallet.Tests.Wallet;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test per ImportedKeyAccount e i nuovi percorsi factory in WalletLoader
|
/// Tests for ImportedKeyAccount and the new factory paths in WalletLoader
|
||||||
/// (blueprint §4.4 — importati WIF, xpub, xprv).
|
/// (blueprint §4.4 — imported WIF, xpub, xprv).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ImportedKeyAccountTests
|
public class ImportedKeyAccountTests
|
||||||
{
|
{
|
||||||
private static ChainProfile Profile => ChainProfiles.Mainnet;
|
private static ChainProfile Profile => ChainProfiles.Mainnet;
|
||||||
private static Network Network => PalladiumNetworks.For(Profile.Kind);
|
private static Network Network => PalladiumNetworks.For(Profile.Kind);
|
||||||
|
|
||||||
// Chiave WIF valida per PLM mainnet (prefix 0x80 = Compressed WIF "K"/"L")
|
// Valid WIF key for PLM mainnet (prefix 0x80 = Compressed WIF "K"/"L")
|
||||||
private static Key GenerateKey() => new Key();
|
private static Key GenerateKey() => new Key();
|
||||||
|
|
||||||
private static string ToWif(Key key) => key.GetWif(Network).ToString();
|
private static string ToWif(Key key) => key.GetWif(Network).ToString();
|
||||||
@@ -43,7 +43,7 @@ public class ImportedKeyAccountTests
|
|||||||
|
|
||||||
Assert.Equal(addr.ToString(), account.GetAddress(false, 0).ToString());
|
Assert.Equal(addr.ToString(), account.GetAddress(false, 0).ToString());
|
||||||
Assert.Equal(addr.ToString(), account.GetReceiveAddress(0).ToString());
|
Assert.Equal(addr.ToString(), account.GetReceiveAddress(0).ToString());
|
||||||
// Change → primo indirizzo
|
// Change → first address
|
||||||
Assert.Equal(addr.ToString(), account.GetChangeAddress(0).ToString());
|
Assert.Equal(addr.ToString(), account.GetChangeAddress(0).ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ public class ImportedKeyAccountTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void NewFromXpub_produce_account_watch_only()
|
public void NewFromXpub_produce_account_watch_only()
|
||||||
{
|
{
|
||||||
// Crea un HD account, esporta la zpub, reimporta come xpub watch-only.
|
// Create an HD account, export the zpub, re-import as watch-only xpub.
|
||||||
var (_, hdFull) = WalletLoader.NewFromMnemonic(
|
var (_, hdFull) = WalletLoader.NewFromMnemonic(
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
null, ScriptKind.NativeSegwit, Profile);
|
null, ScriptKind.NativeSegwit, Profile);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public class TransactionFactoryTests
|
|||||||
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
|
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Tx fittizia che accredita <paramref name="sats"/> sull'indirizzo receiving/0.</summary>
|
/// <summary>Fake transaction that credits <paramref name="sats"/> to the receiving/0 address.</summary>
|
||||||
private static (List<CachedUtxo>, Dictionary<string, Transaction>) Fund(HdAccount account, long sats)
|
private static (List<CachedUtxo>, Dictionary<string, Transaction>) Fund(HdAccount account, long sats)
|
||||||
{
|
{
|
||||||
var funding = Net.CreateTransaction();
|
var funding = Net.CreateTransaction();
|
||||||
@@ -51,17 +51,17 @@ public class TransactionFactoryTests
|
|||||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
feeRateSatPerVByte: 2, changeIndex: 0);
|
||||||
|
|
||||||
Assert.True(built.Signed);
|
Assert.True(built.Signed);
|
||||||
// Output: destinatario + change.
|
// Output: recipient + change.
|
||||||
Assert.Equal(2, built.Transaction.Outputs.Count);
|
Assert.Equal(2, built.Transaction.Outputs.Count);
|
||||||
Assert.Contains(built.Transaction.Outputs,
|
Assert.Contains(built.Transaction.Outputs,
|
||||||
o => o.ScriptPubKey == destination.ScriptPubKey && o.Value.Satoshi == 400_000);
|
o => o.ScriptPubKey == destination.ScriptPubKey && o.Value.Satoshi == 400_000);
|
||||||
|
|
||||||
// Fee coerente col rate richiesto (±20% per gli arrotondamenti di stima).
|
// Fee consistent with the requested rate (±20% for estimation rounding).
|
||||||
var vsize = built.Transaction.GetVirtualSize();
|
var vsize = built.Transaction.GetVirtualSize();
|
||||||
var expected = vsize * 2;
|
var expected = vsize * 2;
|
||||||
Assert.InRange(built.Fee.Satoshi, expected * 0.8, expected * 1.5);
|
Assert.InRange(built.Fee.Satoshi, expected * 0.8, expected * 1.5);
|
||||||
|
|
||||||
// RBF abilitato (§6.6).
|
// RBF enabled (§6.6).
|
||||||
Assert.All(built.Transaction.Inputs, i => Assert.True(i.Sequence < Sequence.Final));
|
Assert.All(built.Transaction.Inputs, i => Assert.True(i.Sequence < Sequence.Final));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,12 +96,12 @@ public class TransactionFactoryTests
|
|||||||
{
|
{
|
||||||
var account = Account();
|
var account = Account();
|
||||||
var (utxos, txs) = Fund(account, 1_000_000);
|
var (utxos, txs) = Fund(account, 1_000_000);
|
||||||
utxos[0].Height = 0; // in mempool: visibile nel saldo pending, non spendibile
|
utxos[0].Height = 0; // in mempool: visible in pending balance, not spendable
|
||||||
|
|
||||||
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
feeRateSatPerVByte: 2, changeIndex: 0));
|
||||||
Assert.Contains("in attesa di conferma", ex.Message);
|
Assert.Contains("pending confirmation", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -109,7 +109,7 @@ public class TransactionFactoryTests
|
|||||||
{
|
{
|
||||||
var account = Account();
|
var account = Account();
|
||||||
var (utxos, txs) = Fund(account, 1_000_000);
|
var (utxos, txs) = Fund(account, 1_000_000);
|
||||||
utxos[0].Frozen = true; // freeze (§6.2)
|
utxos[0].Frozen = true; // frozen (§6.2)
|
||||||
|
|
||||||
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
@@ -130,8 +130,8 @@ public class TransactionFactoryTests
|
|||||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
feeRateSatPerVByte: 2, changeIndex: 0);
|
||||||
|
|
||||||
Assert.False(built.Signed);
|
Assert.False(built.Signed);
|
||||||
// Il flusso air-gapped (§6.5): la PSBT della macchina online si firma
|
// Air-gapped flow (§6.5): the online-machine PSBT is signed
|
||||||
// offline con le chiavi e si finalizza.
|
// offline with the keys and then finalised.
|
||||||
var psbt = built.Psbt;
|
var psbt = built.Psbt;
|
||||||
psbt.SignWithKeys(full.GetExtPrivateKey(false, 0));
|
psbt.SignWithKeys(full.GetExtPrivateKey(false, 0));
|
||||||
psbt.Finalize();
|
psbt.Finalize();
|
||||||
@@ -157,7 +157,7 @@ public class TransactionFactoryTests
|
|||||||
Assert.False(CoinAmount.TryParseCoins("-1", out _));
|
Assert.False(CoinAmount.TryParseCoins("-1", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1.5 PLM = 150_000_000 sat, espressi in ciascuna unità (§8).
|
// 1.5 PLM = 150_000_000 sat, expressed in each unit (§8).
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("PLM", "1.50000000 PLM", "1.5")]
|
[InlineData("PLM", "1.50000000 PLM", "1.5")]
|
||||||
[InlineData("mPLM", "1500.00000 mPLM", "1500")]
|
[InlineData("mPLM", "1500.00000 mPLM", "1500")]
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ public class WalletLoaderTests
|
|||||||
var (doc, accountSeed) = WalletLoader.NewFromMnemonic(
|
var (doc, accountSeed) = WalletLoader.NewFromMnemonic(
|
||||||
ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||||
|
|
||||||
// Crea documento watch-only rimuovendo la mnemonica
|
// Build a watch-only document by removing the mnemonic.
|
||||||
var docWo = new WalletDocument
|
var docWo = new WalletDocument
|
||||||
{
|
{
|
||||||
Network = doc.Network,
|
Network = doc.Network,
|
||||||
|
|||||||
Reference in New Issue
Block a user