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; /// /// 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 ). /// 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); } /// /// Server-side chain state: transactions, per-scripthash histories and /// single-transaction block headers, wired onto a FakeElectrumServer. /// private sealed class Scenario { public int TipHeight = 200; public readonly Dictionary Txs = []; public readonly Dictionary> History = []; public readonly Dictionary Headers = []; /// Creates a tx paying to and registers it. 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); } /// 80-byte header of a block whose only transaction is . 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() }); server.Handle("blockchain.block.header", p => Headers.TryGetValue(p[0].GetInt32(), out var hex) ? hex : throw new FakeElectrumError(-32600, "no such block")); server.Handle("blockchain.block.headers", p => { var start = p[0].GetInt32(); var count = p[1].GetInt32(); var hexes = new List(); for (var h = start; hexes.Count < count && Headers.TryGetValue(h, out var hex); h++) hexes.Add(hex); return new { count = hexes.Count, hex = string.Concat(hexes) }; }); } } 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); // Verified means "its Merkle proof was checked" — a mempool tx has no proof to // check yet, so it's vacuously true; "not confirmed" is signalled by Height <= 0, // not by Verified (no merkle/header RPCs happen, asserted below). Assert.True(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( () => new WalletSynchronizer(account, client).SyncOnceAsync()); Assert.Contains(funding.GetHash().ToString(), ex.Message); } // ---- checkpoint anchoring ---- /// /// Builds a chain of single-tx headers from to /// (inclusive), each linked to the previous via /// HashPrevBlock. The last height carries as its Merkle /// root; overrides the root at specific heights. /// private static Dictionary ChainedHeaders(int fromHeight, int toHeight, uint256 txid, Dictionary? roots = null) { var headers = new Dictionary(); uint256? prevHash = null; for (var h = fromHeight; h <= toHeight; h++) { 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; } 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); // Anchoring runs before the header lookup, so the range call (blockchain.block.headers) // covering 100..105 back to the checkpoint already includes the tx's own header — // no separate single-header call needed. Assert.Equal(0, server.CallCount("blockchain.block.header")); Assert.Equal(1, server.CallCount("blockchain.block.headers")); } [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/range call counts stay at those 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(0, server.CallCount("blockchain.block.header")); Assert.Equal(1, server.CallCount("blockchain.block.headers")); } [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( () => 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( () => 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")); } // ---- verifica progressiva (§7.4) ---- [Fact] public async Task PartialResult_arriva_prima_della_proof_poi_il_risultato_finale_e_completamente_verificato() { 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; // Gate the Merkle-proof response so the download phase can complete (and fire // PartialResult) well before verification does. using var gate = new ManualResetEventSlim(false); server.Handle("blockchain.transaction.get_merkle", p => { gate.Wait(); return new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty() }; }); var sync = new WalletSynchronizer(account, client); SyncResult? partial = null; var partialReceived = new TaskCompletionSource(); sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); }; var syncTask = sync.SyncOnceAsync(); await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.NotNull(partial); Assert.False(Assert.Single(partial!.History).Verified); Assert.False(Assert.Single(partial.Utxos).Verified); Assert.Equal(1_000_000, partial.PendingVerificationSats); Assert.Equal(1_000_000, partial.ConfirmedSats); // reported confirmed, just not yet proof-checked gate.Set(); var final = await syncTask; Assert.True(Assert.Single(final.History).Verified); Assert.True(Assert.Single(final.Utxos).Verified); Assert.Equal(0, final.PendingVerificationSats); } [Fact] public async Task Un_coinbase_immaturo_e_non_ancora_verificato_non_produce_un_saldo_spendibile_negativo() { // Regression: ImmatureSats and PendingVerificationSats can overlap (an immature // coinbase can also be unverified) — "ConfirmedSats - ImmatureSats - PendingVerificationSats" // double-subtracts that overlap and can go negative. SpendableSats must be computed // directly from IsSpendable instead. var account = Account(); var scenario = new Scenario { TipHeight = 200 }; // 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121 — immature. 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; using var gate = new ManualResetEventSlim(false); server.Handle("blockchain.transaction.get_merkle", p => { gate.Wait(); return new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty() }; }); var sync = new WalletSynchronizer(account, client); var partialReceived = new TaskCompletionSource(); SyncResult? partial = null; sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); }; var syncTask = sync.SyncOnceAsync(); await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); // Immature AND unverified at the same time: both non-spendable subsets are the // full 5 PLM, but the actual spendable balance must be exactly zero, not negative. Assert.Equal(5_000_000_000, partial!.ImmatureSats); Assert.Equal(5_000_000_000, partial.PendingVerificationSats); Assert.Equal(0, partial.SpendableSats); gate.Set(); await syncTask; } // ---- 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); } [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] 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); } }