From d651d088e72341d03afbd26edda504a783fcfb52 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Sat, 5 Sep 2026 09:22:52 +0200 Subject: [PATCH 01/49] feat: add StackClient http client --- lib/networking/http.dart | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/lib/networking/http.dart b/lib/networking/http.dart index 821bdc4af9..3c1a916301 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 @@ -269,3 +273,51 @@ 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); + default: + throw ClientException( + 'Unsupported HTTP method ${request.method}', + request.url, + ); + } + + return StreamedResponse( + ByteStream.fromBytes(response.bodyBytes), + response.code, + contentLength: response.bodyBytes.length, + request: request, + ); + } +} From 418b640a58e54bfdbb0b82bd8c95137cdc747416 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Sat, 5 Sep 2026 09:23:30 +0200 Subject: [PATCH 02/49] feat: add OpenCryptoPay support --- .../open_crypto_pay_send_handler.dart | 191 +++++++ .../send_view/confirm_transaction_view.dart | 230 ++++++-- lib/pages/send_view/send_view.dart | 31 ++ lib/pages/send_view/sol_token_send_view.dart | 36 ++ lib/pages/send_view/token_send_view.dart | 34 ++ .../wallet_view/sub_widgets/desktop_send.dart | 30 ++ .../sub_widgets/desktop_sol_token_send.dart | 35 ++ .../sub_widgets/desktop_token_send.dart | 35 ++ lib/wallets/wallet/impl/ethereum_wallet.dart | 25 + .../impl/sub_wallets/eth_token_wallet.dart | 4 + .../templates/pubspec.template.yaml | 4 + .../open_crypto_pay_send_handler_test.dart | 504 ++++++++++++++++++ 12 files changed, 1109 insertions(+), 50 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/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..a544ef2de2 --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_send_handler.dart @@ -0,0 +1,191 @@ +/* + * 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:opencryptopay/opencryptopay.dart'; + +import '../../networking/http.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../utilities/amount/amount.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.coin, + required this.sendToController, + required this.onAmountReceived, + required this.setValidAddress, + required this.isMounted, + this.tokenSymbol, + this.tokenDecimals, + @visibleForTesting OpenCryptoPayController? controller, + }) : _controller = + controller ?? + OpenCryptoPayController( + service: OpenCryptoPayService(client: StackClient()), + ); + + final CryptoCurrency coin; + final TextEditingController sendToController; + final void Function(Amount amount) onAmountReceived; + 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 _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 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, + ); + case OpenCryptoPaySuccess() when result.session.isQuoteExpired: + // ignore: use_build_context_synchronously + await showQuoteExpiredError(context); + case OpenCryptoPaySuccess(): + _applySuccess(result); + } + } + + 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, + ); + onAmountReceived(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 ed4e967792..f717464ae2 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -37,10 +37,9 @@ 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'; import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; @@ -49,6 +48,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 +64,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 +83,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { this.isPaynymNotificationTransaction = false, this.isTokenTx = false, this.onSuccessInsteadOfRouteOnSuccess, + this.openCryptoPayHandler, }); static const String routeName = "/confirmTransactionView"; @@ -95,6 +97,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { final bool isTokenTx; final VoidCallback? onSuccessInsteadOfRouteOnSuccess; final VoidCallback onSuccess; + final OpenCryptoPaySendHandler? openCryptoPayHandler; @override ConsumerState createState() => @@ -314,34 +317,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, coin); + final sendProgressController = sendingDialog.controller; + final closeSendingDialog = sendingDialog.close; final time = Future.delayed(const Duration(milliseconds: 2500)); @@ -460,36 +458,22 @@ 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 (context.mounted) { - if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); - } else { - widget.onSuccessInsteadOfRouteOnSuccess!.call(); - } + if (isOcp && txids.isNotEmpty) { + // Broadcast (txid) proof type: submit the txid to the + // OpenCryptoPay provider. + unawaited(ocp.submitProof(context, txids.first)); } + + _navigateOnSuccess(context); } on BadHttpAddressException catch (_) { if (context.mounted) { // pop building dialog @@ -580,6 +564,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, CryptoCurrency coin) { + 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: 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.info.coin); + + 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 18b8d5be2e..a9ecceee40 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.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 'package:tuple/tuple.dart'; import '../../models/epic_slatepack_models.dart'; @@ -82,6 +83,7 @@ import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../address_book_views/address_book_view.dart'; import '../coin_control/coin_control_view.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'; @@ -156,6 +158,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 @@ -316,6 +328,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, @@ -1152,6 +1170,7 @@ class _SendViewState extends ConsumerState { clearSendForm(); } }, + openCryptoPayHandler: _openCryptoPay, ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, @@ -1342,6 +1361,18 @@ class _SendViewState extends ConsumerState { onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); baseAmountController.addListener(_baseAmountChanged); + _openCryptoPay = OpenCryptoPaySendHandler( + coin: coin, + sendToController: sendToController, + onAmountReceived: (parsed) { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format(parsed, withUnitName: false); + ref.read(pSendAmount.notifier).state = parsed; + }, + setValidAddress: _openCryptoPaySetValidAddress, + isMounted: () => mounted, + ); if (_data != null) { final hasAmount = _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..8c41aa8fa7 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'; @@ -51,6 +52,7 @@ import '../../widgets/icon_widgets/x_icon.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.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'; @@ -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,23 @@ class _SolTokenSendViewState extends ConsumerState { _addressToggleFlag = true; } + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + final coin = ref.read(pWallets).getWallet(walletId).info.coin; + _openCryptoPay = OpenCryptoPaySendHandler( + coin: coin, + sendToController: sendToController, + onAmountReceived: (parsed) { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format(parsed, withUnitName: false); + ref.read(pSendAmount.notifier).state = parsed; + }, + 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..dd10a66c00 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/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'; @@ -56,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'; @@ -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,21 @@ class _TokenSendViewState extends ConsumerState { _addressToggleFlag = true; } + _openCryptoPay = OpenCryptoPaySendHandler( + coin: coin, + sendToController: sendToController, + onAmountReceived: (parsed) { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format(parsed, withUnitName: false); + ref.read(pSendAmount.notifier).state = parsed; + }, + 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 5060d2bdb0..daa69b5984 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,22 @@ 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)) { + if (!mounted) return; + unawaited(_openCryptoPay.handle(context, qrCodeData)); + return; + } + final paymentData = AddressUtils.parsePaymentUri( qrCodeData, logging: Logging.instance, @@ -1244,6 +1262,18 @@ class _DesktopSendState extends ConsumerState { onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); + _openCryptoPay = OpenCryptoPaySendHandler( + coin: coin, + sendToController: sendToController, + onAmountReceived: (parsed) { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format(parsed, withUnitName: false); + ref.read(pSendAmount.notifier).state = parsed; + }, + setValidAddress: _openCryptoPaySetValidAddress, + isMounted: () => mounted, + ); if (_data != null) { final hasAmount = _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..ba71f2dd08 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,22 @@ class _DesktopSolTokenSendState extends ConsumerState { _addressToggleFlag = true; } + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + _openCryptoPay = OpenCryptoPaySendHandler( + coin: coin, + sendToController: sendToController, + onAmountReceived: (parsed) { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format(parsed, withUnitName: false); + ref.read(pSendAmount.notifier).state = parsed; + }, + 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..8597fd84f3 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'; @@ -104,6 +106,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 +279,7 @@ class _DesktopTokenSendState extends ConsumerState { txData: txData, walletId: walletId, onSuccess: clearSendForm, + openCryptoPayHandler: _openCryptoPay, isTokenTx: true, routeOnSuccessName: DesktopHomeView.routeName, ), @@ -438,6 +451,12 @@ class _DesktopTokenSendState 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, @@ -619,6 +638,22 @@ class _DesktopTokenSendState extends ConsumerState { _addressToggleFlag = true; } + final tokenContract = ref.read(pCurrentTokenWallet)?.tokenContract; + _openCryptoPay = OpenCryptoPaySendHandler( + coin: coin, + sendToController: sendToController, + onAmountReceived: (parsed) { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format(parsed, withUnitName: false); + ref.read(pSendAmount.notifier).state = parsed; + }, + setValidAddress: _openCryptoPaySetValidAddress, + isMounted: () => mounted, + tokenSymbol: tokenContract?.symbol, + tokenDecimals: tokenContract?.decimals, + ); + _cryptoFocus.addListener(() { if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index 354d7fea55..269cbfc10c 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -59,6 +59,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 = @@ -540,6 +564,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 a9161185f8..4c1c4ba2b5 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -271,6 +271,10 @@ dependencies: # required for web3dart to use EthereumAddress class... wallet: 0.0.18 + opencryptopay: + git: + url: https://github.com/Cyrix126/opencryptopay + 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 new file mode 100644 index 0000000000..833cbe8390 --- /dev/null +++ b/test/pages/open_crypto_pay/open_crypto_pay_send_handler_test.dart @@ -0,0 +1,504 @@ +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); + // 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, + ); + } + 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( + coin: coin, + sendToController: sendTo, + onAmountReceived: (parsed) { + amount.text = harness.ref + .read(pAmountFormatter(coin)) + .format(parsed, withUnitName: false); + harness.ref.read(pSendAmount.notifier).state = parsed; + }, + 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('/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('/tx/')).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 8163b5461c21ae5cfc21131cf77b4b3d6bc756d0 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Sat, 5 Sep 2026 09:54:15 +0200 Subject: [PATCH 03/49] fix: add missing QR scan button to desktop token send views --- .../wallet_view/sub_widgets/desktop_sol_token_send.dart | 9 +++++++++ .../wallet_view/sub_widgets/desktop_token_send.dart | 9 +++++++++ 2 files changed, 18 insertions(+) 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 ba71f2dd08..536f250270 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 @@ -46,6 +46,7 @@ import '../../../../widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../../widgets/desktop/secondary_button.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'; @@ -1047,6 +1048,14 @@ class _DesktopSolTokenSendState 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/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 8597fd84f3..40d6a0f905 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 @@ -50,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'; @@ -1034,6 +1035,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(), + ), ], ), ), From ec1e72a1a9cf520fe5a13aee7250e8d8d35fe787 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Sat, 5 Sep 2026 13:48:14 +0200 Subject: [PATCH 04/49] feat: show business information on OpenCryptoPay confirmation --- .../open_crypto_pay_send_handler.dart | 25 +++++ .../send_view/confirm_transaction_view.dart | 103 +++++++++++++----- .../templates/pubspec.template.yaml | 2 +- .../open_crypto_pay_send_handler_test.dart | 85 ++++++++++++++- 4 files changed, 184 insertions(+), 31 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 a544ef2de2..b20cfcddee 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 @@ -79,6 +79,31 @@ class OpenCryptoPaySendHandler { bool get isQuoteExpired => _session?.isQuoteExpired ?? false; + List get businessLines { + final details = _session?.details; + if (details == null) return const []; + final recipient = details.recipient; + final displayName = details.displayName; + String line(List parts) => + parts.nonNulls.where((part) => part.isNotEmpty).join(" "); + final lines = [ + displayName, + if (recipient != null) ...[ + if (recipient.name != displayName) recipient.name, + line([recipient.street, recipient.houseNumber]), + line([recipient.zip, recipient.city]), + recipient.country, + recipient.phone, + recipient.mail, + recipient.website, + if (recipient.registrationNumber case final number? + when number.isNotEmpty) + "Registration number: $number", + ], + ]; + return lines.nonNulls.where((line) => line.isNotEmpty).toList(); + } + bool isActivePaymentFor(String? recipientAddress) => _session?.isActivePaymentFor(recipientAddress) ?? false; diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index f717464ae2..4aab30578c 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -313,24 +313,31 @@ class _ConfirmTransactionViewState } } + /// Firo private (spark) sends carry the recipient in sparkRecipients. + String? get _recipientAddress => + widget.txData.recipients?.firstOrNull?.address ?? + widget.txData.sparkRecipients?.firstOrNull?.address; + + OpenCryptoPaySendHandler? get _activeOcp { + final ocp = widget.openCryptoPayHandler; + return ocp != null && ocp.isActivePaymentFor(_recipientAddress) + ? ocp + : null; + } + Future _attemptSend(BuildContext context) async { final wallet = ref.read(pWallets).getWallet(walletId); final coin = wallet.info.coin; - 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); + final ocp = _activeOcp; - if (isOcp && ocp.isQuoteExpired) { + if (ocp != null && ocp.isQuoteExpired) { // Abort before anything is broadcast or submitted (both proof types). await ocp.showQuoteExpiredError(context, paymentNotSent: true); return; } - if (isOcp && !ocp.requiresBroadcast) { + if (ocp != null && !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. @@ -467,7 +474,7 @@ class _ConfirmTransactionViewState widget.onSuccess.call(); - if (isOcp && txids.isNotEmpty) { + if (ocp != null && txids.isNotEmpty) { // Broadcast (txid) proof type: submit the txid to the // OpenCryptoPay provider. unawaited(ocp.submitProof(context, txids.first)); @@ -746,6 +753,7 @@ class _ConfirmTransactionViewState final String unit; final wallet = ref.watch(pWallets).getWallet(walletId); + final businessLines = _activeOcp?.businessLines ?? const []; if (widget.isTokenTx) { if (wallet is SolanaWallet) { // For Solana tokens, use the Solana token wallet provider or TxData as fallback. @@ -894,21 +902,31 @@ class _ConfirmTransactionViewState Text( widget.isPaynymTransaction ? widget.txData.paynymAccountLite!.nymName - : widget - .txData - .recipients - ?.firstOrNull - ?.address ?? - widget - .txData - .sparkRecipients! - .first - .address, + : _recipientAddress!, style: STextStyles.itemSubtitle12(context), ), ], ), ), + if (businessLines.isNotEmpty) const SizedBox(height: 12), + if (businessLines.isNotEmpty) + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Business", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 4), + for (final line in businessLines) + SelectableText( + line, + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), const SizedBox(height: 12), RoundedWhiteContainer( child: Row( @@ -1244,16 +1262,7 @@ class _ConfirmTransactionViewState // TODO: [prio=med] spark transaction specifics - better handling widget.isPaynymTransaction ? widget.txData.paynymAccountLite!.nymName - : widget - .txData - .recipients - ?.firstOrNull - ?.address ?? - widget - .txData - .sparkRecipients! - .first - .address, + : _recipientAddress!, style: STextStyles.desktopTextExtraExtraSmall( context, @@ -1266,6 +1275,42 @@ class _ConfirmTransactionViewState ], ), ), + if (businessLines.isNotEmpty) + Container( + height: 1, + color: Theme.of( + context, + ).extension()!.background, + ), + if (businessLines.isNotEmpty) + Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Business", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + const SizedBox(height: 2), + for (final line in businessLines) + SelectableText( + line, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + ), + ], + ), + ), if (widget.isPaynymTransaction) Container( height: 1, diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 4c1c4ba2b5..e653025ed2 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -274,7 +274,7 @@ dependencies: opencryptopay: git: url: https://github.com/Cyrix126/opencryptopay - ref: 7295eeab494a2f79a5b390fa90260f93bdacfa34 + ref: 46cadf907ccdd21149e37a86ab04175298ff542b 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 833cbe8390..c222353039 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 @@ -37,11 +37,30 @@ 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}) => { +const _recipientJson = { + "name": "Test Shop AG", + "address": { + "street": "Bahnhofstrasse", + "houseNumber": "7", + "city": "Zug", + "zip": "6300", + "country": "CH", + }, + "phone": "+41792684224", + "mail": "mail@example.org", + "website": "https://example.org/", + "registrationNumber": "CHE-429.856.521", +}; + +Map _paymentInfoJson({ + required String quoteExpiration, + Map? recipient, +}) => { "id": "pl_test", "tag": "payRequest", "callback": _callbackUrl, "displayName": "Test Shop", + if (recipient != null) "recipient": recipient, "quote": { "id": "plq_test", "expiration": quoteExpiration, @@ -295,6 +314,70 @@ void main() { expect(setup.handler.isActivePaymentFor("bc1qsomeotheraddress"), isFalse); expect(setup.handler.requiresBroadcast, isTrue); expect(setup.handler.isQuoteExpired, isFalse); + expect(setup.handler.businessLines, ["Test Shop"]); + }); + + testWidgets("lists the business information of the pending payment", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: _mockOcpServer( + paymentInfo: _paymentInfoJson( + quoteExpiration: _futureExpiration(), + recipient: _recipientJson, + ), + txDetails: _btcDetailsJson(hint: _hashHint), + ), + ); + + await _handle(tester, harness, setup.handler); + + expect(setup.handler.businessLines, [ + "Test Shop", + "Test Shop AG", + "Bahnhofstrasse 7", + "6300 Zug", + "CH", + "+41792684224", + "mail@example.org", + "https://example.org/", + "Registration number: CHE-429.856.521", + ]); + }); + + testWidgets("skips empty and missing business fields", (tester) async { + final harness = await _pumpHarness(tester); + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: _mockOcpServer( + paymentInfo: _paymentInfoJson( + quoteExpiration: _futureExpiration(), + recipient: { + "name": "Test Shop", + "address": { + "street": "Bahnhofstrasse", + "houseNumber": "", + "city": "Zug", + }, + "phone": "", + "registrationNumber": "", + }, + ), + txDetails: _btcDetailsJson(hint: _hashHint), + ), + ); + + await _handle(tester, harness, setup.handler); + + expect(setup.handler.businessLines, [ + "Test Shop", + "Bahnhofstrasse", + "Zug", + ]); }); testWidgets("signed-hex hint results in requiresBroadcast false", ( From 2e690e7bb151dc05475160c2c502b75536eef668 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Sun, 13 Sep 2026 08:21:17 +0200 Subject: [PATCH 05/49] feat: OCP show labeled business details --- .../open_crypto_pay_send_handler.dart | 36 +++++------ .../send_view/confirm_transaction_view.dart | 54 ++++------------ .../open_crypto_pay_business_details.dart | 64 +++++++++++++++++++ .../templates/pubspec.template.yaml | 2 +- .../open_crypto_pay_send_handler_test.dart | 44 ++++++++----- 5 files changed, 123 insertions(+), 77 deletions(-) create mode 100644 lib/pages/send_view/sub_widgets/open_crypto_pay_business_details.dart 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 b20cfcddee..49eb3d88f1 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 @@ -31,6 +31,8 @@ CryptoCoin cryptoCoinFor(CryptoCurrency currency, {String? tokenSymbol}) => displayName: tokenSymbol ?? currency.prettyName, ); +typedef BusinessDetail = ({String label, String value, Uri? uri}); + class OpenCryptoPaySendHandler { OpenCryptoPaySendHandler({ required this.coin, @@ -79,29 +81,27 @@ class OpenCryptoPaySendHandler { bool get isQuoteExpired => _session?.isQuoteExpired ?? false; - List get businessLines { + List get businessDetails { final details = _session?.details; if (details == null) return const []; final recipient = details.recipient; - final displayName = details.displayName; - String line(List parts) => - parts.nonNulls.where((part) => part.isNotEmpty).join(" "); - final lines = [ - displayName, + BusinessDetail? detail(String label, String? value, {Uri? uri}) => + value == null || value.isEmpty + ? null + : (label: label, value: value, uri: uri); + final legalName = details.legalName; + return [ + legalName == null + ? detail("Name", details.displayName) + : detail("Legal name", legalName), if (recipient != null) ...[ - if (recipient.name != displayName) recipient.name, - line([recipient.street, recipient.houseNumber]), - line([recipient.zip, recipient.city]), - recipient.country, - recipient.phone, - recipient.mail, - recipient.website, - if (recipient.registrationNumber case final number? - when number.isNotEmpty) - "Registration number: $number", + detail("Postal address", recipient.postalAddress), + detail("Phone number", recipient.phone, uri: recipient.phoneUri), + detail("Email", recipient.mail, uri: recipient.mailUri), + detail("Website", recipient.website, uri: recipient.websiteUri), + detail("Registration number", recipient.registrationNumber), ], - ]; - return lines.nonNulls.where((line) => line.isNotEmpty).toList(); + ].nonNulls.toList(); } bool isActivePaymentFor(String? recipientAddress) => diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 4aab30578c..9da6329c89 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -69,6 +69,7 @@ import '../pinpad_views/lock_screen_view.dart'; import '../wallet_view/wallet_view.dart'; import 'sub_widgets/epic_slatepack_dialog.dart'; import 'sub_widgets/mwc_slatepack_dialog.dart'; +import 'sub_widgets/open_crypto_pay_business_details.dart'; import 'sub_widgets/sending_transaction_dialog.dart'; class ConfirmTransactionView extends ConsumerStatefulWidget { @@ -753,7 +754,8 @@ class _ConfirmTransactionViewState final String unit; final wallet = ref.watch(pWallets).getWallet(walletId); - final businessLines = _activeOcp?.businessLines ?? const []; + final businessDetails = + _activeOcp?.businessDetails ?? const []; if (widget.isTokenTx) { if (wallet is SolanaWallet) { // For Solana tokens, use the Solana token wallet provider or TxData as fallback. @@ -908,23 +910,11 @@ class _ConfirmTransactionViewState ], ), ), - if (businessLines.isNotEmpty) const SizedBox(height: 12), - if (businessLines.isNotEmpty) + if (businessDetails.isNotEmpty) const SizedBox(height: 12), + if (businessDetails.isNotEmpty) RoundedWhiteContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - "Business", - style: STextStyles.smallMed12(context), - ), - const SizedBox(height: 4), - for (final line in businessLines) - SelectableText( - line, - style: STextStyles.itemSubtitle12(context), - ), - ], + child: OpenCryptoPayBusinessDetails( + details: businessDetails, ), ), const SizedBox(height: 12), @@ -1275,40 +1265,18 @@ class _ConfirmTransactionViewState ], ), ), - if (businessLines.isNotEmpty) + if (businessDetails.isNotEmpty) Container( height: 1, color: Theme.of( context, ).extension()!.background, ), - if (businessLines.isNotEmpty) + if (businessDetails.isNotEmpty) Padding( padding: const EdgeInsets.all(12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Business", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - ), - const SizedBox(height: 2), - for (final line in businessLines) - SelectableText( - line, - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, - ), - ), - ], + child: OpenCryptoPayBusinessDetails( + details: businessDetails, ), ), if (widget.isPaynymTransaction) diff --git a/lib/pages/send_view/sub_widgets/open_crypto_pay_business_details.dart b/lib/pages/send_view/sub_widgets/open_crypto_pay_business_details.dart new file mode 100644 index 0000000000..a3884c625f --- /dev/null +++ b/lib/pages/send_view/sub_widgets/open_crypto_pay_business_details.dart @@ -0,0 +1,64 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../themes/stack_colors.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../open_crypto_pay/open_crypto_pay_send_handler.dart'; + +/// Labeled business details of an OpenCryptoPay recipient. +class OpenCryptoPayBusinessDetails extends StatelessWidget { + const OpenCryptoPayBusinessDetails({super.key, required this.details}); + + final List details; + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final headerStyle = isDesktop + ? STextStyles.desktopTextExtraExtraSmall600(context) + : STextStyles.w600_12(context); + final labelStyle = isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.smallMed12(context); + final valueStyle = isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ) + : STextStyles.itemSubtitle12(context); + final linkStyle = valueStyle.copyWith( + color: STextStyles.link2(context).color, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text("Business", style: headerStyle), + for (final detail in details) ...[ + const SizedBox(height: 8), + Text(detail.label, style: labelStyle), + const SizedBox(height: 2), + if (detail.uri case final uri?) + SelectableText( + detail.value, + style: linkStyle, + onTap: () => launchUrl(uri, mode: LaunchMode.externalApplication), + ) + else + SelectableText(detail.value, style: valueStyle), + ], + ], + ); + } +} diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index e653025ed2..1b5e677274 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -274,7 +274,7 @@ dependencies: opencryptopay: git: url: https://github.com/Cyrix126/opencryptopay - ref: 46cadf907ccdd21149e37a86ab04175298ff542b + ref: adfc0d1fb4620e84fcd3833a7cb99570770eeb76 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 c222353039..8a97c96e14 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 @@ -314,7 +314,9 @@ void main() { expect(setup.handler.isActivePaymentFor("bc1qsomeotheraddress"), isFalse); expect(setup.handler.requiresBroadcast, isTrue); expect(setup.handler.isQuoteExpired, isFalse); - expect(setup.handler.businessLines, ["Test Shop"]); + expect(setup.handler.businessDetails, [ + (label: "Name", value: "Test Shop", uri: null), + ]); }); testWidgets("lists the business information of the pending payment", ( @@ -335,16 +337,29 @@ void main() { await _handle(tester, harness, setup.handler); - expect(setup.handler.businessLines, [ - "Test Shop", - "Test Shop AG", - "Bahnhofstrasse 7", - "6300 Zug", - "CH", - "+41792684224", - "mail@example.org", - "https://example.org/", - "Registration number: CHE-429.856.521", + expect(setup.handler.businessDetails, [ + (label: "Legal name", value: "Test Shop AG", uri: null), + ( + label: "Postal address", + value: "Bahnhofstrasse 7\n6300 Zug\nCH", + uri: null, + ), + ( + label: "Phone number", + value: "+41792684224", + uri: Uri.parse("tel:+41792684224"), + ), + ( + label: "Email", + value: "mail@example.org", + uri: Uri.parse("mailto:mail@example.org"), + ), + ( + label: "Website", + value: "https://example.org/", + uri: Uri.parse("https://example.org/"), + ), + (label: "Registration number", value: "CHE-429.856.521", uri: null), ]); }); @@ -373,10 +388,9 @@ void main() { await _handle(tester, harness, setup.handler); - expect(setup.handler.businessLines, [ - "Test Shop", - "Bahnhofstrasse", - "Zug", + expect(setup.handler.businessDetails, [ + (label: "Legal name", value: "Test Shop", uri: null), + (label: "Postal address", value: "Bahnhofstrasse\nZug", uri: null), ]); }); From b96a9a3c488382b758b0231f139f0980a188553e Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Tue, 15 Sep 2026 02:44:07 +0200 Subject: [PATCH 06/49] refactor: adapt to opencryptopay 0.4.0 result types --- .../open_crypto_pay_send_handler.dart | 11 +++++--- .../templates/pubspec.template.yaml | 2 +- .../open_crypto_pay_send_handler_test.dart | 27 +++++++++++++++++++ 3 files changed, 35 insertions(+), 5 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 49eb3d88f1..8f50bc63c6 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 @@ -144,11 +144,12 @@ class OpenCryptoPaySendHandler { switch (result) { case OpenCryptoPayFailure(): + final text = OpenCryptoPayStrings.failure(result); await _showError( // ignore: use_build_context_synchronously context: context, - title: result.title, - message: result.message, + title: text.title, + message: text.message, ); case OpenCryptoPaySuccess() when result.session.isQuoteExpired: // ignore: use_build_context_synchronously @@ -195,7 +196,7 @@ class OpenCryptoPaySendHandler { // ignore: use_build_context_synchronously await showQuoteExpiredError(context, paymentNotSent: true); return false; - case OpenCryptoPayProofFailed(:final message, :final error): + case OpenCryptoPayProofFailed(:final error): // The session stays active so the user can retry. Logging.instance.w( "OpenCryptoPay proof submission failed", @@ -205,7 +206,9 @@ class OpenCryptoPaySendHandler { unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: message, + message: OpenCryptoPayStrings.proofFailure( + requiresBroadcast: session.requiresBroadcast, + ).message, // ignore: use_build_context_synchronously context: context, ), diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 1b5e677274..41f79f182a 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -274,7 +274,7 @@ dependencies: opencryptopay: git: url: https://github.com/Cyrix126/opencryptopay - ref: adfc0d1fb4620e84fcd3833a7cb99570770eeb76 + ref: 83754ac3565573b0551db9a47dd63f2d79d36bbd 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 8a97c96e14..3f0a53725e 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 @@ -464,6 +464,33 @@ void main() { expect(setup.handler.isQuoteExpired, isFalse); }); + testWidgets("network failure shows a generic dialog without details", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: MockClient((_) async => throw Exception("socket closed")), + ); + + final fut = setup.handler.handle(harness.context, _qrLink); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text(OpenCryptoPayStrings.genericErrorTitle), findsOneWidget); + expect( + find.text(OpenCryptoPayStrings.genericErrorMessage), + findsOneWidget, + ); + expect(find.textContaining("socket closed"), findsNothing); + await _tapOk(tester); + await fut; + + expect(setup.sendTo.text, isEmpty); + expect(setup.handler.isActivePaymentFor(_btcAddress), isFalse); + }); + testWidgets( "no pending payment (404) shows a dialog and prefills nothing", (tester) async { From 0d85fd0f3245b81a3a3d54b6c09bdf8968ffe0dc Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Tue, 15 Sep 2026 02:44:12 +0200 Subject: [PATCH 07/49] fix: show an error dialog when the OpenCryptoPay lookup throws --- .../open_crypto_pay/open_crypto_pay_send_handler.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 8f50bc63c6..0a425e9990 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 @@ -140,9 +140,15 @@ class OpenCryptoPaySendHandler { message: OpenCryptoPayStrings.loading, ); - if (!isMounted() || result == null) return; + if (!isMounted()) return; switch (result) { + case null: + await _showError( + context: context, + title: OpenCryptoPayStrings.genericErrorTitle, + message: OpenCryptoPayStrings.genericErrorMessage, + ); case OpenCryptoPayFailure(): final text = OpenCryptoPayStrings.failure(result); await _showError( From 31e7a90c7642c67130f7319252ed4f6484761b64 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Tue, 15 Sep 2026 02:44:17 +0200 Subject: [PATCH 08/49] fix: show OpenCryptoPay proof failures in a dialog --- .../open_crypto_pay_send_handler.dart | 22 ++++++------------- .../open_crypto_pay_send_handler_test.dart | 20 +++++++++-------- 2 files changed, 18 insertions(+), 24 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 0a425e9990..686ac8bc55 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 @@ -8,13 +8,10 @@ * */ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:opencryptopay/opencryptopay.dart'; import '../../networking/http.dart'; -import '../../notifications/show_flush_bar.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/logger.dart'; import '../../utilities/show_loading.dart'; @@ -152,13 +149,11 @@ class OpenCryptoPaySendHandler { case OpenCryptoPayFailure(): final text = OpenCryptoPayStrings.failure(result); await _showError( - // ignore: use_build_context_synchronously context: context, title: text.title, message: text.message, ); case OpenCryptoPaySuccess() when result.session.isQuoteExpired: - // ignore: use_build_context_synchronously await showQuoteExpiredError(context); case OpenCryptoPaySuccess(): _applySuccess(result); @@ -199,7 +194,6 @@ class OpenCryptoPaySendHandler { error: error, ); if (!isMounted()) return false; - // ignore: use_build_context_synchronously await showQuoteExpiredError(context, paymentNotSent: true); return false; case OpenCryptoPayProofFailed(:final error): @@ -209,15 +203,13 @@ class OpenCryptoPaySendHandler { error: error, ); if (!isMounted()) return false; - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: OpenCryptoPayStrings.proofFailure( - requiresBroadcast: session.requiresBroadcast, - ).message, - // ignore: use_build_context_synchronously - context: context, - ), + final text = OpenCryptoPayStrings.proofFailure( + requiresBroadcast: session.requiresBroadcast, + ); + await _showError( + context: context, + title: text.title, + message: text.message, ); return false; } 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 3f0a53725e..de5dc3bef8 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 @@ -217,9 +217,9 @@ _HandlerSetup _makeHandler({ harness.ref.read(pSendAmount.notifier).state = parsed; }, setValidAddress: validAddresses.add, - isMounted: isMounted ?? () => true, tokenSymbol: tokenSymbol, tokenDecimals: tokenDecimals, + isMounted: isMounted ?? () => true, controller: OpenCryptoPayController( service: OpenCryptoPayService(client: client), ), @@ -558,15 +558,13 @@ void main() { }, ); - testWidgets("failure retains the payment so the user can retry", ( + testWidgets("failure shows a dialog and retains the payment for 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), @@ -576,11 +574,15 @@ void main() { 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); + final fut = setup.handler.submitProof(harness.context, "some_txid"); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text(OpenCryptoPayStrings.proofFailedTitle), findsOneWidget); + expect(find.text(OpenCryptoPayStrings.proofFailed), findsOneWidget); + await _tapOk(tester); + + expect(await fut, isFalse); expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); }); From 775b2de84474661cf3e2495e6d3b902efe98a3e9 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Tue, 15 Sep 2026 02:44:17 +0200 Subject: [PATCH 09/49] fix: submit the OpenCryptoPay txid proof before leaving the confirm view --- lib/pages/send_view/confirm_transaction_view.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 9da6329c89..0c308883f5 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -471,16 +471,16 @@ class _ConfirmTransactionViewState _refreshAfterSend(wallet); - closeSendingDialog(); - - widget.onSuccess.call(); - - if (ocp != null && txids.isNotEmpty) { + if (ocp != null && txids.isNotEmpty && context.mounted) { // Broadcast (txid) proof type: submit the txid to the // OpenCryptoPay provider. - unawaited(ocp.submitProof(context, txids.first)); + await ocp.submitProof(context, txids.first); } + closeSendingDialog(); + + widget.onSuccess.call(); + _navigateOnSuccess(context); } on BadHttpAddressException catch (_) { if (context.mounted) { From ec1ef3244a74b03df02000b2afec2d0bef306657 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Tue, 15 Sep 2026 11:23:01 +0200 Subject: [PATCH 10/49] feat: show a notifying status when the OpenCryptoPay proof is pending --- .../send_view/confirm_transaction_view.dart | 10 +++++++++- .../sending_transaction_dialog.dart | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 0c308883f5..c06dea310c 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -474,7 +474,15 @@ class _ConfirmTransactionViewState if (ocp != null && txids.isNotEmpty && context.mounted) { // Broadcast (txid) proof type: submit the txid to the // OpenCryptoPay provider. - await ocp.submitProof(context, txids.first); + final proof = ocp.submitProof(context, txids.first); + final done = await Future.any([ + proof, + Future.delayed(const Duration(seconds: 2)), + ]); + if (done == null) { + sendProgressController.message.value = "Notifying the seller..."; + } + await proof; } closeSendingDialog(); diff --git a/lib/pages/send_view/sub_widgets/sending_transaction_dialog.dart b/lib/pages/send_view/sub_widgets/sending_transaction_dialog.dart index c16792d116..59e8fa5953 100644 --- a/lib/pages/send_view/sub_widgets/sending_transaction_dialog.dart +++ b/lib/pages/send_view/sub_widgets/sending_transaction_dialog.dart @@ -63,6 +63,13 @@ class _RestoringDialogState extends ConsumerState { ), ); + return ValueListenableBuilder( + valueListenable: widget.controller.message, + builder: (context, message, _) => _build(context, assetPath, message), + ); + } + + Widget _build(BuildContext context, String assetPath, String? message) { if (Util.isDesktop) { return DesktopDialog( maxHeight: assetPath.endsWith(".gif") ? double.infinity : null, @@ -87,6 +94,9 @@ class _RestoringDialogState extends ConsumerState { : ProgressAndSuccess( controller: _progressAndSuccessController!, ), + if (message != null) const SizedBox(height: 24), + if (message != null) + Text(message, style: STextStyles.desktopTextSmall(context)), ], ), ), @@ -110,6 +120,13 @@ class _RestoringDialogState extends ConsumerState { textAlign: TextAlign.center, style: STextStyles.pageTitleH2(context), ), + if (message != null) const SizedBox(height: 8), + if (message != null) + Text( + message, + textAlign: TextAlign.center, + style: STextStyles.smallMed14(context), + ), const SizedBox( height: 32, ), @@ -118,6 +135,7 @@ class _RestoringDialogState extends ConsumerState { ) : StackDialog( title: "Sending transaction", + message: message, icon: ProgressAndSuccess( controller: _progressAndSuccessController!, ), @@ -129,6 +147,8 @@ class _RestoringDialogState extends ConsumerState { class ProgressAndSuccessController { VoidCallback? triggerSuccess; + + final ValueNotifier message = ValueNotifier(null); } class ProgressAndSuccess extends StatefulWidget { From d2592c882fd1c285b98123ab34bb4185ee7d08c9 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Tue, 15 Sep 2026 11:23:01 +0200 Subject: [PATCH 11/49] refactor: replace OpenCryptoPay isMounted callback with context.mounted --- .../open_crypto_pay/open_crypto_pay_send_handler.dart | 10 ++++------ lib/pages/send_view/send_view.dart | 1 - lib/pages/send_view/sol_token_send_view.dart | 1 - lib/pages/send_view/token_send_view.dart | 1 - .../wallet_view/sub_widgets/desktop_send.dart | 1 - .../sub_widgets/desktop_sol_token_send.dart | 1 - .../wallet_view/sub_widgets/desktop_token_send.dart | 1 - .../open_crypto_pay_send_handler_test.dart | 2 -- 8 files changed, 4 insertions(+), 14 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 686ac8bc55..ced114d23c 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 @@ -36,7 +36,6 @@ class OpenCryptoPaySendHandler { required this.sendToController, required this.onAmountReceived, required this.setValidAddress, - required this.isMounted, this.tokenSymbol, this.tokenDecimals, @visibleForTesting OpenCryptoPayController? controller, @@ -50,7 +49,6 @@ class OpenCryptoPaySendHandler { final TextEditingController sendToController; final void Function(Amount amount) onAmountReceived; 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 @@ -109,7 +107,7 @@ class OpenCryptoPaySendHandler { required String title, required String message, }) async { - if (!isMounted()) return; + if (!context.mounted) return; await showDialog( context: context, builder: (_) => StackOkDialog( @@ -137,7 +135,7 @@ class OpenCryptoPaySendHandler { message: OpenCryptoPayStrings.loading, ); - if (!isMounted()) return; + if (!context.mounted) return; switch (result) { case null: @@ -193,7 +191,7 @@ class OpenCryptoPaySendHandler { "OpenCryptoPay proof submission failed", error: error, ); - if (!isMounted()) return false; + if (!context.mounted) return false; await showQuoteExpiredError(context, paymentNotSent: true); return false; case OpenCryptoPayProofFailed(:final error): @@ -202,7 +200,7 @@ class OpenCryptoPaySendHandler { "OpenCryptoPay proof submission failed", error: error, ); - if (!isMounted()) return false; + if (!context.mounted) return false; final text = OpenCryptoPayStrings.proofFailure( requiresBroadcast: session.requiresBroadcast, ); diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index a9ecceee40..46ea9165f9 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -1371,7 +1371,6 @@ class _SendViewState extends ConsumerState { ref.read(pSendAmount.notifier).state = parsed; }, setValidAddress: _openCryptoPaySetValidAddress, - isMounted: () => mounted, ); if (_data != null) { diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index 8c41aa8fa7..285e117691 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -619,7 +619,6 @@ class _SolTokenSendViewState extends ConsumerState { ref.read(pSendAmount.notifier).state = parsed; }, setValidAddress: _openCryptoPaySetValidAddress, - isMounted: () => mounted, tokenSymbol: tokenWallet?.tokenSymbol, tokenDecimals: tokenWallet?.tokenDecimals, ); diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index dd10a66c00..8c41f4c927 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -645,7 +645,6 @@ class _TokenSendViewState extends ConsumerState { ref.read(pSendAmount.notifier).state = parsed; }, setValidAddress: _openCryptoPaySetValidAddress, - isMounted: () => mounted, tokenSymbol: tokenContract.symbol, tokenDecimals: tokenContract.decimals, ); 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 daa69b5984..fe6e127ad0 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 @@ -1272,7 +1272,6 @@ class _DesktopSendState extends ConsumerState { ref.read(pSendAmount.notifier).state = parsed; }, setValidAddress: _openCryptoPaySetValidAddress, - isMounted: () => mounted, ); if (_data != 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 536f250270..96fe13db0f 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 @@ -677,7 +677,6 @@ class _DesktopSolTokenSendState extends ConsumerState { ref.read(pSendAmount.notifier).state = parsed; }, setValidAddress: _openCryptoPaySetValidAddress, - isMounted: () => mounted, tokenSymbol: tokenWallet?.tokenSymbol, tokenDecimals: tokenWallet?.tokenDecimals, ); 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 40d6a0f905..edd2d3f01b 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 @@ -650,7 +650,6 @@ class _DesktopTokenSendState extends ConsumerState { ref.read(pSendAmount.notifier).state = parsed; }, setValidAddress: _openCryptoPaySetValidAddress, - isMounted: () => mounted, tokenSymbol: tokenContract?.symbol, tokenDecimals: tokenContract?.decimals, ); 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 de5dc3bef8..1560d26ec2 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 @@ -202,7 +202,6 @@ _HandlerSetup _makeHandler({ required Client client, String? tokenSymbol, int? tokenDecimals, - bool Function()? isMounted, }) { final sendTo = TextEditingController(); final amount = TextEditingController(); @@ -219,7 +218,6 @@ _HandlerSetup _makeHandler({ setValidAddress: validAddresses.add, tokenSymbol: tokenSymbol, tokenDecimals: tokenDecimals, - isMounted: isMounted ?? () => true, controller: OpenCryptoPayController( service: OpenCryptoPayService(client: client), ), From 5cf42afb9806803331583d4f0e99826e01301697 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Wed, 16 Sep 2026 03:47:00 +0200 Subject: [PATCH 12/49] feat: confirm before sending an edited OpenCryptoPay payment --- .../open_crypto_pay_send_handler.dart | 77 ++++++- .../send_view/confirm_transaction_view.dart | 83 ++++--- .../open_crypto_pay_send_handler_test.dart | 210 +++++++++++++++++- 3 files changed, 322 insertions(+), 48 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 ced114d23c..2811b05a98 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 @@ -17,8 +17,28 @@ import '../../utilities/logger.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/dialogs/basic_dialog.dart'; import '../../widgets/stack_dialog.dart'; +({String title, String message}) _quoteMismatchText({ + required bool sameRecipient, + required bool sameAmount, +}) { + final changed = switch ((sameRecipient, sameAmount)) { + (false, false) => "recipient and amount", + (false, true) => "recipient", + _ => "amount", + }; + return ( + title: "${changed[0].toUpperCase()}${changed.substring(1)} changed", + message: + "The payment request asked for a different $changed. " + "The seller may not recognize this payment.", + ); +} + /// Map a wallet [CryptoCurrency] (plus optional token symbol) to the /// library's [CryptoCoin] descriptor. CryptoCoin cryptoCoinFor(CryptoCurrency currency, {String? tokenSymbol}) => @@ -60,6 +80,7 @@ class OpenCryptoPaySendHandler { final OpenCryptoPayController _controller; OpenCryptoPaySession? _session; + Amount? _quotedAmount; Future showQuoteExpiredError( BuildContext context, { @@ -102,6 +123,50 @@ class OpenCryptoPaySendHandler { bool isActivePaymentFor(String? recipientAddress) => _session?.isActivePaymentFor(recipientAddress) ?? false; + /// Whether sending [amount] to [address] may proceed. A pending payment + /// request with another recipient or amount asks for confirmation; + /// continuing to another recipient abandons the request. + Future confirmSend( + BuildContext context, + String? address, + Amount amount, + ) async { + final session = _session; + if (session == null || session.isCompleted || session.isQuoteExpired) { + return true; + } + final sameRecipient = session.isActivePaymentFor(address); + final sameAmount = _quotedAmount == null || amount == _quotedAmount; + if (sameRecipient && sameAmount) return true; + if (!context.mounted) return false; + final text = _quoteMismatchText( + sameRecipient: sameRecipient, + sameAmount: sameAmount, + ); + final proceed = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => BasicDialog( + title: text.title, + message: text.message, + leftButton: SecondaryButton( + label: "Cancel", + onPressed: () => Navigator.of(context).pop(false), + ), + rightButton: PrimaryButton( + label: "Continue", + onPressed: () => Navigator.of(context).pop(true), + ), + flex: true, + ), + ); + if (proceed == true && !sameRecipient) { + _session = null; + _quotedAmount = null; + } + return proceed ?? false; + } + Future _showError({ required BuildContext context, required String title, @@ -166,13 +231,11 @@ class OpenCryptoPaySendHandler { sendToController.text = address; final rawAmount = result.amountInSmallestUnit(_fractionDigits); - if (rawAmount != null) { - final parsed = Amount( - rawValue: rawAmount, - fractionDigits: _fractionDigits, - ); - onAmountReceived(parsed); - } + final quoted = rawAmount == null + ? null + : Amount(rawValue: rawAmount, fractionDigits: _fractionDigits); + _quotedAmount = quoted; + if (quoted != null) onAmountReceived(quoted); setValidAddress(address); } diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index c06dea310c..61195fb778 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -330,6 +330,15 @@ class _ConfirmTransactionViewState final wallet = ref.read(pWallets).getWallet(walletId); final coin = wallet.info.coin; + final handler = widget.openCryptoPayHandler; + if (handler != null) { + final amount = _feeAndAmount(wallet).amount; + if (!await handler.confirmSend(context, _recipientAddress, amount)) { + return; + } + if (!context.mounted) return; + } + final ocp = _activeOcp; if (ocp != null && ocp.isQuoteExpired) { @@ -756,6 +765,41 @@ class _ConfirmTransactionViewState super.dispose(); } + /// Fee and amount sent to recipients, following the Firo balance type. + ({Amount? fee, Amount amount}) _feeAndAmount(Wallet wallet) { + if (wallet is FiroWallet) { + switch (ref.read(publicPrivateBalanceStateProvider.state).state) { + case BalanceType.public: + if (widget.txData.sparkMints != null) { + return ( + fee: widget.txData.sparkMints! + .map((e) => e.fee!) + .reduce((value, element) => value += element), + amount: widget.txData.sparkMints! + .map((e) => e.amountSpark!) + .reduce((value, element) => value += element), + ); + } + return ( + fee: widget.txData.fee, + amount: widget.txData.amountWithoutChange!, + ); + + case BalanceType.private: + final zero = Amount.zeroWith( + fractionDigits: wallet.cryptoCurrency.fractionDigits, + ); + return ( + fee: widget.txData.fee, + amount: + (widget.txData.amountWithoutChange ?? zero) + + (widget.txData.amountSparkWithoutChange ?? zero), + ); + } + } + return (fee: widget.txData.fee, amount: widget.txData.amountWithoutChange!); + } + @override Widget build(BuildContext context) { final coin = ref.watch(pWalletCoin(walletId)); @@ -780,42 +824,9 @@ class _ConfirmTransactionViewState unit = coin.ticker; } - final Amount? fee; - final Amount amountWithoutChange; - - if (wallet is FiroWallet) { - switch (ref.read(publicPrivateBalanceStateProvider.state).state) { - case BalanceType.public: - if (widget.txData.sparkMints != null) { - fee = widget.txData.sparkMints! - .map((e) => e.fee!) - .reduce((value, element) => value += element); - amountWithoutChange = widget.txData.sparkMints! - .map((e) => e.amountSpark!) - .reduce((value, element) => value += element); - } else { - fee = widget.txData.fee; - amountWithoutChange = widget.txData.amountWithoutChange!; - } - break; - - case BalanceType.private: - fee = widget.txData.fee; - amountWithoutChange = - (widget.txData.amountWithoutChange ?? - Amount.zeroWith( - fractionDigits: wallet.cryptoCurrency.fractionDigits, - )) + - (widget.txData.amountSparkWithoutChange ?? - Amount.zeroWith( - fractionDigits: wallet.cryptoCurrency.fractionDigits, - )); - break; - } - } else { - fee = widget.txData.fee; - amountWithoutChange = widget.txData.amountWithoutChange!; - } + final feeAndAmount = _feeAndAmount(wallet); + final fee = feeAndAmount.fee; + final amountWithoutChange = feeAndAmount.amount; return ConditionalParent( condition: !isDesktop, 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 1560d26ec2..c5457ab14f 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 @@ -11,6 +11,7 @@ import 'package:stackwallet/pages/open_crypto_pay/open_crypto_pay_send_handler.d 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.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'; @@ -86,10 +87,15 @@ Map _paymentInfoJson({ ], }; -Map _btcDetailsJson({required String hint}) => { +Map _btcDetailsJson({ + required String hint, + bool withAmount = true, +}) => { "expiryDate": "2100-01-01T00:00:00.000Z", "blockchain": "Bitcoin", - "uri": "bitcoin:$_btcAddress?amount=0.00001947&label=DFX Payment", + "uri": + "bitcoin:$_btcAddress?${withAmount ? "amount=0.00001947&" : ""}" + "label=DFX Payment", "hint": hint, }; @@ -244,12 +250,16 @@ Future _handle( await tester.pump(); } -/// Dismiss a visible StackOkDialog via its OK button. -Future _tapOk(WidgetTester tester) async { - await tester.tap(find.text("OK")); +Future _tapButton(WidgetTester tester, String label) async { + await tester.tap(find.text(label)); await tester.pump(); } +/// Dismiss a visible StackOkDialog via its OK button. +Future _tapOk(WidgetTester tester) => _tapButton(tester, "OK"); + +Amount _btc(int sats) => Amount(rawValue: BigInt.from(sats), fractionDigits: 8); + void main() { group("cryptoCoinFor", () { test("maps a native coin to the library's CryptoCoin", () { @@ -513,6 +523,196 @@ void main() { ); }); + group("OpenCryptoPaySendHandler.confirmSend", () { + final dialogTitle = find.textContaining(" changed"); + + Future<_HandlerSetup> pendingPayment( + WidgetTester tester, + _Harness harness, { + bool withAmount = true, + String? quoteExpiration, + List? requests, + }) async { + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: _mockOcpServer( + paymentInfo: _paymentInfoJson( + quoteExpiration: quoteExpiration ?? _futureExpiration(), + ), + txDetails: _btcDetailsJson(hint: _hashHint, withAmount: withAmount), + onRequest: requests?.add, + ), + ); + await _handle(tester, harness, setup.handler); + return setup; + } + + testWidgets("passes silently without a pending payment", (tester) async { + final harness = await _pumpHarness(tester); + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: _mockOcpServer(paymentInfo: const {}), + ); + + final fut = setup.handler.confirmSend( + harness.context, + "bc1qother", + _btc(1), + ); + await tester.pump(); + expect(dialogTitle, findsNothing); + expect(await fut, isTrue); + }); + + testWidgets("passes silently for the quoted recipient and amount", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingPayment(tester, harness); + + final fut = setup.handler.confirmSend( + harness.context, + _btcAddress, + _btc(1947), + ); + await tester.pump(); + expect(dialogTitle, findsNothing); + expect(await fut, isTrue); + expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); + }); + + testWidgets("an open-amount request binds only the recipient", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingPayment(tester, harness, withAmount: false); + expect(setup.amount.text, isEmpty); + + final fut = setup.handler.confirmSend( + harness.context, + _btcAddress, + _btc(99999), + ); + await tester.pump(); + expect(dialogTitle, findsNothing); + expect(await fut, isTrue); + }); + + testWidgets("another amount asks and keeps the payment on Continue", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingPayment(tester, harness); + + var fut = setup.handler.confirmSend( + harness.context, + _btcAddress, + _btc(1948), + ); + await tester.pump(); + expect(dialogTitle, findsOneWidget); + expect(find.text("Amount changed"), findsOneWidget); + expect( + find.textContaining("asked for a different amount."), + findsOneWidget, + ); + await _tapButton(tester, "Cancel"); + expect(await fut, isFalse); + expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); + + fut = setup.handler.confirmSend(harness.context, _btcAddress, _btc(1948)); + await tester.pump(); + await _tapButton(tester, "Continue"); + expect(await fut, isTrue); + expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); + }); + + testWidgets("another recipient asks and abandons the payment on Continue", ( + tester, + ) async { + final requests = []; + final harness = await _pumpHarness(tester); + final setup = await pendingPayment(tester, harness, requests: requests); + final requestsBefore = requests.length; + + final fut = setup.handler.confirmSend( + harness.context, + "bc1qother", + _btc(1947), + ); + await tester.pump(); + expect(dialogTitle, findsOneWidget); + expect(find.text("Recipient changed"), findsOneWidget); + expect( + find.textContaining("asked for a different recipient."), + findsOneWidget, + ); + await _tapButton(tester, "Continue"); + expect(await fut, isTrue); + + expect(setup.handler.isActivePaymentFor(_btcAddress), isFalse); + expect( + await setup.handler.submitProof(harness.context, "some_txid"), + isTrue, + ); + expect(requests.length, requestsBefore); + + // A later send from the same form no longer asks. + final again = setup.handler.confirmSend( + harness.context, + "bc1qthird", + _btc(1), + ); + await tester.pump(); + expect(dialogTitle, findsNothing); + expect(await again, isTrue); + }); + + testWidgets("both changed names recipient and amount", (tester) async { + final harness = await _pumpHarness(tester); + final setup = await pendingPayment(tester, harness); + + final fut = setup.handler.confirmSend( + harness.context, + "bc1qother", + _btc(1948), + ); + await tester.pump(); + expect(find.text("Recipient and amount changed"), findsOneWidget); + expect( + find.textContaining("asked for a different recipient and amount."), + findsOneWidget, + ); + await _tapButton(tester, "Cancel"); + expect(await fut, isFalse); + }); + + testWidgets("an expired request does not ask", (tester) async { + final harness = await _pumpHarness(tester); + final setup = await pendingPayment( + tester, + harness, + quoteExpiration: DateTime.now() + .toUtc() + .add(const Duration(seconds: 2)) + .toIso8601String(), + ); + await tester.pump(const Duration(seconds: 3)); + expect(setup.handler.isQuoteExpired, isTrue); + + final fut = setup.handler.confirmSend( + harness.context, + _btcAddress, + _btc(1948), + ); + await tester.pump(); + expect(dialogTitle, findsNothing); + expect(await fut, isTrue); + }); + }); + group("OpenCryptoPaySendHandler.submitProof", () { testWidgets( "success clears the active payment and later calls become no-ops", From 8c7fb3af23964443420366747a54858bf7a96ce2 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Wed, 16 Sep 2026 04:24:42 +0200 Subject: [PATCH 13/49] fix: warning about edited OpenCryptoPay payment before unlocking --- .../send_view/confirm_transaction_view.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 61195fb778..f25e2fd207 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -330,15 +330,6 @@ class _ConfirmTransactionViewState final wallet = ref.read(pWallets).getWallet(walletId); final coin = wallet.info.coin; - final handler = widget.openCryptoPayHandler; - if (handler != null) { - final amount = _feeAndAmount(wallet).amount; - if (!await handler.confirmSend(context, _recipientAddress, amount)) { - return; - } - if (!context.mounted) return; - } - final ocp = _activeOcp; if (ocp != null && ocp.isQuoteExpired) { @@ -1694,6 +1685,15 @@ class _ConfirmTransactionViewState label: "Send", buttonHeight: isDesktop ? ButtonHeight.l : null, onPressed: () async { + final handler = widget.openCryptoPayHandler; + if (handler != null) { + final proceed = await handler.confirmSend( + context, + _recipientAddress, + _feeAndAmount(wallet).amount, + ); + if (!proceed || !context.mounted) return; + } if (isDesktop) { final unlocked = await showDialog( context: context, From b438a2d50650ae9e4a35082bf718e7ff13267ab7 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Wed, 16 Sep 2026 04:24:42 +0200 Subject: [PATCH 14/49] feat: drop an overridden OpenCryptoPay request after a failed send --- .../open_crypto_pay_send_handler.dart | 23 ++++++++++----- .../send_view/confirm_transaction_view.dart | 13 +++++++++ lib/pages/send_view/send_view.dart | 1 + lib/pages/send_view/sol_token_send_view.dart | 1 + lib/pages/send_view/token_send_view.dart | 1 + .../wallet_view/sub_widgets/desktop_send.dart | 1 + .../sub_widgets/desktop_sol_token_send.dart | 1 + .../sub_widgets/desktop_token_send.dart | 1 + .../open_crypto_pay_send_handler_test.dart | 29 ++++++++++++++----- 9 files changed, 56 insertions(+), 15 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 2811b05a98..ee22e4cff4 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 @@ -13,6 +13,7 @@ import 'package:opencryptopay/opencryptopay.dart'; import '../../networking/http.dart'; import '../../utilities/amount/amount.dart'; +import '../../utilities/extensions/extensions.dart'; import '../../utilities/logger.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/util.dart'; @@ -32,7 +33,7 @@ import '../../widgets/stack_dialog.dart'; _ => "amount", }; return ( - title: "${changed[0].toUpperCase()}${changed.substring(1)} changed", + title: "${changed.capitalize()} changed", message: "The payment request asked for a different $changed. " "The seller may not recognize this payment.", @@ -81,6 +82,17 @@ class OpenCryptoPaySendHandler { final OpenCryptoPayController _controller; OpenCryptoPaySession? _session; Amount? _quotedAmount; + bool _quoteOverridden = false; + + /// Whether the user chose to send despite a recipient or amount that + /// differs from the payment request. + bool get quoteOverridden => _quoteOverridden; + + void reset() { + _session = null; + _quotedAmount = null; + _quoteOverridden = false; + } Future showQuoteExpiredError( BuildContext context, { @@ -124,8 +136,7 @@ class OpenCryptoPaySendHandler { _session?.isActivePaymentFor(recipientAddress) ?? false; /// Whether sending [amount] to [address] may proceed. A pending payment - /// request with another recipient or amount asks for confirmation; - /// continuing to another recipient abandons the request. + /// request with another recipient or amount asks for confirmation. Future confirmSend( BuildContext context, String? address, @@ -160,10 +171,7 @@ class OpenCryptoPaySendHandler { flex: true, ), ); - if (proceed == true && !sameRecipient) { - _session = null; - _quotedAmount = null; - } + if (proceed == true) _quoteOverridden = true; return proceed ?? false; } @@ -235,6 +243,7 @@ class OpenCryptoPaySendHandler { ? null : Amount(rawValue: rawAmount, fractionDigits: _fractionDigits); _quotedAmount = quoted; + _quoteOverridden = false; if (quoted != null) onAmountReceived(quoted); setValidAddress(address); diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index f25e2fd207..5a27cb510b 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -502,11 +502,13 @@ class _ConfirmTransactionViewState context: context, ), ); + _discardOverriddenRequest(); return; } } catch (e, s) { const message = "Broadcast transaction failed"; Logging.instance.e(message, error: e, stackTrace: s); + _discardOverriddenRequest(); // pop sending dialog if (context.mounted) { closeSendingDialog(); @@ -695,6 +697,7 @@ class _ConfirmTransactionViewState // The handler showed the error and retained the payment for retry. // Nothing was broadcast, so no funds moved. sendingDialog.close(); + _discardOverriddenRequest(); return; } @@ -756,6 +759,16 @@ class _ConfirmTransactionViewState super.dispose(); } + /// After a failed send that overrode the payment request, drop the request + /// and clear the send form so the code can be scanned again. + void _discardOverriddenRequest() { + final handler = widget.openCryptoPayHandler; + if (handler == null || !handler.quoteOverridden) return; + handler.reset(); + // Every send view clears its form in onSuccess. + widget.onSuccess.call(); + } + /// Fee and amount sent to recipients, following the Firo balance type. ({Amount? fee, Amount amount}) _feeAndAmount(Wallet wallet) { if (wallet is FiroWallet) { diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 46ea9165f9..1ec1865e11 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -1219,6 +1219,7 @@ class _SendViewState extends ConsumerState { } void clearSendForm() { + _openCryptoPay.reset(); if (!mounted) { return; } diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index 285e117691..85662eeb42 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -560,6 +560,7 @@ class _SolTokenSendViewState extends ConsumerState { } void clearSendForm() { + _openCryptoPay.reset(); sendToController.text = ""; memoController.text = ""; cryptoAmountController.text = ""; diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 8c41f4c927..888aed7331 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -590,6 +590,7 @@ class _TokenSendViewState extends ConsumerState { } void clearSendForm() { + _openCryptoPay.reset(); sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; 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 fe6e127ad0..51caf199bf 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 @@ -851,6 +851,7 @@ class _DesktopSendState extends ConsumerState { } void clearSendForm() { + _openCryptoPay.reset(); if (!mounted) { return; } 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 96fe13db0f..0139c48daf 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 @@ -373,6 +373,7 @@ class _DesktopSolTokenSendState extends ConsumerState { } void clearSendForm() { + _openCryptoPay.reset(); sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; 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 edd2d3f01b..3037b7bed4 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 @@ -356,6 +356,7 @@ class _DesktopTokenSendState extends ConsumerState { } void clearSendForm() { + _openCryptoPay.reset(); sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; 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 c5457ab14f..efb0dd98a1 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 @@ -629,13 +629,12 @@ void main() { expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); }); - testWidgets("another recipient asks and abandons the payment on Continue", ( + testWidgets("another recipient asks and marks the request overridden", ( tester, ) async { - final requests = []; final harness = await _pumpHarness(tester); - final setup = await pendingPayment(tester, harness, requests: requests); - final requestsBefore = requests.length; + final setup = await pendingPayment(tester, harness); + expect(setup.handler.quoteOverridden, isFalse); final fut = setup.handler.confirmSend( harness.context, @@ -652,6 +651,21 @@ void main() { await _tapButton(tester, "Continue"); expect(await fut, isTrue); + expect(setup.handler.quoteOverridden, isTrue); + expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); + }); + + testWidgets("reset drops the request without a network call", ( + tester, + ) async { + final requests = []; + final harness = await _pumpHarness(tester); + final setup = await pendingPayment(tester, harness, requests: requests); + final requestsBefore = requests.length; + + setup.handler.reset(); + + expect(setup.handler.quoteOverridden, isFalse); expect(setup.handler.isActivePaymentFor(_btcAddress), isFalse); expect( await setup.handler.submitProof(harness.context, "some_txid"), @@ -659,15 +673,14 @@ void main() { ); expect(requests.length, requestsBefore); - // A later send from the same form no longer asks. - final again = setup.handler.confirmSend( + final fut = setup.handler.confirmSend( harness.context, - "bc1qthird", + "bc1qother", _btc(1), ); await tester.pump(); expect(dialogTitle, findsNothing); - expect(await again, isTrue); + expect(await fut, isTrue); }); testWidgets("both changed names recipient and amount", (tester) async { From 5369326f1b1d12d75dae1929c27114312a7ff53b Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Wed, 16 Sep 2026 04:24:42 +0200 Subject: [PATCH 15/49] chore: bump opencryptopay --- 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 41f79f182a..9f41eaabd9 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -274,7 +274,7 @@ dependencies: opencryptopay: git: url: https://github.com/Cyrix126/opencryptopay - ref: 83754ac3565573b0551db9a47dd63f2d79d36bbd + ref: 4069af5f721bf1e1efc8d5992daf2f4a8f58c8e9 dev_dependencies: flutter_test: From 269d23addc50853f99a18fb7c03e26a696542203 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Wed, 16 Sep 2026 07:02:01 +0200 Subject: [PATCH 16/49] fix: report an unconfirmed OpenCryptoPay delivery --- .../open_crypto_pay_send_handler.dart | 3 +- .../send_view/confirm_transaction_view.dart | 3 +- .../open_crypto_pay_send_handler_test.dart | 30 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 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 ee22e4cff4..e43382b8d6 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 @@ -266,7 +266,7 @@ class OpenCryptoPaySendHandler { if (!context.mounted) return false; await showQuoteExpiredError(context, paymentNotSent: true); return false; - case OpenCryptoPayProofFailed(:final error): + case OpenCryptoPayProofFailed(:final error, :final providerAnswered): // The session stays active so the user can retry. Logging.instance.w( "OpenCryptoPay proof submission failed", @@ -275,6 +275,7 @@ class OpenCryptoPaySendHandler { if (!context.mounted) return false; final text = OpenCryptoPayStrings.proofFailure( requiresBroadcast: session.requiresBroadcast, + providerAnswered: providerAnswered, ); await _showError( context: context, diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 5a27cb510b..275eee251c 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -695,9 +695,10 @@ class _ConfirmTransactionViewState 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(); _discardOverriddenRequest(); + // Pick up a transaction the provider may have broadcast anyway. + _refreshAfterSend(wallet); return; } 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 efb0dd98a1..48cb75fb53 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 @@ -119,12 +119,14 @@ MockClient _mockOcpServer({ Map? txDetails, int paymentInfoStatus = 200, int proofStatus = 200, + bool proofUnreachable = false, void Function(Uri url)? onRequest, }) { return MockClient((request) async { onRequest?.call(request.url); // Proof submissions go to the callback URL with /cb/ replaced by /tx/. if (request.url.path.contains('/tx/')) { + if (proofUnreachable) throw Exception("socket closed"); return Response( proofStatus == 200 ? '{"status": "ok"}' : '{}', proofStatus, @@ -797,6 +799,34 @@ void main() { expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); }); + testWidgets("a lost response on hex-proof submission is reported as " + "unconfirmed delivery", (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), + proofUnreachable: true, + ), + ); + await _handle(tester, harness, setup.handler); + + final fut = setup.handler.submitProof(harness.context, "deadbeef"); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + find.text(OpenCryptoPayStrings.deliveryUnconfirmedTitle), + findsOneWidget, + ); + expect(find.textContaining("Nothing was sent"), findsNothing); + await _tapOk(tester); + expect(await fut, 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); From 5ab899ce09ba335c4635a370131ecb3deae84138 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Wed, 16 Sep 2026 07:02:15 +0200 Subject: [PATCH 17/49] chore: bump opencryptopay --- 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 9f41eaabd9..6dff7a3750 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -274,7 +274,7 @@ dependencies: opencryptopay: git: url: https://github.com/Cyrix126/opencryptopay - ref: 4069af5f721bf1e1efc8d5992daf2f4a8f58c8e9 + ref: a4a14e4d6961d3e7cfc4827ee52ec03f48162a90 dev_dependencies: flutter_test: From 78b332c37703dcbb05fcb1a73d532ac5517fac9c Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Wed, 16 Sep 2026 07:23:42 +0200 Subject: [PATCH 18/49] fix: keep the quoted OpenCryptoPay amount for tokens --- lib/pages/send_view/sol_token_send_view.dart | 9 +++++---- lib/pages/send_view/token_send_view.dart | 5 +++-- .../wallet_view/sub_widgets/desktop_sol_token_send.dart | 9 +++++---- .../wallet_view/sub_widgets/desktop_token_send.dart | 9 +++++++-- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index 85662eeb42..7f4abe4f02 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -614,10 +614,11 @@ class _SolTokenSendViewState extends ConsumerState { coin: coin, sendToController: sendToController, onAmountReceived: (parsed) { - cryptoAmountController.text = ref - .read(pAmountFormatter(coin)) - .format(parsed, withUnitName: false); - ref.read(pSendAmount.notifier).state = parsed; + cryptoAmountController.text = parsed.decimal.toStringAsFixed( + parsed.fractionDigits, + ); + _amountToSend = parsed; + _updatePreviewButtonState(_address, parsed); }, setValidAddress: _openCryptoPaySetValidAddress, tokenSymbol: tokenWallet?.tokenSymbol, diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 888aed7331..a9a080bde7 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -642,8 +642,9 @@ class _TokenSendViewState extends ConsumerState { onAmountReceived: (parsed) { cryptoAmountController.text = ref .read(pAmountFormatter(coin)) - .format(parsed, withUnitName: false); - ref.read(pSendAmount.notifier).state = parsed; + .format(parsed, tokenContract: tokenContract, withUnitName: false); + _amountToSend = parsed; + _updatePreviewButtonState(_address, parsed); }, setValidAddress: _openCryptoPaySetValidAddress, tokenSymbol: tokenContract.symbol, 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 0139c48daf..676b4a0beb 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 @@ -672,10 +672,11 @@ class _DesktopSolTokenSendState extends ConsumerState { coin: coin, sendToController: sendToController, onAmountReceived: (parsed) { - cryptoAmountController.text = ref - .read(pAmountFormatter(coin)) - .format(parsed, withUnitName: false); - ref.read(pSendAmount.notifier).state = parsed; + cryptoAmountController.text = parsed.decimal.toStringAsFixed( + parsed.fractionDigits, + ); + _amountToSend = parsed; + _updatePreviewButtonState(_address, parsed); }, setValidAddress: _openCryptoPaySetValidAddress, tokenSymbol: tokenWallet?.tokenSymbol, 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 3037b7bed4..9e48469bcf 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 @@ -647,8 +647,13 @@ class _DesktopTokenSendState extends ConsumerState { onAmountReceived: (parsed) { cryptoAmountController.text = ref .read(pAmountFormatter(coin)) - .format(parsed, withUnitName: false); - ref.read(pSendAmount.notifier).state = parsed; + .format( + parsed, + tokenContract: ref.read(pCurrentTokenWallet)!.tokenContract, + withUnitName: false, + ); + _amountToSend = parsed; + _updatePreviewButtonState(_address, parsed); }, setValidAddress: _openCryptoPaySetValidAddress, tokenSymbol: tokenContract?.symbol, From 354f0b2decbe9925890481dee4d845df95eb8943 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 13:15:27 +0200 Subject: [PATCH 19/49] feat: honor the OpenCryptoPay minimum network fee --- .../open_crypto_pay_send_fee.dart | 205 +++++++ .../open_crypto_pay_send_handler.dart | 53 +- lib/pages/send_view/send_view.dart | 18 +- lib/pages/send_view/token_send_view.dart | 16 +- .../wallet_view/sub_widgets/desktop_send.dart | 18 +- .../sub_widgets/desktop_token_send.dart | 16 +- .../open_crypto_pay_send_handler_test.dart | 508 +++++++++++++++++- 7 files changed, 819 insertions(+), 15 deletions(-) create mode 100644 lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart new file mode 100644 index 0000000000..cf1402923e --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart @@ -0,0 +1,205 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/widgets.dart'; + +import '../../models/paymint/fee_object_model.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/enums/fee_rate_type_enum.dart'; +import '../../utilities/eth_commons.dart'; +import '../../utilities/logger.dart'; +import '../../wallets/wallet/impl/ethereum_wallet.dart'; +import '../../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import '../../wallets/wallet/wallet.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; +import '../../widgets/eth_fee_form.dart'; + +const _highFeeTitle = "High network fee"; +String _highFeeMessage(String required, String fast) => + "The payment request requires a network fee of at least $required, " + "above the current fast estimate of $fast."; +const _unmetFeeTitle = "Network fee too low"; +String _unmetFeeMessage(String required, String fastest) => + "The payment request requires a network fee of at least $required, " + "above this wallet's fastest fee of $fastest."; + +/// Fee values for a send, with the payment request's minimum fee applied. +typedef OpenCryptoPaySendFee = ({ + FeeRateType feeRateType, + int? satsPerVByte, + EthEIP1559Fee? ethFee, +}); + +/// Asks the user to confirm; false when they cancelled. +typedef OpenCryptoPayConfirm = Future Function( + BuildContext context, + String title, + String message, +); + +/// Tells the user the payment cannot be made yet. +typedef OpenCryptoPayNotify = Future Function( + BuildContext context, + String title, + String message, +); + +/// The chosen fee, raised to the minimum when below it. Null when the user +/// cancelled the confirmation or no fee level reaches the minimum. +Future openCryptoPaySendFee( + BuildContext context, + Wallet wallet, { + required Amount amount, + required num minFee, + required OpenCryptoPaySendFee chosen, + required OpenCryptoPayConfirm confirm, + required OpenCryptoPayNotify unmet, +}) async { + final bool isUtxo = wallet is ElectrumXInterface; + final bool isEvm = wallet is EthereumWallet || wallet is EthTokenWallet; + final FeeObject fees; + try { + fees = await wallet.fees; + } catch (e, s) { + Logging.instance.w( + "OpenCryptoPay fee estimate unavailable", + error: e, + stackTrace: s, + ); + return chosen; + } + if (!context.mounted) return null; + if (isUtxo) return _utxoSendFee(context, fees, minFee, chosen, confirm); + if (isEvm) { + return _evmSendFee( + context, + wallet, + fees as EthFeeObject, + minFee, + chosen, + confirm, + ); + } + return _levelSendFee(context, wallet, fees, amount, minFee, chosen, unmet); +} + +/// Picks the lowest fee level at or above the chosen one whose estimated fee +/// reaches the minimum. +Future _levelSendFee( + BuildContext context, + Wallet wallet, + FeeObject fees, + Amount amount, + num minFee, + OpenCryptoPaySendFee chosen, + OpenCryptoPayNotify unmet, +) async { + final required = BigInt.from(minFee.ceil()); + final levels = [ + (type: FeeRateType.slow, rate: fees.slow), + (type: FeeRateType.average, rate: fees.medium), + (type: FeeRateType.fast, rate: fees.fast), + ]; + final start = levels.indexWhere((l) => l.type == chosen.feeRateType); + Amount? fee; + for (final level in levels.sublist(start < 0 ? 0 : start)) { + fee = await wallet.estimateFeeFor(amount, level.rate); + if (fee.raw >= required) { + return level.type == chosen.feeRateType + ? chosen + : ( + feeRateType: level.type, + satsPerVByte: chosen.satsPerVByte, + ethFee: chosen.ethFee, + ); + } + } + final fastest = fee!; + String coins(BigInt raw) => + "${Amount(rawValue: raw, fractionDigits: fastest.fractionDigits).decimal} " + "${wallet.cryptoCurrency.ticker}"; + if (!context.mounted) return null; + await unmet( + context, + _unmetFeeTitle, + _unmetFeeMessage(coins(required), coins(fastest.raw)), + ); + return null; +} + +Future _utxoSendFee( + BuildContext context, + FeeObject fees, + num minFee, + OpenCryptoPaySendFee chosen, + OpenCryptoPayConfirm confirm, +) async { + final requiredPerKb = BigInt.from((minFee * 1000).ceil()); + final current = switch (chosen.feeRateType) { + FeeRateType.fast => fees.fast, + FeeRateType.average => fees.medium, + FeeRateType.slow => fees.slow, + FeeRateType.custom => BigInt.from((chosen.satsPerVByte ?? 0) * 1000), + }; + if (current >= requiredPerKb) return chosen; + final required = minFee.ceil(); + String perVByte(BigInt perKb) => + "${Decimal.fromBigInt(perKb).shift(-3).toStringAsFixed(2)} sats/vByte"; + if (requiredPerKb > fees.fast && + !await confirm( + context, + _highFeeTitle, + _highFeeMessage("$required sats/vByte", perVByte(fees.fast)), + )) { + return null; + } + return ( + feeRateType: FeeRateType.custom, + satsPerVByte: required, + ethFee: chosen.ethFee, + ); +} + +Future _evmSendFee( + BuildContext context, + Wallet wallet, + EthFeeObject fees, + num minFee, + OpenCryptoPaySendFee chosen, + OpenCryptoPayConfirm confirm, +) async { + final minWei = BigInt.from(minFee.ceil()); + final current = switch (chosen.feeRateType) { + FeeRateType.fast => fees.fast, + FeeRateType.average => fees.medium, + FeeRateType.slow => fees.slow, + FeeRateType.custom => chosen.ethFee?.maxFeePerGasWei ?? BigInt.zero, + }; + if (current >= minWei) return chosen; + Decimal gwei(BigInt wei) => Decimal.fromBigInt(wei).shift(-9); + if (minWei > fees.fast && + !await confirm( + context, + _highFeeTitle, + _highFeeMessage( + "${gwei(minWei).toStringAsFixed(2)} gwei", + "${gwei(fees.fast).toStringAsFixed(2)} gwei", + ), + )) { + return null; + } + // The priority fee tops the base fee up to the minimum gas price. + var priorityWei = minWei - fees.suggestBaseFee; + if (priorityWei.isNegative) priorityWei = BigInt.zero; + return ( + feeRateType: FeeRateType.custom, + satsPerVByte: chosen.satsPerVByte, + ethFee: EthEIP1559Fee( + maxFeePerGasGwei: gwei(minWei), + maxPriorityFeePerGasGwei: gwei(priorityWei), + gasLimit: + chosen.ethFee?.gasLimit ?? + (wallet is EthTokenWallet + ? kEthereumTokenMinGasLimit + : kEthereumMinGasLimit), + ), + ); +} 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 e43382b8d6..5347018a42 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 @@ -13,15 +13,19 @@ import 'package:opencryptopay/opencryptopay.dart'; import '../../networking/http.dart'; import '../../utilities/amount/amount.dart'; +import '../../utilities/enums/fee_rate_type_enum.dart'; import '../../utilities/extensions/extensions.dart'; import '../../utilities/logger.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/wallet/wallet.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/dialogs/basic_dialog.dart'; +import '../../widgets/eth_fee_form.dart'; import '../../widgets/stack_dialog.dart'; +import 'open_crypto_pay_send_fee.dart'; ({String title, String message}) _quoteMismatchText({ required bool sameRecipient, @@ -154,12 +158,56 @@ class OpenCryptoPaySendHandler { sameRecipient: sameRecipient, sameAmount: sameAmount, ); + final proceed = await _confirm(context, text.title, text.message); + if (proceed) _quoteOverridden = true; + return proceed; + } + + /// The fee to build the transaction with: the given one, raised to the + /// payment request's minimum when below it. Null when the send must stop. + Future sendFee( + BuildContext context, + Wallet wallet, { + required String? address, + required Amount amount, + required FeeRateType feeRateType, + int? satsPerVByte, + EthEIP1559Fee? ethFee, + }) async { + final chosen = ( + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, + ethFee: ethFee, + ); + final session = _session; + if (session == null || + session.minFee <= 0 || + !session.isActivePaymentFor(address)) { + return chosen; + } + return openCryptoPaySendFee( + context, + wallet, + amount: amount, + minFee: session.minFee, + chosen: chosen, + confirm: _confirm, + unmet: (context, title, message) => + _showError(context: context, title: title, message: message), + ); + } + + Future _confirm( + BuildContext context, + String title, + String message, + ) async { final proceed = await showDialog( context: context, barrierDismissible: false, builder: (context) => BasicDialog( - title: text.title, - message: text.message, + title: title, + message: message, leftButton: SecondaryButton( label: "Cancel", onPressed: () => Navigator.of(context).pop(false), @@ -171,7 +219,6 @@ class OpenCryptoPaySendHandler { flex: true, ), ); - if (proceed == true) _quoteOverridden = true; return proceed ?? false; } diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 5b1254adec..50dae7aea5 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -935,6 +935,19 @@ class _SendViewState extends ConsumerState { } } + final chosenRateType = ref.read(feeRateTypeMobileStateProvider); + if (!mounted) return; + final fee = await _openCryptoPay.sendFee( + context, + wallet, + address: _address, + amount: amount, + feeRateType: chosenRateType, + satsPerVByte: chosenRateType.customSatsPerVByte(customFeeRate), + ethFee: _ethFee.value, + ); + if (fee == null || !mounted) return; + try { bool wasCancelled = false; @@ -965,8 +978,7 @@ class _SendViewState extends ConsumerState { final time = Future.delayed(const Duration(milliseconds: 2500)); Future txDataFuture; - final feeRateType = ref.read(feeRateTypeMobileStateProvider); - final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate); + final (:feeRateType, :satsPerVByte, :ethFee) = fee; if (isPaynymSend) { txDataFuture = (wallet as PaynymInterface).preparePaymentCodeSend( @@ -1106,7 +1118,7 @@ class _SendViewState extends ConsumerState { memo: memo, feeRateType: feeRateType, satsPerVByte: satsPerVByte, - ethEIP1559Fee: _ethFee.value, + ethEIP1559Fee: ethFee, utxos: (wallet is CoinControlInterface && wallet is! SalviumWallet && diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 6ca281a29b..d5b74c9926 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -481,6 +481,18 @@ class _TokenSendViewState extends ConsumerState { // } // } + final feeRateType = ref.read(feeRateTypeMobileStateProvider); + if (!mounted) return; + final fee = await _openCryptoPay.sendFee( + context, + tokenWallet, + address: _address, + amount: amount, + feeRateType: feeRateType, + ethFee: _ethFee.value, + ); + if (fee == null || !mounted) return; + try { bool wasCancelled = false; @@ -522,9 +534,9 @@ class _TokenSendViewState extends ConsumerState { )!, ), ], - feeRateType: ref.read(feeRateTypeMobileStateProvider), + feeRateType: fee.feeRateType, note: noteController.text, - ethEIP1559Fee: _ethFee.value, + ethEIP1559Fee: fee.ethFee, ), ); 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 98bba3a66b..07d66e957a 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 @@ -551,6 +551,19 @@ class _DesktopSendState extends ConsumerState { } } + final chosenRateType = ref.read(feeRateTypeDesktopStateProvider); + if (!mounted) return; + final fee = await _openCryptoPay.sendFee( + context, + wallet, + address: _address, + amount: amount, + feeRateType: chosenRateType, + satsPerVByte: chosenRateType.customSatsPerVByte(customFeeRate), + ethFee: _ethFee.value, + ); + if (fee == null || !mounted) return; + try { bool wasCancelled = false; @@ -591,8 +604,7 @@ class _DesktopSendState extends ConsumerState { TxData txData; Future txDataFuture; - final feeRateType = ref.read(feeRateTypeDesktopStateProvider); - final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate); + final (:feeRateType, :satsPerVByte, :ethFee) = fee; if (isPaynymSend) { final paynymWallet = wallet as PaynymInterface; @@ -743,7 +755,7 @@ class _DesktopSendState extends ConsumerState { ref.read(pDesktopUseUTXOs).isNotEmpty) ? ref.read(pDesktopUseUTXOs) : null, - ethEIP1559Fee: _ethFee.value, + ethEIP1559Fee: ethFee, ), ); } 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 547b5c22ff..f30156ab8f 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 @@ -220,6 +220,18 @@ class _DesktopTokenSendState extends ConsumerState { } } + final feeRateType = ref.read(feeRateTypeDesktopStateProvider); + if (!mounted) return; + final fee = await _openCryptoPay.sendFee( + context, + tokenWallet, + address: _address, + amount: amount, + feeRateType: feeRateType, + ethFee: _ethFee.value, + ); + if (fee == null || !mounted) return; + try { bool wasCancelled = false; @@ -268,9 +280,9 @@ class _DesktopTokenSendState extends ConsumerState { )!, ), ], - feeRateType: ref.read(feeRateTypeDesktopStateProvider), + feeRateType: fee.feeRateType, nonce: nonce, - ethEIP1559Fee: _ethFee.value, + ethEIP1559Fee: fee.ethFee, ), ); 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 48cb75fb53..136b4c09d6 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 @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -7,6 +8,8 @@ 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/models/paymint/fee_object_model.dart'; +import 'package:stackwallet/pages/open_crypto_pay/open_crypto_pay_send_fee.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'; @@ -14,7 +17,14 @@ import 'package:stackwallet/themes/theme_service.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/amount/amount_formatter.dart'; import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; +import 'package:stackwallet/utilities/eth_commons.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; +import 'package:stackwallet/widgets/eth_fee_form.dart'; import '../../sample_data/theme_json.dart'; @@ -56,6 +66,8 @@ const _recipientJson = { Map _paymentInfoJson({ required String quoteExpiration, Map? recipient, + num btcMinFee = 0, + num ethMinFee = 0, }) => { "id": "pl_test", "tag": "payRequest", @@ -70,7 +82,7 @@ Map _paymentInfoJson({ "transferAmounts": [ { "method": "Bitcoin", - "minFee": 0, + "minFee": btcMinFee, "assets": [ {"asset": "BTC", "amount": "0.00001947"}, ], @@ -78,7 +90,7 @@ Map _paymentInfoJson({ }, { "method": "Ethereum", - "minFee": 0, + "minFee": ethMinFee, "assets": [ {"asset": "USDT", "amount": "1.246858"}, ], @@ -153,6 +165,101 @@ class _Harness { late WidgetRef ref; } +/// UTXO wallet exposing only fee estimates, in sat/kB. +class _FakeUtxoWallet implements ElectrumXInterface { + _FakeUtxoWallet.offline() : _fees = null; + + _FakeUtxoWallet({required int fast, required int medium, required int slow}) + : _fees = FeeObject( + numberOfBlocksFast: 1, + numberOfBlocksAverage: 5, + numberOfBlocksSlow: 20, + fast: BigInt.from(fast), + medium: BigInt.from(medium), + slow: BigInt.from(slow), + ); + + final FeeObject? _fees; + + @override + Future get fees async => + _fees ?? (throw Exception("estimateFee failed")); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// EVM fee estimates in wei; medium is the midpoint of fast and slow. +EthFeeObject _ethFees({ + required int baseFee, + required int fast, + required int slow, +}) => EthFeeObject( + suggestBaseFee: BigInt.from(baseFee), + numberOfBlocksFast: 1, + numberOfBlocksAverage: 3, + numberOfBlocksSlow: 6, + fast: BigInt.from(fast), + medium: BigInt.from((fast + slow) ~/ 2), + slow: BigInt.from(slow), +); + +/// Token wallet exposing only fee estimates. +class _FakeTokenWallet implements EthTokenWallet { + _FakeTokenWallet(this._fees); + + final EthFeeObject _fees; + + @override + Future get fees async => _fees; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Ethereum wallet exposing only fee estimates. +class _FakeEthWallet implements EthereumWallet { + _FakeEthWallet(this._fees); + + final EthFeeObject _fees; + + @override + Future get fees async => _fees; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Wallet with fixed fee levels; the fee object holds level ids and the +/// estimate returns a fee amount per id. +class _FakeLevelWallet implements Wallet { + _FakeLevelWallet(this._feeByLevel); + + final Map _feeByLevel; + + @override + Monero get cryptoCurrency => Monero(CryptoCurrencyNetwork.main); + + @override + Future get fees async => FeeObject( + numberOfBlocksFast: 10, + numberOfBlocksAverage: 15, + numberOfBlocksSlow: 20, + fast: BigInt.from(3), + medium: BigInt.from(2), + slow: BigInt.from(1), + ); + + @override + Future estimateFeeFor(Amount amount, BigInt feeRate) async => Amount( + rawValue: BigInt.from(_feeByLevel[feeRate.toInt()]!), + fractionDigits: 12, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + /// 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 { @@ -728,6 +835,403 @@ void main() { }); }); + group("OpenCryptoPaySendHandler.sendFee", () { + const title = "High network fee"; + + Future<_HandlerSetup> pendingBtc( + WidgetTester tester, + _Harness harness, { + num minFee = 0, + }) async { + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: _mockOcpServer( + paymentInfo: _paymentInfoJson( + quoteExpiration: _futureExpiration(), + btcMinFee: minFee, + ), + txDetails: _btcDetailsJson(hint: _hashHint), + ), + ); + await _handle(tester, harness, setup.handler); + return setup; + } + + Future<_HandlerSetup> pendingErc20( + WidgetTester tester, + _Harness harness, { + num minFee = 0, + }) async { + final setup = _makeHandler( + harness: harness, + coin: Ethereum(CryptoCurrencyNetwork.main), + tokenSymbol: "USDT", + tokenDecimals: 6, + client: _mockOcpServer( + paymentInfo: _paymentInfoJson( + quoteExpiration: _futureExpiration(), + ethMinFee: minFee, + ), + txDetails: _erc20DetailsJson(), + ), + ); + await _handle(tester, harness, setup.handler); + return setup; + } + + Future feeFor( + WidgetTester tester, + _Harness harness, + OpenCryptoPaySendHandler handler, + Wallet wallet, { + String? address = _btcAddress, + FeeRateType feeRateType = FeeRateType.average, + int? satsPerVByte, + EthEIP1559Fee? ethFee, + String? tap, + String? message, + }) async { + final fut = handler.sendFee( + harness.context, + wallet, + address: address, + amount: _btc(1000), + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, + ethFee: ethFee, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + if (tap != null) { + expect(find.text(title), findsOneWidget); + if (message != null) expect(find.text(message), findsOneWidget); + await _tapButton(tester, tap); + await tester.pumpAndSettle(); + } else { + expect(find.text(title), findsNothing); + } + return fut; + } + + // sat/kB estimates: fast 5 sat/vB, average 3, slow 1. + final utxoWallet = _FakeUtxoWallet(fast: 5000, medium: 3000, slow: 1000); + const average = ( + feeRateType: FeeRateType.average, + satsPerVByte: null, + ethFee: null, + ); + + testWidgets("no override without a pending payment or a minimum", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final none = await pendingBtc(tester, harness); + final floor = await feeFor(tester, harness, none.handler, utxoWallet); + expect(floor, average); + + final other = await pendingBtc(tester, harness, minFee: 4); + final elsewhere = await feeFor( + tester, + harness, + other.handler, + utxoWallet, + address: "bc1qother", + ); + expect(elsewhere, average); + }); + + testWidgets("a preset at or above the minimum is kept", (tester) async { + final harness = await _pumpHarness(tester); + final setup = await pendingBtc(tester, harness, minFee: 3); + final floor = await feeFor(tester, harness, setup.handler, utxoWallet); + expect(floor, average); + + final custom = await feeFor( + tester, + harness, + setup.handler, + utxoWallet, + feeRateType: FeeRateType.custom, + satsPerVByte: 7, + ); + expect(custom!.satsPerVByte, 7); + }); + + testWidgets("a preset below the minimum is raised without asking", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingBtc(tester, harness, minFee: 4.2); + final floor = await feeFor( + tester, + harness, + setup.handler, + utxoWallet, + feeRateType: FeeRateType.slow, + ); + expect(floor!.feeRateType, FeeRateType.custom); + expect(floor.satsPerVByte, 5); + }); + + testWidgets("a custom rate below the minimum is raised", (tester) async { + final harness = await _pumpHarness(tester); + final setup = await pendingBtc(tester, harness, minFee: 4.2); + final floor = await feeFor( + tester, + harness, + setup.handler, + utxoWallet, + feeRateType: FeeRateType.custom, + satsPerVByte: 2, + ); + expect(floor!.satsPerVByte, 5); + }); + + testWidgets("the minimum is compared in sat/kB", (tester) async { + final harness = await _pumpHarness(tester); + final setup = await pendingBtc(tester, harness, minFee: 2.146); + final wallet = _FakeUtxoWallet(fast: 5000, medium: 2146, slow: 2100); + + final exact = await feeFor(tester, harness, setup.handler, wallet); + expect(exact, average); + + final under = await feeFor( + tester, + harness, + setup.handler, + wallet, + feeRateType: FeeRateType.slow, + ); + expect(under!.feeRateType, FeeRateType.custom); + expect(under.satsPerVByte, 3); + }); + + testWidgets("a minimum above the fast estimate asks first", (tester) async { + final harness = await _pumpHarness(tester); + final setup = await pendingBtc(tester, harness, minFee: 12); + + final cancelled = await feeFor( + tester, + harness, + setup.handler, + utxoWallet, + tap: "Cancel", + message: + "The payment request requires a network fee of at least " + "12 sats/vByte, above the current fast estimate of 5.00 sats/vByte.", + ); + expect(cancelled, isNull); + + final accepted = await feeFor( + tester, + harness, + setup.handler, + utxoWallet, + tap: "Continue", + ); + expect(accepted!.feeRateType, FeeRateType.custom); + expect(accepted.satsPerVByte, 12); + }); + + // Fee amounts per level id: slow 1, average 2, fast 3. + final levelWallet = _FakeLevelWallet({1: 1000, 2: 2000, 3: 3000}); + + testWidgets("a fee level at or above the minimum is kept", (tester) async { + final harness = await _pumpHarness(tester); + final setup = await pendingBtc(tester, harness, minFee: 1500); + final floor = await feeFor(tester, harness, setup.handler, levelWallet); + expect(floor, average); + }); + + testWidgets("the lowest fee level reaching the minimum is chosen", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingBtc(tester, harness, minFee: 2500); + final floor = await feeFor( + tester, + harness, + setup.handler, + levelWallet, + feeRateType: FeeRateType.slow, + ); + expect(floor!.feeRateType, FeeRateType.fast); + }); + + testWidgets("a minimum above the fastest level blocks the send", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingBtc(tester, harness, minFee: 3500); + final fut = setup.handler.sendFee( + harness.context, + levelWallet, + address: _btcAddress, + amount: _btc(1000), + feeRateType: FeeRateType.fast, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text("Network fee too low"), findsOneWidget); + expect( + find.text( + "The payment request requires a network fee of at least " + "0.0000000035 XMR, above this wallet's fastest fee of " + "0.000000003 XMR.", + ), + findsOneWidget, + ); + await _tapOk(tester); + await tester.pumpAndSettle(); + expect(await fut, isNull); + expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); + }); + + testWidgets("an unavailable fee estimate keeps the chosen fee", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingBtc(tester, harness, minFee: 12); + final floor = await feeFor( + tester, + harness, + setup.handler, + _FakeUtxoWallet.offline(), + ); + expect(floor, average); + }); + + // base 10 gwei, fast 12 gwei, slow 10.5 gwei. + final ethFees = _ethFees( + baseFee: 10000000000, + fast: 12000000000, + slow: 10500000000, + ); + final tokenWallet = _FakeTokenWallet(ethFees); + final gwei = BigInt.from(1000000000); + + testWidgets("an EVM preset at or above the minimum is kept", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingErc20(tester, harness, minFee: 12000000000); + const fast = ( + feeRateType: FeeRateType.fast, + satsPerVByte: null, + ethFee: null, + ); + final floor = await feeFor( + tester, + harness, + setup.handler, + tokenWallet, + address: _erc20Recipient, + feeRateType: FeeRateType.fast, + ); + expect(floor, fast); + + final customFee = EthEIP1559Fee( + maxFeePerGasGwei: Decimal.fromInt(15), + maxPriorityFeePerGasGwei: Decimal.one, + gasLimit: 90000, + ); + final custom = await feeFor( + tester, + harness, + setup.handler, + tokenWallet, + address: _erc20Recipient, + feeRateType: FeeRateType.custom, + ethFee: customFee, + ); + expect(custom!.ethFee, same(customFee)); + }); + + testWidgets( + "an EVM minimum below the fast estimate is raised without asking", + (tester) async { + final harness = await _pumpHarness(tester); + final setup = await pendingErc20(tester, harness, minFee: 11000000000); + final floor = await feeFor( + tester, + harness, + setup.handler, + tokenWallet, + address: _erc20Recipient, + feeRateType: FeeRateType.slow, + ); + expect(floor!.feeRateType, FeeRateType.custom); + final fee = floor.ethFee!; + expect(fee.maxFeePerGasWei, gwei * BigInt.from(11)); + expect(fee.maxPriorityFeePerGasWei, gwei); + expect(fee.gasLimit, kEthereumTokenMinGasLimit); + }, + ); + + testWidgets("an EVM custom fee below the minimum keeps its gas limit", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingErc20(tester, harness, minFee: 11000000000); + final floor = await feeFor( + tester, + harness, + setup.handler, + tokenWallet, + address: _erc20Recipient, + feeRateType: FeeRateType.custom, + ethFee: EthEIP1559Fee( + maxFeePerGasGwei: Decimal.fromInt(5), + maxPriorityFeePerGasGwei: Decimal.one, + gasLimit: 90000, + ), + ); + final fee = floor!.ethFee!; + expect(fee.maxFeePerGasWei, gwei * BigInt.from(11)); + expect(fee.maxPriorityFeePerGasWei, gwei); + expect(fee.gasLimit, 90000); + }); + + testWidgets("an EVM minimum above the fast estimate asks first", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingErc20(tester, harness, minFee: 30000000000); + const message = + "The payment request requires a network fee of at least " + "30.00 gwei, above the current fast estimate of 12.00 gwei."; + + final cancelled = await feeFor( + tester, + harness, + setup.handler, + _FakeEthWallet(ethFees), + address: _erc20Recipient, + feeRateType: FeeRateType.fast, + tap: "Cancel", + message: message, + ); + expect(cancelled, isNull); + + final accepted = await feeFor( + tester, + harness, + setup.handler, + _FakeEthWallet(ethFees), + address: _erc20Recipient, + feeRateType: FeeRateType.fast, + tap: "Continue", + message: message, + ); + expect(accepted!.feeRateType, FeeRateType.custom); + final fee = accepted.ethFee!; + expect(fee.maxFeePerGasWei, gwei * BigInt.from(30)); + expect(fee.maxPriorityFeePerGasWei, gwei * BigInt.from(20)); + expect(fee.gasLimit, kEthereumMinGasLimit); + }); + }); + group("OpenCryptoPaySendHandler.submitProof", () { testWidgets( "success clears the active payment and later calls become no-ops", From 09a572fa421be9f9e5326f70791964b9759c47ac Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 13:15:27 +0200 Subject: [PATCH 20/49] chore: bump opencryptopay --- 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 54363ef58d..f342777e06 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -279,7 +279,7 @@ dependencies: opencryptopay: git: url: https://github.com/Cyrix126/opencryptopay - ref: a4a14e4d6961d3e7cfc4827ee52ec03f48162a90 + ref: 24d6fae0f2e016123213f4a73670267e1a9d9166 dev_dependencies: flutter_test: From 846fd1b4f704faa5847aacdf4a8b144ede0193e0 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 13:39:37 +0200 Subject: [PATCH 21/49] fix: record OpenCryptoPay hex payments locally --- .../send_view/confirm_transaction_view.dart | 29 ++++++++++++------- lib/wallets/wallet/impl/ethereum_wallet.dart | 24 +++++++++++---- .../impl/sub_wallets/eth_token_wallet.dart | 20 +++++++++---- 3 files changed, 50 insertions(+), 23 deletions(-) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 275eee251c..b0973b48af 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -702,22 +702,29 @@ class _ConfirmTransactionViewState return; } - final txid = widget.txData.tempTx?.txid; - - await _markInputsAsUsed(); + // The provider holds the signed transaction, so the payment is complete + // even if recording it locally fails. + try { + await _markInputsAsUsed(); + if (widget.txData.tempTx != null) { + await wallet.updateSentCachedTxData(txData: widget.txData); + } + final txid = widget.txData.tempTx?.txid; + if (txid != null) { + await _saveNote(txid: txid, note: noteController.text); + } + } catch (e, s) { + Logging.instance.e( + "Failed to record the submitted OpenCryptoPay transaction", + error: e, + stackTrace: s, + ); + } 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(); diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index 9860c678f1..67a9e4aff6 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -174,6 +174,10 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { return web3.bytesToHex(signed, include0x: true, padToEvenLength: true); } + /// The transaction id of the signed transaction hex [raw]. + String txidOfSignedHex(String raw) => + web3.bytesToHex(web3.keccak256(web3.hexToBytes(raw)), include0x: true); + Amount estimateEthFee(BigInt feeRate, int gasLimit, int decimals) { final gweiAmount = feeRate.toDecimal() / (Decimal.ten.pow(9).toDecimal()); final fee = @@ -695,12 +699,20 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { cryptoCurrency.fractionDigits, ); - return txData.copyWith( - nonce: tx.nonce, - web3dartTransaction: tx, - fee: feeEstimate, - chainId: prep.chainId, - raw: await signWeb3TransactionToHex(tx: tx, chainId: prep.chainId), + final raw = await signWeb3TransactionToHex(tx: tx, chainId: prep.chainId); + final txid = txidOfSignedHex(raw); + + return _prepareTempTx( + txData.copyWith( + nonce: tx.nonce, + web3dartTransaction: tx, + fee: feeEstimate, + chainId: prep.chainId, + raw: raw, + txid: txid, + txHash: txid, + ), + (await getCurrentReceivingAddress())!.value, ); } 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 7f00d40246..e799894d80 100644 --- a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart @@ -263,15 +263,23 @@ class EthTokenWallet extends Wallet { gasLimit, cryptoCurrency.fractionDigits, ); - return txData.copyWith( - fee: feeEstimate, - web3dartTransaction: tx, + final raw = await ethWallet.signWeb3TransactionToHex( + tx: tx, chainId: prep.chainId, - nonce: tx.nonce, - raw: await ethWallet.signWeb3TransactionToHex( - tx: tx, + ); + final txid = ethWallet.txidOfSignedHex(raw); + + return _prepareTempTx( + txData.copyWith( + fee: feeEstimate, + web3dartTransaction: tx, chainId: prep.chainId, + nonce: tx.nonce, + raw: raw, + txid: txid, + txHash: txid, ), + (await ethWallet.getCurrentReceivingAddress())!.value, ); } From 688c10b93988d936efa24e7d53be4fe48c4fccc8 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 13:53:07 +0200 Subject: [PATCH 22/49] fix: formatting --- .../send_view/confirm_transaction_view.dart | 150 +++++++++--------- .../sending_transaction_dialog.dart | 37 ++--- .../sub_widgets/desktop_sol_token_send.dart | 3 +- .../sub_widgets/desktop_token_send.dart | 3 +- 4 files changed, 88 insertions(+), 105 deletions(-) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index b0973b48af..806ef81dce 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -199,9 +199,8 @@ class _ConfirmTransactionViewState if (context.mounted) { widget.onSuccess.call(); if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -278,9 +277,8 @@ class _ConfirmTransactionViewState if (context.mounted) { widget.onSuccess.call(); if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -565,9 +563,9 @@ class _ConfirmTransactionViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), onPressed: () { @@ -844,13 +842,13 @@ class _ConfirmTransactionViewState condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, leading: AppBarBackButton( onPressed: () async { // if (FocusScope.of(context).hasFocus) { @@ -1082,18 +1080,18 @@ class _ConfirmTransactionViewState ), child: RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: Theme.of( - context, - ).extension()!.background, + borderColor: Theme.of(context) + .extension()! + .background, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, borderRadius: BorderRadius.only( topLeft: Radius.circular( Constants.size.circularBorderRadius, @@ -1260,9 +1258,9 @@ class _ConfirmTransactionViewState ), Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), Padding( padding: const EdgeInsets.all(12), @@ -1288,9 +1286,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1299,9 +1297,9 @@ class _ConfirmTransactionViewState if (businessDetails.isNotEmpty) Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), if (businessDetails.isNotEmpty) Padding( @@ -1313,9 +1311,9 @@ class _ConfirmTransactionViewState if (widget.isPaynymTransaction) Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), if (widget.isPaynymTransaction) Padding( @@ -1337,9 +1335,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1348,9 +1346,9 @@ class _ConfirmTransactionViewState if (coin is Ethereum) Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), if (coin is Ethereum) Padding( @@ -1372,9 +1370,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1505,9 +1503,9 @@ class _ConfirmTransactionViewState focusNode: _noteFocusNode, style: STextStyles.desktopTextExtraSmall(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of(context) + .extension()! + .textFieldActiveText, height: 1.8, ), onChanged: (_) => setState(() {}), @@ -1567,9 +1565,9 @@ class _ConfirmTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: SelectableText( ref.watch(pAmountFormatter(coin)).format(fee!), style: STextStyles.itemSubtitle(context), @@ -1598,9 +1596,9 @@ class _ConfirmTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: SelectableText( "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", style: STextStyles.itemSubtitle(context), @@ -1618,22 +1616,21 @@ class _ConfirmTransactionViewState padding: isDesktop ? const EdgeInsets.symmetric(horizontal: 16, vertical: 18) : const EdgeInsets.all(12), - color: Theme.of( - context, - ).extension()!.snackBarBackSuccess, + color: Theme.of(context) + .extension()! + .snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( isDesktop ? "Total amount to send" : "Total amount", style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) : STextStyles.titleBold12(context).copyWith( color: Theme.of(context) .extension()! @@ -1645,13 +1642,12 @@ class _ConfirmTransactionViewState .watch(pAmountFormatter(coin)) .format(amountWithoutChange + fee!), style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) : STextStyles.itemSubtitle12(context).copyWith( color: Theme.of(context) .extension()! @@ -1669,16 +1665,16 @@ class _ConfirmTransactionViewState ? const EdgeInsets.symmetric(horizontal: 32, vertical: 8) : const EdgeInsets.symmetric(vertical: 8), child: RoundedContainer( - color: Theme.of( - context, - ).extension()!.warningBackground, + color: Theme.of(context) + .extension()! + .warningBackground, child: Row( children: [ Icon( Icons.warning_amber_rounded, - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, size: 20, ), const SizedBox(width: 8), @@ -1687,9 +1683,9 @@ class _ConfirmTransactionViewState "This transaction spends a UTXO containing " "an ordinal inscription.", style: STextStyles.smallMed12(context).copyWith( - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, ), ), ), diff --git a/lib/pages/send_view/sub_widgets/sending_transaction_dialog.dart b/lib/pages/send_view/sub_widgets/sending_transaction_dialog.dart index 59e8fa5953..e88a0dc09b 100644 --- a/lib/pages/send_view/sub_widgets/sending_transaction_dialog.dart +++ b/lib/pages/send_view/sub_widgets/sending_transaction_dialog.dart @@ -57,11 +57,7 @@ class _RestoringDialogState extends ConsumerState { @override Widget build(BuildContext context) { - final assetPath = ref.watch( - coinImageSecondaryProvider( - widget.coin, - ), - ); + final assetPath = ref.watch(coinImageSecondaryProvider(widget.coin)); return ValueListenableBuilder( valueListenable: widget.controller.message, @@ -82,15 +78,9 @@ class _RestoringDialogState extends ConsumerState { "Sending transaction", style: STextStyles.desktopH3(context), ), - const SizedBox( - height: 40, - ), + const SizedBox(height: 40), assetPath.endsWith(".gif") - ? Flexible( - child: Image.file( - File(assetPath), - ), - ) + ? Flexible(child: Image.file(File(assetPath))) : ProgressAndSuccess( controller: _progressAndSuccessController!, ), @@ -112,9 +102,7 @@ class _RestoringDialogState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Image.file( - File(assetPath), - ), + Image.file(File(assetPath)), Text( "Sending transaction", textAlign: TextAlign.center, @@ -127,9 +115,7 @@ class _RestoringDialogState extends ConsumerState { textAlign: TextAlign.center, style: STextStyles.smallMed14(context), ), - const SizedBox( - height: 32, - ), + const SizedBox(height: 32), ], ), ) @@ -221,13 +207,15 @@ class _ProgressAndSuccessState extends State values: [ ValueDelegate.color( const ["**"], - value: - Theme.of(context).extension()!.accentColorDark, + value: Theme.of(context) + .extension()! + .accentColorDark, ), ValueDelegate.strokeColor( const ["**"], - value: - Theme.of(context).extension()!.accentColorDark, + value: Theme.of(context) + .extension()! + .accentColorDark, ), ], ), @@ -253,7 +241,8 @@ class _ProgressAndSuccessState extends State height: widget.height, onLoaded: (composition) { setState(() { - controller2.duration = composition.duration * + controller2.duration = + composition.duration * (composition.markers.last.end - composition.markers[1].start); controller2.value = composition.markers[1].start; }); 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 7e1d4c749b..673c4852be 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 @@ -1083,8 +1083,7 @@ class _DesktopSolTokenSendState extends ConsumerState { ), if (sendToController.text.isEmpty) TextFieldIconButton( - semanticsLabel: - "Scan QR Button. Opens Camera For Scanning QR Code.", + semanticsLabel: "Scan QR Button. Opens Camera For Scanning QR Code.", key: const Key("sendViewScanQrButtonKey"), onTap: scanQr, child: const QrCodeIcon(), 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 f30156ab8f..8add221bd5 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 @@ -1097,8 +1097,7 @@ class _DesktopTokenSendState extends ConsumerState { ), if (sendToController.text.isEmpty) TextFieldIconButton( - semanticsLabel: - "Scan QR Button. Opens Camera For Scanning QR Code.", + semanticsLabel: "Scan QR Button. Opens Camera For Scanning QR Code.", key: const Key("sendViewScanQrButtonKey"), onTap: scanQr, child: const QrCodeIcon(), From 6e2c806dd6c08d68ea6a24757d2397a288951f04 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 14:17:04 +0200 Subject: [PATCH 23/49] chore: bump opencryptopay --- 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 f342777e06..832df55689 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -279,7 +279,7 @@ dependencies: opencryptopay: git: url: https://github.com/Cyrix126/opencryptopay - ref: 24d6fae0f2e016123213f4a73670267e1a9d9166 + ref: b4466c483d3a700c3d03b3c8720503caf439da86 dev_dependencies: flutter_test: From c9fdb05676b330151a6dcf56118ee943afeaa49d Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 14:19:09 +0200 Subject: [PATCH 24/49] fix: long line --- .../open_crypto_pay/open_crypto_pay_send_fee.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart index cf1402923e..bed6826acb 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart @@ -113,9 +113,14 @@ Future _levelSendFee( } } final fastest = fee!; - String coins(BigInt raw) => - "${Amount(rawValue: raw, fractionDigits: fastest.fractionDigits).decimal} " - "${wallet.cryptoCurrency.ticker}"; + String coins(BigInt raw) { + final amount = Amount( + rawValue: raw, + fractionDigits: fastest.fractionDigits, + ); + return "${amount.decimal} ${wallet.cryptoCurrency.ticker}"; + } + if (!context.mounted) return null; await unmet( context, From 84a16ba83214227933255e341f587db8c9a121da Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 15:39:46 +0200 Subject: [PATCH 25/49] fix: default null fields before raw ETH signing --- lib/wallets/wallet/impl/ethereum_wallet.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index 67a9e4aff6..bbb444eb59 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -160,11 +160,13 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { 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. + // Fill the defaults Web3Client.signTransaction applies. + final complete = tx.copyWith( + value: tx.value ?? eth_wallet.EtherAmount.zero(), + data: tx.data ?? Uint8List(0), + ); var signed = web3.signTransactionRaw( - tx, + complete, _credentials!, chainId: chainId.toInt(), ); From 61b1b6e4a092b6fb076b04d087be22eab2b790a6 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 15:44:00 +0200 Subject: [PATCH 26/49] docs: drop stale proof retry comment --- lib/pages/open_crypto_pay/open_crypto_pay_send_handler.dart | 1 - 1 file changed, 1 deletion(-) 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 5347018a42..0eb2280991 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 @@ -314,7 +314,6 @@ class OpenCryptoPaySendHandler { await showQuoteExpiredError(context, paymentNotSent: true); return false; case OpenCryptoPayProofFailed(:final error, :final providerAnswered): - // The session stays active so the user can retry. Logging.instance.w( "OpenCryptoPay proof submission failed", error: error, From e0cd6c3ad134c082330270cb3e6f2fcf0f206ba8 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 18:30:12 +0200 Subject: [PATCH 27/49] fix: prefill OpenCryptoPay amount in editable form --- lib/pages/send_view/send_view.dart | 2 +- lib/pages/send_view/token_send_view.dart | 2 +- .../my_stack_view/wallet_view/sub_widgets/desktop_send.dart | 2 +- .../wallet_view/sub_widgets/desktop_token_send.dart | 6 +----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 50dae7aea5..6ec97168d1 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -1353,7 +1353,7 @@ class _SendViewState extends ConsumerState { onAmountReceived: (parsed) { cryptoAmountController.text = ref .read(pAmountFormatter(coin)) - .format(parsed, withUnitName: false); + .formatEditable(parsed); ref.read(pSendAmount.notifier).state = parsed; }, setValidAddress: _openCryptoPaySetValidAddress, diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index d5b74c9926..989c712fbc 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -660,7 +660,7 @@ class _TokenSendViewState extends ConsumerState { onAmountReceived: (parsed) { cryptoAmountController.text = ref .read(pAmountFormatter(coin)) - .format(parsed, tokenContract: tokenContract, withUnitName: false); + .formatEditable(parsed); _amountToSend = parsed; _updatePreviewButtonState(_address, parsed); }, 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 07d66e957a..6b99e7dd22 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 @@ -1306,7 +1306,7 @@ class _DesktopSendState extends ConsumerState { onAmountReceived: (parsed) { cryptoAmountController.text = ref .read(pAmountFormatter(coin)) - .format(parsed, withUnitName: false); + .formatEditable(parsed); ref.read(pSendAmount.notifier).state = parsed; }, setValidAddress: _openCryptoPaySetValidAddress, 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 8add221bd5..2d47f0c6c4 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 @@ -687,11 +687,7 @@ class _DesktopTokenSendState extends ConsumerState { onAmountReceived: (parsed) { cryptoAmountController.text = ref .read(pAmountFormatter(coin)) - .format( - parsed, - tokenContract: ref.read(pCurrentTokenWallet)!.tokenContract, - withUnitName: false, - ); + .formatEditable(parsed); _amountToSend = parsed; _updatePreviewButtonState(_address, parsed); }, From be5d31ac536cd4ef0bd3078b797acd5ef92ebb5b Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 18:44:48 +0200 Subject: [PATCH 28/49] fix: give the OpenCryptoPay EVM fee cap base fee headroom --- lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart | 6 ++++-- .../open_crypto_pay/open_crypto_pay_send_handler_test.dart | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart index bed6826acb..1e9a096313 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart @@ -191,14 +191,16 @@ Future _evmSendFee( )) { return null; } - // The priority fee tops the base fee up to the minimum gas price. + // The priority fee tops the base fee up to the minimum gas price; the cap + // gets the same base fee headroom as the presets. var priorityWei = minWei - fees.suggestBaseFee; if (priorityWei.isNegative) priorityWei = BigInt.zero; + final maxFeeWei = fees.suggestBaseFee * BigInt.two + priorityWei; return ( feeRateType: FeeRateType.custom, satsPerVByte: chosen.satsPerVByte, ethFee: EthEIP1559Fee( - maxFeePerGasGwei: gwei(minWei), + maxFeePerGasGwei: gwei(maxFeeWei), maxPriorityFeePerGasGwei: gwei(priorityWei), gasLimit: chosen.ethFee?.gasLimit ?? 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 136b4c09d6..63f1dd1656 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 @@ -1163,7 +1163,7 @@ void main() { ); expect(floor!.feeRateType, FeeRateType.custom); final fee = floor.ethFee!; - expect(fee.maxFeePerGasWei, gwei * BigInt.from(11)); + expect(fee.maxFeePerGasWei, gwei * BigInt.from(21)); expect(fee.maxPriorityFeePerGasWei, gwei); expect(fee.gasLimit, kEthereumTokenMinGasLimit); }, @@ -1188,7 +1188,7 @@ void main() { ), ); final fee = floor!.ethFee!; - expect(fee.maxFeePerGasWei, gwei * BigInt.from(11)); + expect(fee.maxFeePerGasWei, gwei * BigInt.from(21)); expect(fee.maxPriorityFeePerGasWei, gwei); expect(fee.gasLimit, 90000); }); @@ -1226,7 +1226,7 @@ void main() { ); expect(accepted!.feeRateType, FeeRateType.custom); final fee = accepted.ethFee!; - expect(fee.maxFeePerGasWei, gwei * BigInt.from(30)); + expect(fee.maxFeePerGasWei, gwei * BigInt.from(40)); expect(fee.maxPriorityFeePerGasWei, gwei * BigInt.from(20)); expect(fee.gasLimit, kEthereumMinGasLimit); }); From c7c6202b3396afcc64999c876d8d41732be1a711 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 18:52:42 +0200 Subject: [PATCH 29/49] fix: skip the OpenCryptoPay fee raise for Firo private sends --- .../open_crypto_pay_send_handler.dart | 5 ++++- lib/pages/send_view/send_view.dart | 3 +++ .../wallet_view/sub_widgets/desktop_send.dart | 3 +++ .../open_crypto_pay_send_handler_test.dart | 17 +++++++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) 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 0eb2280991..359b39f5be 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 @@ -165,6 +165,7 @@ class OpenCryptoPaySendHandler { /// The fee to build the transaction with: the given one, raised to the /// payment request's minimum when below it. Null when the send must stop. + /// [feeRateApplies] is false when the send builds its own fee. Future sendFee( BuildContext context, Wallet wallet, { @@ -173,6 +174,7 @@ class OpenCryptoPaySendHandler { required FeeRateType feeRateType, int? satsPerVByte, EthEIP1559Fee? ethFee, + bool feeRateApplies = true, }) async { final chosen = ( feeRateType: feeRateType, @@ -180,7 +182,8 @@ class OpenCryptoPaySendHandler { ethFee: ethFee, ); final session = _session; - if (session == null || + if (!feeRateApplies || + session == null || session.minFee <= 0 || !session.isActivePaymentFor(address)) { return chosen; diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 6ec97168d1..82f4c0423f 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -945,6 +945,9 @@ class _SendViewState extends ConsumerState { feeRateType: chosenRateType, satsPerVByte: chosenRateType.customSatsPerVByte(customFeeRate), ethFee: _ethFee.value, + feeRateApplies: + coin is! Firo || + ref.read(publicPrivateBalanceStateProvider) == BalanceType.public, ); if (fee == null || !mounted) return; 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 6b99e7dd22..2e4332915f 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 @@ -561,6 +561,9 @@ class _DesktopSendState extends ConsumerState { feeRateType: chosenRateType, satsPerVByte: chosenRateType.customSatsPerVByte(customFeeRate), ethFee: _ethFee.value, + feeRateApplies: + coin is! Firo || + ref.read(publicPrivateBalanceStateProvider) == BalanceType.public, ); if (fee == null || !mounted) return; 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 63f1dd1656..2e898ecd7f 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 @@ -889,6 +889,7 @@ void main() { FeeRateType feeRateType = FeeRateType.average, int? satsPerVByte, EthEIP1559Fee? ethFee, + bool feeRateApplies = true, String? tap, String? message, }) async { @@ -900,6 +901,7 @@ void main() { feeRateType: feeRateType, satsPerVByte: satsPerVByte, ethFee: ethFee, + feeRateApplies: feeRateApplies, ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); @@ -941,6 +943,21 @@ void main() { expect(elsewhere, average); }); + testWidgets("a send building its own fee keeps the chosen one", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingBtc(tester, harness, minFee: 20); + final floor = await feeFor( + tester, + harness, + setup.handler, + utxoWallet, + feeRateApplies: false, + ); + expect(floor, average); + }); + testWidgets("a preset at or above the minimum is kept", (tester) async { final harness = await _pumpHarness(tester); final setup = await pendingBtc(tester, harness, minFee: 3); From 8de67d29ef5ad56194f967a17b1ba5af4113c410 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 19:05:50 +0200 Subject: [PATCH 30/49] fix: disable preview while the OpenCryptoPay fee check runs --- lib/pages/send_view/send_view.dart | 7 ++++++- lib/pages/send_view/token_send_view.dart | 7 ++++++- .../wallet_view/sub_widgets/desktop_send.dart | 7 ++++++- .../wallet_view/sub_widgets/desktop_token_send.dart | 7 ++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 82f4c0423f..ef6c5e7bbf 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -160,6 +160,7 @@ class _SendViewState extends ConsumerState { Set selectedUTXOs = {}; late final OpenCryptoPaySendHandler _openCryptoPay; + bool _feeCheckPending = false; void _openCryptoPaySetValidAddress(String address) { _address = address; @@ -937,6 +938,7 @@ class _SendViewState extends ConsumerState { final chosenRateType = ref.read(feeRateTypeMobileStateProvider); if (!mounted) return; + setState(() => _feeCheckPending = true); final fee = await _openCryptoPay.sendFee( context, wallet, @@ -949,7 +951,9 @@ class _SendViewState extends ConsumerState { coin is! Firo || ref.read(publicPrivateBalanceStateProvider) == BalanceType.public, ); - if (fee == null || !mounted) return; + if (!mounted) return; + setState(() => _feeCheckPending = false); + if (fee == null) return; try { bool wasCancelled = false; @@ -2806,6 +2810,7 @@ class _SendViewState extends ConsumerState { builder: (context, ethFee, _) { final enabled = previewEnabled && + !_feeCheckPending && (!needsEthFee || ethFee != null); return TextButton( onPressed: enabled diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 989c712fbc..b8367f569b 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -123,6 +123,7 @@ class _TokenSendViewState extends ConsumerState { final _ethFee = ValueNotifier(null); late final OpenCryptoPaySendHandler _openCryptoPay; + bool _feeCheckPending = false; void _openCryptoPaySetValidAddress(String address) { _address = address; @@ -483,6 +484,7 @@ class _TokenSendViewState extends ConsumerState { final feeRateType = ref.read(feeRateTypeMobileStateProvider); if (!mounted) return; + setState(() => _feeCheckPending = true); final fee = await _openCryptoPay.sendFee( context, tokenWallet, @@ -491,7 +493,9 @@ class _TokenSendViewState extends ConsumerState { feeRateType: feeRateType, ethFee: _ethFee.value, ); - if (fee == null || !mounted) return; + if (!mounted) return; + setState(() => _feeCheckPending = false); + if (fee == null) return; try { bool wasCancelled = false; @@ -1381,6 +1385,7 @@ class _TokenSendViewState extends ConsumerState { builder: (context, ethFee, _) { final enabled = previewEnabled && + !_feeCheckPending && (!needsEthFee || ethFee != null); return TextButton( onPressed: enabled 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 2e4332915f..32770d11c9 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 @@ -130,6 +130,7 @@ class _DesktopSendState extends ConsumerState { late final bool isMimblewimblecoin; late final bool isEpiccash; late final OpenCryptoPaySendHandler _openCryptoPay; + bool _feeCheckPending = false; String? _note; String? _onChainNote; @@ -553,6 +554,7 @@ class _DesktopSendState extends ConsumerState { final chosenRateType = ref.read(feeRateTypeDesktopStateProvider); if (!mounted) return; + setState(() => _feeCheckPending = true); final fee = await _openCryptoPay.sendFee( context, wallet, @@ -565,7 +567,9 @@ class _DesktopSendState extends ConsumerState { coin is! Firo || ref.read(publicPrivateBalanceStateProvider) == BalanceType.public, ); - if (fee == null || !mounted) return; + if (!mounted) return; + setState(() => _feeCheckPending = false); + if (fee == null) return; try { bool wasCancelled = false; @@ -2281,6 +2285,7 @@ class _DesktopSendState extends ConsumerState { builder: (context, ethFee, _) { final enabled = previewEnabled && + !_feeCheckPending && _nonceIsValid && (!needsEthFee || ethFee != null); return PrimaryButton( 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 2d47f0c6c4..f1289ec315 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 @@ -119,6 +119,7 @@ class _DesktopTokenSendState extends ConsumerState { bool get _nonceIsValid => _nonceInput.isValid; late final OpenCryptoPaySendHandler _openCryptoPay; + bool _feeCheckPending = false; void _openCryptoPaySetValidAddress(String address) { _address = address; @@ -222,6 +223,7 @@ class _DesktopTokenSendState extends ConsumerState { final feeRateType = ref.read(feeRateTypeDesktopStateProvider); if (!mounted) return; + setState(() => _feeCheckPending = true); final fee = await _openCryptoPay.sendFee( context, tokenWallet, @@ -230,7 +232,9 @@ class _DesktopTokenSendState extends ConsumerState { feeRateType: feeRateType, ethFee: _ethFee.value, ); - if (fee == null || !mounted) return; + if (!mounted) return; + setState(() => _feeCheckPending = false); + if (fee == null) return; try { bool wasCancelled = false; @@ -1204,6 +1208,7 @@ class _DesktopTokenSendState extends ConsumerState { builder: (context, ethFee, _) { final enabled = previewEnabled && + !_feeCheckPending && _nonceIsValid && (!needsEthFee || ethFee != null); return PrimaryButton( From 9425b4dcf0e5d0f1004efc2f22e037e73f2a81f2 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 19:12:13 +0200 Subject: [PATCH 31/49] fix: block an OpenCryptoPay send when the fee cannot be estimated --- .../open_crypto_pay_send_fee.dart | 37 +++++++++++- .../open_crypto_pay_send_handler_test.dart | 56 +++++++++++++++++-- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart index 1e9a096313..53ffcba93d 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart @@ -17,6 +17,10 @@ String _highFeeMessage(String required, String fast) => "The payment request requires a network fee of at least $required, " "above the current fast estimate of $fast."; const _unmetFeeTitle = "Network fee too low"; +const _unknownFeeTitle = "Network fee unknown"; +const _unknownFeeMessage = + "The network fee could not be estimated, so the payment request's " + "minimum cannot be checked. Check the wallet's connection and sync."; String _unmetFeeMessage(String required, String fastest) => "The payment request requires a network fee of at least $required, " "above this wallet's fastest fee of $fastest."; @@ -43,7 +47,8 @@ typedef OpenCryptoPayNotify = Future Function( ); /// The chosen fee, raised to the minimum when below it. Null when the user -/// cancelled the confirmation or no fee level reaches the minimum. +/// cancelled the confirmation, no fee level reaches the minimum, or the fee +/// cannot be estimated. Future openCryptoPaySendFee( BuildContext context, Wallet wallet, { @@ -64,7 +69,8 @@ Future openCryptoPaySendFee( error: e, stackTrace: s, ); - return chosen; + if (!context.mounted) return null; + return _feeUnknown(context, unmet); } if (!context.mounted) return null; if (isUtxo) return _utxoSendFee(context, fees, minFee, chosen, confirm); @@ -101,7 +107,23 @@ Future _levelSendFee( final start = levels.indexWhere((l) => l.type == chosen.feeRateType); Amount? fee; for (final level in levels.sublist(start < 0 ? 0 : start)) { - fee = await wallet.estimateFeeFor(amount, level.rate); + try { + fee = await wallet.estimateFeeFor(amount, level.rate); + } catch (e, s) { + Logging.instance.w( + "OpenCryptoPay fee estimate unavailable", + error: e, + stackTrace: s, + ); + if (!context.mounted) return null; + return _feeUnknown(context, unmet); + } + // A zero estimate means the wallet cannot estimate yet. + if (fee.raw <= BigInt.zero) { + Logging.instance.w("OpenCryptoPay fee estimate is zero"); + if (!context.mounted) return null; + return _feeUnknown(context, unmet); + } if (fee.raw >= required) { return level.type == chosen.feeRateType ? chosen @@ -130,6 +152,15 @@ Future _levelSendFee( return null; } +/// Stops the send when the fee cannot be checked against the minimum. +Future _feeUnknown( + BuildContext context, + OpenCryptoPayNotify unmet, +) async { + await unmet(context, _unknownFeeTitle, _unknownFeeMessage); + return null; +} + Future _utxoSendFee( BuildContext context, FeeObject fees, 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 2e898ecd7f..ce934ddbda 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 @@ -1105,18 +1105,66 @@ void main() { expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); }); - testWidgets("an unavailable fee estimate keeps the chosen fee", ( - tester, + Future expectFeeUnknown( + WidgetTester tester, + _Harness harness, + OpenCryptoPaySendHandler handler, + Wallet wallet, ) async { + final fut = handler.sendFee( + harness.context, + wallet, + address: _btcAddress, + amount: _btc(1000), + feeRateType: FeeRateType.average, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text("Network fee unknown"), findsOneWidget); + expect( + find.text( + "The network fee could not be estimated, so the payment request's " + "minimum cannot be checked. Check the wallet's connection and sync.", + ), + findsOneWidget, + ); + await _tapOk(tester); + await tester.pumpAndSettle(); + expect(await fut, isNull); + } + + testWidgets("unavailable fee levels block the send", (tester) async { final harness = await _pumpHarness(tester); final setup = await pendingBtc(tester, harness, minFee: 12); - final floor = await feeFor( + await expectFeeUnknown( tester, harness, setup.handler, _FakeUtxoWallet.offline(), ); - expect(floor, average); + }); + + testWidgets("a failing fee estimate blocks the send", (tester) async { + final harness = await _pumpHarness(tester); + final setup = await pendingBtc(tester, harness, minFee: 1500); + // No level is known, so every estimate throws. + await expectFeeUnknown( + tester, + harness, + setup.handler, + _FakeLevelWallet({}), + ); + }); + + testWidgets("a zero fee estimate blocks the send", (tester) async { + final harness = await _pumpHarness(tester); + final setup = await pendingBtc(tester, harness, minFee: 1500); + await expectFeeUnknown( + tester, + harness, + setup.handler, + _FakeLevelWallet({1: 0, 2: 0, 3: 0}), + ); }); // base 10 gwei, fast 12 gwei, slow 10.5 gwei. From 74b4cfd9d8b709889f4ae8e18059e822cb1b7037 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 19:17:07 +0200 Subject: [PATCH 32/49] fix: refuse an OpenCryptoPay request for another token contract --- .../open_crypto_pay_send_handler.dart | 24 ++++++++++++++ lib/pages/send_view/token_send_view.dart | 1 + .../sub_widgets/desktop_token_send.dart | 1 + .../open_crypto_pay_send_handler_test.dart | 32 +++++++++++++++++++ 4 files changed, 58 insertions(+) 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 359b39f5be..3c5258bd87 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 @@ -55,6 +55,12 @@ CryptoCoin cryptoCoinFor(CryptoCurrency currency, {String? tokenSymbol}) => typedef BusinessDetail = ({String label, String value, Uri? uri}); +const _tokenMismatchTitle = "Different token"; +const _tokenMismatchMessage = + "The payment request is for a token with a different contract address " + "than this wallet's token. Scan the code from the wallet holding that " + "token."; + class OpenCryptoPaySendHandler { OpenCryptoPaySendHandler({ required this.coin, @@ -63,6 +69,7 @@ class OpenCryptoPaySendHandler { required this.setValidAddress, this.tokenSymbol, this.tokenDecimals, + this.tokenContractAddress, @visibleForTesting OpenCryptoPayController? controller, }) : _controller = controller ?? @@ -81,6 +88,9 @@ class OpenCryptoPaySendHandler { final String? tokenSymbol; final int? tokenDecimals; + /// Set for token wallets; a request for another contract is refused. + final String? tokenContractAddress; + int get _fractionDigits => tokenDecimals ?? coin.fractionDigits; final OpenCryptoPayController _controller; @@ -276,11 +286,25 @@ class OpenCryptoPaySendHandler { ); case OpenCryptoPaySuccess() when result.session.isQuoteExpired: await showQuoteExpiredError(context); + case OpenCryptoPaySuccess() when _isOtherToken(result): + await _showError( + context: context, + title: _tokenMismatchTitle, + message: _tokenMismatchMessage, + ); case OpenCryptoPaySuccess(): _applySuccess(result); } } + bool _isOtherToken(OpenCryptoPaySuccess result) { + final requested = result.tokenContractAddress; + final held = tokenContractAddress; + return requested != null && + held != null && + requested.toLowerCase() != held.toLowerCase(); + } + void _applySuccess(OpenCryptoPaySuccess result) { _session = result.session; diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index b8367f569b..13ac84e58a 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -671,6 +671,7 @@ class _TokenSendViewState extends ConsumerState { setValidAddress: _openCryptoPaySetValidAddress, tokenSymbol: tokenContract.symbol, tokenDecimals: tokenContract.decimals, + tokenContractAddress: tokenContract.address, ); 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 f1289ec315..b9b8b64aec 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 @@ -698,6 +698,7 @@ class _DesktopTokenSendState extends ConsumerState { setValidAddress: _openCryptoPaySetValidAddress, tokenSymbol: tokenContract?.symbol, tokenDecimals: tokenContract?.decimals, + tokenContractAddress: tokenContract?.address, ); _cryptoFocus.addListener(() { 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 ce934ddbda..37a4712514 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 @@ -317,6 +317,7 @@ _HandlerSetup _makeHandler({ required Client client, String? tokenSymbol, int? tokenDecimals, + String? tokenContractAddress, }) { final sendTo = TextEditingController(); final amount = TextEditingController(); @@ -333,6 +334,7 @@ _HandlerSetup _makeHandler({ setValidAddress: validAddresses.add, tokenSymbol: tokenSymbol, tokenDecimals: tokenDecimals, + tokenContractAddress: tokenContractAddress, controller: OpenCryptoPayController( service: OpenCryptoPayService(client: client), ), @@ -539,6 +541,8 @@ void main() { coin: Ethereum(CryptoCurrencyNetwork.main), tokenSymbol: "USDT", tokenDecimals: 6, + // Checksum case; the request names it in lower case. + tokenContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", client: _mockOcpServer( paymentInfo: _paymentInfoJson(quoteExpiration: _futureExpiration()), txDetails: _erc20DetailsJson(), @@ -554,6 +558,34 @@ void main() { expect(setup.handler.isActivePaymentFor(_erc20Recipient), isTrue); }); + testWidgets("a request for another token contract is refused", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = _makeHandler( + harness: harness, + coin: Ethereum(CryptoCurrencyNetwork.main), + tokenSymbol: "USDT", + tokenDecimals: 6, + tokenContractAddress: "0x1111111111111111111111111111111111111111", + client: _mockOcpServer( + paymentInfo: _paymentInfoJson(quoteExpiration: _futureExpiration()), + txDetails: _erc20DetailsJson(), + ), + ); + + final fut = setup.handler.handle(harness.context, _qrLink); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text("Different token"), findsOneWidget); + await _tapOk(tester); + await tester.pumpAndSettle(); + await fut; + + expect(setup.sendTo.text, isEmpty); + expect(setup.handler.isActivePaymentFor(_erc20Recipient), isFalse); + }); + testWidgets("expired quote at fetch shows the expiry dialog and does not " "prefill the form", (tester) async { final harness = await _pumpHarness(tester); From 84a4936dbd6b12296c38ef33b591290a29ebaf1f Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 19:23:03 +0200 Subject: [PATCH 33/49] fix: mark MWEB inputs used after an OpenCryptoPay hex proof --- lib/pages/send_view/confirm_transaction_view.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 806ef81dce..e3d3d70264 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -18,6 +18,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; +import '../../db/drift/database.dart'; import '../../models/input.dart'; import '../../models/isar/models/isar_models.dart'; import '../../models/isar/models/transaction_note.dart'; @@ -640,6 +641,19 @@ class _ConfirmTransactionViewState if (sparkCoins != null && sparkCoins.isNotEmpty) { await db.isar.writeTxn(() => db.isar.sparkCoins.putAll(sparkCoins)); } + + final mwebUtxos = widget.txData.usedUTXOs + ?.whereType() + .map((e) => e.utxo.copyWith(used: true)) + .toList(); + if (mwebUtxos != null && mwebUtxos.isNotEmpty) { + final drift = Drift.get(walletId); + await drift.transaction(() async { + for (final utxo in mwebUtxos) { + await drift.update(drift.mwebUtxos).replace(utxo); + } + }); + } } void _refreshAfterSend(Wallet wallet) { From c15ed90aa78524496d198105006944ada5b19318 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 19:27:30 +0200 Subject: [PATCH 34/49] fix: start the OpenCryptoPay flow from a pasted link --- lib/pages/send_view/send_view.dart | 5 +++++ lib/pages/send_view/sol_token_send_view.dart | 5 +++++ lib/pages/send_view/token_send_view.dart | 5 +++++ .../my_stack_view/wallet_view/sub_widgets/desktop_send.dart | 5 +++++ .../wallet_view/sub_widgets/desktop_sol_token_send.dart | 5 +++++ .../wallet_view/sub_widgets/desktop_token_send.dart | 5 +++++ 6 files changed, 30 insertions(+) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index ef6c5e7bbf..e74c9c8953 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -268,6 +268,11 @@ class _SendViewState extends ConsumerState { if (content.contains("\n")) { content = content.substring(0, content.indexOf("\n")).trim(); } + if (OpenCryptoPayController.isOpenCryptoPayUri(content)) { + if (!mounted) return; + unawaited(_openCryptoPay.handle(context, content)); + return; + } try { final paymentData = AddressUtils.parsePaymentUri( diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index b69f57b233..a3a738652d 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -147,6 +147,11 @@ class _SolTokenSendViewState extends ConsumerState { if (content.contains("\n")) { content = content.substring(0, content.indexOf("\n")); } + if (OpenCryptoPayController.isOpenCryptoPayUri(content)) { + if (!mounted) return; + unawaited(_openCryptoPay.handle(context, content)); + return; + } sendToController.text = content.trim(); _address = content.trim(); diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 13ac84e58a..c3123e3e95 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -140,6 +140,11 @@ class _TokenSendViewState extends ConsumerState { if (content.contains("\n")) { content = content.substring(0, content.indexOf("\n")); } + if (OpenCryptoPayController.isOpenCryptoPayUri(content)) { + if (!mounted) return; + unawaited(_openCryptoPay.handle(context, content)); + return; + } sendToController.text = content.trim(); _address = content.trim(); 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 32770d11c9..1555a8d63f 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 @@ -1115,6 +1115,11 @@ class _DesktopSendState extends ConsumerState { if (content.contains("\n")) { content = content.substring(0, content.indexOf("\n")).trim(); } + if (OpenCryptoPayController.isOpenCryptoPayUri(content)) { + if (!mounted) return; + unawaited(_openCryptoPay.handle(context, content)); + return; + } try { final paymentData = AddressUtils.parsePaymentUri( 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 673c4852be..03f88de4a5 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 @@ -578,6 +578,11 @@ class _DesktopSolTokenSendState extends ConsumerState { if (content.contains("\n")) { content = content.substring(0, content.indexOf("\n")); } + if (OpenCryptoPayController.isOpenCryptoPayUri(content)) { + if (!mounted) return; + unawaited(_openCryptoPay.handle(context, content)); + return; + } sendToController.text = content; _address = content; 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 b9b8b64aec..59c588c7c7 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 @@ -573,6 +573,11 @@ class _DesktopTokenSendState extends ConsumerState { if (content.contains("\n")) { content = content.substring(0, content.indexOf("\n")); } + if (OpenCryptoPayController.isOpenCryptoPayUri(content)) { + if (!mounted) return; + unawaited(_openCryptoPay.handle(context, content)); + return; + } sendToController.text = content; _address = content; From a99d214e274a222af52973473cb22b50f412e00e Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Thu, 17 Sep 2026 19:46:04 +0200 Subject: [PATCH 35/49] ci: add missing API key stubs for fork PRs --- .github/workflows/test.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 53b0a10648..af54fdfedc 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -60,6 +60,10 @@ jobs: const kShopInBitPartnerSecret = ""; const kCakePayApiToken = ""; const kExolixApiKey = ""; + const kLetsExchangeId = ""; + const kLetsExchangeToken = ""; + const kCypherGoatApiKey = ""; + const kCypherGoatAffiliate = ""; EOF fi From 9bc34058d804691ae904f102f7210a01651612d0 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 05:48:50 +0200 Subject: [PATCH 36/49] fix: submit OpenCryptoPay txid proof before the success delay --- .../send_view/confirm_transaction_view.dart | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index e3d3d70264..020767a514 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -449,9 +449,6 @@ class _ConfirmTransactionViewState final results = await Future.wait([txDataFuture, time]); final confirmedTx = results.first as TxData; - sendProgressController.triggerSuccess?.call(); - await Future.delayed(const Duration(seconds: 5)); - if (wallet is FiroWallet && confirmedTx.sparkMints != null) { txids.addAll(confirmedTx.sparkMints!.map((e) => e.txid!)); } else if (wallet is FiroWallet && confirmedTx.sparkSpends != null) { @@ -459,16 +456,6 @@ class _ConfirmTransactionViewState } else { txids.add(confirmedTx.txid!); } - if (coin is! Ethereum) { - ref.refresh(desktopUseUTXOs); - } - - // save note - for (final txid in txids) { - await _saveNote(txid: txid, note: note); - } - - _refreshAfterSend(wallet); if (ocp != null && txids.isNotEmpty && context.mounted) { // Broadcast (txid) proof type: submit the txid to the @@ -484,6 +471,20 @@ class _ConfirmTransactionViewState await proof; } + sendProgressController.triggerSuccess?.call(); + await Future.delayed(const Duration(seconds: 5)); + + if (coin is! Ethereum) { + ref.refresh(desktopUseUTXOs); + } + + // save note + for (final txid in txids) { + await _saveNote(txid: txid, note: note); + } + + _refreshAfterSend(wallet); + closeSendingDialog(); widget.onSuccess.call(); From 4d334386137ef783c9c3bbc498d6d71a3d7847b7 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 08:08:31 +0200 Subject: [PATCH 37/49] feat: offer a retry when OpenCryptoPay proof delivery fails --- .../open_crypto_pay_send_handler.dart | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 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 3c5258bd87..fc77a7a129 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 @@ -213,8 +213,9 @@ class OpenCryptoPaySendHandler { Future _confirm( BuildContext context, String title, - String message, - ) async { + String message, { + String confirmLabel = "Continue", + }) async { final proceed = await showDialog( context: context, barrierDismissible: false, @@ -226,7 +227,7 @@ class OpenCryptoPaySendHandler { onPressed: () => Navigator.of(context).pop(false), ), rightButton: PrimaryButton( - label: "Continue", + label: confirmLabel, onPressed: () => Navigator.of(context).pop(true), ), flex: true, @@ -350,12 +351,14 @@ class OpenCryptoPaySendHandler { requiresBroadcast: session.requiresBroadcast, providerAnswered: providerAnswered, ); - await _showError( - context: context, - title: text.title, - message: text.message, + final retry = await _confirm( + context, + text.title, + text.message, + confirmLabel: "Retry", ); - return false; + if (!retry || !context.mounted) return false; + return submitProof(context, txProof); } } } From 44eaf561189956de35010e526179c6b7645d66bb Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 08:25:17 +0200 Subject: [PATCH 38/49] fix: forward response headers in StackClient so bodies decode as UTF-8 --- lib/networking/http.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/networking/http.dart b/lib/networking/http.dart index 3c1a916301..9ed6c9a0af 100644 --- a/lib/networking/http.dart +++ b/lib/networking/http.dart @@ -318,6 +318,7 @@ class StackClient extends BaseClient { response.code, contentLength: response.bodyBytes.length, request: request, + headers: response.headers, ); } } From eb1cc65c5c47c83274a06174d9c9eac912658ae3 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 08:32:21 +0200 Subject: [PATCH 39/49] fix: format the OpenCryptoPay amount for the locale in SPL token send views --- lib/pages/send_view/sol_token_send_view.dart | 5 +++-- .../wallet_view/sub_widgets/desktop_sol_token_send.dart | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index a3a738652d..36d4ebd9fc 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -667,8 +667,9 @@ class _SolTokenSendViewState extends ConsumerState { coin: coin, sendToController: sendToController, onAmountReceived: (parsed) { - cryptoAmountController.text = parsed.decimal.toStringAsFixed( - parsed.fractionDigits, + cryptoAmountController.text = Amount.formatEditableDecimal( + parsed.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, ); _amountToSend = parsed; _updatePreviewButtonState(_address, parsed); 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 03f88de4a5..449ce9f8aa 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 @@ -701,8 +701,9 @@ class _DesktopSolTokenSendState extends ConsumerState { coin: coin, sendToController: sendToController, onAmountReceived: (parsed) { - cryptoAmountController.text = parsed.decimal.toStringAsFixed( - parsed.fractionDigits, + cryptoAmountController.text = Amount.formatEditableDecimal( + parsed.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, ); _amountToSend = parsed; _updatePreviewButtonState(_address, parsed); From 6ae940678d3e506c712858a9de95e1b007696671 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 08:37:05 +0200 Subject: [PATCH 40/49] fix: check the requested token mint in SPL token send views --- lib/pages/send_view/sol_token_send_view.dart | 1 + .../wallet_view/sub_widgets/desktop_sol_token_send.dart | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index 36d4ebd9fc..e897b84ae6 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -677,6 +677,7 @@ class _SolTokenSendViewState extends ConsumerState { setValidAddress: _openCryptoPaySetValidAddress, tokenSymbol: tokenWallet?.tokenSymbol, tokenDecimals: tokenWallet?.tokenDecimals, + tokenContractAddress: tokenWallet?.tokenMint, ); super.initState(); 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 449ce9f8aa..a79741254d 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 @@ -711,6 +711,7 @@ class _DesktopSolTokenSendState extends ConsumerState { setValidAddress: _openCryptoPaySetValidAddress, tokenSymbol: tokenWallet?.tokenSymbol, tokenDecimals: tokenWallet?.tokenDecimals, + tokenContractAddress: tokenWallet?.tokenMint, ); super.initState(); From fd0ea83cbe773cbfc2f2fe56f23d81f91643514f Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 08:48:12 +0200 Subject: [PATCH 41/49] feat: refuse an OpenCryptoPay SPL token payment whose fee is below the minimum --- .../open_crypto_pay_send_fee.dart | 53 +++++++++++++++---- .../open_crypto_pay_send_handler.dart | 45 ++++++++++++---- lib/pages/send_view/sol_token_send_view.dart | 8 +++ .../sub_widgets/desktop_sol_token_send.dart | 8 +++ 4 files changed, 94 insertions(+), 20 deletions(-) diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart index 53ffcba93d..6a1f8064a0 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart @@ -24,6 +24,9 @@ const _unknownFeeMessage = String _unmetFeeMessage(String required, String fastest) => "The payment request requires a network fee of at least $required, " "above this wallet's fastest fee of $fastest."; +String _fixedFeeMessage(String required, String fee) => + "The payment request requires a network fee of at least $required, " + "above this transaction's fee of $fee."; /// Fee values for a send, with the payment request's minimum fee applied. typedef OpenCryptoPaySendFee = ({ @@ -98,7 +101,7 @@ Future _levelSendFee( OpenCryptoPaySendFee chosen, OpenCryptoPayNotify unmet, ) async { - final required = BigInt.from(minFee.ceil()); + final required = _requiredRaw(minFee); final levels = [ (type: FeeRateType.slow, rate: fees.slow), (type: FeeRateType.average, rate: fees.medium), @@ -135,23 +138,51 @@ Future _levelSendFee( } } final fastest = fee!; - String coins(BigInt raw) { - final amount = Amount( - rawValue: raw, - fractionDigits: fastest.fractionDigits, - ); - return "${amount.decimal} ${wallet.cryptoCurrency.ticker}"; - } - if (!context.mounted) return null; await unmet( context, _unmetFeeTitle, - _unmetFeeMessage(coins(required), coins(fastest.raw)), + _unmetFeeMessage( + _coins(wallet, required, fastest.fractionDigits), + _coins(wallet, fastest.raw, fastest.fractionDigits), + ), ); return null; } +/// Whether a prepared transaction's [fee] reaches the minimum, for sends that +/// build their own fee. +Future openCryptoPayPreparedFeeMeetsMinimum( + BuildContext context, + Wallet wallet, { + required num minFee, + required Amount? fee, + required OpenCryptoPayNotify unmet, +}) async { + if (fee == null || fee.raw <= BigInt.zero) { + await _feeUnknown(context, unmet); + return false; + } + final required = _requiredRaw(minFee); + if (fee.raw >= required) return true; + await unmet( + context, + _unmetFeeTitle, + _fixedFeeMessage( + _coins(wallet, required, fee.fractionDigits), + _coins(wallet, fee.raw, fee.fractionDigits), + ), + ); + return false; +} + +BigInt _requiredRaw(num minFee) => BigInt.from(minFee.ceil()); + +String _coins(Wallet wallet, BigInt raw, int fractionDigits) { + final amount = Amount(rawValue: raw, fractionDigits: fractionDigits); + return "${amount.decimal} ${wallet.cryptoCurrency.ticker}"; +} + /// Stops the send when the fee cannot be checked against the minimum. Future _feeUnknown( BuildContext context, @@ -202,7 +233,7 @@ Future _evmSendFee( OpenCryptoPaySendFee chosen, OpenCryptoPayConfirm confirm, ) async { - final minWei = BigInt.from(minFee.ceil()); + final minWei = _requiredRaw(minFee); final current = switch (chosen.feeRateType) { FeeRateType.fast => fees.fast, FeeRateType.average => fees.medium, 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 fc77a7a129..220fec089b 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 @@ -191,13 +191,8 @@ class OpenCryptoPaySendHandler { satsPerVByte: satsPerVByte, ethFee: ethFee, ); - final session = _session; - if (!feeRateApplies || - session == null || - session.minFee <= 0 || - !session.isActivePaymentFor(address)) { - return chosen; - } + final session = _minFeeSessionFor(address); + if (!feeRateApplies || session == null) return chosen; return openCryptoPaySendFee( context, wallet, @@ -205,11 +200,43 @@ class OpenCryptoPaySendHandler { minFee: session.minFee, chosen: chosen, confirm: _confirm, - unmet: (context, title, message) => - _showError(context: context, title: title, message: message), + unmet: _notify, + ); + } + + /// Whether a prepared transaction's fee reaches the payment request's + /// minimum, for sends that build their own fee. + Future preparedFeeMeetsMinimum( + BuildContext context, + Wallet wallet, { + required String? address, + required Amount? fee, + }) async { + final session = _minFeeSessionFor(address); + if (session == null) return true; + return openCryptoPayPreparedFeeMeetsMinimum( + context, + wallet, + minFee: session.minFee, + fee: fee, + unmet: _notify, ); } + /// The active payment session for [address] when it carries a minimum fee. + OpenCryptoPaySession? _minFeeSessionFor(String? address) { + final session = _session; + if (session == null || + session.minFee <= 0 || + !session.isActivePaymentFor(address)) { + return null; + } + return session; + } + + Future _notify(BuildContext context, String title, String message) => + _showError(context: context, title: title, message: message); + Future _confirm( BuildContext context, String title, diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index e897b84ae6..5b93b1e11e 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -546,6 +546,14 @@ class _SolTokenSendViewState extends ConsumerState { // pop building dialog Navigator.of(context).pop(); + final feeOk = await _openCryptoPay.preparedFeeMeetsMinimum( + context, + tokenWallet, + address: _address, + fee: txData.fee, + ); + if (!feeOk || !mounted) return; + unawaited( Navigator.of(context).push( RouteGenerator.getRoute( 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 a79741254d..57f14fe98f 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 @@ -305,6 +305,14 @@ class _DesktopSolTokenSendState extends ConsumerState { // pop building dialog Navigator.of(context, rootNavigator: true).pop(); + final feeOk = await _openCryptoPay.preparedFeeMeetsMinimum( + context, + tokenWallet, + address: _address, + fee: txData.fee, + ); + if (!feeOk || !mounted) return; + unawaited( showDialog( context: context, From cd204f2d2ff99a6166687c8df3096dcf557c4125 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 09:24:25 +0200 Subject: [PATCH 42/49] fix: compare a custom EVM fee by its effective gas price against the OpenCryptoPay minimum --- lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart index 6a1f8064a0..5ea17c0b2f 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart @@ -234,11 +234,17 @@ Future _evmSendFee( OpenCryptoPayConfirm confirm, ) async { final minWei = _requiredRaw(minFee); + BigInt customPrice(EthEIP1559Fee? fee) { + if (fee == null) return BigInt.zero; + final price = fees.suggestBaseFee + fee.maxPriorityFeePerGasWei; + return price < fee.maxFeePerGasWei ? price : fee.maxFeePerGasWei; + } + final current = switch (chosen.feeRateType) { FeeRateType.fast => fees.fast, FeeRateType.average => fees.medium, FeeRateType.slow => fees.slow, - FeeRateType.custom => chosen.ethFee?.maxFeePerGasWei ?? BigInt.zero, + FeeRateType.custom => customPrice(chosen.ethFee), }; if (current >= minWei) return chosen; Decimal gwei(BigInt wei) => Decimal.fromBigInt(wei).shift(-9); From 3b9a9de36ce7d60dc8aa2cf1b9bb377d2fddf936 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 09:34:19 +0200 Subject: [PATCH 43/49] fix: clear op_return data when an OpenCryptoPay link is pasted or scanned --- lib/pages/send_view/send_view.dart | 2 ++ .../my_stack_view/wallet_view/sub_widgets/desktop_send.dart | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index e74c9c8953..2da76c53f6 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -270,6 +270,7 @@ class _SendViewState extends ConsumerState { } if (OpenCryptoPayController.isOpenCryptoPayUri(content)) { if (!mounted) return; + _setOpReturnData(null); unawaited(_openCryptoPay.handle(context, content)); return; } @@ -345,6 +346,7 @@ class _SendViewState extends ConsumerState { if (OpenCryptoPayController.isOpenCryptoPayUri(qrResult.rawContent)) { if (!mounted) return; + _setOpReturnData(null); unawaited(_openCryptoPay.handle(context, qrResult.rawContent!)); return; } 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 1555a8d63f..690eca87dd 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 @@ -970,6 +970,7 @@ class _DesktopSendState extends ConsumerState { try { if (OpenCryptoPayController.isOpenCryptoPayUri(qrCodeData)) { if (!mounted) return; + _setOpReturnData(null); unawaited(_openCryptoPay.handle(context, qrCodeData)); return; } @@ -1117,6 +1118,7 @@ class _DesktopSendState extends ConsumerState { } if (OpenCryptoPayController.isOpenCryptoPayUri(content)) { if (!mounted) return; + _setOpReturnData(null); unawaited(_openCryptoPay.handle(context, content)); return; } From 96fbc3a12565b047a88340627d1f676a6f2463ea Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 09:37:34 +0200 Subject: [PATCH 44/49] fix: keep the unconfirmed delivery wording after an OpenCryptoPay retry --- .../open_crypto_pay/open_crypto_pay_send_handler.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 220fec089b..04c1b8748f 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 @@ -98,6 +98,9 @@ class OpenCryptoPaySendHandler { Amount? _quotedAmount; bool _quoteOverridden = false; + /// Whether a proof submission got no answer, so the provider may hold it. + bool _deliveryUnconfirmed = false; + /// Whether the user chose to send despite a recipient or amount that /// differs from the payment request. bool get quoteOverridden => _quoteOverridden; @@ -106,6 +109,7 @@ class OpenCryptoPaySendHandler { _session = null; _quotedAmount = null; _quoteOverridden = false; + _deliveryUnconfirmed = false; } Future showQuoteExpiredError( @@ -335,6 +339,7 @@ class OpenCryptoPaySendHandler { void _applySuccess(OpenCryptoPaySuccess result) { _session = result.session; + _deliveryUnconfirmed = false; // Prefill the form in place. final address = result.address; @@ -374,9 +379,10 @@ class OpenCryptoPaySendHandler { error: error, ); if (!context.mounted) return false; + _deliveryUnconfirmed |= !providerAnswered; final text = OpenCryptoPayStrings.proofFailure( requiresBroadcast: session.requiresBroadcast, - providerAnswered: providerAnswered, + providerAnswered: providerAnswered && !_deliveryUnconfirmed, ); final retry = await _confirm( context, From a752c525a446bcf12f2d8f2006771e793f411657 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 09:44:52 +0200 Subject: [PATCH 45/49] refactor: reuse resolveEip1559FeeCaps for the OpenCryptoPay minimum fee --- .../open_crypto_pay/open_crypto_pay_send_fee.dart | 14 +++++++------- lib/wallets/wallet/impl/ethereum_wallet.dart | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart index 5ea17c0b2f..9266ef2681 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart @@ -259,17 +259,17 @@ Future _evmSendFee( )) { return null; } - // The priority fee tops the base fee up to the minimum gas price; the cap - // gets the same base fee headroom as the presets. - var priorityWei = minWei - fees.suggestBaseFee; - if (priorityWei.isNegative) priorityWei = BigInt.zero; - final maxFeeWei = fees.suggestBaseFee * BigInt.two + priorityWei; + // The priority fee tops the base fee up to the minimum gas price. + final caps = resolveEip1559FeeCaps( + baseFee: fees.suggestBaseFee, + priorityFeePerGas: minWei - fees.suggestBaseFee, + ); return ( feeRateType: FeeRateType.custom, satsPerVByte: chosen.satsPerVByte, ethFee: EthEIP1559Fee( - maxFeePerGasGwei: gwei(maxFeeWei), - maxPriorityFeePerGasGwei: gwei(priorityWei), + maxFeePerGasGwei: gwei(caps.maxFeePerGas), + maxPriorityFeePerGasGwei: gwei(caps.maxPriorityFeePerGas), gasLimit: chosen.ethFee?.gasLimit ?? (wallet is EthTokenWallet diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index bbb444eb59..645f627ed8 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -87,7 +87,6 @@ Future> findReplacedPendingEthereumTransactions({ // Eth can not use tor with web3dart -@visibleForTesting ({BigInt maxFeePerGas, BigInt maxPriorityFeePerGas}) resolveEip1559FeeCaps({ required BigInt baseFee, required BigInt priorityFeePerGas, From dd6fe588ba3f944dc600938dcbab4abae58a217b Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 09:44:52 +0200 Subject: [PATCH 46/49] refactor: guard the OpenCryptoPay fee check in the handler instead of view state --- .../open_crypto_pay_send_handler.dart | 26 ++++++++++++------- lib/pages/send_view/send_view.dart | 4 --- lib/pages/send_view/token_send_view.dart | 4 --- .../wallet_view/sub_widgets/desktop_send.dart | 4 --- .../sub_widgets/desktop_token_send.dart | 4 --- 5 files changed, 17 insertions(+), 25 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 04c1b8748f..1df8a3efc2 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 @@ -100,6 +100,7 @@ class OpenCryptoPaySendHandler { /// Whether a proof submission got no answer, so the provider may hold it. bool _deliveryUnconfirmed = false; + bool _feeCheckInFlight = false; /// Whether the user chose to send despite a recipient or amount that /// differs from the payment request. @@ -197,15 +198,22 @@ class OpenCryptoPaySendHandler { ); final session = _minFeeSessionFor(address); if (!feeRateApplies || session == null) return chosen; - return openCryptoPaySendFee( - context, - wallet, - amount: amount, - minFee: session.minFee, - chosen: chosen, - confirm: _confirm, - unmet: _notify, - ); + // A second Preview tap during the check is ignored. + if (_feeCheckInFlight) return null; + _feeCheckInFlight = true; + try { + return await openCryptoPaySendFee( + context, + wallet, + amount: amount, + minFee: session.minFee, + chosen: chosen, + confirm: _confirm, + unmet: _notify, + ); + } finally { + _feeCheckInFlight = false; + } } /// Whether a prepared transaction's fee reaches the payment request's diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 2da76c53f6..bbd753a984 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -160,7 +160,6 @@ class _SendViewState extends ConsumerState { Set selectedUTXOs = {}; late final OpenCryptoPaySendHandler _openCryptoPay; - bool _feeCheckPending = false; void _openCryptoPaySetValidAddress(String address) { _address = address; @@ -945,7 +944,6 @@ class _SendViewState extends ConsumerState { final chosenRateType = ref.read(feeRateTypeMobileStateProvider); if (!mounted) return; - setState(() => _feeCheckPending = true); final fee = await _openCryptoPay.sendFee( context, wallet, @@ -959,7 +957,6 @@ class _SendViewState extends ConsumerState { ref.read(publicPrivateBalanceStateProvider) == BalanceType.public, ); if (!mounted) return; - setState(() => _feeCheckPending = false); if (fee == null) return; try { @@ -2817,7 +2814,6 @@ class _SendViewState extends ConsumerState { builder: (context, ethFee, _) { final enabled = previewEnabled && - !_feeCheckPending && (!needsEthFee || ethFee != null); return TextButton( onPressed: enabled diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index c3123e3e95..ab7cc4787f 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -123,7 +123,6 @@ class _TokenSendViewState extends ConsumerState { final _ethFee = ValueNotifier(null); late final OpenCryptoPaySendHandler _openCryptoPay; - bool _feeCheckPending = false; void _openCryptoPaySetValidAddress(String address) { _address = address; @@ -489,7 +488,6 @@ class _TokenSendViewState extends ConsumerState { final feeRateType = ref.read(feeRateTypeMobileStateProvider); if (!mounted) return; - setState(() => _feeCheckPending = true); final fee = await _openCryptoPay.sendFee( context, tokenWallet, @@ -499,7 +497,6 @@ class _TokenSendViewState extends ConsumerState { ethFee: _ethFee.value, ); if (!mounted) return; - setState(() => _feeCheckPending = false); if (fee == null) return; try { @@ -1391,7 +1388,6 @@ class _TokenSendViewState extends ConsumerState { builder: (context, ethFee, _) { final enabled = previewEnabled && - !_feeCheckPending && (!needsEthFee || ethFee != null); return TextButton( onPressed: enabled 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 690eca87dd..3e549c0d23 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 @@ -130,7 +130,6 @@ class _DesktopSendState extends ConsumerState { late final bool isMimblewimblecoin; late final bool isEpiccash; late final OpenCryptoPaySendHandler _openCryptoPay; - bool _feeCheckPending = false; String? _note; String? _onChainNote; @@ -554,7 +553,6 @@ class _DesktopSendState extends ConsumerState { final chosenRateType = ref.read(feeRateTypeDesktopStateProvider); if (!mounted) return; - setState(() => _feeCheckPending = true); final fee = await _openCryptoPay.sendFee( context, wallet, @@ -568,7 +566,6 @@ class _DesktopSendState extends ConsumerState { ref.read(publicPrivateBalanceStateProvider) == BalanceType.public, ); if (!mounted) return; - setState(() => _feeCheckPending = false); if (fee == null) return; try { @@ -2292,7 +2289,6 @@ class _DesktopSendState extends ConsumerState { builder: (context, ethFee, _) { final enabled = previewEnabled && - !_feeCheckPending && _nonceIsValid && (!needsEthFee || ethFee != null); return PrimaryButton( 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 59c588c7c7..b895d4c337 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 @@ -119,7 +119,6 @@ class _DesktopTokenSendState extends ConsumerState { bool get _nonceIsValid => _nonceInput.isValid; late final OpenCryptoPaySendHandler _openCryptoPay; - bool _feeCheckPending = false; void _openCryptoPaySetValidAddress(String address) { _address = address; @@ -223,7 +222,6 @@ class _DesktopTokenSendState extends ConsumerState { final feeRateType = ref.read(feeRateTypeDesktopStateProvider); if (!mounted) return; - setState(() => _feeCheckPending = true); final fee = await _openCryptoPay.sendFee( context, tokenWallet, @@ -233,7 +231,6 @@ class _DesktopTokenSendState extends ConsumerState { ethFee: _ethFee.value, ); if (!mounted) return; - setState(() => _feeCheckPending = false); if (fee == null) return; try { @@ -1214,7 +1211,6 @@ class _DesktopTokenSendState extends ConsumerState { builder: (context, ethFee, _) { final enabled = previewEnabled && - !_feeCheckPending && _nonceIsValid && (!needsEthFee || ethFee != null); return PrimaryButton( From 96633a2d1848cb5db66567c41a3b6f02949bf974 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 09:44:52 +0200 Subject: [PATCH 47/49] refactor: drop the unused StackClient parameter and timeout --- lib/networking/http.dart | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/lib/networking/http.dart b/lib/networking/http.dart index 9ed6c9a0af..8cc87516b7 100644 --- a/lib/networking/http.dart +++ b/lib/networking/http.dart @@ -277,12 +277,6 @@ class HTTP { /// 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(); @@ -298,14 +292,11 @@ class StackClient extends BaseClient { final Response response; switch (request.method) { case 'GET': - response = await _client - .get( - url: request.url, - headers: request.headers, - proxyInfo: proxyInfo, - connectionTimeout: timeout, - ) - .timeout(timeout); + response = await const HTTP().get( + url: request.url, + headers: request.headers, + proxyInfo: proxyInfo, + ); default: throw ClientException( 'Unsupported HTTP method ${request.method}', From a8a738deeea85eec408c625412b2a0eb85ae9391 Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 09:58:59 +0200 Subject: [PATCH 48/49] fix: reword comments --- .../open_crypto_pay/open_crypto_pay_send_handler.dart | 5 ++--- lib/pages/send_view/confirm_transaction_view.dart | 7 ++----- lib/wallets/wallet/impl/ethereum_wallet.dart | 5 ++--- lib/widgets/eth_fee_form.dart | 3 +-- 4 files changed, 7 insertions(+), 13 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 1df8a3efc2..e4d3e9845b 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 @@ -82,9 +82,8 @@ class OpenCryptoPaySendHandler { final void Function(Amount amount) onAmountReceived; final void Function(String address) setValidAddress; - /// 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. + /// Set for token wallets so the request targets the token and amounts use + /// its decimals. final String? tokenSymbol; final int? tokenDecimals; diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 020767a514..4668ac00b0 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -338,9 +338,7 @@ class _ConfirmTransactionViewState } if (ocp != null && !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. + // Signed-hex proof type: the provider broadcasts the transaction. return await _submitOpenCryptoPayHexProof(context, ocp, wallet); } @@ -624,8 +622,7 @@ class _ConfirmTransactionViewState TransactionNote(walletId: walletId, txid: txid, value: note), ); - /// In case of a signed tx submitted without a broadcast, - /// prevent used input to be reused. + /// Marks the inputs of a signed transaction handed to the provider as used. Future _markInputsAsUsed() async { final db = ref.read(mainDBProvider); diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index 645f627ed8..4d40851c9c 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -149,9 +149,8 @@ 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). + /// Signs [tx] and returns the raw signed transaction hex, 0x-prefixed with + /// the EIP-1559 type byte, for flows where a third party broadcasts. Future signWeb3TransactionToHex({ required web3.Transaction tx, required BigInt chainId, diff --git a/lib/widgets/eth_fee_form.dart b/lib/widgets/eth_fee_form.dart index ef0ecae71c..f04d181f91 100644 --- a/lib/widgets/eth_fee_form.dart +++ b/lib/widgets/eth_fee_form.dart @@ -94,8 +94,7 @@ class _EthFeeFormState extends State { gasLimit: _gasLimitCache, ); - // Blank or separator-only input is invalid, not zero: a zero max fee cannot - // cover a positive network base fee and would fail at build time. + // Blank or separator-only input is invalid. Amount? _parseFeeInput(String value) { return Amount.tryParseEditableAmount( value, From f963b1915a8647ab678eb171a6f3fe14e0ab55ac Mon Sep 17 00:00:00 2001 From: Cyrix126 Date: Mon, 21 Sep 2026 11:55:22 +0200 Subject: [PATCH 49/49] test: update OpenCryptoPay handler tests --- .../open_crypto_pay_send_handler_test.dart | 103 +++++++++++++++++- 1 file changed, 100 insertions(+), 3 deletions(-) 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 37a4712514..6ec939ef42 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 @@ -1228,9 +1228,10 @@ void main() { ); expect(floor, fast); + // base 10 + tip 2 = 12 gwei meets the minimum. final customFee = EthEIP1559Fee( maxFeePerGasGwei: Decimal.fromInt(15), - maxPriorityFeePerGasGwei: Decimal.one, + maxPriorityFeePerGasGwei: Decimal.fromInt(2), gasLimit: 90000, ); final custom = await feeFor( @@ -1245,6 +1246,30 @@ void main() { expect(custom!.ethFee, same(customFee)); }); + testWidgets("an EVM custom fee is judged by base fee plus tip", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingErc20(tester, harness, minFee: 12000000000); + // The cap allows 15 gwei but base 10 + tip 1 pays only 11 gwei. + final floor = await feeFor( + tester, + harness, + setup.handler, + tokenWallet, + address: _erc20Recipient, + feeRateType: FeeRateType.custom, + ethFee: EthEIP1559Fee( + maxFeePerGasGwei: Decimal.fromInt(15), + maxPriorityFeePerGasGwei: Decimal.one, + gasLimit: 90000, + ), + ); + final fee = floor!.ethFee!; + expect(fee.maxFeePerGasWei, gwei * BigInt.from(22)); + expect(fee.maxPriorityFeePerGasWei, gwei * BigInt.two); + }); + testWidgets( "an EVM minimum below the fast estimate is raised without asking", (tester) async { @@ -1394,7 +1419,7 @@ void main() { expect(find.text(OpenCryptoPayStrings.proofFailedTitle), findsOneWidget); expect(find.text(OpenCryptoPayStrings.proofFailed), findsOneWidget); - await _tapOk(tester); + await _tapButton(tester, "Cancel"); expect(await fut, isFalse); expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); @@ -1423,11 +1448,83 @@ void main() { findsOneWidget, ); expect(find.textContaining("Nothing was sent"), findsNothing); - await _tapOk(tester); + await _tapButton(tester, "Cancel"); + expect(await fut, isFalse); + expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); + }); + + testWidgets("retry resubmits the proof until the user cancels", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + var proofRequests = 0; + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: _mockOcpServer( + paymentInfo: _paymentInfoJson(quoteExpiration: _futureExpiration()), + txDetails: _btcDetailsJson(hint: _hashHint), + proofStatus: 500, + onRequest: (url) { + if (url.path.contains('/tx/')) proofRequests++; + }, + ), + ); + await _handle(tester, harness, setup.handler); + + final fut = setup.handler.submitProof(harness.context, "some_txid"); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await _tapButton(tester, "Retry"); + await tester.pump(const Duration(milliseconds: 100)); + + expect(proofRequests, 2); + expect(find.text(OpenCryptoPayStrings.proofFailedTitle), findsOneWidget); + await _tapButton(tester, "Cancel"); expect(await fut, isFalse); expect(setup.handler.isActivePaymentFor(_btcAddress), isTrue); }); + testWidgets("a rejection after a lost response keeps the unconfirmed " + "wording", (tester) async { + final harness = await _pumpHarness(tester); + var proofRequests = 0; + final paymentInfo = _paymentInfoJson( + quoteExpiration: _futureExpiration(), + ); + final txDetails = _btcDetailsJson(hint: _hexHint); + final setup = _makeHandler( + harness: harness, + coin: Bitcoin(CryptoCurrencyNetwork.main), + client: MockClient((request) async { + if (request.url.path.contains('/tx/')) { + if (proofRequests++ == 0) throw Exception("socket closed"); + return Response('{}', 400); + } + if (request.url.queryParameters.containsKey('method')) { + return Response(jsonEncode(txDetails), 200); + } + return Response(jsonEncode(paymentInfo), 200); + }), + ); + await _handle(tester, harness, setup.handler); + + final fut = setup.handler.submitProof(harness.context, "deadbeef"); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await _tapButton(tester, "Retry"); + await tester.pump(const Duration(milliseconds: 100)); + + expect(proofRequests, 2); + expect( + find.text(OpenCryptoPayStrings.deliveryUnconfirmedTitle), + findsOneWidget, + ); + expect(find.textContaining("Nothing was sent"), findsNothing); + await _tapButton(tester, "Cancel"); + expect(await fut, isFalse); + }); + testWidgets("quote expiring before hex-proof submission aborts with a " "'NOT sent' dialog and retains the payment", (tester) async { final harness = await _pumpHarness(tester);