From bd5e7405995637a16f9cf9717a6402f940676dd1 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 11 Jun 2026 10:47:13 +0200 Subject: [PATCH] feat(spv): header sync, Merkle proofs, scripthash --- src/Core/Spv/BlockHeaderInfo.cs | 53 ++++++++ src/Core/Spv/MerkleProof.cs | 67 ++++++++++ src/Core/Spv/Scripthash.cs | 21 ++++ src/Core/Spv/WalletSynchronizer.cs | 195 +++++++++++++++++++++++++++++ 4 files changed, 336 insertions(+) create mode 100644 src/Core/Spv/BlockHeaderInfo.cs create mode 100644 src/Core/Spv/MerkleProof.cs create mode 100644 src/Core/Spv/Scripthash.cs create mode 100644 src/Core/Spv/WalletSynchronizer.cs diff --git a/src/Core/Spv/BlockHeaderInfo.cs b/src/Core/Spv/BlockHeaderInfo.cs new file mode 100644 index 0000000..184f51b --- /dev/null +++ b/src/Core/Spv/BlockHeaderInfo.cs @@ -0,0 +1,53 @@ +using NBitcoin; +using NBitcoin.Crypto; +using PalladiumWallet.Core.Chain; + +namespace PalladiumWallet.Core.Spv; + +/// +/// Header di blocco parsato dagli 80 byte canonici (blueprint §7.2): +/// versione, prev_hash, merkle_root, timestamp, bits, nonce. +/// +public sealed record BlockHeaderInfo( + int Version, uint256 PrevHash, uint256 MerkleRoot, uint Timestamp, uint Bits, uint Nonce, uint256 Hash) +{ + public const int Size = 80; + + public static BlockHeaderInfo Parse(string headerHex) => Parse(Convert.FromHexString(headerHex)); + + public static BlockHeaderInfo Parse(byte[] raw) + { + if (raw.Length != Size) + throw new ArgumentException($"Header di {raw.Length} byte (attesi {Size}).", nameof(raw)); + + return new BlockHeaderInfo( + Version: BitConverter.ToInt32(raw, 0), + PrevHash: new uint256(raw.AsSpan(4, 32)), + MerkleRoot: new uint256(raw.AsSpan(36, 32)), + Timestamp: BitConverter.ToUInt32(raw, 68), + Bits: BitConverter.ToUInt32(raw, 72), + Nonce: BitConverter.ToUInt32(raw, 76), + Hash: new uint256(Hashes.DoubleSHA256RawBytes(raw, 0, Size))); + } + + /// + /// Validazione SPV dell'header (§7.2): collegamento al precedente e, + /// se il profilo non impone lo skip (catena LWMA), verifica PoW + /// hash <= 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). + /// + public bool IsValidChild(uint256 expectedPrevHash, ChainProfile profile) + { + if (PrevHash != expectedPrevHash) + return false; + if (profile.SkipPowValidation) + return true; + var target = new Target(Bits).ToUInt256(); + return Hash <= target; + } + + /// Confronto con un checkpoint hardcoded (§7.3). + public bool MatchesCheckpoint(Checkpoint checkpoint) => + Hash == uint256.Parse(checkpoint.BlockHash); +} diff --git a/src/Core/Spv/MerkleProof.cs b/src/Core/Spv/MerkleProof.cs new file mode 100644 index 0000000..8c09502 --- /dev/null +++ b/src/Core/Spv/MerkleProof.cs @@ -0,0 +1,67 @@ +using NBitcoin; +using NBitcoin.Crypto; + +namespace PalladiumWallet.Core.Spv; + +/// +/// Verifica delle prove di Merkle (blueprint §7.1/§7.4): le risposte dei server +/// non sono fidate, ogni transazione confermata va verificata contro il +/// merkle_root dell'header al suo blocco (§17). +/// +public static class MerkleProof +{ + /// + /// Risale la prova dal txid alla radice: a ogni livello il nodo fratello + /// si concatena a sinistra o destra secondo il bit di posizione, con + /// doppio SHA-256. I nodi sono nell'ordine restituito dal server + /// (dal basso verso l'alto), in hex display (byte invertiti). + /// + public static uint256 ComputeRoot(uint256 txid, int position, IEnumerable branch) + { + var hash = txid; + Span data = stackalloc byte[64]; + foreach (var sibling in branch) + { + if ((position & 1) == 0) + { + hash.ToBytes(data[..32]); + sibling.ToBytes(data[32..]); + } + else + { + sibling.ToBytes(data[..32]); + hash.ToBytes(data[32..]); + } + hash = new uint256(Hashes.DoubleSHA256RawBytes(data.ToArray(), 0, 64)); + position >>= 1; + } + return hash; + } + + public static bool Verify(uint256 txid, int position, IEnumerable branch, uint256 merkleRoot) => + ComputeRoot(txid, position, branch) == merkleRoot; + + /// Radice di Merkle di una lista completa di txid (per i test e per blocchi piccoli). + public static uint256 ComputeRootFromLeaves(IReadOnlyList txids) + { + if (txids.Count == 0) + throw new ArgumentException("Serve almeno una foglia.", nameof(txids)); + + var level = txids.ToList(); + while (level.Count > 1) + { + var next = new List((level.Count + 1) / 2); + for (var i = 0; i < level.Count; i += 2) + { + var left = level[i]; + var right = i + 1 < level.Count ? level[i + 1] : level[i]; // dispari: duplica l'ultimo + var data = new byte[64]; + left.ToBytes(data.AsSpan(..32)); + right.ToBytes(data.AsSpan(32..)); + next.Add(new uint256(Hashes.DoubleSHA256RawBytes(data, 0, 64))); + } + level = next; + } + return level[0]; + } +} diff --git a/src/Core/Spv/Scripthash.cs b/src/Core/Spv/Scripthash.cs new file mode 100644 index 0000000..5d42334 --- /dev/null +++ b/src/Core/Spv/Scripthash.cs @@ -0,0 +1,21 @@ +using System.Security.Cryptography; +using NBitcoin; + +namespace PalladiumWallet.Core.Spv; + +/// +/// Scripthash del protocollo di indicizzazione (blueprint §0/§10): SHA-256 +/// dello scriptPubKey con i byte in ordine inverso, esadecimale. +/// +public static class Scripthash +{ + public static string FromScript(Script scriptPubKey) + { + var hash = SHA256.HashData(scriptPubKey.ToBytes()); + Array.Reverse(hash); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + public static string FromAddress(BitcoinAddress address) => + FromScript(address.ScriptPubKey); +} diff --git a/src/Core/Spv/WalletSynchronizer.cs b/src/Core/Spv/WalletSynchronizer.cs new file mode 100644 index 0000000..d84a766 --- /dev/null +++ b/src/Core/Spv/WalletSynchronizer.cs @@ -0,0 +1,195 @@ +using NBitcoin; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; +using PalladiumWallet.Core.Net; +using PalladiumWallet.Core.Storage; + +namespace PalladiumWallet.Core.Spv; + +/// Indirizzo derivato e tracciato durante la sincronizzazione. +public sealed record TrackedAddress( + BitcoinAddress Address, string ScriptHash, bool IsChange, int Index) +{ + public Script ScriptPubKey => Address.ScriptPubKey; +} + +/// Esito di una passata di sincronizzazione. +public sealed class SyncResult +{ + public required int TipHeight { get; init; } + public required long ConfirmedSats { get; init; } + public required long UnconfirmedSats { get; init; } + public required int NextReceiveIndex { get; init; } + public required int NextChangeIndex { get; init; } + public required IReadOnlyList History { get; init; } + public required IReadOnlyList Utxos { get; init; } + public required IReadOnlyList Addresses { get; init; } + public required IReadOnlyDictionary Transactions { get; init; } +} + +/// +/// Sincronizzazione del wallet (blueprint §7.4): per ogni indirizzo calcola lo +/// scripthash e si sottoscrive; scarica storico e transazioni; verifica ogni tx +/// confermata con la prova di Merkle contro l'header del suo blocco (le risposte +/// del server non sono fidate, §17); ricostruisce localmente UTXO e saldo; +/// estende la scansione fino al gap limit (§5). +/// +public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client, int gapLimit = 20) +{ + /// Avanzamento leggibile (per CLI e barra di stato GUI). + public event Action? Progress; + + public async Task SyncOnceAsync(CancellationToken ct = default) + { + var tip = await client.SubscribeHeadersAsync(ct); + Progress?.Invoke($"tip della catena: {tip.Height}"); + + // 1-2. Scansione indirizzi con gap limit, per catena receiving e change. + var tracked = new List(); + var historyByAddress = new Dictionary>(); + var nextReceive = await ScanChainAsync(isChange: false, tracked, historyByAddress, ct); + var nextChange = await ScanChainAsync(isChange: true, tracked, historyByAddress, ct); + + // 3. Storico unico (txid → altezza massima riportata). + var txHeights = new Dictionary(); + foreach (var item in historyByAddress.Values.SelectMany(h => h)) + txHeights[item.TxHash] = item.Height; + + // 4. Scarica le transazioni. + Progress?.Invoke($"scarico {txHeights.Count} transazioni…"); + var network = PalladiumNetworks.For(account.Profile.Kind); + var transactions = new Dictionary(); + foreach (var txid in txHeights.Keys) + { + var hex = await client.GetTransactionAsync(txid, ct); + transactions[txid] = Transaction.Parse(hex, network); + } + + // 5. Verifica Merkle delle confermate (§7.4 punto 4). + var verified = new Dictionary(); + foreach (var (txid, height) in txHeights) + { + if (height <= 0) + { + verified[txid] = false; // in mempool: nessuna prova possibile + continue; + } + var proof = await client.GetMerkleAsync(txid, height, ct); + var header = BlockHeaderInfo.Parse(await client.GetBlockHeaderAsync(height, ct)); + var ok = MerkleProof.Verify( + uint256.Parse(txid), + proof.Pos, + proof.Merkle.Select(uint256.Parse), + header.MerkleRoot); + if (!ok) + throw new SpvVerificationException( + $"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile."); + verified[txid] = true; + } + + // 6. Ricostruzione locale degli UTXO: accrediti = output verso nostri + // script; spesi = outpoint consumati da una qualunque tx del wallet. + var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t); + var spent = transactions.Values + .SelectMany(tx => tx.Inputs) + .Select(i => i.PrevOut) + .ToHashSet(); + + var utxos = new List(); + foreach (var (txid, tx) in transactions) + { + for (var vout = 0; vout < tx.Outputs.Count; vout++) + { + var output = tx.Outputs[vout]; + if (!byScript.TryGetValue(output.ScriptPubKey, out var addr)) + continue; + if (spent.Contains(new OutPoint(tx, vout))) + continue; + utxos.Add(new CachedUtxo + { + Txid = txid, + Vout = vout, + ValueSats = output.Value.Satoshi, + Address = addr.Address.ToString(), + IsChange = addr.IsChange, + AddressIndex = addr.Index, + Height = txHeights[txid], + }); + } + } + + // 7. Delta per voce di storico (entrate - uscite del wallet). + var history = new List(); + foreach (var (txid, tx) in transactions) + { + var received = tx.Outputs + .Where(o => byScript.ContainsKey(o.ScriptPubKey)) + .Sum(o => o.Value.Satoshi); + var sentSats = tx.Inputs + .Where(i => transactions.TryGetValue(i.PrevOut.Hash.ToString(), out var prev) + && byScript.ContainsKey(prev.Outputs[i.PrevOut.N].ScriptPubKey)) + .Sum(i => transactions[i.PrevOut.Hash.ToString()].Outputs[i.PrevOut.N].Value.Satoshi); + history.Add(new CachedTx + { + Txid = txid, + Height = txHeights[txid], + DeltaSats = received - sentSats, + Verified = verified[txid], + }); + } + history.Sort((a, b) => + { + // Non confermate (height<=0) in cima, poi per altezza decrescente. + var ha = a.Height <= 0 ? int.MaxValue : a.Height; + var hb = b.Height <= 0 ? int.MaxValue : b.Height; + return hb.CompareTo(ha); + }); + + return new SyncResult + { + TipHeight = tip.Height, + ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats), + UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats), + NextReceiveIndex = nextReceive, + NextChangeIndex = nextChange, + History = history, + Utxos = utxos, + Addresses = tracked, + Transactions = transactions, + }; + } + + /// + /// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi + /// vuoti consecutivi (§5). Ritorna il primo indice non usato. + /// + private async Task ScanChainAsync(bool isChange, List tracked, + Dictionary> historyByAddress, CancellationToken ct) + { + var consecutiveEmpty = 0; + var index = 0; + var firstUnused = 0; + for (; consecutiveEmpty < gapLimit; index++) + { + var address = account.GetAddress(isChange, index); + var scripthash = Scripthash.FromAddress(address); + tracked.Add(new TrackedAddress(address, scripthash, isChange, index)); + + // La subscribe registra anche la notifica push per i cambi futuri. + var status = await client.SubscribeScripthashAsync(scripthash, ct); + if (status is null) + { + consecutiveEmpty++; + continue; + } + + historyByAddress[scripthash] = await client.GetHistoryAsync(scripthash, ct); + consecutiveEmpty = 0; + firstUnused = index + 1; + } + return firstUnused; + } +} + +/// La verifica SPV è fallita: i dati del server contraddicono le prove (§17). +public sealed class SpvVerificationException(string message) : Exception(message);