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:
1 parent
92fd783658
commit
c8ba2294f2
13 files changed
+507
-7
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <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]
|
||||
private async Task ConfirmSend()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -504,6 +504,16 @@
|
||||
MaxWidth="120" HorizontalAlignment="Left"
|
||||
FontFamily="monospace"/>
|
||||
</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>
|
||||
</Border>
|
||||
</Grid>
|
||||
@@ -626,6 +636,16 @@
|
||||
<TextBox Text="{Binding SendFeeRate}"
|
||||
FontFamily="monospace" FontSize="15"
|
||||
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>
|
||||
</Border>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user