diff --git a/lib/models/send_view_auto_fill_data.dart b/lib/models/send_view_auto_fill_data.dart index bb8817ca2..0687af324 100644 --- a/lib/models/send_view_auto_fill_data.dart +++ b/lib/models/send_view_auto_fill_data.dart @@ -10,17 +10,24 @@ import 'package:decimal/decimal.dart'; +import '../services/open_crypto_pay/models.dart'; + class SendViewAutoFillData { final String address; final String contactLabel; final Decimal? amount; final String note; + /// When set, ConfirmTransactionView completes the OpenCryptoPay submission + /// flow for the prepared transaction. + final OpenCryptoPayCommit? openCryptoPayCommit; + SendViewAutoFillData({ required this.address, required this.contactLabel, this.amount, this.note = "", + this.openCryptoPayCommit, }); Map toJson() { diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart new file mode 100644 index 000000000..7a42c05b9 --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -0,0 +1,483 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tuple/tuple.dart'; + +import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/send_view_auto_fill_data.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/db/main_db_provider.dart'; +import '../../providers/providers.dart'; +import '../../services/open_crypto_pay/evm_uri.dart'; +import '../../services/open_crypto_pay/method_support.dart'; +import '../../services/open_crypto_pay/models.dart'; +import '../../services/open_crypto_pay/open_crypto_pay_api.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/address_utils.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/wallet/impl/ethereum_wallet.dart'; +import '../../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import '../../wallets/wallet/wallet.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/loading_indicator.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../send_view/send_view.dart'; +import '../send_view/token_send_view.dart'; + +enum OpenCryptoPayConfirmResult { quoteExpired } + +/// Fetches the transaction details for the selected method/asset, shows a +/// summary, then forwards to the standard [SendView] prefilled with the +/// payment address and amount. +class OpenCryptoPayConfirmView extends ConsumerStatefulWidget { + const OpenCryptoPayConfirmView({ + super.key, + required this.paymentDetails, + required this.selectedMethod, + required this.selectedAsset, + required this.walletId, + required this.coin, + }); + + final OpenCryptoPayPaymentDetails paymentDetails; + final OpenCryptoPayTransferMethod selectedMethod; + final OpenCryptoPayAsset selectedAsset; + final String walletId; + final CryptoCurrency coin; + + @override + ConsumerState createState() => + _OpenCryptoPayConfirmViewState(); +} + +class _OpenCryptoPayConfirmViewState + extends ConsumerState { + OpenCryptoPayTransactionDetails? _txDetails; + bool _isLoading = true; + String? _errorMessage; + + DateTime? get _expiresAt => + _txDetails?.expiryDate ?? widget.paymentDetails.quote?.expiration; + + bool get _isExpired { + final expiresAt = _expiresAt; + return expiresAt != null && expiresAt.isBefore(DateTime.now()); + } + + @override + void initState() { + super.initState(); + _fetch(); + } + + Future _fetch() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final quote = widget.paymentDetails.quote; + if (quote == null) { + throw Exception("No quote provided by the payment provider"); + } + _txDetails = await OpenCryptoPayApi.instance.getTransactionDetails( + callbackUrl: widget.paymentDetails.callback, + quoteId: quote.id, + method: widget.selectedMethod.method, + asset: widget.selectedAsset.asset, + ); + } catch (e, s) { + Logging.instance.e( + "OpenCryptoPay tx fetch failed", + error: e, + stackTrace: s, + ); + _errorMessage = 'Failed to fetch transaction details: $e'; + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + /// Parses address and amount from the transaction URI. For EVM URIs this + /// also extracts the EIP-681 `@chainId` suffix that [AddressUtils] leaves + /// attached to the address. + ({String? address, Decimal? amount, int? chainId, String? scheme}) + _parseTransactionUri(String uri) { + final evmUri = OpenCryptoPayEvmUri.tryParse(uri); + if (evmUri != null && !evmUri.isTokenTransfer) { + return ( + address: evmUri.targetAddress, + amount: evmUri.isNativeTransfer + ? evmUri.amount(fractionDigits: widget.coin.fractionDigits) + : Decimal.tryParse(widget.selectedAsset.amount), + chainId: evmUri.chainId, + scheme: evmUri.scheme, + ); + } + + final parsedUri = Uri.tryParse(uri); + final data = AddressUtils.parsePaymentUri(uri, logging: Logging.instance); + var address = data?.address ?? parsedUri?.path; + int? chainId; + if (address != null) { + final at = address.indexOf('@'); + if (at != -1) { + chainId = int.tryParse(address.substring(at + 1)); + address = address.substring(0, at); + } + if (address.isEmpty) address = null; + } + final amount = data?.amount != null + ? Decimal.tryParse(data!.amount!) + : Decimal.tryParse(widget.selectedAsset.amount); + return ( + address: address, + amount: amount, + chainId: chainId, + scheme: data?.scheme ?? parsedUri?.scheme, + ); + } + + EthContract? _enabledErc20Token(String contractAddress) { + final normalized = contractAddress.toLowerCase(); + final mainDB = ref.read(mainDBProvider); + for (final address in ref.read(pWalletTokenAddresses(widget.walletId))) { + final contract = mainDB.getEthContractSync(address); + if (contract == null || contract.type != EthContractType.erc20) { + continue; + } + if (contract.address.toLowerCase() == normalized) { + return contract; + } + } + return null; + } + + Future _loadTokenWallet(EthContract contract) async { + final wallet = ref.read(pWallets).getWallet(widget.walletId); + if (wallet is! EthereumWallet) { + throw Exception("Ethereum wallet not loaded"); + } + + final old = ref.read(tokenServiceStateProvider); + final tokenWallet = + Wallet.loadTokenWallet(ethWallet: wallet, contract: contract) + as EthTokenWallet; + await tokenWallet.init(); + unawaited(old?.exit()); + ref.read(tokenServiceStateProvider.state).state = tokenWallet; + return tokenWallet; + } + + Future _proceedToSend() async { + if (_isExpired) { + _warn("Quote expired, refreshing..."); + if (mounted) { + Navigator.of(context).pop(OpenCryptoPayConfirmResult.quoteExpired); + } + return; + } + + final uri = _txDetails?.uri; + if (uri == null) { + _warn("No transaction URI provided by the payment provider"); + return; + } + + if (_txDetails?.blockchain != null && + _txDetails!.blockchain != widget.selectedMethod.method) { + _warn("Payment details do not match the selected method"); + return; + } + + final submissionFlow = OpenCryptoPayMethodSupport.submissionFlowFor( + widget.selectedMethod.method, + ); + if (submissionFlow == null || + submissionFlow == OpenCryptoPaySubmissionFlow.external) { + _warn("This Open CryptoPay method is not supported yet"); + return; + } + + final expiresAt = _expiresAt; + if (expiresAt == null) { + _warn("No quote expiration provided by the payment provider"); + return; + } + + final recipient = + widget.paymentDetails.recipient?.name ?? + widget.paymentDetails.displayName ?? + "OpenCryptoPay"; + + final evmUri = widget.selectedMethod.method == 'Ethereum' + ? OpenCryptoPayEvmUri.tryParse(uri) + : null; + if (widget.selectedMethod.method == 'Ethereum') { + if (evmUri == null) { + _warn("Could not parse Ethereum payment details"); + return; + } + if (evmUri.chainId != null && evmUri.chainId != 1) { + _warn("Payment URI is for a different Ethereum network"); + return; + } + if (evmUri.functionName != null && !evmUri.isTokenTransfer) { + _warn("Unsupported Ethereum payment request"); + return; + } + if (evmUri.isTokenTransfer) { + if (evmUri.chainId != 1) { + _warn("Payment URI is for a different Ethereum network"); + return; + } + if (widget.selectedAsset.asset.toUpperCase() == + widget.coin.ticker.toUpperCase()) { + _warn("Payment token details are invalid"); + return; + } + await _proceedToTokenSend( + evmUri: evmUri, + expiresAt: expiresAt, + recipient: recipient, + submissionFlow: submissionFlow, + ); + return; + } + if (widget.selectedAsset.asset.toUpperCase() != + widget.coin.ticker.toUpperCase()) { + _warn("Payment token details are invalid"); + return; + } + } + + final parsed = _parseTransactionUri(uri); + if (parsed.address == null) { + _warn("Could not parse payment address"); + return; + } + if (parsed.amount == null) { + _warn("Could not parse payment amount"); + return; + } + if (parsed.scheme != null && + parsed.scheme!.isNotEmpty && + parsed.scheme != widget.coin.uriScheme) { + _warn("Payment URI does not match this wallet"); + return; + } + + if (!mounted) return; + await Navigator.of(context).pushNamed( + SendView.routeName, + arguments: Tuple3( + widget.walletId, + widget.coin, + SendViewAutoFillData( + address: parsed.address!, + contactLabel: recipient, + amount: parsed.amount, + note: "OpenCryptoPay: $recipient", + openCryptoPayCommit: OpenCryptoPayCommit( + callbackUrl: widget.paymentDetails.callback, + quoteId: widget.paymentDetails.quote!.id, + paymentId: widget.paymentDetails.quote!.paymentId, + method: widget.selectedMethod.method, + asset: widget.selectedAsset.asset, + expiresAt: expiresAt, + submissionFlow: submissionFlow, + minFee: widget.selectedMethod.minFee, + recipientAddress: parsed.address!, + amount: parsed.amount!, + ), + ), + ), + ); + } + + Future _proceedToTokenSend({ + required OpenCryptoPayEvmUri evmUri, + required DateTime expiresAt, + required String recipient, + required OpenCryptoPaySubmissionFlow submissionFlow, + }) async { + final contract = _enabledErc20Token(evmUri.targetAddress); + if (contract == null) { + _warn("This token is not enabled in this wallet"); + return; + } + if (contract.symbol.toUpperCase() != + widget.selectedAsset.asset.toUpperCase()) { + _warn("Payment token does not match the selected asset"); + return; + } + + try { + await _loadTokenWallet(contract); + } catch (e, s) { + Logging.instance.e( + "OpenCryptoPay token wallet load failed", + error: e, + stackTrace: s, + ); + _warn("Could not load token wallet"); + return; + } + + final amount = evmUri.amount(fractionDigits: contract.decimals); + if (!mounted) return; + await Navigator.of(context).pushNamed( + TokenSendView.routeName, + arguments: Tuple4( + widget.walletId, + widget.coin, + contract, + SendViewAutoFillData( + address: evmUri.recipientAddress!, + contactLabel: recipient, + amount: amount, + note: "OpenCryptoPay: $recipient", + openCryptoPayCommit: OpenCryptoPayCommit( + callbackUrl: widget.paymentDetails.callback, + quoteId: widget.paymentDetails.quote!.id, + paymentId: widget.paymentDetails.quote!.paymentId, + method: widget.selectedMethod.method, + asset: widget.selectedAsset.asset, + expiresAt: expiresAt, + submissionFlow: submissionFlow, + minFee: widget.selectedMethod.minFee, + recipientAddress: evmUri.recipientAddress!, + amount: amount, + tokenContractAddress: contract.address, + ), + ), + ), + ); + } + + void _warn(String message) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: message, + context: context, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + leading: const AppBarBackButton(), + title: Text( + "Confirm Payment", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: _body()), + ), + ), + ); + } + + Widget _body() { + if (_isLoading) return const Center(child: LoadingIndicator()); + + if (_errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _errorMessage!, + style: STextStyles.itemSubtitle(context), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + PrimaryButton(label: "Retry", onPressed: _fetch), + ], + ), + ); + } + + final details = widget.paymentDetails; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Payment Summary", + style: STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 8), + if (details.recipient?.name != null) + _row("To", details.recipient!.name!), + if (details.requestedAmount != null) + _row( + "Fiat amount", + "${details.requestedAmount!.amount} " + "${details.requestedAmount!.asset}", + ), + _row( + "Crypto amount", + "${widget.selectedAsset.amount} " + "${widget.selectedAsset.asset}", + ), + _row("Network", widget.selectedMethod.method), + ], + ), + ), + if (_txDetails?.hint != null) ...[ + const SizedBox(height: 16), + RoundedWhiteContainer( + child: Text(_txDetails!.hint!, style: STextStyles.label(context)), + ), + ], + const SizedBox(height: 24), + PrimaryButton(label: "Proceed to Send", onPressed: _proceedToSend), + ], + ), + ); + } + + Widget _row(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + SizedBox( + width: 100, + child: Text(label, style: STextStyles.label(context)), + ), + Expanded( + child: Text( + value, + style: STextStyles.itemSubtitle(context), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart new file mode 100644 index 000000000..ea0e188a2 --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart @@ -0,0 +1,320 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/db/main_db_provider.dart'; +import '../../services/open_crypto_pay/method_support.dart'; +import '../../services/open_crypto_pay/models.dart'; +import '../../services/open_crypto_pay/open_crypto_pay_api.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/loading_indicator.dart'; +import '../../widgets/rounded_white_container.dart'; +import 'open_crypto_pay_confirm_view.dart'; + +/// Shows the payment details from an Open CryptoPay QR code and lets the user +/// choose a payment method/asset that is supported by this wallet. +class OpenCryptoPayView extends ConsumerStatefulWidget { + const OpenCryptoPayView({ + super.key, + required this.qrUrl, + required this.walletId, + required this.coin, + }); + + static const String routeName = "/openCryptoPayView"; + + final String qrUrl; + + /// Only methods/assets this wallet can safely settle are offered. + final String walletId; + final CryptoCurrency coin; + + @override + ConsumerState createState() => _OpenCryptoPayViewState(); +} + +class _OpenCryptoPayViewState extends ConsumerState { + OpenCryptoPayPaymentDetails? _details; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _fetch(); + } + + Future _fetch() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final details = await OpenCryptoPayApi.instance.getPaymentDetails( + widget.qrUrl, + ); + if (mounted) setState(() => _details = details); + } on OpenCryptoPayNoPendingPaymentException catch (e) { + if (mounted) setState(() => _errorMessage = e.message); + } catch (e, s) { + Logging.instance.e("OpenCryptoPay fetch failed", error: e, stackTrace: s); + if (mounted) setState(() => _errorMessage = 'Failed to fetch: $e'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + bool _isSupportedOption( + OpenCryptoPayTransferMethod method, + OpenCryptoPayAsset asset, + Iterable enabledErc20Tokens, + ) { + return OpenCryptoPayMethodSupport.isSupportedWalletOption( + coin: widget.coin, + method: method, + asset: asset, + enabledErc20Symbols: enabledErc20Tokens.map((e) => e.symbol), + ); + } + + List _enabledErc20Tokens() { + if (widget.coin is! Ethereum) return const []; + final mainDB = ref.watch(mainDBProvider); + return ref + .watch(pWalletTokenAddresses(widget.walletId)) + .map(mainDB.getEthContractSync) + .whereType() + .where((e) => e.type == EthContractType.erc20) + .toList(); + } + + Future _onSelected( + OpenCryptoPayTransferMethod method, + OpenCryptoPayAsset asset, + ) async { + final quote = _details?.quote; + if (quote == null) return; + + if (quote.isExpired) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Quote expired, refreshing...", + context: context, + ), + ); + await _fetch(); + return; + } + + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => OpenCryptoPayConfirmView( + paymentDetails: _details!, + selectedMethod: method, + selectedAsset: asset, + walletId: widget.walletId, + coin: widget.coin, + ), + ), + ); + + if (result == OpenCryptoPayConfirmResult.quoteExpired && mounted) { + await _fetch(); + } + } + + @override + Widget build(BuildContext context) { + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + leading: const AppBarBackButton(), + title: Text( + "Open CryptoPay", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: _body()), + ), + ), + ); + } + + Widget _body() { + if (_isLoading) return const Center(child: LoadingIndicator()); + + if (_errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _errorMessage!, + style: STextStyles.itemSubtitle(context), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + PrimaryButton(label: "Retry", onPressed: _fetch), + ], + ), + ); + } + + final details = _details; + if (details == null) { + return const Center(child: Text("No payment data")); + } + + final enabledErc20Tokens = _enabledErc20Tokens(); + + // Flatten into (method, asset) pairs that this wallet can safely settle. + final options = [ + for (final m in details.availableMethods) + for (final a in m.assets) + if (_isSupportedOption(m, a, enabledErc20Tokens)) + (method: m, asset: a), + ]; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (details.recipient != null) ...[ + _recipientCard(details.recipient!), + const SizedBox(height: 16), + ], + if (details.requestedAmount != null) ...[ + _amountCard(details), + const SizedBox(height: 16), + ], + Text( + "Select Payment Method", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + if (options.isEmpty) + RoundedWhiteContainer( + child: Text( + "No supported Open CryptoPay option available for " + "${widget.coin.prettyName}.", + style: STextStyles.itemSubtitle(context), + ), + ) + else + ...options.map( + (o) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _methodCard(o.method, o.asset), + ), + ), + if (details.quote != null) ...[ + const SizedBox(height: 8), + Text( + "Quote expires: ${details.quote!.expiration.toLocal()}", + style: STextStyles.label(context), + ), + ], + ], + ), + ); + } + + Widget _recipientCard(OpenCryptoPayRecipient recipient) { + return RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Recipient", style: STextStyles.itemSubtitle12(context)), + if (recipient.name != null) ...[ + const SizedBox(height: 4), + Text(recipient.name!, style: STextStyles.titleBold12(context)), + ], + if (recipient.formattedAddress.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + recipient.formattedAddress, + style: STextStyles.itemSubtitle(context), + ), + ], + ], + ), + ); + } + + Widget _amountCard(OpenCryptoPayPaymentDetails details) { + return RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Amount Due", style: STextStyles.itemSubtitle12(context)), + const SizedBox(height: 4), + Text( + "${details.requestedAmount!.amount} ${details.requestedAmount!.asset}", + style: STextStyles.pageTitleH2(context), + ), + if (details.displayName != null) ...[ + const SizedBox(height: 4), + Text( + details.displayName!, + style: STextStyles.itemSubtitle(context), + ), + ], + ], + ), + ); + } + + Widget _methodCard( + OpenCryptoPayTransferMethod method, + OpenCryptoPayAsset asset, + ) { + return GestureDetector( + onTap: () => _onSelected(method, asset), + child: RoundedWhiteContainer( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${asset.amount} ${asset.asset}", + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 2), + Text( + "via ${method.method}", + style: STextStyles.itemSubtitle(context), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: Theme.of( + context, + ).extension()!.textFieldDefaultSearchIconLeft, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index dfd6c98bd..4bfbcc075 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../models/input.dart'; import '../../models/isar/models/transaction_note.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; @@ -24,6 +25,8 @@ import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/deskt import '../../providers/providers.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; +import '../../services/open_crypto_pay/models.dart'; +import '../../services/open_crypto_pay/open_crypto_pay_api.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; import '../../utilities/amount/amount.dart'; @@ -32,19 +35,24 @@ import '../../utilities/constants.dart'; import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/coins/bitcoin.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/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'; import '../../wallets/models/tx_data.dart'; import '../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../wallets/wallet/impl/ethereum_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/impl/sub_wallets/eth_token_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; +import '../../wallets/wallet/wallet.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -76,6 +84,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { this.isPaynymNotificationTransaction = false, this.isTokenTx = false, this.onSuccessInsteadOfRouteOnSuccess, + this.openCryptoPayCommit, }); static const String routeName = "/confirmTransactionView"; @@ -89,6 +98,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { final bool isTokenTx; final VoidCallback? onSuccessInsteadOfRouteOnSuccess; final VoidCallback onSuccess; + final OpenCryptoPayCommit? openCryptoPayCommit; @override ConsumerState createState() => @@ -271,6 +281,37 @@ class _ConfirmTransactionViewState Future _attemptSend(BuildContext context) async { final wallet = ref.read(pWallets).getWallet(walletId); final coin = wallet.info.coin; + final openCryptoPayCommit = widget.openCryptoPayCommit; + + if (openCryptoPayCommit?.isExpired ?? false) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Open CryptoPay quote expired. Please scan again.", + context: context, + ), + ); + } + return; + } + + final openCryptoPayError = _validateOpenCryptoPaySend( + wallet, + openCryptoPayCommit, + ); + if (openCryptoPayError != null) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: openCryptoPayError, + context: context, + ), + ); + } + return; + } final sendProgressController = ProgressAndSuccessController(); @@ -296,7 +337,17 @@ class _ConfirmTransactionViewState final note = noteController.text; try { - if (widget.isTokenTx) { + if (openCryptoPayCommit?.submissionFlow == + OpenCryptoPaySubmissionFlow.rawHexToProvider) { + final submitWallet = widget.isTokenTx + ? ref.read(pCurrentTokenWallet)! + : wallet; + txDataFuture = _submitOpenCryptoPayRawHex( + submitWallet, + widget.txData, + openCryptoPayCommit!, + ); + } else if (widget.isTokenTx) { if (wallet is SolanaWallet) { // For Solana tokens, use the Solana token wallet. txDataFuture = ref @@ -386,15 +437,21 @@ class _ConfirmTransactionViewState final results = await Future.wait([txDataFuture, time]); - sendProgressController.triggerSuccess?.call(); - await Future.delayed(const Duration(seconds: 5)); - if (wallet is FiroWallet && (results.first as TxData).sparkMints != null) { txids.addAll((results.first as TxData).sparkMints!.map((e) => e.txid!)); } else { txids.add((results.first as TxData).txid!); } + + if (openCryptoPayCommit?.submissionFlow == + OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) { + final result = results.first as TxData; + await _commitOpenCryptoPayTxId(openCryptoPayCommit!, result); + } + + sendProgressController.triggerSuccess?.call(); + await Future.delayed(const Duration(seconds: 5)); if (coin is! Ethereum) { ref.refresh(desktopUseUTXOs); } @@ -445,7 +502,9 @@ class _ConfirmTransactionViewState return; } } catch (e, s) { - const message = "Broadcast transaction failed"; + final message = widget.openCryptoPayCommit == null + ? "Broadcast transaction failed" + : "Open CryptoPay payment failed"; Logging.instance.e(message, error: e, stackTrace: s); // pop sending dialog if (context.mounted) { @@ -520,6 +579,270 @@ class _ConfirmTransactionViewState } } + String? _validateOpenCryptoPaySend( + Wallet wallet, + OpenCryptoPayCommit? commit, + ) { + if (commit == null) return null; + + final minFeeError = _validateOpenCryptoPayMinFee(wallet, commit); + if (minFeeError != null) return minFeeError; + + final transactionError = _validateOpenCryptoPayTransaction( + wallet, + commit, + widget.txData, + ); + if (transactionError != null) return transactionError; + + final tokenError = _validateOpenCryptoPayToken(commit); + if (tokenError != null) return tokenError; + + switch (commit.submissionFlow) { + case OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast: + return null; + case OpenCryptoPaySubmissionFlow.rawHexToProvider: + if (wallet is! FiroWallet && + wallet is! EthereumWallet && + wallet.cryptoCurrency is! Bitcoin) { + return "This Open CryptoPay method is not supported yet"; + } + if (wallet is EthereumWallet) { + if (widget.txData.web3dartTransaction == null || + widget.txData.chainId == null) { + return "Could not build signed Ethereum transaction"; + } + } else if (widget.txData.raw == null || widget.txData.raw!.isEmpty) { + return "Could not build signed transaction"; + } + return null; + case OpenCryptoPaySubmissionFlow.external: + return "This Open CryptoPay method is not supported yet"; + } + } + + String? _validateOpenCryptoPayTransaction( + Wallet wallet, + OpenCryptoPayCommit commit, + TxData txData, + ) { + final recipients = _openCryptoPayRecipients(txData); + if (recipients.length != 1) { + return "Open CryptoPay requires exactly one recipient"; + } + + final actual = recipients.single; + if (_normalizeOpenCryptoPayAddress(wallet, actual.address) != + _normalizeOpenCryptoPayAddress(wallet, commit.recipientAddress)) { + return "Open CryptoPay recipient changed. Please scan again."; + } + + if (actual.amount.decimal != commit.amount) { + return "Open CryptoPay amount changed. Please scan again."; + } + + return null; + } + + String? _validateOpenCryptoPayToken(OpenCryptoPayCommit commit) { + final tokenContractAddress = commit.tokenContractAddress; + if (tokenContractAddress == null) return null; + + if (!widget.isTokenTx || commit.method != 'Ethereum') { + return "Open CryptoPay token payment is not supported here"; + } + + final tokenWallet = ref.read(pCurrentTokenWallet); + if (tokenWallet == null) { + return "Could not verify Open CryptoPay token wallet"; + } + + if (tokenWallet.tokenContract.address.toLowerCase() != + tokenContractAddress.toLowerCase()) { + return "Open CryptoPay token contract changed. Please scan again."; + } + + if (tokenWallet.tokenContract.symbol.toUpperCase() != + commit.asset.toUpperCase()) { + return "Open CryptoPay token asset changed. Please scan again."; + } + + return null; + } + + String? _validateOpenCryptoPayMinFee( + Wallet wallet, + OpenCryptoPayCommit commit, + ) { + if (commit.minFee <= Decimal.zero) return null; + + if (wallet is EthereumWallet) { + final gasPrice = + widget.txData.web3dartTransaction?.maxFeePerGas?.getInWei; + if (gasPrice == null) { + return "Could not verify Open CryptoPay minimum gas price"; + } + if (gasPrice < _ceilDecimalToBigInt(commit.minFee)) { + return "Open CryptoPay requires at least " + "${commit.minFee} wei gas price"; + } + return null; + } + + if (wallet.cryptoCurrency is Bitcoin || wallet is FiroWallet) { + final fee = widget.txData.fee; + final vSize = widget.txData.vSize; + if (fee == null || vSize == null || vSize <= 0) { + return "Could not verify Open CryptoPay minimum fee"; + } + final minTotalFee = _ceilDecimalToBigInt( + commit.minFee * Decimal.fromInt(vSize), + ); + if (fee.raw < minTotalFee) { + return "Open CryptoPay requires at least " + "${commit.minFee} sat/vB fee"; + } + } + + return null; + } + + BigInt _ceilDecimalToBigInt(Decimal value) { + final truncated = value.toBigInt(); + if (Decimal.fromBigInt(truncated) == value) { + return truncated; + } + return truncated + BigInt.one; + } + + List<({String address, Amount amount})> _openCryptoPayRecipients( + TxData txData, + ) { + final recipients = <({String address, Amount amount})>[]; + final standardRecipients = txData.recipients; + if (standardRecipients != null) { + for (final recipient in standardRecipients) { + if (!recipient.isChange) { + recipients.add(( + address: recipient.address, + amount: recipient.amount, + )); + } + } + } + final sparkRecipients = txData.sparkRecipients; + if (sparkRecipients != null) { + for (final recipient in sparkRecipients) { + if (!recipient.isChange) { + recipients.add(( + address: recipient.address, + amount: recipient.amount, + )); + } + } + } + return recipients; + } + + String _normalizeOpenCryptoPayAddress(Wallet wallet, String address) { + if (wallet is EthereumWallet) { + return address.toLowerCase(); + } + return address; + } + + Future _submitOpenCryptoPayRawHex( + Wallet wallet, + TxData txData, + OpenCryptoPayCommit commit, + ) async { + txData = await _prepareOpenCryptoPayRawHexTx(wallet, txData); + final raw = txData.raw; + if (raw == null || raw.isEmpty) { + throw Exception("Could not build signed transaction"); + } + + final txid = txData.tempTx?.txid ?? txData.txid ?? txData.txHash; + if (txid == null || txid.isEmpty) { + throw Exception("Could not determine signed transaction ID"); + } + if (commit.isExpired) { + throw Exception("Open CryptoPay quote expired. Please scan again."); + } + + await OpenCryptoPayApi.instance.commitRawHex(commit: commit, hex: raw); + + final updatedInputs = txData.usedUTXOs?.map((e) { + if (e is StandardInput) { + return StandardInput( + e.utxo.copyWith(used: true), + derivePathType: e.derivePathType, + ); + } + return e; + }).toList(); + + final updatedTxData = txData.copyWith( + usedUTXOs: updatedInputs, + txHash: txid, + txid: txid, + ); + + final updatedUtxos = updatedInputs + ?.whereType() + .map((e) => e.utxo) + .toList(); + final mainDB = ref.read(mainDBProvider); + if (updatedUtxos != null && updatedUtxos.isNotEmpty) { + await mainDB.putUTXOs(updatedUtxos); + } + + if (updatedTxData.usedSparkCoins != null && + updatedTxData.usedSparkCoins!.isNotEmpty) { + await mainDB.isar.writeTxn(() async { + await mainDB.isar.sparkCoins.putAll(updatedTxData.usedSparkCoins!); + }); + } + + return await wallet.updateSentCachedTxData(txData: updatedTxData); + } + + Future _commitOpenCryptoPayTxId( + OpenCryptoPayCommit commit, + TxData txData, + ) async { + try { + await OpenCryptoPayApi.instance.commitTxId( + commit: commit, + txId: txData.txid!, + ); + } catch (e, s) { + Logging.instance.e( + "OpenCryptoPay commit failed after local broadcast", + error: e, + stackTrace: s, + ); + throw Exception( + "Open CryptoPay commit failed after broadcasting " + "${txData.txid}: $e", + ); + } + } + + Future _prepareOpenCryptoPayRawHexTx( + Wallet wallet, + TxData txData, + ) async { + if (wallet is EthTokenWallet) { + return await wallet.signSendWithoutBroadcast(txData: txData); + } + if (wallet is EthereumWallet) { + return await wallet.signSendWithoutBroadcast(txData: txData); + } + + return txData; + } + @override void initState() { isDesktop = Util.isDesktop; diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 94b5663c8..a2b5bca59 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -29,6 +29,7 @@ import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; +import '../../services/open_crypto_pay/lnurl_utils.dart'; import '../../services/spark_names_service.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; @@ -81,6 +82,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_view.dart'; import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/dual_balance_selection_sheet.dart'; @@ -304,15 +306,32 @@ class _SendViewState extends ConsumerState { if (paymentData != null && paymentData.coin?.uriScheme == coin.uriScheme) { _applyUri(paymentData); - } else { - _address = qrResult.rawContent!.split("\n").first.trim(); - sendToController.text = _address ?? ""; + return; + } - _setValidAddressProviders(_address); - setState(() { - _addressToggleFlag = sendToController.text.isNotEmpty; - }); + // Check for OpenCryptoPay QR code after standard payment URIs so a + // normal coin URI with a Lightning fallback still follows the usual flow. + if (LnurlUtils.isOpenCryptoPayUrl(qrResult.rawContent!)) { + if (mounted) { + await Navigator.of(context).pushNamed( + OpenCryptoPayView.routeName, + arguments: ( + qrUrl: qrResult.rawContent!, + walletId: walletId, + coin: coin, + ), + ); + } + return; } + + _address = qrResult.rawContent!.split("\n").first.trim(); + sendToController.text = _address ?? ""; + + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); } on PlatformException catch (e, s) { // ref // .read( @@ -1074,6 +1093,7 @@ class _SendViewState extends ConsumerState { walletId: walletId, isPaynymTransaction: isPaynymSend, onSuccess: clearSendForm, + openCryptoPayCommit: _data?.openCryptoPayCommit, ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 3d30fc5f6..49dc6e644 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -57,6 +57,7 @@ import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../address_book_views/address_book_view.dart'; import '../token_view/token_view.dart'; +import '../wallet_view/wallet_view.dart'; import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; @@ -522,7 +523,10 @@ class _TokenSendViewState extends ConsumerState { walletId: walletId, isTokenTx: true, onSuccess: clearSendForm, - routeOnSuccessName: TokenView.routeName, + routeOnSuccessName: _data?.openCryptoPayCommit == null + ? TokenView.routeName + : WalletView.routeName, + openCryptoPayCommit: _data?.openCryptoPayCommit, ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, @@ -613,7 +617,9 @@ class _TokenSendViewState extends ConsumerState { } sendToController.text = _data.contactLabel; _address = _data.address.trim(); + noteController.text = _data.note; _addressToggleFlag = true; + _updatePreviewButtonState(_address, _amountToSend); } super.initState(); diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 74a129efd..3d42f3dd3 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -34,6 +34,7 @@ import '../../services/event_bus/events/global/node_connection_status_changed_ev import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../services/event_bus/global_event_bus.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; +import '../../services/open_crypto_pay/lnurl_utils.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; @@ -71,6 +72,7 @@ import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/custom_loading_overlay.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/frost_scaffold.dart'; +import '../../widgets/icon_widgets/qrcode_icon.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/small_tor_icon.dart'; import '../../widgets/stack_dialog.dart'; @@ -98,6 +100,7 @@ import '../masternodes/masternodes_home_view.dart'; import '../monkey/monkey_view.dart'; import '../namecoin_names/namecoin_names_home_view.dart'; import '../notification_views/notifications_view.dart'; +import '../open_crypto_pay/open_crypto_pay_view.dart'; import '../ordinals/ordinals_view.dart'; import '../paynym/paynym_claim_view.dart'; import '../paynym/paynym_home_view.dart'; @@ -425,6 +428,51 @@ class _WalletViewState extends ConsumerState { } } + Future _onOpenCryptoPayPressed(BuildContext context) async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + + final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + + if (qrResult.rawContent == null) return; + + if (LnurlUtils.isOpenCryptoPayUrl(qrResult.rawContent!)) { + if (mounted) { + unawaited( + Navigator.of(context).pushNamed( + OpenCryptoPayView.routeName, + arguments: ( + qrUrl: qrResult.rawContent!, + walletId: walletId, + coin: coin, + ), + ), + ); + } + } else { + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "The scanned QR code is not an Open CryptoPay payment code.", + context: context, + ), + ); + } + } + } catch (e, s) { + Logging.instance.e( + "Failed to scan QR for OpenCryptoPay", + error: e, + stackTrace: s, + ); + } + } + Future attemptAnonymize() async { bool shouldPop = false; unawaited( @@ -1343,6 +1391,12 @@ class _WalletViewState extends ConsumerState { ); }, ), + if (!viewOnly) + WalletNavigationBarItemData( + label: "Pay", + icon: const QrCodeIcon(), + onTap: () => _onOpenCryptoPayPressed(context), + ), ], ), ), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index cad05cbcd..cb6321452 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -88,6 +88,7 @@ import 'pages/namecoin_names/manage_domain_view.dart'; import 'pages/namecoin_names/namecoin_names_home_view.dart'; import 'pages/namecoin_names/sub_widgets/name_details.dart'; import 'pages/notification_views/notifications_view.dart'; +import 'pages/open_crypto_pay/open_crypto_pay_view.dart'; import 'pages/ordinals/ordinal_details_view.dart'; import 'pages/ordinals/ordinals_filter_view.dart'; import 'pages/ordinals/ordinals_view.dart'; @@ -1863,6 +1864,20 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case OpenCryptoPayView.routeName: + if (args is ({String qrUrl, String walletId, CryptoCurrency coin})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => OpenCryptoPayView( + qrUrl: args.qrUrl, + walletId: args.walletId, + coin: args.coin, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SendView.routeName: if (args is Tuple2) { return getRoute( @@ -1912,6 +1927,23 @@ class RouteGenerator { ), settings: RouteSettings(name: settings.name), ); + } else if (args + is Tuple4< + String, + CryptoCurrency, + EthContract, + SendViewAutoFillData + >) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => TokenSendView( + walletId: args.item1, + coin: args.item2, + tokenContract: args.item3, + autoFillData: args.item4, + ), + settings: RouteSettings(name: settings.name), + ); } return _routeError("${settings.name} invalid args: ${args.toString()}"); diff --git a/lib/services/open_crypto_pay/evm_uri.dart b/lib/services/open_crypto_pay/evm_uri.dart new file mode 100644 index 000000000..fe1746a80 --- /dev/null +++ b/lib/services/open_crypto_pay/evm_uri.dart @@ -0,0 +1,79 @@ +import 'package:decimal/decimal.dart'; + +/// Minimal EIP-681 parser for Open CryptoPay EVM transaction details. +class OpenCryptoPayEvmUri { + final String scheme; + final String targetAddress; + final int? chainId; + final String? functionName; + final String? recipientAddress; + final BigInt? amountRaw; + + const OpenCryptoPayEvmUri({ + required this.scheme, + required this.targetAddress, + required this.chainId, + required this.functionName, + required this.recipientAddress, + required this.amountRaw, + }); + + bool get isTokenTransfer => + functionName == 'transfer' && + recipientAddress != null && + amountRaw != null; + + bool get isNativeTransfer => functionName == null && amountRaw != null; + + Decimal amount({required int fractionDigits}) => + Decimal.fromBigInt(amountRaw!).shift(-fractionDigits); + + static OpenCryptoPayEvmUri? tryParse(String uri) { + final parsed = Uri.tryParse(uri); + if (parsed == null || parsed.scheme != 'ethereum') return null; + + final pathParts = parsed.path.split('/'); + if (pathParts.isEmpty || pathParts.first.isEmpty) return null; + + final targetParts = pathParts.first.split('@'); + final targetAddress = targetParts.first; + if (!_isHexAddress(targetAddress)) return null; + + if (targetParts.length > 2) return null; + final int? chainId; + if (targetParts.length > 1) { + chainId = int.tryParse(targetParts[1]); + if (chainId == null) return null; + } else { + chainId = null; + } + final functionName = pathParts.length > 1 && pathParts[1].isNotEmpty + ? pathParts[1] + : null; + + final recipientAddress = parsed.queryParameters['address']; + final amountRaw = functionName == 'transfer' + ? _parseRawInteger(parsed.queryParameters['uint256']) + : _parseRawInteger(parsed.queryParameters['value']); + + return OpenCryptoPayEvmUri( + scheme: parsed.scheme, + targetAddress: targetAddress, + chainId: chainId, + functionName: functionName, + recipientAddress: + recipientAddress != null && _isHexAddress(recipientAddress) + ? recipientAddress + : null, + amountRaw: amountRaw, + ); + } + + static bool _isHexAddress(String value) => + RegExp(r'^0x[0-9a-fA-F]{40}$').hasMatch(value); + + static BigInt? _parseRawInteger(String? value) { + if (value == null || !RegExp(r'^[0-9]+$').hasMatch(value)) return null; + return BigInt.tryParse(value); + } +} diff --git a/lib/services/open_crypto_pay/lnurl_utils.dart b/lib/services/open_crypto_pay/lnurl_utils.dart new file mode 100644 index 000000000..ec8977e4b --- /dev/null +++ b/lib/services/open_crypto_pay/lnurl_utils.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:bech32/bech32.dart'; + +/// LNURL (LUD-01) helpers scoped to Open CryptoPay QR handling. +/// +/// Stack does not support Lightning in general — this lives under +/// `services/open_crypto_pay/` because OCP is currently the sole consumer. +/// If broader LNURL support is ever added, promote this to `utilities/`. +class LnurlUtils { + /// Decodes a bech32-encoded LNURL string back to a URL. + static String decodeLnurl(String lnurl) { + final decoded = const Bech32Codec().decode(lnurl, lnurl.length); + return utf8.decode(_fromBase32(decoded.data)); + } + + /// Returns true if [url] is an Open CryptoPay QR payload, i.e. has a + /// `lightning` query parameter containing a bech32 LNURL. + static bool isOpenCryptoPayUrl(String url) { + return extractLnurl(url)?.toUpperCase().startsWith('LNURL') ?? false; + } + + /// Returns the `lightning` query parameter, if any. + static String? extractLnurl(String url) { + try { + return Uri.parse(url).queryParameters['lightning']; + } catch (_) { + return null; + } + } + + /// Regroups 5-bit bech32 data into 8-bit bytes. + static List _fromBase32(List data) { + int acc = 0; + int bits = 0; + final result = []; + for (final value in data) { + if (value < 0 || (value >> 5) != 0) { + throw const FormatException('Invalid bech32 data'); + } + acc = (acc << 5) | value; + bits += 5; + while (bits >= 8) { + bits -= 8; + result.add((acc >> bits) & 0xff); + } + } + if (bits >= 5 || ((acc << (8 - bits)) & 0xff) != 0) { + throw const FormatException('Invalid bech32 padding'); + } + return result; + } +} diff --git a/lib/services/open_crypto_pay/method_support.dart b/lib/services/open_crypto_pay/method_support.dart new file mode 100644 index 000000000..722cfdc07 --- /dev/null +++ b/lib/services/open_crypto_pay/method_support.dart @@ -0,0 +1,67 @@ +import '../../wallets/crypto_currency/crypto_currency.dart'; +import 'models.dart'; + +/// Centralizes which Open CryptoPay methods Stack can complete safely with the +/// existing send flow. +class OpenCryptoPayMethodSupport { + const OpenCryptoPayMethodSupport._(); + + static OpenCryptoPaySubmissionFlow? submissionFlowFor(String method) { + switch (method) { + case 'Solana': + case 'Cardano': + return OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast; + // The OCP spec requires Monero callbacks to include both txid and raw + // transaction hex. Stack does not currently expose the raw hex here. + case 'Monero': + return null; + case 'Ethereum': + case 'Polygon': + case 'Arbitrum': + case 'Optimism': + case 'Base': + case 'BinanceSmartChain': + case 'Bitcoin': + case 'Firo': + return OpenCryptoPaySubmissionFlow.rawHexToProvider; + case 'Lightning': + case 'BinancePay': + case 'InternetComputer': + return OpenCryptoPaySubmissionFlow.external; + default: + return null; + } + } + + static bool isSupportedWalletOption({ + required CryptoCurrency coin, + required OpenCryptoPayTransferMethod method, + required OpenCryptoPayAsset asset, + Iterable enabledErc20Symbols = const [], + }) { + final ticker = coin.ticker.toUpperCase(); + final assetTicker = asset.asset.toUpperCase(); + + if (coin is Bitcoin) { + return method.method == 'Bitcoin' && assetTicker == ticker; + } + if (coin is Ethereum) { + if (method.method != 'Ethereum') return false; + if (assetTicker == ticker) return true; + return enabledErc20Symbols + .map((e) => e.toUpperCase()) + .contains(assetTicker); + } + if (coin is Solana) { + return method.method == 'Solana' && assetTicker == ticker; + } + if (coin is Cardano) { + return method.method == 'Cardano' && assetTicker == ticker; + } + if (coin is Firo) { + return method.method == 'Firo' && assetTicker == ticker; + } + + return false; + } +} diff --git a/lib/services/open_crypto_pay/models.dart b/lib/services/open_crypto_pay/models.dart new file mode 100644 index 000000000..fc3fd1be5 --- /dev/null +++ b/lib/services/open_crypto_pay/models.dart @@ -0,0 +1,263 @@ +import 'package:decimal/decimal.dart'; + +/// Data models for the Open CryptoPay standard. +/// +/// See https://github.com/openCryptoPay/landingPage + +enum OpenCryptoPaySubmissionFlow { + /// The wallet broadcasts locally, then sends the resulting txid to `/tx/`. + txIdAfterLocalBroadcast, + + /// The provider broadcasts after receiving raw signed transaction hex. + rawHexToProvider, + + /// Payment is completed outside Stack Wallet, such as Lightning/BinancePay. + external, +} + +class OpenCryptoPayRecipient { + final String? name; + final String? street; + final String? houseNumber; + final String? zip; + final String? city; + final String? country; + + OpenCryptoPayRecipient({ + this.name, + this.street, + this.houseNumber, + this.zip, + this.city, + this.country, + }); + + factory OpenCryptoPayRecipient.fromJson(Map json) { + final address = json['address'] as Map?; + return OpenCryptoPayRecipient( + name: json['name'] as String?, + street: address?['street'] as String?, + houseNumber: address?['houseNumber'] as String?, + zip: address?['zip'] as String?, + city: address?['city'] as String?, + country: address?['country'] as String?, + ); + } + + String get formattedAddress { + final parts = []; + if (street != null) { + parts.add(houseNumber != null ? '$street $houseNumber' : street!); + } + if (zip != null || city != null) { + parts.add([zip, city].whereType().join(' ')); + } + if (country != null) parts.add(country!); + return parts.join(', '); + } +} + +class OpenCryptoPayAsset { + final String asset; + final String amount; + + OpenCryptoPayAsset({required this.asset, required this.amount}); + + factory OpenCryptoPayAsset.fromJson(Map json) { + return OpenCryptoPayAsset( + asset: json['asset'] as String, + amount: json['amount'].toString(), + ); + } +} + +class OpenCryptoPayTransferMethod { + final String method; + final List assets; + final bool available; + final Decimal minFee; + + OpenCryptoPayTransferMethod({ + required this.method, + required this.assets, + required this.available, + required this.minFee, + }); + + factory OpenCryptoPayTransferMethod.fromJson(Map json) { + return OpenCryptoPayTransferMethod( + method: json['method'] as String, + minFee: + Decimal.tryParse(json['minFee']?.toString() ?? '0') ?? Decimal.zero, + assets: (json['assets'] as List) + .map((e) => OpenCryptoPayAsset.fromJson(e as Map)) + .toList(), + available: json['available'] as bool, + ); + } +} + +class OpenCryptoPayQuote { + final String id; + final String paymentId; + final DateTime expiration; + + OpenCryptoPayQuote({ + required this.id, + required this.paymentId, + required this.expiration, + }); + + factory OpenCryptoPayQuote.fromJson(Map json) { + final paymentId = json['payment'] as String?; + if (paymentId == null || paymentId.isEmpty) { + throw Exception('OpenCryptoPay: quote payment id is missing'); + } + + return OpenCryptoPayQuote( + id: json['id'] as String, + paymentId: paymentId, + expiration: DateTime.parse(json['expiration'] as String), + ); + } + + bool get isExpired => expiration.isBefore(DateTime.now()); +} + +class OpenCryptoPayRequestedAmount { + final String asset; + final num amount; + + OpenCryptoPayRequestedAmount({required this.asset, required this.amount}); + + factory OpenCryptoPayRequestedAmount.fromJson(Map json) { + return OpenCryptoPayRequestedAmount( + asset: json['asset'] as String, + amount: json['amount'] as num, + ); + } +} + +class OpenCryptoPayPaymentDetails { + final String id; + final String? standard; + final List possibleStandards; + final String? displayName; + final String callback; + final OpenCryptoPayRecipient? recipient; + final OpenCryptoPayQuote? quote; + final OpenCryptoPayRequestedAmount? requestedAmount; + final List transferAmounts; + + OpenCryptoPayPaymentDetails({ + required this.id, + this.standard, + required this.possibleStandards, + this.displayName, + required this.callback, + this.recipient, + this.quote, + this.requestedAmount, + required this.transferAmounts, + }); + + factory OpenCryptoPayPaymentDetails.fromJson(Map json) { + return OpenCryptoPayPaymentDetails( + id: json['id'] as String, + standard: json['standard'] as String?, + possibleStandards: + (json['possibleStandards'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + displayName: json['displayName'] as String?, + callback: json['callback'] as String? ?? '', + recipient: json['recipient'] == null + ? null + : OpenCryptoPayRecipient.fromJson( + json['recipient'] as Map, + ), + quote: json['quote'] == null + ? null + : OpenCryptoPayQuote.fromJson(json['quote'] as Map), + requestedAmount: json['requestedAmount'] == null + ? null + : OpenCryptoPayRequestedAmount.fromJson( + json['requestedAmount'] as Map, + ), + transferAmounts: + (json['transferAmounts'] as List?) + ?.map( + (e) => OpenCryptoPayTransferMethod.fromJson( + e as Map, + ), + ) + .toList() ?? + [], + ); + } + + /// Methods that are available and have at least one asset. + List get availableMethods => + transferAmounts.where((m) => m.available && m.assets.isNotEmpty).toList(); + + bool get supportsOpenCryptoPay => + standard == 'OpenCryptoPay' || + possibleStandards.contains('OpenCryptoPay'); +} + +class OpenCryptoPayTransactionDetails { + final String? blockchain; + final String? uri; + final String? hint; + final DateTime? expiryDate; + + OpenCryptoPayTransactionDetails({ + this.blockchain, + this.uri, + this.hint, + this.expiryDate, + }); + + factory OpenCryptoPayTransactionDetails.fromJson(Map json) { + return OpenCryptoPayTransactionDetails( + blockchain: json['blockchain'] as String?, + uri: json['uri'] as String?, + hint: json['hint'] as String?, + expiryDate: json['expiryDate'] == null + ? null + : DateTime.parse(json['expiryDate'] as String), + ); + } +} + +/// Context required to notify the provider via the `/tx/{paymentId}` endpoint. +class OpenCryptoPayCommit { + final String callbackUrl; + final String quoteId; + final String paymentId; + final String method; + final String asset; + final DateTime expiresAt; + final OpenCryptoPaySubmissionFlow submissionFlow; + final Decimal minFee; + final String recipientAddress; + final Decimal amount; + final String? tokenContractAddress; + + const OpenCryptoPayCommit({ + required this.callbackUrl, + required this.quoteId, + required this.paymentId, + required this.method, + required this.asset, + required this.expiresAt, + required this.submissionFlow, + required this.minFee, + required this.recipientAddress, + required this.amount, + this.tokenContractAddress, + }); + + bool get isExpired => expiresAt.isBefore(DateTime.now()); +} diff --git a/lib/services/open_crypto_pay/open_crypto_pay_api.dart b/lib/services/open_crypto_pay/open_crypto_pay_api.dart new file mode 100644 index 000000000..c1b526667 --- /dev/null +++ b/lib/services/open_crypto_pay/open_crypto_pay_api.dart @@ -0,0 +1,218 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../../app_config.dart'; +import '../../networking/http.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/prefs.dart'; +import '../tor_service.dart'; +import 'lnurl_utils.dart'; +import 'models.dart'; + +/// Client for the Open CryptoPay standard. +/// +/// See https://github.com/openCryptoPay/landingPage +class OpenCryptoPayApi { + OpenCryptoPayApi._(); + + static final OpenCryptoPayApi instance = OpenCryptoPayApi._(); + + final HTTP _client = const HTTP(); + + static const Duration _httpTimeout = Duration(seconds: 15); + + ({InternetAddress host, int port})? get _proxyInfo => + AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null; + + /// Throws if [uri] is not an absolute https URL. LUD-01 mandates HTTPS; + /// rejecting plain http also closes off MITM and SSRF-into-loopback risks + /// from a malicious QR. + void _requireHttps(Uri uri, String label) { + if (uri.scheme != 'https' || !uri.hasAuthority) { + throw Exception('OpenCryptoPay: $label must be an https URL'); + } + } + + /// Fetches the payment details (available methods, quote, recipient, etc) + /// for the payment encoded in [qrUrl]. + Future getPaymentDetails( + String qrUrl, { + int timeout = 10, + }) async { + final lnurl = LnurlUtils.extractLnurl(qrUrl); + if (lnurl == null) { + throw Exception('No lightning parameter found in URL'); + } + + final apiUrl = Uri.parse(LnurlUtils.decodeLnurl(lnurl)); + _requireHttps(apiUrl, 'decoded LNURL'); + final uri = apiUrl.replace( + queryParameters: { + ...apiUrl.queryParameters, + 'timeout': timeout.toString(), + }, + ); + + Logging.instance.d('OpenCryptoPay: GET $uri'); + final response = await _get(uri); + + if (response.code == 404) { + String message = 'No pending payment found'; + try { + final json = jsonDecode(response.body) as Map; + message = json['message'] as String? ?? message; + } catch (_) {} + throw OpenCryptoPayNoPendingPaymentException(message); + } + if (response.code != 200) { + throw Exception('OpenCryptoPay ${response.code}: ${response.body}'); + } + + final json = jsonDecode(response.body) as Map; + final details = OpenCryptoPayPaymentDetails.fromJson(json); + if (!details.supportsOpenCryptoPay) { + throw Exception('OpenCryptoPay: endpoint did not return OpenCryptoPay'); + } + + // Pin all subsequent calls (callback fetch + commit) to the same host as + // the LNURL we already trusted. Otherwise a malicious provider response + // could redirect the txid + raw hex to an attacker-controlled host. + final callback = Uri.tryParse(details.callback); + if (callback == null) { + throw Exception('OpenCryptoPay: invalid callback URL'); + } + _requireHttps(callback, 'callback'); + if (callback.host != apiUrl.host) { + throw Exception( + 'OpenCryptoPay: callback host ${callback.host} does not match ' + 'LNURL host ${apiUrl.host}', + ); + } + + return details; + } + + /// Fetches the transaction details (payment address URI) for the chosen + /// [method] and [asset]. + Future getTransactionDetails({ + required String callbackUrl, + required String quoteId, + required String method, + required String asset, + }) async { + final base = Uri.parse(callbackUrl); + _requireHttps(base, 'callback'); + final uri = base.replace( + queryParameters: { + ...base.queryParameters, + 'quote': quoteId, + 'method': method, + 'asset': asset, + }, + ); + + Logging.instance.d('OpenCryptoPay: GET $uri'); + final response = await _get(uri); + + if (response.code != 200) { + throw Exception('OpenCryptoPay ${response.code}: ${response.body}'); + } + + return OpenCryptoPayTransactionDetails.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Notifies the provider of a locally broadcast transaction so the merchant + /// side can settle the payment. + Future commitTxId({ + required OpenCryptoPayCommit commit, + required String txId, + }) async { + if (commit.submissionFlow != + OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) { + throw UnsupportedError( + 'OpenCryptoPay method ${commit.method} cannot be committed with txid', + ); + } + + await _commit(commit: commit, queryParameters: {'tx': txId}); + } + + /// Sends raw signed transaction hex to the provider for methods where the + /// provider is responsible for broadcasting. + Future commitRawHex({ + required OpenCryptoPayCommit commit, + required String hex, + }) async { + if (commit.submissionFlow != OpenCryptoPaySubmissionFlow.rawHexToProvider) { + throw UnsupportedError( + 'OpenCryptoPay method ${commit.method} cannot be committed with hex', + ); + } + + await _commit(commit: commit, queryParameters: {'hex': hex}); + } + + Future _commit({ + required OpenCryptoPayCommit commit, + required Map queryParameters, + }) async { + final base = _commitEndpoint(commit.callbackUrl, commit.paymentId); + _requireHttps(base, 'commit endpoint'); + final uri = base.replace( + queryParameters: { + ...base.queryParameters, + 'quote': commit.quoteId, + 'method': commit.method, + ...queryParameters, + }, + ); + + Logging.instance.d('OpenCryptoPay: GET ${_redactedUri(uri)}'); + final response = await _get(uri); + if (response.code != 200) { + throw Exception( + 'OpenCryptoPay commit ${response.code}: ${response.body}', + ); + } + } + + Uri _commitEndpoint(String callbackUrl, String paymentId) { + final callback = Uri.parse(callbackUrl); + if (paymentId.isEmpty) { + throw Exception('OpenCryptoPay: quote payment id is missing'); + } + final segments = callback.pathSegments.toList(); + final cbIndex = segments.lastIndexOf('cb'); + if (cbIndex == -1) { + throw Exception('OpenCryptoPay: callback URL does not contain /cb/'); + } + return callback.replace( + pathSegments: [...segments.take(cbIndex), 'tx', paymentId], + ); + } + + Uri _redactedUri(Uri uri) { + if (!uri.queryParameters.containsKey('hex')) return uri; + return uri.replace( + queryParameters: {...uri.queryParameters, 'hex': ''}, + ); + } + + Future _get(Uri uri) { + return _client + .get(url: uri, proxyInfo: _proxyInfo, connectionTimeout: _httpTimeout) + .timeout(_httpTimeout); + } +} + +class OpenCryptoPayNoPendingPaymentException implements Exception { + final String message; + OpenCryptoPayNoPendingPaymentException(this.message); + + @override + String toString() => message; +} diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index 829110651..1bd924e07 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -218,7 +218,9 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { final addressHex = (await getCurrentReceivingAddress())!.value; final address = eth_wallet.EthereumAddress.fromHex(addressHex); - final eth_wallet.EtherAmount ethBalance = await client.getBalance(address); + final eth_wallet.EtherAmount ethBalance = await client.getBalance( + address, + ); final balance = Balance( total: Amount( rawValue: ethBalance.getInWei, @@ -584,6 +586,29 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { } } + Future signSendWithoutBroadcast({ + required TxData txData, + TxData Function(TxData txData, String myAddress)? prepareTempTx, + }) async { + final client = getEthClient(); + if (_credentials == null) { + await _initCredentials(); + } + + final signedTx = await client.signTransaction( + _credentials!, + txData.web3dartTransaction!, + chainId: txData.chainId!.toInt(), + ); + final txid = web3.bytesToHex(web3.keccak256(signedTx), include0x: true); + final raw = web3.bytesToHex(signedTx, include0x: true); + + return (prepareTempTx ?? _prepareTempTx)( + txData.copyWith(raw: raw, txid: txid, txHash: txid), + (await getCurrentReceivingAddress())!.value, + ); + } + @override Future recover({required bool isRescan}) async { await refreshMutex.protect(() async { 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 6aca5a008..24065d801 100644 --- a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart @@ -281,6 +281,13 @@ class EthTokenWallet extends Wallet { } } + Future signSendWithoutBroadcast({required TxData txData}) async { + return await ethWallet.signSendWithoutBroadcast( + txData: txData, + prepareTempTx: _prepareTempTx, + ); + } + @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { return ethWallet.estimateEthFee( diff --git a/test/open_crypto_pay_evm_uri_test.dart b/test/open_crypto_pay_evm_uri_test.dart new file mode 100644 index 000000000..1484a63fc --- /dev/null +++ b/test/open_crypto_pay_evm_uri_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/open_crypto_pay/evm_uri.dart'; + +void main() { + test("parses native Ethereum payment URI", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC@1" + "?value=660720000000000", + ); + + expect(result, isNotNull); + expect(result!.targetAddress, "0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC"); + expect(result.chainId, 1); + expect(result.functionName, isNull); + expect(result.amountRaw, BigInt.parse("660720000000000")); + expect(result.isNativeTransfer, true); + expect(result.isTokenTransfer, false); + }); + + test("parses ERC20 transfer URI", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer" + "?address=0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC&uint256=1261570", + ); + + expect(result, isNotNull); + expect(result!.targetAddress, "0xdAC17F958D2ee523a2206206994597C13D831ec7"); + expect(result.chainId, 1); + expect(result.functionName, "transfer"); + expect( + result.recipientAddress, + "0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC", + ); + expect(result.amountRaw, BigInt.from(1261570)); + expect(result.isTokenTransfer, true); + }); + + test("parses non-mainnet chain id for caller validation", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@5/transfer" + "?address=0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC&uint256=1", + ); + + expect(result, isNotNull); + expect(result!.chainId, 5); + expect(result.isTokenTransfer, true); + }); + + test("rejects malformed chain id", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@abc/transfer" + "?address=0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC&uint256=1", + ); + + expect(result, isNull); + }); + + test("rejects malformed contract address", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:not-a-contract@1/transfer" + "?address=0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC&uint256=1", + ); + + expect(result, isNull); + }); + + test("does not mark unsupported contract calls as ERC20 transfers", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/approve" + "?address=0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC&uint256=1", + ); + + expect(result, isNotNull); + expect(result!.functionName, "approve"); + expect(result.isTokenTransfer, false); + }); + + test("does not mark token transfer missing recipient as valid", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer" + "?uint256=1", + ); + + expect(result, isNotNull); + expect(result!.isTokenTransfer, false); + }); + + test("does not mark token transfer missing amount as valid", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer" + "?address=0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC", + ); + + expect(result, isNotNull); + expect(result!.isTokenTransfer, false); + }); +} diff --git a/test/open_crypto_pay_models_test.dart b/test/open_crypto_pay_models_test.dart new file mode 100644 index 000000000..df5a48f5b --- /dev/null +++ b/test/open_crypto_pay_models_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/open_crypto_pay/models.dart'; + +void main() { + test("parses quote payment id used for commit endpoint", () { + final quote = OpenCryptoPayQuote.fromJson({ + "id": "quote-id", + "payment": "payment-id", + "expiration": "2026-04-28T12:00:00Z", + }); + + expect(quote.id, "quote-id"); + expect(quote.paymentId, "payment-id"); + }); + + test("rejects quotes without a payment id", () { + expect( + () => OpenCryptoPayQuote.fromJson({ + "id": "quote-id", + "expiration": "2026-04-28T12:00:00Z", + }), + throwsException, + ); + }); +}