Files
PalladiumWallet/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs
T
davide e349a80ddc feat(spv): anchor header trust to hardcoded mainnet checkpoints
ChainProfiles.Mainnet.Checkpoints was an empty array with a "populate
before release" TODO, and even BlockHeaderInfo.MatchesCheckpoint()/
IsValidChild() — the methods meant to enforce it — were never called
anywhere in WalletSynchronizer. So on this LWMA chain, where PoW can't
be recomputed client-side (SkipPowValidation), a malicious or
eclipsing server could hand back any internally-consistent 80-byte
header for a Merkle proof: nothing tied it to the real Palladium
chain.

Populated Mainnet.Checkpoints with 24 real [height, hash, bits]
checkpoints (every 20,000 blocks + one near tip), pulled via RPC from
a fully-synced palladiumd. Testnet/Regtest are left empty (no node
available to verify against, and WalletSynchronizer already treats a
missing checkpoint as a no-op, so this is safe, just unanchored).

Added WalletSynchronizer.AnchorToCheckpointAsync: for every header
used in a Merkle proof, downloads the intervening headers back to the
nearest checkpoint at or below that height and verifies an unbroken
prev-hash chain terminating in the checkpoint's exact hash, finally
wiring up the previously-dead MatchesCheckpoint/IsValidChild. Verified
ranges are memoized in-memory per sync session to avoid re-walking.

Updated SECURITY.md and USERGUIDE.md to describe what is actually
enforced now (previously the guide deliberately avoided promising
checkpoint anchoring, since the array was empty).
2026-07-07 15:12:17 +02:00

