From cb551efce4b1d5642e74fa296be6ac1da1953dd8 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 3 Nov 2026 12:15:27 +0100 Subject: [PATCH] fix: parse fee rate with the invariant culture On Italian/German systems "1.5" was read as 15 sat/vB. The Send form, the Donate form and the CLI now share CoinAmount.TryParseFeeRate (invariant culture, '.' or ',' as decimal separator, range (0, 1000]). Donate and the CLI report an invalid rate instead of silently using 1. --- CHANGELOG.md | 5 ++ USERGUIDE.md | 5 +- .../ViewModels/MainWindowViewModel.Donate.cs | 8 ++- .../ViewModels/MainWindowViewModel.Send.cs | 2 +- src/Cli/Program.cs | 4 +- src/Core/Wallet/CoinAmount.cs | 21 +++++++ .../Wallet/CoinAmountTests.cs | 61 +++++++++++++++++++ 7 files changed, 99 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e669745..70bf005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ release and covers the full history from the initial commit. ### Fixed +- Fee rate parsing no longer depends on the system culture: on Italian/German systems `1.5` + was read as `15` sat/vB (10x the intended fee). The Send form, the Donate form and the CLI + `--feerate` now share `CoinAmount.TryParseFeeRate` (invariant culture, `.` or `,` as decimal + separator, no thousands separators, range (0, 1000] sat/vB). Donate and the CLI no longer + silently fall back to 1 sat/vB on an invalid value: they report an error. - `ElectrumClient`: after a protocol error (e.g. a malformed line from the server) the read loop died but the client kept reporting `IsConnected == true`, and any later request waited forever for a response that could never arrive. The read loop now marks the connection dead diff --git a/USERGUIDE.md b/USERGUIDE.md index 831f313..37c79a4 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -382,7 +382,8 @@ wallet must be spendable (not watch-only) to actually broadcast. - **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. + 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). @@ -439,7 +440,7 @@ software of your choice, then broadcast the finalized transaction. The Help overlay includes a donation form (developer address `plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay`) using the same prepare/confirm flow and the -fee rate from the Send tab (falling back to 1 sat/vB if invalid). Entirely optional. +fee rate from the Send tab (an invalid rate is reported as an error). Entirely optional. --- diff --git a/src/App/ViewModels/MainWindowViewModel.Donate.cs b/src/App/ViewModels/MainWindowViewModel.Donate.cs index 7bbb0cb..470d816 100644 --- a/src/App/ViewModels/MainWindowViewModel.Donate.cs +++ b/src/App/ViewModels/MainWindowViewModel.Donate.cs @@ -44,9 +44,11 @@ public partial class MainWindowViewModel DonatePreview = Loc.Tr("msg.amount.invalid"); return; } - if (!decimal.TryParse(SendFeeRate, System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out var feeRate) || feeRate <= 0) - feeRate = 1m; + if (!CoinAmount.TryParseFeeRate(SendFeeRate, out var feeRate)) + { + DonatePreview = Loc.Tr("msg.feerate.invalid"); + return; + } _pendingDonate = new TransactionFactory(_account).Build( _doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate, diff --git a/src/App/ViewModels/MainWindowViewModel.Send.cs b/src/App/ViewModels/MainWindowViewModel.Send.cs index 40c7610..4661076 100644 --- a/src/App/ViewModels/MainWindowViewModel.Send.cs +++ b/src/App/ViewModels/MainWindowViewModel.Send.cs @@ -74,7 +74,7 @@ public partial class MainWindowViewModel SendPreview = Loc.Tr("msg.amount.invalid"); return; } - if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0) + if (!CoinAmount.TryParseFeeRate(SendFeeRate, out var feeRate)) { SendPreview = Loc.Tr("msg.feerate.invalid"); return; diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index 4e336c0..b1dd0e7 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -217,7 +217,9 @@ static async Task Send(string[] o) long amount = 0; if (!sendAll && !CoinAmount.TryParseCoins(Opt(o, "--amount") ?? "", out amount)) throw new WalletSpendException("--amount missing or invalid (or use --all)."); - var feeRate = decimal.TryParse(Opt(o, "--feerate"), out var fr) ? fr : 1m; + var feeRate = 1m; + if (Opt(o, "--feerate") is { } feeText && !CoinAmount.TryParseFeeRate(feeText, out feeRate)) + throw new WalletSpendException($"--feerate invalid (expected sat/vB in (0, {CoinAmount.MaxFeeRate}])."); // Source transactions of the UTXOs: needed for amounts and signing. await using var client = await Connect(o, account.Profile); diff --git a/src/Core/Wallet/CoinAmount.cs b/src/Core/Wallet/CoinAmount.cs index 828e821..fe1732d 100644 --- a/src/Core/Wallet/CoinAmount.cs +++ b/src/Core/Wallet/CoinAmount.cs @@ -80,4 +80,25 @@ public static class CoinAmount } return true; } + + /// Upper bound accepted for a fee rate, in sat/vB (guards against typos). + public const decimal MaxFeeRate = 1000m; + + /// + /// Parses a fee rate in sat/vB, independent of the system culture. Accepts a decimal point or + /// comma; rejects thousands separators, exponents, signs, zero and values above + /// . + /// + public static bool TryParseFeeRate(string? text, out decimal satPerVbyte) + { + satPerVbyte = 0; + if (text is null) + return false; + text = text.Trim().Replace(',', '.'); + if (!decimal.TryParse(text, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var rate) + || rate <= 0 || rate > MaxFeeRate) + return false; + satPerVbyte = rate; + return true; + } } diff --git a/tests/PalladiumWallet.Tests/Wallet/CoinAmountTests.cs b/tests/PalladiumWallet.Tests/Wallet/CoinAmountTests.cs index 53b9962..867f20d 100644 --- a/tests/PalladiumWallet.Tests/Wallet/CoinAmountTests.cs +++ b/tests/PalladiumWallet.Tests/Wallet/CoinAmountTests.cs @@ -5,6 +5,67 @@ namespace PalladiumWallet.Tests.Wallet; public class CoinAmountTests { + // ---- fee rate (sat/vB): culture-independent ---- + + [Theory] + [InlineData("1", "1")] + [InlineData("1.5", "1.5")] + [InlineData("1,5", "1.5")] + [InlineData("0.5", "0.5")] + [InlineData(" 2 ", "2")] + [InlineData("2.25", "2.25")] + [InlineData("1000", "1000")] + public void TryParseFeeRate_accepts_valid(string text, string expected) + { + Assert.True(CoinAmount.TryParseFeeRate(text, out var rate)); + Assert.Equal(decimal.Parse(expected, System.Globalization.CultureInfo.InvariantCulture), rate); + } + + [Theory] + [InlineData("1.000")] // looks like a thousands separator in it-IT/de-DE but is 1.000 → accepted as 1 + public void TryParseFeeRate_dot_is_always_decimal(string text) + { + Assert.True(CoinAmount.TryParseFeeRate(text, out var rate)); + Assert.Equal(1m, rate); + } + + [Theory] + [InlineData("1e3")] + [InlineData("-1")] + [InlineData("+1")] + [InlineData("0")] + [InlineData("0.0")] + [InlineData("1000.01")] + [InlineData("1,000.5")] + [InlineData("1 000")] + [InlineData("abc")] + [InlineData("")] + [InlineData(null)] + public void TryParseFeeRate_rejects_invalid(string? text) + { + Assert.False(CoinAmount.TryParseFeeRate(text, out var rate)); + Assert.Equal(0m, rate); + } + + [Theory] + [InlineData("it-IT")] + [InlineData("de-DE")] + [InlineData("fr-FR")] + [InlineData("en-US")] + public void TryParseFeeRate_ignores_current_culture(string culture) + { + var saved = System.Globalization.CultureInfo.CurrentCulture; + try + { + System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo(culture); + Assert.True(CoinAmount.TryParseFeeRate("1.5", out var a)); + Assert.True(CoinAmount.TryParseFeeRate("2,25", out var b)); + Assert.Equal(1.5m, a); + Assert.Equal(2.25m, b); + } + finally { System.Globalization.CultureInfo.CurrentCulture = saved; } + } + // ---- valid amounts: all units ---- [Theory]