test(core): cover error paths, boundaries and concurrency

Empty header range during checkpoint anchoring, send-all limits, SyncCache
roundtrip, corrupted config, derivation edge cases, spendability
boundaries, unit formatting, loader errors, in-flight cap, disposal under
load and wallet-lock contention. Also raises the synchronizer partial-result
timeouts so they hold under load.
This commit is contained in:
davide committed 2026-11-03 11:45:43 +01:00
1 parent cb93927d15
commit 54d07f8e92
7 files changed
+536 -2

No files matched your search

@@ -0,0 +1,133 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Tests.Wallet;
/// <summary>
/// Boundary values, unit formatting, derivation-path round trips and user-facing error messages
/// that the other suites did not pin down.
/// </summary>
public class BoundaryAndMessageTests
{
private const string Words =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
private static readonly ChainProfile Profile = ChainProfiles.Mainnet;
// ---- BlockHeaderInfo ----
[Theory]
[InlineData(0)]
[InlineData(79)]
[InlineData(81)]
public void HeaderOfTheWrongLength_IsRejected(int length)
{
var ex = Assert.Throws<ArgumentException>(() => BlockHeaderInfo.Parse(new byte[length]));
Assert.Contains("80", ex.Message);
}
// ---- CoinAmount ----
[Fact]
public void Format_AppendsTheUnitOnlyWhenOneIsGiven()
{
Assert.Equal("1.00000000", CoinAmount.Format(100_000_000));
Assert.Equal("1.00000000 PLM", CoinAmount.Format(100_000_000, "PLM"));
}
[Fact]
public void FormatIn_ZeroDecimalsAndZeroValues_AreRenderedCorrectly()
{
Assert.Equal("5", CoinAmount.FormatIn(5, "sat", withLabel: false));
Assert.Equal("0", CoinAmount.FormatIn(0, "sat", withLabel: false));
Assert.Equal("0.00000", CoinAmount.FormatIn(0, "mPLM", withLabel: false));
Assert.Equal("0.00 µPLM", CoinAmount.FormatIn(0, "µPLM"));
}
[Fact]
public void UnknownUnit_IsRejectedWithItsNameInTheMessage()
{
var ex = Assert.Throws<ArgumentException>(() => CoinAmount.FormatIn(1, "BTC"));
Assert.Contains("BTC", ex.Message);
}
[Theory]
[InlineData("0", 0)]
[InlineData("0.0", 0)]
public void ZeroIsAValidAmount_ButNegativeIsNot(string text, long expected)
{
Assert.True(CoinAmount.TryParseCoins(text, out var sats));
Assert.Equal(expected, sats);
Assert.False(CoinAmount.TryParseCoins("-1", out _));
Assert.False(CoinAmount.TryParseCoins("-0.00000001", out _));
}
// ---- WalletLoader ----
[Fact]
public void CustomDerivationPath_SurvivesTheDocumentRoundTrip()
{
var custom = new KeyPath("44'/0'/7'");
var (doc, account) = WalletLoader.NewFromMnemonic(Words, null, ScriptKind.NativeSegwit, Profile, custom);
var reopened = WalletLoader.ToAccount(doc);
Assert.Equal(custom.ToString(), doc.AccountPath);
Assert.Equal(account.GetReceiveAddress(3), reopened.GetReceiveAddress(3));
var (_, defaultAccount) = WalletLoader.NewFromMnemonic(Words, null, ScriptKind.NativeSegwit, Profile);
Assert.NotEqual(defaultAccount.GetReceiveAddress(0), account.GetReceiveAddress(0));
}
[Fact]
public void InvalidMnemonic_ProducesAMeaningfulError()
{
var ex = Assert.Throws<InvalidDataException>(() =>
WalletLoader.NewFromMnemonic("abandon abandon", null, ScriptKind.NativeSegwit, Profile));
Assert.Contains("mnemonic", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ScriptKindOverride_WinsOverTheKindDetectedFromTheKeyHeader()
{
Assert.True(Bip39.TryParse(Words, out var m));
var hd = HdAccount.FromMnemonic(m!, null, ScriptKind.NativeSegwit, Profile);
var (xpubDoc, _) = WalletLoader.NewFromXpub(hd.ToSlip132(), Profile, ScriptKind.Legacy);
Assert.Equal(nameof(ScriptKind.Legacy), xpubDoc.ScriptKind);
var (xprvDoc, _) = WalletLoader.NewFromXprv(hd.ToSlip132Private(), Profile, ScriptKind.Legacy);
Assert.Equal(nameof(ScriptKind.Legacy), xprvDoc.ScriptKind);
}
[Fact]
public void InvalidExtendedKeys_ProduceMeaningfulErrors()
{
var pub = Assert.Throws<InvalidDataException>(() => WalletLoader.NewFromXpub("garbage", Profile, ScriptKind.NativeSegwit));
Assert.Contains("public", pub.Message, StringComparison.OrdinalIgnoreCase);
var prv = Assert.Throws<InvalidDataException>(() => WalletLoader.NewFromXprv("garbage", Profile, ScriptKind.NativeSegwit));
Assert.Contains("private", prv.Message, StringComparison.OrdinalIgnoreCase);
}
// ---- storage / net ----
[Fact]
public void WrongPasswordException_CarriesAUserFacingMessage() =>
Assert.False(string.IsNullOrWhiteSpace(new WrongPasswordException().Message));
[Fact]
public void PinFile_ContainingJsonNull_IsTreatedAsEmpty_NotACrash()
{
var path = Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}.json");
try
{
File.WriteAllText(path, "null");
Assert.False(new CertificatePinStore(path).Reset("host", 50002)); // nothing pinned, no exception
}
finally { File.Delete(path); }
}
}
@@ -0,0 +1,74 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Tests.Wallet;
/// <summary>Spend-policy limits: send-all affordability and the standard-size cap.</summary>
public class TransactionFactoryLimitsTests
{
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>One funding tx with <paramref name="count"/> outputs to receive/0, each a verified UTXO.</summary>
private static (List<CachedUtxo>, Dictionary<string, Transaction>) Fund(HdAccount account, int count, long satsEach)
{
var funding = Net.CreateTransaction();
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
for (var i = 0; i < count; i++)
funding.Outputs.Add(Money.Satoshis(satsEach), account.GetReceiveAddress(0));
var txid = funding.GetHash().ToString();
var utxos = Enumerable.Range(0, count).Select(i => new CachedUtxo
{
Txid = txid, Vout = i, ValueSats = satsEach,
Address = account.GetReceiveAddress(0).ToString(),
AddressIndex = 0, Height = 100, Verified = true,
}).ToList();
return (utxos, new Dictionary<string, Transaction> { [txid] = funding });
}
[Fact]
public void SendAll_WithBalanceBelowTheFee_IsRejectedAsInsufficientFunds()
{
var account = Account();
var (utxos, txs) = Fund(account, 1, 50); // 50 sat cannot pay for a ~110 vB transaction
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(5), amountSats: 0,
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 1_000, sendAll: true));
Assert.Contains("Insufficient funds", ex.Message);
}
[Fact]
public void SendAll_OverTheStandardSizeCap_IsRejectedWithGuidance()
{
var account = Account();
// ~68 vB per P2WPKH input: 1,700 inputs exceed the 100,000 vB standard limit.
var (utxos, txs) = Fund(account, 1_700, 10_000);
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(5), amountSats: 0,
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 1_000, sendAll: true));
Assert.Contains("Send-all cannot fit in one standard transaction", ex.Message);
}
[Fact]
public void AmountAboveTheBalance_IsRejectedAsInsufficientFunds()
{
var account = Account();
var (utxos, txs) = Fund(account, 1, 100_000);
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(5), amountSats: 500_000,
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 1_000));
}
}
@@ -0,0 +1,62 @@
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Tests.Wallet;
/// <summary>
/// Boundary tests of the single spendability gate: heights 0 and -1 are mempool, never confirmed.
/// </summary>
public class UtxoSpendabilityTests
{
private static readonly ChainProfile Profile = ChainProfiles.Mainnet;
private static CachedUtxo Utxo(int height, bool verified = true, bool frozen = false, bool coinbase = false) => new()
{
Txid = "aa", Vout = 0, ValueSats = 1, Address = "a", Height = height,
Verified = verified, Frozen = frozen, IsCoinbase = coinbase,
};
[Theory]
[InlineData(0)] // unconfirmed
[InlineData(-1)] // unconfirmed with unconfirmed parents (Electrum convention)
public void MempoolUtxos_HaveZeroConfirmations_AndAreNeverSpendable(int height)
{
var utxo = Utxo(height, verified: true);
Assert.Equal(0, utxo.Confirmations(tipHeight: 1_000));
Assert.False(utxo.IsSpendable(Profile, tipHeight: 1_000));
}
[Theory]
[InlineData(100, 100, 1)]
[InlineData(100, 105, 6)]
public void Confirmations_CountTheBlockItself(int height, int tip, int expected) =>
Assert.Equal(expected, Utxo(height).Confirmations(tip));
[Fact]
public void RegularOutput_BecomesSpendableExactlyAtMinConfirmations()
{
var min = Profile.MinConfirmations;
var utxo = Utxo(height: 100);
Assert.False(utxo.IsSpendable(Profile, tipHeight: 100 + min - 2)); // min-1 confirmations
Assert.True(utxo.IsSpendable(Profile, tipHeight: 100 + min - 1)); // min confirmations
}
[Fact]
public void Coinbase_NeedsMaturityPlusOne_Confirmations()
{
var utxo = Utxo(height: 100, coinbase: true);
var needed = Profile.CoinbaseMaturity + 1;
Assert.Equal(needed, utxo.RequiredConfirmations(Profile));
Assert.False(utxo.IsSpendable(Profile, tipHeight: 100 + needed - 2));
Assert.True(utxo.IsSpendable(Profile, tipHeight: 100 + needed - 1));
}
[Fact]
public void UnverifiedOrFrozen_AreNeverSpendable_EvenWhenDeeplyConfirmed()
{
Assert.False(Utxo(100, verified: false).IsSpendable(Profile, tipHeight: 10_000));
Assert.False(Utxo(100, frozen: true).IsSpendable(Profile, tipHeight: 10_000));
Assert.True(Utxo(100).IsSpendable(Profile, tipHeight: 10_000));
}
}