Files
PalladiumWallet/tests/PalladiumWallet.Tests/Wallet/FeePolicyTests.cs
T
davide c8ba2294f2 feat(app): suggest fee rates from the server and enforce the relay fee
After connecting, the Send tab offers Fast/Normal/Economy rates from the
server's fee estimates (never below the relay fee, implausible values
ignored). A manual rate below the relay fee is refused before building and
a fee above 1% of the amount is flagged in the preview. See ADR 0039.
2026-11-03 14:33:32 +01:00

158 lines
6.1 KiB
C#

using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Wallet;
using PalladiumWallet.Tests.Net;
namespace PalladiumWallet.Tests.Wallet;
public class FeePolicyTests
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
// ---- conversion coin/kB → sat/vB ----
[Theory]
[InlineData("0.00001", "1")] // 1,000 sat/kB = 1 sat/vB
[InlineData("0.00002", "2")]
[InlineData("0.000015", "1.5")]
[InlineData("0.0000125", "1.25")]
[InlineData("0.00000123", "0.12")] // rounded to 2 decimals
[InlineData("0.01", "1000")] // exactly the ceiling
public void ToSatPerVbyte_converts(string coinPerKb, string expected) =>
Assert.Equal(decimal.Parse(expected, System.Globalization.CultureInfo.InvariantCulture),
FeePolicy.ToSatPerVbyte(decimal.Parse(coinPerKb, System.Globalization.CultureInfo.InvariantCulture)));
[Theory]
[InlineData("-1")] // "no estimate"
[InlineData("0")]
[InlineData("-0.5")]
[InlineData("0.0100001")] // just above 1000 sat/vB
[InlineData("50")] // absurd
[InlineData("0.0000000001")] // rounds to 0
public void ToSatPerVbyte_rejects_noestimate_and_implausible(string coinPerKb) =>
Assert.Null(FeePolicy.ToSatPerVbyte(decimal.Parse(coinPerKb, System.Globalization.CultureInfo.InvariantCulture)));
// ---- relay floor, default, checks ----
[Fact]
public void AtLeastRelay_raises_a_low_estimate_and_keeps_a_high_one()
{
Assert.Equal(2m, FeePolicy.AtLeastRelay(1m, 2m));
Assert.Equal(5m, FeePolicy.AtLeastRelay(5m, 2m));
Assert.Equal(1m, FeePolicy.AtLeastRelay(1m, null));
Assert.Null(FeePolicy.AtLeastRelay(null, 2m));
}
[Fact]
public void DefaultRate_is_normal_never_below_relay_and_falls_back_to_one()
{
Assert.Equal(1m, FeePolicy.DefaultRate(null));
Assert.Equal(1m, FeePolicy.DefaultRate(new FeeEstimates(null, null, null, null)));
Assert.Equal(3m, FeePolicy.DefaultRate(new FeeEstimates(1m, 9m, 3m, 1m)));
Assert.Equal(4m, FeePolicy.DefaultRate(new FeeEstimates(4m, null, null, null)));
}
[Theory]
[InlineData("0.5", "1", true)]
[InlineData("1", "1", false)]
[InlineData("2", "1", false)]
public void IsBelowRelay_compares_against_the_relay_fee(string rate, string relay, bool expected) =>
Assert.Equal(expected, FeePolicy.IsBelowRelay(decimal.Parse(rate), decimal.Parse(relay)));
[Fact]
public void IsBelowRelay_without_a_known_relay_fee_never_blocks() =>
Assert.False(FeePolicy.IsBelowRelay(0.01m, null));
[Theory]
[InlineData(100, 10_000, false)] // exactly 1%
[InlineData(101, 10_000, true)]
[InlineData(5_000, 10_000, true)]
[InlineData(1, 0, false)] // no amount: nothing to compare
public void IsHighFee_flags_more_than_one_percent(long fee, long amount, bool expected) =>
Assert.Equal(expected, FeePolicy.IsHighFee(fee, amount));
[Fact]
public void Format_is_culture_independent()
{
var saved = System.Globalization.CultureInfo.CurrentCulture;
try
{
System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo("it-IT");
Assert.Equal("1.5", FeePolicy.Format(1.5m));
Assert.Equal("2", FeePolicy.Format(2.00m));
}
finally { System.Globalization.CultureInfo.CurrentCulture = saved; }
}
// ---- Build / FetchAsync ----
[Fact]
public void Build_applies_the_relay_floor_and_drops_unusable_values()
{
var e = FeePolicy.Build(relayCoinPerKb: 0.00002m, fast: 0.00001m, normal: -1m, economy: 0.0001m);
Assert.Equal(2m, e.Relay);
Assert.Equal(2m, e.Fast); // 1 raised to the relay fee 2
Assert.Null(e.Normal); // -1: no estimate
Assert.Equal(10m, e.Economy);
Assert.True(e.HasAny);
}
[Fact]
public void Build_with_nothing_usable_has_no_estimates()
{
var e = FeePolicy.Build(null, -1m, -1m, -1m);
Assert.False(e.HasAny);
Assert.Null(e.Relay);
}
[Fact]
public async Task FetchAsync_reads_the_relay_fee_and_the_three_targets()
{
await using var server = new FakeElectrumServer();
server.Handle("blockchain.relayfee", _ => 0.00001m);
server.Handle("blockchain.estimatefee", p => p[0].GetInt32() switch
{
FeePolicy.FastTarget => 0.00005m,
FeePolicy.NormalTarget => 0.00002m,
_ => 0.00001m,
});
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var e = await FeePolicy.FetchAsync(client).WaitAsync(Timeout);
Assert.Equal(new FeeEstimates(1m, 5m, 2m, 1m), e);
}
[Fact]
public async Task FetchAsync_tolerates_a_server_without_the_methods_and_a_minus_one()
{
await using var server = new FakeElectrumServer(); // no handlers: "unknown method" errors
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var none = await FeePolicy.FetchAsync(client).WaitAsync(Timeout);
Assert.False(none.HasAny);
server.Handle("blockchain.relayfee", _ => 0.00001m);
server.Handle("blockchain.estimatefee", _ => -1);
var minusOne = await FeePolicy.FetchAsync(client).WaitAsync(Timeout);
Assert.False(minusOne.HasAny);
Assert.Equal(1m, minusOne.Relay);
}
[Fact]
public async Task FetchAsync_ignores_an_absurd_estimate_and_a_malformed_answer()
{
await using var server = new FakeElectrumServer();
server.Handle("blockchain.relayfee", _ => "not a number");
server.Handle("blockchain.estimatefee", p => p[0].GetInt32() == FeePolicy.FastTarget ? 100m : 0.00003m);
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
var e = await FeePolicy.FetchAsync(client).WaitAsync(Timeout);
Assert.Null(e.Relay);
Assert.Null(e.Fast); // 100 PLM/kB = 10,000,000 sat/vB: implausible, ignored
Assert.Equal(3m, e.Normal);
Assert.Equal(3m, e.Economy);
}
}