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

+10
View File
@@ -7,6 +7,16 @@ release and covers the full history from the initial commit.
## [Unreleased] ## [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 ### Changed
- **CLI (behaviour change):** the wallet password no longer has to be a command-line argument. - **CLI (behaviour change):** the wallet password no longer has to be a command-line argument.
+1
View File
@@ -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 - Lie about unconfirmed (mempool) transactions — the wallet shows mempool transactions as unconfirmed and non-spendable
- Refuse to relay a broadcast transaction - Refuse to relay a broadcast transaction
- Delay reporting of new blocks - 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): It cannot (given correct Merkle verification):
+13 -5
View File
@@ -379,11 +379,19 @@ wallet must be spendable (not watch-only) to actually broadcast.
than 1 satoshi (0.00000001 PLM). than 1 satoshi (0.00000001 PLM).
- **Send all** — checkbox; disables the amount field and sends the entire spendable balance, - **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). **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 - **Fee (sat/vB)** — the fee rate, which you can always type by hand. **Default: 1 sat/vB.**
presets and no automatic fee estimation. On the Palladium chain, blocks are rarely full, Once connected, the wallet asks the server for the minimum relay fee and for estimates, and
so 1 sat/vB normally confirms in the next few blocks; raise it if a transaction lingers in shows three presets — **Fast** (~2 blocks), **Normal** (~6) and **Economy** (~24) — with the
the mempool. Must be a number greater than zero and at most 1000; `.` or `,` is the decimal numbers underneath; **Normal** is preselected unless you already typed a rate. An estimate
separator (no thousands separators), regardless of the system language. 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 - **QR scan** (Android only) — camera button; reads a plain address or a
`palladium:` payment URI (the address part is used). `palladium:` payment URI (the address part is used).
@@ -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
+1
View File
@@ -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 | | 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 | | 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 | | 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. 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. If one misstates the original reasoning, fix it in a pull request.
+6
View File
@@ -458,6 +458,12 @@ public sealed class Loc
["msg.broadcasted"] = ["Trasmessa", "Broadcast", "Transmitida", "Diffusée", "Transmitida", "Übertragen", "已广播"], ["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.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)", " · 未签名(仅观察)"], ["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"] = [ ["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.", "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.", "The TLS certificate of {0} has changed from the one saved. If the server renewed its certificate, reset the SSL certificates.",
@@ -49,6 +49,12 @@ public partial class MainWindowViewModel
DonatePreview = Loc.Tr("msg.feerate.invalid"); DonatePreview = Loc.Tr("msg.feerate.invalid");
return; 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( _pendingDonate = new TransactionFactory(_account).Build(
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate, _doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
@@ -57,7 +63,8 @@ public partial class MainWindowViewModel
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " + DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " + $"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
$"({_pendingDonate.Transaction.GetVirtualSize()} vB)" + $"({_pendingDonate.Transaction.GetVirtualSize()} vB)" +
(_pendingDonate.Signed ? "" : " · watch-only"); (_pendingDonate.Signed ? "" : " · watch-only") +
HighFeeWarning(_pendingDonate.Fee.Satoshi, amount);
HasPendingDonate = _pendingDonate.Signed; HasPendingDonate = _pendingDonate.Signed;
} }
catch (Exception ex) catch (Exception ex)
+86 -1
View File
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using NBitcoin; using NBitcoin;
using Avalonia.Threading;
using PalladiumWallet.App.Localization; using PalladiumWallet.App.Localization;
using PalladiumWallet.App.Services; using PalladiumWallet.App.Services;
using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Chain;
@@ -25,6 +26,73 @@ public partial class MainWindowViewModel
[ObservableProperty] [ObservableProperty]
private bool sendAll; 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] [ObservableProperty]
private string sendPreview = ""; private string sendPreview = "";
@@ -79,6 +147,12 @@ public partial class MainWindowViewModel
SendPreview = Loc.Tr("msg.feerate.invalid"); SendPreview = Loc.Tr("msg.feerate.invalid");
return; 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( _pendingSend = new TransactionFactory(_account).Build(
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate, _doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
@@ -87,7 +161,9 @@ public partial class MainWindowViewModel
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " + SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " + $"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" + $"({_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; HasPendingSend = _pendingSend.Signed;
PendingPsbtBase64 = _pendingSend.Signed ? "" : _pendingSend.Psbt.ToBase64(); PendingPsbtBase64 = _pendingSend.Signed ? "" : _pendingSend.Psbt.ToBase64();
} }
@@ -101,6 +177,15 @@ public partial class MainWindowViewModel
await Task.CompletedTask; await Task.CompletedTask;
} }
/// <summary>
/// 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.
/// </summary>
private string HighFeeWarning(long feeSats, long amountSats) =>
FeePolicy.IsHighFee(feeSats, amountSats)
? "\n" + string.Format(Loc.Tr("msg.fee.high"), FeePolicy.HighFeePercent)
: "";
[RelayCommand] [RelayCommand]
private async Task ConfirmSend() private async Task ConfirmSend()
{ {
@@ -261,6 +261,8 @@ public partial class MainWindowViewModel
} }
if (lastError is not null) if (lastError is not null)
throw lastError; throw lastError;
if (_client is { } fresh)
_ = RefreshFeeEstimatesAsync(fresh);
} }
if (_account is null || _doc is null) if (_account is null || _doc is null)
+20
View File
@@ -504,6 +504,16 @@
MaxWidth="120" HorizontalAlignment="Left" MaxWidth="120" HorizontalAlignment="Left"
FontFamily="monospace"/> FontFamily="monospace"/>
</Grid> </Grid>
<!-- Fee presets from the server estimate (shown once the server answered) -->
<StackPanel Spacing="4" IsVisible="{Binding HasFeeEstimates}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="{Binding Loc[send.fee.fast]}" Command="{Binding UseFeePresetCommand}" CommandParameter="fast"/>
<Button Content="{Binding Loc[send.fee.normal]}" Command="{Binding UseFeePresetCommand}" CommandParameter="normal"/>
<Button Content="{Binding Loc[send.fee.economy]}" Command="{Binding UseFeePresetCommand}" CommandParameter="economy"/>
</StackPanel>
<TextBlock Text="{Binding FeeEstimateHint}" FontSize="11" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}"/>
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
</Grid> </Grid>
@@ -626,6 +636,16 @@
<TextBox Text="{Binding SendFeeRate}" <TextBox Text="{Binding SendFeeRate}"
FontFamily="monospace" FontSize="15" FontFamily="monospace" FontSize="15"
MinHeight="48" HorizontalAlignment="Stretch"/> MinHeight="48" HorizontalAlignment="Stretch"/>
<!-- Fee presets from the server estimate (shown once the server answered) -->
<StackPanel Spacing="4" IsVisible="{Binding HasFeeEstimates}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="{Binding Loc[send.fee.fast]}" Command="{Binding UseFeePresetCommand}" CommandParameter="fast"/>
<Button Content="{Binding Loc[send.fee.normal]}" Command="{Binding UseFeePresetCommand}" CommandParameter="normal"/>
<Button Content="{Binding Loc[send.fee.economy]}" Command="{Binding UseFeePresetCommand}" CommandParameter="economy"/>
</StackPanel>
<TextBlock Text="{Binding FeeEstimateHint}" FontSize="11" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}"/>
</StackPanel>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
+87
View File
@@ -0,0 +1,87 @@
using System.Globalization;
using System.Text.Json;
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.Core.Wallet;
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>Conversions and checks for the fee rate, kept pure so they can be tested exhaustively.</summary>
public static class FeePolicy
{
public const int FastTarget = 2;
public const int NormalTarget = 6;
public const int EconomyTarget = 24;
/// <summary>The fee warning threshold: a fee above this share of the amount sent.</summary>
public const int HighFeePercent = 1;
private const decimal SatsPerCoin = 100_000_000m;
private const decimal BytesPerKb = 1000m;
/// <summary>
/// Converts a server value in coin/kB to sat/vB (× 100,000,000 / 1,000). Null for "no
/// estimate" (-1), zero, negative, or above <see cref="CoinAmount.MaxFeeRate"/>, which is
/// treated as an implausible answer rather than followed.
/// </summary>
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;
}
/// <summary>An estimate never goes below the relay fee: the node would reject it.</summary>
public static decimal? AtLeastRelay(decimal? estimate, decimal? relay) =>
estimate is { } e ? Math.Max(e, relay ?? 0m) : null;
/// <summary>The preselected rate: the normal estimate, else 1 sat/vB, never below the relay fee.</summary>
public static decimal DefaultRate(FeeEstimates? estimates) =>
Math.Max(estimates?.Normal ?? 1m, estimates?.Relay ?? 0m);
/// <summary>True when a manual rate is below the server's minimum relay fee.</summary>
public static bool IsBelowRelay(decimal rate, decimal? relay) => relay is { } r && rate < r;
/// <summary>True when the fee is more than <see cref="HighFeePercent"/>% of the amount sent.</summary>
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);
/// <summary>
/// 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.
/// </summary>
public static async Task<FeeEstimates> 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);
}
/// <summary>Builds the estimates from raw server values (coin/kB), applying the sanity rules.</summary>
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<decimal?> TryAsync(Func<Task<decimal>> call)
{
try { return await call(); }
catch (Exception ex) when (ex is ElectrumServerException or InvalidOperationException
or FormatException or JsonException) { return null; }
}
}
@@ -396,6 +396,91 @@ public sealed class MainWindowViewModelTests : VmTestBase
Assert.True(_server.CallCount("blockchain.transaction.get_merkle") >= 1); // Merkle proof was demanded 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] [Fact]
public Task Send_PreparesASignedTransaction_ForAFundedWallet_AndRejectsABadAmount() => UiSession.Run(async () => 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);
}
}