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.
This commit is contained in:
davide committed 2026-11-03 14:33:32 +01:00
1 parent 92fd783658
commit c8ba2294f2
13 files changed
+507 -7

No files matched your search

@@ -396,6 +396,91 @@ public sealed class MainWindowViewModelTests : VmTestBase
Assert.True(_server.CallCount("blockchain.transaction.get_merkle") >= 1); // Merkle proof was demanded
});
[Fact]
public Task FeePresets_ComeFromTheServer_AndTheNormalEstimateIsPreselected() => UiSession.Run(async () =>
{
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
WireFundedChain(account.GetReceiveAddress(0), 50_000_000);
_server.Handle("blockchain.relayfee", _ => 0.00001m);
_server.Handle("blockchain.estimatefee", p => p[0].GetInt32() switch
{
FeePolicy.FastTarget => 0.00005m,
FeePolicy.NormalTarget => 0.00003m,
_ => 0.00001m,
});
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, null, "fees");
await Eventually(() => vm.HasFeeEstimates && !vm.IsSyncing);
Assert.Equal("3", vm.SendFeeRate); // normal, preselected
Assert.Contains("5", vm.FeeEstimateHint);
vm.UseFeePresetCommand.Execute("fast");
Assert.Equal("5", vm.SendFeeRate);
vm.UseFeePresetCommand.Execute("economy");
Assert.Equal("1", vm.SendFeeRate);
});
[Fact]
public Task FeePresets_DoNotOverrideARateTheUserAlreadyTyped() => UiSession.Run(async () =>
{
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
WireFundedChain(account.GetReceiveAddress(0), 50_000_000);
_server.Handle("blockchain.relayfee", _ => 0.00001m);
_server.Handle("blockchain.estimatefee", _ => 0.00004m);
var vm = new MainWindowViewModel();
vm.SendFeeRate = "7"; // typed before the estimates arrive
RestoreFlow(vm, Mnemonic, null, "fees2");
await Eventually(() => vm.HasFeeEstimates && !vm.IsSyncing);
Assert.Equal("7", vm.SendFeeRate);
});
[Fact]
public Task WithoutServerEstimates_TheManualDefaultStays_AndNoPresetsAreShown() => UiSession.Run(async () =>
{
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
WireFundedChain(account.GetReceiveAddress(0), 50_000_000); // the fake answers "unknown method"
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, null, "fees3");
await Eventually(() => !vm.IsSyncing && vm.History.Count == 1);
await Task.Delay(300); // let the optional estimate request settle
Assert.False(vm.HasFeeEstimates);
Assert.Equal("1", vm.SendFeeRate);
vm.UseFeePresetCommand.Execute("fast"); // nothing to apply
Assert.Equal("1", vm.SendFeeRate);
});
[Fact]
public Task Send_RejectsARateBelowTheRelayFee_AndWarnsWhenTheFeeIsLarge() => UiSession.Run(async () =>
{
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
WireFundedChain(account.GetReceiveAddress(0), 50_000_000);
_server.Handle("blockchain.relayfee", _ => 0.00002m); // 2 sat/vB
_server.Handle("blockchain.estimatefee", _ => 0.00002m);
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, null, "fees4");
await Eventually(() => vm.HasFeeEstimates && !vm.IsSyncing && vm.History.Count == 1);
vm.SendTo = account.GetReceiveAddress(7).ToString();
vm.SendAmount = "0.1";
vm.SendFeeRate = "1"; // below the relay fee
await vm.PrepareSendCommand.ExecuteAsync(null);
Assert.False(vm.HasPendingSend);
Assert.Equal(string.Format(Loc.Tr("msg.feerate.belowrelay"), "2"), vm.SendPreview);
vm.SendFeeRate = "2";
await vm.PrepareSendCommand.ExecuteAsync(null);
Assert.True(vm.HasPendingSend, vm.SendPreview);
Assert.DoesNotContain(string.Format(Loc.Tr("msg.fee.high"), 1), vm.SendPreview);
vm.SendAmount = "0.00001"; // 1,000 sat: the fee is >1%
vm.SendFeeRate = "50";
await vm.PrepareSendCommand.ExecuteAsync(null);
Assert.True(vm.HasPendingSend, vm.SendPreview);
Assert.Contains(string.Format(Loc.Tr("msg.fee.high"), 1), vm.SendPreview);
});
[Fact]
public Task Send_PreparesASignedTransaction_ForAFundedWallet_AndRejectsABadAmount() => UiSession.Run(async () =>
{
@@ -0,0 +1,157 @@
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);
}
}