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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user