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:
1 parent
cb93927d15
commit
54d07f8e92
7 files changed
+536
-2
No files matched your search
@@ -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<NotSupportedException>(() => 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<ArgumentOutOfRangeException>(() => DerivationPaths.PurposeFor((ScriptKind)999));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => DerivationPaths.ScriptPubKeyTypeFor((ScriptKind)999));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeAccountOrIndex_IsRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => DerivationPaths.AccountPath(ScriptKind.NativeSegwit, ChainProfiles.Mainnet, -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => 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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.Tests.Net;
|
||||
|
||||
/// <summary>Concurrency guarantees: the in-flight cap, disposal under load, exclusive wallet lock.</summary>
|
||||
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<bool> 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<OperationCanceledException>(() => 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<Exception>(() => 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 { } }
|
||||
}
|
||||
}
|
||||
@@ -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<SpvVerificationException>(
|
||||
() => 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.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user