test(spv): cover checkpoint-anchoring memoization and the non-generic retry path

Two branches of WalletSynchronizer had no coverage: the early-return in
AnchorToCheckpointAsync when a range is already memoized (a second sync
with a tx below the previously-anchored height must not re-walk the
header chain), and the void RetryOnBusyAsync overload used by the
transaction-download tasks (only the generic <T> overload, exercised by
get_history, had a test).
This commit is contained in:
2026-07-07 16:24:07 +02:00
parent 45f7b1401e
commit db5b65ca0d
@@ -279,17 +279,18 @@ public class WalletSynchronizerTests
/// <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).
/// HashPrevBlock. The last height carries <paramref name="txid"/> as its Merkle
/// root; <paramref name="roots"/> overrides the root at specific heights.
/// </summary>
private static Dictionary<int, string> ChainedHeaders(int fromHeight, int toHeight, uint256 txid)
private static Dictionary<int, string> ChainedHeaders(int fromHeight, int toHeight, uint256 txid,
Dictionary<int, uint256>? roots = null)
{
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);
var root = roots?.GetValueOrDefault(h) ?? (h == toHeight ? txid : uint256.One);
var hex = Scenario.SingleTxHeaderHex(root, h, merkleRoot: root, prevHash: prevHash);
headers[h] = hex;
prevHash = BlockHeaderInfo.Parse(hex).Hash;
}
@@ -321,6 +322,44 @@ public class WalletSynchronizerTests
Assert.Equal(6, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task Un_range_gia_ancorato_non_viene_ricamminato_al_sync_successivo()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
// Second tx (at height 103) known upfront so the chain can commit to it,
// but announced by the server only after the first sync.
var tx2 = Net.CreateTransaction();
tx2.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
tx2.Outputs.Add(Money.Satoshis(500_000), account.GetReceiveAddress(1));
var chain = ChainedHeaders(100, 105, funding.GetHash(),
roots: new() { [103] = tx2.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 sync = new WalletSynchronizer(checkpointAccount, client);
await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105
// 103 <= the memoized 105: anchoring must early-return without re-walking,
// so the header call count stays at the 6 of the first walk (103 is cached).
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
var result = await sync.SyncOnceAsync();
Assert.Equal(1_500_000, result.ConfirmedSats);
Assert.Equal(6, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task Un_checkpoint_con_hash_sbagliato_fa_fallire_la_sync()
{
@@ -416,6 +455,34 @@ public class WalletSynchronizerTests
Assert.Equal(1_000_000, result.ConfirmedSats);
}
[Fact]
public async Task I_download_di_transazioni_su_server_busy_vengono_ritentati()
{
// Same throttling scenario but on transaction.get, which runs inside the
// download tasks (the non-generic retry overload, unlike get_history).
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var server = new FakeElectrumServer();
scenario.WireTo(server);
var failures = 2;
var txs = scenario.Txs;
server.Handle("blockchain.transaction.get", p =>
{
if (Interlocked.Decrement(ref failures) >= 0)
throw new FakeElectrumError(-101, "server busy");
return txs[p[0].GetString()!].ToHex();
});
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);
Assert.Equal(funding.GetHash().ToString(), Assert.Single(result.History).Txid);
}
// ---- cache su disco ----
[Fact]