491 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Tests.Net;
namespace PalladiumWallet.Tests.Spv;
/// <summary>
/// End-to-end tests of the SPV synchroniser against the in-process fake server:
/// gap-limit scanning, UTXO/history reconstruction, Merkle verification (the
/// path that must reject a lying server), busy retry, and the disk cache.
/// Every block in these scenarios contains a single transaction, so the Merkle
/// root is the txid itself and the branch is empty (the tree math has its own
/// dedicated tests in <see cref="MerkleProofTests"/>).
/// </summary>
public class WalletSynchronizerTests
{
private static readonly ChainProfile Profile = ChainProfiles.Regtest;
private static readonly Network Net = PalladiumNetworks.Regtest;
private static HdAccount Account(ChainProfile? profile = null)
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, profile ?? Profile);
}
/// <summary>
/// Server-side chain state: transactions, per-scripthash histories and
/// single-transaction block headers, wired onto a FakeElectrumServer.
/// </summary>
private sealed class Scenario
{
public int TipHeight = 200;
public readonly Dictionary<string, Transaction> Txs = [];
public readonly Dictionary<string, List<(string Txid, int Height)>> History = [];
public readonly Dictionary<int, string> Headers = [];
/// <summary>Creates a tx paying <paramref name="sats"/> to <paramref name="to"/> and registers it.</summary>
public Transaction Pay(BitcoinAddress to, long sats, int height, OutPoint? from = null,
bool coinbase = false, BitcoinAddress? changeTo = null, long changeSats = 0)
{
var tx = Net.CreateTransaction();
tx.Inputs.Add(coinbase ? new TxIn() : new TxIn(from ?? new OutPoint(uint256.One, 0)));
tx.Outputs.Add(Money.Satoshis(sats), to);
if (changeTo is not null)
tx.Outputs.Add(Money.Satoshis(changeSats), changeTo);
Register(tx, height, to, changeTo);
return tx;
}
public void Register(Transaction tx, int height, params BitcoinAddress?[] touchedAddresses)
{
var txid = tx.GetHash().ToString();
Txs[txid] = tx;
foreach (var addr in touchedAddresses)
{
if (addr is null) continue;
var sh = Scripthash.FromAddress(addr);
if (!History.TryGetValue(sh, out var list))
History[sh] = list = [];
if (!list.Contains((txid, height)))
list.Add((txid, height));
}
if (height > 0 && !Headers.ContainsKey(height))
Headers[height] = SingleTxHeaderHex(tx.GetHash(), height);
}
/// <summary>80-byte header of a block whose only transaction is <paramref name="txid"/>.</summary>
public static string SingleTxHeaderHex(uint256 txid, int height, uint256? merkleRoot = null,
uint256? prevHash = null)
{
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
header.HashPrevBlock = prevHash ?? uint256.Zero;
header.HashMerkleRoot = merkleRoot ?? txid;
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
header.Bits = new Target(0x1d00ffffu);
header.Nonce = (uint)height;
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
}
public void WireTo(FakeElectrumServer server)
{
server.Handle("blockchain.headers.subscribe",
_ => new { height = TipHeight, hex = SingleTxHeaderHex(uint256.One, TipHeight) });
server.Handle("blockchain.scripthash.subscribe", _ => null);
server.Handle("blockchain.scripthash.get_history", p =>
(History.GetValueOrDefault(p[0].GetString()!) ?? [])
.Select(h => new { tx_hash = h.Txid, height = h.Height }));
server.Handle("blockchain.transaction.get", p =>
Txs.TryGetValue(p[0].GetString()!, out var tx)
? tx.ToHex()
: throw new FakeElectrumError(-32600, "no such transaction"));
server.Handle("blockchain.transaction.get_merkle", p =>
new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() });
server.Handle("blockchain.block.header", p =>
Headers.TryGetValue(p[0].GetInt32(), out var hex)
? hex
: throw new FakeElectrumError(-32600, "no such block"));
}
}
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(Scenario scenario)
{
var server = new FakeElectrumServer();
scenario.WireTo(server);
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
return (server, client);
}
// ---- scansione ----
[Fact]
public async Task Wallet_vuoto_scansiona_il_gap_limit_e_riporta_saldo_zero()
{
var scenario = new Scenario();
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(Account(), client).SyncOnceAsync();
Assert.Equal(200, result.TipHeight);
Assert.Equal(0, result.ConfirmedSats);
Assert.Equal(0, result.UnconfirmedSats);
Assert.Equal(0, result.NextReceiveIndex);
Assert.Equal(0, result.NextChangeIndex);
// Default gap limit 20 on both chains.
Assert.Equal(40, result.Addresses.Count);
Assert.Equal(40, server.CallCount("blockchain.scripthash.get_history"));
Assert.Empty(result.Utxos);
Assert.Empty(result.History);
}
[Fact]
public async Task La_scoperta_continua_oltre_il_primo_batch_quando_un_indirizzo_e_usato()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(3), 50_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client, gapLimit: 5).SyncOnceAsync();
// Index 3 used → the window slides: 5 more empty addresses (5..9) close the scan.
Assert.Equal(4, result.NextReceiveIndex);
Assert.Equal(10, result.Addresses.Count(a => !a.IsChange));
Assert.Equal(5, result.Addresses.Count(a => a.IsChange));
Assert.Equal(50_000, result.ConfirmedSats);
}
// ---- ricostruzione UTXO e storico ----
[Fact]
public async Task Una_ricezione_confermata_produce_utxo_saldo_e_storico_verificato()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
Assert.Equal(0, result.ImmatureSats); // regtest: 1 conferma minima, ne ha 101
Assert.Equal(1, result.NextReceiveIndex);
var utxo = Assert.Single(result.Utxos);
Assert.Equal(funding.GetHash().ToString(), utxo.Txid);
Assert.Equal(100, utxo.Height);
Assert.False(utxo.IsChange);
var entry = Assert.Single(result.History);
Assert.Equal(1_000_000, entry.DeltaSats);
Assert.True(entry.Verified); // Merkle proof checked against the header
var row = result.AddressRows.Single(r => r.Address == account.GetReceiveAddress(0).ToString());
Assert.Equal(1_000_000, row.BalanceSats);
Assert.Equal(1, row.TxCount);
}
[Fact]
public async Task Una_spesa_rimuove_l_utxo_e_produce_il_delta_negativo()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
// Spend: 600k to an external address, 390k change to change/0, 10k fee.
var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
var spend = Net.CreateTransaction();
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)));
spend.Outputs.Add(Money.Satoshis(600_000), external);
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
scenario.Register(spend, 101, account.GetReceiveAddress(0), account.GetChangeAddress(0));
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
// The funding UTXO is spent: only the change remains.
var utxo = Assert.Single(result.Utxos);
Assert.Equal(spend.GetHash().ToString(), utxo.Txid);
Assert.True(utxo.IsChange);
Assert.Equal(390_000, result.ConfirmedSats);
Assert.Equal(1, result.NextChangeIndex);
// History newest-first with the net deltas.
Assert.Equal(2, result.History.Count);
Assert.Equal(spend.GetHash().ToString(), result.History[0].Txid);
Assert.Equal(-610_000, result.History[0].DeltaSats); // 390k change 1M spent
Assert.Equal(1_000_000, result.History[1].DeltaSats);
}
[Fact]
public async Task Una_tx_in_mempool_conta_nel_saldo_non_confermato_e_non_verifica_merkle()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(0, result.ConfirmedSats);
Assert.Equal(250_000, result.UnconfirmedSats);
var entry = Assert.Single(result.History);
Assert.False(entry.Verified);
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
Assert.Equal(0, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task Un_coinbase_immaturo_finisce_nel_saldo_immaturo()
{
var account = Account();
var scenario = new Scenario { TipHeight = 200 };
// 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121.
scenario.Pay(account.GetReceiveAddress(0), 5_000_000_000, height: 190, coinbase: true);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(5_000_000_000, result.ConfirmedSats);
Assert.Equal(5_000_000_000, result.ImmatureSats);
Assert.True(Assert.Single(result.Utxos).IsCoinbase);
}
// ---- sicurezza SPV ----
[Fact]
public async Task Una_prova_merkle_che_non_torna_con_l_header_fa_fallire_la_sync()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
// The server lies: the block 100 header commits to a different Merkle root.
scenario.Headers[100] = Scenario.SingleTxHeaderHex(
funding.GetHash(), 100, merkleRoot: uint256.One);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
() => new WalletSynchronizer(account, client).SyncOnceAsync());
Assert.Contains(funding.GetHash().ToString(), ex.Message);
}
// ---- checkpoint anchoring ----
/// <summary>
/// Builds a chain of single-tx headers from <paramref name="fromHeight"/> to
/// <paramref name="toHeight"/> (inclusive), each linked to the previous via
/// HashPrevBlock, and registers them on the scenario. The last height carries
/// <paramref name="txid"/> as its Merkle root (single-tx block).
/// </summary>
private static Dictionary<int, string> ChainedHeaders(int fromHeight, int toHeight, uint256 txid)
{
var headers = new Dictionary<int, string>();
uint256? prevHash = null;
for (var h = fromHeight; h <= toHeight; h++)
{
var hex = Scenario.SingleTxHeaderHex(h == toHeight ? txid : uint256.One, h,
merkleRoot: h == toHeight ? txid : null, prevHash: prevHash);
headers[h] = hex;
prevHash = BlockHeaderInfo.Parse(hex).Hash;
}
return headers;
}
[Fact]
public async Task Una_tx_anchorata_a_un_checkpoint_valido_verifica_la_catena_di_header()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var chain = ChainedHeaders(100, 105, funding.GetHash());
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointProfile = Profile with
{
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
// 100..105 inclusive = 6 headers fetched to walk the chain back to the checkpoint.
Assert.Equal(6, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task Un_checkpoint_con_hash_sbagliato_fa_fallire_la_sync()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var chain = ChainedHeaders(100, 105, funding.GetHash());
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointProfile = Profile with
{
// Wrong hash at the checkpoint height: the real chain does not match it.
Checkpoints = [new Checkpoint(100, uint256.One.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
Assert.Contains("checkpoint height 100", ex.Message);
}
[Fact]
public async Task Una_catena_di_header_spezzata_fa_fallire_la_sync()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var chain = ChainedHeaders(100, 105, funding.GetHash());
// Tamper with block 103: it no longer points at block 102's real hash.
chain[103] = Scenario.SingleTxHeaderHex(uint256.One, 103, prevHash: uint256.One);
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointProfile = Profile with
{
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
Assert.Contains("Broken header chain at height 103", ex.Message);
}
[Fact]
public async Task Senza_checkpoint_a_copertura_dell_altezza_l_anchoring_e_un_no_op()
{
// Regtest profile (Profile) has no checkpoints at all: same behaviour as before
// this feature existed — only the Merkle proof is checked.
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
Assert.Equal(1, server.CallCount("blockchain.block.header"));
}
// ---- resilienza ----
[Fact]
public async Task Gli_errori_server_busy_vengono_ritentati_fino_al_successo()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var server = new FakeElectrumServer();
scenario.WireTo(server);
// The first 3 history calls fail with the ElectrumX throttling error.
var failures = 3;
var histories = scenario.History;
server.Handle("blockchain.scripthash.get_history", p =>
{
if (Interlocked.Decrement(ref failures) >= 0)
throw new FakeElectrumError(-102, "excessive resource usage");
return (histories.GetValueOrDefault(p[0].GetString()!) ?? [])
.Select(h => new { tx_hash = h.Txid, height = h.Height });
});
await using var _ = server;
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
}
// ---- cache su disco ----
[Fact]
public async Task La_cache_precaricata_evita_di_riscaricare_tx_prove_e_header()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var first = new WalletSynchronizer(account, client);
var result1 = await first.SyncOnceAsync();
var (rawTx, verifiedAt, headers) = first.ExportCaches(Net);
Assert.Single(rawTx); // the confirmed tx is exported
Assert.Single(verifiedAt); // with its verified height
Assert.NotEmpty(headers);
// Fresh synchroniser (new launch), warm cache: no re-download.
server.ResetCallCounts();
var second = new WalletSynchronizer(account, client);
second.PreloadCaches(rawTx, verifiedAt, headers,
result1.NextReceiveIndex, result1.NextChangeIndex, Net);
var result2 = await second.SyncOnceAsync();
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
Assert.Equal(0, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task Le_tx_non_confermate_non_vengono_esportate_nella_cache()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0); // mempool
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var sync = new WalletSynchronizer(account, client);
await sync.SyncOnceAsync();
var (rawTx, verifiedAt, _) = sync.ExportCaches(Net);
// Unconfirmed txs can change (RBF): they must always be re-downloaded.
Assert.Empty(rawTx);
Assert.Empty(verifiedAt);
}
// ---- account a indirizzi fissi (WIF importati) ----
[Fact]
public async Task Un_account_con_indirizzi_fissi_scansiona_solo_quelli()
{
var key = new Key();
var address = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
var account = new ImportedKeyAccount([(address, key)], ScriptKind.NativeSegwit, Profile);
var scenario = new Scenario();
scenario.Pay(address, 750_000, height: 150);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
Assert.Equal(750_000, result.ConfirmedSats);
Assert.Single(result.Addresses);
Assert.Equal(1, server.CallCount("blockchain.scripthash.get_history"));
Assert.Equal(1, result.NextReceiveIndex);
}
}