From 025fc9154cf545acb703defeba15cef1fd63596e Mon Sep 17 00:00:00 2001 From: David Adegoke <64401859+Blazebrain@users.noreply.github.com> Date: Sat, 11 Oct 2025 02:20:09 +0100 Subject: [PATCH] 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 --- lib/src/screens/send/widgets/send_card.dart | 69 +++++++++++++++---- .../screens/settings/other_settings_page.dart | 6 ++ .../payment_confirmation_bottom_sheet.dart | 57 ++++++++++----- .../swap_confirmation_bottom_sheet.dart | 62 ++++++++++++----- lib/utils/payment_request.dart | 31 +++++++-- .../exchange/exchange_view_model.dart | 8 ++- lib/view_model/send/output.dart | 1 + 7 files changed, 179 insertions(+), 55 deletions(-) diff --git a/lib/src/screens/send/widgets/send_card.dart b/lib/src/screens/send/widgets/send_card.dart index d12071936..dee54419f 100644 --- a/lib/src/screens/send/widgets/send_card.dart +++ b/lib/src/screens/send/widgets/send_card.dart @@ -107,6 +107,8 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin Navigator.of(context).pushNamed(Routes.devQRTools), ), + if (FeatureFlag.hasDevOptions) + SettingsCellWithArrow( + title: '[dev] exchange provider logs', + handler: (BuildContext context) => + Navigator.of(context).pushNamed(Routes.devExchangeProviderLogs), + ), Spacer(), SettingsVersionCell( title: S.of(context).version(_otherSettingsViewModel.currentVersion), diff --git a/lib/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart b/lib/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart index 23913aa2a..afe2e404b 100644 --- a/lib/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart +++ b/lib/src/widgets/bottom_sheet/payment_confirmation_bottom_sheet.dart @@ -65,6 +65,21 @@ class _PaymentConfirmationContent extends StatelessWidget { final VoidCallback onChangeWallet; final VoidCallback onSwap; + /// Checks if the given address is a MWEB or SP (Silent Payment) address + bool _isMwebOrSpAddress(String address) { + if (address.isEmpty) return false; + + final lowerAddress = address.toLowerCase(); + + // Check for MWEB addresses (Litecoin MWEB addresses start with "ltcmweb1") + if (lowerAddress.startsWith('ltcmweb1')) return true; + + // Check for Silent Payment addresses (Bitcoin SP addresses start with "sp1", "tsp1") + if (lowerAddress.startsWith('sp1') || lowerAddress.startsWith('tsp1')) return true; + + return false; + } + @override Widget build(BuildContext context) { return Observer( @@ -78,6 +93,9 @@ class _PaymentConfirmationContent extends StatelessWidget { final hasAtLeastOneWallet = paymentFlowResult.type == PaymentFlowType.singleWallet || hasMultipleWallets; + final isMwebOrSpAddress = + _isMwebOrSpAddress(paymentFlowResult.addressDetectionResult?.address ?? ''); + return Container( padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), child: Column( @@ -114,13 +132,15 @@ class _PaymentConfirmationContent extends StatelessWidget { ), const SizedBox(height: 72), if (hasAtLeastOneWallet) ...[ - PrimaryButton( - onPressed: onSwap, - text: '${S.current.swap} $currentWalletName', - color: Theme.of(context).colorScheme.surfaceContainer, - textColor: Theme.of(context).colorScheme.onSecondaryContainer, - ), - const SizedBox(height: 10), + if (!isMwebOrSpAddress) ...[ + PrimaryButton( + onPressed: onSwap, + text: '${S.current.swap} $currentWalletName', + color: Theme.of(context).colorScheme.surfaceContainer, + textColor: Theme.of(context).colorScheme.onSecondaryContainer, + ), + const SizedBox(height: 10), + ], PrimaryButton( onPressed: hasMultipleWallets ? onSelectWallet : onChangeWallet, text: S.current.switch_wallet, @@ -136,16 +156,19 @@ class _PaymentConfirmationContent extends StatelessWidget { textColor: Theme.of(context).colorScheme.onSecondaryContainer, ), const SizedBox(height: 10), - PrimaryButton( - onPressed: onSwap, - text: '${S.current.swap} $currentWalletName', - color: hasAtLeastOneWallet - ? Theme.of(context).colorScheme.surfaceContainer - : Theme.of(context).colorScheme.primary, - textColor: hasAtLeastOneWallet - ? Theme.of(context).colorScheme.onSecondaryContainer - : Theme.of(context).colorScheme.onPrimary, - ), + if (!isMwebOrSpAddress) ...[ + PrimaryButton( + onPressed: onSwap, + text: '${S.current.swap} $currentWalletName', + color: hasAtLeastOneWallet + ? Theme.of(context).colorScheme.surfaceContainer + : Theme.of(context).colorScheme.primary, + textColor: hasAtLeastOneWallet + ? Theme.of(context).colorScheme.onSecondaryContainer + : Theme.of(context).colorScheme.onPrimary, + ), + const SizedBox(height: 10), + ], const SizedBox(height: 32), ], ], diff --git a/lib/src/widgets/bottom_sheet/swap_confirmation_bottom_sheet.dart b/lib/src/widgets/bottom_sheet/swap_confirmation_bottom_sheet.dart index 0864e882c..69f4abf0b 100644 --- a/lib/src/widgets/bottom_sheet/swap_confirmation_bottom_sheet.dart +++ b/lib/src/widgets/bottom_sheet/swap_confirmation_bottom_sheet.dart @@ -1,5 +1,3 @@ -import 'dart:developer'; - import 'package:cake_wallet/core/amount_validator.dart'; import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/di.dart'; @@ -11,8 +9,10 @@ import 'package:cake_wallet/src/widgets/bottom_sheet/swap_details_bottom_sheet.d import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/utils/address_formatter.dart'; import 'package:cake_wallet/utils/debounce.dart'; +import 'package:cw_core/crypto_amount_format.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cw_core/wallet_type.dart'; @@ -76,14 +76,12 @@ class SwapConfirmationContentState extends State { late TextEditingController _amountController; late TextEditingController _amountFiatController; late TextEditingController _addressController; - late TextEditingController _noteController; final _receiveAmountDebounce = Debounce(Duration(milliseconds: 500)); final _receiveAmountFiatDebounce = Debounce(Duration(milliseconds: 500)); final FocusNode _amountFocus = FocusNode(); final FocusNode _amountFiatFocus = FocusNode(); final FocusNode _addressFocus = FocusNode(); - final FocusNode _noteFocus = FocusNode(); final _formKey = GlobalKey(); ReactionDisposer? _receiveAmountReaction; @@ -94,6 +92,7 @@ class SwapConfirmationContentState extends State { bool _showingFailureDialog = false; bool _showingSwapDetailsDialog = false; + bool _isUserTypingFiat = false; @override void initState() { @@ -106,8 +105,6 @@ class SwapConfirmationContentState extends State { : '0.00'); _amountFiatController = TextEditingController(text: widget.exchangeViewModel.receiveAmountFiatFormatted); - _noteController = - TextEditingController(text: widget.paymentFlowResult.addressDetectionResult?.note ?? ''); WidgetsBinding.instance.addPostFrameCallback( (_) => _setUpReactions( @@ -123,11 +120,9 @@ class SwapConfirmationContentState extends State { _amountController.dispose(); _amountFiatController.dispose(); _addressController.dispose(); - _noteController.dispose(); _amountFocus.dispose(); _amountFiatFocus.dispose(); _addressFocus.dispose(); - _noteFocus.dispose(); _receiveAmountReaction?.call(); _receiveAddressReaction?.call(); _tradeStateReaction?.call(); @@ -176,7 +171,23 @@ class SwapConfirmationContentState extends State { hintText: 'Amount (${detectedCurrencyName})', focusNode: _amountFocus, controller: _amountController, - keyboardType: TextInputType.numberWithOptions(decimal: true), + keyboardType: TextInputType.numberWithOptions(decimal: true, signed: false), + inputFormatters: [ + FilteringTextInputFormatter.deny(RegExp('[\\-|\\ ]')), + ], + onChanged: (value) { + final sanitized = value + .replaceAll(',', '.') + .withMaxDecimals(widget.exchangeViewModel.receiveCurrency.decimals); + if (sanitized != _amountController.text) { + // Update text while preserving a sane cursor position to avoid auto-selection + _amountController.value = _amountController.value.copyWith( + text: sanitized, + selection: TextSelection.collapsed(offset: sanitized.length), + composing: TextRange.empty, + ); + } + }, validator: (value) { return AmountValidator( isAutovalidate: true, @@ -260,14 +271,6 @@ class SwapConfirmationContentState extends State { focusNode: _addressFocus, controller: _addressController, ), - const SizedBox(height: 8), - SwapConfirmationTextfield( - maxLines: 1, - key: ValueKey('swap_confirmation_bottomsheet_note_textfield_key'), - hintText: 'Transaction Note', - focusNode: _noteFocus, - controller: _noteController, - ), SizedBox(height: 8), Center( child: Text( @@ -303,7 +306,7 @@ class SwapConfirmationContentState extends State { _receiveAmountFiatReaction = reaction((_) => exchangeViewModel.receiveAmountFiatFormatted, (String amount) { - if (_amountFiatController.text != amount) { + if (!_isUserTypingFiat && _amountFiatController.text != amount) { _amountFiatController.text = amount; } }); @@ -381,9 +384,14 @@ class SwapConfirmationContentState extends State { _amountFiatController.addListener(() { if (_amountFiatController.text != exchangeViewModel.receiveAmountFiatFormatted) { + _isUserTypingFiat = true; _receiveAmountFiatDebounce.run(() { exchangeViewModel.loadLimits(); exchangeViewModel.setReceiveAmountFromFiat(fiatAmount: _amountFiatController.text); + // Reset the flag after the debounced operation completes + Future.delayed(Duration(milliseconds: 100), () { + _isUserTypingFiat = false; + }); }); } }); @@ -394,6 +402,17 @@ class SwapConfirmationContentState extends State { } }); + _amountFiatFocus.addListener(() { + if (_amountFiatFocus.hasFocus) { + _isUserTypingFiat = true; + } else { + // Reset the flag when user stops focusing on the field + Future.delayed(Duration(milliseconds: 200), () { + _isUserTypingFiat = false; + }); + } + }); + exchangeViewModel.receiveCurrency = walletTypeToCryptoCurrency(paymentFlowResult.walletType!); await exchangeViewModel.fetchFiatPrice(exchangeViewModel.receiveCurrency); @@ -417,6 +436,8 @@ class SwapConfirmationTextfield extends StatelessWidget { this.maxLines = 1, this.validator, this.keyboardType, + this.onChanged, + this.inputFormatters, }); final FocusNode focusNode; @@ -427,7 +448,8 @@ class SwapConfirmationTextfield extends StatelessWidget { final int maxLines; final String? Function(String?)? validator; final TextInputType? keyboardType; - + final void Function(String)? onChanged; + final List? inputFormatters; @override Widget build(BuildContext context) { return Container( @@ -472,6 +494,8 @@ class SwapConfirmationTextfield extends StatelessWidget { maxLines: maxLines, validator: validator, keyboardType: keyboardType, + onChanged: onChanged, + inputFormatters: inputFormatters, ), ], ), diff --git a/lib/utils/payment_request.dart b/lib/utils/payment_request.dart index c6519897c..b574ab260 100644 --- a/lib/utils/payment_request.dart +++ b/lib/utils/payment_request.dart @@ -44,10 +44,12 @@ class PaymentRequest { if (nano != null) { if (amount.isNotEmpty) { - if (address.contains("nano")) { - amount = nanoUtil!.getRawAsUsableString(amount, nanoUtil!.rawPerNano); - } else if (address.contains("ban")) { - amount = nanoUtil!.getRawAsUsableString(amount, nanoUtil!.rawPerBanano); + if (!_isAlreadyUsableAmount(amount)) { + if (address.contains("nano")) { + amount = nanoUtil!.getRawAsUsableString(amount, nanoUtil!.rawPerNano); + } else if (address.contains("ban")) { + amount = nanoUtil!.getRawAsUsableString(amount, nanoUtil!.rawPerBanano); + } } } } @@ -72,4 +74,25 @@ class PaymentRequest { final String? callbackUrl; final String? callbackMessage; final String? contractAddress; + + /// 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. + /// + /// 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. + static bool _isAlreadyUsableAmount(String amount) { + if (amount.isEmpty) return false; + + // Try to parse as double - if successful, it's already in usable format + final parsed = double.tryParse(amount.replaceAll(',', '.')); + if (parsed == null) return false; + + // Check if the amount contains a decimal point and is a reasonable number, + // it's likely already in usable format rather than raw format + // Raw amounts are typically very large integers without decimal points + if (amount.contains('.') && parsed > 0 && parsed < 1000000000) return true; + + // If it's a small integer (less than 1 billion), it's likely already usable + if (parsed == parsed.toInt() && parsed < 1000000000) return true; + + return false; + } } diff --git a/lib/view_model/exchange/exchange_view_model.dart b/lib/view_model/exchange/exchange_view_model.dart index 390a0bec4..8c120887d 100644 --- a/lib/view_model/exchange/exchange_view_model.dart +++ b/lib/view_model/exchange/exchange_view_model.dart @@ -43,6 +43,7 @@ import 'package:cake_wallet/utils/token_utilities.dart'; import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; import 'package:cake_wallet/view_model/send/fees_view_model.dart'; import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart'; +import 'package:cw_core/crypto_amount_format.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/erc20_token.dart'; import 'package:cw_core/spl_token.dart'; @@ -452,10 +453,13 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with @action void setReceiveAmountFromFiat({required String fiatAmount}) { final _enteredAmount = double.tryParse(fiatAmount.replaceAll(',', '.')) ?? 0.0; - final crypto = _enteredAmount / fiatConversionStore.prices[receiveCurrency]!; + final price = fiatConversionStore.prices[receiveCurrency]; + if (price == null || price == 0.0) return; + + final crypto = _enteredAmount / price; final receiveAmountTmp = _cryptoNumberFormat.format(crypto); if (receiveAmount != receiveAmountTmp) { - changeReceiveAmount(amount: receiveAmountTmp); + changeReceiveAmount(amount: receiveAmountTmp.withMaxDecimals(receiveCurrency.decimals)); } } diff --git a/lib/view_model/send/output.dart b/lib/view_model/send/output.dart index 9a9aa462b..5e7c8d45b 100644 --- a/lib/view_model/send/output.dart +++ b/lib/view_model/send/output.dart @@ -241,6 +241,7 @@ abstract class OutputBase with Store { void setSendAll(String fullBalance) { cryptoFullBalance = fullBalance; sendAll = true; + _updateFiatAmount(); } @action