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 IReadOnlyList AddressRows { 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); }); // Saldo e numero di transazioni per singolo indirizzo (vista indirizzi). var balanceByAddress = utxos .GroupBy(u => u.Address) .ToDictionary(g => g.Key, g => g.Sum(u => u.ValueSats)); var addressRows = tracked .OrderBy(t => t.IsChange).ThenBy(t => t.Index) .Select(t => new CachedAddress { Address = t.Address.ToString(), IsChange = t.IsChange, Index = t.Index, BalanceSats = balanceByAddress.GetValueOrDefault(t.Address.ToString()), TxCount = historyByAddress.TryGetValue(t.ScriptHash, out var h) ? h.Count : 0, }) .ToList(); return new SyncResult { 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, AddressRows = addressRows, 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);