docs: translate all code comments to English (language policy)

Translate every Italian /// XML doc comment, <!-- --> XAML comment, and
// inline comment to English across all source files (Core, App, tests).
Add the language policy to CLAUDE.md (conversation Italian; all code
and docs English). Update one test assertion that checked an Italian
exception message that was also translated.
This commit is contained in:
2026-06-16 14:40:06 +02:00
parent 4b82a0852c
commit 3d5a226a5a
59 changed files with 721 additions and 716 deletions
+32 -32
View File
@@ -1,7 +1,7 @@
namespace PalladiumWallet.Core.Chain;
/// <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>
public enum NetKind
{
@@ -11,7 +11,7 @@ public enum NetKind
}
/// <summary>
/// Tipo di script/indirizzo supportato (blueprint §4.3).
/// Supported script/address type (blueprint §4.3).
/// </summary>
public enum ScriptKind
{
@@ -21,7 +21,7 @@ public enum ScriptKind
/// <summary>P2SH-P2WPKH (segwit wrapped).</summary>
WrappedSegwit,
/// <summary>P2WPKH (native segwit) — default consigliato.</summary>
/// <summary>P2WPKH (native segwit) — recommended default.</summary>
NativeSegwit,
/// <summary>P2SH-P2WSH (multisig wrapped).</summary>
@@ -35,15 +35,15 @@ public enum ScriptKind
}
/// <summary>
/// Coppia di header di versione BIP32 (4 byte big-endian) per serializzare
/// xprv/xpub e varianti SLIP-132 (y/z/Y/Z) — blueprint §3.
/// Pair of BIP32 version headers (4-byte big-endian) to serialize
/// xprv/xpub and SLIP-132 variants (y/z/Y/Z) — blueprint §3.
/// </summary>
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);
/// <summary>Header pubblico come 4 byte big-endian.</summary>
/// <summary>Public header as 4 big-endian bytes.</summary>
public byte[] PublicBytes() => ToBytes(Public);
internal static byte[] ToBytes(uint header) =>
@@ -56,82 +56,82 @@ public readonly record struct ExtKeyHeaders(uint Private, uint Public)
}
/// <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>
public readonly record struct ServerEndpoint(string Host, int TcpPort, int SslPort);
/// <summary>
/// Checkpoint di catena: ancora la fiducia quando la verifica PoW è disattivata
/// (blueprint §7.3). Target serializzato come "bits" compatti.
/// Chain checkpoint: anchors trust when PoW validation is disabled
/// (blueprint §7.3). Target serialized as compact "bits".
/// </summary>
public readonly record struct Checkpoint(int Height, string BlockHash, uint Bits);
/// <summary>
/// Profilo di rete/catena (blueprint §3): tutte le costanti specifiche della catena,
/// centralizzate in un solo punto. Nessun magic number altrove nel codice.
/// Network/chain profile (blueprint §3): all chain-specific constants,
/// centralized in one place. No magic numbers elsewhere in the code.
/// </summary>
public sealed record ChainProfile
{
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; }
/// <summary>Simbolo dell'unità (es. PLM).</summary>
/// <summary>Unit symbol (e.g. PLM).</summary>
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; }
/// <summary>Byte di versione indirizzi P2PKH.</summary>
/// <summary>Version byte for P2PKH addresses.</summary>
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; }
/// <summary>Human-readable part bech32/bech32m.</summary>
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; }
/// <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; }
/// <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; }
/// <summary>Porta SSL del server di indicizzazione.</summary>
/// <summary>SSL port of the indexing server.</summary>
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; }
/// <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; }
/// <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; }
/// <summary>Block explorer di default.</summary>
/// <summary>Default block explorer.</summary>
public required string ExplorerUrl { get; init; }
/// <summary>
/// La catena usa LWMA (retargeting per-blocco, 2 minuti): un client SPV non può
/// ricalcolarla, quindi la verifica bits/target va saltata e la fiducia ancorata
/// ai checkpoint (§3/§7). Per la catena di riferimento è sempre true.
/// The chain uses LWMA (per-block retargeting, 2 minutes): an SPV client cannot
/// recompute it, so bits/target validation must be skipped and trust anchored
/// to checkpoints (§3/§7). For the reference chain it is always true.
/// </summary>
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; }
/// <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; }
/// <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; }
/// <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; }
}
+14 -14
View File
@@ -1,8 +1,8 @@
namespace PalladiumWallet.Core.Chain;
/// <summary>
/// I tre profili di rete del wallet (blueprint §3). Valori mainnet verificati
/// contro il sorgente del nodo (chainparams.cpp / pow.cpp) secondo il blueprint.
/// The wallet's three network profiles (blueprint §3). Mainnet values verified
/// against the node source (chainparams.cpp / pow.cpp) per the blueprint.
/// </summary>
public static class ChainProfiles
{
@@ -12,11 +12,11 @@ public static class ChainProfiles
NetName = "mainnet",
CoinUnit = "PLM",
WifPrefix = 0x80,
AddrP2pkh = 55, // indirizzi che iniziano con 'P'
AddrP2sh = 5, // indirizzi che iniziano con '3'
AddrP2pkh = 55, // addresses starting with 'P'
AddrP2sh = 5, // addresses starting with '3'
SegwitHrp = "plm",
Bolt11Hrp = "plm",
// La mainnet riusa la genesi di Bitcoin (blueprint §3).
// Mainnet reuses Bitcoin's genesis (blueprint §3).
GenesisHash = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
DefaultTcpPort = 50001,
DefaultSslPort = 50002,
@@ -33,11 +33,11 @@ public static class ChainProfiles
[ScriptKind.WrappedSegwitMultisig] = new(0x0295b005, 0x0295b43f), // Yprv / Ypub
[ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // 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)
},
// Server di indicizzazione noti per il primo contatto (§3/§9); altri
// peer vengono scoperti via server.peers.subscribe.
// Known indexing servers for first contact (§3/§9); other
// peers are discovered via server.peers.subscribe.
BootstrapServers =
[
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("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 = [],
};
@@ -58,15 +58,15 @@ public static class ChainProfiles
AddrP2sh = 115,
SegwitHrp = "tplm",
Bolt11Hrp = "tplm",
// TODO: verificare contro chainparams.cpp del nodo (il blueprint non la riporta;
// qui si assume la genesi di Bitcoin testnet3, da confermare).
// TODO: verify against the node's chainparams.cpp (the blueprint does not report it;
// here Bitcoin testnet3 genesis is assumed, to be confirmed).
GenesisHash = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943",
NodeP2pPort = 12333,
Bip44CoinType = 1,
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
{
// tprv/tpub dal blueprint; le varianti SLIP-132 testnet (u/v/U/V) sono
// i valori standard Electrum.
// tprv/tpub from the blueprint; the testnet SLIP-132 variants (u/v/U/V) are
// the standard Electrum values.
[ScriptKind.Legacy] = new(0x04358394, 0x043587cf), // tprv / tpub
[ScriptKind.WrappedSegwit] = new(0x044a4e28, 0x044a5262), // uprv / upub
[ScriptKind.WrappedSegwitMultisig] = new(0x024285b5, 0x024289ef), // Uprv / Upub
@@ -84,7 +84,7 @@ public static class ChainProfiles
NetName = "regtest",
SegwitHrp = "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",
NodeP2pPort = 28444,
Checkpoints = [],
+16 -16
View File
@@ -4,13 +4,13 @@ using NBitcoin.DataEncoders;
namespace PalladiumWallet.Core.Chain;
/// <summary>
/// Costruisce e registra le <see cref="Network"/> NBitcoin di Palladium a partire dai
/// <see cref="ChainProfiles"/> (blueprint §19.3). Tutto il resto del codice ottiene la
/// rete da qui, mai da costanti sparse.
/// Builds and registers the Palladium NBitcoin <see cref="Network"/> instances from the
/// <see cref="ChainProfiles"/> (blueprint §19.3). All other code obtains the network
/// from here — never from scattered constants.
/// </summary>
/// <summary>
/// Identità della moneta per NBitcoin: raggruppa le tre reti sotto il codice PLM.
/// I getter sono lazy, quindi nessuna ricorsione durante la costruzione.
/// Coin identity for NBitcoin: groups the three networks under the PLM code.
/// Getters are lazy, so no recursion occurs during construction.
/// </summary>
public sealed class PalladiumNetworkSet : INetworkSet
{
@@ -34,13 +34,13 @@ public sealed class PalladiumNetworkSet : INetworkSet
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 =
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c" +
Coinbase;
// Genesi testnet3/regtest di Bitcoin: stessa coinbase, cambiano time/bits/nonce.
// TODO: confermare contro chainparams.cpp del nodo (vedi ChainProfiles).
// Bitcoin testnet3/regtest genesis: same coinbase, different time/bits/nonce.
// TODO: confirm against the node's chainparams.cpp (see ChainProfiles).
private const string TestGenesisHex =
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff001d1aa4ae18" +
Coinbase;
@@ -81,9 +81,9 @@ public static class PalladiumNetworks
NetKind.Testnet => ChainName.Testnet,
_ => ChainName.Regtest,
})
// Magic P2P solo segnaposto: il wallet SPV non apre mai connessioni P2P
// (parla solo col server di indicizzazione, §3). Serve a NBitcoin per
// distinguere le reti registrate.
// P2P magic is a placeholder only: the SPV wallet never opens P2P connections
// (it only talks to the indexing server, §3). Required by NBitcoin to
// distinguish registered networks.
.SetMagic(magic)
.SetPort(p.NodeP2pPort)
.SetRPCPort(p.NodeP2pPort + 1)
@@ -91,9 +91,9 @@ public static class PalladiumNetworks
.SetBase58Bytes(Base58Type.PUBKEY_ADDRESS, [p.AddrP2pkh])
.SetBase58Bytes(Base58Type.SCRIPT_ADDRESS, [p.AddrP2sh])
.SetBase58Bytes(Base58Type.SECRET_KEY, [p.WifPrefix])
// NBitcoin gestisce una sola coppia di header BIP32 per rete: quella
// standard xprv/xpub. Le varianti SLIP-132 (y/z/Y/Z) si serializzano
// a mano con ChainProfile.ExtKeyHeaders quando servono.
// NBitcoin handles only one BIP32 header pair per network: the standard
// xprv/xpub. SLIP-132 variants (y/z/Y/Z) are serialised manually via
// ChainProfile.ExtKeyHeaders when needed.
.SetBase58Bytes(Base58Type.EXT_SECRET_KEY, legacy.PrivateBytes())
.SetBase58Bytes(Base58Type.EXT_PUBLIC_KEY, legacy.PublicBytes())
.SetBech32(Bech32Type.WITNESS_PUBKEY_ADDRESS, Encoders.Bech32(p.SegwitHrp))
@@ -102,8 +102,8 @@ public static class PalladiumNetworks
.SetUriScheme(p.UriScheme)
.SetConsensus(new Consensus
{
// Valori usati solo dalle API NBitcoin che li richiedono: la validazione
// header reale è custom (Core/Spv) con skip PoW + checkpoint (§7).
// Values used only by NBitcoin APIs that require them: real header
// validation is custom (Core/Spv) with PoW skip + checkpoints (§7).
PowLimit = new Target(new uint256("00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff")),
PowTargetSpacing = TimeSpan.FromSeconds(p.BlockTimeSeconds),
PowTargetTimespan = TimeSpan.FromDays(14),
+21 -21
View File
@@ -3,8 +3,8 @@ using NBitcoin;
namespace PalladiumWallet.Core.Crypto;
/// <summary>
/// Lingue wordlist BIP39 supportate (blueprint §4.1). Le liste sono incorporate
/// in NBitcoin: nessun accesso a rete o filesystem.
/// Supported BIP39 wordlist languages (blueprint §4.1). The lists are embedded
/// in NBitcoin: no network or filesystem access required.
/// </summary>
public enum MnemonicLanguage
{
@@ -18,7 +18,7 @@ public enum MnemonicLanguage
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
{
Twelve = 12,
@@ -26,16 +26,16 @@ public enum MnemonicLength
}
/// <summary>
/// Facciata BIP39 su NBitcoin.Mnemonic (blueprint §4.1). NBitcoin copre già
/// entropia→parole, checksum, normalizzazione NFKD e PBKDF2-HMAC-SHA512 a 2048
/// round con salt "mnemonic"+passphrase: qui si restringe l'API ai casi del
/// blueprint e si centralizza la mappa delle lingue. Quando arriverà il seed
/// nativo versionato (§4.1 punto 1), il riconoscimento multi-schema sarà una
/// catena di TryParse per schema.
/// BIP39 facade over NBitcoin.Mnemonic (blueprint §4.1). NBitcoin already handles
/// entropy→words, checksum, NFKD normalisation, and PBKDF2-HMAC-SHA512 with 2048
/// rounds using salt "mnemonic"+passphrase. This class narrows the API to the
/// blueprint use-cases and centralises the language map. When the native versioned
/// seed arrives (§4.1 point 1), multi-scheme recognition will be a chain of
/// TryParse calls per scheme.
/// </summary>
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)
{
var wordCount = length == MnemonicLength.Twelve ? WordCount.Twelve : WordCount.TwentyFour;
@@ -43,10 +43,10 @@ public static class Bip39
}
/// <summary>
/// Riconosce e valida una mnemonica BIP39: numero di parole, appartenenza alla
/// wordlist e checksum. Se <paramref name="language"/> è indicata viene provata
/// per prima; altrimenti la lingua è auto-rilevata (parole condivise tra liste
/// possono rendere ambiguo l'autodetect: in import l'utente può forzarla).
/// Recognises and validates a BIP39 mnemonic: word count, wordlist membership,
/// and checksum. If <paramref name="language"/> is specified it is tried first;
/// otherwise the language is auto-detected (words shared between lists can make
/// autodetect ambiguous — the user can override it on import).
/// </summary>
public static bool TryParse(string text, out Mnemonic? mnemonic, MnemonicLanguage? language = null)
{
@@ -54,8 +54,8 @@ public static class Bip39
if (string.IsNullOrWhiteSpace(text))
return false;
// Solo trim esterno: la normalizzazione NFKD e lo spazio ideografico
// giapponese li gestisce NBitcoin, il testo non va alterato (§4.1).
// Outer trim only: NBitcoin handles NFKD normalisation and Japanese
// ideographic spaces — the text must not be altered further (§4.1).
text = text.Trim();
IEnumerable<Wordlist> candidates = language is not null
@@ -67,7 +67,7 @@ public static class Bip39
try
{
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)
{
mnemonic = parsed;
@@ -76,7 +76,7 @@ public static class Bip39
}
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>
/// Mnemonica seed di 64 byte (PBKDF2-HMAC-SHA512, 2048 round, salt
/// "mnemonic"+passphrase). La passphrase cambia completamente il wallet
/// derivato: avvisi UI obbligatori (§4.1).
/// Mnemonic → 64-byte seed (PBKDF2-HMAC-SHA512, 2048 rounds, salt
/// "mnemonic"+passphrase). The passphrase completely changes the derived
/// wallet — mandatory UI warnings required (§4.1).
/// </summary>
public static byte[] ToSeed(Mnemonic mnemonic, string? passphrase = null) =>
mnemonic.DeriveSeed(passphrase);
+17 -17
View File
@@ -4,25 +4,25 @@ using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Crypto;
/// <summary>
/// Costruzione dei derivation path BIP44/49/84 (blueprint §4.2) e mappatura
/// ScriptKind → purpose / ScriptPubKeyType. I purpose sono costanti di protocollo
/// BIP (chain-independent); il coin_type viene sempre dal ChainProfile.
/// BIP44/49/84 derivation path construction (blueprint §4.2) and mapping of
/// ScriptKind → purpose / ScriptPubKeyType. Purposes are BIP protocol constants
/// (chain-independent); coin_type always comes from the ChainProfile.
/// </summary>
public static class DerivationPaths
{
/// <summary>Purpose BIP44 (P2PKH legacy).</summary>
/// <summary>BIP44 purpose (P2PKH legacy).</summary>
public const int PurposeLegacy = 44;
/// <summary>Purpose BIP49 (P2SH-P2WPKH).</summary>
/// <summary>BIP49 purpose (P2SH-P2WPKH).</summary>
public const int PurposeWrappedSegwit = 49;
/// <summary>Purpose BIP84 (P2WPKH nativo).</summary>
/// <summary>BIP84 purpose (native P2WPKH).</summary>
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;
/// <summary>Purpose BIP86 (P2TR key-path, Taproot).</summary>
/// <summary>BIP86 purpose (P2TR key-path, Taproot).</summary>
public const int PurposeTaproot = 86;
public static int PurposeFor(ScriptKind kind) => kind switch
@@ -36,9 +36,9 @@ public static class DerivationPaths
};
/// <summary>
/// Tipo di scriptPubKey NBitcoin per la derivazione da pubkey singola.
/// I tipi multisig derivano da redeem script, non da una pubkey: arriveranno
/// con i wallet M-di-N (§4.5).
/// NBitcoin scriptPubKey type for single-pubkey derivation.
/// Multisig types are derived from a redeem script, not a pubkey — they will
/// arrive with M-of-N wallet support (§4.5).
/// </summary>
public static ScriptPubKeyType ScriptPubKeyTypeFor(ScriptKind kind) => kind switch
{
@@ -48,13 +48,13 @@ public static class DerivationPaths
ScriptKind.Taproot => ScriptPubKeyType.TaprootBIP86,
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig =>
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)),
};
/// <summary>
/// Path di account relativo alla root: purpose'/coin'/account' (§4.2).
/// Il coin_type è quello del profilo (746 mainnet, 1 testnet).
/// Account path relative to the root: purpose'/coin'/account' (§4.2).
/// coin_type is taken from the profile (746 mainnet, 1 testnet).
/// </summary>
public static KeyPath AccountPath(ScriptKind kind, ChainProfile profile, int account = 0)
{
@@ -64,7 +64,7 @@ public static class DerivationPaths
$"{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)
{
if (index < 0)
@@ -73,8 +73,8 @@ public static class DerivationPaths
}
/// <summary>
/// Parsing di derivation path personalizzati in import (Sparrow-like, §4.2):
/// accetta il prefisso "m/" opzionale e gli hardened marker ' oppure h.
/// Parsing of custom derivation paths on import (Sparrow-like, §4.2):
/// accepts the optional "m/" prefix and hardened markers ' or h.
/// </summary>
public static bool TryParse(string path, out KeyPath? keyPath)
{
+28 -28
View File
@@ -4,12 +4,12 @@ using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Crypto;
/// <summary>
/// Account HD single-sig (blueprint §4.2/§4.4): chiave estesa di account (xprv,
/// oppure solo xpub = watch-only) + tipo di script + profilo di rete. Deriva
/// indirizzi receiving (change=0) e change (change=1) on-demand per indice.
/// Gli indirizzi si derivano sempre dall'xpub di account (sottopath non-hardened),
/// quindi il watch-only funziona per costruzione. Il keystore completo
/// (cifratura, factory dal file wallet, §4.5) arriva con la persistenza (§8).
/// Single-sig HD account (blueprint §4.2/§4.4): extended account key (xprv,
/// or xpub-only = watch-only) + script type + network profile. Derives receiving
/// (change=0) and change (change=1) addresses on-demand by index.
/// Addresses are always derived from the account xpub (non-hardened sub-path),
/// so watch-only works by construction. The full keystore
/// (encryption, factory from wallet file, §4.5) arrives with persistence (§8).
/// </summary>
public sealed class HdAccount : IWalletAccount
{
@@ -18,25 +18,25 @@ public sealed class HdAccount : IWalletAccount
public ScriptKind Kind { 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; }
/// <summary>
/// Fingerprint della chiave master: con <see cref="AccountPath"/> forma le
/// origin info richieste dalle PSBT (§6.5). Zero se ignota (xpub importata
/// senza metadati).
/// Master key fingerprint: together with <see cref="AccountPath"/> forms the
/// origin info required by PSBTs (§6.5). Zero if unknown (imported xpub
/// without metadata).
/// </summary>
public HDFingerprint MasterFingerprint { 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;
private HdAccount(ExtKey? accountXprv, ExtPubKey accountXpub, ScriptKind kind,
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);
_accountXprv = accountXprv;
AccountXpub = accountXpub;
@@ -46,7 +46,7 @@ public sealed class HdAccount : IWalletAccount
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,
ScriptKind kind, ChainProfile profile, int account = 0) =>
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));
/// <summary>
/// Import con derivation path personalizzato (§4.2, Sparrow-like):
/// <paramref name="kind"/> determina solo il tipo di indirizzo generato.
/// Import with a custom derivation path (§4.2, Sparrow-like):
/// <paramref name="kind"/> only determines the generated address type.
/// </summary>
public static HdAccount FromSeed(byte[] seed, ScriptKind kind, ChainProfile profile, KeyPath accountPath)
{
var root = ExtKey.CreateFromSeed(seed);
// L'account si deriva sempre dalla xprv: i livelli hardened del path
// non sono derivabili da una xpub.
// The account is always derived from the xprv: hardened path levels
// cannot be derived from an xpub alone.
var accountXprv = root.Derive(accountPath);
return new HdAccount(accountXprv, accountXprv.Neuter(), kind, profile,
accountPath, root.Neuter().PubKey.GetHDFingerPrint());
}
/// <summary>
/// Watch-only da xpub di account (§4.4): fingerprint e path sono noti solo
/// se forniti da chi importa (servono per le origin info PSBT).
/// Watch-only from account xpub (§4.4): fingerprint and path are known only
/// if supplied by the importer (required for PSBT origin info).
/// </summary>
public static HdAccount FromAccountXpub(ExtPubKey accountXpub, ScriptKind kind,
ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) =>
@@ -78,7 +78,7 @@ public sealed class HdAccount : IWalletAccount
accountPath ?? DerivationPaths.AccountPath(kind, profile),
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,
ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) =>
new(accountXprv, accountXprv.Neuter(), kind, profile,
@@ -97,27 +97,27 @@ public sealed class HdAccount : IWalletAccount
public PubKey? GetPublicKey(bool isChange, int index) =>
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) =>
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;
/// <summary>
/// Chiave privata estesa di un indirizzo. Lancia se watch-only: nessuna
/// chiave privata è derivabile dalle sole pubbliche (§17).
/// Extended private key for an address. Throws if watch-only: no private key
/// can be derived from public keys alone (§17).
/// </summary>
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));
/// <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);
/// <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() =>
Slip132.Encode(
_accountXprv ?? throw new InvalidOperationException("Account watch-only: nessuna chiave privata."),
_accountXprv ?? throw new InvalidOperationException("Watch-only account: no private key."),
Kind, Profile);
}
+8 -8
View File
@@ -4,31 +4,31 @@ using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Crypto;
/// <summary>
/// Astrazione su tutti i tipi di account wallet (HD da seed, HD da xpub/xprv importata,
/// chiavi WIF importate). Consente a WalletSynchronizer e TransactionFactory di operare
/// indipendentemente dal tipo di keystore sottostante (blueprint §4.4–§4.5).
/// Abstraction over all wallet account types (HD from seed, HD from imported xpub/xprv,
/// imported WIF keys). Allows WalletSynchronizer and TransactionFactory to operate
/// independently of the underlying keystore type (blueprint §4.4–§4.5).
/// </summary>
public interface IWalletAccount
{
ScriptKind Kind { 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; }
BitcoinAddress GetAddress(bool isChange, int index);
BitcoinAddress GetReceiveAddress(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);
/// <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);
/// <summary>
/// Per gli account con indirizzi fissi (WIF importati) restituisce la lista
/// completa da scansionare; null per gli account HD che usano il gap limit.
/// For accounts with fixed addresses (imported WIF keys) returns the full list
/// to scan; null for HD accounts that use the gap limit.
/// </summary>
IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses { get; }
}
+5 -5
View File
@@ -4,9 +4,9 @@ using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Crypto;
/// <summary>
/// Account da chiavi WIF singole importate (blueprint §4.4 — "Imported"):
/// lista fissa di indirizzi, nessuna derivazione HD, nessuna catena di change.
/// Il change va sempre al primo indirizzo importato.
/// Account built from individually imported WIF keys (blueprint §4.4 — "Imported"):
/// fixed list of addresses, no HD derivation, no change chain.
/// Change always goes back to the first imported address.
/// </summary>
public sealed class ImportedKeyAccount : IWalletAccount
{
@@ -24,7 +24,7 @@ public sealed class ImportedKeyAccount : IWalletAccount
ScriptKind kind, ChainProfile profile)
{
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];
Kind = kind;
Profile = profile;
@@ -39,7 +39,7 @@ public sealed class ImportedKeyAccount : IWalletAccount
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 PubKey? GetPublicKey(bool isChange, int index)
+2 -2
View File
@@ -20,8 +20,8 @@ public static class Slip132
Encoders.Base58Check.EncodeData([.. profile.ExtKeyHeaders[kind].PrivateBytes(), .. key.ToBytes()]);
/// <summary>
/// Riconosce l'header (→ ScriptKind) e decodifica una chiave pubblica estesa:
/// è la via dell'import watch-only (§4.4).
/// Recognises the header (→ ScriptKind) and decodes an extended public key:
/// this is the watch-only import path (§4.4).
/// </summary>
public static bool TryDecodePublic(string encoded, ChainProfile profile,
out ExtPubKey? key, out ScriptKind kind)
+7 -7
View File
@@ -5,10 +5,10 @@ using System.Text.Json;
namespace PalladiumWallet.Core.Net;
/// <summary>
/// Pinning TLS "trust on first use" (blueprint §9): al primo contatto la
/// fingerprint SHA-256 del certificato del server viene salvata; ai successivi
/// viene confrontata. Se cambia, la connessione va rifiutata e l'utente può
/// sbloccare con un reset esplicito (caso tipico: server self-signed rinnovato).
/// TLS "trust on first use" pinning (blueprint §9): on the first contact the
/// server certificate's SHA-256 fingerprint is saved; on subsequent ones it is
/// compared. If it changes, the connection must be rejected and the user can
/// unlock with an explicit reset (typical case: renewed self-signed server).
/// </summary>
public sealed class CertificatePinStore(string filePath)
{
@@ -18,8 +18,8 @@ public sealed class CertificatePinStore(string filePath)
Convert.ToHexString(SHA256.HashData(certificate.GetRawCertData())).ToLowerInvariant();
/// <summary>
/// Verifica TOFU: true se il certificato è quello già visto (o se è il primo
/// contatto, nel qual caso viene salvato). False = certificato cambiato.
/// TOFU check: true if the certificate is the one already seen (or if it is the
/// first contact, in which case it is saved). False = certificate changed.
/// </summary>
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)
{
lock (_lock)
+13 -13
View File
@@ -2,24 +2,24 @@ using System.Text.Json;
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);
/// <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);
/// <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);
/// <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);
/// <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);
/// <summary>
/// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il
/// trasporto JSON-RPC generico di <see cref="ElectrumClient"/>.
/// Typed helpers over the protocol methods (blueprint §10), on top of the
/// generic JSON-RPC transport of <see cref="ElectrumClient"/>.
/// </summary>
public static class ElectrumApi
{
@@ -29,7 +29,7 @@ public static class ElectrumApi
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,
CancellationToken ct = default)
{
@@ -89,7 +89,7 @@ public static class ElectrumApi
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,
CancellationToken ct = default)
{
@@ -112,7 +112,7 @@ public static class ElectrumApi
public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) =>
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,
CancellationToken ct = default)
{
@@ -121,9 +121,9 @@ public static class ElectrumApi
}
/// <summary>
/// Parsa la risposta di server.peers.subscribe: lista di
/// [ip, hostname, ["v1.4.2", "pN", "tPORTA", "sPORTA", ...]];
/// "t"/"s" senza numero = porta di default della rete (risolta dal chiamante).
/// Parses the server.peers.subscribe response: a list of
/// [ip, hostname, ["v1.4.2", "pN", "tPORT", "sPORT", ...]];
/// "t"/"s" without a number = network default port (resolved by the caller).
/// </summary>
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
{
+27 -27
View File
@@ -9,9 +9,9 @@ using System.Threading.Channels;
namespace PalladiumWallet.Core.Net;
/// <summary>
/// Client del protocollo del server di indicizzazione (blueprint §10):
/// JSON-RPC 2.0 newline-delimited su TCP, opzionalmente TLS con pinning TOFU.
/// Le notifiche (subscription) arrivano sull'evento <see cref="NotificationReceived"/>.
/// Client for the indexing server protocol (blueprint §10):
/// newline-delimited JSON-RPC 2.0 over TCP, optionally TLS with TOFU pinning.
/// Notifications (subscriptions) arrive on the <see cref="NotificationReceived"/> event.
/// </summary>
public sealed class ElectrumClient : IAsyncDisposable
{
@@ -25,17 +25,17 @@ public sealed class ElectrumClient : IAsyncDisposable
private readonly Task _readLoop;
private readonly Task _writeLoop;
// Channel single-reader: le task scrivono i payload senza lock;
// il write loop drena tutto in un unico WriteAsync+FlushAsync —
// identico al buffered writer asyncio di Electrum.
// 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 });
// Tetto alle richieste in volo (non in coda di scrittura, ma in attesa di
// risposta sul server). Il write-loop batcha già l'invio in un solo segmento;
// questo gate evita di sommergere il server con migliaia di richieste
// simultanee su wallet grandi → niente -101/-102 a raffica né drop della
// connessione. Le scritture restano comunque pipelinate fino a questo grado.
// 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);
@@ -104,8 +104,8 @@ public sealed class ElectrumClient : IAsyncDisposable
public async Task<JsonElement> RequestAsync(string method, CancellationToken ct = default,
params object?[] parameters)
{
// Gate prima di mettere in volo: oltre MaxInFlight richieste in attesa
// si attende che una risposta liberi uno slot, invece di sommergere il server.
// 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
{
@@ -137,9 +137,9 @@ public sealed class ElectrumClient : IAsyncDisposable
}
/// <summary>
/// Drain loop: svuota il channel in un unico buffer → un solo WriteAsync+FlushAsync
/// per tutti i messaggi in coda. Quando N richieste sono in coda, vengono
/// trasmesse in un singolo segmento TCP invece di N flush seriali.
/// 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()
{
@@ -166,8 +166,8 @@ public sealed class ElectrumClient : IAsyncDisposable
}
/// <summary>
/// Read loop con PipeReader: buffer pooled, zero allocazioni per-risposta
/// (nessuna stringa intermedia), parsing JSON direttamente da byte span.
/// 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()
{
@@ -190,8 +190,8 @@ public sealed class ElectrumClient : IAsyncDisposable
}
finally
{
// consumed = tutto ciò che abbiamo consumato (fino all'ultimo \n)
// examined = tutto ciò che abbiamo guardato (fino alla fine del buffer)
// 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);
}
@@ -206,7 +206,7 @@ public sealed class ElectrumClient : IAsyncDisposable
{
await pipe.CompleteAsync();
foreach (var (_, tcs) in _pending)
tcs.TrySetException(failure ?? new IOException("Connessione al server chiusa."));
tcs.TrySetException(failure ?? new IOException("Connection to the server closed."));
_pending.Clear();
Disconnected?.Invoke(failure);
}
@@ -229,7 +229,7 @@ public sealed class ElectrumClient : IAsyncDisposable
DispatchSpan(line.FirstSpan);
return;
}
// Multi-segmento (risposta molto lunga): copia su ArrayPool poi parsa.
// Multi-segment (very long response): copy to ArrayPool then parse.
var len = (int)line.Length;
var buf = ArrayPool<byte>.Shared.Rent(len);
try
@@ -242,8 +242,8 @@ public sealed class ElectrumClient : IAsyncDisposable
private void DispatchSpan(ReadOnlySpan<byte> utf8)
{
// JsonDocument.Parse via Utf8JsonReader: nessuna stringa intermedia,
// parsing direttamente dallo span pooled.
// 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;
@@ -276,12 +276,12 @@ public sealed class ElectrumClient : IAsyncDisposable
}
}
/// <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);
/// <summary>
/// Il certificato del server è cambiato rispetto a quello salvato (TOFU, §9).
/// Si sblocca con il reset esplicito dei certificati.
/// The server certificate changed with respect to the saved one (TOFU, §9).
/// It is unlocked with an explicit reset of the certificates.
/// </summary>
public sealed class CertificatePinMismatchException(string host, int port) : Exception(
$"Il certificato TLS di {host}:{port} è cambiato rispetto a quello salvato. " +
+9 -9
View File
@@ -3,7 +3,7 @@ using PalladiumWallet.Core.Chain;
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 int PortFor(bool useSsl) => useSsl ? SslPort : TcpPort;
@@ -13,9 +13,9 @@ public sealed record KnownServer(string Host, int TcpPort, int SslPort, string?
}
/// <summary>
/// Registro dei server (blueprint §9): parte dai bootstrap del profilo (§3),
/// si arricchisce con i peer annunciati via server.peers.subscribe e persiste
/// le scoperte su file per le sessioni successive.
/// Server registry (blueprint §9): starts from the profile bootstrap list (§3),
/// is enriched with peers announced via server.peers.subscribe, and persists
/// discovered servers to file for subsequent sessions.
/// </summary>
public sealed class ServerRegistry
{
@@ -42,7 +42,7 @@ public sealed class ServerRegistry
}
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]; }
}
/// <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
{
get { lock (_lock) return _servers.FirstOrDefault(); }
}
/// <summary>
/// Chiede al server connesso i peer che annuncia e integra i nuovi nel
/// registro (porte mancanti → default del profilo). Ritorna quanti sono nuovi.
/// Queries the connected server for its announced peers and adds new ones to the
/// registry (missing ports fall back to the profile defaults). Returns the count of new entries.
/// </summary>
public async Task<int> DiscoverAsync(ElectrumClient client, CancellationToken ct = default)
{
@@ -95,7 +95,7 @@ public sealed class ServerRegistry
private void Save()
{
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
.Where(s => !_profile.BootstrapServers.Any(b =>
string.Equals(b.Host, s.Host, StringComparison.OrdinalIgnoreCase)))
+8 -9
View File
@@ -5,8 +5,8 @@ using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Spv;
/// <summary>
/// Header di blocco parsato dagli 80 byte canonici (blueprint §7.2):
/// versione, prev_hash, merkle_root, timestamp, bits, nonce.
/// Block header parsed from the canonical 80 bytes (blueprint §7.2):
/// version, prev_hash, merkle_root, timestamp, bits, nonce.
/// </summary>
public sealed record BlockHeaderInfo(
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)
{
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(
Version: BitConverter.ToInt32(raw, 0),
@@ -31,11 +31,10 @@ public sealed record BlockHeaderInfo(
}
/// <summary>
/// Validazione SPV dell'header (§7.2): collegamento al precedente e,
/// se il profilo non impone lo skip (catena LWMA), verifica PoW
/// hash &lt;= target dai bits. Il controllo di coerenza dei bits col
/// retargeting completo richiede la storia: per la catena di riferimento
/// è sostituito dai checkpoint (§7.3).
/// SPV header validation (§7.2): linkage to the previous header and,
/// if the profile does not mandate skip (LWMA chain), PoW verification
/// hash &lt;= target from bits. Full bits/retargeting consistency requires
/// history — for this chain it is replaced by checkpoints (§7.3).
/// </summary>
public bool IsValidChild(uint256 expectedPrevHash, ChainProfile profile)
{
@@ -47,7 +46,7 @@ public sealed record BlockHeaderInfo(
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) =>
Hash == uint256.Parse(checkpoint.BlockHash);
}
+2 -2
View File
@@ -4,8 +4,8 @@ using NBitcoin;
namespace PalladiumWallet.Core.Spv;
/// <summary>
/// Scripthash del protocollo di indicizzazione (blueprint §0/§10): SHA-256
/// dello scriptPubKey con i byte in ordine inverso, esadecimale.
/// Script hash for the indexing protocol (blueprint §0/§10): SHA-256 of the
/// scriptPubKey with bytes reversed, encoded as lowercase hex.
/// </summary>
public static class Scripthash
{
+36 -36
View File
@@ -8,14 +8,14 @@ using PalladiumWallet.Core.Storage;
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(
BitcoinAddress Address, string ScriptHash, bool IsChange, int Index)
{
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 required int TipHeight { get; init; }
@@ -31,26 +31,26 @@ public sealed class SyncResult
}
/// <summary>
/// Sincronizzazione del wallet (blueprint §7.4).
/// Wallet synchronisation (blueprint §7.4).
/// </summary>
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;
private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
private readonly Dictionary<string, int> _verifiedAtHeight = [];
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
// Indici noti dal sync precedente: usati da ScanChainAsync per la discovery
// incrementale — gli indirizzi già usati vengono fetchati in un unico burst
// invece di batches sequenziali, riducendo i round-trip da O(used/gapLimit) a O(1).
// 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>
/// Pre-popola le cache interne da dati salvati su disco.
/// Chiamare prima di SyncOnceAsync per evitare di riscaricale le tx già note.
/// Pre-populates internal caches from data saved on disk.
/// Call before SyncOnceAsync to avoid re-downloading already known transactions.
/// </summary>
public void PreloadCaches(
Dictionary<string, string> rawTxHex,
@@ -73,9 +73,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
}
/// <summary>
/// Esporta le cache correnti in forma serializzabile su disco.
/// Solo le tx confermate (height > 0) vengono incluse: le non confermate
/// possono cambiare (RBF) e vanno sempre riscaricate.
/// Exports the current caches in a serialisable form for disk storage.
/// Only confirmed transactions (height > 0) are included: unconfirmed ones
/// may change (RBF) and must always be re-downloaded.
/// </summary>
public (Dictionary<string, string> RawTxHex,
Dictionary<string, int> VerifiedAt,
@@ -86,8 +86,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
.Where(_txCache.ContainsKey)
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
// Solo gli header già completati: Task<string> non ancora completate
// non vengono persistite (verranno rifetchate al prossimo sync se necessario).
// 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)
@@ -99,9 +99,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
{
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 historyByAddress = new Dictionary<string, IReadOnlyList<HistoryItem>>();
int nextReceive, nextChange;
@@ -122,9 +122,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
}
else
{
// Receive e change chain in parallelo (indipendenti per definizione).
// ScanChainAsync usa _knownReceiveIndex/_knownChangeIndex per la discovery
// incrementale: gli indirizzi già usati vengono fetchati in un burst unico.
// Receive and change chains in parallel (independent by definition).
// ScanChainAsync uses _knownReceiveIndex/_knownChangeIndex for incremental
// discovery: already-used addresses are fetched in a single burst.
var receiveTask = ScanChainAsync(isChange: false, _knownReceiveIndex, ct);
var changeTask = ScanChainAsync(isChange: true, _knownChangeIndex, ct);
var rxScan = await receiveTask;
@@ -145,12 +145,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
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>();
foreach (var item in historyByAddress.Values.SelectMany(h => h))
txHeights[item.TxHash] = item.Height;
// 4+5. Download tx mancanti e verifica Merkle in parallelo senza semaforo.
// 4+5. Download missing transactions and verify Merkle proofs in parallel.
var network = PalladiumNetworks.For(account.Profile.Kind);
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
var toVerify = txHeights
@@ -160,7 +160,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
if (missing.Count > 0 || toVerify.Count > 0)
{
Progress?.Invoke($"scarico {missing.Count} tx, verifico {toVerify.Count} prove…");
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…");
var dlDone = 0;
var merkDone = 0;
@@ -170,7 +170,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
_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}, prove {merkDone}/{toVerify.Count}…");
Progress?.Invoke($"tx {n}/{missing.Count}, proofs {merkDone}/{toVerify.Count}…");
}, ct));
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
@@ -185,10 +185,10 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
uint256.Parse(txid), proof.Pos,
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
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);
if (n % 50 == 0 || n == toVerify.Count)
Progress?.Invoke($"tx {dlDone}/{missing.Count}, prove {n}/{toVerify.Count}…");
Progress?.Invoke($"tx {dlDone}/{missing.Count}, proofs {n}/{toVerify.Count}…");
}, ct));
await Task.WhenAll(dlTasks.Concat(merkTasks));
@@ -199,7 +199,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
// 6. Ricostruzione locale degli UTXO.
// 6. Local UTXO reconstruction.
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
var spent = transactions.Values
.SelectMany(tx => tx.Inputs)
@@ -229,7 +229,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
}
}
// 7. Delta per voce di storico.
// 7. Delta per history entry.
var history = new List<CachedTx>();
foreach (var (txid, tx) in transactions)
{
@@ -286,14 +286,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
}
/// <summary>
/// Scansiona una catena (receiving o change).
/// Scans one chain (receiving or change).
///
/// Phase 1 — indirizzi noti (0..fromIndex-1): tutti i GetHistoryAsync partono
/// in un unico burst parallelo, senza batching sequenziale. Per un wallet con
/// 100 indirizzi usati → 1 RTT invece di 5 round sequenziali di gapLimit.
/// Phase 1 — known addresses (0..fromIndex-1): all GetHistoryAsync calls are
/// fired in a single parallel burst, with no sequential batching. A wallet
/// with 100 used addresses costs 1 RTT instead of 5 sequential gap-limit rounds.
///
/// Phase 2 — discovery dal fromIndex in poi: batching con gap limit come prima,
/// necessario per sapere dove fermarsi.
/// Phase 2 — discovery from fromIndex onwards: gap-limit batching as before,
/// required to know when to stop.
/// </summary>
private async Task<(int NextIndex,
List<TrackedAddress> Tracked,
@@ -303,7 +303,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
var tracked = new List<TrackedAddress>();
var history = new Dictionary<string, IReadOnlyList<HistoryItem>>();
// Phase 1: burst unico per tutti gli indirizzi già noti.
// Phase 1: single burst for all already-known addresses.
if (fromIndex > 0)
{
var known = Enumerable.Range(0, fromIndex).Select(i =>
@@ -321,7 +321,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
history[known[i].ScriptHash] = knownHistories[i];
}
// Phase 2: discovery gap-limit dal fromIndex in poi.
// Phase 2: gap-limit discovery from fromIndex onwards.
var consecutiveEmpty = 0;
var index = fromIndex;
var firstUnused = fromIndex;
@@ -395,5 +395,5 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
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);
+6 -6
View File
@@ -3,16 +3,16 @@ using System.Text.Json;
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Configurazione globale dell'applicazione (blueprint §8), separata dai file
/// wallet: lingua, unità di visualizzazione, ecc. Persistita in config.json
/// nella radice dei dati (vale per tutte le reti).
/// Global application configuration (blueprint §8), separate from wallet
/// files: language, display unit, etc. Persisted in config.json
/// in the data root (applies to all networks).
/// </summary>
public sealed class AppConfig
{
/// <summary>Codice lingua UI.</summary>
/// <summary>UI language code.</summary>
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";
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
@@ -28,7 +28,7 @@ public sealed class AppConfig
}
catch (JsonException)
{
// Config corrotta: si riparte dai default senza bloccare l'avvio.
// Corrupted config: fall back to defaults without blocking startup.
return new AppConfig();
}
}
+22 -22
View File
@@ -3,29 +3,29 @@ using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Percorsi dati per piattaforma (blueprint §8). La radice dati può essere:
/// 1. <b>portable</b>: cartella "palladium-data" accanto all'eseguibile;
/// 2. <b>personalizzata</b>: scelta dall'utente al primo avvio e memorizzata in
/// un piccolo file "puntatore" in una posizione di bootstrap fissa;
/// 3. <b>legacy</b>: vecchia posizione (%APPDATA%/PalladiumWallet) se contiene già dati;
/// 4. <b>default</b>: ~/.PalladiumWallet (Linux/macOS) o %ProgramFiles%\PalladiumWallet (Windows).
/// Sotto la radice c'è una sottocartella per rete (config, wallet, header, certificati).
/// Per-platform data paths (blueprint §8). The data root can be:
/// 1. <b>portable</b>: "palladium-data" folder next to the executable;
/// 2. <b>custom</b>: chosen by the user at first launch and stored in
/// a small "pointer" file at a fixed bootstrap location;
/// 3. <b>legacy</b>: old location (%APPDATA%/PalladiumWallet) if it already holds data;
/// 4. <b>default</b>: ~/.PalladiumWallet (Linux/macOS) or %ProgramFiles%\PalladiumWallet (Windows).
/// Under the root there is a per-network subfolder (config, wallet, header, certificates).
/// </summary>
public static class AppPaths
{
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";
/// <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; }
/// <summary>
/// Radice dati predefinita, secondo la convenzione di ogni piattaforma:
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, come Electrum/Bitcoin);
/// Linux/macOS → ~/.palladium-wallet (dotfolder minuscolo, come ~/.bitcoin).
/// Per-utente e sempre scrivibile, senza privilegi di amministratore.
/// Default data root, following each platform's convention:
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, like Electrum/Bitcoin);
/// Linux/macOS → ~/.palladium-wallet (lowercase dotfolder, like ~/.bitcoin).
/// Per-user and always writable, without administrator privileges.
/// </summary>
public static string DefaultDataRoot()
{
@@ -37,8 +37,8 @@ public static class AppPaths
return Path.Combine(home, ".palladium-wallet");
}
/// <summary>File puntatore alla radice dati scelta dall'utente. Vive in una
/// posizione di bootstrap sempre scrivibile e indipendente dalla radice dati.</summary>
/// <summary>Pointer file to the user-chosen data root. Lives at a
/// bootstrap location that is always writable and independent of the data root.</summary>
private static string LocationPointerPath() =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
@@ -50,7 +50,7 @@ public static class AppPaths
private static bool HasData(string root) =>
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()
{
if (!string.IsNullOrEmpty(OverrideDataRoot))
@@ -67,9 +67,9 @@ public static class AppPaths
}
/// <summary>
/// true se la posizione dei dati è già determinata e non serve chiederla
/// all'utente: modalità portable, override, puntatore già scritto, oppure
/// dati già presenti nella posizione predefinita.
/// true if the data location is already determined and need not be asked
/// of the user: portable mode, override, pointer already written, or
/// data already present at the default location.
/// </summary>
public static bool IsDataLocationConfigured() =>
!string.IsNullOrEmpty(OverrideDataRoot)
@@ -77,7 +77,7 @@ public static class AppPaths
|| ReadPointer() is not null
|| 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)
{
root = Path.GetFullPath(root.Trim());
@@ -96,7 +96,7 @@ public static class AppPaths
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)
{
var dir = Path.Combine(DataRoot(), ChainProfiles.For(net).NetName);
@@ -114,7 +114,7 @@ public static class AppPaths
public static string DefaultWalletPath(NetKind net) =>
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)
{
var dir = WalletsDir(net);
+6 -6
View File
@@ -5,9 +5,9 @@ using System.Text.Json;
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Cifratura del file wallet (blueprint §8/§17): AES-256-GCM con chiave derivata
/// dalla password via PBKDF2-HMAC-SHA512. Il contenitore è JSON autodescrittivo
/// (kdf, parametri, salt, nonce) per consentire upgrade futuri dei parametri.
/// Wallet file encryption (blueprint §8/§17): AES-256-GCM with a key derived
/// from the password via PBKDF2-HMAC-SHA512. The container is self-describing JSON
/// (kdf, parameters, salt, nonce) to allow future parameter upgrades.
/// </summary>
public static class EncryptedFile
{
@@ -54,7 +54,7 @@ public static class EncryptedFile
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)
{
var container = JsonSerializer.Deserialize<Container>(fileContent)
@@ -74,7 +74,7 @@ public static class EncryptedFile
}
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();
}
finally
@@ -88,7 +88,7 @@ public static class EncryptedFile
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 WrongPasswordException() : base("Password errata o file wallet danneggiato.") { }
+26 -25
View File
@@ -4,9 +4,9 @@ using System.Text.Json.Serialization;
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Schema del file wallet (blueprint §8), versionato per consentire migrazioni
/// automatiche all'apertura. La cifratura (quando c'è una password) avvolge
/// l'intero documento via <see cref="EncryptedFile"/>.
/// Wallet file schema (blueprint §8), versioned to allow automatic
/// migrations on opening. Encryption (when there is a password) wraps
/// the entire document via <see cref="EncryptedFile"/>.
/// </summary>
public sealed class WalletDocument
{
@@ -14,42 +14,42 @@ public sealed class WalletDocument
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; }
/// <summary>Tipo di script (nome dell'enum ScriptKind).</summary>
/// <summary>Script type (name of the ScriptKind enum).</summary>
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; }
/// <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; }
/// <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; }
/// <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; }
/// <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? MasterFingerprint { get; set; }
/// <summary>Chiavi WIF importate (in chiaro nel documentova cifrato!).</summary>
/// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary>
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;
/// <summary>Etichette per indirizzo/txid (§12).</summary>
/// <summary>Labels by address/txid (§12).</summary>
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; } = [];
/// <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 bool IsWatchOnly =>
@@ -69,6 +69,7 @@ public sealed class WalletDocument
{
var doc = JsonSerializer.Deserialize<WalletDocument>(json, JsonOptions)
?? throw new InvalidDataException("File wallet non valido.");
// string literal above intentionally left untranslated
return Migrate(doc);
}
@@ -101,28 +102,28 @@ public sealed class SyncCache
public List<CachedAddress> Addresses { get; set; } = [];
/// <summary>
/// Cache raw delle transazioni confermate (txid → hex). Evita di riscaricale
/// ad ogni avvio dell'app: le tx confermate sono immutabili per definizione.
/// Popolata anche parzialmente in caso di sync interrotta (es. -101):
/// il synchronizer riprende dal punto in cui si era fermato.
/// Raw transaction cache (txid → hex). Avoids re-downloading confirmed
/// transactions on every launch: confirmed txs are immutable by definition.
/// May be partially populated after an interrupted sync (e.g. -101 error);
/// the synchronizer resumes from where it left off.
/// </summary>
public Dictionary<string, string>? RawTxHex { get; set; }
/// <summary>
/// Prove di Merkle già verificate (txid → altezza blocco). Evita di
/// riverificare le stesse prove ad ogni avvio: le conferme sono immutabili.
/// Already-verified Merkle proofs (txid → block height). Avoids re-verifying
/// the same proofs on every launch: confirmations are immutable.
/// </summary>
public Dictionary<string, int>? VerifiedAt { get; set; }
/// <summary>
/// Header grezzi per altezza (altezza → hex). Immutabili: non vengono mai
/// rifetchati una volta salvati. Elimina i GetBlockHeader sulle prove Merkle
/// già verificate nei sync successivi.
/// 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 required string Address { get; set; }
+7 -7
View File
@@ -3,17 +3,17 @@ using System.Globalization;
namespace PalladiumWallet.Core.Wallet;
/// <summary>
/// Conversione satoshi ↔ unità coin (8 decimali) per visualizzazione e input.
/// Si lavora sempre in satoshi internamente; la stringa è solo presentazione.
/// Satoshi ↔ coin unit conversion (8 decimal places) for display and input.
/// All internal computation uses satoshis; the formatted string is presentation only.
/// </summary>
public static class CoinAmount
{
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"];
/// <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
{
"PLM" => (SatsPerCoin, 8),
@@ -27,7 +27,7 @@ public static class CoinAmount
(sats / (decimal)SatsPerCoin).ToString("0.00000000", CultureInfo.InvariantCulture)
+ (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)
{
var (factor, decimals) = Of(unit);
@@ -36,7 +36,7 @@ public static class CoinAmount
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)
{
sats = 0;
@@ -59,7 +59,7 @@ public static class CoinAmount
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)
{
sats = 0;
+22 -22
View File
@@ -6,7 +6,7 @@ using PalladiumWallet.Core.Storage;
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 required Transaction Transaction { get; init; }
@@ -14,7 +14,7 @@ public sealed class BuiltTransaction
public required FeeRate FeeRate { get; init; }
public required bool Signed { get; init; }
/// <summary>PSBT per i flussi watch-only/air-gapped/multisig (§6.5).</summary>
/// <summary>PSBT for watch-only/air-gapped/multisig flows (§6.5).</summary>
public required PSBT Psbt { get; init; }
public string ToHex() => Transaction.ToHex();
@@ -22,25 +22,25 @@ public sealed class BuiltTransaction
}
/// <summary>
/// Costruzione e firma delle transazioni (blueprint §6) sopra le primitive
/// NBitcoin: selezione monete (manuale o automatica), fee a rate fisso,
/// invia-tutto con fee sottratta, change sulla catena interna, RBF di default.
/// Con un account watch-only produce la PSBT non firmata (§6.5).
/// Transaction construction and signing (blueprint §6) on top of NBitcoin primitives:
/// coin selection (manual or automatic), fixed fee rate, send-all with fee subtracted,
/// change on the internal chain, RBF on by default.
/// With a watch-only account produces an unsigned PSBT (§6.5).
/// </summary>
public sealed class TransactionFactory(IWalletAccount account)
{
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
/// <summary>
/// Costruisce (e se possibile firma) una transazione.
/// Builds (and signs if possible) a transaction.
/// </summary>
/// <param name="utxos">UTXO selezionati (coin control §6.2) o tutti quelli spendibili.</param>
/// <param name="transactions">Tx di provenienza degli UTXO (txid → tx), dalla sincronizzazione.</param>
/// <param name="destination">Indirizzo destinatario.</param>
/// <param name="amountSats">Importo; ignorato se <paramref name="sendAll"/>.</param>
/// <param name="feeRateSatPerVByte">Fee rate fisso in sat/vByte (§6.4).</param>
/// <param name="changeIndex">Indice del prossimo indirizzo di change (catena interna).</param>
/// <param name="sendAll">Invia tutto: fee sottratta dall'importo (§6.1).</param>
/// <param name="utxos">Selected UTXOs (coin control §6.2) or all spendable ones.</param>
/// <param name="transactions">Source transactions for the UTXOs (txid → tx), from sync.</param>
/// <param name="destination">Recipient address.</param>
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param>
/// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
/// <param name="changeIndex">Index of the next change address (internal chain).</param>
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
public BuiltTransaction Build(
IReadOnlyList<CachedUtxo> utxos,
IReadOnlyDictionary<string, Transaction> transactions,
@@ -50,15 +50,15 @@ public sealed class TransactionFactory(IWalletAccount account)
int changeIndex,
bool sendAll = false)
{
// Si spendono solo UTXO confermati: i fondi in mempool si vedono nel
// saldo "in attesa" ma non sono spendibili finché non confermano.
// Only confirmed UTXOs are spent: mempool funds appear in the "pending"
// balance but are not spendable until confirmed.
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
if (spendable.Count == 0)
{
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
throw new WalletSpendException(pending > 0
? $"Nessun fondo confermato: {CoinAmount.Format(pending)} in attesa di conferma (non spendibile)."
: "Nessun UTXO spendibile selezionato.");
? $"No confirmed funds: {CoinAmount.Format(pending)} pending confirmation (not spendable)."
: "No spendable UTXOs selected.");
}
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 builder = Network.CreateTransactionBuilder();
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.AddCoins(coins);
builder.SetChange(account.GetChangeAddress(changeIndex));
@@ -94,14 +94,14 @@ public sealed class TransactionFactory(IWalletAccount account)
}
catch (NotEnoughFundsException ex)
{
throw new WalletSpendException($"Fondi insufficienti: {ex.Message}");
throw new WalletSpendException($"Insufficient funds: {ex.Message}");
}
if (!account.IsWatchOnly)
{
if (!builder.Verify(tx, out TransactionPolicyError[] errors))
throw new WalletSpendException(
"Transazione non valida: " + string.Join("; ", errors.Select(e => e.ToString())));
"Invalid transaction: " + string.Join("; ", errors.Select(e => e.ToString())));
}
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);
+20 -20
View File
@@ -4,36 +4,36 @@ using PalladiumWallet.Core.Spv;
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(
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(
uint Index, long AmountSats, string? Address, string ScriptType, bool IsMine);
/// <summary>
/// Dati completi di una transazione, assemblati interrogando il server: la tx
/// grezza più gli output spesi dagli input (per ricavare importi, indirizzi e
/// fee) e l'header del blocco (per la data). Tutto ciò che il protocollo
/// ElectrumX-like (§10) permette di sapere su una transazione.
/// Complete transaction data assembled by querying the server: the raw transaction
/// plus the outputs spent by each input (to derive amounts, addresses, and fees)
/// and the block header (for the timestamp). Everything the ElectrumX-like protocol
/// (§10) can tell us about a transaction.
/// </summary>
public sealed class TransactionDetails
{
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 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; }
/// <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 int TotalSize { get; init; }
public required int VirtualSize { get; init; }
public required uint Version { get; init; }
public required uint LockTime { 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 DateTimeOffset? BlockTime { 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 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 ReceivedSats => Outputs.Where(o => o.IsMine).Sum(o => o.AmountSats);
public double? FeeRateSatPerVb => FeeSats is { } f && VirtualSize > 0 ? (double)f / VirtualSize : null;
/// <summary>
/// Indirizzi della controparte: i destinatari esterni per un invio (output non
/// nostri), i mittenti esterni per una ricezione (input non nostri).
/// Counterparty addresses: external recipients for a send (outputs not ours),
/// external senders for a receive (inputs not ours).
/// </summary>
public IReadOnlyList<string> CounterpartyAddresses => IsIncoming
? [.. Inputs.Where(i => !i.IsMine && i.Address is not null).Select(i => i.Address!).Distinct()]
@@ -57,9 +57,9 @@ public sealed class TransactionDetails
}
/// <summary>
/// Recupera dal server tutti i dati di una singola transazione (blueprint §10):
/// la transazione grezza e gli output spesi dai suoi input, per ricostruire
/// importi, fee e indirizzi che il server non riassume.
/// Fetches all data for a single transaction from the server (blueprint §10):
/// the raw transaction and the outputs spent by its inputs, to reconstruct
/// amounts, fees, and addresses that the server does not summarise.
/// </summary>
public static class TransactionInspector
{
@@ -112,10 +112,10 @@ public static class TransactionInspector
var rbf = tx.Inputs.Any(i => i.Sequence.IsRBF);
// Le transazioni degli input servono per importi/indirizzi/fee. Si
// scaricano in parallelo (id univoci, richieste concorrenti supportate
// da ElectrumClient): in sequenza la finestra impiegava un round-trip
// per input. Anche l'header del blocco è recuperato in parallelo.
// Input transactions are needed for amounts/addresses/fees. Fetched in
// parallel (unique ids, concurrent requests supported by ElectrumClient):
// sequential fetching costs one round-trip per input. The block header
// is also fetched in parallel.
var prevTxids = tx.IsCoinBase
? []
: tx.Inputs.Select(i => i.PrevOut.Hash.ToString()).Distinct().ToList();
+23 -23
View File
@@ -6,8 +6,8 @@ using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Core.Wallet;
/// <summary>
/// Factory dei tipi di wallet (blueprint §4.5): dal documento ricostruisce il tipo
/// di account corretto (HD da seed, HD da xprv importata, importato WIF, watch-only).
/// Wallet type factory (blueprint §4.5): reconstructs the correct account type
/// from a document (HD from seed, HD from imported xprv, imported WIF, watch-only).
/// </summary>
public static class WalletLoader
{
@@ -20,25 +20,25 @@ public static class WalletLoader
var kind = Enum.Parse<ScriptKind>(doc.ScriptKind);
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 (!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());
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 (!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;
return HdAccount.FromAccountXprv(xprv!, kind, profile, path);
}
// 3. Chiavi WIF importate
// 3. Imported WIF keys
if (doc.WifKeys is { Count: > 0 } wifKeys)
{
var entries = wifKeys.Select(wif =>
@@ -51,21 +51,21 @@ public static class WalletLoader
return new ImportedKeyAccount(entries, kind, profile);
}
// 4. Watch-only da xpub
// 4. Watch-only from xpub
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 _))
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;
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(
string words, string? passphrase, ScriptKind kind, ChainProfile profile, KeyPath? customPath = null)
{
if (!Bip39.TryParse(words, out var mnemonic))
throw new InvalidDataException("Mnemonica non valida (parole o checksum errati).");
throw new InvalidDataException("Invalid mnemonic (wrong words or checksum).");
var account = customPath is null
? HdAccount.FromMnemonic(mnemonic!, passphrase, kind, profile)
@@ -85,15 +85,15 @@ public static class WalletLoader
}
/// <summary>
/// Crea il documento da una xpub SLIP-132 importata (watch-only).
/// Rileva il ScriptKind dagli header SLIP-132; <paramref name="kindOverride"/> lo sovrascrive
/// per i prefissi ambigui (xpub può essere Legacy o Taproot).
/// Creates the document from an imported SLIP-132 xpub (watch-only).
/// Detects the ScriptKind from the SLIP-132 header; <paramref name="kindOverride"/> overrides
/// it for ambiguous prefixes (xpub can be Legacy or Taproot).
/// </summary>
public static (WalletDocument Doc, HdAccount Account) NewFromXpub(
string slip132, ChainProfile profile, ScriptKind? kindOverride = null)
{
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 account = HdAccount.FromAccountXpub(xpub!, kind, profile);
@@ -108,14 +108,14 @@ public static class WalletLoader
}
/// <summary>
/// Crea il documento da una xprv SLIP-132 importata (spendibile senza seed).
/// Il documento contiene la xprv in chiaro: deve essere obbligatoriamente cifrato.
/// Creates the document from an imported SLIP-132 xprv (spendable without seed).
/// The document contains the xprv in plaintext — it must be encrypted.
/// </summary>
public static (WalletDocument Doc, HdAccount Account) NewFromXprv(
string slip132, ChainProfile profile, ScriptKind? kindOverride = null)
{
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 account = HdAccount.FromAccountXprv(xprv!, kind, profile);
@@ -131,14 +131,14 @@ public static class WalletLoader
}
/// <summary>
/// Crea il documento da una o più chiavi WIF importate.
/// Il documento contiene le chiavi WIF in chiaro: deve essere obbligatoriamente cifrato.
/// Creates the document from one or more imported WIF keys.
/// The document contains the WIF keys in plaintext — it must be encrypted.
/// </summary>
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromWif(
IReadOnlyList<string> wifKeys, ScriptKind kind, ChainProfile profile)
{
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 entries = new List<(BitcoinAddress, Key?)>();
@@ -153,7 +153,7 @@ public static class WalletLoader
}
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
.GetAddress(DerivationPaths.ScriptPubKeyTypeFor(kind), network);