From 9a008407d1717abe38462c6635eb4c09075186be Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Wed, 15 Jul 2026 10:51:24 +0000 Subject: [PATCH 1/6] feat: add OpenCryptoPay support --- lib/networking/http.dart | 73 +++ .../open_crypto_pay_send_handler.dart | 224 ++++++++ .../send_view/confirm_transaction_view.dart | 227 ++++++-- lib/pages/send_view/send_view.dart | 27 + lib/pages/send_view/sol_token_send_view.dart | 31 ++ lib/pages/send_view/token_send_view.dart | 30 ++ .../wallet_view/sub_widgets/desktop_send.dart | 25 + .../sub_widgets/desktop_sol_token_send.dart | 31 ++ .../sub_widgets/desktop_token_send.dart | 39 ++ lib/wallets/wallet/impl/ethereum_wallet.dart | 29 +- .../impl/sub_wallets/eth_token_wallet.dart | 4 + .../templates/pubspec.template.yaml | 4 + .../open_crypto_pay_send_handler_test.dart | 498 ++++++++++++++++++ 13 files changed, 1194 insertions(+), 48 deletions(-) create mode 100644 lib/pages/open_crypto_pay/open_crypto_pay_send_handler.dart create mode 100644 test/pages/open_crypto_pay/open_crypto_pay_send_handler_test.dart diff --git a/lib/networking/http.dart b/lib/networking/http.dart index efa997e64a..9444e66050 100644 --- a/lib/networking/http.dart +++ b/lib/networking/http.dart @@ -3,9 +3,13 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart'; import 'package:socks5_proxy/socks_client.dart'; +import '../app_config.dart'; +import '../services/tor_service.dart'; import '../utilities/logger.dart'; +import '../utilities/prefs.dart'; // WIP wrapper layer @@ -163,3 +167,72 @@ class HTTP { return completer.future; } } + +/// HTTP client class that can be used with librairies that +/// accept an http.Client +class StackClient extends BaseClient { + StackClient({HTTP? client, this.timeout = const Duration(seconds: 30)}) + : _client = client ?? const HTTP(); + + final HTTP _client; + final Duration timeout; + + static ({InternetAddress host, int port})? _proxyInfo() { + if (AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor) { + return TorService.sharedInstance.getProxyInfo(); + } else { + return null; + } + } + + @override + Future send(BaseRequest request) async { + final proxyInfo = _proxyInfo(); + + final Response response; + switch (request.method) { + case 'GET': + response = await _client + .get( + url: request.url, + headers: request.headers, + proxyInfo: proxyInfo, + connectionTimeout: timeout, + ) + .timeout(timeout); + case 'POST': + final body = await _readBody(request); + response = await _client + .post( + url: request.url, + headers: request.headers, + body: body, + proxyInfo: proxyInfo, + ) + .timeout(timeout); + default: + throw ClientException( + 'Unsupported HTTP method ${request.method}', + request.url, + ); + } + + return StreamedResponse( + ByteStream.fromBytes(response.bodyBytes), + response.code, + contentLength: response.bodyBytes.length, + request: request, + ); + } + + Future _readBody(BaseRequest request) async { + if (request is! Request) { + throw UnsupportedError( + 'Client only supports Request bodies, ' + 'got ${request.runtimeType}.', + ); + } + final bytes = await request.finalize().toBytes(); + return bytes.isEmpty ? null : String.fromCharCodes(bytes); + } +} diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_send_handler.dart b/lib/pages/open_crypto_pay/open_crypto_pay_send_handler.dart new file mode 100644 index 0000000000..cb171cf635 --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_send_handler.dart @@ -0,0 +1,224 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:opencryptopay/opencryptopay.dart'; + +import '../../networking/http.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/ui/preview_tx_button_state_provider.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_formatter.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/show_loading.dart'; +import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../widgets/stack_dialog.dart'; + +/// Map a wallet [CryptoCurrency] (plus optional token symbol) to the +/// library's [CryptoCoin] descriptor. +CryptoCoin cryptoCoinFor(CryptoCurrency currency, {String? tokenSymbol}) => + CryptoCoin( + ticker: tokenSymbol ?? currency.ticker, + prettyName: currency.prettyName, + displayName: tokenSymbol ?? currency.prettyName, + ); + +class OpenCryptoPaySendHandler { + OpenCryptoPaySendHandler({ + required this.ref, + required this.coin, + required this.sendToController, + required this.cryptoAmountController, + required this.setValidAddress, + required this.isMounted, + this.tokenSymbol, + this.tokenDecimals, + @visibleForTesting OpenCryptoPayController? controller, + }) : _controller = + controller ?? + OpenCryptoPayController( + service: OpenCryptoPayService(client: StackClient()), + ); + + final WidgetRef ref; + final CryptoCurrency coin; + final TextEditingController sendToController; + final TextEditingController cryptoAmountController; + final void Function(String address) setValidAddress; + final bool Function() isMounted; + + /// Set for token wallets (ERC-20, SPL, ...) so the OpenCryptoPay request + /// targets the token asset (ex: "USDT") instead of the chain's native coin + /// and amounts use the token's decimals. + final String? tokenSymbol; + final int? tokenDecimals; + + int get _fractionDigits => tokenDecimals ?? coin.fractionDigits; + + final OpenCryptoPayController _controller; + OpenCryptoPaySession? _session; + + Future showQuoteExpiredError( + BuildContext context, { + bool paymentNotSent = false, + }) => _showError( + context: context, + title: OpenCryptoPayStrings.quoteExpiredTitle, + message: OpenCryptoPayStrings.quoteExpiredMessage( + paymentNotSent: paymentNotSent, + ), + ); + + bool get requiresBroadcast => _session?.requiresBroadcast ?? true; + + bool get isQuoteExpired => _session?.isQuoteExpired ?? false; + + bool isActivePaymentFor(String? recipientAddress) => + _session?.isActivePaymentFor(recipientAddress) ?? false; + + Future _run({ + required BuildContext context, + required String qrData, + required CryptoCoin coin, + required String logLabel, + }) async { + return showLoading( + whileFuture: _controller.run( + qrData: qrData, + coin: coin, + onError: (e, s) => + Logging.instance.w(logLabel, error: e, stackTrace: s), + ), + context: context, + rootNavigator: Util.isDesktop, + message: OpenCryptoPayStrings.loading, + ); + } + + Future _showError({ + required BuildContext context, + required String title, + required String message, + }) async { + if (!isMounted()) return; + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: title, + message: message, + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 600 : null, + ), + ); + } + + Future _showErrorOrSuccess( + BuildContext context, + OpenCryptoPayResult result, + ) async { + switch (result) { + case OpenCryptoPayFailure(): + await _showError( + context: context, + title: result.title, + message: result.message, + ); + return null; + case OpenCryptoPaySuccess(): + return result; + } + } + + Future handle(BuildContext context, String qrData) async { + final result = await _run( + context: context, + qrData: qrData, + coin: cryptoCoinFor(coin, tokenSymbol: tokenSymbol), + logLabel: "OpenCryptoPay flow failed", + ); + + if (!isMounted() || result == null) return; + + // ignore: use_build_context_synchronously + final success = await _showErrorOrSuccess(context, result); + if (success == null) return; + + if (success.session.isQuoteExpired) { + // ignore: use_build_context_synchronously + await showQuoteExpiredError(context); + return; + } + + _applySuccess(success); + } + + void _applySuccess(OpenCryptoPaySuccess result) { + _session = result.session; + + // Prefill the form in place. + final address = result.address; + sendToController.text = address; + + final rawAmount = result.amountInSmallestUnit(_fractionDigits); + if (rawAmount != null) { + final parsed = Amount( + rawValue: rawAmount, + fractionDigits: _fractionDigits, + ); + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format(parsed, withUnitName: false); + ref.read(pSendAmount.notifier).state = parsed; + } + + setValidAddress(address); + } + + Future submitProof(BuildContext context, String txProof) async { + final session = _session; + if (session == null) return true; + + final result = await session.submitProof(txProof); + switch (result) { + case OpenCryptoPayProofAccepted(): + _session = null; + return true; + case OpenCryptoPayProofQuoteExpired(:final error): + Logging.instance.w( + "OpenCryptoPay proof submission failed", + error: error, + ); + if (!isMounted()) return false; + // ignore: use_build_context_synchronously + await showQuoteExpiredError(context, paymentNotSent: true); + return false; + case OpenCryptoPayProofFailed(:final message, :final error): + // The session stays active so the user can retry. + Logging.instance.w( + "OpenCryptoPay proof submission failed", + error: error, + ); + if (!isMounted()) return false; + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: message, + // ignore: use_build_context_synchronously + context: context, + ), + ); + return false; + } + } +} diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 732008dfb7..d884bd396e 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -41,6 +41,7 @@ import '../../wallets/crypto_currency/coins/epiccash.dart'; import '../../wallets/crypto_currency/coins/ethereum.dart'; import '../../wallets/crypto_currency/coins/mimblewimblecoin.dart'; import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; +import '../../wallets/isar/models/spark_coin.dart'; import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; @@ -49,6 +50,7 @@ import '../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../../wallets/wallet/impl/solana_wallet.dart'; +import '../../wallets/wallet/wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; import '../../widgets/background.dart'; @@ -64,6 +66,7 @@ import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../../wl_gen/interfaces/libepiccash_interface.dart'; +import '../open_crypto_pay/open_crypto_pay_send_handler.dart'; import '../pinpad_views/lock_screen_view.dart'; import '../wallet_view/wallet_view.dart'; import 'sub_widgets/epic_slatepack_dialog.dart'; @@ -82,6 +85,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { this.isPaynymNotificationTransaction = false, this.isTokenTx = false, this.onSuccessInsteadOfRouteOnSuccess, + this.openCryptoPayHandler, }); static const String routeName = "/confirmTransactionView"; @@ -95,6 +99,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { final bool isTokenTx; final VoidCallback? onSuccessInsteadOfRouteOnSuccess; final VoidCallback onSuccess; + final OpenCryptoPaySendHandler? openCryptoPayHandler; @override ConsumerState createState() => @@ -314,34 +319,29 @@ class _ConfirmTransactionViewState final wallet = ref.read(pWallets).getWallet(walletId); final coin = wallet.info.coin; - final sendProgressController = ProgressAndSuccessController(); - var isSendingDialogOpen = true; + final ocp = widget.openCryptoPayHandler; + // Firo private (spark) sends carry the recipient in sparkRecipients. + final recipientAddress = + widget.txData.recipients?.firstOrNull?.address ?? + widget.txData.sparkRecipients?.firstOrNull?.address; + final isOcp = ocp != null && ocp.isActivePaymentFor(recipientAddress); + + if (isOcp && ocp.isQuoteExpired) { + // Abort before anything is broadcast or submitted (both proof types). + await ocp.showQuoteExpiredError(context, paymentNotSent: true); + return; + } - void closeSendingDialog() { - if (!context.mounted || !isSendingDialogOpen) { - return; - } - final navigator = Navigator.of(context, rootNavigator: true); - if (navigator.canPop()) { - navigator.pop(); - } - isSendingDialogOpen = false; + if (isOcp && !ocp.requiresBroadcast) { + // Signed-hex proof type: the provider broadcasts the transaction, so + // do NOT broadcast here. The txid proof type falls through to the + // normal confirmSend flow below. + return await _submitOpenCryptoPayHexProof(context, ocp, wallet); } - unawaited( - showDialog( - context: context, - useRootNavigator: true, - useSafeArea: false, - barrierDismissible: false, - builder: (context) { - return SendingTransactionDialog( - coin: coin, - controller: sendProgressController, - ); - }, - ).whenComplete(() => isSendingDialogOpen = false), - ); + final sendingDialog = _showSendingDialog(context, wallet); + final sendProgressController = sendingDialog.controller; + final closeSendingDialog = sendingDialog.close; final time = Future.delayed(const Duration(milliseconds: 2500)); @@ -458,26 +458,19 @@ class _ConfirmTransactionViewState // save note for (final txid in txids) { - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); + await _saveNote(txid: txid, note: note); } - if (widget.isTokenTx) { - if (wallet is SolanaWallet) { - unawaited(ref.read(pCurrentSolanaTokenWallet)!.refresh()); - } else { - unawaited(ref.read(pCurrentTokenWallet)!.refresh()); - } - } else { - unawaited(wallet.refresh()); - } + _refreshAfterSend(wallet); closeSendingDialog(); widget.onSuccess.call(); + if (isOcp && txids.isNotEmpty) { + // Broadcast (txid) proof type: submit the txid to the + // OpenCryptoPay provider. + unawaited(ocp.submitProof(context, txids.first)); + } // Check for 1000 FIRO transparent self-send → prompt MN registration bool navigatedToMN = false; @@ -590,14 +583,8 @@ class _ConfirmTransactionViewState } } - if (!navigatedToMN && context.mounted) { - if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); - } else { - widget.onSuccessInsteadOfRouteOnSuccess!.call(); - } + if (!navigatedToMN) { + _navigateOnSuccess(context); } } on BadHttpAddressException catch (_) { if (context.mounted) { @@ -689,6 +676,152 @@ class _ConfirmTransactionViewState } } + /// Show the modal [SendingTransactionDialog] used while a send/submit is + /// in flight. Returns its progress controller and a close callback. + ({ProgressAndSuccessController controller, VoidCallback close}) + _showSendingDialog(BuildContext context, Wallet wallet) { + final sendProgressController = ProgressAndSuccessController(); + var isSendingDialogOpen = true; + + void closeSendingDialog() { + if (!context.mounted || !isSendingDialogOpen) { + return; + } + final navigator = Navigator.of(context, rootNavigator: true); + if (navigator.canPop()) { + navigator.pop(); + } + isSendingDialogOpen = false; + } + + unawaited( + showDialog( + context: context, + useRootNavigator: true, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return SendingTransactionDialog( + coin: wallet.info.coin, + controller: sendProgressController, + ); + }, + ).whenComplete(() => isSendingDialogOpen = false), + ); + + return (controller: sendProgressController, close: closeSendingDialog); + } + + Future _saveNote({required String txid, required String note}) => ref + .read(mainDBProvider) + .putTransactionNote( + TransactionNote(walletId: walletId, txid: txid, value: note), + ); + + /// In case of a signed tx submitted without a broadcast, + /// prevent used input to be reused. + Future _markInputsAsUsed() async { + final db = ref.read(mainDBProvider); + + final utxos = widget.txData.usedUTXOs + ?.whereType() + .map((e) => e.utxo.copyWith(used: true)) + .toList(); + if (utxos != null && utxos.isNotEmpty) { + await db.putUTXOs(utxos); + } + + // Spark coins already carry isUsed: true from prepare time. + final sparkCoins = widget.txData.usedSparkCoins; + if (sparkCoins != null && sparkCoins.isNotEmpty) { + await db.isar.writeTxn(() => db.isar.sparkCoins.putAll(sparkCoins)); + } + } + + void _refreshAfterSend(Wallet wallet) { + if (widget.isTokenTx) { + if (wallet is SolanaWallet) { + unawaited(ref.read(pCurrentSolanaTokenWallet)!.refresh()); + } else { + unawaited(ref.read(pCurrentTokenWallet)!.refresh()); + } + } else { + unawaited(wallet.refresh()); + } + } + + void _navigateOnSuccess(BuildContext context) { + if (!context.mounted) return; + if (widget.onSuccessInsteadOfRouteOnSuccess == null) { + Navigator.of(context).popUntil(ModalRoute.withName(routeOnSuccessName)); + } else { + widget.onSuccessInsteadOfRouteOnSuccess!.call(); + } + } + + /// OpenCryptoPay signed-hex proof type: submit the signed transaction hex + /// to the provider, who broadcasts it itself. + Future _submitOpenCryptoPayHexProof( + BuildContext context, + OpenCryptoPaySendHandler ocp, + Wallet wallet, + ) async { + final hex = widget.txData.raw; + if (hex == null) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Cannot complete OpenCryptoPay payment", + message: + "This payment requires submitting a signed transaction, " + "which is not supported for this coin.", + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 450 : null, + ), + ); + return; + } + + final sendingDialog = _showSendingDialog(context, wallet); + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + final results = await Future.wait([ocp.submitProof(context, hex), time]); + if (results.first != true) { + // The handler showed the error and retained the payment for retry. + // Nothing was broadcast, so no funds moved. + sendingDialog.close(); + return; + } + + final txid = widget.txData.tempTx?.txid; + + await _markInputsAsUsed(); + + if (wallet.info.coin is! Ethereum) { + ref.refresh(desktopUseUTXOs); + } + + if (widget.txData.tempTx != null) { + await wallet.updateSentCachedTxData(txData: widget.txData); + } + + if (txid != null) { + await _saveNote(txid: txid, note: noteController.text); + } + + _refreshAfterSend(wallet); + + sendingDialog.controller.triggerSuccess?.call(); + await Future.delayed(const Duration(seconds: 5)); + + sendingDialog.close(); + + widget.onSuccess.call(); + + _navigateOnSuccess(context); + } + @override void initState() { super.initState(); diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 7eb453d6a5..b69f460757 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -17,6 +17,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:isar_community/isar.dart'; +import 'package:opencryptopay/opencryptopay.dart'; import 'package:tuple/tuple.dart'; import '../../models/epic_slatepack_models.dart'; @@ -25,6 +26,7 @@ import '../../models/isar/models/isar_models.dart'; import '../../models/mwc_slatepack_models.dart'; import '../../models/paynym/paynym_account_lite.dart'; import '../../models/send_view_auto_fill_data.dart'; +import '../../pages/open_crypto_pay/open_crypto_pay_send_handler.dart'; import '../../providers/providers.dart'; import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; @@ -162,6 +164,16 @@ class _SendViewState extends ConsumerState { Set selectedUTXOs = {}; + late final OpenCryptoPaySendHandler _openCryptoPay; + + void _openCryptoPaySetValidAddress(String address) { + _address = address; + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + void _applyUri(PaymentUriData paymentData) { try { // auto fill address @@ -420,6 +432,12 @@ class _SendViewState extends ConsumerState { Logging.instance.d("qrResult content: ${qrResult.rawContent}"); if (qrResult.rawContent == null) return; + if (OpenCryptoPayController.isOpenCryptoPayUri(qrResult.rawContent)) { + if (!mounted) return; + unawaited(_openCryptoPay.handle(context, qrResult.rawContent!)); + return; + } + final paymentData = AddressUtils.parsePaymentUri( qrResult.rawContent!, logging: Logging.instance, @@ -1256,6 +1274,7 @@ class _SendViewState extends ConsumerState { clearSendForm(); } }, + openCryptoPayHandler: _openCryptoPay, ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, @@ -1493,6 +1512,14 @@ class _SendViewState extends ConsumerState { onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); baseAmountController.addListener(_baseAmountChanged); + _openCryptoPay = OpenCryptoPaySendHandler( + ref: ref, + coin: coin, + sendToController: sendToController, + cryptoAmountController: cryptoAmountController, + setValidAddress: _openCryptoPaySetValidAddress, + isMounted: () => mounted, + ); if (_data != null) { if (_data.amount != null) { diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index 6187d4c53a..6595e80263 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -15,6 +15,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:opencryptopay/opencryptopay.dart'; import '../../models/isar/models/isar_models.dart'; import '../../models/send_view_auto_fill_data.dart'; @@ -50,6 +51,7 @@ import '../../widgets/icon_widgets/sol_token_icon.dart'; import '../../widgets/icon_widgets/x_icon.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; +import '../open_crypto_pay/open_crypto_pay_send_handler.dart'; import '../../widgets/textfield_icon_button.dart'; import '../token_view/sol_token_view.dart'; import 'confirm_transaction_view.dart'; @@ -112,6 +114,16 @@ class _SolTokenSendViewState extends ConsumerState { late Future _calculateFeesFuture; String cachedFees = ""; + late final OpenCryptoPaySendHandler _openCryptoPay; + + void _openCryptoPaySetValidAddress(String address) { + _address = address; + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + void _onTokenSendViewPasteAddressFieldButtonPressed() async { final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); if (data?.text != null && data!.text!.isNotEmpty) { @@ -141,6 +153,12 @@ class _SolTokenSendViewState extends ConsumerState { Logging.instance.d("qrResult content: ${qrResult.rawContent}"); if (qrResult.rawContent == null) return; + if (OpenCryptoPayController.isOpenCryptoPayUri(qrResult.rawContent)) { + if (!mounted) return; + unawaited(_openCryptoPay.handle(context, qrResult.rawContent!)); + return; + } + final paymentData = AddressUtils.parsePaymentUri( qrResult.rawContent!, logging: Logging.instance, @@ -492,6 +510,7 @@ class _SolTokenSendViewState extends ConsumerState { walletId: walletId, isTokenTx: true, onSuccess: clearSendForm, + openCryptoPayHandler: _openCryptoPay, routeOnSuccessName: SolTokenView.routeName, ), settings: const RouteSettings( @@ -588,6 +607,18 @@ class _SolTokenSendViewState extends ConsumerState { _addressToggleFlag = true; } + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + _openCryptoPay = OpenCryptoPaySendHandler( + ref: ref, + coin: ref.read(pWallets).getWallet(walletId).info.coin, + sendToController: sendToController, + cryptoAmountController: cryptoAmountController, + setValidAddress: _openCryptoPaySetValidAddress, + isMounted: () => mounted, + tokenSymbol: tokenWallet?.tokenSymbol, + tokenDecimals: tokenWallet?.tokenDecimals, + ); + super.initState(); } diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 3d30fc5f6a..6f9a0058c0 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -15,9 +15,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:opencryptopay/opencryptopay.dart'; import '../../models/isar/models/isar_models.dart'; import '../../models/send_view_auto_fill_data.dart'; +import '../../pages/open_crypto_pay/open_crypto_pay_send_handler.dart'; import '../../providers/providers.dart'; import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; @@ -122,6 +124,16 @@ class _TokenSendViewState extends ConsumerState { EthEIP1559Fee? ethFee; + late final OpenCryptoPaySendHandler _openCryptoPay; + + void _openCryptoPaySetValidAddress(String address) { + _address = address; + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + void _onTokenSendViewPasteAddressFieldButtonPressed() async { final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); if (data?.text != null && data!.text!.isNotEmpty) { @@ -165,6 +177,12 @@ class _TokenSendViewState extends ConsumerState { Logging.instance.d("qrResult content: ${qrResult.rawContent}"); if (qrResult.rawContent == null) return; + if (OpenCryptoPayController.isOpenCryptoPayUri(qrResult.rawContent)) { + if (!mounted) return; + unawaited(_openCryptoPay.handle(context, qrResult.rawContent!)); + return; + } + final paymentData = AddressUtils.parsePaymentUri( qrResult.rawContent!, logging: Logging.instance, @@ -522,6 +540,7 @@ class _TokenSendViewState extends ConsumerState { walletId: walletId, isTokenTx: true, onSuccess: clearSendForm, + openCryptoPayHandler: _openCryptoPay, routeOnSuccessName: TokenView.routeName, ), settings: const RouteSettings( @@ -616,6 +635,17 @@ class _TokenSendViewState extends ConsumerState { _addressToggleFlag = true; } + _openCryptoPay = OpenCryptoPaySendHandler( + ref: ref, + coin: coin, + sendToController: sendToController, + cryptoAmountController: cryptoAmountController, + setValidAddress: _openCryptoPaySetValidAddress, + isMounted: () => mounted, + tokenSymbol: tokenContract.symbol, + tokenDecimals: tokenContract.decimals, + ); + super.initState(); } diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 9388807794..fa35dd0568 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:opencryptopay/opencryptopay.dart'; import '../../../../models/epic_slatepack_models.dart'; import '../../../../models/isar/models/blockchain_data/address.dart'; @@ -24,6 +25,7 @@ import '../../../../models/isar/models/contact_entry.dart'; import '../../../../models/mwc_slatepack_models.dart'; import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; +import '../../../../pages/open_crypto_pay/open_crypto_pay_send_handler.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; import '../../../../pages/send_view/sub_widgets/epic_slatepack_dialog.dart'; @@ -124,6 +126,7 @@ class _DesktopSendState extends ConsumerState { late final bool hasOptionalMemo; late final bool isMimblewimblecoin; late final bool isEpiccash; + late final OpenCryptoPaySendHandler _openCryptoPay; String? _note; String? _onChainNote; @@ -771,6 +774,7 @@ class _DesktopSendState extends ConsumerState { txData: txData, walletId: walletId, onSuccess: clearSendForm, + openCryptoPayHandler: _openCryptoPay, isPaynymTransaction: isPaynymSend, routeOnSuccessName: DesktopHomeView.routeName, ), @@ -921,8 +925,21 @@ class _DesktopSendState extends ConsumerState { // return null; // } + void _openCryptoPaySetValidAddress(String address) { + _address = address; + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + void _processQrCodeData(String qrCodeData) { try { + if (OpenCryptoPayController.isOpenCryptoPayUri(qrCodeData)) { + unawaited(_openCryptoPay.handle(context, qrCodeData)); + return; + } + final paymentData = AddressUtils.parsePaymentUri( qrCodeData, logging: Logging.instance, @@ -1244,6 +1261,14 @@ class _DesktopSendState extends ConsumerState { onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); + _openCryptoPay = OpenCryptoPaySendHandler( + ref: ref, + coin: coin, + sendToController: sendToController, + cryptoAmountController: cryptoAmountController, + setValidAddress: _openCryptoPaySetValidAddress, + isMounted: () => mounted, + ); if (_data != null) { if (_data.amount != null) { 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 cd4e227a51..2cde992a5b 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 @@ -14,10 +14,12 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:opencryptopay/opencryptopay.dart'; import '../../../../models/isar/models/contact_entry.dart'; import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; +import '../../../../pages/open_crypto_pay/open_crypto_pay_send_handler.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; import '../../../../providers/providers.dart'; @@ -98,6 +100,16 @@ class _DesktopSolTokenSendState extends ConsumerState { bool _cryptoAmountChangeLock = false; late VoidCallback onCryptoAmountChanged; + late final OpenCryptoPaySendHandler _openCryptoPay; + + void _openCryptoPaySetValidAddress(String address) { + _address = address; + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + Future pasteMemo() async { if (memoController.text.isNotEmpty) { setState(() { @@ -284,6 +296,7 @@ class _DesktopSolTokenSendState extends ConsumerState { txData: txData, walletId: walletId, onSuccess: clearSendForm, + openCryptoPayHandler: _openCryptoPay, isTokenTx: true, routeOnSuccessName: DesktopHomeView.routeName, ), @@ -474,6 +487,12 @@ class _DesktopSolTokenSendState extends ConsumerState { Logging.instance.d("qrResult content: $qrResult"); + if (OpenCryptoPayController.isOpenCryptoPayUri(qrResult)) { + if (!mounted) return; + unawaited(_openCryptoPay.handle(context, qrResult)); + return; + } + final paymentData = AddressUtils.parsePaymentUri( qrResult, logging: Logging.instance, @@ -646,6 +665,18 @@ class _DesktopSolTokenSendState extends ConsumerState { _addressToggleFlag = true; } + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + _openCryptoPay = OpenCryptoPaySendHandler( + ref: ref, + coin: coin, + sendToController: sendToController, + cryptoAmountController: cryptoAmountController, + setValidAddress: _openCryptoPaySetValidAddress, + isMounted: () => mounted, + tokenSymbol: tokenWallet?.tokenSymbol, + tokenDecimals: tokenWallet?.tokenDecimals, + ); + super.initState(); } 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 f01cdd2464..dfca1f3090 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 @@ -14,10 +14,12 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:opencryptopay/opencryptopay.dart'; import '../../../../models/isar/models/contact_entry.dart'; import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; +import '../../../../pages/open_crypto_pay/open_crypto_pay_send_handler.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; import '../../../../providers/providers.dart'; @@ -48,6 +50,7 @@ import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/eth_fee_form.dart'; import '../../../../widgets/icon_widgets/addressbook_icon.dart'; import '../../../../widgets/icon_widgets/clipboard_icon.dart'; +import '../../../../widgets/icon_widgets/qrcode_icon.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; @@ -104,6 +107,16 @@ class _DesktopTokenSendState extends ConsumerState { EthEIP1559Fee? ethFee; + late final OpenCryptoPaySendHandler _openCryptoPay; + + void _openCryptoPaySetValidAddress(String address) { + _address = address; + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + Future previewSend() async { final tokenWallet = ref.read(pCurrentTokenWallet)!; @@ -267,6 +280,7 @@ class _DesktopTokenSendState extends ConsumerState { txData: txData, walletId: walletId, onSuccess: clearSendForm, + openCryptoPayHandler: _openCryptoPay, isTokenTx: true, routeOnSuccessName: DesktopHomeView.routeName, ), @@ -438,6 +452,11 @@ class _DesktopTokenSendState extends ConsumerState { Logging.instance.d("qrResult content: $qrResult"); + if (OpenCryptoPayController.isOpenCryptoPayUri(qrResult)) { + unawaited(_openCryptoPay.handle(context, qrResult)); + return; + } + final paymentData = AddressUtils.parsePaymentUri( qrResult, logging: Logging.instance, @@ -619,6 +638,18 @@ class _DesktopTokenSendState extends ConsumerState { _addressToggleFlag = true; } + final tokenContract = ref.read(pCurrentTokenWallet)?.tokenContract; + _openCryptoPay = OpenCryptoPaySendHandler( + ref: ref, + coin: coin, + sendToController: sendToController, + cryptoAmountController: cryptoAmountController, + setValidAddress: _openCryptoPaySetValidAddress, + isMounted: () => mounted, + tokenSymbol: tokenContract?.symbol, + tokenDecimals: tokenContract?.decimals, + ); + _cryptoFocus.addListener(() { if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -999,6 +1030,14 @@ class _DesktopTokenSendState extends ConsumerState { }, child: const AddressBookIcon(), ), + if (sendToController.text.isEmpty) + TextFieldIconButton( + semanticsLabel: + "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("sendViewScanQrButtonKey"), + onTap: scanQr, + child: const QrCodeIcon(), + ), ], ), ), diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index 829110651b..f8df697deb 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -60,6 +60,30 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { return web3.Web3Client(node.host, client); } + /// Sign [tx] without broadcasting; returns the raw signed transaction hex + /// (0x-prefixed, EIP-1559 type byte included). Used for flows where a + /// third party broadcasts (e.g. OpenCryptoPay signed-hex proof). + Future signWeb3TransactionToHex({ + required web3.Transaction tx, + required BigInt chainId, + }) async { + if (_credentials == null) { + await _initCredentials(); + } + // The tx built by prepareSend always has nonce and fees populated, so + // the pure signTransactionRaw is equivalent to Web3Client.signTransaction + // without constructing a client. + var signed = web3.signTransactionRaw( + tx, + _credentials!, + chainId: chainId.toInt(), + ); + if (tx.isEIP1559) { + signed = web3.prependTransactionType(0x02, signed); + } + return web3.bytesToHex(signed, include0x: true, padToEvenLength: true); + } + Amount estimateEthFee(BigInt feeRate, int gasLimit, int decimals) { final gweiAmount = feeRate.toDecimal() / (Decimal.ten.pow(9).toDecimal()); final fee = @@ -218,7 +242,9 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { final addressHex = (await getCurrentReceivingAddress())!.value; final address = eth_wallet.EthereumAddress.fromHex(addressHex); - final eth_wallet.EtherAmount ethBalance = await client.getBalance(address); + final eth_wallet.EtherAmount ethBalance = await client.getBalance( + address, + ); final balance = Balance( total: Amount( rawValue: ethBalance.getInWei, @@ -551,6 +577,7 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { web3dartTransaction: tx, fee: feeEstimate, chainId: prep.chainId, + raw: await signWeb3TransactionToHex(tx: tx, chainId: prep.chainId), ); } diff --git a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart index 6aca5a0082..a3914789d3 100644 --- a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart @@ -265,6 +265,10 @@ class EthTokenWallet extends Wallet { web3dartTransaction: tx, chainId: prep.chainId, nonce: tx.nonce, + raw: await ethWallet.signWeb3TransactionToHex( + tx: tx, + chainId: prep.chainId, + ), ); } diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 6764428fac..132dc72721 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -273,6 +273,10 @@ dependencies: # required for web3dart to use EthereumAddress class... wallet: 0.0.18 + opencryptopay: + git: + url: https://github.com/Cyrix126/opencryptopay + ref: da0507f0d8642ddc95c1a86450a142ff7a2ba26a dev_dependencies: flutter_test: diff --git a/test/pages/open_crypto_pay/open_crypto_pay_send_handler_test.dart b/test/pages/open_crypto_pay/open_crypto_pay_send_handler_test.dart new file mode 100644 index 0000000000..abdbb11e5f --- /dev/null +++ b/test/pages/open_crypto_pay/open_crypto_pay_send_handler_test.dart @@ -0,0 +1,498 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:http/testing.dart'; +import 'package:opencryptopay/opencryptopay.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/open_crypto_pay/open_crypto_pay_send_handler.dart'; +import 'package:stackwallet/providers/ui/preview_tx_button_state_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_service.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'; + +import '../../sample_data/theme_json.dart'; + +// LNURL from the library's own sample data; decodes to +// https://api.dfx.swiss/v1/lnurlp/pl_beeddb41cd4b6d9e +const _lnurl = + 'LNURL1DP68GURN8GHJ7CTSDYHXGENC9EEHW6TNWVHHVVF0D3H82UNVWQHHQMZLVFJK2ERYV' + 'G6RZCMYX33RVEPEV5YEJ9WT'; +const _qrLink = 'https://app.dfx.swiss/pl/?lightning=$_lnurl'; +const _callbackUrl = 'https://api.dfx.swiss/v1/lnurlp/cb/pl_beeddb41cd4b6d9e'; + +const _btcAddress = 'bc1qzx3ug7j0e64207fe2m424hvxmvd496q8gdytt6'; +const _erc20Recipient = '0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC'; + +const _hexHint = + 'Use this data to create a transaction and sign it. Send the signed ' + 'transaction back as HEX via the endpoint ' + 'https://api.dfx.swiss/v1/lnurlp/tx/plp_test. We check the transferred ' + 'HEX and broadcast the transaction to the blockchain.'; +const _hashHint = + 'Use this data to create a transaction, sign and broadcast it. Then ' + 'send the transaction id back via the endpoint.'; + +Map _paymentInfoJson({required String quoteExpiration}) => { + "id": "pl_test", + "tag": "payRequest", + "callback": _callbackUrl, + "displayName": "Test Shop", + "quote": { + "id": "plq_test", + "expiration": quoteExpiration, + "payment": "plp_test", + }, + "transferAmounts": [ + { + "method": "Bitcoin", + "minFee": 0, + "assets": [ + {"asset": "BTC", "amount": "0.00001947"}, + ], + "available": true, + }, + { + "method": "Ethereum", + "minFee": 0, + "assets": [ + {"asset": "USDT", "amount": "1.246858"}, + ], + "available": true, + }, + ], +}; + +Map _btcDetailsJson({required String hint}) => { + "expiryDate": "2100-01-01T00:00:00.000Z", + "blockchain": "Bitcoin", + "uri": "bitcoin:$_btcAddress?amount=0.00001947&label=DFX Payment", + "hint": hint, +}; + +Map _erc20DetailsJson() => { + "expiryDate": "2100-01-01T00:00:00.000Z", + "blockchain": "Ethereum", + "uri": + "ethereum:0xdac17f958d2ee523a2206206994597c13d831ec7@1/transfer" + "?address=$_erc20Recipient&uint256=1246858", + "hint": _hexHint, +}; + +String _futureExpiration() => + DateTime.now().toUtc().add(const Duration(days: 365)).toIso8601String(); + +String _pastExpiration() => "2000-01-01T00:00:00.000Z"; + +/// Mock the OpenCryptoPay requests flow plus the proof callback endpoint. +MockClient _mockOcpServer({ + required Map paymentInfo, + Map? txDetails, + int paymentInfoStatus = 200, + int proofStatus = 200, + void Function(Uri url)? onRequest, +}) { + return MockClient((request) async { + onRequest?.call(request.url); + if (request.url.path.contains('/cb/')) { + return Response( + proofStatus == 200 ? '{"status": "ok"}' : '{}', + proofStatus, + ); + } + if (request.url.queryParameters.containsKey('method')) { + return Response(jsonEncode(txDetails), 200); + } + return Response(jsonEncode(paymentInfo), paymentInfoStatus); + }); +} + +class _FakeThemeService implements ThemeService { + @override + StackTheme? getTheme({required String themeId}) => + StackTheme.fromJson(json: lightThemeJsonMap); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Harness { + late BuildContext context; + late WidgetRef ref; +} + +/// Pump a minimal app with the theme + providers the handler's UI needs and +/// capture a BuildContext/WidgetRef for driving the handler. +Future<_Harness> _pumpHarness(WidgetTester tester) async { + final harness = _Harness(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pThemeService.overrideWithValue(_FakeThemeService()), + pAmountFormatter.overrideWithProvider( + (coin) => Provider( + (ref) => AmountFormatter( + unit: AmountUnit.normal, + locale: "en_US", + coin: coin, + maxDecimals: 18, + ), + ), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Material( + child: Consumer( + builder: (context, ref, _) { + harness.context = context; + harness.ref = ref; + // Watch to keep the autoDispose provider alive for assertions. + final amount = ref.watch(pSendAmount); + return Text("pSendAmount:${amount?.raw}"); + }, + ), + ), + ), + ), + ); + return harness; +} + +typedef _HandlerSetup = ({ + OpenCryptoPaySendHandler handler, + TextEditingController sendTo, + TextEditingController amount, + List validAddresses, +}); + +_HandlerSetup _makeHandler({ + required _Harness harness, + required CryptoCurrency coin, + required Client client, + String? tokenSymbol, + int? tokenDecimals, + bool Function()? isMounted, +}) { + final sendTo = TextEditingController(); + final amount = TextEditingController(); + final validAddresses = []; + final handler = OpenCryptoPaySendHandler( + ref: harness.ref, + coin: coin, + sendToController: sendTo, + cryptoAmountController: amount, + setValidAddress: validAddresses.add, + isMounted: isMounted ?? () => true, + tokenSymbol: tokenSymbol, + tokenDecimals: tokenDecimals, + controller: OpenCryptoPayController( + service: OpenCryptoPayService(client: client), + ), + ); + return ( + handler: handler, + sendTo: sendTo, + amount: amount, + validAddresses: validAddresses, + ); +} + +/// Run handler.handle and pump enough frames for the loading dialog to open +/// and close. Only use when no blocking error dialog is expected. +Future _handle( + WidgetTester tester, + _Harness harness, + OpenCryptoPaySendHandler handler, +) async { + final fut = handler.handle(harness.context, _qrLink); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await fut; + await tester.pump(); +} + +/// Dismiss a visible StackOkDialog via its OK button. +Future _tapOk(WidgetTester tester) async { + await tester.tap(find.text("OK")); + await tester.pump(); +} + +void main() { + group("cryptoCoinFor", () { + test("maps a native coin to the library's CryptoCoin", () { + final btc = cryptoCoinFor(Bitcoin(CryptoCurrencyNetwork.main)); + expect(btc.ticker, "BTC"); + expect(btc.prettyName, "Bitcoin"); + expect(btc.displayName, "Bitcoin"); + + final xmr = cryptoCoinFor(Monero(CryptoCurrencyNetwork.main)); + expect(xmr.ticker, "XMR"); + expect(xmr.prettyName, "Monero"); + expect(xmr.displayName, "Monero"); + + final eth = cryptoCoinFor(Ethereum(CryptoCurrencyNetwork.main)); + expect(eth.ticker, "ETH"); + expect(eth.prettyName, "Ethereum"); + expect(eth.displayName, "Ethereum"); + }); + + test("tokenSymbol overrides ticker so requests target the token asset", () { + final erc20 = cryptoCoinFor( + Ethereum(CryptoCurrencyNetwork.main), + tokenSymbol: "USDT", + ); + expect(erc20.ticker, "USDT"); + expect(erc20.prettyName, "Ethereum"); + expect(erc20.displayName, "USDT"); + + final spl = cryptoCoinFor( + Solana(CryptoCurrencyNetwork.main), + tokenSymbol: "USDC", + ); + expect(spl.ticker, "USDC"); + expect(spl.prettyName, "Solana"); + }); + }); + + group("OpenCryptoPaySendHandler.handle", () { + testWidgets("prefills the send form for a payable payment (txid flow)", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: _mockOcpServer( + paymentInfo: _paymentInfoJson(quoteExpiration: _futureExpiration()), + txDetails: _btcDetailsJson(hint: _hashHint), + ), + ); + + await _handle(tester, harness, setup.handler); + + expect(setup.sendTo.text, _btcAddress); + expect(setup.amount.text, "0.00001947"); + expect(setup.validAddresses, [_btcAddress]); + expect(harness.ref.read(pSendAmount)?.raw, BigInt.from(1947)); + expect(harness.ref.read(pSendAmount)?.fractionDigits, 8); + expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); + expect(setup.handler.isActivePaymentFor("bc1qsomeotheraddress"), isFalse); + expect(setup.handler.requiresBroadcast, isTrue); + expect(setup.handler.isQuoteExpired, isFalse); + }); + + testWidgets("signed-hex hint results in requiresBroadcast false", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: _mockOcpServer( + paymentInfo: _paymentInfoJson(quoteExpiration: _futureExpiration()), + txDetails: _btcDetailsJson(hint: _hexHint), + ), + ); + + await _handle(tester, harness, setup.handler); + + expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); + expect(setup.handler.requiresBroadcast, isFalse); + }); + + testWidgets("raw (uint256) token amounts use the token's decimals", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = _makeHandler( + harness: harness, + coin: Ethereum(CryptoCurrencyNetwork.main), + tokenSymbol: "USDT", + tokenDecimals: 6, + client: _mockOcpServer( + paymentInfo: _paymentInfoJson(quoteExpiration: _futureExpiration()), + txDetails: _erc20DetailsJson(), + ), + ); + + await _handle(tester, harness, setup.handler); + + expect(setup.sendTo.text, _erc20Recipient); + expect(setup.amount.text, "1.246858"); + expect(harness.ref.read(pSendAmount)?.raw, BigInt.from(1246858)); + expect(harness.ref.read(pSendAmount)?.fractionDigits, 6); + expect(setup.handler.isActivePaymentFor(_erc20Recipient), isTrue); + }); + + testWidgets("expired quote at fetch shows the expiry dialog and does not " + "prefill the form", (tester) async { + final harness = await _pumpHarness(tester); + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: _mockOcpServer( + paymentInfo: _paymentInfoJson(quoteExpiration: _pastExpiration()), + txDetails: _btcDetailsJson(hint: _hashHint), + ), + ); + + final fut = setup.handler.handle(harness.context, _qrLink); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text("Payment quote expired"), findsOneWidget); + await _tapOk(tester); + await fut; + + expect(setup.sendTo.text, isEmpty); + expect(setup.amount.text, isEmpty); + expect(setup.validAddresses, isEmpty); + expect(setup.handler.isActivePaymentFor(_btcAddress), isFalse); + expect(setup.handler.isQuoteExpired, isFalse); + }); + + testWidgets( + "no pending payment (404) shows a dialog and prefills nothing", + (tester) async { + final harness = await _pumpHarness(tester); + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: _mockOcpServer(paymentInfo: const {}, paymentInfoStatus: 404), + ); + + final fut = setup.handler.handle(harness.context, _qrLink); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text(OpenCryptoPayStrings.noPendingTitle), findsOneWidget); + await _tapOk(tester); + await fut; + + expect(setup.sendTo.text, isEmpty); + expect(setup.handler.isActivePaymentFor(_btcAddress), isFalse); + }, + ); + }); + + group("OpenCryptoPaySendHandler.submitProof", () { + testWidgets( + "success clears the active payment and later calls become no-ops", + (tester) async { + final requests = []; + final harness = await _pumpHarness(tester); + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: _mockOcpServer( + paymentInfo: _paymentInfoJson(quoteExpiration: _futureExpiration()), + txDetails: _btcDetailsJson(hint: _hashHint), + onRequest: requests.add, + ), + ); + + await _handle(tester, harness, setup.handler); + expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); + + final ok = await setup.handler.submitProof( + harness.context, + "some_txid", + ); + expect(ok, isTrue); + expect(setup.handler.isActivePaymentFor(_btcAddress), isFalse); + + // A second call must not hit the network again. + final proofRequests = requests + .where((u) => u.path.contains('/cb/')) + .length; + final okAgain = await setup.handler.submitProof( + harness.context, + "some_txid", + ); + expect(okAgain, isTrue); + expect( + requests.where((u) => u.path.contains('/cb/')).length, + proofRequests, + ); + }, + ); + + testWidgets("failure retains the payment so the user can retry", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + var mounted = true; + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + isMounted: () => mounted, + client: _mockOcpServer( + paymentInfo: _paymentInfoJson(quoteExpiration: _futureExpiration()), + txDetails: _btcDetailsJson(hint: _hashHint), + proofStatus: 500, + ), + ); + + await _handle(tester, harness, setup.handler); + + // Unmounted so the failure flushbar is skipped; the state handling is + // what is under test here. + mounted = false; + final ok = await setup.handler.submitProof(harness.context, "some_txid"); + expect(ok, isFalse); + expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); + }); + + testWidgets("quote expiring before hex-proof submission aborts with a " + "'NOT sent' dialog and retains the payment", (tester) async { + final harness = await _pumpHarness(tester); + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: _mockOcpServer( + paymentInfo: _paymentInfoJson( + quoteExpiration: DateTime.now() + .toUtc() + .add(const Duration(seconds: 2)) + .toIso8601String(), + ), + txDetails: _btcDetailsJson(hint: _hexHint), + ), + ); + + // Quote is still valid while fetching... + await _handle(tester, harness, setup.handler); + expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); + expect(setup.handler.requiresBroadcast, isFalse); + + // ...but expires before the user confirms the send. isQuoteExpired + // reads package:clock's zone-aware clock, which testWidgets backs with + // FakeAsync, so pumping the fake clock forward is what ages the quote. + await tester.pump(const Duration(seconds: 3)); + expect(setup.handler.isQuoteExpired, isTrue); + + final fut = setup.handler.submitProof(harness.context, "deadbeef"); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text("Payment quote expired"), findsOneWidget); + expect(find.textContaining("The payment was NOT sent"), findsOneWidget); + await _tapOk(tester); + + expect(await fut, isFalse); + // Retained: details are only cleared on successful submission. + expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); + }); + }); +} From ba8da5b62a512917e360f4cf066ea3f11c84f10c Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 16 Jul 2026 07:32:49 +0000 Subject: [PATCH 2/6] feat: update opencryptopay package --- scripts/app_config/templates/pubspec.template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 132dc72721..ecefb553be 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -276,7 +276,7 @@ dependencies: opencryptopay: git: url: https://github.com/Cyrix126/opencryptopay - ref: da0507f0d8642ddc95c1a86450a142ff7a2ba26a + ref: a4fca400430f436abf84c75cf102734f779794b5 dev_dependencies: flutter_test: From 031ce45ba5151e764bd009e72233ffa205a9fc5f Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 16 Jul 2026 08:19:01 +0000 Subject: [PATCH 3/6] fix: StackClient buggy POST --- lib/networking/http.dart | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/lib/networking/http.dart b/lib/networking/http.dart index 9444e66050..dd07a3ec5c 100644 --- a/lib/networking/http.dart +++ b/lib/networking/http.dart @@ -200,16 +200,6 @@ class StackClient extends BaseClient { connectionTimeout: timeout, ) .timeout(timeout); - case 'POST': - final body = await _readBody(request); - response = await _client - .post( - url: request.url, - headers: request.headers, - body: body, - proxyInfo: proxyInfo, - ) - .timeout(timeout); default: throw ClientException( 'Unsupported HTTP method ${request.method}', @@ -225,14 +215,4 @@ class StackClient extends BaseClient { ); } - Future _readBody(BaseRequest request) async { - if (request is! Request) { - throw UnsupportedError( - 'Client only supports Request bodies, ' - 'got ${request.runtimeType}.', - ); - } - final bytes = await request.finalize().toBytes(); - return bytes.isEmpty ? null : String.fromCharCodes(bytes); - } } From 38b8519d154716ba20c7a44c24e017b7432b945c Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 16 Jul 2026 08:39:33 +0000 Subject: [PATCH 4/6] fix: formatting --- lib/networking/http.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/networking/http.dart b/lib/networking/http.dart index dd07a3ec5c..0622257666 100644 --- a/lib/networking/http.dart +++ b/lib/networking/http.dart @@ -214,5 +214,4 @@ class StackClient extends BaseClient { request: request, ); } - } From 5a1d5a36018d56521dc0c8c745f218ecef066803 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 16 Jul 2026 10:23:39 +0000 Subject: [PATCH 5/6] fix: update opencryptopay package and fix test --- scripts/app_config/templates/pubspec.template.yaml | 2 +- .../open_crypto_pay_send_handler_test.dart | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index ecefb553be..0bd97481cf 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -276,7 +276,7 @@ dependencies: opencryptopay: git: url: https://github.com/Cyrix126/opencryptopay - ref: a4fca400430f436abf84c75cf102734f779794b5 + ref: 7295eeab494a2f79a5b390fa90260f93bdacfa34 dev_dependencies: flutter_test: diff --git a/test/pages/open_crypto_pay/open_crypto_pay_send_handler_test.dart b/test/pages/open_crypto_pay/open_crypto_pay_send_handler_test.dart index abdbb11e5f..c07b53c1f6 100644 --- a/test/pages/open_crypto_pay/open_crypto_pay_send_handler_test.dart +++ b/test/pages/open_crypto_pay/open_crypto_pay_send_handler_test.dart @@ -98,7 +98,8 @@ MockClient _mockOcpServer({ }) { return MockClient((request) async { onRequest?.call(request.url); - if (request.url.path.contains('/cb/')) { + // Proof submissions go to the callback URL with /cb/ replaced by /tx/. + if (request.url.path.contains('/tx/')) { return Response( proofStatus == 200 ? '{"status": "ok"}' : '{}', proofStatus, @@ -414,15 +415,16 @@ void main() { // A second call must not hit the network again. final proofRequests = requests - .where((u) => u.path.contains('/cb/')) + .where((u) => u.path.contains('/tx/')) .length; + expect(proofRequests, 1); final okAgain = await setup.handler.submitProof( harness.context, "some_txid", ); expect(okAgain, isTrue); expect( - requests.where((u) => u.path.contains('/cb/')).length, + requests.where((u) => u.path.contains('/tx/')).length, proofRequests, ); }, From c261e431eebde10f41a0b896a8f2ffaf7907ab27 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Sat, 18 Jul 2026 10:39:43 +0000 Subject: [PATCH 6/6] fix: small cleanup, mounted guard --- .../open_crypto_pay_send_handler.dart | 71 ++++++------------- .../send_view/confirm_transaction_view.dart | 12 ++-- lib/pages/send_view/send_view.dart | 2 +- lib/pages/send_view/sol_token_send_view.dart | 2 +- lib/pages/send_view/token_send_view.dart | 2 +- .../wallet_view/sub_widgets/desktop_send.dart | 1 + .../sub_widgets/desktop_token_send.dart | 1 + 7 files changed, 33 insertions(+), 58 deletions(-) diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_send_handler.dart b/lib/pages/open_crypto_pay/open_crypto_pay_send_handler.dart index cb171cf635..44ce96869e 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_send_handler.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_send_handler.dart @@ -87,25 +87,6 @@ class OpenCryptoPaySendHandler { bool isActivePaymentFor(String? recipientAddress) => _session?.isActivePaymentFor(recipientAddress) ?? false; - Future _run({ - required BuildContext context, - required String qrData, - required CryptoCoin coin, - required String logLabel, - }) async { - return showLoading( - whileFuture: _controller.run( - qrData: qrData, - coin: coin, - onError: (e, s) => - Logging.instance.w(logLabel, error: e, stackTrace: s), - ), - context: context, - rootNavigator: Util.isDesktop, - message: OpenCryptoPayStrings.loading, - ); - } - Future _showError({ required BuildContext context, required String title, @@ -123,46 +104,40 @@ class OpenCryptoPaySendHandler { ); } - Future _showErrorOrSuccess( - BuildContext context, - OpenCryptoPayResult result, - ) async { + Future handle(BuildContext context, String qrData) async { + final result = await showLoading( + whileFuture: _controller.run( + qrData: qrData, + coin: cryptoCoinFor(coin, tokenSymbol: tokenSymbol), + onError: (e, s) => Logging.instance.w( + "OpenCryptoPay flow failed", + error: e, + stackTrace: s, + ), + ), + context: context, + rootNavigator: Util.isDesktop, + message: OpenCryptoPayStrings.loading, + ); + + if (!isMounted() || result == null) return; + switch (result) { case OpenCryptoPayFailure(): await _showError( + // ignore: use_build_context_synchronously context: context, title: result.title, message: result.message, ); - return null; + case OpenCryptoPaySuccess() when result.session.isQuoteExpired: + // ignore: use_build_context_synchronously + await showQuoteExpiredError(context); case OpenCryptoPaySuccess(): - return result; + _applySuccess(result); } } - Future handle(BuildContext context, String qrData) async { - final result = await _run( - context: context, - qrData: qrData, - coin: cryptoCoinFor(coin, tokenSymbol: tokenSymbol), - logLabel: "OpenCryptoPay flow failed", - ); - - if (!isMounted() || result == null) return; - - // ignore: use_build_context_synchronously - final success = await _showErrorOrSuccess(context, result); - if (success == null) return; - - if (success.session.isQuoteExpired) { - // ignore: use_build_context_synchronously - await showQuoteExpiredError(context); - return; - } - - _applySuccess(success); - } - void _applySuccess(OpenCryptoPaySuccess result) { _session = result.session; diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index d884bd396e..dc329d12d0 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -37,9 +37,7 @@ import '../../utilities/constants.dart'; import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; -import '../../wallets/crypto_currency/coins/epiccash.dart'; -import '../../wallets/crypto_currency/coins/ethereum.dart'; -import '../../wallets/crypto_currency/coins/mimblewimblecoin.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../wallets/isar/models/spark_coin.dart'; import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; @@ -339,7 +337,7 @@ class _ConfirmTransactionViewState return await _submitOpenCryptoPayHexProof(context, ocp, wallet); } - final sendingDialog = _showSendingDialog(context, wallet); + final sendingDialog = _showSendingDialog(context, coin); final sendProgressController = sendingDialog.controller; final closeSendingDialog = sendingDialog.close; @@ -679,7 +677,7 @@ class _ConfirmTransactionViewState /// Show the modal [SendingTransactionDialog] used while a send/submit is /// in flight. Returns its progress controller and a close callback. ({ProgressAndSuccessController controller, VoidCallback close}) - _showSendingDialog(BuildContext context, Wallet wallet) { + _showSendingDialog(BuildContext context, CryptoCurrency coin) { final sendProgressController = ProgressAndSuccessController(); var isSendingDialogOpen = true; @@ -702,7 +700,7 @@ class _ConfirmTransactionViewState barrierDismissible: false, builder: (context) { return SendingTransactionDialog( - coin: wallet.info.coin, + coin: coin, controller: sendProgressController, ); }, @@ -782,7 +780,7 @@ class _ConfirmTransactionViewState return; } - final sendingDialog = _showSendingDialog(context, wallet); + final sendingDialog = _showSendingDialog(context, wallet.info.coin); final time = Future.delayed(const Duration(milliseconds: 2500)); diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index b69f460757..33b4fb66ba 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -26,7 +26,6 @@ import '../../models/isar/models/isar_models.dart'; import '../../models/mwc_slatepack_models.dart'; import '../../models/paynym/paynym_account_lite.dart'; import '../../models/send_view_auto_fill_data.dart'; -import '../../pages/open_crypto_pay/open_crypto_pay_send_handler.dart'; import '../../providers/providers.dart'; import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; @@ -88,6 +87,7 @@ import '../../widgets/textfield_icon_button.dart'; import '../address_book_views/address_book_view.dart'; import '../coin_control/coin_control_view.dart'; import '../masternodes/masternode_constants.dart'; +import '../open_crypto_pay/open_crypto_pay_send_handler.dart'; import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/dual_balance_selection_sheet.dart'; diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index 6595e80263..9a1f0f55dd 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -51,8 +51,8 @@ import '../../widgets/icon_widgets/sol_token_icon.dart'; import '../../widgets/icon_widgets/x_icon.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; -import '../open_crypto_pay/open_crypto_pay_send_handler.dart'; import '../../widgets/textfield_icon_button.dart'; +import '../open_crypto_pay/open_crypto_pay_send_handler.dart'; import '../token_view/sol_token_view.dart'; import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 6f9a0058c0..d50c8210ce 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -19,7 +19,6 @@ import 'package:opencryptopay/opencryptopay.dart'; import '../../models/isar/models/isar_models.dart'; import '../../models/send_view_auto_fill_data.dart'; -import '../../pages/open_crypto_pay/open_crypto_pay_send_handler.dart'; import '../../providers/providers.dart'; import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; @@ -58,6 +57,7 @@ import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../address_book_views/address_book_view.dart'; +import '../open_crypto_pay/open_crypto_pay_send_handler.dart'; import '../token_view/token_view.dart'; import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index fa35dd0568..e944ac3e30 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -936,6 +936,7 @@ class _DesktopSendState extends ConsumerState { void _processQrCodeData(String qrCodeData) { try { if (OpenCryptoPayController.isOpenCryptoPayUri(qrCodeData)) { + if (!mounted) return; unawaited(_openCryptoPay.handle(context, qrCodeData)); return; } 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 dfca1f3090..171c2ffc91 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 @@ -453,6 +453,7 @@ class _DesktopTokenSendState extends ConsumerState { Logging.instance.d("qrResult content: $qrResult"); if (OpenCryptoPayController.isOpenCryptoPayUri(qrResult)) { + if (!mounted) return; unawaited(_openCryptoPay.handle(context, qrResult)); return; }