Files
PalladiumWallet/tests/PalladiumWallet.Tests/Wallet/TransactionInspectorTests.cs
T
davide 448f7dc65d 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.
2026-07-02 23:21:01 +02:00

218 lines
9.3 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 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"));
}
}