What changed, and why it matters
This commit fixes how Stack Wallet's desktop app parses token amounts for Solana tokens and other tokens. Previously, the app used simple string replacement for commas and periods, which could misinterpret numbers depending on the user's locale. For example, in some countries '1,000' means one thousand, while in others it means one. The fix uses locale-aware parsing and rejects inputs containing plus, minus, or space characters. The change also affects how displayed amounts are formatted, using the token's own decimal places instead of the coin's.
Review the new parseDesktopSolTokenAmount and parseDesktopTokenFiatAmount helpers for completeness; ensure similar ad-hoc parsing does not exist in mobile token send flows or other desktop token pages. Consider adding tests for empty strings, multiple separators, and very large inputs.
Security signals we found
Locale-aware amount parsing replaces unsafe comma/period string replacement
Rejection of plus, minus, and whitespace characters in amount inputs
Token-specific decimal handling instead of coin-level decimal handling
New unit test covers locale-sensitive parsing edge cases
Evidence from the diff
The patch replaces ad-hoc Decimal.parse() calls with locale-aware AmountUnit.normal.tryParse() and Amount.tryParseFiatString() helpers in desktop token send flows. It adds input validation rejecting strings containing ‘+’, ‘-‘, or whitespace. It also changes formatting to use AmountUnit.normal.displayAmount() with the token’s actual decimals rather than the parent coin’s formatter. A new unit test verifies locale grouping parsing for en_US and de_DE locales and rejection of negative values.
Changed components
desktop_sol_token_send.dartdesktop_token_send.dartdesktop_token_amount_parsing_test.dartInspect captured patch +115 / −40
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
index cd4e227..76cc736 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
@@ -16,6 +16,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../models/isar/models/contact_entry.dart';
+import '../../../../models/isar/models/solana/sol_contract.dart';
import '../../../../models/paynym/paynym_account_lite.dart';
import '../../../../models/send_view_auto_fill_data.dart';
import '../../../../pages/send_view/confirm_transaction_view.dart';
@@ -25,8 +26,8 @@ import '../../../../providers/ui/preview_tx_button_state_provider.dart';
import '../../../../themes/stack_colors.dart';
import '../../../../utilities/address_utils.dart';
import '../../../../utilities/amount/amount.dart';
-import '../../../../utilities/amount/amount_formatter.dart';
import '../../../../utilities/amount/amount_input_formatter.dart';
+import '../../../../utilities/amount/amount_unit.dart';
import '../../../../utilities/clipboard_interface.dart';
import '../../../../utilities/constants.dart';
import '../../../../utilities/logger.dart';
@@ -50,6 +51,26 @@ import '../../../../widgets/textfield_icon_button.dart';
import '../../../desktop_home_view.dart';
import 'address_book_address_chooser/address_book_address_chooser.dart';
+Amount? parseDesktopSolTokenAmount(
+ String value, {
+ required String locale,
+ required CryptoCurrency coin,
+ required SolContract tokenContract,
+}) {
+ if (value.contains(RegExp(r'[+\- ]'))) return null;
+ return AmountUnit.normal.tryParse(
+ value,
+ locale: locale,
+ coin: coin,
+ tokenContract: tokenContract,
+ );
+}
+
+Amount? parseDesktopSolTokenFiatAmount(String value, {required String locale}) {
+ if (value.contains(RegExp(r'[+\- ]'))) return null;
+ return Amount.tryParseFiatString(value, locale: locale);
+}
+
class DesktopSolTokenSend extends ConsumerStatefulWidget {
const DesktopSolTokenSend({
super.key,
@@ -370,27 +391,40 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
}
}
+ String _formatTokenAmount(Amount amount) {
+ final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!;
+ return AmountUnit.normal.displayAmount(
+ amount: amount,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ coin: coin,
+ maxDecimalPlaces: tokenWallet.tokenDecimals,
+ withUnitName: false,
+ tokenContract: tokenWallet.solContract,
+ );
+ }
+
void _cryptoAmountChanged() async {
if (!_cryptoAmountChangeLock) {
// Get the token's decimal places for proper amount parsing
- final tokenDecimals = ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals;
+ final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!;
if (cryptoAmountController.text.isNotEmpty &&
cryptoAmountController.text != "." &&
cryptoAmountController.text != ",") {
try {
// Parse the amount using the token's decimal places, not the coin's
- final inputDecimal = Decimal.parse(
- cryptoAmountController.text.replaceFirst(",", "."),
+ final parsedAmount = parseDesktopSolTokenAmount(
+ cryptoAmountController.text,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ coin: coin,
+ tokenContract: tokenWallet.solContract,
);
- final cryptoAmount = Amount.fromDecimal(
- inputDecimal,
- fractionDigits: tokenDecimals,
- );
-
+ if (parsedAmount == null) {
+ throw const FormatException();
+ }
// Only proceed if the parsed amount is valid
- if (cryptoAmount.raw > BigInt.zero) {
- _amountToSend = cryptoAmount;
+ if (parsedAmount.raw > BigInt.zero) {
+ _amountToSend = parsedAmount;
if (_cachedAmountToSend != null &&
_cachedAmountToSend == _amountToSend) {
return;
@@ -499,9 +533,7 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
final Amount amount = Decimal.parse(paymentData.amount!).toAmount(
fractionDigits: ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals,
);
- cryptoAmountController.text = ref
- .read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
+ cryptoAmountController.text = _formatTokenAmount(amount);
_amountToSend = amount;
}
@@ -555,15 +587,12 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
.read(pCurrentSolanaTokenWallet)!
.tokenDecimals;
- if (baseAmountString.isNotEmpty &&
- baseAmountString != "." &&
- baseAmountString != ",") {
- final baseAmount = baseAmountString.contains(",")
- ? Decimal.parse(
- baseAmountString.replaceFirst(",", "."),
- ).toAmount(fractionDigits: 2)
- : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2);
+ final baseAmount = parseDesktopSolTokenFiatAmount(
+ baseAmountString,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
+ if (baseAmount != null) {
final Decimal? _price = ref
.read(priceAnd24hChangeNotifierProvider)
.getTokenPrice(ref.read(pCurrentSolanaTokenWallet)!.tokenMint)
@@ -583,12 +612,8 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
}
_cachedAmountToSend = _amountToSend;
- final amountString = ref
- .read(pAmountFormatter(coin))
- .format(_amountToSend!, withUnitName: false);
-
_cryptoAmountChangeLock = true;
- cryptoAmountController.text = amountString;
+ cryptoAmountController.text = _formatTokenAmount(_amountToSend!);
_cryptoAmountChangeLock = false;
} else {
_amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals);
@@ -609,9 +634,7 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
)),
);
- cryptoAmountController.text = balance.spendable.decimal.toStringAsFixed(
- tokenWallet.tokenDecimals,
- );
+ cryptoAmountController.text = _formatTokenAmount(balance.spendable);
}
@override
@@ -639,7 +662,13 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
if (_data != null) {
if (_data!.amount != null) {
- cryptoAmountController.text = _data!.amount!.toString();
+ final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!;
+ cryptoAmountController.text = _formatTokenAmount(
+ Amount.fromDecimal(
+ _data!.amount!,
+ fractionDigits: tokenWallet.tokenDecimals,
+ ),
+ );
}
sendToController.text = _data!.contactLabel;
_address = _data!.address;
@@ -734,7 +763,7 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
inputFormatters: [
AmountInputFormatter(
decimals: tokenWallet.tokenDecimals,
- unit: ref.watch(pAmountUnit(coin)),
+ unit: AmountUnit.normal,
locale: ref.watch(
localeServiceChangeNotifierProvider.select(
(value) => value.locale,
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart
index f01cdd2..014f8d8 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart
@@ -55,6 +55,11 @@ import '../../../desktop_home_view.dart';
import 'address_book_address_chooser/address_book_address_chooser.dart';
import 'desktop_send_fee_form.dart';
+Amount? parseDesktopTokenFiatAmount(String value, {required String locale}) {
+ if (value.contains(RegExp(r'[+\- ]'))) return null;
+ return Amount.tryParseFiatString(value, locale: locale);
+}
+
class DesktopTokenSend extends ConsumerStatefulWidget {
const DesktopTokenSend({
super.key,
@@ -523,15 +528,12 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
.tokenContract
.decimals;
- if (baseAmountString.isNotEmpty &&
- baseAmountString != "." &&
- baseAmountString != ",") {
- final baseAmount = baseAmountString.contains(",")
- ? Decimal.parse(
- baseAmountString.replaceFirst(",", "."),
- ).toAmount(fractionDigits: 2)
- : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2);
+ final baseAmount = parseDesktopTokenFiatAmount(
+ baseAmountString,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
+ if (baseAmount != null) {
final Decimal? _price = ref
.read(priceAnd24hChangeNotifierProvider)
.getTokenPrice(ref.read(pCurrentTokenWallet)!.tokenContract.address)
diff --git a/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart b/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart
new file mode 100644
index 0000000..00a6580
--- /dev/null
+++ b/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart
@@ -0,0 +1,44 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/models/isar/models/solana/sol_contract.dart';
+import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart';
+import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart';
+import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart';
+
+void main() {
+ test('desktop token inputs parse locale grouping', () {
+ final token = SolContract(
+ address: 'mint',
+ name: 'Token',
+ symbol: 'TKN',
+ decimals: 6,
+ );
+ final solana = Solana(CryptoCurrencyNetwork.main);
+
+ expect(
+ parseDesktopSolTokenAmount(
+ '1,000',
+ locale: 'en_US',
+ coin: solana,
+ tokenContract: token,
+ )?.raw,
+ BigInt.from(1000000000),
+ );
+ expect(
+ parseDesktopSolTokenFiatAmount('1,000', locale: 'en_US')?.raw,
+ BigInt.from(100000),
+ );
+ expect(
+ parseDesktopTokenFiatAmount('1.000', locale: 'de_DE')?.raw,
+ BigInt.from(100000),
+ );
+ expect(
+ parseDesktopSolTokenAmount(
+ '-1',
+ locale: 'en_US',
+ coin: solana,
+ tokenContract: token,
+ ),
+ isNull,
+ );
+ });
+}
Why this scored 59/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.