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"/> + + + +