serialize external amounts with locale-independent decimals and use standard monero family query parameters
What changed, and why it matters
This commit fixes how the wallet builds payment QR codes and web links so that the amount is written in a standard, locale-independent decimal format and uses the correct parameter names for Monero-family coins. Before, a user in a country that uses a comma as the decimal separator could generate a QR code with an amount another wallet might misread, and Monero-style URIs used non-standard fields. The change also adds input formatting and parsing helpers to keep the user's typed amount consistent with their locale while exporting a canonical decimal string.
Review the new Amount.tryParseCanonicalAmount, Amount.tryParseEditableAmount, and AmountInputFormatter implementations to confirm they reject malformed input and always serialize with a dot decimal separator. Verify that buildPaymentUriString is used everywhere payment URIs are constructed, and that downstream parsers handle tx_amount/tx_description correctly for Monero-family coins.
Security signals we found
Locale-dependent amount serialization in payment URIs
Monero-family URI parameter standardization (tx_amount/tx_description)
Amount parsing hardening with canonical format and overprecision truncation
New input formatter and relocalization listener for amount fields
Evidence from the diff
The patch replaces Amount.tryParseLocalizedNumber with Amount.tryParseEditableAmount and adds AmountInputFormatter plus listenForAmountRelocalization in the receive/QR generation UI. It introduces AddressUtils.buildPaymentUriString, which emits ‘tx_amount’/’tx_description’ for monero/wownero and ‘amount’/’message’ otherwise, and removes the old special-case Monero fragment (#description) handling. ShopInBit parsing now uses Amount.tryParseCanonicalAmount with truncateOverprecision. Tests cover standard and Monero-family URIs. The core security-relevant issue is preventing locale-dependent decimal serialization in payment URIs, which could cause amount misinterpretation by a recipient wallet.
Changed components
lib/pages/receive_view/generate_receiving_uri_qr_code_view.dartlib/pages/shopinbit/shopinbit_payment_shared.dartlib/utilities/address_utils.darttest/address_utils_test.dartInspect captured patch +86 / −39
diff --git a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart
index adeb67c..0c5e3d7 100644
--- a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart
+++ b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart
@@ -27,6 +27,8 @@ import '../../providers/global/locale_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_field_relocalization.dart';
+import '../../utilities/amount/amount_input_formatter.dart';
import '../../utilities/assets.dart';
import '../../utilities/clipboard_interface.dart';
import '../../utilities/constants.dart';
@@ -157,7 +159,11 @@ class _GenerateUriQrCodeViewState extends ConsumerState<GenerateUriQrCodeView> {
final noteString = noteController.text;
final locale = ref.read(localeServiceChangeNotifierProvider).locale;
- final amount = Amount.tryParseLocalizedNumber(amountString, locale: locale);
+ final amount = Amount.tryParseEditableAmount(
+ amountString,
+ locale: locale,
+ fractionDigits: widget.coin.fractionDigits,
+ );
if (amountString.isNotEmpty && amount == null) {
showFloatingFlushBar(
@@ -168,15 +174,6 @@ class _GenerateUriQrCodeViewState extends ConsumerState<GenerateUriQrCodeView> {
return null;
}
- final Map<String, String> queryParams = {};
-
- if (amountString.isNotEmpty) {
- queryParams["amount"] = amount.toString();
- }
- if (noteString.isNotEmpty) {
- queryParams["message"] = noteString;
- }
-
String receivingAddress = widget.receivingAddress;
if ((widget.coin is Bitcoincash || widget.coin is Ecash) &&
receivingAddress.contains(":")) {
@@ -184,10 +181,11 @@ class _GenerateUriQrCodeViewState extends ConsumerState<GenerateUriQrCodeView> {
receivingAddress = receivingAddress.split(":").sublist(1).join();
}
- final uriString = AddressUtils.buildUriString(
- widget.coin.uriScheme,
- receivingAddress,
- queryParams,
+ final uriString = AddressUtils.buildPaymentUriString(
+ scheme: widget.coin.uriScheme,
+ address: receivingAddress,
+ amount: amount?.decimal.toString(),
+ message: noteString,
);
Logging.instance.d("Generated receiving QR code for: $uriString");
@@ -295,6 +293,8 @@ class _GenerateUriQrCodeViewState extends ConsumerState<GenerateUriQrCodeView> {
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
+ listenForAmountRelocalization(ref.listen, controllers: [amountController]);
+
return ConditionalParent(
condition: !isDesktop,
builder: (child) => Background(
@@ -391,6 +391,17 @@ class _GenerateUriQrCodeViewState extends ConsumerState<GenerateUriQrCodeView> {
keyboardType: Util.isDesktop
? null
: const TextInputType.numberWithOptions(decimal: true),
+ inputFormatters: [
+ AmountInputFormatter(
+ controller: amountController,
+ decimals: widget.coin.fractionDigits,
+ locale: ref.watch(
+ localeServiceChangeNotifierProvider.select(
+ (value) => value.locale,
+ ),
+ ),
+ ),
+ ],
onChanged: (_) => setState(() {}),
decoration:
standardInputDecoration(
diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart
index c5b4156..4552e56 100644
--- a/lib/pages/shopinbit/shopinbit_payment_shared.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart
@@ -1,4 +1,3 @@
-import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -74,16 +73,14 @@ ShopInBitPaymentTarget parseShopInBitPaymentTarget({
Amount? amount;
if (amountStr != null && amountStr.isNotEmpty) {
- try {
- amount = Amount.fromDecimal(
- Decimal.parse(amountStr),
- fractionDigits: fractionDigits,
- );
- } catch (e, s) {
+ amount = Amount.tryParseCanonicalAmount(
+ amountStr,
+ fractionDigits: fractionDigits,
+ truncateOverprecision: true,
+ );
+ if (amount == null) {
Logging.instance.e(
"Failed to parse ShopInBit payment amount '$amountStr'",
- error: e,
- stackTrace: s,
);
}
}
diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart
index acd6c1e..cb57e48 100644
--- a/lib/utilities/address_utils.dart
+++ b/lib/utilities/address_utils.dart
@@ -193,27 +193,32 @@ class AddressUtils {
uriString = "$scheme:$address";
}
- if (scheme.toLowerCase() == "monero") {
- // Handle Monero-specific formatting.
- if (filteredParams.containsKey("tx_description")) {
- final description = filteredParams.remove("tx_description")!;
- if (filteredParams.isNotEmpty) {
- uriString += Uri(queryParameters: filteredParams).toString();
- }
- uriString += "#${Uri.encodeComponent(description)}";
- } else if (filteredParams.isNotEmpty) {
- uriString += Uri(queryParameters: filteredParams).toString();
- }
- } else {
- // General case for other cryptocurrencies.
- if (filteredParams.isNotEmpty) {
- uriString += Uri(queryParameters: filteredParams).toString();
- }
+ if (filteredParams.isNotEmpty) {
+ uriString += Uri(queryParameters: filteredParams).toString();
}
return uriString;
}
+ static String buildPaymentUriString({
+ required String scheme,
+ required String address,
+ String? amount,
+ String? message,
+ }) {
+ final normalizedScheme = scheme.toLowerCase();
+ final usesMoneroParameters =
+ normalizedScheme == "monero" || normalizedScheme == "wownero";
+ final params = <String, String>{
+ if (amount != null && amount.isNotEmpty)
+ usesMoneroParameters ? "tx_amount" : "amount": amount,
+ if (message != null && message.isNotEmpty)
+ usesMoneroParameters ? "tx_description" : "message": message,
+ };
+
+ return buildUriString(scheme, address, params);
+ }
+
/// returns empty if bad data
static Map<String, dynamic> decodeQRSeedData(String data) {
Map<String, dynamic> result = {};
diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart
index 059eb7a..dc1bd24 100644
--- a/test/address_utils_test.dart
+++ b/test/address_utils_test.dart
@@ -264,4 +264,38 @@ void main() {
"firo:$firoAddress?amount=10.0123&message=Some+kind+of+message%21",
);
});
+
+ test("build a standard payment URI", () {
+ expect(
+ AddressUtils.buildPaymentUriString(
+ scheme: "firo",
+ address: firoAddress,
+ amount: "10.0123",
+ message: "Some kind of message!",
+ ),
+ "firo:$firoAddress?amount=10.0123&message=Some+kind+of+message%21",
+ );
+ });
+
+ test("build Monero-family payment URIs with standard query parameters", () {
+ for (final scheme in ["monero", "wownero"]) {
+ final uri = AddressUtils.buildPaymentUriString(
+ scheme: scheme,
+ address: firoAddress,
+ amount: "1.25",
+ message: "Some kind of message!",
+ );
+
+ expect(
+ uri,
+ "$scheme:$firoAddress?tx_amount=1.25&"
+ "tx_description=Some+kind+of+message%21",
+ );
+ expect(uri, isNot(contains("#")));
+
+ final parsed = AddressUtils.parsePaymentUri(uri);
+ expect(parsed?.amount, "1.25");
+ expect(parsed?.message, "Some kind of message!");
+ }
+ });
}
Why this scored 45/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.