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.
This commit is contained in:
1 parent
d431996ddc
commit
cb551efce4
7 files changed
+99
-7
No files matched your search
@@ -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
|
||||
|
||||
+3
-2
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+3
-1
@@ -217,7 +217,9 @@ static async Task<int> 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);
|
||||
|
||||
@@ -80,4 +80,25 @@ public static class CoinAmount
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Upper bound accepted for a fee rate, in sat/vB (guards against typos).</summary>
|
||||
public const decimal MaxFeeRate = 1000m;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="MaxFeeRate"/>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user