diff --git a/lib/networking/http.dart b/lib/networking/http.dart index 821bdc4af9..3caf2564d4 100644 --- a/lib/networking/http.dart +++ b/lib/networking/http.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart'; +import 'package:http/io_client.dart'; import 'package:socks5_proxy/socks_client.dart'; import '../utilities/logger.dart'; @@ -269,3 +271,27 @@ class HTTP { return completer.future; } } + +/// HTTP client class that can be used with libraries that +/// accept an http.Client +class StackClient extends BaseClient { + StackClient({required this.proxyInfo}); + + final ({InternetAddress host, int port})? Function() proxyInfo; + + @override + Future send(BaseRequest request) async { + final httpClient = HttpClient(); + final proxy = proxyInfo(); + if (proxy != null) { + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(proxy.host, proxy.port), + ]); + } + try { + return await IOClient(httpClient).send(request); + } finally { + httpClient.close(); + } + } +} 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..9266ef2681 --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart @@ -0,0 +1,280 @@ +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"; +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."; +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 = ({ + 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, no fee level reaches the minimum, or the fee +/// cannot be estimated. +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, + ); + if (!context.mounted) return null; + return _feeUnknown(context, unmet); + } + 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 = _requiredRaw(minFee); + 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)) { + 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 + : ( + feeRateType: level.type, + satsPerVByte: chosen.satsPerVByte, + ethFee: chosen.ethFee, + ); + } + } + final fastest = fee!; + if (!context.mounted) return null; + await unmet( + context, + _unmetFeeTitle, + _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, + OpenCryptoPayNotify unmet, +) async { + await unmet(context, _unknownFeeTitle, _unknownFeeMessage); + 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 = _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 => customPrice(chosen.ethFee), + }; + 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. + final caps = resolveEip1559FeeCaps( + baseFee: fees.suggestBaseFee, + priorityFeePerGas: minWei - fees.suggestBaseFee, + ); + return ( + feeRateType: FeeRateType.custom, + satsPerVByte: chosen.satsPerVByte, + ethFee: EthEIP1559Fee( + maxFeePerGasGwei: gwei(caps.maxFeePerGas), + maxPriorityFeePerGasGwei: gwei(caps.maxPriorityFeePerGas), + 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 new file mode 100644 index 0000000000..5199c68a60 --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_send_handler.dart @@ -0,0 +1,419 @@ +/* + * 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:io'; + +import 'package:flutter/material.dart'; +import 'package:opencryptopay/opencryptopay.dart'; + +import '../../app_config.dart'; +import '../../networking/http.dart'; +import '../../services/tor_service.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/prefs.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, + required bool sameAmount, +}) { + final changed = switch ((sameRecipient, sameAmount)) { + (false, false) => "recipient and amount", + (false, true) => "recipient", + _ => "amount", + }; + return ( + title: "${changed.capitalize()} 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}) => + CryptoCoin( + ticker: tokenSymbol ?? currency.ticker, + prettyName: currency.prettyName, + displayName: tokenSymbol ?? currency.prettyName, + ); + +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, + required this.sendToController, + required this.onAmountReceived, + required this.setValidAddress, + this.tokenSymbol, + this.tokenDecimals, + this.tokenContractAddress, + @visibleForTesting OpenCryptoPayController? controller, + }) : _controller = + controller ?? + OpenCryptoPayController( + service: OpenCryptoPayService( + client: StackClient(proxyInfo: _proxyInfo), + ), + ); + + static ({InternetAddress host, int port})? _proxyInfo() { + if (AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor) { + return TorService.sharedInstance.getProxyInfo(); + } else { + return null; + } + } + + final CryptoCurrency coin; + final TextEditingController sendToController; + final void Function(Amount amount) onAmountReceived; + final void Function(String address) setValidAddress; + + /// Set for token wallets so the request targets the token and amounts use + /// its decimals. + 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; + OpenCryptoPaySession? _session; + Amount? _quotedAmount; + bool _quoteOverridden = false; + + /// 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. + bool get quoteOverridden => _quoteOverridden; + + void reset() { + _session = null; + _quotedAmount = null; + _quoteOverridden = false; + _deliveryUnconfirmed = false; + } + + 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; + + List get businessDetails { + final details = _session?.details; + if (details == null) return const []; + final recipient = details.recipient; + 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) ...[ + 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), + ], + ].nonNulls.toList(); + } + + 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. + 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 _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. + /// [feeRateApplies] is false when the send builds its own fee. + Future sendFee( + BuildContext context, + Wallet wallet, { + required String? address, + required Amount amount, + required FeeRateType feeRateType, + int? satsPerVByte, + EthEIP1559Fee? ethFee, + bool feeRateApplies = true, + }) async { + final chosen = ( + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, + ethFee: ethFee, + ); + final session = _minFeeSessionFor(address); + if (!feeRateApplies || session == null) return chosen; + // 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 + /// 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, + String message, { + String confirmLabel = "Continue", + }) async { + final proceed = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => BasicDialog( + title: title, + message: message, + leftButton: SecondaryButton( + label: "Cancel", + onPressed: () => Navigator.of(context).pop(false), + ), + rightButton: PrimaryButton( + label: confirmLabel, + onPressed: () => Navigator.of(context).pop(true), + ), + flex: true, + ), + ); + return proceed ?? false; + } + + Future _showError({ + required BuildContext context, + required String title, + required String message, + }) async { + if (!context.mounted) 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 (!context.mounted) return; + + switch (result) { + case null: + await _showError( + context: context, + title: OpenCryptoPayStrings.genericErrorTitle, + message: OpenCryptoPayStrings.genericErrorMessage, + ); + case OpenCryptoPayFailure(): + final text = OpenCryptoPayStrings.failure(result); + await _showError( + context: context, + title: text.title, + message: text.message, + ); + 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; + _deliveryUnconfirmed = false; + + // Prefill the form in place. + final address = result.address; + sendToController.text = address; + + final rawAmount = result.amountInSmallestUnit(_fractionDigits); + final quoted = rawAmount == null + ? null + : Amount(rawValue: rawAmount, fractionDigits: _fractionDigits); + _quotedAmount = quoted; + _quoteOverridden = false; + if (quoted != null) onAmountReceived(quoted); + + 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 (!context.mounted) return false; + await showQuoteExpiredError(context, paymentNotSent: true); + return false; + case OpenCryptoPayProofFailed(:final error, :final providerAnswered): + Logging.instance.w( + "OpenCryptoPay proof submission failed", + error: error, + ); + if (!context.mounted) return false; + _deliveryUnconfirmed |= !providerAnswered; + final text = OpenCryptoPayStrings.proofFailure( + requiresBroadcast: session.requiresBroadcast, + providerAnswered: providerAnswered && !_deliveryUnconfirmed, + ); + final retry = await _confirm( + context, + text.title, + text.message, + confirmLabel: "Retry", + ); + if (!retry || !context.mounted) return false; + return submitProof(context, txProof); + } + } +} diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index ed4e967792..4668ac00b0 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'; @@ -37,10 +38,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 +49,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,10 +65,12 @@ 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'; 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 { @@ -82,6 +85,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { this.isPaynymNotificationTransaction = false, this.isTokenTx = false, this.onSuccessInsteadOfRouteOnSuccess, + this.openCryptoPayHandler, }); static const String routeName = "/confirmTransactionView"; @@ -95,6 +99,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { final bool isTokenTx; final VoidCallback? onSuccessInsteadOfRouteOnSuccess; final VoidCallback onSuccess; + final OpenCryptoPaySendHandler? openCryptoPayHandler; @override ConsumerState createState() => @@ -195,9 +200,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(); } @@ -274,9 +278,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(); } @@ -310,38 +313,38 @@ 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 sendProgressController = ProgressAndSuccessController(); - var isSendingDialogOpen = true; + final ocp = _activeOcp; - void closeSendingDialog() { - if (!context.mounted || !isSendingDialogOpen) { - return; - } - final navigator = Navigator.of(context, rootNavigator: true); - if (navigator.canPop()) { - navigator.pop(); - } - isSendingDialogOpen = false; + if (ocp != null && ocp.isQuoteExpired) { + // Abort before anything is broadcast or submitted (both proof types). + await ocp.showQuoteExpiredError(context, paymentNotSent: true); + return; } - unawaited( - showDialog( - context: context, - useRootNavigator: true, - useSafeArea: false, - barrierDismissible: false, - builder: (context) { - return SendingTransactionDialog( - coin: coin, - controller: sendProgressController, - ); - }, - ).whenComplete(() => isSendingDialogOpen = false), - ); + if (ocp != null && !ocp.requiresBroadcast) { + // Signed-hex proof type: the provider broadcasts the transaction. + return await _submitOpenCryptoPayHexProof(context, ocp, wallet); + } + + final sendingDialog = _showSendingDialog(context, coin); + final sendProgressController = sendingDialog.controller; + final closeSendingDialog = sendingDialog.close; final time = Future.delayed(const Duration(milliseconds: 2500)); @@ -444,9 +447,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) { @@ -454,42 +454,40 @@ class _ConfirmTransactionViewState } else { txids.add(confirmedTx.txid!); } + + if (ocp != null && txids.isNotEmpty && context.mounted) { + // Broadcast (txid) proof type: submit the txid to the + // OpenCryptoPay provider. + 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; + } + + 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 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(); - } - } + _navigateOnSuccess(context); } on BadHttpAddressException catch (_) { if (context.mounted) { // pop building dialog @@ -502,11 +500,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(); @@ -563,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: () { @@ -580,6 +580,173 @@ 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), + ); + + /// Marks the inputs of a signed transaction handed to the provider as used. + 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)); + } + + 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) { + 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. + sendingDialog.close(); + _discardOverriddenRequest(); + // Pick up a transaction the provider may have broadcast anyway. + _refreshAfterSend(wallet); + return; + } + + // 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); + } + + _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(); @@ -610,12 +777,59 @@ 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) { + 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)); final String unit; final wallet = ref.watch(pWallets).getWallet(walletId); + 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. @@ -632,54 +846,21 @@ 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, 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) { @@ -764,21 +945,19 @@ 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 (businessDetails.isNotEmpty) const SizedBox(height: 12), + if (businessDetails.isNotEmpty) + RoundedWhiteContainer( + child: OpenCryptoPayBusinessDetails( + details: businessDetails, + ), + ), const SizedBox(height: 12), RoundedWhiteContainer( child: Row( @@ -913,18 +1092,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, @@ -1091,9 +1270,9 @@ class _ConfirmTransactionViewState ), Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), Padding( padding: const EdgeInsets.all(12), @@ -1114,34 +1293,39 @@ 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, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], ), ), + if (businessDetails.isNotEmpty) + Container( + height: 1, + color: Theme.of(context) + .extension()! + .background, + ), + if (businessDetails.isNotEmpty) + Padding( + padding: const EdgeInsets.all(12), + child: OpenCryptoPayBusinessDetails( + details: businessDetails, + ), + ), if (widget.isPaynymTransaction) Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), if (widget.isPaynymTransaction) Padding( @@ -1163,9 +1347,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1174,9 +1358,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( @@ -1198,9 +1382,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1331,9 +1515,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(() {}), @@ -1393,9 +1577,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), @@ -1424,9 +1608,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), @@ -1444,22 +1628,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()! @@ -1471,13 +1654,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()! @@ -1495,16 +1677,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), @@ -1513,9 +1695,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, ), ), ), @@ -1532,6 +1714,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, diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 13615a092d..bbd753a984 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'; @@ -83,6 +84,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'; @@ -157,6 +159,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 @@ -255,6 +267,12 @@ class _SendViewState extends ConsumerState { if (content.contains("\n")) { content = content.substring(0, content.indexOf("\n")).trim(); } + if (OpenCryptoPayController.isOpenCryptoPayUri(content)) { + if (!mounted) return; + _setOpReturnData(null); + unawaited(_openCryptoPay.handle(context, content)); + return; + } try { final paymentData = AddressUtils.parsePaymentUri( @@ -325,6 +343,13 @@ class _SendViewState extends ConsumerState { Logging.instance.d("qrResult content: ${qrResult.rawContent}"); if (qrResult.rawContent == null) return; + if (OpenCryptoPayController.isOpenCryptoPayUri(qrResult.rawContent)) { + if (!mounted) return; + _setOpReturnData(null); + unawaited(_openCryptoPay.handle(context, qrResult.rawContent!)); + return; + } + final paymentData = AddressUtils.parsePaymentUri( qrResult.rawContent!, logging: Logging.instance, @@ -917,6 +942,23 @@ 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, + feeRateApplies: + coin is! Firo || + ref.read(publicPrivateBalanceStateProvider) == BalanceType.public, + ); + if (!mounted) return; + if (fee == null) return; + try { bool wasCancelled = false; @@ -947,8 +989,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( @@ -1088,7 +1129,7 @@ class _SendViewState extends ConsumerState { memo: memo, feeRateType: feeRateType, satsPerVByte: satsPerVByte, - ethEIP1559Fee: _ethFee.value, + ethEIP1559Fee: ethFee, utxos: (wallet is CoinControlInterface && wallet is! SalviumWallet && @@ -1133,6 +1174,7 @@ class _SendViewState extends ConsumerState { clearSendForm(); } }, + openCryptoPayHandler: _openCryptoPay, ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, @@ -1181,6 +1223,7 @@ class _SendViewState extends ConsumerState { } void clearSendForm() { + _openCryptoPay.reset(); if (!mounted) { return; } @@ -1315,6 +1358,17 @@ 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)) + .formatEditable(parsed); + ref.read(pSendAmount.notifier).state = parsed; + }, + setValidAddress: _openCryptoPaySetValidAddress, + ); 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 13c66ff01d..5b93b1e11e 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'; @@ -53,6 +54,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'; @@ -128,6 +130,16 @@ class _SolTokenSendViewState extends ConsumerState { Timer? _baseAmountChangedFeeUpdateTimer; late Future _calculateFeesFuture; + 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) { @@ -135,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(); @@ -157,6 +174,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, @@ -523,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( @@ -532,6 +563,7 @@ class _SolTokenSendViewState extends ConsumerState { walletId: walletId, isTokenTx: true, onSuccess: clearSendForm, + openCryptoPayHandler: _openCryptoPay, routeOnSuccessName: SolTokenView.routeName, ), settings: const RouteSettings( @@ -581,6 +613,7 @@ class _SolTokenSendViewState extends ConsumerState { } void clearSendForm() { + _openCryptoPay.reset(); sendToController.text = ""; memoController.text = ""; cryptoAmountController.text = ""; @@ -636,6 +669,25 @@ 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 = Amount.formatEditableDecimal( + parsed.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + _amountToSend = parsed; + _updatePreviewButtonState(_address, parsed); + }, + setValidAddress: _openCryptoPaySetValidAddress, + tokenSymbol: tokenWallet?.tokenSymbol, + tokenDecimals: tokenWallet?.tokenDecimals, + tokenContractAddress: tokenWallet?.tokenMint, + ); + super.initState(); } 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/lib/pages/send_view/sub_widgets/sending_transaction_dialog.dart b/lib/pages/send_view/sub_widgets/sending_transaction_dialog.dart index c16792d116..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,12 +57,15 @@ 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, + 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, @@ -75,18 +78,15 @@ 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!, ), + if (message != null) const SizedBox(height: 24), + if (message != null) + Text(message, style: STextStyles.desktopTextSmall(context)), ], ), ), @@ -102,22 +102,26 @@ 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, style: STextStyles.pageTitleH2(context), ), - const SizedBox( - height: 32, - ), + if (message != null) const SizedBox(height: 8), + if (message != null) + Text( + message, + textAlign: TextAlign.center, + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 32), ], ), ) : StackDialog( title: "Sending transaction", + message: message, icon: ProgressAndSuccess( controller: _progressAndSuccessController!, ), @@ -129,6 +133,8 @@ class _RestoringDialogState extends ConsumerState { class ProgressAndSuccessController { VoidCallback? triggerSuccess; + + final ValueNotifier message = ValueNotifier(null); } class ProgressAndSuccess extends StatefulWidget { @@ -201,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, ), ], ), @@ -233,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/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 5601d41c9a..ab7cc4787f 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'; @@ -57,6 +58,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'; @@ -120,6 +122,16 @@ class _TokenSendViewState extends ConsumerState { final _ethFee = ValueNotifier(null); + 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) { @@ -127,6 +139,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(); @@ -163,6 +180,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, @@ -463,6 +486,19 @@ 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 (!mounted) return; + if (fee == null) return; + try { bool wasCancelled = false; @@ -504,9 +540,9 @@ class _TokenSendViewState extends ConsumerState { )!, ), ], - feeRateType: ref.read(feeRateTypeMobileStateProvider), + feeRateType: fee.feeRateType, note: noteController.text, - ethEIP1559Fee: _ethFee.value, + ethEIP1559Fee: fee.ethFee, ), ); @@ -527,6 +563,7 @@ class _TokenSendViewState extends ConsumerState { walletId: walletId, isTokenTx: true, onSuccess: clearSendForm, + openCryptoPayHandler: _openCryptoPay, routeOnSuccessName: TokenView.routeName, ), settings: const RouteSettings( @@ -576,6 +613,7 @@ class _TokenSendViewState extends ConsumerState { } void clearSendForm() { + _openCryptoPay.reset(); sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; @@ -622,6 +660,22 @@ class _TokenSendViewState extends ConsumerState { _addressToggleFlag = true; } + _openCryptoPay = OpenCryptoPaySendHandler( + coin: coin, + sendToController: sendToController, + onAmountReceived: (parsed) { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .formatEditable(parsed); + _amountToSend = parsed; + _updatePreviewButtonState(_address, parsed); + }, + 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_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 09d2e67979..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 @@ -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'; @@ -127,6 +129,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; @@ -548,6 +551,23 @@ 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, + feeRateApplies: + coin is! Firo || + ref.read(publicPrivateBalanceStateProvider) == BalanceType.public, + ); + if (!mounted) return; + if (fee == null) return; + try { bool wasCancelled = false; @@ -588,8 +608,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; @@ -740,7 +759,7 @@ class _DesktopSendState extends ConsumerState { ref.read(pDesktopUseUTXOs).isNotEmpty) ? ref.read(pDesktopUseUTXOs) : null, - ethEIP1559Fee: _ethFee.value, + ethEIP1559Fee: ethFee, ), ); } @@ -777,6 +796,7 @@ class _DesktopSendState extends ConsumerState { txData: txData, walletId: walletId, onSuccess: clearSendForm, + openCryptoPayHandler: _openCryptoPay, isPaynymTransaction: isPaynymSend, routeOnSuccessName: DesktopHomeView.routeName, ), @@ -852,6 +872,7 @@ class _DesktopSendState extends ConsumerState { } void clearSendForm() { + _openCryptoPay.reset(); if (!mounted) { return; } @@ -934,8 +955,23 @@ 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; + _setOpReturnData(null); + unawaited(_openCryptoPay.handle(context, qrCodeData)); + return; + } + final paymentData = AddressUtils.parsePaymentUri( qrCodeData, logging: Logging.instance, @@ -1077,6 +1113,12 @@ class _DesktopSendState extends ConsumerState { if (content.contains("\n")) { content = content.substring(0, content.indexOf("\n")).trim(); } + if (OpenCryptoPayController.isOpenCryptoPayUri(content)) { + if (!mounted) return; + _setOpReturnData(null); + unawaited(_openCryptoPay.handle(context, content)); + return; + } try { final paymentData = AddressUtils.parsePaymentUri( @@ -1269,6 +1311,17 @@ class _DesktopSendState extends ConsumerState { onCryptoAmountChanged = _cryptoAmountChanged; cryptoAmountController.addListener(onCryptoAmountChanged); + _openCryptoPay = OpenCryptoPaySendHandler( + coin: coin, + sendToController: sendToController, + onAmountReceived: (parsed) { + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .formatEditable(parsed); + ref.read(pSendAmount.notifier).state = parsed; + }, + setValidAddress: _openCryptoPaySetValidAddress, + ); 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 9c84751966..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 @@ -14,11 +14,13 @@ 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/isar/models/solana/sol_contract.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'; @@ -46,6 +48,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'; @@ -117,6 +120,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(() { @@ -292,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, @@ -302,6 +323,7 @@ class _DesktopSolTokenSendState extends ConsumerState { txData: txData, walletId: walletId, onSuccess: clearSendForm, + openCryptoPayHandler: _openCryptoPay, isTokenTx: true, routeOnSuccessName: DesktopHomeView.routeName, ), @@ -376,6 +398,7 @@ class _DesktopSolTokenSendState extends ConsumerState { } void clearSendForm() { + _openCryptoPay.reset(); sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; @@ -484,6 +507,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, @@ -557,6 +586,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; @@ -670,6 +704,24 @@ class _DesktopSolTokenSendState extends ConsumerState { _addressToggleFlag = true; } + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + _openCryptoPay = OpenCryptoPaySendHandler( + coin: coin, + sendToController: sendToController, + onAmountReceived: (parsed) { + cryptoAmountController.text = Amount.formatEditableDecimal( + parsed.decimal, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + _amountToSend = parsed; + _updatePreviewButtonState(_address, parsed); + }, + setValidAddress: _openCryptoPaySetValidAddress, + tokenSymbol: tokenWallet?.tokenSymbol, + tokenDecimals: tokenWallet?.tokenDecimals, + tokenContractAddress: tokenWallet?.tokenMint, + ); + super.initState(); } @@ -1044,6 +1096,13 @@ 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 9388a2230e..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 @@ -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'; @@ -51,6 +53,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'; @@ -115,6 +118,16 @@ class _DesktopTokenSendState extends ConsumerState { bool get _nonceIsValid => _nonceInput.isValid; + late final OpenCryptoPaySendHandler _openCryptoPay; + + void _openCryptoPaySetValidAddress(String address) { + _address = address; + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + Future previewSend() async { final nonceInput = _nonceInput; if (!nonceInput.isValid) return; @@ -207,6 +220,19 @@ 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 (!mounted) return; + if (fee == null) return; + try { bool wasCancelled = false; @@ -255,9 +281,9 @@ class _DesktopTokenSendState extends ConsumerState { )!, ), ], - feeRateType: ref.read(feeRateTypeDesktopStateProvider), + feeRateType: fee.feeRateType, nonce: nonce, - ethEIP1559Fee: _ethFee.value, + ethEIP1559Fee: fee.ethFee, ), ); @@ -281,6 +307,7 @@ class _DesktopTokenSendState extends ConsumerState { txData: txData, walletId: walletId, onSuccess: clearSendForm, + openCryptoPayHandler: _openCryptoPay, isTokenTx: true, routeOnSuccessName: DesktopHomeView.routeName, ), @@ -355,6 +382,7 @@ class _DesktopTokenSendState extends ConsumerState { } void clearSendForm() { + _openCryptoPay.reset(); sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; @@ -459,6 +487,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, @@ -536,6 +570,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; @@ -647,6 +686,23 @@ 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)) + .formatEditable(parsed); + _amountToSend = parsed; + _updatePreviewButtonState(_address, parsed); + }, + setValidAddress: _openCryptoPaySetValidAddress, + tokenSymbol: tokenContract?.symbol, + tokenDecimals: tokenContract?.decimals, + tokenContractAddress: tokenContract?.address, + ); + _cryptoFocus.addListener(() { if (!_cryptoFocus.hasFocus && !_baseFocus.hasFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1042,6 +1098,13 @@ class _DesktopTokenSendState extends ConsumerState { }, child: const AddressBookIcon(), ), + if (sendToController.text.isEmpty) + TextFieldIconButton( + semanticsLabel: "Scan QR Button. Opens Camera For Scanning QR Code.", + key: const Key("sendViewScanQrButtonKey"), + onTap: scanQr, + child: const QrCodeIcon(), + ), ], ), ), diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index f5444c7428..4d40851c9c 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, @@ -150,6 +149,35 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { return web3.Web3Client(node.host, client); } + /// 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, + }) async { + if (_credentials == null) { + await _initCredentials(); + } + // 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( + complete, + _credentials!, + chainId: chainId.toInt(), + ); + if (tx.isEIP1559) { + signed = web3.prependTransactionType(0x02, signed); + } + 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 = @@ -671,11 +699,20 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { cryptoCurrency.fractionDigits, ); - return txData.copyWith( - nonce: tx.nonce, - web3dartTransaction: tx, - fee: feeEstimate, - 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 1c61f297b4..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,11 +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, + ); + 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, ); } diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 12a7efaa39..4495144e0b 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -276,6 +276,10 @@ dependencies: # required for web3dart to use EthereumAddress class... wallet: 0.0.18 + opencryptopay: + git: + url: https://github.com/cypherstack/opencryptopay + ref: b4466c483d3a700c3d03b3c8720503caf439da86 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..6ec939ef42 --- /dev/null +++ b/test/pages/open_crypto_pay/open_crypto_pay_send_handler_test.dart @@ -0,0 +1,1569 @@ +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'; +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'; +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'; + +// 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.'; + +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, + num btcMinFee = 0, + num ethMinFee = 0, +}) => { + "id": "pl_test", + "tag": "payRequest", + "callback": _callbackUrl, + "displayName": "Test Shop", + if (recipient != null) "recipient": recipient, + "quote": { + "id": "plq_test", + "expiration": quoteExpiration, + "payment": "plp_test", + }, + "transferAmounts": [ + { + "method": "Bitcoin", + "minFee": btcMinFee, + "assets": [ + {"asset": "BTC", "amount": "0.00001947"}, + ], + "available": true, + }, + { + "method": "Ethereum", + "minFee": ethMinFee, + "assets": [ + {"asset": "USDT", "amount": "1.246858"}, + ], + "available": true, + }, + ], +}; + +Map _btcDetailsJson({ + required String hint, + bool withAmount = true, +}) => { + "expiryDate": "2100-01-01T00:00:00.000Z", + "blockchain": "Bitcoin", + "uri": + "bitcoin:$_btcAddress?${withAmount ? "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, + 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, + ); + } + 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; +} + +/// 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 { + 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, + String? tokenContractAddress, +}) { + 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, + tokenSymbol: tokenSymbol, + tokenDecimals: tokenDecimals, + tokenContractAddress: tokenContractAddress, + 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(); +} + +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", () { + 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); + expect(setup.handler.businessDetails, [ + (label: "Name", value: "Test Shop", uri: null), + ]); + }); + + 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.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), + ]); + }); + + 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.businessDetails, [ + (label: "Legal name", value: "Test Shop", uri: null), + (label: "Postal address", value: "Bahnhofstrasse\nZug", uri: null), + ]); + }); + + 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, + // Checksum case; the request names it in lower case. + tokenContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + 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("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); + 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("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 { + 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.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 marks the request overridden", ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final setup = await pendingPayment(tester, harness); + expect(setup.handler.quoteOverridden, isFalse); + + 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.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"), + isTrue, + ); + expect(requests.length, requestsBefore); + + final fut = setup.handler.confirmSend( + harness.context, + "bc1qother", + _btc(1), + ); + await tester.pump(); + expect(dialogTitle, findsNothing); + expect(await fut, 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.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, + bool feeRateApplies = true, + String? tap, + String? message, + }) async { + final fut = handler.sendFee( + harness.context, + wallet, + address: address, + amount: _btc(1000), + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, + ethFee: ethFee, + feeRateApplies: feeRateApplies, + ); + 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 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); + 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); + }); + + 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); + await expectFeeUnknown( + tester, + harness, + setup.handler, + _FakeUtxoWallet.offline(), + ); + }); + + 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. + 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); + + // base 10 + tip 2 = 12 gwei meets the minimum. + final customFee = EthEIP1559Fee( + maxFeePerGasGwei: Decimal.fromInt(15), + maxPriorityFeePerGasGwei: Decimal.fromInt(2), + 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 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 { + 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(21)); + 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(21)); + 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(40)); + 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", + (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 shows a dialog and retains the payment for retry", ( + 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), + proofStatus: 500, + ), + ); + + 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)); + + expect(find.text(OpenCryptoPayStrings.proofFailedTitle), findsOneWidget); + expect(find.text(OpenCryptoPayStrings.proofFailed), findsOneWidget); + await _tapButton(tester, "Cancel"); + + expect(await fut, isFalse); + 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 _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); + 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); + }); + }); +}