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);