test: cover network/SPV layer end to end (was previously untested)
ElectrumClient, WalletSynchronizer, TransactionInspector, CertificatePinStore and peer discovery went from 0% coverage to close to full coverage, all against the fake ElectrumX server: - ElectrumClient: request/response correlation under load, server errors, notifications, disconnection, cancellation, SSL handshake with TOFU pinning. - WalletSynchronizer: gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature balances, busy-retry, disk-cache reuse, and the security-critical path where a lying server fails Merkle verification and the sync must abort. - TransactionInspector: fee calculation from resolved inputs, mine/theirs attribution, RBF flag, coinbase handling, degraded paths when a previous transaction can't be resolved. - CertificatePinStore: TOFU pin/match/mismatch/reset, corrupted-file fallback. - ServerRegistry: peer discovery and persistence via DiscoverAsync. - UpdateChecker: release-tag parsing and semver comparison.
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
using PalladiumWallet.Tests.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the transaction detail assembly (§10): fee from resolved inputs,
|
||||
/// mine/theirs attribution, RBF flag, coinbase, and the degraded paths when the
|
||||
/// server cannot provide a previous transaction.
|
||||
/// </summary>
|
||||
public class TransactionInspectorTests
|
||||
{
|
||||
private static readonly Network Net = PalladiumNetworks.Regtest;
|
||||
|
||||
private static HdAccount Account()
|
||||
{
|
||||
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, ChainProfiles.Regtest);
|
||||
}
|
||||
|
||||
/// <summary>Funding (1M sats to receive/0) + spend (600k external, 390k change/0, 10k fee, RBF).</summary>
|
||||
private static (Transaction Funding, Transaction Spend, BitcoinAddress External, HdAccount Account) SpendPair()
|
||||
{
|
||||
var account = Account();
|
||||
var funding = Net.CreateTransaction();
|
||||
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||
funding.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||
|
||||
var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||
var spend = Net.CreateTransaction();
|
||||
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)) { Sequence = 0xfffffffd });
|
||||
spend.Outputs.Add(Money.Satoshis(600_000), external);
|
||||
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
|
||||
return (funding, spend, external, account);
|
||||
}
|
||||
|
||||
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(
|
||||
params Transaction[] served)
|
||||
{
|
||||
var byId = served.ToDictionary(t => t.GetHash().ToString());
|
||||
var server = new FakeElectrumServer();
|
||||
server.Handle("blockchain.transaction.get", p =>
|
||||
byId.TryGetValue(p[0].GetString()!, out var tx)
|
||||
? tx.ToHex()
|
||||
: throw new FakeElectrumError(-32600, "no such transaction"));
|
||||
server.Handle("blockchain.block.header", p =>
|
||||
{
|
||||
var height = p[0].GetInt32();
|
||||
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
|
||||
header.Bits = new Target(0x1d00ffffu);
|
||||
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
|
||||
});
|
||||
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
return (server, client);
|
||||
}
|
||||
|
||||
private static HashSet<string> Owned(HdAccount account) =>
|
||||
[
|
||||
account.GetReceiveAddress(0).ToString(),
|
||||
account.GetChangeAddress(0).ToString(),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task Una_spesa_confermata_riporta_fee_conferme_e_attribuzione_mine_theirs()
|
||||
{
|
||||
var (funding, spend, external, account) = SpendPair();
|
||||
var (server, client) = await StartAsync(funding, spend);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||
Owned(account), netSats: -610_000, verified: true);
|
||||
|
||||
Assert.Equal(10_000, details.FeeSats);
|
||||
Assert.Equal(1_000_000, details.TotalInSats);
|
||||
Assert.Equal(990_000, details.TotalOutSats);
|
||||
Assert.Equal(100, details.Confirmations); // 200 − 101 + 1
|
||||
Assert.True(details.RbfSignaled);
|
||||
Assert.True(details.Verified);
|
||||
Assert.False(details.IsCoinbase);
|
||||
Assert.False(details.IsIncoming);
|
||||
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_700_000_101), details.BlockTime);
|
||||
Assert.Equal((double)10_000 / details.VirtualSize, details.FeeRateSatPerVb);
|
||||
|
||||
var input = Assert.Single(details.Inputs);
|
||||
Assert.True(input.IsMine);
|
||||
Assert.Equal(1_000_000, input.AmountSats);
|
||||
Assert.Equal(account.GetReceiveAddress(0).ToString(), input.Address);
|
||||
|
||||
Assert.Equal(2, details.Outputs.Count);
|
||||
Assert.Equal(600_000, details.SentToOthersSats);
|
||||
Assert.Equal(390_000, details.ReceivedSats);
|
||||
Assert.Contains(details.Outputs, o => o.IsMine && o.AmountSats == 390_000);
|
||||
Assert.Contains(details.Outputs, o => !o.IsMine && o.AmountSats == 600_000);
|
||||
|
||||
// Outgoing tx: the counterparty is the external recipient.
|
||||
Assert.Equal([external.ToString()], details.CounterpartyAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_ricezione_indica_il_mittente_come_controparte()
|
||||
{
|
||||
var account = Account();
|
||||
var senderKey = new Key();
|
||||
var senderAddr = senderKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||
|
||||
// The sender's coin, then the payment to us spending it.
|
||||
var senderFunding = Net.CreateTransaction();
|
||||
senderFunding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
|
||||
senderFunding.Outputs.Add(Money.Satoshis(2_000_000), senderAddr);
|
||||
|
||||
var payment = Net.CreateTransaction();
|
||||
payment.Inputs.Add(new TxIn(new OutPoint(senderFunding, 0)));
|
||||
payment.Outputs.Add(Money.Satoshis(1_500_000), account.GetReceiveAddress(0));
|
||||
|
||||
var (server, client) = await StartAsync(senderFunding, payment);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, payment.GetHash().ToString(), tipHeight: 200, height: 150,
|
||||
Owned(account), netSats: 1_500_000, verified: true);
|
||||
|
||||
Assert.True(details.IsIncoming);
|
||||
Assert.Equal([senderAddr.ToString()], details.CounterpartyAddresses);
|
||||
Assert.Equal(500_000, details.FeeSats); // 2M in − 1.5M out
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_coinbase_non_ha_fee_ne_importi_in_ingresso()
|
||||
{
|
||||
var account = Account();
|
||||
var coinbase = Net.CreateTransaction();
|
||||
coinbase.Inputs.Add(new TxIn());
|
||||
coinbase.Outputs.Add(Money.Satoshis(5_000_000_000), account.GetReceiveAddress(0));
|
||||
|
||||
var (server, client) = await StartAsync(coinbase);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, coinbase.GetHash().ToString(), tipHeight: 200, height: 190,
|
||||
Owned(account), netSats: 5_000_000_000, verified: true);
|
||||
|
||||
Assert.True(details.IsCoinbase);
|
||||
Assert.Null(details.FeeSats);
|
||||
Assert.Null(details.TotalInSats);
|
||||
Assert.Null(details.FeeRateSatPerVb);
|
||||
Assert.Equal(11, details.Confirmations);
|
||||
var input = Assert.Single(details.Inputs);
|
||||
Assert.True(input.IsCoinbase);
|
||||
Assert.Null(input.AmountSats);
|
||||
// No previous transactions to resolve.
|
||||
Assert.Equal(0, server.CallCount("blockchain.transaction.get") - 1); // only the coinbase itself
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Se_la_tx_precedente_non_e_recuperabile_la_fee_resta_ignota()
|
||||
{
|
||||
var (funding, spend, _, account) = SpendPair();
|
||||
// The server only knows the spend: the funding lookup fails.
|
||||
var (server, client) = await StartAsync(spend);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||
Owned(account), netSats: -610_000, verified: false);
|
||||
|
||||
Assert.Null(details.FeeSats);
|
||||
Assert.Null(details.TotalInSats);
|
||||
var input = Assert.Single(details.Inputs);
|
||||
Assert.Null(input.AmountSats);
|
||||
Assert.False(input.IsMine); // unresolvable → not attributable
|
||||
Assert.Equal(funding.GetHash().ToString(), input.PrevTxid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_tx_in_mempool_non_ha_conferme_ne_timestamp()
|
||||
{
|
||||
var (funding, spend, _, account) = SpendPair();
|
||||
var (server, client) = await StartAsync(funding, spend);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 0,
|
||||
Owned(account), netSats: -610_000, verified: false);
|
||||
|
||||
Assert.Equal(0, details.Confirmations);
|
||||
Assert.Null(details.BlockTime);
|
||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_cache_delle_tx_evita_le_richieste_al_server()
|
||||
{
|
||||
var (funding, spend, _, account) = SpendPair();
|
||||
var cache = new Dictionary<string, Transaction>
|
||||
{
|
||||
[funding.GetHash().ToString()] = funding,
|
||||
[spend.GetHash().ToString()] = spend,
|
||||
};
|
||||
var (server, client) = await StartAsync();
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 0,
|
||||
Owned(account), netSats: -610_000, verified: false, cache);
|
||||
|
||||
Assert.Equal(10_000, details.FeeSats);
|
||||
Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user