fix(search): respect locale and coin precision
What changed, and why it matters
This commit fixes how the wallet's search and filter features interpret typed-in amounts. Previously, the app assumed everyone uses a dot (.) as the decimal separator and parsed amounts in a way that could misread or silently mishandle values for some currencies. Now it respects the user's locale (e.g., comma for German) and uses the coin's own precision rules. There is no direct evidence this was a security vulnerability, but incorrect amount parsing in a wallet can lead to users selecting wrong coins or filters, so it is treated as a correctness/reliability fix rather than an exploit.
Treat as a routine bug-fix / hardening commit. Review the Amount.tryParseEditableAmount implementation to confirm it handles edge cases (leading/trailing separators, group separators, overflow) and ensure the new unit tests cover all supported locales and coin precisions. No emergency response is warranted.
Security signals we found
Locale-aware numeric parsing prevents decimal-separator confusion
Coin-specific fractionDigits now enforced consistently during search/filter parsing
Malformed amount inputs are rejected instead of silently parsed
No new dependencies or network/crypto changes introduced
Evidence from the diff
The patch replaces Decimal.tryParse and manual comma-to-dot replacement with Amount.tryParseEditableAmount / formatter.tryParseEditable, passing the current locale and the cryptoCurrency.fractionDigits. It threads a locale parameter through UTXO search queries in main_db.dart/queries.dart and updates coin-control and transaction-filter UI code to supply it. A new unit test verifies empty/malformed amount handling and locale-specific separators. The change removes the package:decimal import from affected files. No memory-safety, injection, or cryptographic issues are visible in the diff.
Changed components
lib/db/isar/main_db.dartlib/db/queries/queries.dartlib/pages/coin_control/coin_control_view.dartlib/pages/wallet_view/transaction_views/transaction_search_filter_view.dartlib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dartlib/pages_desktop_specific/coin_control/desktop_coin_control_view.dartInspect captured patch +181 / −86
diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart
index e8efa7d..b304442 100644
--- a/lib/db/isar/main_db.dart
+++ b/lib/db/isar/main_db.dart
@@ -10,7 +10,6 @@
import 'dart:io';
-import 'package:decimal/decimal.dart';
import 'package:isar_community/isar.dart';
import 'package:tuple/tuple.dart';
diff --git a/lib/db/queries/queries.dart b/lib/db/queries/queries.dart
index c8bbe73..89f96fa 100644
--- a/lib/db/queries/queries.dart
+++ b/lib/db/queries/queries.dart
@@ -42,12 +42,11 @@ extension MainDBQueries on MainDB {
required CCFilter filter,
required CCSortDescriptor sort,
required String searchTerm,
+ required String locale,
required CryptoCurrency cryptoCurrency,
}) {
var preSort = getUTXOs(walletId).filter().group((q) {
- final qq = q.group(
- (q) => q.usedIsNull().or().usedEqualTo(false),
- );
+ final qq = q.group((q) => q.usedIsNull().or().usedEqualTo(false));
switch (filter) {
case CCFilter.frozen:
return qq.and().isBlockedEqualTo(true);
@@ -59,39 +58,36 @@ extension MainDBQueries on MainDB {
});
if (searchTerm.isNotEmpty) {
- preSort = preSort.and().group(
- (q) {
- var qq = q.addressContains(searchTerm, caseSensitive: false);
-
- qq = qq.or().nameContains(searchTerm, caseSensitive: false);
- qq = qq.or().group(
- (q) => q
- .isBlockedEqualTo(true)
- .and()
- .blockedReasonContains(searchTerm, caseSensitive: false),
- );
-
- qq = qq.or().txidContains(searchTerm, caseSensitive: false);
- qq = qq.or().blockHashContains(searchTerm, caseSensitive: false);
-
- final maybeDecimal = Decimal.tryParse(searchTerm);
- if (maybeDecimal != null) {
- qq = qq.or().valueEqualTo(
- Amount.fromDecimal(
- maybeDecimal,
- fractionDigits: cryptoCurrency.fractionDigits,
- ).raw.toInt(),
- );
- }
-
- final maybeInt = int.tryParse(searchTerm);
- if (maybeInt != null) {
- qq = qq.or().valueEqualTo(maybeInt);
- }
-
- return qq;
- },
- );
+ preSort = preSort.and().group((q) {
+ var qq = q.addressContains(searchTerm, caseSensitive: false);
+
+ qq = qq.or().nameContains(searchTerm, caseSensitive: false);
+ qq = qq.or().group(
+ (q) => q
+ .isBlockedEqualTo(true)
+ .and()
+ .blockedReasonContains(searchTerm, caseSensitive: false),
+ );
+
+ qq = qq.or().txidContains(searchTerm, caseSensitive: false);
+ qq = qq.or().blockHashContains(searchTerm, caseSensitive: false);
+
+ final maybeAmount = Amount.tryParseEditableAmount(
+ searchTerm,
+ locale: locale,
+ fractionDigits: cryptoCurrency.fractionDigits,
+ );
+ if (maybeAmount != null) {
+ qq = qq.or().valueEqualTo(maybeAmount.raw.toInt());
+ }
+
+ final maybeInt = int.tryParse(searchTerm);
+ if (maybeInt != null) {
+ qq = qq.or().valueEqualTo(maybeInt);
+ }
+
+ return qq;
+ });
}
final List<Id> ids;
@@ -114,12 +110,11 @@ extension MainDBQueries on MainDB {
required CCFilter filter,
required CCSortDescriptor sort,
required String searchTerm,
+ required String locale,
required CryptoCurrency cryptoCurrency,
}) {
var preSort = getUTXOs(walletId).filter().group((q) {
- final qq = q.group(
- (q) => q.usedIsNull().or().usedEqualTo(false),
- );
+ final qq = q.group((q) => q.usedIsNull().or().usedEqualTo(false));
switch (filter) {
case CCFilter.frozen:
return qq.and().isBlockedEqualTo(true);
@@ -131,39 +126,36 @@ extension MainDBQueries on MainDB {
});
if (searchTerm.isNotEmpty) {
- preSort = preSort.and().group(
- (q) {
- var qq = q.addressContains(searchTerm, caseSensitive: false);
-
- qq = qq.or().nameContains(searchTerm, caseSensitive: false);
- qq = qq.or().group(
- (q) => q
- .isBlockedEqualTo(true)
- .and()
- .blockedReasonContains(searchTerm, caseSensitive: false),
- );
-
- qq = qq.or().txidContains(searchTerm, caseSensitive: false);
- qq = qq.or().blockHashContains(searchTerm, caseSensitive: false);
-
- final maybeDecimal = Decimal.tryParse(searchTerm);
- if (maybeDecimal != null) {
- qq = qq.or().valueEqualTo(
- Amount.fromDecimal(
- maybeDecimal,
- fractionDigits: cryptoCurrency.fractionDigits,
- ).raw.toInt(),
- );
- }
-
- final maybeInt = int.tryParse(searchTerm);
- if (maybeInt != null) {
- qq = qq.or().valueEqualTo(maybeInt);
- }
-
- return qq;
- },
- );
+ preSort = preSort.and().group((q) {
+ var qq = q.addressContains(searchTerm, caseSensitive: false);
+
+ qq = qq.or().nameContains(searchTerm, caseSensitive: false);
+ qq = qq.or().group(
+ (q) => q
+ .isBlockedEqualTo(true)
+ .and()
+ .blockedReasonContains(searchTerm, caseSensitive: false),
+ );
+
+ qq = qq.or().txidContains(searchTerm, caseSensitive: false);
+ qq = qq.or().blockHashContains(searchTerm, caseSensitive: false);
+
+ final maybeAmount = Amount.tryParseEditableAmount(
+ searchTerm,
+ locale: locale,
+ fractionDigits: cryptoCurrency.fractionDigits,
+ );
+ if (maybeAmount != null) {
+ qq = qq.or().valueEqualTo(maybeAmount.raw.toInt());
+ }
+
+ final maybeInt = int.tryParse(searchTerm);
+ if (maybeInt != null) {
+ qq = qq.or().valueEqualTo(maybeInt);
+ }
+
+ return qq;
+ });
}
final List<UTXO> utxos;
diff --git a/lib/pages/coin_control/coin_control_view.dart b/lib/pages/coin_control/coin_control_view.dart
index 7960733..d6954cc 100644
--- a/lib/pages/coin_control/coin_control_view.dart
+++ b/lib/pages/coin_control/coin_control_view.dart
@@ -18,6 +18,7 @@ import 'package:tuple/tuple.dart';
import '../../db/isar/main_db.dart';
import '../../models/isar/models/isar_models.dart';
+import '../../providers/global/locale_provider.dart';
import '../../providers/global/wallets_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/amount/amount.dart';
@@ -132,6 +133,9 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
final coin = ref.watch(pWalletCoin(widget.walletId));
final currentHeight = ref.watch(pWalletChainHeight(widget.walletId));
+ final locale = ref.watch(
+ localeServiceChangeNotifierProvider.select((value) => value.locale),
+ );
if (_sort == CCSortDescriptor.address && !_isSearching) {
_list = null;
@@ -140,6 +144,7 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
filter: CCFilter.all,
sort: _sort,
searchTerm: "",
+ locale: locale,
cryptoCurrency: coin,
);
} else {
@@ -154,6 +159,7 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
: CCFilter.available,
sort: _sort,
searchTerm: _isSearching ? searchController.text : "",
+ locale: locale,
cryptoCurrency: coin,
);
}
diff --git a/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart b/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart
index cff1054..28ce036 100644
--- a/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart
+++ b/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart
@@ -8,7 +8,6 @@
*
*/
-import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -18,6 +17,7 @@ import '../../../providers/ui/transaction_filter_provider.dart';
import '../../../themes/stack_colors.dart';
import '../../../themes/theme_providers.dart';
import '../../../utilities/amount/amount.dart';
+import '../../../utilities/amount/amount_field_relocalization.dart';
import '../../../utilities/amount/amount_formatter.dart';
import '../../../utilities/amount/amount_input_formatter.dart';
import '../../../utilities/constants.dart';
@@ -36,6 +36,21 @@ import '../../../widgets/rounded_white_container.dart';
import '../../../widgets/stack_text_field.dart';
import '../../../widgets/textfield_icon_button.dart';
+({bool isValid, Amount? amount}) parseTransactionFilterAmountInput({
+ required String text,
+ required String locale,
+ required AmountFormatter formatter,
+}) {
+ final decimalSeparator =
+ Util.getSymbolsFor(locale: locale)?.DECIMAL_SEP ?? ".";
+ if (text.isEmpty || text == decimalSeparator) {
+ return (isValid: true, amount: null);
+ }
+
+ final amount = formatter.tryParseEditable(text);
+ return (isValid: amount != null, amount: amount);
+}
+
class TransactionSearchFilterView extends ConsumerStatefulWidget {
const TransactionSearchFilterView({super.key, required this.coin});
@@ -77,7 +92,7 @@ class _TransactionSearchViewState
? ""
: ref
.read(pAmountFormatter(widget.coin))
- .format(filterState.amount!, withUnitName: false);
+ .formatEditable(filterState.amount!);
_amountTextEditingController.text = amount;
}
@@ -100,6 +115,10 @@ class _TransactionSearchViewState
@override
Widget build(BuildContext context) {
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [_amountTextEditingController],
+ );
if (Util.isDesktop) {
return DesktopDialog(
maxWidth: 576,
@@ -630,16 +649,14 @@ class _TransactionSearchViewState
}
Future<void> _onApplyPressed() async {
- final amountText = _amountTextEditingController.text;
- Amount? amount;
- if (amountText.isNotEmpty && !(amountText == "," || amountText == ".")) {
- amount = amountText.contains(",")
- ? Decimal.parse(
- amountText.replaceFirst(",", "."),
- ).toAmount(fractionDigits: widget.coin.fractionDigits)
- : Decimal.parse(
- amountText,
- ).toAmount(fractionDigits: widget.coin.fractionDigits);
+ final locale = ref.read(localeServiceChangeNotifierProvider).locale;
+ final parsedAmount = parseTransactionFilterAmountInput(
+ text: _amountTextEditingController.text,
+ locale: locale,
+ formatter: ref.read(pAmountFormatter(widget.coin)),
+ );
+ if (!parsedAmount.isValid) {
+ return;
}
final TransactionFilter filter = TransactionFilter(
@@ -648,7 +665,7 @@ class _TransactionSearchViewState
trade: _isActiveTradeCheckbox,
from: _selectedFromDate,
to: _selectedToDate,
- amount: amount,
+ amount: parsedAmount.amount,
keyword: _keywordTextEditingController.text,
);
diff --git a/lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart b/lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart
index d095935..57295f4 100644
--- a/lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart
+++ b/lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart
@@ -18,6 +18,7 @@ import 'package:isar_community/isar.dart';
import '../../db/isar/main_db.dart';
import '../../models/input.dart';
import '../../models/isar/models/blockchain_data/utxo.dart';
+import '../../providers/global/locale_provider.dart';
import '../../themes/coin_icon_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/amount/amount.dart';
@@ -108,6 +109,10 @@ class _DesktopCoinControlUseDialogState
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
+ final locale = ref.watch(
+ localeServiceChangeNotifierProvider.select((value) => value.locale),
+ );
+
if (_sort == CCSortDescriptor.address) {
_list = null;
_map = MainDB.instance.queryUTXOsGroupedByAddressSync(
@@ -115,6 +120,7 @@ class _DesktopCoinControlUseDialogState
filter: _filter,
sort: _sort,
searchTerm: _searchString,
+ locale: locale,
cryptoCurrency: coin,
);
} else {
@@ -124,6 +130,7 @@ class _DesktopCoinControlUseDialogState
filter: _filter,
sort: _sort,
searchTerm: _searchString,
+ locale: locale,
cryptoCurrency: coin,
);
}
diff --git a/lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart b/lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart
index 3489f6c..e9f2b93 100644
--- a/lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart
+++ b/lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart
@@ -17,6 +17,7 @@ import 'package:isar_community/isar.dart';
import '../../db/isar/main_db.dart';
import '../../models/isar/models/blockchain_data/utxo.dart';
+import '../../providers/global/locale_provider.dart';
import '../../themes/coin_icon_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/assets.dart';
@@ -84,6 +85,10 @@ class _DesktopCoinControlViewState
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
+ final locale = ref.watch(
+ localeServiceChangeNotifierProvider.select((value) => value.locale),
+ );
+
if (_sort == CCSortDescriptor.address) {
_list = null;
_map = MainDB.instance.queryUTXOsGroupedByAddressSync(
@@ -91,6 +96,7 @@ class _DesktopCoinControlViewState
filter: _filter,
sort: _sort,
searchTerm: _searchString,
+ locale: locale,
cryptoCurrency: coin,
);
} else {
@@ -100,6 +106,7 @@ class _DesktopCoinControlViewState
filter: _filter,
sort: _sort,
searchTerm: _searchString,
+ locale: locale,
cryptoCurrency: coin,
);
}
diff --git a/test/pages/wallet_view/transaction_views/transaction_search_filter_view_test.dart b/test/pages/wallet_view/transaction_views/transaction_search_filter_view_test.dart
new file mode 100644
index 0000000..b4969a4
--- /dev/null
+++ b/test/pages/wallet_view/transaction_views/transaction_search_filter_view_test.dart
@@ -0,0 +1,67 @@
+import "package:flutter_test/flutter_test.dart";
+import "package:stackwallet/pages/wallet_view/transaction_views/transaction_search_filter_view.dart";
+import "package:stackwallet/utilities/amount/amount_formatter.dart";
+import "package:stackwallet/utilities/amount/amount_unit.dart";
+import "package:stackwallet/wallets/crypto_currency/crypto_currency.dart";
+
+void main() {
+ AmountFormatter formatter(String locale) => AmountFormatter(
+ unit: AmountUnit.normal,
+ locale: locale,
+ coin: Bitcoin(CryptoCurrencyNetwork.main),
+ maxDecimals: 8,
+ );
+
+ test("transaction filter distinguishes empty from malformed amounts", () {
+ for (final text in ["", "."]) {
+ expect(
+ parseTransactionFilterAmountInput(
+ text: text,
+ locale: "en_US",
+ formatter: formatter("en_US"),
+ ),
+ (isValid: true, amount: null),
+ reason: text,
+ );
+ }
+
+ final trailingSeparator = parseTransactionFilterAmountInput(
+ text: "1.",
+ locale: "en_US",
+ formatter: formatter("en_US"),
+ );
+ expect(trailingSeparator.isValid, isTrue);
+ expect(trailingSeparator.amount?.raw, BigInt.from(100000000));
+
+ for (final text in ["1..", "1.2."]) {
+ expect(
+ parseTransactionFilterAmountInput(
+ text: text,
+ locale: "en_US",
+ formatter: formatter("en_US"),
+ ).isValid,
+ isFalse,
+ reason: text,
+ );
+ }
+ });
+
+ test("transaction filter uses the locale separator", () {
+ expect(
+ parseTransactionFilterAmountInput(
+ text: ",",
+ locale: "de_DE",
+ formatter: formatter("de_DE"),
+ ),
+ (isValid: true, amount: null),
+ );
+ expect(
+ parseTransactionFilterAmountInput(
+ text: "1,,",
+ locale: "de_DE",
+ formatter: formatter("de_DE"),
+ ).isValid,
+ isFalse,
+ );
+ });
+}
Why this scored 17/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.