feat(spv): header sync, Merkle proofs, scripthash
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
using NBitcoin;
|
||||
using NBitcoin.Crypto;
|
||||
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.
|
||||
/// </summary>
|
||||
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)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Confronto con un checkpoint hardcoded (§7.3).</summary>
|
||||
public bool MatchesCheckpoint(Checkpoint checkpoint) =>
|
||||
Hash == uint256.Parse(checkpoint.BlockHash);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using NBitcoin;
|
||||
using NBitcoin.Crypto;
|
||||
|
||||
namespace PalladiumWallet.Core.Spv;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public static class MerkleProof
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public static uint256 ComputeRoot(uint256 txid, int position, IEnumerable<uint256> branch)
|
||||
{
|
||||
var hash = txid;
|
||||
Span<byte> 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<uint256> branch, uint256 merkleRoot) =>
|
||||
ComputeRoot(txid, position, branch) == merkleRoot;
|
||||
|
||||
/// <summary>Radice di Merkle di una lista completa di txid (per i test e per blocchi piccoli).</summary>
|
||||
public static uint256 ComputeRootFromLeaves(IReadOnlyList<uint256> 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<uint256>((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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Security.Cryptography;
|
||||
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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <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; }
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
// 4. Scarica le transazioni.
|
||||
Progress?.Invoke($"scarico {txHeights.Count} transazioni…");
|
||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||
var transactions = new Dictionary<string, Transaction>();
|
||||
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<string, bool>();
|
||||
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<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);
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
|
||||
/// vuoti consecutivi (§5). Ritorna il primo indice non usato.
|
||||
/// </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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>La verifica SPV è fallita: i dati del server contraddicono le prove (§17).</summary>
|
||||
public sealed class SpvVerificationException(string message) : Exception(message);
|
||||
Reference in New Issue
Block a user