2026-06-11 10:47:13 +02:00
|
|
|
using NBitcoin;
|
|
|
|
|
using PalladiumWallet.Core.Chain;
|
|
|
|
|
using PalladiumWallet.Core.Crypto;
|
|
|
|
|
using PalladiumWallet.Core.Net;
|
|
|
|
|
using PalladiumWallet.Core.Storage;
|
|
|
|
|
|
|
|
|
|
namespace PalladiumWallet.Core.Spv;
|
|
|
|
|
|
|
|
|
|
/// <summary>Indirizzo derivato e tracciato durante la sincronizzazione.</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>
|
|
|
|
|
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<CachedTx> History { get; init; }
|
|
|
|
|
public required IReadOnlyList<CachedUtxo> Utxos { get; init; }
|
|
|
|
|
public required IReadOnlyList<TrackedAddress> Addresses { get; init; }
|
2026-06-11 11:27:28 +02:00
|
|
|
public required IReadOnlyList<CachedAddress> AddressRows { get; init; }
|
2026-06-11 10:47:13 +02:00
|
|
|
public required IReadOnlyDictionary<string, Transaction> Transactions { get; init; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 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).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client, int gapLimit = 20)
|
|
|
|
|
{
|
|
|
|
|
/// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary>
|
|
|
|
|
public event Action<string>? Progress;
|
|
|
|
|
|
2026-06-11 13:37:31 +02:00
|
|
|
// Cache tra le passate (stesso synchronizer per tutta la vita della
|
|
|
|
|
// connessione): le tx già scaricate e le prove di Merkle già verificate a
|
|
|
|
|
// una data altezza non si rifanno — le risincronizzazioni da notifica
|
|
|
|
|
// costano solo ciò che è cambiato (modello Electrum).
|
|
|
|
|
private readonly Dictionary<string, Transaction> _txCache = [];
|
|
|
|
|
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
|
|
|
|
|
2026-06-11 10:47:13 +02:00
|
|
|
public async Task<SyncResult> 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<TrackedAddress>();
|
|
|
|
|
var historyByAddress = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
|
|
|
|
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<string, int>();
|
|
|
|
|
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
|
|
|
|
txHeights[item.TxHash] = item.Height;
|
|
|
|
|
|
2026-06-11 13:37:31 +02:00
|
|
|
// 4. Scarica in parallelo le sole transazioni nuove.
|
2026-06-11 10:47:13 +02:00
|
|
|
var network = PalladiumNetworks.For(account.Profile.Kind);
|
2026-06-11 13:37:31 +02:00
|
|
|
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
|
|
|
|
if (missing.Count > 0)
|
2026-06-11 10:47:13 +02:00
|
|
|
{
|
2026-06-11 13:37:31 +02:00
|
|
|
Progress?.Invoke($"scarico {missing.Count} transazioni…");
|
|
|
|
|
var downloaded = await Task.WhenAll(missing.Select(async txid =>
|
|
|
|
|
(txid, Transaction.Parse(await client.GetTransactionAsync(txid, ct), network))));
|
|
|
|
|
foreach (var (txid, tx) in downloaded)
|
|
|
|
|
_txCache[txid] = tx;
|
2026-06-11 10:47:13 +02:00
|
|
|
}
|
2026-06-11 13:37:31 +02:00
|
|
|
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
2026-06-11 10:47:13 +02:00
|
|
|
|
2026-06-11 13:37:31 +02:00
|
|
|
// 5. Verifica Merkle delle confermate (§7.4 punto 4), in parallelo e
|
|
|
|
|
// solo per le tx non ancora verificate a quell'altezza.
|
|
|
|
|
var toVerify = txHeights
|
|
|
|
|
.Where(kv => kv.Value > 0
|
|
|
|
|
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
|
|
|
|
.ToList();
|
|
|
|
|
if (toVerify.Count > 0)
|
2026-06-11 10:47:13 +02:00
|
|
|
{
|
2026-06-11 13:37:31 +02:00
|
|
|
Progress?.Invoke($"verifico {toVerify.Count} prove di Merkle…");
|
|
|
|
|
await Task.WhenAll(toVerify.Select(async kv =>
|
2026-06-11 10:47:13 +02:00
|
|
|
{
|
2026-06-11 13:37:31 +02:00
|
|
|
var (txid, height) = kv;
|
|
|
|
|
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
|
|
|
|
var headerTask = client.GetBlockHeaderAsync(height, ct);
|
|
|
|
|
var proof = await proofTask;
|
|
|
|
|
var header = BlockHeaderInfo.Parse(await headerTask);
|
|
|
|
|
if (!MerkleProof.Verify(
|
|
|
|
|
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.");
|
|
|
|
|
}));
|
|
|
|
|
foreach (var (txid, height) in toVerify)
|
|
|
|
|
_verifiedAtHeight[txid] = height;
|
2026-06-11 10:47:13 +02:00
|
|
|
}
|
2026-06-11 13:37:31 +02:00
|
|
|
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
|
2026-06-11 10:47:13 +02:00
|
|
|
|
|
|
|
|
// 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<CachedUtxo>();
|
|
|
|
|
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<CachedTx>();
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-11 11:27:28 +02:00
|
|
|
// 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();
|
|
|
|
|
|
2026-06-11 10:47:13 +02:00
|
|
|
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,
|
2026-06-11 11:27:28 +02:00
|
|
|
AddressRows = addressRows,
|
2026-06-11 10:47:13 +02:00
|
|
|
Transactions = transactions,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
|
2026-06-11 13:37:31 +02:00
|
|
|
/// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit
|
|
|
|
|
/// subscribe per volta (le richieste JSON-RPC sono pipelinabili).
|
|
|
|
|
/// Ritorna il primo indice non usato.
|
2026-06-11 10:47:13 +02:00
|
|
|
/// </summary>
|
|
|
|
|
private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked,
|
|
|
|
|
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
var consecutiveEmpty = 0;
|
|
|
|
|
var index = 0;
|
|
|
|
|
var firstUnused = 0;
|
2026-06-11 13:37:31 +02:00
|
|
|
while (consecutiveEmpty < gapLimit)
|
2026-06-11 10:47:13 +02:00
|
|
|
{
|
2026-06-11 13:37:31 +02:00
|
|
|
var batch = Enumerable.Range(index, gapLimit).Select(i =>
|
|
|
|
|
{
|
|
|
|
|
var address = account.GetAddress(isChange, i);
|
|
|
|
|
return new TrackedAddress(address, Scripthash.FromAddress(address), isChange, i);
|
|
|
|
|
}).ToList();
|
|
|
|
|
index += batch.Count;
|
|
|
|
|
tracked.AddRange(batch);
|
2026-06-11 10:47:13 +02:00
|
|
|
|
|
|
|
|
// La subscribe registra anche la notifica push per i cambi futuri.
|
2026-06-11 13:37:31 +02:00
|
|
|
var statuses = await Task.WhenAll(
|
|
|
|
|
batch.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
|
|
|
|
|
|
|
|
|
|
var used = batch.Where((t, i) => statuses[i] is not null).ToList();
|
|
|
|
|
var histories = await Task.WhenAll(
|
|
|
|
|
used.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
|
|
|
|
for (var i = 0; i < used.Count; i++)
|
|
|
|
|
historyByAddress[used[i].ScriptHash] = histories[i];
|
|
|
|
|
|
|
|
|
|
for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++)
|
2026-06-11 10:47:13 +02:00
|
|
|
{
|
2026-06-11 13:37:31 +02:00
|
|
|
if (statuses[i] is null)
|
|
|
|
|
{
|
|
|
|
|
consecutiveEmpty++;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
consecutiveEmpty = 0;
|
|
|
|
|
firstUnused = batch[i].Index + 1;
|
|
|
|
|
}
|
2026-06-11 10:47:13 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return firstUnused;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>La verifica SPV è fallita: i dati del server contraddicono le prove (§17).</summary>
|
|
|
|
|
public sealed class SpvVerificationException(string message) : Exception(message);
|