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>
This commit is contained in:
David Adegoke
2025-10-11 04:20:09 +03:00
committed by GitHub
co-authored by Omar Hatem
parent a56ae8e4ab
commit 025fc9154c
7 changed files with 179 additions and 55 deletions
+56 -13
View File
@@ -107,6 +107,8 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
bool _effectsInstalled = false;
BuildContext? loadingBottomSheetContext;
bool _justHandledPasteButton = false;
String _lastHandledAddress = '';
@override
void initState() {
@@ -385,22 +387,27 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
onPushPasteButton: (context) async {
output.resetParsedAddress();
await output.fetchParsedAddress(context);
_justHandledPasteButton = true;
try {
output.resetParsedAddress();
await output.fetchParsedAddress(context);
final address =
output.isParsedAddress ? output.extractedAddress : output.address;
final address =
output.isParsedAddress ? output.extractedAddress : output.address;
await _handlePaymentFlow(
address,
PaymentRequest(
await _handlePaymentFlow(
address,
cryptoAmountController.text,
noteController.text,
"",
null,
),
);
PaymentRequest(
address,
cryptoAmountController.text,
noteController.text,
"",
null,
),
);
} finally {
_justHandledPasteButton = false;
}
},
onPushAddressBookButton: (context) async {
output.resetParsedAddress();
@@ -419,6 +426,7 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
controller: extractedAddressController,
readOnly: true,
enableInteractiveSelection: false,
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 16,
fontWeight: FontWeight.w500,
@@ -768,7 +776,42 @@ class SendCardState extends State<SendCard> with AutomaticKeepAliveClientMixin<S
addressFocusNode.addListener(() async {
if (!addressFocusNode.hasFocus && addressController.text.isNotEmpty) {
final current = addressController.text.trim();
if (current.isEmpty) return;
if (_justHandledPasteButton || _lastHandledAddress == current) return;
await output.fetchParsedAddress(context);
// If it's a URI with params, go through URI flow
if (current.contains('=')) {
try {
final uri = Uri.parse(current);
_lastHandledAddress = current;
await _handlePaymentFlow(
uri.toString(),
PaymentRequest.fromUri(uri),
);
return;
} catch (_) {
// fall through to plain address
}
}
final parsedAddress = output.isParsedAddress
? output.extractedAddress
: output.address;
_lastHandledAddress = current;
await _handlePaymentFlow(
parsedAddress,
PaymentRequest(
parsedAddress,
cryptoAmountController.text,
noteController.text,
"",
null,
),
);
}
});
@@ -138,6 +138,12 @@ class OtherSettingsPage extends BasePage {
title: '[dev] *QR tools',
handler: (context) => 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),
@@ -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),
],
],
@@ -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<SwapConfirmationContent> {
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<FormState>();
ReactionDisposer? _receiveAmountReaction;
@@ -94,6 +92,7 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
bool _showingFailureDialog = false;
bool _showingSwapDetailsDialog = false;
bool _isUserTypingFiat = false;
@override
void initState() {
@@ -106,8 +105,6 @@ class SwapConfirmationContentState extends State<SwapConfirmationContent> {
: '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<SwapConfirmationContent> {
_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<SwapConfirmationContent> {
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<SwapConfirmationContent> {
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<SwapConfirmationContent> {
_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<SwapConfirmationContent> {
_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<SwapConfirmationContent> {
}
});
_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<TextInputFormatter>? 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,
),
],
),
+27 -4
View File
@@ -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;
}
}
@@ -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));
}
}
+1
View File
@@ -241,6 +241,7 @@ abstract class OutputBase with Store {
void setSendAll(String fullBalance) {
cryptoFullBalance = fullBalance;
sendAll = true;
_updateFiatAmount();
}
@action