Pay Anything Flow Enhancements (#2564)

* fix: update Bitcoin and Litecoin Bech32 address patterns for improved detection for pay anything flow * feat: add Litecoin MWEB address detection patterns for pay anything flow * feat: Add support for ERC-681 payment scheme to QR and link to exchange send from external QR * feat: Introduce TokenUtilities to centralize token management across viewmodels - Added TokenUtilities class to centralize token-related operations for EVM, Solana, and Tron tokens. - Refactored existing methods in ExchangeTradeViewModel and SendViewModel to utilize TokenUtilities. - Updated PaymentRequest to include contractAddress. - Enhanced payment QR scanning to fetch token details for QRs with contract addresses - ERC-681 QR scheme * fix: Prefill amount with other data when app is brought up via payment deeplink on QR scan * fix: Reduce trade not created errors WIP * feat: Add exchange provider logging functionality * feat: Enhance ERC681URI with amount normalization, prevents parsing issues with various input formats for amounts * - Use regex from address validator for btc/ltc manual checks - Add check for solana addresses - Handle failures in normalizeToInteger method - fix issue with address only being added when there's an amount * feat: Trigger pay anything flow when address is pasted via ctrl v or paste input * fix: remove duplicate registration * fix: disable text selection in SendCard address field * feat: Prevent swap option from showing for Mweb and Silent Payment addresses in Pay Anything flow * refactor: remove notes widget and controllers from swap confirmation bottomsheet * fix: Issue with tap send all button not responsive, and updateing fiat amount, after wallet syncing is complete if it was previously tapped while wallet was syncing * fix: Resolve excessive decimal places error with input sanitization and formatting for amount entry * fix: Add check for already usable amount format to prevent errors with Nano QR scanning * fix: Improve fiat input handling by preventing unnecessary updates during user input * fix: Handle potential null case for setReceiveAmountFromFiat * Update lib/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

David Adegoke committed Oct 11, 2025 at 02:20 UTC 025fc9154cf545acb703defeba15cef1fd63596e
7 files changed +179 -55
lib/src/screens/send/widgets/send_card.dart
+56 -13
@@ -107,6 +107,8 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
107
108 bool _effectsInstalled = false;
109 BuildContext? loadingBottomSheetContext;
110 + bool _justHandledPasteButton = false;
111 + String _lastHandledAddress = '';
112
113 @override
114 void initState() {
@@ -385,22 +387,27 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
387 color: Theme.of(context).colorScheme.onSurfaceVariant,
388 ),
389 onPushPasteButton: (context) async {
388 - output.resetParsedAddress();
389 - await output.fetchParsedAddress(context);
390 + _justHandledPasteButton = true;
391 + try {
392 + output.resetParsedAddress();
393 + await output.fetchParsedAddress(context);
394
391 - final address =
392 - output.isParsedAddress ? output.extractedAddress : output.address;
395 + final address =
396 + output.isParsedAddress ? output.extractedAddress : output.address;
397
394 - await _handlePaymentFlow(
395 - address,
396 - PaymentRequest(
398 + await _handlePaymentFlow(
399 address,
398 - cryptoAmountController.text,
399 - noteController.text,
400 - "",
401 - null,
402 - ),
403 - );
400 + PaymentRequest(
401 + address,
402 + cryptoAmountController.text,
403 + noteController.text,
404 + "",
405 + null,
406 + ),
407 + );
408 + } finally {
409 + _justHandledPasteButton = false;
410 + }
411 },
412 onPushAddressBookButton: (context) async {
413 output.resetParsedAddress();
@@ -419,6 +426,7 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
426 fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
427 controller: extractedAddressController,
428 readOnly: true,
429 + enableInteractiveSelection: false,
430 textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
431 fontSize: 16,
432 fontWeight: FontWeight.w500,
@@ -768,7 +776,42 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
776
777 addressFocusNode.addListener(() async {
778 if (!addressFocusNode.hasFocus && addressController.text.isNotEmpty) {
779 + final current = addressController.text.trim();
780 + if (current.isEmpty) return;
781 + if (_justHandledPasteButton || _lastHandledAddress == current) return;
782 +
783 await output.fetchParsedAddress(context);
784 +
785 + // If it's a URI with params, go through URI flow
786 + if (current.contains('=')) {
787 + try {
788 + final uri = Uri.parse(current);
789 + _lastHandledAddress = current;
790 + await _handlePaymentFlow(
791 + uri.toString(),
792 + PaymentRequest.fromUri(uri),
793 + );
794 + return;
795 + } catch (_) {
796 + // fall through to plain address
797 + }
798 + }
799 +
800 + final parsedAddress = output.isParsedAddress
801 + ? output.extractedAddress
802 + : output.address;
803 +
804 + _lastHandledAddress = current;
805 + await _handlePaymentFlow(
806 + parsedAddress,
807 + PaymentRequest(
808 + parsedAddress,
809 + cryptoAmountController.text,
810 + noteController.text,
811 + "",
812 + null,
813 + ),
814 + );
815 }
816 });
817
lib/src/screens/settings/other_settings_page.dart
+6
@@ -138,6 +138,12 @@ class OtherSettingsPage extends BasePage {
138 title: '[dev] *QR tools',
139 handler: (context) => Navigator.of(context).pushNamed(Routes.devQRTools),
140 ),
141 + if (FeatureFlag.hasDevOptions)
142 + SettingsCellWithArrow(
143 + title: '[dev] exchange provider logs',
144 + handler: (BuildContext context) =>
145 + Navigator.of(context).pushNamed(Routes.devExchangeProviderLogs),
146 + ),
147 Spacer(),
148 SettingsVersionCell(
149 title: S.of(context).version(_otherSettingsViewModel.currentVersion),
lib/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart
+40 -17
@@ -65,6 +65,21 @@ class _PaymentConfirmationContent extends StatelessWidget {
65 final VoidCallback onChangeWallet;
66 final VoidCallback onSwap;
67
68 + /// Checks if the given address is a MWEB or SP (Silent Payment) address
69 + bool _isMwebOrSpAddress(String address) {
70 + if (address.isEmpty) return false;
71 +
72 + final lowerAddress = address.toLowerCase();
73 +
74 + // Check for MWEB addresses (Litecoin MWEB addresses start with "ltcmweb1")
75 + if (lowerAddress.startsWith('ltcmweb1')) return true;
76 +
77 + // Check for Silent Payment addresses (Bitcoin SP addresses start with "sp1", "tsp1")
78 + if (lowerAddress.startsWith('sp1') || lowerAddress.startsWith('tsp1')) return true;
79 +
80 + return false;
81 + }
82 +
83 @override
84 Widget build(BuildContext context) {
85 return Observer(
@@ -78,6 +93,9 @@ class _PaymentConfirmationContent extends StatelessWidget {
93 final hasAtLeastOneWallet =
94 paymentFlowResult.type == PaymentFlowType.singleWallet || hasMultipleWallets;
95
96 + final isMwebOrSpAddress =
97 + _isMwebOrSpAddress(paymentFlowResult.addressDetectionResult?.address ?? '');
98 +
99 return Container(
100 padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
101 child: Column(
@@ -114,13 +132,15 @@ class _PaymentConfirmationContent extends StatelessWidget {
132 ),
133 const SizedBox(height: 72),
134 if (hasAtLeastOneWallet) ...[
117 - PrimaryButton(
118 - onPressed: onSwap,
119 - text: '${S.current.swap} $currentWalletName',
120 - color: Theme.of(context).colorScheme.surfaceContainer,
121 - textColor: Theme.of(context).colorScheme.onSecondaryContainer,
122 - ),
123 - const SizedBox(height: 10),
135 + if (!isMwebOrSpAddress) ...[
136 + PrimaryButton(
137 + onPressed: onSwap,
138 + text: '${S.current.swap} $currentWalletName',
139 + color: Theme.of(context).colorScheme.surfaceContainer,
140 + textColor: Theme.of(context).colorScheme.onSecondaryContainer,
141 + ),
142 + const SizedBox(height: 10),
143 + ],
144 PrimaryButton(
145 onPressed: hasMultipleWallets ? onSelectWallet : onChangeWallet,
146 text: S.current.switch_wallet,
@@ -136,16 +156,19 @@ class _PaymentConfirmationContent extends StatelessWidget {
156 textColor: Theme.of(context).colorScheme.onSecondaryContainer,
157 ),
158 const SizedBox(height: 10),
139 - PrimaryButton(
140 - onPressed: onSwap,
141 - text: '${S.current.swap} $currentWalletName',
142 - color: hasAtLeastOneWallet
143 - ? Theme.of(context).colorScheme.surfaceContainer
144 - : Theme.of(context).colorScheme.primary,
145 - textColor: hasAtLeastOneWallet
146 - ? Theme.of(context).colorScheme.onSecondaryContainer
147 - : Theme.of(context).colorScheme.onPrimary,
148 - ),
159 + if (!isMwebOrSpAddress) ...[
160 + PrimaryButton(
161 + onPressed: onSwap,
162 + text: '${S.current.swap} $currentWalletName',
163 + color: hasAtLeastOneWallet
164 + ? Theme.of(context).colorScheme.surfaceContainer
165 + : Theme.of(context).colorScheme.primary,
166 + textColor: hasAtLeastOneWallet
167 + ? Theme.of(context).colorScheme.onSecondaryContainer
168 + : Theme.of(context).colorScheme.onPrimary,
169 + ),
170 + const SizedBox(height: 10),
171 + ],
172 const SizedBox(height: 32),
173 ],
174 ],
lib/src/widgets/bottom_sheet/swap_confirmation_bottom_sheet.dart
+43 -19
@@ -1,5 +1,3 @@
1 -import 'dart:developer';
2 -
1 import 'package:cake_wallet/core/amount_validator.dart';
2 import 'package:cake_wallet/core/auth_service.dart';
3 import 'package:cake_wallet/di.dart';
@@ -11,8 +9,10 @@ import 'package:cake_wallet/src/widgets/bottom_sheet/swap_details_bottom_sheet.d
9 import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
10 import 'package:cake_wallet/utils/address_formatter.dart';
11 import 'package:cake_wallet/utils/debounce.dart';
12 +import 'package:cw_core/crypto_amount_format.dart';
13
14 import 'package:flutter/material.dart';
15 +import 'package:flutter/services.dart';
16 import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
17 import 'package:cake_wallet/src/widgets/primary_button.dart';
18 import 'package:cw_core/wallet_type.dart';
@@ -76,14 +76,12 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
76 late TextEditingController _amountController;
77 late TextEditingController _amountFiatController;
78 late TextEditingController _addressController;
79 - late TextEditingController _noteController;
79
80 final _receiveAmountDebounce = Debounce(Duration(milliseconds: 500));
81 final _receiveAmountFiatDebounce = Debounce(Duration(milliseconds: 500));
82 final FocusNode _amountFocus = FocusNode();
83 final FocusNode _amountFiatFocus = FocusNode();
84 final FocusNode _addressFocus = FocusNode();
86 - final FocusNode _noteFocus = FocusNode();
85 final _formKey = GlobalKey<FormState>();
86
87 ReactionDisposer? _receiveAmountReaction;
@@ -94,6 +92,7 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
92
93 bool _showingFailureDialog = false;
94 bool _showingSwapDetailsDialog = false;
95 + bool _isUserTypingFiat = false;
96
97 @override
98 void initState() {
@@ -106,8 +105,6 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
105 : '0.00');
106 _amountFiatController =
107 TextEditingController(text: widget.exchangeViewModel.receiveAmountFiatFormatted);
109 - _noteController =
110 - TextEditingController(text: widget.paymentFlowResult.addressDetectionResult?.note ?? '');
108
109 WidgetsBinding.instance.addPostFrameCallback(
110 (_) => _setUpReactions(
@@ -123,11 +120,9 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
120 _amountController.dispose();
121 _amountFiatController.dispose();
122 _addressController.dispose();
126 - _noteController.dispose();
123 _amountFocus.dispose();
124 _amountFiatFocus.dispose();
125 _addressFocus.dispose();
130 - _noteFocus.dispose();
126 _receiveAmountReaction?.call();
127 _receiveAddressReaction?.call();
128 _tradeStateReaction?.call();
@@ -176,7 +171,23 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
171 hintText: 'Amount (${detectedCurrencyName})',
172 focusNode: _amountFocus,
173 controller: _amountController,
179 - keyboardType: TextInputType.numberWithOptions(decimal: true),
174 + keyboardType: TextInputType.numberWithOptions(decimal: true, signed: false),
175 + inputFormatters: [
176 + FilteringTextInputFormatter.deny(RegExp('[\\-|\\ ]')),
177 + ],
178 + onChanged: (value) {
179 + final sanitized = value
180 + .replaceAll(',', '.')
181 + .withMaxDecimals(widget.exchangeViewModel.receiveCurrency.decimals);
182 + if (sanitized != _amountController.text) {
183 + // Update text while preserving a sane cursor position to avoid auto-selection
184 + _amountController.value = _amountController.value.copyWith(
185 + text: sanitized,
186 + selection: TextSelection.collapsed(offset: sanitized.length),
187 + composing: TextRange.empty,
188 + );
189 + }
190 + },
191 validator: (value) {
192 return AmountValidator(
193 isAutovalidate: true,
@@ -260,14 +271,6 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
271 focusNode: _addressFocus,
272 controller: _addressController,
273 ),
263 - const SizedBox(height: 8),
264 - SwapConfirmationTextfield(
265 - maxLines: 1,
266 - key: ValueKey('swap_confirmation_bottomsheet_note_textfield_key'),
267 - hintText: 'Transaction Note',
268 - focusNode: _noteFocus,
269 - controller: _noteController,
270 - ),
274 SizedBox(height: 8),
275 Center(
276 child: Text(
@@ -303,7 +306,7 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
306
307 _receiveAmountFiatReaction =
308 reaction((_) => exchangeViewModel.receiveAmountFiatFormatted, (String amount) {
306 - if (_amountFiatController.text != amount) {
309 + if (!_isUserTypingFiat && _amountFiatController.text != amount) {
310 _amountFiatController.text = amount;
311 }
312 });
@@ -381,9 +384,14 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
384
385 _amountFiatController.addListener(() {
386 if (_amountFiatController.text != exchangeViewModel.receiveAmountFiatFormatted) {
387 + _isUserTypingFiat = true;
388 _receiveAmountFiatDebounce.run(() {
389 exchangeViewModel.loadLimits();
390 exchangeViewModel.setReceiveAmountFromFiat(fiatAmount: _amountFiatController.text);
391 + // Reset the flag after the debounced operation completes
392 + Future.delayed(Duration(milliseconds: 100), () {
393 + _isUserTypingFiat = false;
394 + });
395 });
396 }
397 });
@@ -394,6 +402,17 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
402 }
403 });
404
405 + _amountFiatFocus.addListener(() {
406 + if (_amountFiatFocus.hasFocus) {
407 + _isUserTypingFiat = true;
408 + } else {
409 + // Reset the flag when user stops focusing on the field
410 + Future.delayed(Duration(milliseconds: 200), () {
411 + _isUserTypingFiat = false;
412 + });
413 + }
414 + });
415 +
416 exchangeViewModel.receiveCurrency = walletTypeToCryptoCurrency(paymentFlowResult.walletType!);
417 await exchangeViewModel.fetchFiatPrice(exchangeViewModel.receiveCurrency);
418
@@ -417,6 +436,8 @@ class SwapConfirmationTextfield extends StatelessWidget {
436 this.maxLines = 1,
437 this.validator,
438 this.keyboardType,
439 + this.onChanged,
440 + this.inputFormatters,
441 });
442
443 final FocusNode focusNode;
@@ -427,7 +448,8 @@ class SwapConfirmationTextfield extends StatelessWidget {
448 final int maxLines;
449 final String? Function(String?)? validator;
450 final TextInputType? keyboardType;
430 -
451 + final void Function(String)? onChanged;
452 + final List<TextInputFormatter>? inputFormatters;
453 @override
454 Widget build(BuildContext context) {
455 return Container(
@@ -472,6 +494,8 @@ class SwapConfirmationTextfield extends StatelessWidget {
494 maxLines: maxLines,
495 validator: validator,
496 keyboardType: keyboardType,
497 + onChanged: onChanged,
498 + inputFormatters: inputFormatters,
499 ),
500 ],
501 ),
lib/utils/payment_request.dart
+27 -4
@@ -44,10 +44,12 @@ class PaymentRequest {
44
45 if (nano != null) {
46 if (amount.isNotEmpty) {
47 - if (address.contains("nano")) {
48 - amount = nanoUtil!.getRawAsUsableString(amount, nanoUtil!.rawPerNano);
49 - } else if (address.contains("ban")) {
50 - amount = nanoUtil!.getRawAsUsableString(amount, nanoUtil!.rawPerBanano);
47 + if (!_isAlreadyUsableAmount(amount)) {
48 + if (address.contains("nano")) {
49 + amount = nanoUtil!.getRawAsUsableString(amount, nanoUtil!.rawPerNano);
50 + } else if (address.contains("ban")) {
51 + amount = nanoUtil!.getRawAsUsableString(amount, nanoUtil!.rawPerBanano);
52 + }
53 }
54 }
55 }
@@ -72,4 +74,25 @@ class PaymentRequest {
74 final String? callbackUrl;
75 final String? callbackMessage;
76 final String? contractAddress;
77 +
78 + /// Checks if the amount string is already in a usable format (e.g., "123.45") and doesn't need to be converted from raw format.
79 + ///
80 + /// This was causing an error for us when we scan Nano QRs with amounts in them, the amounts are already in usable format so when the parsing was done, it returns 0 wrongly.
81 + static bool _isAlreadyUsableAmount(String amount) {
82 + if (amount.isEmpty) return false;
83 +
84 + // Try to parse as double - if successful, it's already in usable format
85 + final parsed = double.tryParse(amount.replaceAll(',', '.'));
86 + if (parsed == null) return false;
87 +
88 + // Check if the amount contains a decimal point and is a reasonable number,
89 + // it's likely already in usable format rather than raw format
90 + // Raw amounts are typically very large integers without decimal points
91 + if (amount.contains('.') && parsed > 0 && parsed < 1000000000) return true;
92 +
93 + // If it's a small integer (less than 1 billion), it's likely already usable
94 + if (parsed == parsed.toInt() && parsed < 1000000000) return true;
95 +
96 + return false;
97 + }
98 }
lib/view_model/exchange/exchange_view_model.dart
+6 -2
@@ -43,6 +43,7 @@ import 'package:cake_wallet/utils/token_utilities.dart';
43 import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart';
44 import 'package:cake_wallet/view_model/send/fees_view_model.dart';
45 import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart';
46 +import 'package:cw_core/crypto_amount_format.dart';
47 import 'package:cw_core/crypto_currency.dart';
48 import 'package:cw_core/erc20_token.dart';
49 import 'package:cw_core/spl_token.dart';
@@ -452,10 +453,13 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
453 @action
454 void setReceiveAmountFromFiat({required String fiatAmount}) {
455 final _enteredAmount = double.tryParse(fiatAmount.replaceAll(',', '.')) ?? 0.0;
455 - final crypto = _enteredAmount / fiatConversionStore.prices[receiveCurrency]!;
456 + final price = fiatConversionStore.prices[receiveCurrency];
457 + if (price == null || price == 0.0) return;
458 +
459 + final crypto = _enteredAmount / price;
460 final receiveAmountTmp = _cryptoNumberFormat.format(crypto);
461 if (receiveAmount != receiveAmountTmp) {
458 - changeReceiveAmount(amount: receiveAmountTmp);
462 + changeReceiveAmount(amount: receiveAmountTmp.withMaxDecimals(receiveCurrency.decimals));
463 }
464 }
465
lib/view_model/send/output.dart
+1
@@ -241,6 +241,7 @@ abstract class OutputBase with Store {
241 void setSendAll(String fullBalance) {
242 cryptoFullBalance = fullBalance;
243 sendAll = true;
244 + _updateFiatAmount();
245 }
246
247 @action