diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3e920cd..9858703 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,16 @@ release and covers the full history from the initial commit.
## [Unreleased]
+### Added
+
+- Fee presets from the server: after connecting, the Send tab offers Fast, Normal and Economy
+ rates from `blockchain.estimatefee` (2, 6 and 24 blocks) and shows them with the relay fee;
+ Normal is preselected unless the user already typed a rate. Estimates are converted from
+ PLM/kB to sat/vB, never go below the relay fee, and implausible values are ignored; without
+ an estimate the manual field and the 1 sat/vB default remain. A manual rate below the relay
+ fee is refused before building, and a fee above 1% of the amount sent is flagged in the
+ preview. Applies to Send and Donate. See ADR 0039.
+
### Changed
- **CLI (behaviour change):** the wallet password no longer has to be a command-line argument.
diff --git a/SECURITY.md b/SECURITY.md
index 8bf9e52..e50d253 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -35,6 +35,7 @@ The indexing server (ElectrumX-compatible, port 50001/50002) is a **semi-trusted
- Lie about unconfirmed (mempool) transactions — the wallet shows mempool transactions as unconfirmed and non-spendable
- Refuse to relay a broadcast transaction
- Delay reporting of new blocks
+- Suggest misleading fee estimates or relay fee (the wallet ignores implausible values, never goes below the relay fee, and always shows the absolute fee, flagging one above 1% of the amount — ADR 0039)
It cannot (given correct Merkle verification):
diff --git a/USERGUIDE.md b/USERGUIDE.md
index fb5cee1..177d4b6 100644
--- a/USERGUIDE.md
+++ b/USERGUIDE.md
@@ -379,11 +379,19 @@ wallet must be spendable (not watch-only) to actually broadcast.
than 1 satoshi (0.00000001 PLM).
- **Send all** — checkbox; disables the amount field and sends the entire spendable balance,
**with the fee deducted from the amount** (the recipient receives balance − fee).
-- **Fee (sat/vB)** — a single manually-entered fee rate. **Default: 1 sat/vB.** There are no
- presets and no automatic fee estimation. On the Palladium chain, blocks are rarely full,
- so 1 sat/vB normally confirms in the next few blocks; raise it if a transaction lingers in
- the mempool. Must be a number greater than zero and at most 1000; `.` or `,` is the decimal
- separator (no thousands separators), regardless of the system language.
+- **Fee (sat/vB)** — the fee rate, which you can always type by hand. **Default: 1 sat/vB.**
+ Once connected, the wallet asks the server for the minimum relay fee and for estimates, and
+ shows three presets — **Fast** (~2 blocks), **Normal** (~6) and **Economy** (~24) — with the
+ numbers underneath; **Normal** is preselected unless you already typed a rate. An estimate
+ never goes below the relay fee, and an implausible one (above 1000 sat/vB) is ignored. If the
+ server gives no estimate the presets are hidden and the manual field stays at 1. A manual rate
+ below the server's relay fee is refused with a message (the node would reject it anyway).
+ The estimate comes from the server, which is not trusted: the preview always shows the
+ **absolute fee**, and warns when it is more than 1% of the amount sent. On the Palladium
+ chain, blocks are rarely full, so 1 sat/vB normally confirms in the next few blocks; raise it
+ if a transaction lingers in the mempool. Must be a number greater than zero and at most 1000;
+ `.` or `,` is the decimal separator (no thousands separators), regardless of the system
+ language.
- **QR scan** (Android only) — camera button; reads a plain address or a
`palladium:` payment URI (the address part is used).
diff --git a/docs/adr/0039-server-fee-estimates-and-relay-floor.md b/docs/adr/0039-server-fee-estimates-and-relay-floor.md
new file mode 100644
index 0000000..79450f0
--- /dev/null
+++ b/docs/adr/0039-server-fee-estimates-and-relay-floor.md
@@ -0,0 +1,31 @@
+# 0039. Server fee estimates, relay floor and a high-fee warning
+
+- **Status:** Accepted
+- **Area:** Wallet
+- **Recorded:** 2026-11-03
+
+## Context
+
+`EstimateFeeAsync` and `RelayFeeAsync` existed but nothing called them. The default fee rate was the constant 1 sat/vB and the only validation was "greater than zero" (and the F-01 upper bound). A rate below the node's minimum relay fee built a transaction that was rejected only at broadcast, with a generic server error, and a congested chain gave no guidance. ADR 0017 chose a single manual fee rate; this extends it without replacing the manual field.
+
+## Decision
+
+- After connecting, the ViewModel asks the server for `blockchain.relayfee` and `blockchain.estimatefee` with targets 2, 6 and 24 blocks (`FeePolicy.FetchAsync`, `Core/Wallet/FeePolicy.cs`). Each call is optional: an error or a malformed answer leaves that value empty.
+- Values are converted from coin/kB to sat/vB (× 100,000,000 / 1,000), rounded to 2 decimals. `-1` (no estimate), zero, negative, or above `CoinAmount.MaxFeeRate` (1000 sat/vB) is discarded as implausible. An estimate is raised to the relay fee if lower.
+- The Send form offers Fast, Normal and Economy buttons and a hint line with the numbers; Normal is preselected only if the user has not typed a rate. Without usable estimates the presets are hidden and the manual field keeps the 1 sat/vB default.
+- A manual rate below the known relay fee is refused before building, with a message naming the minimum. Donate follows the same rules.
+- The estimate is untrusted input, so the absolute fee is always shown, and the preview adds a warning when the fee exceeds 1% of the amount sent (`FeePolicy.IsHighFee`). The warning is advisory, not blocking.
+
+## Consequences
+
+- A rate the node would reject is caught locally with a clear message; the default follows the network instead of a constant.
+- A malicious server can still suggest a high-but-plausible rate (up to 1000 sat/vB); the bound, the visible absolute fee and the 1% warning limit the damage, and the user can always type a rate.
+- Presets fetched once per connection are not refreshed during a long session; reconnecting refreshes them.
+- The relay-fee check relies on a value from the same server; a server that reports a high relay fee can only make the wallet more conservative, not less.
+
+## References
+
+- `src/Core/Wallet/FeePolicy.cs`
+- `src/App/ViewModels/MainWindowViewModel.Send.cs`
+- `tests/PalladiumWallet.Tests/Wallet/FeePolicyTests.cs`
+- Related: 0017
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 945668b..44563b7 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -42,6 +42,7 @@ One ADR per feature or significant design decision. Format: `template.md`. Workf
| 0036 | [Verify the chain identity of a server right after connecting](0036-verify-chain-identity-on-connect.md) | SPV | Accepted |
| 0037 | [Derive an address private key only when it is revealed](0037-derive-address-private-keys-on-reveal.md) | Security | Accepted |
| 0038 | [CLI password sources and explicit plaintext storage](0038-cli-password-sources-and-explicit-plaintext.md) | Distribution | Accepted |
+| 0039 | [Server fee estimates, relay floor and a high-fee warning](0039-server-fee-estimates-and-relay-floor.md) | Wallet | Accepted |
ADRs `0002` onwards were written retroactively on 2026-11-03 from the code, comments and changelog.
If one misstates the original reasoning, fix it in a pull request.
diff --git a/src/App/Localization/Loc.cs b/src/App/Localization/Loc.cs
index 3411dcc..7ef3eaa 100644
--- a/src/App/Localization/Loc.cs
+++ b/src/App/Localization/Loc.cs
@@ -458,6 +458,12 @@ public sealed class Loc
["msg.broadcasted"] = ["Trasmessa", "Broadcast", "Transmitida", "Diffusée", "Transmitida", "Übertragen", "已广播"],
["msg.donate.thanks"] = ["Grazie! txid", "Thank you! txid", "¡Gracias! txid", "Merci ! txid", "Obrigado! txid", "Danke! txid", "谢谢!交易 ID"],
["msg.unsigned.watchonly"] = [" · NON firmata (watch-only)", " · NOT signed (watch-only)", " · NO firmada (watch-only)", " · NON signée (watch-only)", " · NÃO assinada (watch-only)", " · NICHT signiert (watch-only)", " · 未签名(仅观察)"],
+ ["send.fee.fast"] = ["Veloce", "Fast", "Rápida", "Rapide", "Rápida", "Schnell", "快速"],
+ ["send.fee.normal"] = ["Normale", "Normal", "Normal", "Normale", "Normal", "Normal", "正常"],
+ ["send.fee.economy"] = ["Economica", "Economy", "Económica", "Économique", "Econômica", "Sparsam", "经济"],
+ ["send.fee.hint"] = ["Stima della rete (sat/vB): veloce {0} · normale {1} · economica {2} · minimo relay {3}", "Network estimate (sat/vB): fast {0} · normal {1} · economy {2} · relay minimum {3}", "Estimación de la red (sat/vB): rápida {0} · normal {1} · económica {2} · mínimo de relay {3}", "Estimation du réseau (sat/vB) : rapide {0} · normale {1} · économique {2} · minimum de relais {3}", "Estimativa da rede (sat/vB): rápida {0} · normal {1} · econômica {2} · mínimo de relay {3}", "Netzwerkschätzung (sat/vB): schnell {0} · normal {1} · sparsam {2} · Relay-Minimum {3}", "网络估算 (sat/vB):快速 {0} · 正常 {1} · 经济 {2} · 中继最低 {3}"],
+ ["msg.feerate.belowrelay"] = ["Fee rate sotto il minimo di relay del server ({0} sat/vB): la transazione verrebbe rifiutata.", "Fee rate below the server's minimum relay fee ({0} sat/vB): the transaction would be rejected.", "Tarifa por debajo del mínimo de relay del servidor ({0} sat/vB): la transacción sería rechazada.", "Taux de frais inférieur au minimum de relais du serveur ({0} sat/vB) : la transaction serait rejetée.", "Taxa abaixo do mínimo de relay do servidor ({0} sat/vB): a transação seria rejeitada.", "Gebührenrate unter dem Relay-Minimum des Servers ({0} sat/vB): Die Transaktion würde abgelehnt.", "手续费率低于服务器的最低中继费 ({0} sat/vB):交易将被拒绝。"],
+ ["msg.fee.high"] = ["Attenzione: la fee supera l'{0}% dell'importo inviato.", "Warning: the fee is more than {0}% of the amount sent.", "Atención: la comisión supera el {0}% del importe enviado.", "Attention : les frais dépassent {0} % du montant envoyé.", "Atenção: a taxa excede {0}% do valor enviado.", "Achtung: Die Gebühr beträgt mehr als {0} % des gesendeten Betrags.", "警告:手续费超过发送金额的 {0}%。"],
["msg.cert.mismatch"] = [
"Il certificato TLS di {0} è cambiato rispetto a quello salvato. Se il server ha rinnovato il certificato, esegui il reset dei certificati SSL.",
"The TLS certificate of {0} has changed from the one saved. If the server renewed its certificate, reset the SSL certificates.",
diff --git a/src/App/ViewModels/MainWindowViewModel.Donate.cs b/src/App/ViewModels/MainWindowViewModel.Donate.cs
index 470d816..8c63db3 100644
--- a/src/App/ViewModels/MainWindowViewModel.Donate.cs
+++ b/src/App/ViewModels/MainWindowViewModel.Donate.cs
@@ -49,6 +49,12 @@ public partial class MainWindowViewModel
DonatePreview = Loc.Tr("msg.feerate.invalid");
return;
}
+ if (FeePolicy.IsBelowRelay(feeRate, _feeEstimates?.Relay))
+ {
+ DonatePreview = string.Format(Loc.Tr("msg.feerate.belowrelay"),
+ FeePolicy.Format(_feeEstimates!.Relay!.Value));
+ return;
+ }
_pendingDonate = new TransactionFactory(_account).Build(
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
@@ -57,7 +63,8 @@ public partial class MainWindowViewModel
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
$"({_pendingDonate.Transaction.GetVirtualSize()} vB)" +
- (_pendingDonate.Signed ? "" : " · watch-only");
+ (_pendingDonate.Signed ? "" : " · watch-only") +
+ HighFeeWarning(_pendingDonate.Fee.Satoshi, amount);
HasPendingDonate = _pendingDonate.Signed;
}
catch (Exception ex)
diff --git a/src/App/ViewModels/MainWindowViewModel.Send.cs b/src/App/ViewModels/MainWindowViewModel.Send.cs
index 4661076..f4ea07d 100644
--- a/src/App/ViewModels/MainWindowViewModel.Send.cs
+++ b/src/App/ViewModels/MainWindowViewModel.Send.cs
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using NBitcoin;
+using Avalonia.Threading;
using PalladiumWallet.App.Localization;
using PalladiumWallet.App.Services;
using PalladiumWallet.Core.Chain;
@@ -25,6 +26,73 @@ public partial class MainWindowViewModel
[ObservableProperty]
private bool sendAll;
+ // ---- fee presets from the server (ADR 0039) ----
+
+ private FeeEstimates? _feeEstimates;
+ private bool _feeRateTouched;
+ private bool _settingFeeRate;
+
+ [ObservableProperty]
+ private bool hasFeeEstimates;
+
+ [ObservableProperty]
+ private string feeEstimateHint = "";
+
+ partial void OnSendFeeRateChanged(string value)
+ {
+ if (!_settingFeeRate)
+ _feeRateTouched = true;
+ }
+
+ private void SetFeeRate(string value)
+ {
+ _settingFeeRate = true;
+ try { SendFeeRate = value; }
+ finally { _settingFeeRate = false; }
+ }
+
+ private async Task RefreshFeeEstimatesAsync(ElectrumClient client)
+ {
+ try
+ {
+ var estimates = await FeePolicy.FetchAsync(client);
+ Dispatcher.UIThread.Post(() => ApplyFeeEstimates(estimates));
+ }
+ catch
+ {
+ // Optional information: a failure leaves the manual field and the 1 sat/vB default.
+ }
+ }
+
+ private void ApplyFeeEstimates(FeeEstimates estimates)
+ {
+ _feeEstimates = estimates;
+ HasFeeEstimates = estimates.HasAny;
+ FeeEstimateHint = estimates.HasAny
+ ? string.Format(Loc.Tr("send.fee.hint"),
+ estimates.Fast is { } f ? FeePolicy.Format(f) : "—",
+ estimates.Normal is { } n ? FeePolicy.Format(n) : "—",
+ estimates.Economy is { } e ? FeePolicy.Format(e) : "—",
+ estimates.Relay is { } r ? FeePolicy.Format(r) : "—")
+ : "";
+ // Preselect the normal estimate unless the user already typed something.
+ if (!_feeRateTouched && estimates.HasAny)
+ SetFeeRate(FeePolicy.Format(FeePolicy.DefaultRate(estimates)));
+ }
+
+ [RelayCommand]
+ private void UseFeePreset(string preset)
+ {
+ var rate = preset switch
+ {
+ "fast" => _feeEstimates?.Fast,
+ "economy" => _feeEstimates?.Economy,
+ _ => _feeEstimates?.Normal,
+ };
+ if (rate is { } r)
+ SetFeeRate(FeePolicy.Format(r));
+ }
+
[ObservableProperty]
private string sendPreview = "";
@@ -79,6 +147,12 @@ public partial class MainWindowViewModel
SendPreview = Loc.Tr("msg.feerate.invalid");
return;
}
+ if (FeePolicy.IsBelowRelay(feeRate, _feeEstimates?.Relay))
+ {
+ SendPreview = string.Format(Loc.Tr("msg.feerate.belowrelay"),
+ FeePolicy.Format(_feeEstimates!.Relay!.Value));
+ return;
+ }
_pendingSend = new TransactionFactory(_account).Build(
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
@@ -87,7 +161,9 @@ public partial class MainWindowViewModel
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
- (_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly"));
+ (_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly")) +
+ HighFeeWarning(_pendingSend.Fee.Satoshi,
+ SendAll ? _pendingSend.Transaction.TotalOut.Satoshi : amount);
HasPendingSend = _pendingSend.Signed;
PendingPsbtBase64 = _pendingSend.Signed ? "" : _pendingSend.Psbt.ToBase64();
}
@@ -101,6 +177,15 @@ public partial class MainWindowViewModel
await Task.CompletedTask;
}
+ ///
+ /// Advisory text when the absolute fee is large against the amount: the estimate comes from
+ /// an untrusted server, so the resulting fee is always shown and flagged, never just followed.
+ ///
+ private string HighFeeWarning(long feeSats, long amountSats) =>
+ FeePolicy.IsHighFee(feeSats, amountSats)
+ ? "\n" + string.Format(Loc.Tr("msg.fee.high"), FeePolicy.HighFeePercent)
+ : "";
+
[RelayCommand]
private async Task ConfirmSend()
{
diff --git a/src/App/ViewModels/MainWindowViewModel.Sync.cs b/src/App/ViewModels/MainWindowViewModel.Sync.cs
index eb37d85..dedfc69 100644
--- a/src/App/ViewModels/MainWindowViewModel.Sync.cs
+++ b/src/App/ViewModels/MainWindowViewModel.Sync.cs
@@ -261,6 +261,8 @@ public partial class MainWindowViewModel
}
if (lastError is not null)
throw lastError;
+ if (_client is { } fresh)
+ _ = RefreshFeeEstimatesAsync(fresh);
}
if (_account is null || _doc is null)
diff --git a/src/App/Views/MainView.axaml b/src/App/Views/MainView.axaml
index 497a04b..a8b9bd7 100644
--- a/src/App/Views/MainView.axaml
+++ b/src/App/Views/MainView.axaml
@@ -504,6 +504,16 @@
MaxWidth="120" HorizontalAlignment="Left"
FontFamily="monospace"/>
+
+
+
+
+
+
+
+
+
@@ -626,6 +636,16 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Core/Wallet/FeePolicy.cs b/src/Core/Wallet/FeePolicy.cs
new file mode 100644
index 0000000..686aa96
--- /dev/null
+++ b/src/Core/Wallet/FeePolicy.cs
@@ -0,0 +1,87 @@
+using System.Globalization;
+using System.Text.Json;
+using PalladiumWallet.Core.Net;
+
+namespace PalladiumWallet.Core.Wallet;
+
+///
+/// Fee suggestions from the indexing server (untrusted input): the relay fee and estimates for
+/// three confirmation targets, in sat/vB. A null value means the server could not provide one
+/// or gave an implausible one (ADR 0039).
+///
+public sealed record FeeEstimates(decimal? Relay, decimal? Fast, decimal? Normal, decimal? Economy)
+{
+ public bool HasAny => Fast is not null || Normal is not null || Economy is not null;
+}
+
+/// Conversions and checks for the fee rate, kept pure so they can be tested exhaustively.
+public static class FeePolicy
+{
+ public const int FastTarget = 2;
+ public const int NormalTarget = 6;
+ public const int EconomyTarget = 24;
+
+ /// The fee warning threshold: a fee above this share of the amount sent.
+ public const int HighFeePercent = 1;
+
+ private const decimal SatsPerCoin = 100_000_000m;
+ private const decimal BytesPerKb = 1000m;
+
+ ///
+ /// Converts a server value in coin/kB to sat/vB (× 100,000,000 / 1,000). Null for "no
+ /// estimate" (-1), zero, negative, or above , which is
+ /// treated as an implausible answer rather than followed.
+ ///
+ public static decimal? ToSatPerVbyte(decimal coinPerKb)
+ {
+ if (coinPerKb <= 0)
+ return null;
+ var rate = Math.Round(coinPerKb * SatsPerCoin / BytesPerKb, 2, MidpointRounding.AwayFromZero);
+ return rate is > 0 and <= CoinAmount.MaxFeeRate ? rate : null;
+ }
+
+ /// An estimate never goes below the relay fee: the node would reject it.
+ public static decimal? AtLeastRelay(decimal? estimate, decimal? relay) =>
+ estimate is { } e ? Math.Max(e, relay ?? 0m) : null;
+
+ /// The preselected rate: the normal estimate, else 1 sat/vB, never below the relay fee.
+ public static decimal DefaultRate(FeeEstimates? estimates) =>
+ Math.Max(estimates?.Normal ?? 1m, estimates?.Relay ?? 0m);
+
+ /// True when a manual rate is below the server's minimum relay fee.
+ public static bool IsBelowRelay(decimal rate, decimal? relay) => relay is { } r && rate < r;
+
+ /// True when the fee is more than % of the amount sent.
+ public static bool IsHighFee(long feeSats, long amountSats) =>
+ amountSats > 0 && feeSats * 100 > amountSats * HighFeePercent;
+
+ public static string Format(decimal rate) => rate.ToString("0.##", CultureInfo.InvariantCulture);
+
+ ///
+ /// Asks the server for the relay fee and the three estimates. Every call is optional: a
+ /// server error or an unusable value just leaves that field null.
+ ///
+ public static async Task FetchAsync(ElectrumClient client, CancellationToken ct = default)
+ {
+ var relay = await TryAsync(() => client.RelayFeeAsync(ct));
+ var fast = await TryAsync(() => client.EstimateFeeAsync(FastTarget, ct));
+ var normal = await TryAsync(() => client.EstimateFeeAsync(NormalTarget, ct));
+ var economy = await TryAsync(() => client.EstimateFeeAsync(EconomyTarget, ct));
+ return Build(relay, fast, normal, economy);
+ }
+
+ /// Builds the estimates from raw server values (coin/kB), applying the sanity rules.
+ public static FeeEstimates Build(decimal? relayCoinPerKb, decimal? fast, decimal? normal, decimal? economy)
+ {
+ var relay = relayCoinPerKb is { } r ? ToSatPerVbyte(r) : null;
+ decimal? Convert(decimal? v) => AtLeastRelay(v is { } x ? ToSatPerVbyte(x) : null, relay);
+ return new FeeEstimates(relay, Convert(fast), Convert(normal), Convert(economy));
+ }
+
+ private static async Task TryAsync(Func> call)
+ {
+ try { return await call(); }
+ catch (Exception ex) when (ex is ElectrumServerException or InvalidOperationException
+ or FormatException or JsonException) { return null; }
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs b/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs
index 62d81db..2e5f7b5 100644
--- a/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs
+++ b/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs
@@ -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 () =>
{
diff --git a/tests/PalladiumWallet.Tests/Wallet/FeePolicyTests.cs b/tests/PalladiumWallet.Tests/Wallet/FeePolicyTests.cs
new file mode 100644
index 0000000..41b1d7d
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Wallet/FeePolicyTests.cs
@@ -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);
+ }
+}