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 () =>
{