From 54d07f8e92f02c87be23dc1cb49220d4b14fd61d Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 3 Nov 2026 11:45:43 +0100 Subject: [PATCH] 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. --- .../Crypto/DerivationPathsEdgeCaseTests.cs | 56 ++++++++ .../Net/ConcurrencyTests.cs | 98 +++++++++++++ .../Spv/WalletSynchronizerTests.cs | 28 +++- .../Storage/StorageEdgeCaseTests.cs | 87 ++++++++++++ .../Wallet/BoundaryAndMessageTests.cs | 133 ++++++++++++++++++ .../Wallet/TransactionFactoryLimitsTests.cs | 74 ++++++++++ .../Wallet/UtxoSpendabilityTests.cs | 62 ++++++++ 7 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 tests/PalladiumWallet.Tests/Crypto/DerivationPathsEdgeCaseTests.cs create mode 100644 tests/PalladiumWallet.Tests/Net/ConcurrencyTests.cs create mode 100644 tests/PalladiumWallet.Tests/Storage/StorageEdgeCaseTests.cs create mode 100644 tests/PalladiumWallet.Tests/Wallet/BoundaryAndMessageTests.cs create mode 100644 tests/PalladiumWallet.Tests/Wallet/TransactionFactoryLimitsTests.cs create mode 100644 tests/PalladiumWallet.Tests/Wallet/UtxoSpendabilityTests.cs diff --git a/tests/PalladiumWallet.Tests/Crypto/DerivationPathsEdgeCaseTests.cs b/tests/PalladiumWallet.Tests/Crypto/DerivationPathsEdgeCaseTests.cs new file mode 100644 index 0000000..eb88716 --- /dev/null +++ b/tests/PalladiumWallet.Tests/Crypto/DerivationPathsEdgeCaseTests.cs @@ -0,0 +1,56 @@ +using NBitcoin; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; + +namespace PalladiumWallet.Tests.Crypto; + +public class DerivationPathsEdgeCaseTests +{ + [Theory] + [InlineData(ScriptKind.WrappedSegwitMultisig)] + [InlineData(ScriptKind.NativeSegwitMultisig)] + public void Multisig_Kinds_FailLoudlyInsteadOfDerivingAWrongWallet(ScriptKind kind) => + Assert.Throws(() => DerivationPaths.ScriptPubKeyTypeFor(kind)); + + [Fact] + public void Multisig_UsesBip48Purpose_AndTaprootBip86() + { + Assert.Equal(48, DerivationPaths.PurposeFor(ScriptKind.NativeSegwitMultisig)); + Assert.Equal(48, DerivationPaths.PurposeFor(ScriptKind.WrappedSegwitMultisig)); + Assert.Equal(86, DerivationPaths.PurposeFor(ScriptKind.Taproot)); + } + + [Fact] + public void UnknownScriptKind_IsRejected() + { + Assert.Throws(() => DerivationPaths.PurposeFor((ScriptKind)999)); + Assert.Throws(() => DerivationPaths.ScriptPubKeyTypeFor((ScriptKind)999)); + } + + [Fact] + public void NegativeAccountOrIndex_IsRejected() + { + Assert.Throws(() => DerivationPaths.AccountPath(ScriptKind.NativeSegwit, ChainProfiles.Mainnet, -1)); + Assert.Throws(() => DerivationPaths.AddressSubPath(false, -1)); + } + + [Fact] + public void AccountPath_UsesCoinTypeFromProfile() + { + Assert.Equal("84'/746'/0'", DerivationPaths.AccountPath(ScriptKind.NativeSegwit, ChainProfiles.Mainnet).ToString()); + Assert.Equal("49'/746'/2'", DerivationPaths.AccountPath(ScriptKind.WrappedSegwit, ChainProfiles.Mainnet, 2).ToString()); + } + + [Fact] + public void KindFor_MapsAddressTypes() + { + var net = PalladiumNetworks.Regtest; + var key = new Key(); + Assert.Equal(ScriptKind.NativeSegwit, DerivationPaths.KindFor(key.PubKey.GetAddress(ScriptPubKeyType.Segwit, net))); + Assert.Equal(ScriptKind.WrappedSegwit, DerivationPaths.KindFor(key.PubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, net))); + Assert.Equal(ScriptKind.Legacy, DerivationPaths.KindFor(key.PubKey.GetAddress(ScriptPubKeyType.Legacy, net))); + Assert.Equal(ScriptKind.Taproot, DerivationPaths.KindFor(key.PubKey.GetAddress(ScriptPubKeyType.TaprootBIP86, net))); + Assert.Equal(ScriptKind.NativeSegwitMultisig, + DerivationPaths.KindFor(key.PubKey.WitHash.ScriptPubKey.WitHash.GetAddress(net))); + } +} diff --git a/tests/PalladiumWallet.Tests/Net/ConcurrencyTests.cs b/tests/PalladiumWallet.Tests/Net/ConcurrencyTests.cs new file mode 100644 index 0000000..62e318e --- /dev/null +++ b/tests/PalladiumWallet.Tests/Net/ConcurrencyTests.cs @@ -0,0 +1,98 @@ +using PalladiumWallet.Core.Net; +using PalladiumWallet.Core.Storage; + +namespace PalladiumWallet.Tests.Net; + +/// Concurrency guarantees: the in-flight cap, disposal under load, exclusive wallet lock. +public class ConcurrencyTests +{ + // Generous: the rest of the suite (property tests) saturates the CPU while these run. + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60); + + private static async Task WaitFor(Func condition) + { + var deadline = DateTime.UtcNow + Timeout; + while (!condition()) + { + Assert.True(DateTime.UtcNow < deadline, "condition not reached in time"); + await Task.Delay(10); + } + } + + [Fact] + public async Task InFlightRequests_AreCappedAtTheConfiguredLimit() + { + await using var server = new FakeElectrumServer(); + server.Handle("hold", _ => FakeElectrumServer.NoResponse); // received, never answered + await using var client = await ElectrumClient.ConnectAsync( + server.Host, server.Port, useSsl: false, maxInFlight: 4); + + using var cts = new CancellationTokenSource(); + var requests = Enumerable.Range(0, 20) + .Select(_ => client.RequestAsync("hold", cts.Token)).ToList(); + + await WaitFor(() => server.CallCount("hold") == 4); + await Task.Delay(250); // the other 16 must stay queued client-side, not reach the server + Assert.Equal(4, server.CallCount("hold")); + + cts.Cancel(); + foreach (var r in requests) + await Assert.ThrowsAnyAsync(() => r.WaitAsync(Timeout)); + } + + [Fact] + public async Task CompletedRequests_ReleaseTheirSlot_SoAllRequestsEventuallyFinish() + { + await using var server = new FakeElectrumServer(); + server.Handle("echo", p => p[0].GetInt32()); + await using var client = await ElectrumClient.ConnectAsync( + server.Host, server.Port, useSsl: false, maxInFlight: 2); + + var results = await Task.WhenAll(Enumerable.Range(0, 100) + .Select(i => client.RequestAsync("echo", default, i))).WaitAsync(Timeout); + + Assert.Equal(Enumerable.Range(0, 100), results.Select(r => r.GetInt32())); + } + + [Fact] + public async Task DisposingTheClient_WithRequestsInFlight_DoesNotHang() + { + await using var server = new FakeElectrumServer(); + server.Handle("hold", _ => FakeElectrumServer.NoResponse); + var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false); + var pending = Enumerable.Range(0, 10).Select(_ => client.RequestAsync("hold")).ToList(); + await WaitFor(() => server.CallCount("hold") == 10); + + await client.DisposeAsync().AsTask().WaitAsync(Timeout); + + foreach (var p in pending) + await Assert.ThrowsAnyAsync(() => p.WaitAsync(Timeout)); // failed, not stuck + } + + [Fact] + public async Task WalletLock_UnderContention_HasExactlyOneWinner() + { + var path = Path.Combine(Path.GetTempPath(), $"plm-lock-{Guid.NewGuid()}.wallet.json"); + try + { + using var start = new ManualResetEventSlim(false); + var attempts = Enumerable.Range(0, 16) + .Select(_ => Task.Run(() => { start.Wait(); return WalletLock.TryAcquire(path); })) + .ToList(); + start.Set(); + var locks = await Task.WhenAll(attempts); + + try + { + Assert.Single(locks, l => l is not null); + Assert.Equal(15, locks.Count(l => l is null)); + } + finally { foreach (var l in locks) l?.Dispose(); } + + // Released → acquirable again (no stale lock left behind). + using var again = WalletLock.TryAcquire(path); + Assert.NotNull(again); + } + finally { try { File.Delete(path); File.Delete(path + ".lock"); } catch { } } + } +} diff --git a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs index 1f3c4ac..1db3dd1 100644 --- a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs +++ b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs @@ -425,6 +425,30 @@ public class WalletSynchronizerTests Assert.Contains("Broken header chain at height 103", ex.Message); } + [Fact] + public async Task A_server_returning_no_headers_during_anchoring_fails_the_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 checkpointAccount = Account(Profile with + { + Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)], + }); + + var (server, client) = await StartAsync(scenario); + await using var _ = server; await using var __ = client; + // A dishonest/pruned server answers the range request with zero headers. + server.Handle("blockchain.block.headers", _ => new { count = 0, hex = "" }); + + var ex = await Assert.ThrowsAsync( + () => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync()); + Assert.Contains("returned no headers", ex.Message); + } + [Fact] public async Task Senza_checkpoint_a_copertura_dell_altezza_l_anchoring_e_un_no_op() { @@ -469,7 +493,7 @@ public class WalletSynchronizerTests sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); }; var syncTask = sync.SyncOnceAsync(); - await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(30)); Assert.NotNull(partial); Assert.False(Assert.Single(partial!.History).Verified); @@ -513,7 +537,7 @@ public class WalletSynchronizerTests sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); }; var syncTask = sync.SyncOnceAsync(); - await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(30)); // 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. diff --git a/tests/PalladiumWallet.Tests/Storage/StorageEdgeCaseTests.cs b/tests/PalladiumWallet.Tests/Storage/StorageEdgeCaseTests.cs new file mode 100644 index 0000000..9266ae7 --- /dev/null +++ b/tests/PalladiumWallet.Tests/Storage/StorageEdgeCaseTests.cs @@ -0,0 +1,87 @@ +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; +using PalladiumWallet.Core.Storage; + +namespace PalladiumWallet.Tests.Storage; + +[Collection("AppPaths")] // DefaultDataRoot reads the static override seam +public class StorageEdgeCaseTests +{ + [Fact] + public void SyncCache_RoundTripsEveryField_ThroughEncryptedStore() + { + var doc = new WalletDocument + { + Network = "regtest", + ScriptKind = nameof(ScriptKind.NativeSegwit), + Cache = new SyncCache + { + TipHeight = 123, ConfirmedSats = 5_000, UnconfirmedSats = 7, ImmatureSats = 100, + PendingVerificationSats = 200, SpendableSats = 4_700, NextReceiveIndex = 3, NextChangeIndex = 2, + History = [new CachedTx { Txid = "aa", Height = 10, DeltaSats = -50, Verified = true }], + Utxos = [new CachedUtxo { Txid = "bb", Vout = 1, ValueSats = 99, Address = "a", IsChange = true, + AddressIndex = 4, Height = 11, IsCoinbase = true, Frozen = true, Verified = true }], + Addresses = [new CachedAddress { Address = "a", IsChange = true, Index = 4, BalanceSats = 99, TxCount = 2 }], + RawTxHex = new() { ["aa"] = "0102" }, + VerifiedAt = new() { ["aa"] = 10 }, + BlockHeaders = new() { [10] = "00" }, + AnchoredUpTo = new() { [100] = 150 }, + }, + }; + var path = Path.Combine(Path.GetTempPath(), $"plm-cache-{Guid.NewGuid()}.json"); + try + { + WalletStore.Save(doc, path, "pw"); + var c = WalletStore.Load(path, "pw").Cache!; + + Assert.Equal((123, 5_000, 7, 100, 200, 4_700, 3, 2), + (c.TipHeight, c.ConfirmedSats, c.UnconfirmedSats, c.ImmatureSats, c.PendingVerificationSats, + c.SpendableSats, c.NextReceiveIndex, c.NextChangeIndex)); + Assert.True(c.History[0].Verified); + Assert.True(c.Utxos[0].Frozen && c.Utxos[0].IsCoinbase && c.Utxos[0].IsChange); + Assert.Equal(2, c.Addresses[0].TxCount); + Assert.Equal("0102", c.RawTxHex!["aa"]); + Assert.Equal(10, c.VerifiedAt!["aa"]); + Assert.Equal("00", c.BlockHeaders![10]); + Assert.Equal(150, c.AnchoredUpTo![100]); + } + finally { File.Delete(path); } + } + + [Theory] + [InlineData("{ not json")] + [InlineData("")] + [InlineData("null")] + public void AppConfig_FallsBackToDefaults_OnCorruptedOrEmptyFile(string content) + { + var path = Path.Combine(Path.GetTempPath(), $"plm-config-{Guid.NewGuid()}.json"); + try + { + File.WriteAllText(path, content); + var config = AppConfig.Load(path); + Assert.NotNull(config); + Assert.Null(config.LastServerHost); + } + finally { File.Delete(path); } + } + + [Fact] + public void AppConfig_Load_ReturnsDefaultsWhenFileIsMissing() => + Assert.NotNull(AppConfig.Load(Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid()}.json"))); + + [Fact] + public void DefaultDataRoot_FollowsPlatformConvention() + { + var saved = AppPaths.DefaultRootOverride; + AppPaths.DefaultRootOverride = null; + try + { + var root = AppPaths.DefaultDataRoot(); + if (OperatingSystem.IsWindows()) + Assert.EndsWith(AppPaths.AppDirName, root); + else + Assert.EndsWith(".palladium-wallet", root); + } + finally { AppPaths.DefaultRootOverride = saved; } + } +} diff --git a/tests/PalladiumWallet.Tests/Wallet/BoundaryAndMessageTests.cs b/tests/PalladiumWallet.Tests/Wallet/BoundaryAndMessageTests.cs new file mode 100644 index 0000000..425372a --- /dev/null +++ b/tests/PalladiumWallet.Tests/Wallet/BoundaryAndMessageTests.cs @@ -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; + +/// +/// Boundary values, unit formatting, derivation-path round trips and user-facing error messages +/// that the other suites did not pin down. +/// +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(() => 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(() => 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(() => + 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(() => WalletLoader.NewFromXpub("garbage", Profile, ScriptKind.NativeSegwit)); + Assert.Contains("public", pub.Message, StringComparison.OrdinalIgnoreCase); + var prv = Assert.Throws(() => 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); } + } +} diff --git a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryLimitsTests.cs b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryLimitsTests.cs new file mode 100644 index 0000000..db5ec41 --- /dev/null +++ b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryLimitsTests.cs @@ -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; + +/// Spend-policy limits: send-all affordability and the standard-size cap. +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); + } + + /// One funding tx with outputs to receive/0, each a verified UTXO. + private static (List, Dictionary) 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 { [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(() => 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(() => 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(() => new TransactionFactory(account).Build( + utxos, txs, account.GetReceiveAddress(5), amountSats: 500_000, + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 1_000)); + } +} diff --git a/tests/PalladiumWallet.Tests/Wallet/UtxoSpendabilityTests.cs b/tests/PalladiumWallet.Tests/Wallet/UtxoSpendabilityTests.cs new file mode 100644 index 0000000..42bd2bf --- /dev/null +++ b/tests/PalladiumWallet.Tests/Wallet/UtxoSpendabilityTests.cs @@ -0,0 +1,62 @@ +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Storage; +using PalladiumWallet.Core.Wallet; + +namespace PalladiumWallet.Tests.Wallet; + +/// +/// Boundary tests of the single spendability gate: heights 0 and -1 are mempool, never confirmed. +/// +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)); + } +}