From cd9f6024ae870f6e883204595e7f5f3bbfef82fe Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Tue, 28 Apr 2026 00:23:51 +0800 Subject: [PATCH 01/20] feat(open_crypto_pay): add native payment flow Add Open CryptoPay QR handling, payment details fetching, method selection, and native-wallet send routing with HTTPS and callback host hardening. --- lib/models/send_view_auto_fill_data.dart | 8 + .../open_crypto_pay_confirm_view.dart | 252 +++++++++++++++ .../open_crypto_pay/open_crypto_pay_view.dart | 287 ++++++++++++++++++ .../send_view/confirm_transaction_view.dart | 23 ++ lib/pages/send_view/send_view.dart | 18 ++ lib/pages/wallet_view/wallet_view.dart | 54 ++++ lib/route_generator.dart | 15 + lib/services/open_crypto_pay/lnurl_utils.dart | 53 ++++ lib/services/open_crypto_pay/models.dart | 194 ++++++++++++ .../open_crypto_pay/open_crypto_pay_api.dart | 175 +++++++++++ 10 files changed, 1079 insertions(+) create mode 100644 lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart create mode 100644 lib/pages/open_crypto_pay/open_crypto_pay_view.dart create mode 100644 lib/services/open_crypto_pay/lnurl_utils.dart create mode 100644 lib/services/open_crypto_pay/models.dart create mode 100644 lib/services/open_crypto_pay/open_crypto_pay_api.dart diff --git a/lib/models/send_view_auto_fill_data.dart b/lib/models/send_view_auto_fill_data.dart index bb8817ca2..4e185b702 100644 --- a/lib/models/send_view_auto_fill_data.dart +++ b/lib/models/send_view_auto_fill_data.dart @@ -10,17 +10,25 @@ 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 will notify the OpenCryptoPay provider + /// with the broadcast tx ID (and raw hex, where available) after a + /// successful send. + 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..42038386b --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -0,0 +1,252 @@ +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/send_view_auto_fill_data.dart'; +import '../../notifications/show_flush_bar.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 '../../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'; + +/// 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; + + @override + void initState() { + super.initState(); + _fetch(); + } + + Future _fetch() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + _txDetails = await OpenCryptoPayApi.instance.getTransactionDetails( + callbackUrl: widget.paymentDetails.callback, + quoteId: widget.paymentDetails.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. Strips the EVM + /// `@chainId` suffix that [AddressUtils] leaves attached. + ({String? address, Decimal? amount}) _parseTransactionUri(String uri) { + final data = AddressUtils.parsePaymentUri(uri, logging: Logging.instance); + var address = data?.address ?? Uri.tryParse(uri)?.path; + if (address != null) { + final at = address.indexOf('@'); + if (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); + } + + Future _proceedToSend() async { + final uri = _txDetails?.uri; + if (uri == null) { + _warn("No transaction URI provided by the payment provider"); + return; + } + + final parsed = _parseTransactionUri(uri); + if (parsed.address == null) { + _warn("Could not parse payment address"); + return; + } + + final recipient = widget.paymentDetails.recipient?.name ?? + widget.paymentDetails.displayName ?? + "OpenCryptoPay"; + + 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, + method: widget.selectedMethod.method, + asset: widget.selectedAsset.asset, + ), + ), + ), + ); + } + + 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..3dc4c0aaa --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart @@ -0,0 +1,287 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../notifications/show_flush_bar.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 '../../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 assets matching this coin's ticker 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 _matchesWalletCoin(String asset) => + widget.coin.ticker.toUpperCase() == asset.toUpperCase(); + + void _onSelected( + OpenCryptoPayTransferMethod method, + OpenCryptoPayAsset asset, + ) { + final quote = _details?.quote; + if (quote == null) return; + + if (quote.isExpired) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Quote expired, refreshing…", + context: context, + ), + ); + _fetch(); + return; + } + + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => OpenCryptoPayConfirmView( + paymentDetails: _details!, + selectedMethod: method, + selectedAsset: asset, + walletId: widget.walletId, + coin: widget.coin, + ), + ), + ); + } + + @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")); + } + + // Flatten into (method, asset) pairs that this wallet actually supports. + final options = [ + for (final m in details.availableMethods) + for (final a in m.assets) + if (_matchesWalletCoin(a.asset)) (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 payment 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 8f7eab592..6db1027cc 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -29,6 +29,8 @@ import '../../providers/global/global_nav_key_provider.dart'; 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'; @@ -84,6 +86,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { this.isPaynymNotificationTransaction = false, this.isTokenTx = false, this.onSuccessInsteadOfRouteOnSuccess, + this.openCryptoPayCommit, }); static const String routeName = "/confirmTransactionView"; @@ -97,6 +100,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { final bool isTokenTx; final VoidCallback? onSuccessInsteadOfRouteOnSuccess; final VoidCallback onSuccess; + final OpenCryptoPayCommit? openCryptoPayCommit; @override ConsumerState createState() => @@ -530,6 +534,25 @@ class _ConfirmTransactionViewState } else { txids.add(confirmedTx.txid!); } + + // Notify the OpenCryptoPay provider of the broadcast tx so the merchant + // can settle. Best-effort — a failure here doesn't unwind the send. + if (widget.openCryptoPayCommit != null) { + final result = results.first as TxData; + try { + await OpenCryptoPayApi.instance.commit( + commit: widget.openCryptoPayCommit!, + txId: result.txid!, + hex: result.raw, + ); + } catch (e, s) { + Logging.instance.e( + "OpenCryptoPay commit failed (tx already broadcast)", + error: e, + stackTrace: s, + ); + } + } if (coin is! Ethereum) { ref.refresh(desktopUseUTXOs); } diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 94b5663c8..0d4a646b7 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'; @@ -296,6 +298,21 @@ class _SendViewState extends ConsumerState { Logging.instance.d("qrResult content: ${qrResult.rawContent}"); if (qrResult.rawContent == null) return; + // Check for OpenCryptoPay QR code. + if (LnurlUtils.isOpenCryptoPayUrl(qrResult.rawContent!)) { + if (mounted) { + await Navigator.of(context).pushNamed( + OpenCryptoPayView.routeName, + arguments: ( + qrUrl: qrResult.rawContent!, + walletId: walletId, + coin: coin, + ), + ); + } + return; + } + final paymentData = AddressUtils.parsePaymentUri( qrResult.rawContent!, logging: Logging.instance, @@ -1074,6 +1091,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/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 12affd42b..82bfa031e 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'; @@ -427,6 +430,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( @@ -1378,6 +1426,12 @@ class _WalletViewState extends ConsumerState { ).pushNamed(GiftCardsView.routeName); }, ), + if (!viewOnly) + WalletNavigationBarItemData( + label: "Pay", + icon: const QrCodeIcon(), + onTap: () => _onOpenCryptoPayPressed(context), + ), ], ), ), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 0f0fbbf58..6de25d29e 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -97,6 +97,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'; @@ -2117,6 +2118,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( 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/models.dart b/lib/services/open_crypto_pay/models.dart new file mode 100644 index 000000000..61939e53d --- /dev/null +++ b/lib/services/open_crypto_pay/models.dart @@ -0,0 +1,194 @@ +/// Data models for the Open CryptoPay standard. +/// +/// See https://github.com/openCryptoPay/landingPage + +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; + + OpenCryptoPayTransferMethod({ + required this.method, + required this.assets, + required this.available, + }); + + factory OpenCryptoPayTransferMethod.fromJson(Map json) { + return OpenCryptoPayTransferMethod( + method: json['method'] as String, + assets: (json['assets'] as List) + .map((e) => OpenCryptoPayAsset.fromJson(e as Map)) + .toList(), + available: json['available'] as bool, + ); + } +} + +class OpenCryptoPayQuote { + final String id; + final DateTime expiration; + + OpenCryptoPayQuote({required this.id, required this.expiration}); + + factory OpenCryptoPayQuote.fromJson(Map json) { + return OpenCryptoPayQuote( + id: json['id'] as String, + 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? displayName; + final String callback; + final OpenCryptoPayRecipient? recipient; + final OpenCryptoPayQuote? quote; + final OpenCryptoPayRequestedAmount? requestedAmount; + final List transferAmounts; + + OpenCryptoPayPaymentDetails({ + required this.id, + 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, + 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(); +} + +class OpenCryptoPayTransactionDetails { + final String? uri; + final String? hint; + + OpenCryptoPayTransactionDetails({this.uri, this.hint}); + + factory OpenCryptoPayTransactionDetails.fromJson(Map json) { + return OpenCryptoPayTransactionDetails( + uri: json['uri'] as String?, + hint: json['hint'] as String?, + ); + } +} + +/// Context required to notify the provider of a broadcast transaction via +/// the `/tx/` endpoint (derived from the payment details callback URL). +class OpenCryptoPayCommit { + final String callbackUrl; + final String quoteId; + final String method; + final String asset; + + const OpenCryptoPayCommit({ + required this.callbackUrl, + required this.quoteId, + required this.method, + required this.asset, + }); +} 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..ac5aa3288 --- /dev/null +++ b/lib/services/open_crypto_pay/open_crypto_pay_api.dart @@ -0,0 +1,175 @@ +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 _client.get( + url: uri, + proxyInfo: _proxyInfo, + connectionTimeout: _httpTimeout, + ); + + 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 details = OpenCryptoPayPaymentDetails.fromJson( + jsonDecode(response.body) as Map, + ); + + // 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 _client.get( + url: uri, + proxyInfo: _proxyInfo, + connectionTimeout: _httpTimeout, + ); + + if (response.code != 200) { + throw Exception('OpenCryptoPay ${response.code}: ${response.body}'); + } + + return OpenCryptoPayTransactionDetails.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Notifies the provider of a signed (and broadcast) transaction so the + /// merchant-side can settle the payment. The `/tx/` endpoint is derived + /// from the payment details callback URL. + Future commit({ + required OpenCryptoPayCommit commit, + required String txId, + String? hex, + }) async { + final base = Uri.parse(commit.callbackUrl.replaceAll('/cb/', '/tx/')); + _requireHttps(base, 'commit endpoint'); + final uri = base.replace( + queryParameters: { + ...base.queryParameters, + 'quote': commit.quoteId, + 'method': commit.method, + 'asset': commit.asset, + 'tx': txId, + if (hex != null && hex.isNotEmpty) 'hex': hex, + }, + ); + + Logging.instance.d('OpenCryptoPay: GET $uri'); + final response = await _client.get( + url: uri, + proxyInfo: _proxyInfo, + connectionTimeout: _httpTimeout, + ); + if (response.code != 200) { + throw Exception( + 'OpenCryptoPay commit ${response.code}: ${response.body}', + ); + } + } +} + +class OpenCryptoPayNoPendingPaymentException implements Exception { + final String message; + OpenCryptoPayNoPendingPaymentException(this.message); + + @override + String toString() => message; +} From 4ddccb72e6891d7812f17b66444dc5b5452a5264 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Mon, 27 Apr 2026 19:02:04 +0800 Subject: [PATCH 02/20] fix(open_crypto_pay): enforce spec-safe submissions Filter OCP methods by supported wallet network, enforce quote expiry and minimum fees, and route raw-hex settlement through the provider where required. --- lib/models/send_view_auto_fill_data.dart | 5 +- .../open_crypto_pay_confirm_view.dart | 107 +++++- .../open_crypto_pay/open_crypto_pay_view.dart | 50 +-- .../send_view/confirm_transaction_view.dart | 305 ++++++++++++++++-- lib/pages/send_view/send_view.dart | 36 ++- .../open_crypto_pay/method_support.dart | 62 ++++ lib/services/open_crypto_pay/models.dart | 59 +++- .../open_crypto_pay/open_crypto_pay_api.dart | 100 ++++-- lib/wallets/wallet/impl/ethereum_wallet.dart | 24 ++ 9 files changed, 643 insertions(+), 105 deletions(-) create mode 100644 lib/services/open_crypto_pay/method_support.dart diff --git a/lib/models/send_view_auto_fill_data.dart b/lib/models/send_view_auto_fill_data.dart index 4e185b702..0687af324 100644 --- a/lib/models/send_view_auto_fill_data.dart +++ b/lib/models/send_view_auto_fill_data.dart @@ -18,9 +18,8 @@ class SendViewAutoFillData { final Decimal? amount; final String note; - /// When set, ConfirmTransactionView will notify the OpenCryptoPay provider - /// with the broadcast tx ID (and raw hex, where available) after a - /// successful send. + /// When set, ConfirmTransactionView completes the OpenCryptoPay submission + /// flow for the prepared transaction. final OpenCryptoPayCommit? openCryptoPayCommit; SendViewAutoFillData({ 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 index 42038386b..d020a2e4d 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -7,6 +7,7 @@ import 'package:tuple/tuple.dart'; import '../../models/send_view_auto_fill_data.dart'; import '../../notifications/show_flush_bar.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'; @@ -21,6 +22,8 @@ import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import '../send_view/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. @@ -51,6 +54,14 @@ class _OpenCryptoPayConfirmViewState 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(); @@ -64,37 +75,65 @@ class _OpenCryptoPayConfirmViewState }); 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: widget.paymentDetails.quote!.id, + 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); + 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. Strips the EVM - /// `@chainId` suffix that [AddressUtils] leaves attached. - ({String? address, Decimal? amount}) _parseTransactionUri(String uri) { + /// 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 parsedUri = Uri.tryParse(uri); final data = AddressUtils.parsePaymentUri(uri, logging: Logging.instance); - var address = data?.address ?? Uri.tryParse(uri)?.path; + var address = data?.address ?? parsedUri?.path; + int? chainId; if (address != null) { final at = address.indexOf('@'); - if (at != -1) address = address.substring(0, at); + 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); + return ( + address: address, + amount: amount, + chainId: chainId, + scheme: data?.scheme ?? parsedUri?.scheme, + ); } 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"); @@ -106,8 +145,45 @@ class _OpenCryptoPayConfirmViewState _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 (_txDetails?.blockchain != null && + _txDetails!.blockchain != widget.selectedMethod.method) { + _warn("Payment details do not match the selected method"); + return; + } + if (widget.selectedMethod.method == 'Ethereum' && + parsed.chainId != null && + parsed.chainId != 1) { + _warn("Payment URI is for a different Ethereum network"); + 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 ?? + final recipient = + widget.paymentDetails.recipient?.name ?? widget.paymentDetails.displayName ?? "OpenCryptoPay"; @@ -127,6 +203,11 @@ class _OpenCryptoPayConfirmViewState quoteId: widget.paymentDetails.quote!.id, method: widget.selectedMethod.method, asset: widget.selectedAsset.asset, + expiresAt: expiresAt, + submissionFlow: submissionFlow, + minFee: widget.selectedMethod.minFee, + recipientAddress: parsed.address!, + amount: parsed.amount!, ), ), ), @@ -147,11 +228,11 @@ class _OpenCryptoPayConfirmViewState Widget build(BuildContext context) { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of(context).extension()!.background, appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.backgroundAppBar, + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, leading: const AppBarBackButton(), title: Text( "Confirm Payment", diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart index 3dc4c0aaa..511485c47 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../notifications/show_flush_bar.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'; @@ -31,7 +32,7 @@ class OpenCryptoPayView extends ConsumerStatefulWidget { final String qrUrl; - /// Only assets matching this coin's ticker are offered. + /// Only methods/assets this wallet can safely settle are offered. final String walletId; final CryptoCurrency coin; @@ -71,13 +72,19 @@ class _OpenCryptoPayViewState extends ConsumerState { } } - bool _matchesWalletCoin(String asset) => - widget.coin.ticker.toUpperCase() == asset.toUpperCase(); + bool _isSupportedOption( + OpenCryptoPayTransferMethod method, + OpenCryptoPayAsset asset, + ) => OpenCryptoPayMethodSupport.isSupportedWalletOption( + coin: widget.coin, + method: method, + asset: asset, + ); - void _onSelected( + Future _onSelected( OpenCryptoPayTransferMethod method, OpenCryptoPayAsset asset, - ) { + ) async { final quote = _details?.quote; if (quote == null) return; @@ -85,16 +92,16 @@ class _OpenCryptoPayViewState extends ConsumerState { unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: "Quote expired, refreshing…", + message: "Quote expired, refreshing...", context: context, ), ); - _fetch(); + await _fetch(); return; } - Navigator.of(context).push( - MaterialPageRoute( + final result = await Navigator.of(context).push( + MaterialPageRoute( builder: (_) => OpenCryptoPayConfirmView( paymentDetails: _details!, selectedMethod: method, @@ -104,17 +111,21 @@ class _OpenCryptoPayViewState extends ConsumerState { ), ), ); + + if (result == OpenCryptoPayConfirmResult.quoteExpired && mounted) { + await _fetch(); + } } @override Widget build(BuildContext context) { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of(context).extension()!.background, appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.backgroundAppBar, + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, leading: const AppBarBackButton(), title: Text( "Open CryptoPay", @@ -153,11 +164,11 @@ class _OpenCryptoPayViewState extends ConsumerState { return const Center(child: Text("No payment data")); } - // Flatten into (method, asset) pairs that this wallet actually supports. + // 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 (_matchesWalletCoin(a.asset)) (method: m, asset: a), + if (_isSupportedOption(m, a)) (method: m, asset: a), ]; return SingleChildScrollView( @@ -180,7 +191,8 @@ class _OpenCryptoPayViewState extends ConsumerState { if (options.isEmpty) RoundedWhiteContainer( child: Text( - "No payment option available for ${widget.coin.prettyName}.", + "No supported Open CryptoPay option available for " + "${widget.coin.prettyName}.", style: STextStyles.itemSubtitle(context), ), ) @@ -275,9 +287,9 @@ class _OpenCryptoPayViewState extends ConsumerState { ), Icon( Icons.chevron_right, - color: Theme.of(context) - .extension()! - .textFieldDefaultSearchIconLeft, + 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 6db1027cc..eadcf247c 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -39,20 +39,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/wallet_mixin_interfaces/ordinals_interface.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'; @@ -410,6 +414,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(); @@ -435,7 +470,14 @@ class _ConfirmTransactionViewState final note = noteController.text; try { - if (widget.isTokenTx) { + if (openCryptoPayCommit?.submissionFlow == + OpenCryptoPaySubmissionFlow.rawHexToProvider) { + txDataFuture = _submitOpenCryptoPayRawHex( + wallet, + widget.txData, + openCryptoPayCommit!, + ); + } else if (widget.isTokenTx) { if (wallet is SolanaWallet) { // For Solana tokens, use the Solana token wallet. txDataFuture = ref @@ -526,33 +568,20 @@ 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 { txids.add(confirmedTx.txid!); } - // Notify the OpenCryptoPay provider of the broadcast tx so the merchant - // can settle. Best-effort — a failure here doesn't unwind the send. - if (widget.openCryptoPayCommit != null) { + if (openCryptoPayCommit?.submissionFlow == + OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) { final result = results.first as TxData; - try { - await OpenCryptoPayApi.instance.commit( - commit: widget.openCryptoPayCommit!, - txId: result.txid!, - hex: result.raw, - ); - } catch (e, s) { - Logging.instance.e( - "OpenCryptoPay commit failed (tx already broadcast)", - error: e, - stackTrace: s, - ); - } + await _commitOpenCryptoPayTxId(openCryptoPayCommit!, result); } + + sendProgressController.triggerSuccess?.call(); + await Future.delayed(const Duration(seconds: 5)); if (coin is! Ethereum) { ref.refresh(desktopUseUTXOs); } @@ -782,7 +811,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) { @@ -857,6 +888,238 @@ 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; + + 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? _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 EthereumWallet) { + return await wallet.signSendWithoutBroadcast(txData: txData); + } + + return txData; + } + @override void initState() { super.initState(); diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 0d4a646b7..a2b5bca59 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -298,7 +298,19 @@ class _SendViewState extends ConsumerState { Logging.instance.d("qrResult content: ${qrResult.rawContent}"); if (qrResult.rawContent == null) return; - // Check for OpenCryptoPay QR code. + final paymentData = AddressUtils.parsePaymentUri( + qrResult.rawContent!, + logging: Logging.instance, + ); + + if (paymentData != null && + paymentData.coin?.uriScheme == coin.uriScheme) { + _applyUri(paymentData); + return; + } + + // 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( @@ -313,23 +325,13 @@ class _SendViewState extends ConsumerState { return; } - final paymentData = AddressUtils.parsePaymentUri( - qrResult.rawContent!, - logging: Logging.instance, - ); + _address = qrResult.rawContent!.split("\n").first.trim(); + sendToController.text = _address ?? ""; - if (paymentData != null && - paymentData.coin?.uriScheme == coin.uriScheme) { - _applyUri(paymentData); - } else { - _address = qrResult.rawContent!.split("\n").first.trim(); - sendToController.text = _address ?? ""; - - _setValidAddressProviders(_address); - setState(() { - _addressToggleFlag = sendToController.text.isNotEmpty; - }); - } + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); } on PlatformException catch (e, s) { // ref // .read( 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..f78dba96a --- /dev/null +++ b/lib/services/open_crypto_pay/method_support.dart @@ -0,0 +1,62 @@ +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, + }) { + final ticker = coin.ticker.toUpperCase(); + final assetTicker = asset.asset.toUpperCase(); + + if (coin is Bitcoin) { + return method.method == 'Bitcoin' && assetTicker == ticker; + } + if (coin is Ethereum) { + return method.method == 'Ethereum' && assetTicker == ticker; + } + 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 index 61939e53d..e65159f95 100644 --- a/lib/services/open_crypto_pay/models.dart +++ b/lib/services/open_crypto_pay/models.dart @@ -1,7 +1,20 @@ +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; @@ -62,16 +75,20 @@ 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(), @@ -112,6 +129,8 @@ class OpenCryptoPayRequestedAmount { class OpenCryptoPayPaymentDetails { final String id; + final String? standard; + final List possibleStandards; final String? displayName; final String callback; final OpenCryptoPayRecipient? recipient; @@ -121,6 +140,8 @@ class OpenCryptoPayPaymentDetails { OpenCryptoPayPaymentDetails({ required this.id, + this.standard, + required this.possibleStandards, this.displayName, required this.callback, this.recipient, @@ -132,6 +153,12 @@ class OpenCryptoPayPaymentDetails { 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 @@ -147,7 +174,8 @@ class OpenCryptoPayPaymentDetails { : OpenCryptoPayRequestedAmount.fromJson( json['requestedAmount'] as Map, ), - transferAmounts: (json['transferAmounts'] as List?) + transferAmounts: + (json['transferAmounts'] as List?) ?.map( (e) => OpenCryptoPayTransferMethod.fromJson( e as Map, @@ -161,18 +189,33 @@ class OpenCryptoPayPaymentDetails { /// 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.uri, this.hint}); + 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), ); } } @@ -184,11 +227,23 @@ class OpenCryptoPayCommit { final String quoteId; final String method; final String asset; + final DateTime expiresAt; + final OpenCryptoPaySubmissionFlow submissionFlow; + final Decimal minFee; + final String recipientAddress; + final Decimal amount; const OpenCryptoPayCommit({ required this.callbackUrl, required this.quoteId, required this.method, required this.asset, + required this.expiresAt, + required this.submissionFlow, + required this.minFee, + required this.recipientAddress, + required this.amount, }); + + 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 index ac5aa3288..4f4c322fe 100644 --- a/lib/services/open_crypto_pay/open_crypto_pay_api.dart +++ b/lib/services/open_crypto_pay/open_crypto_pay_api.dart @@ -23,8 +23,8 @@ class OpenCryptoPayApi { ({InternetAddress host, int port})? get _proxyInfo => AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor - ? TorService.sharedInstance.getProxyInfo() - : null; + ? 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 @@ -56,11 +56,7 @@ class OpenCryptoPayApi { ); Logging.instance.d('OpenCryptoPay: GET $uri'); - final response = await _client.get( - url: uri, - proxyInfo: _proxyInfo, - connectionTimeout: _httpTimeout, - ); + final response = await _get(uri); if (response.code == 404) { String message = 'No pending payment found'; @@ -74,9 +70,11 @@ class OpenCryptoPayApi { throw Exception('OpenCryptoPay ${response.code}: ${response.body}'); } - final details = OpenCryptoPayPaymentDetails.fromJson( - jsonDecode(response.body) as Map, - ); + 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 @@ -116,11 +114,7 @@ class OpenCryptoPayApi { ); Logging.instance.d('OpenCryptoPay: GET $uri'); - final response = await _client.get( - url: uri, - proxyInfo: _proxyInfo, - connectionTimeout: _httpTimeout, - ); + final response = await _get(uri); if (response.code != 200) { throw Exception('OpenCryptoPay ${response.code}: ${response.body}'); @@ -131,39 +125,85 @@ class OpenCryptoPayApi { ); } - /// Notifies the provider of a signed (and broadcast) transaction so the - /// merchant-side can settle the payment. The `/tx/` endpoint is derived - /// from the payment details callback URL. - Future commit({ + /// Notifies the provider of a locally broadcast transaction so the merchant + /// side can settle the payment. The `/tx/` endpoint is derived from the + /// payment details callback URL. + Future commitTxId({ required OpenCryptoPayCommit commit, required String txId, - String? hex, }) async { - final base = Uri.parse(commit.callbackUrl.replaceAll('/cb/', '/tx/')); + 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); _requireHttps(base, 'commit endpoint'); final uri = base.replace( queryParameters: { ...base.queryParameters, 'quote': commit.quoteId, 'method': commit.method, - 'asset': commit.asset, - 'tx': txId, - if (hex != null && hex.isNotEmpty) 'hex': hex, + ...queryParameters, }, ); - Logging.instance.d('OpenCryptoPay: GET $uri'); - final response = await _client.get( - url: uri, - proxyInfo: _proxyInfo, - connectionTimeout: _httpTimeout, - ); + 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) { + final callback = Uri.parse(callbackUrl); + final segments = callback.pathSegments.toList(); + final cbIndex = segments.indexOf('cb'); + if (cbIndex == -1) { + throw Exception('OpenCryptoPay: callback URL does not contain /cb/'); + } + segments[cbIndex] = 'tx'; + return callback.replace(pathSegments: segments); + } + + 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 { diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index 829110651..06b611ce8 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -584,6 +584,30 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { } } + Future signSendWithoutBroadcast({required TxData txData}) 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( + txData.copyWith( + raw: raw, + txid: txid, + txHash: txid, + ), + (await getCurrentReceivingAddress())!.value, + ); + } + @override Future recover({required bool isRescan}) async { await refreshMutex.protect(() async { From 25fdf86b5146d9e030d5763d1128b1e365e447e6 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Tue, 28 Apr 2026 00:06:23 +0800 Subject: [PATCH 03/20] feat(open_crypto_pay): support enabled Ethereum tokens Add EIP-681 parsing and route Ethereum mainnet ERC-20 OCP payments through the existing token send flow, using enabled token contract addresses as the authority. --- .../open_crypto_pay_confirm_view.dart | 190 ++++++++++++++++-- .../open_crypto_pay/open_crypto_pay_view.dart | 33 ++- .../send_view/confirm_transaction_view.dart | 38 +++- lib/pages/send_view/token_send_view.dart | 8 +- lib/route_generator.dart | 17 ++ lib/services/open_crypto_pay/evm_uri.dart | 79 ++++++++ .../open_crypto_pay/method_support.dart | 7 +- lib/services/open_crypto_pay/models.dart | 2 + lib/wallets/wallet/impl/ethereum_wallet.dart | 17 +- .../impl/sub_wallets/eth_token_wallet.dart | 7 + test/open_crypto_pay_evm_uri_test.dart | 97 +++++++++ 11 files changed, 457 insertions(+), 38 deletions(-) create mode 100644 lib/services/open_crypto_pay/evm_uri.dart create mode 100644 test/open_crypto_pay_evm_uri_test.dart 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 index d020a2e4d..a114eeff0 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -5,8 +5,12 @@ 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'; @@ -15,12 +19,18 @@ 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 } @@ -102,6 +112,18 @@ class _OpenCryptoPayConfirmViewState /// 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; @@ -125,6 +147,37 @@ class _OpenCryptoPayConfirmViewState ); } + 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..."); @@ -140,32 +193,11 @@ class _OpenCryptoPayConfirmViewState 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 (_txDetails?.blockchain != null && _txDetails!.blockchain != widget.selectedMethod.method) { _warn("Payment details do not match the selected method"); return; } - if (widget.selectedMethod.method == 'Ethereum' && - parsed.chainId != null && - parsed.chainId != 1) { - _warn("Payment URI is for a different Ethereum network"); - return; - } final submissionFlow = OpenCryptoPayMethodSupport.submissionFlowFor( widget.selectedMethod.method, @@ -187,6 +219,63 @@ class _OpenCryptoPayConfirmViewState 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, @@ -214,6 +303,65 @@ class _OpenCryptoPayConfirmViewState ); } + 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, + 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( diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart index 511485c47..ea0e188a2 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart @@ -3,7 +3,9 @@ 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'; @@ -11,6 +13,7 @@ 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'; @@ -75,11 +78,26 @@ class _OpenCryptoPayViewState extends ConsumerState { bool _isSupportedOption( OpenCryptoPayTransferMethod method, OpenCryptoPayAsset asset, - ) => OpenCryptoPayMethodSupport.isSupportedWalletOption( - coin: widget.coin, - method: method, - asset: 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, @@ -164,11 +182,14 @@ class _OpenCryptoPayViewState extends ConsumerState { 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)) (method: m, asset: a), + if (_isSupportedOption(m, a, enabledErc20Tokens)) + (method: m, asset: a), ]; return SingleChildScrollView( diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index eadcf247c..0388436be 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -54,6 +54,7 @@ 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/ordinals_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; import '../../wallets/wallet/wallet.dart'; @@ -472,8 +473,11 @@ class _ConfirmTransactionViewState try { if (openCryptoPayCommit?.submissionFlow == OpenCryptoPaySubmissionFlow.rawHexToProvider) { + final submitWallet = widget.isTokenTx + ? ref.read(pCurrentTokenWallet)! + : wallet; txDataFuture = _submitOpenCryptoPayRawHex( - wallet, + submitWallet, widget.txData, openCryptoPayCommit!, ); @@ -904,6 +908,9 @@ class _ConfirmTransactionViewState ); if (transactionError != null) return transactionError; + final tokenError = _validateOpenCryptoPayToken(commit); + if (tokenError != null) return tokenError; + switch (commit.submissionFlow) { case OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast: return null; @@ -950,6 +957,32 @@ class _ConfirmTransactionViewState 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, @@ -1113,6 +1146,9 @@ class _ConfirmTransactionViewState 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); } 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/route_generator.dart b/lib/route_generator.dart index 6de25d29e..a46bed5f2 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -2181,6 +2181,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/method_support.dart b/lib/services/open_crypto_pay/method_support.dart index f78dba96a..722cfdc07 100644 --- a/lib/services/open_crypto_pay/method_support.dart +++ b/lib/services/open_crypto_pay/method_support.dart @@ -37,6 +37,7 @@ class OpenCryptoPayMethodSupport { required CryptoCurrency coin, required OpenCryptoPayTransferMethod method, required OpenCryptoPayAsset asset, + Iterable enabledErc20Symbols = const [], }) { final ticker = coin.ticker.toUpperCase(); final assetTicker = asset.asset.toUpperCase(); @@ -45,7 +46,11 @@ class OpenCryptoPayMethodSupport { return method.method == 'Bitcoin' && assetTicker == ticker; } if (coin is Ethereum) { - return method.method == 'Ethereum' && assetTicker == ticker; + 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; diff --git a/lib/services/open_crypto_pay/models.dart b/lib/services/open_crypto_pay/models.dart index e65159f95..46629906a 100644 --- a/lib/services/open_crypto_pay/models.dart +++ b/lib/services/open_crypto_pay/models.dart @@ -232,6 +232,7 @@ class OpenCryptoPayCommit { final Decimal minFee; final String recipientAddress; final Decimal amount; + final String? tokenContractAddress; const OpenCryptoPayCommit({ required this.callbackUrl, @@ -243,6 +244,7 @@ class OpenCryptoPayCommit { required this.minFee, required this.recipientAddress, required this.amount, + this.tokenContractAddress, }); bool get isExpired => expiresAt.isBefore(DateTime.now()); diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index 06b611ce8..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,7 +586,10 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { } } - Future signSendWithoutBroadcast({required TxData txData}) async { + Future signSendWithoutBroadcast({ + required TxData txData, + TxData Function(TxData txData, String myAddress)? prepareTempTx, + }) async { final client = getEthClient(); if (_credentials == null) { await _initCredentials(); @@ -598,12 +603,8 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { final txid = web3.bytesToHex(web3.keccak256(signedTx), include0x: true); final raw = web3.bytesToHex(signedTx, include0x: true); - return _prepareTempTx( - txData.copyWith( - raw: raw, - txid: txid, - txHash: txid, - ), + return (prepareTempTx ?? _prepareTempTx)( + txData.copyWith(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 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); + }); +} From 143ba7322b9f04a3470b1c1881d35ccc91814768 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Tue, 28 Apr 2026 00:23:27 +0800 Subject: [PATCH 04/20] fix(open_crypto_pay): use quote payment id for commits --- .../open_crypto_pay_confirm_view.dart | 2 ++ lib/services/open_crypto_pay/models.dart | 18 ++++++++++--- .../open_crypto_pay/open_crypto_pay_api.dart | 17 +++++++------ test/open_crypto_pay_models_test.dart | 25 +++++++++++++++++++ 4 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 test/open_crypto_pay_models_test.dart 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 index a114eeff0..7a42c05b9 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -290,6 +290,7 @@ class _OpenCryptoPayConfirmViewState 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, @@ -348,6 +349,7 @@ class _OpenCryptoPayConfirmViewState 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, diff --git a/lib/services/open_crypto_pay/models.dart b/lib/services/open_crypto_pay/models.dart index 46629906a..fc3fd1be5 100644 --- a/lib/services/open_crypto_pay/models.dart +++ b/lib/services/open_crypto_pay/models.dart @@ -99,13 +99,24 @@ class OpenCryptoPayTransferMethod { class OpenCryptoPayQuote { final String id; + final String paymentId; final DateTime expiration; - OpenCryptoPayQuote({required this.id, required this.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), ); } @@ -220,11 +231,11 @@ class OpenCryptoPayTransactionDetails { } } -/// Context required to notify the provider of a broadcast transaction via -/// the `/tx/` endpoint (derived from the payment details callback URL). +/// 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; @@ -237,6 +248,7 @@ class OpenCryptoPayCommit { const OpenCryptoPayCommit({ required this.callbackUrl, required this.quoteId, + required this.paymentId, required this.method, required this.asset, required this.expiresAt, diff --git a/lib/services/open_crypto_pay/open_crypto_pay_api.dart b/lib/services/open_crypto_pay/open_crypto_pay_api.dart index 4f4c322fe..c1b526667 100644 --- a/lib/services/open_crypto_pay/open_crypto_pay_api.dart +++ b/lib/services/open_crypto_pay/open_crypto_pay_api.dart @@ -126,8 +126,7 @@ class OpenCryptoPayApi { } /// Notifies the provider of a locally broadcast transaction so the merchant - /// side can settle the payment. The `/tx/` endpoint is derived from the - /// payment details callback URL. + /// side can settle the payment. Future commitTxId({ required OpenCryptoPayCommit commit, required String txId, @@ -161,7 +160,7 @@ class OpenCryptoPayApi { required OpenCryptoPayCommit commit, required Map queryParameters, }) async { - final base = _commitEndpoint(commit.callbackUrl); + final base = _commitEndpoint(commit.callbackUrl, commit.paymentId); _requireHttps(base, 'commit endpoint'); final uri = base.replace( queryParameters: { @@ -181,15 +180,19 @@ class OpenCryptoPayApi { } } - Uri _commitEndpoint(String callbackUrl) { + 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.indexOf('cb'); + final cbIndex = segments.lastIndexOf('cb'); if (cbIndex == -1) { throw Exception('OpenCryptoPay: callback URL does not contain /cb/'); } - segments[cbIndex] = 'tx'; - return callback.replace(pathSegments: segments); + return callback.replace( + pathSegments: [...segments.take(cbIndex), 'tx', paymentId], + ); } Uri _redactedUri(Uri uri) { 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, + ); + }); +} From 3ab31f5c21341896d228dfb21ccc45b29805ae34 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Fri, 8 May 2026 20:01:22 +0800 Subject: [PATCH 05/20] refactor(open_crypto_pay): split view sections into widgets --- .../open_crypto_pay_confirm_view.dart | 178 +++++++++++++----- .../open_crypto_pay/open_crypto_pay_view.dart | 154 +++++++++++---- 2 files changed, 250 insertions(+), 82 deletions(-) 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 index 7a42c05b9..e1e068cf2 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -390,77 +390,159 @@ class _OpenCryptoPayConfirmViewState ), ), body: SafeArea( - child: Padding(padding: const EdgeInsets.all(16), child: _body()), + child: Padding( + padding: const EdgeInsets.all(16), + child: _OpenCryptoPayConfirmBody( + isLoading: _isLoading, + errorMessage: _errorMessage, + paymentDetails: widget.paymentDetails, + selectedMethod: widget.selectedMethod, + selectedAsset: widget.selectedAsset, + txDetails: _txDetails, + onRetry: () => unawaited(_fetch()), + onProceed: () => unawaited(_proceedToSend()), + ), + ), ), ), ); } +} - Widget _body() { - if (_isLoading) return const Center(child: LoadingIndicator()); +class _OpenCryptoPayConfirmBody extends StatelessWidget { + const _OpenCryptoPayConfirmBody({ + required this.isLoading, + required this.errorMessage, + required this.paymentDetails, + required this.selectedMethod, + required this.selectedAsset, + required this.txDetails, + required this.onRetry, + required this.onProceed, + }); - 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 bool isLoading; + final String? errorMessage; + final OpenCryptoPayPaymentDetails paymentDetails; + final OpenCryptoPayTransferMethod selectedMethod; + final OpenCryptoPayAsset selectedAsset; + final OpenCryptoPayTransactionDetails? txDetails; + final VoidCallback onRetry; + final VoidCallback onProceed; + + @override + Widget build(BuildContext context) { + if (isLoading) return const Center(child: LoadingIndicator()); + + final error = errorMessage; + if (error != null) { + return _OpenCryptoPayConfirmError(message: error, onRetry: onRetry); } - 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), - ], - ), + _OpenCryptoPaySummaryCard( + paymentDetails: paymentDetails, + selectedMethod: selectedMethod, + selectedAsset: selectedAsset, ), - if (_txDetails?.hint != null) ...[ + if (txDetails?.hint != null) ...[ const SizedBox(height: 16), RoundedWhiteContainer( - child: Text(_txDetails!.hint!, style: STextStyles.label(context)), + child: Text(txDetails!.hint!, style: STextStyles.label(context)), ), ], const SizedBox(height: 24), - PrimaryButton(label: "Proceed to Send", onPressed: _proceedToSend), + PrimaryButton(label: "Proceed to Send", onPressed: onProceed), ], ), ); } +} + +class _OpenCryptoPayConfirmError extends StatelessWidget { + const _OpenCryptoPayConfirmError({ + required this.message, + required this.onRetry, + }); - Widget _row(String label, String value) { + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + message, + style: STextStyles.itemSubtitle(context), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + PrimaryButton(label: "Retry", onPressed: onRetry), + ], + ), + ); + } +} + +class _OpenCryptoPaySummaryCard extends StatelessWidget { + const _OpenCryptoPaySummaryCard({ + required this.paymentDetails, + required this.selectedMethod, + required this.selectedAsset, + }); + + final OpenCryptoPayPaymentDetails paymentDetails; + final OpenCryptoPayTransferMethod selectedMethod; + final OpenCryptoPayAsset selectedAsset; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Payment Summary", style: STextStyles.itemSubtitle12(context)), + const SizedBox(height: 8), + if (paymentDetails.recipient?.name != null) + _OpenCryptoPaySummaryRow( + label: "To", + value: paymentDetails.recipient!.name!, + ), + if (paymentDetails.requestedAmount != null) + _OpenCryptoPaySummaryRow( + label: "Fiat amount", + value: + "${paymentDetails.requestedAmount!.amount} " + "${paymentDetails.requestedAmount!.asset}", + ), + _OpenCryptoPaySummaryRow( + label: "Crypto amount", + value: "${selectedAsset.amount} ${selectedAsset.asset}", + ), + _OpenCryptoPaySummaryRow( + label: "Network", + value: selectedMethod.method, + ), + ], + ), + ); + } +} + +class _OpenCryptoPaySummaryRow extends StatelessWidget { + const _OpenCryptoPaySummaryRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart index ea0e188a2..49df2cdac 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart @@ -21,6 +21,16 @@ import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'open_crypto_pay_confirm_view.dart'; +typedef _OpenCryptoPayOptionSupported = + bool Function( + OpenCryptoPayTransferMethod method, + OpenCryptoPayAsset asset, + Iterable enabledErc20Tokens, + ); + +typedef _OpenCryptoPayOptionSelected = + void Function(OpenCryptoPayTransferMethod method, OpenCryptoPayAsset asset); + /// 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 { @@ -151,44 +161,67 @@ class _OpenCryptoPayViewState extends ConsumerState { ), ), body: SafeArea( - child: Padding(padding: const EdgeInsets.all(16), child: _body()), + child: Padding( + padding: const EdgeInsets.all(16), + child: _OpenCryptoPayBody( + isLoading: _isLoading, + errorMessage: _errorMessage, + details: _details, + coin: widget.coin, + enabledErc20Tokens: _details == null + ? const [] + : _enabledErc20Tokens(), + isSupportedOption: _isSupportedOption, + onRetry: () => unawaited(_fetch()), + onSelected: (method, asset) => + unawaited(_onSelected(method, asset)), + ), + ), ), ), ); } +} - Widget _body() { - if (_isLoading) return const Center(child: LoadingIndicator()); +class _OpenCryptoPayBody extends StatelessWidget { + const _OpenCryptoPayBody({ + required this.isLoading, + required this.errorMessage, + required this.details, + required this.coin, + required this.enabledErc20Tokens, + required this.isSupportedOption, + required this.onRetry, + required this.onSelected, + }); - 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 bool isLoading; + final String? errorMessage; + final OpenCryptoPayPaymentDetails? details; + final CryptoCurrency coin; + final List enabledErc20Tokens; + final _OpenCryptoPayOptionSupported isSupportedOption; + final VoidCallback onRetry; + final _OpenCryptoPayOptionSelected onSelected; + + @override + Widget build(BuildContext context) { + if (isLoading) return const Center(child: LoadingIndicator()); + + final error = errorMessage; + if (error != null) { + return _OpenCryptoPayError(message: error, onRetry: onRetry); } - final details = _details; + final details = this.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)) + if (isSupportedOption(m, a, enabledErc20Tokens)) (method: m, asset: a), ]; @@ -197,11 +230,11 @@ class _OpenCryptoPayViewState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (details.recipient != null) ...[ - _recipientCard(details.recipient!), + _OpenCryptoPayRecipientCard(recipient: details.recipient!), const SizedBox(height: 16), ], if (details.requestedAmount != null) ...[ - _amountCard(details), + _OpenCryptoPayAmountCard(details: details), const SizedBox(height: 16), ], Text( @@ -213,7 +246,7 @@ class _OpenCryptoPayViewState extends ConsumerState { RoundedWhiteContainer( child: Text( "No supported Open CryptoPay option available for " - "${widget.coin.prettyName}.", + "${coin.prettyName}.", style: STextStyles.itemSubtitle(context), ), ) @@ -221,7 +254,11 @@ class _OpenCryptoPayViewState extends ConsumerState { ...options.map( (o) => Padding( padding: const EdgeInsets.only(bottom: 8), - child: _methodCard(o.method, o.asset), + child: _OpenCryptoPayMethodCard( + method: o.method, + asset: o.asset, + onTap: () => onSelected(o.method, o.asset), + ), ), ), if (details.quote != null) ...[ @@ -235,8 +272,40 @@ class _OpenCryptoPayViewState extends ConsumerState { ), ); } +} + +class _OpenCryptoPayError extends StatelessWidget { + const _OpenCryptoPayError({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; - Widget _recipientCard(OpenCryptoPayRecipient recipient) { + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + message, + style: STextStyles.itemSubtitle(context), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + PrimaryButton(label: "Retry", onPressed: onRetry), + ], + ), + ); + } +} + +class _OpenCryptoPayRecipientCard extends StatelessWidget { + const _OpenCryptoPayRecipientCard({required this.recipient}); + + final OpenCryptoPayRecipient recipient; + + @override + Widget build(BuildContext context) { return RoundedWhiteContainer( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -257,8 +326,15 @@ class _OpenCryptoPayViewState extends ConsumerState { ), ); } +} - Widget _amountCard(OpenCryptoPayPaymentDetails details) { +class _OpenCryptoPayAmountCard extends StatelessWidget { + const _OpenCryptoPayAmountCard({required this.details}); + + final OpenCryptoPayPaymentDetails details; + + @override + Widget build(BuildContext context) { return RoundedWhiteContainer( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -280,13 +356,23 @@ class _OpenCryptoPayViewState extends ConsumerState { ), ); } +} - Widget _methodCard( - OpenCryptoPayTransferMethod method, - OpenCryptoPayAsset asset, - ) { +class _OpenCryptoPayMethodCard extends StatelessWidget { + const _OpenCryptoPayMethodCard({ + required this.method, + required this.asset, + required this.onTap, + }); + + final OpenCryptoPayTransferMethod method; + final OpenCryptoPayAsset asset; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { return GestureDetector( - onTap: () => _onSelected(method, asset), + onTap: onTap, child: RoundedWhiteContainer( child: Row( children: [ From ed27bf7ed39507866c590d0cc88011b0c5bb285e Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Fri, 8 May 2026 20:21:19 +0800 Subject: [PATCH 06/20] feat(open_crypto_pay): add desktop payment flow --- .../open_crypto_pay_confirm_view.dart | 196 ++++++++++++------ .../open_crypto_pay/open_crypto_pay_view.dart | 111 +++++++--- .../wallet_view/sub_widgets/desktop_send.dart | 28 ++- .../sub_widgets/desktop_token_send.dart | 27 ++- .../sub_widgets/desktop_wallet_features.dart | 63 ++++++ 5 files changed, 333 insertions(+), 92 deletions(-) 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 index e1e068cf2..661e83372 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -10,6 +10,8 @@ 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 '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart'; +import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart'; import '../../services/open_crypto_pay/evm_uri.dart'; import '../../services/open_crypto_pay/method_support.dart'; import '../../services/open_crypto_pay/models.dart'; @@ -26,6 +28,8 @@ 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/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; @@ -45,6 +49,7 @@ class OpenCryptoPayConfirmView extends ConsumerStatefulWidget { required this.selectedAsset, required this.walletId, required this.coin, + this.isDesktop = false, }); final OpenCryptoPayPaymentDetails paymentDetails; @@ -52,6 +57,7 @@ class OpenCryptoPayConfirmView extends ConsumerStatefulWidget { final OpenCryptoPayAsset selectedAsset; final String walletId; final CryptoCurrency coin; + final bool isDesktop; @override ConsumerState createState() => @@ -276,31 +282,45 @@ class _OpenCryptoPayConfirmViewState return; } + final autoFillData = 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!, + ), + ); + 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!, + if (widget.isDesktop) { + await showDialog( + context: context, + barrierDismissible: true, + builder: (_) => DesktopDialog( + maxHeight: MediaQuery.sizeOf(context).height - 64, + maxWidth: 580, + child: DesktopSend( + walletId: widget.walletId, + autoFillData: autoFillData, ), ), - ), + ); + return; + } + + await Navigator.of(context).pushNamed( + SendView.routeName, + arguments: Tuple3(widget.walletId, widget.coin, autoFillData), ); } @@ -334,33 +354,46 @@ class _OpenCryptoPayConfirmViewState } final amount = evmUri.amount(fractionDigits: contract.decimals); + final autoFillData = 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, + ), + ); + 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, + if (widget.isDesktop) { + await showDialog( + context: context, + barrierDismissible: true, + builder: (_) => DesktopDialog( + maxHeight: MediaQuery.sizeOf(context).height - 64, + maxWidth: 580, + child: DesktopTokenSend( + walletId: widget.walletId, + autoFillData: autoFillData, ), ), - ), + ); + return; + } + + await Navigator.of(context).pushNamed( + TokenSendView.routeName, + arguments: Tuple4(widget.walletId, widget.coin, contract, autoFillData), ); } @@ -376,6 +409,27 @@ class _OpenCryptoPayConfirmViewState @override Widget build(BuildContext context) { + final body = Padding( + padding: const EdgeInsets.all(16), + child: _OpenCryptoPayConfirmBody( + isLoading: _isLoading, + errorMessage: _errorMessage, + paymentDetails: widget.paymentDetails, + selectedMethod: widget.selectedMethod, + selectedAsset: widget.selectedAsset, + txDetails: _txDetails, + onRetry: () => unawaited(_fetch()), + onProceed: () => unawaited(_proceedToSend()), + ), + ); + + if (widget.isDesktop) { + return _OpenCryptoPayConfirmDesktopFrame( + title: "Confirm Payment", + child: body, + ); + } + return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, @@ -389,21 +443,43 @@ class _OpenCryptoPayConfirmViewState style: STextStyles.navBarTitle(context), ), ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: _OpenCryptoPayConfirmBody( - isLoading: _isLoading, - errorMessage: _errorMessage, - paymentDetails: widget.paymentDetails, - selectedMethod: widget.selectedMethod, - selectedAsset: widget.selectedAsset, - txDetails: _txDetails, - onRetry: () => unawaited(_fetch()), - onProceed: () => unawaited(_proceedToSend()), - ), + body: SafeArea(child: body), + ), + ); + } +} + +class _OpenCryptoPayConfirmDesktopFrame extends StatelessWidget { + const _OpenCryptoPayConfirmDesktopFrame({ + required this.title, + required this.child, + }); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height - 64, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text(title, style: STextStyles.desktopH3(context)), + ), + const DesktopDialogCloseButton(), + ], ), - ), + Flexible(child: child), + ], ), ); } diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart index 49df2cdac..300192903 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart @@ -16,6 +16,8 @@ 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/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; @@ -39,6 +41,7 @@ class OpenCryptoPayView extends ConsumerStatefulWidget { required this.qrUrl, required this.walletId, required this.coin, + this.isDesktop = false, }); static const String routeName = "/openCryptoPayView"; @@ -48,6 +51,7 @@ class OpenCryptoPayView extends ConsumerStatefulWidget { /// Only methods/assets this wallet can safely settle are offered. final String walletId; final CryptoCurrency coin; + final bool isDesktop; @override ConsumerState createState() => _OpenCryptoPayViewState(); @@ -128,17 +132,34 @@ class _OpenCryptoPayViewState extends ConsumerState { return; } - final result = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => OpenCryptoPayConfirmView( - paymentDetails: _details!, - selectedMethod: method, - selectedAsset: asset, - walletId: widget.walletId, - coin: widget.coin, - ), - ), - ); + final result = widget.isDesktop + ? await showDialog( + context: context, + barrierDismissible: true, + builder: (_) => DesktopDialog( + maxHeight: MediaQuery.sizeOf(context).height - 64, + maxWidth: 580, + child: OpenCryptoPayConfirmView( + paymentDetails: _details!, + selectedMethod: method, + selectedAsset: asset, + walletId: widget.walletId, + coin: widget.coin, + isDesktop: true, + ), + ), + ) + : 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(); @@ -147,6 +168,24 @@ class _OpenCryptoPayViewState extends ConsumerState { @override Widget build(BuildContext context) { + final body = Padding( + padding: const EdgeInsets.all(16), + child: _OpenCryptoPayBody( + isLoading: _isLoading, + errorMessage: _errorMessage, + details: _details, + coin: widget.coin, + enabledErc20Tokens: _details == null ? const [] : _enabledErc20Tokens(), + isSupportedOption: _isSupportedOption, + onRetry: () => unawaited(_fetch()), + onSelected: (method, asset) => unawaited(_onSelected(method, asset)), + ), + ); + + if (widget.isDesktop) { + return _OpenCryptoPayDesktopFrame(title: "Open CryptoPay", child: body); + } + return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, @@ -160,24 +199,40 @@ class _OpenCryptoPayViewState extends ConsumerState { style: STextStyles.navBarTitle(context), ), ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: _OpenCryptoPayBody( - isLoading: _isLoading, - errorMessage: _errorMessage, - details: _details, - coin: widget.coin, - enabledErc20Tokens: _details == null - ? const [] - : _enabledErc20Tokens(), - isSupportedOption: _isSupportedOption, - onRetry: () => unawaited(_fetch()), - onSelected: (method, asset) => - unawaited(_onSelected(method, asset)), - ), + body: SafeArea(child: body), + ), + ); + } +} + +class _OpenCryptoPayDesktopFrame extends StatelessWidget { + const _OpenCryptoPayDesktopFrame({required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height - 64, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text(title, style: STextStyles.desktopH3(context)), + ), + const DesktopDialogCloseButton(), + ], ), - ), + Flexible(child: child), + ], ), ); } 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 b8dc85f4d..95c7415c9 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 @@ -24,6 +24,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_view.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'; @@ -34,6 +35,7 @@ import '../../../../providers/ui/fee_rate_type_state_provider.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../providers/wallet/desktop_fee_providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; +import '../../../../services/open_crypto_pay/lnurl_utils.dart'; import '../../../../services/spark_names_service.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; @@ -153,7 +155,7 @@ class _DesktopSendState extends ConsumerState { Logging.instance.w("Qr scanning cancelled"); } else { try { - _processQrCodeData(qrResult); + await _processQrCodeData(qrResult); } catch (e, s) { Logging.instance.e( "Error processing QR code data", @@ -767,6 +769,7 @@ class _DesktopSendState extends ConsumerState { onSuccess: clearSendForm, isPaynymTransaction: isPaynymSend, routeOnSuccessName: DesktopHomeView.routeName, + openCryptoPayCommit: _data?.openCryptoPayCommit, ), ), ), @@ -906,7 +909,7 @@ class _DesktopSendState extends ConsumerState { // return null; // } - void _processQrCodeData(String qrCodeData) { + Future _processQrCodeData(String qrCodeData) async { try { final paymentData = AddressUtils.parsePaymentUri( qrCodeData, @@ -916,6 +919,8 @@ class _DesktopSendState extends ConsumerState { if (paymentData != null && paymentData.coin?.uriScheme == coin.uriScheme) { _applyUri(paymentData); + } else if (LnurlUtils.isOpenCryptoPayUrl(qrCodeData)) { + await _showOpenCryptoPay(qrCodeData); } else { _address = qrCodeData.split("\n").first.trim(); sendToController.text = _address ?? ""; @@ -934,6 +939,25 @@ class _DesktopSendState extends ConsumerState { } } + Future _showOpenCryptoPay(String qrUrl) async { + if (!mounted) return; + + await showDialog( + context: context, + barrierDismissible: true, + builder: (_) => DesktopDialog( + maxHeight: MediaQuery.sizeOf(context).height - 64, + maxWidth: 580, + child: OpenCryptoPayView( + qrUrl: qrUrl, + walletId: walletId, + coin: coin, + isDesktop: true, + ), + ), + ); + } + void _setValidAddressProviders(String? address) { if (isPaynymSend) { ref.read(pValidSendToAddress.notifier).state = true; 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 f01cdd246..4be033e67 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 @@ -18,12 +18,14 @@ import 'package:flutter_riverpod/flutter_riverpod.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_view.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/ui/fee_rate_type_state_provider.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../providers/wallet/desktop_fee_providers.dart'; +import '../../../../services/open_crypto_pay/lnurl_utils.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; import '../../../../utilities/amount/amount.dart'; @@ -269,6 +271,7 @@ class _DesktopTokenSendState extends ConsumerState { onSuccess: clearSendForm, isTokenTx: true, routeOnSuccessName: DesktopHomeView.routeName, + openCryptoPayCommit: _data?.openCryptoPayCommit, ), ), ), @@ -477,9 +480,10 @@ class _DesktopTokenSendState extends ConsumerState { setState(() { _addressToggleFlag = sendToController.text.isNotEmpty; }); - - // now check for non standard encoded basic address + } else if (LnurlUtils.isOpenCryptoPayUrl(qrResult)) { + await _showOpenCryptoPay(qrResult); } else { + // now check for non standard encoded basic address _address = qrResult.split("\n").first.trim(); sendToController.text = _address ?? ""; @@ -499,6 +503,25 @@ class _DesktopTokenSendState extends ConsumerState { } } + Future _showOpenCryptoPay(String qrUrl) async { + if (!mounted) return; + + await showDialog( + context: context, + barrierDismissible: true, + builder: (_) => DesktopDialog( + maxHeight: MediaQuery.sizeOf(context).height - 64, + maxWidth: 580, + child: OpenCryptoPayView( + qrUrl: qrUrl, + walletId: walletId, + coin: coin, + isDesktop: true, + ), + ), + ); + } + Future pasteAddress() async { final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); if (data?.text != null && data!.text!.isNotEmpty) { diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart index a9458bfd9..bf957da53 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart @@ -23,6 +23,7 @@ import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/masternodes/masternodes_home_view.dart'; import '../../../../pages/monkey/monkey_view.dart'; import '../../../../pages/namecoin_names/namecoin_names_home_view.dart'; +import '../../../../pages/open_crypto_pay/open_crypto_pay_view.dart'; import '../../../../pages/paynym/paynym_claim_view.dart'; import '../../../../pages/paynym/paynym_home_view.dart'; import '../../../../pages/salvium_stake/salvium_create_stake_view.dart'; @@ -32,6 +33,7 @@ import '../../../../providers/desktop/current_desktop_menu_item.dart'; import '../../../../providers/global/paynym_api_provider.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/wallet/my_paynym_account_state_provider.dart'; +import '../../../../services/open_crypto_pay/lnurl_utils.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../themes/theme_providers.dart'; import '../../../../utilities/amount/amount.dart'; @@ -61,6 +63,7 @@ import '../../../../widgets/custom_loading_overlay.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/loading_indicator.dart'; import '../../../../widgets/static_overflow_row/static_overflow_row.dart'; @@ -78,6 +81,7 @@ enum WalletFeature { anonymizeFunds("Privatize funds", "Privatize funds"), swap("Swap", ""), buy("Buy", "Buy cryptocurrency"), + openCryptoPay("Pay", "Open CryptoPay payments"), paynym("PayNym", "Increased address privacy using BIP47"), coinControl( "Coin control", @@ -131,6 +135,58 @@ class _DesktopWalletFeaturesState extends ConsumerState { ref.read(prevDesktopMenuItemProvider.state).state = DesktopMenuItemId.buy; } + Future _onOpenCryptoPayPressed() async { + try { + final qrResult = await showDialog( + context: context, + builder: (_) => const QrCodeScannerDialog(), + ); + + if (qrResult == null) { + Logging.instance.w("Qr scanning cancelled"); + return; + } + + if (!LnurlUtils.isOpenCryptoPayUrl(qrResult)) { + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "The scanned QR code is not an Open CryptoPay payment code.", + context: context, + ), + ); + } + return; + } + + final wallet = ref.read(pWallets).getWallet(widget.walletId); + if (!mounted) return; + + await showDialog( + context: context, + barrierDismissible: true, + builder: (_) => DesktopDialog( + maxHeight: MediaQuery.sizeOf(context).height - 64, + maxWidth: 580, + child: OpenCryptoPayView( + qrUrl: qrResult, + walletId: widget.walletId, + coin: wallet.info.coin, + isDesktop: true, + ), + ), + ); + } catch (e, s) { + Logging.instance.e( + "Failed to scan QR for OpenCryptoPay", + error: e, + stackTrace: s, + ); + } + } + Future _onMorePressed( List<(WalletFeature, String, FutureOr Function())> options, ) async { @@ -494,6 +550,13 @@ class _DesktopWalletFeaturesState extends ConsumerState { if (showExchange && AppConfig.hasFeature(AppFeature.buy)) (WalletFeature.buy, Assets.svg.swap, _onBuyPressed), + if (!isViewOnly) + ( + WalletFeature.openCryptoPay, + Assets.svg.qrcode, + _onOpenCryptoPayPressed, + ), + if (wallet is LibSalviumWallet) ( WalletFeature.salviumStaking, From 23b8ae79b42e0c7a8f364799ff9820d71c23b078 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Fri, 8 May 2026 20:33:21 +0800 Subject: [PATCH 07/20] fix(open_crypto_pay): verify transaction amount matches quote --- .../open_crypto_pay_confirm_view.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 index 661e83372..cb6f60c8e 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -168,6 +168,11 @@ class _OpenCryptoPayConfirmViewState return null; } + bool _matchesQuotedAmount(Decimal amount) { + final quotedAmount = Decimal.tryParse(widget.selectedAsset.amount); + return quotedAmount != null && amount.compareTo(quotedAmount) == 0; + } + Future _loadTokenWallet(EthContract contract) async { final wallet = ref.read(pWallets).getWallet(widget.walletId); if (wallet is! EthereumWallet) { @@ -275,6 +280,10 @@ class _OpenCryptoPayConfirmViewState _warn("Could not parse payment amount"); return; } + if (!_matchesQuotedAmount(parsed.amount!)) { + _warn("Payment amount does not match the quoted amount"); + return; + } if (parsed.scheme != null && parsed.scheme!.isNotEmpty && parsed.scheme != widget.coin.uriScheme) { @@ -354,6 +363,11 @@ class _OpenCryptoPayConfirmViewState } final amount = evmUri.amount(fractionDigits: contract.decimals); + if (!_matchesQuotedAmount(amount)) { + _warn("Payment amount does not match the quoted amount"); + return; + } + final autoFillData = SendViewAutoFillData( address: evmUri.recipientAddress!, contactLabel: recipient, From e874b1c1503c673eded78068ac39bb496d177c2d Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Tue, 9 Jun 2026 15:21:01 +0800 Subject: [PATCH 08/20] Align Open CryptoPay flow with standard --- lib/services/open_crypto_pay/evm_uri.dart | 25 ++++++++++++- lib/services/open_crypto_pay/lnurl_utils.dart | 13 +++++-- lib/services/open_crypto_pay/models.dart | 35 +++++++++++++++--- .../open_crypto_pay/open_crypto_pay_api.dart | 5 +++ test/open_crypto_pay_evm_uri_test.dart | 10 +++++ test/open_crypto_pay_lnurl_utils_test.dart | 21 +++++++++++ test/open_crypto_pay_models_test.dart | 37 +++++++++++++++++++ 7 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 test/open_crypto_pay_lnurl_utils_test.dart diff --git a/lib/services/open_crypto_pay/evm_uri.dart b/lib/services/open_crypto_pay/evm_uri.dart index fe1746a80..cc68dd0c4 100644 --- a/lib/services/open_crypto_pay/evm_uri.dart +++ b/lib/services/open_crypto_pay/evm_uri.dart @@ -73,7 +73,28 @@ class OpenCryptoPayEvmUri { 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); + if (value == null) return null; + if (RegExp(r'^[0-9]+$').hasMatch(value)) return BigInt.tryParse(value); + + final match = RegExp( + r'^([0-9]+)(?:\.([0-9]+))?[eE]([+-]?[0-9]+)$', + ).firstMatch(value); + if (match == null) return null; + + final whole = match.group(1)!; + final fraction = match.group(2) ?? ''; + final exponent = int.tryParse(match.group(3)!); + if (exponent == null) return null; + + final digits = whole + fraction; + final scale = exponent - fraction.length; + if (scale >= 0) { + return BigInt.parse(digits) * BigInt.from(10).pow(scale); + } + + final divisor = BigInt.from(10).pow(-scale); + final parsed = BigInt.parse(digits); + if (parsed % divisor != BigInt.zero) return null; + return parsed ~/ divisor; } } diff --git a/lib/services/open_crypto_pay/lnurl_utils.dart b/lib/services/open_crypto_pay/lnurl_utils.dart index ec8977e4b..26793c981 100644 --- a/lib/services/open_crypto_pay/lnurl_utils.dart +++ b/lib/services/open_crypto_pay/lnurl_utils.dart @@ -14,16 +14,21 @@ class LnurlUtils { 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. + /// Returns true if [url] is an Open CryptoPay QR payload, i.e. is a bech32 + /// LNURL or has a `lightning` query parameter containing one. static bool isOpenCryptoPayUrl(String url) { return extractLnurl(url)?.toUpperCase().startsWith('LNURL') ?? false; } - /// Returns the `lightning` query parameter, if any. + /// Returns the encoded LNURL payload, if any. static String? extractLnurl(String url) { + final trimmed = url.trim(); + if (trimmed.toUpperCase().startsWith('LNURL')) { + return trimmed; + } + try { - return Uri.parse(url).queryParameters['lightning']; + return Uri.parse(trimmed).queryParameters['lightning']; } catch (_) { return null; } diff --git a/lib/services/open_crypto_pay/models.dart b/lib/services/open_crypto_pay/models.dart index fc3fd1be5..b4ac1ea99 100644 --- a/lib/services/open_crypto_pay/models.dart +++ b/lib/services/open_crypto_pay/models.dart @@ -92,7 +92,7 @@ class OpenCryptoPayTransferMethod { assets: (json['assets'] as List) .map((e) => OpenCryptoPayAsset.fromJson(e as Map)) .toList(), - available: json['available'] as bool, + available: json['available'] as bool? ?? true, ); } } @@ -108,20 +108,39 @@ class OpenCryptoPayQuote { required this.expiration, }); - factory OpenCryptoPayQuote.fromJson(Map json) { - final paymentId = json['payment'] as String?; + factory OpenCryptoPayQuote.fromJson( + Map json, { + String? callback, + }) { + final paymentId = + _paymentIdFromCallback(callback) ?? json['payment'] as String?; if (paymentId == null || paymentId.isEmpty) { throw Exception('OpenCryptoPay: quote payment id is missing'); } return OpenCryptoPayQuote( - id: json['id'] as String, + id: json['id']?.toString() ?? '', paymentId: paymentId, expiration: DateTime.parse(json['expiration'] as String), ); } bool get isExpired => expiration.isBefore(DateTime.now()); + + static String? _paymentIdFromCallback(String? callback) { + if (callback == null) return null; + + final uri = Uri.tryParse(callback); + if (uri == null) return null; + + final segments = uri.pathSegments; + final cbIndex = segments.lastIndexOf('cb'); + if (cbIndex == -1 || cbIndex + 1 >= segments.length) { + return null; + } + + return segments[cbIndex + 1]; + } } class OpenCryptoPayRequestedAmount { @@ -179,7 +198,10 @@ class OpenCryptoPayPaymentDetails { ), quote: json['quote'] == null ? null - : OpenCryptoPayQuote.fromJson(json['quote'] as Map), + : OpenCryptoPayQuote.fromJson( + json['quote'] as Map, + callback: json['callback'] as String?, + ), requestedAmount: json['requestedAmount'] == null ? null : OpenCryptoPayRequestedAmount.fromJson( @@ -203,7 +225,8 @@ class OpenCryptoPayPaymentDetails { bool get supportsOpenCryptoPay => standard == 'OpenCryptoPay' || - possibleStandards.contains('OpenCryptoPay'); + possibleStandards.contains('OpenCryptoPay') || + (callback.isNotEmpty && quote != null && transferAmounts.isNotEmpty); } class OpenCryptoPayTransactionDetails { diff --git a/lib/services/open_crypto_pay/open_crypto_pay_api.dart b/lib/services/open_crypto_pay/open_crypto_pay_api.dart index c1b526667..8ec5facea 100644 --- a/lib/services/open_crypto_pay/open_crypto_pay_api.dart +++ b/lib/services/open_crypto_pay/open_crypto_pay_api.dart @@ -167,6 +167,7 @@ class OpenCryptoPayApi { ...base.queryParameters, 'quote': commit.quoteId, 'method': commit.method, + 'asset': commit.asset, ...queryParameters, }, ); @@ -185,6 +186,10 @@ class OpenCryptoPayApi { if (paymentId.isEmpty) { throw Exception('OpenCryptoPay: quote payment id is missing'); } + if (callback.path.contains('/cb/')) { + return callback.replace(path: callback.path.replaceFirst('/cb/', '/tx/')); + } + final segments = callback.pathSegments.toList(); final cbIndex = segments.lastIndexOf('cb'); if (cbIndex == -1) { diff --git a/test/open_crypto_pay_evm_uri_test.dart b/test/open_crypto_pay_evm_uri_test.dart index 1484a63fc..5558c0fd0 100644 --- a/test/open_crypto_pay_evm_uri_test.dart +++ b/test/open_crypto_pay_evm_uri_test.dart @@ -17,6 +17,16 @@ void main() { expect(result.isTokenTransfer, false); }); + test("parses EIP-681 scientific notation atomic amounts", () { + final result = OpenCryptoPayEvmUri.tryParse( + "ethereum:0x9C2242a0B71FD84661Fd4bC56b75c90Fac6d10FC@1" + "?value=6.6072e14", + ); + + expect(result, isNotNull); + expect(result!.amountRaw, BigInt.parse("660720000000000")); + }); + test("parses ERC20 transfer URI", () { final result = OpenCryptoPayEvmUri.tryParse( "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer" diff --git a/test/open_crypto_pay_lnurl_utils_test.dart b/test/open_crypto_pay_lnurl_utils_test.dart new file mode 100644 index 000000000..7da1eef28 --- /dev/null +++ b/test/open_crypto_pay_lnurl_utils_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/open_crypto_pay/lnurl_utils.dart'; + +void main() { + test("detects raw LNURL payloads", () { + expect(LnurlUtils.extractLnurl("LNURL1TEST"), "LNURL1TEST"); + expect(LnurlUtils.isOpenCryptoPayUrl("lnurl1test"), true); + }); + + test("detects LNURL in lightning query parameter", () { + const payload = "https://example.com/pay?lightning=LNURL1TEST"; + + expect(LnurlUtils.extractLnurl(payload), "LNURL1TEST"); + expect(LnurlUtils.isOpenCryptoPayUrl(payload), true); + }); + + test("ignores URLs without an LNURL payload", () { + expect(LnurlUtils.extractLnurl("https://example.com/pay"), isNull); + expect(LnurlUtils.isOpenCryptoPayUrl("https://example.com/pay"), false); + }); +} diff --git a/test/open_crypto_pay_models_test.dart b/test/open_crypto_pay_models_test.dart index df5a48f5b..841ec063b 100644 --- a/test/open_crypto_pay_models_test.dart +++ b/test/open_crypto_pay_models_test.dart @@ -13,6 +13,25 @@ void main() { expect(quote.paymentId, "payment-id"); }); + test("parses payment id from callback URL like Cake Wallet", () { + final quote = OpenCryptoPayQuote.fromJson({ + "id": "quote-id", + "expiration": "2026-04-28T12:00:00Z", + }, callback: "https://example.com/lnurl/cb/payment-id"); + + expect(quote.paymentId, "payment-id"); + }); + + test("prefers callback payment id over quote payment id", () { + final quote = OpenCryptoPayQuote.fromJson({ + "id": "quote-id", + "payment": "quote-payment-id", + "expiration": "2026-04-28T12:00:00Z", + }, callback: "https://example.com/lnurl/cb/callback-payment-id"); + + expect(quote.paymentId, "callback-payment-id"); + }); + test("rejects quotes without a payment id", () { expect( () => OpenCryptoPayQuote.fromJson({ @@ -22,4 +41,22 @@ void main() { throwsException, ); }); + + test("accepts Cake-compatible details without standard metadata", () { + final details = OpenCryptoPayPaymentDetails.fromJson({ + "callback": "https://example.com/lnurl/cb/payment-id", + "quote": {"id": "quote-id", "expiration": "2026-04-28T12:00:00Z"}, + "transferAmounts": [ + { + "method": "Ethereum", + "assets": [ + {"asset": "ETH", "amount": "0.1"}, + ], + }, + ], + }); + + expect(details.supportsOpenCryptoPay, true); + expect(details.availableMethods.single.available, true); + }); } From 2ae0b0d75fd6597383809c40db70d35ae26bc613 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Wed, 10 Jun 2026 15:09:10 +0800 Subject: [PATCH 09/20] Keep Open CryptoPay parsing strict --- lib/services/open_crypto_pay/models.dart | 35 +++--------------- .../open_crypto_pay/open_crypto_pay_api.dart | 5 --- test/open_crypto_pay_models_test.dart | 37 ------------------- 3 files changed, 6 insertions(+), 71 deletions(-) diff --git a/lib/services/open_crypto_pay/models.dart b/lib/services/open_crypto_pay/models.dart index b4ac1ea99..fc3fd1be5 100644 --- a/lib/services/open_crypto_pay/models.dart +++ b/lib/services/open_crypto_pay/models.dart @@ -92,7 +92,7 @@ class OpenCryptoPayTransferMethod { assets: (json['assets'] as List) .map((e) => OpenCryptoPayAsset.fromJson(e as Map)) .toList(), - available: json['available'] as bool? ?? true, + available: json['available'] as bool, ); } } @@ -108,39 +108,20 @@ class OpenCryptoPayQuote { required this.expiration, }); - factory OpenCryptoPayQuote.fromJson( - Map json, { - String? callback, - }) { - final paymentId = - _paymentIdFromCallback(callback) ?? json['payment'] as String?; + 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']?.toString() ?? '', + id: json['id'] as String, paymentId: paymentId, expiration: DateTime.parse(json['expiration'] as String), ); } bool get isExpired => expiration.isBefore(DateTime.now()); - - static String? _paymentIdFromCallback(String? callback) { - if (callback == null) return null; - - final uri = Uri.tryParse(callback); - if (uri == null) return null; - - final segments = uri.pathSegments; - final cbIndex = segments.lastIndexOf('cb'); - if (cbIndex == -1 || cbIndex + 1 >= segments.length) { - return null; - } - - return segments[cbIndex + 1]; - } } class OpenCryptoPayRequestedAmount { @@ -198,10 +179,7 @@ class OpenCryptoPayPaymentDetails { ), quote: json['quote'] == null ? null - : OpenCryptoPayQuote.fromJson( - json['quote'] as Map, - callback: json['callback'] as String?, - ), + : OpenCryptoPayQuote.fromJson(json['quote'] as Map), requestedAmount: json['requestedAmount'] == null ? null : OpenCryptoPayRequestedAmount.fromJson( @@ -225,8 +203,7 @@ class OpenCryptoPayPaymentDetails { bool get supportsOpenCryptoPay => standard == 'OpenCryptoPay' || - possibleStandards.contains('OpenCryptoPay') || - (callback.isNotEmpty && quote != null && transferAmounts.isNotEmpty); + possibleStandards.contains('OpenCryptoPay'); } class OpenCryptoPayTransactionDetails { diff --git a/lib/services/open_crypto_pay/open_crypto_pay_api.dart b/lib/services/open_crypto_pay/open_crypto_pay_api.dart index 8ec5facea..c1b526667 100644 --- a/lib/services/open_crypto_pay/open_crypto_pay_api.dart +++ b/lib/services/open_crypto_pay/open_crypto_pay_api.dart @@ -167,7 +167,6 @@ class OpenCryptoPayApi { ...base.queryParameters, 'quote': commit.quoteId, 'method': commit.method, - 'asset': commit.asset, ...queryParameters, }, ); @@ -186,10 +185,6 @@ class OpenCryptoPayApi { if (paymentId.isEmpty) { throw Exception('OpenCryptoPay: quote payment id is missing'); } - if (callback.path.contains('/cb/')) { - return callback.replace(path: callback.path.replaceFirst('/cb/', '/tx/')); - } - final segments = callback.pathSegments.toList(); final cbIndex = segments.lastIndexOf('cb'); if (cbIndex == -1) { diff --git a/test/open_crypto_pay_models_test.dart b/test/open_crypto_pay_models_test.dart index 841ec063b..df5a48f5b 100644 --- a/test/open_crypto_pay_models_test.dart +++ b/test/open_crypto_pay_models_test.dart @@ -13,25 +13,6 @@ void main() { expect(quote.paymentId, "payment-id"); }); - test("parses payment id from callback URL like Cake Wallet", () { - final quote = OpenCryptoPayQuote.fromJson({ - "id": "quote-id", - "expiration": "2026-04-28T12:00:00Z", - }, callback: "https://example.com/lnurl/cb/payment-id"); - - expect(quote.paymentId, "payment-id"); - }); - - test("prefers callback payment id over quote payment id", () { - final quote = OpenCryptoPayQuote.fromJson({ - "id": "quote-id", - "payment": "quote-payment-id", - "expiration": "2026-04-28T12:00:00Z", - }, callback: "https://example.com/lnurl/cb/callback-payment-id"); - - expect(quote.paymentId, "callback-payment-id"); - }); - test("rejects quotes without a payment id", () { expect( () => OpenCryptoPayQuote.fromJson({ @@ -41,22 +22,4 @@ void main() { throwsException, ); }); - - test("accepts Cake-compatible details without standard metadata", () { - final details = OpenCryptoPayPaymentDetails.fromJson({ - "callback": "https://example.com/lnurl/cb/payment-id", - "quote": {"id": "quote-id", "expiration": "2026-04-28T12:00:00Z"}, - "transferAmounts": [ - { - "method": "Ethereum", - "assets": [ - {"asset": "ETH", "amount": "0.1"}, - ], - }, - ], - }); - - expect(details.supportsOpenCryptoPay, true); - expect(details.availableMethods.single.available, true); - }); } From d024bc66521fcbfd1935ba30aeeb0651016cada5 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 11 Jun 2026 02:16:25 +0800 Subject: [PATCH 10/20] Restrict Open CryptoPay entry points --- lib/pages/wallet_view/wallet_view.dart | 4 +++- .../wallet_view/sub_widgets/desktop_wallet_features.dart | 4 +++- lib/services/open_crypto_pay/lnurl_utils.dart | 8 ++------ lib/services/open_crypto_pay/method_support.dart | 8 ++++++++ test/open_crypto_pay_lnurl_utils_test.dart | 6 +++--- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 82bfa031e..5c9be12cc 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -35,6 +35,7 @@ import '../../services/event_bus/events/global/wallet_sync_status_changed_event. 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 '../../services/open_crypto_pay/method_support.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; @@ -1426,7 +1427,8 @@ class _WalletViewState extends ConsumerState { ).pushNamed(GiftCardsView.routeName); }, ), - if (!viewOnly) + if (!viewOnly && + OpenCryptoPayMethodSupport.hasSupportedWalletCoin(coin)) WalletNavigationBarItemData( label: "Pay", icon: const QrCodeIcon(), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart index bf957da53..0851d13ad 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart @@ -34,6 +34,7 @@ import '../../../../providers/global/paynym_api_provider.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/wallet/my_paynym_account_state_provider.dart'; import '../../../../services/open_crypto_pay/lnurl_utils.dart'; +import '../../../../services/open_crypto_pay/method_support.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../themes/theme_providers.dart'; import '../../../../utilities/amount/amount.dart'; @@ -550,7 +551,8 @@ class _DesktopWalletFeaturesState extends ConsumerState { if (showExchange && AppConfig.hasFeature(AppFeature.buy)) (WalletFeature.buy, Assets.svg.swap, _onBuyPressed), - if (!isViewOnly) + if (!isViewOnly && + OpenCryptoPayMethodSupport.hasSupportedWalletCoin(coin)) ( WalletFeature.openCryptoPay, Assets.svg.qrcode, diff --git a/lib/services/open_crypto_pay/lnurl_utils.dart b/lib/services/open_crypto_pay/lnurl_utils.dart index 26793c981..a2a2ad50e 100644 --- a/lib/services/open_crypto_pay/lnurl_utils.dart +++ b/lib/services/open_crypto_pay/lnurl_utils.dart @@ -14,8 +14,8 @@ class LnurlUtils { return utf8.decode(_fromBase32(decoded.data)); } - /// Returns true if [url] is an Open CryptoPay QR payload, i.e. is a bech32 - /// LNURL or has a `lightning` query parameter containing one. + /// 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; } @@ -23,10 +23,6 @@ class LnurlUtils { /// Returns the encoded LNURL payload, if any. static String? extractLnurl(String url) { final trimmed = url.trim(); - if (trimmed.toUpperCase().startsWith('LNURL')) { - return trimmed; - } - try { return Uri.parse(trimmed).queryParameters['lightning']; } catch (_) { diff --git a/lib/services/open_crypto_pay/method_support.dart b/lib/services/open_crypto_pay/method_support.dart index 722cfdc07..2419d2897 100644 --- a/lib/services/open_crypto_pay/method_support.dart +++ b/lib/services/open_crypto_pay/method_support.dart @@ -6,6 +6,14 @@ import 'models.dart'; class OpenCryptoPayMethodSupport { const OpenCryptoPayMethodSupport._(); + static bool hasSupportedWalletCoin(CryptoCurrency coin) { + return coin is Bitcoin || + coin is Ethereum || + coin is Solana || + coin is Cardano || + coin is Firo; + } + static OpenCryptoPaySubmissionFlow? submissionFlowFor(String method) { switch (method) { case 'Solana': diff --git a/test/open_crypto_pay_lnurl_utils_test.dart b/test/open_crypto_pay_lnurl_utils_test.dart index 7da1eef28..99334b775 100644 --- a/test/open_crypto_pay_lnurl_utils_test.dart +++ b/test/open_crypto_pay_lnurl_utils_test.dart @@ -2,9 +2,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/services/open_crypto_pay/lnurl_utils.dart'; void main() { - test("detects raw LNURL payloads", () { - expect(LnurlUtils.extractLnurl("LNURL1TEST"), "LNURL1TEST"); - expect(LnurlUtils.isOpenCryptoPayUrl("lnurl1test"), true); + test("ignores raw LNURL payloads", () { + expect(LnurlUtils.extractLnurl("LNURL1TEST"), isNull); + expect(LnurlUtils.isOpenCryptoPayUrl("lnurl1test"), false); }); test("detects LNURL in lightning query parameter", () { From 773cd6036ee934c8b5ee1995d1d14234381b59e4 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 11 Jun 2026 02:55:51 +0800 Subject: [PATCH 11/20] Use built-in OCP method card tap handler --- .../open_crypto_pay/open_crypto_pay_view.dart | 54 +++++++++---------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart index 300192903..72cabd978 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart @@ -426,35 +426,33 @@ class _OpenCryptoPayMethodCard extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - 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, + return RoundedWhiteContainer( + onPressed: onTap, + 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, + ), + ], ), ); } From 8aca547acf58cbd6efc3ec4a997a34a1079b5b48 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 11 Jun 2026 03:06:18 +0800 Subject: [PATCH 12/20] Tighten Open CryptoPay UI integration --- .../open_crypto_pay_confirm_view.dart | 46 ++-------------- .../open_crypto_pay_desktop_frame.dart | 41 ++++++++++++++ .../open_crypto_pay_dialog.dart | 27 ++++++++++ .../open_crypto_pay/open_crypto_pay_view.dart | 54 +++++-------------- .../send_view/confirm_transaction_view.dart | 2 - .../wallet_view/sub_widgets/desktop_send.dart | 18 ++----- .../sub_widgets/desktop_token_send.dart | 18 ++----- .../sub_widgets/desktop_wallet_features.dart | 18 ++----- .../open_crypto_pay/method_support.dart | 7 +-- lib/services/open_crypto_pay/models.dart | 3 -- 10 files changed, 101 insertions(+), 133 deletions(-) create mode 100644 lib/pages/open_crypto_pay/open_crypto_pay_desktop_frame.dart create mode 100644 lib/pages/open_crypto_pay/open_crypto_pay_dialog.dart 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 index cb6f60c8e..888fc1932 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -29,10 +29,10 @@ import '../../wallets/wallet/wallet.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_dialog.dart'; -import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; +import 'open_crypto_pay_desktop_frame.dart'; import '../send_view/send_view.dart'; import '../send_view/token_send_view.dart'; @@ -213,8 +213,7 @@ class _OpenCryptoPayConfirmViewState final submissionFlow = OpenCryptoPayMethodSupport.submissionFlowFor( widget.selectedMethod.method, ); - if (submissionFlow == null || - submissionFlow == OpenCryptoPaySubmissionFlow.external) { + if (submissionFlow == null) { _warn("This Open CryptoPay method is not supported yet"); return; } @@ -438,10 +437,7 @@ class _OpenCryptoPayConfirmViewState ); if (widget.isDesktop) { - return _OpenCryptoPayConfirmDesktopFrame( - title: "Confirm Payment", - child: body, - ); + return OpenCryptoPayDesktopFrame(title: "Confirm Payment", child: body); } return Background( @@ -463,42 +459,6 @@ class _OpenCryptoPayConfirmViewState } } -class _OpenCryptoPayConfirmDesktopFrame extends StatelessWidget { - const _OpenCryptoPayConfirmDesktopFrame({ - required this.title, - required this.child, - }); - - final String title; - final Widget child; - - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(context).height - 64, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text(title, style: STextStyles.desktopH3(context)), - ), - const DesktopDialogCloseButton(), - ], - ), - Flexible(child: child), - ], - ), - ); - } -} - class _OpenCryptoPayConfirmBody extends StatelessWidget { const _OpenCryptoPayConfirmBody({ required this.isLoading, diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_desktop_frame.dart b/lib/pages/open_crypto_pay/open_crypto_pay_desktop_frame.dart new file mode 100644 index 000000000..b38a0dd42 --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_desktop_frame.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../utilities/text_styles.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; + +class OpenCryptoPayDesktopFrame extends StatelessWidget { + const OpenCryptoPayDesktopFrame({ + super.key, + required this.title, + required this.child, + }); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height - 64, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text(title, style: STextStyles.desktopH3(context)), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible(child: child), + ], + ), + ); + } +} diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_dialog.dart b/lib/pages/open_crypto_pay/open_crypto_pay_dialog.dart new file mode 100644 index 000000000..21dabd74c --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_dialog.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import 'open_crypto_pay_view.dart'; + +Future showOpenCryptoPayDesktopDialog({ + required BuildContext context, + required String qrUrl, + required String walletId, + required CryptoCurrency coin, +}) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (_) => DesktopDialog( + maxHeight: MediaQuery.sizeOf(context).height - 64, + maxWidth: 580, + child: OpenCryptoPayView( + qrUrl: qrUrl, + walletId: walletId, + coin: coin, + isDesktop: true, + ), + ), + ); +} diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart index 72cabd978..45003d15c 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../notifications/show_flush_bar.dart'; @@ -10,6 +11,7 @@ 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/assets.dart'; import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -17,11 +19,11 @@ import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_dialog.dart'; -import '../../widgets/desktop/desktop_dialog_close_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'; +import 'open_crypto_pay_desktop_frame.dart'; typedef _OpenCryptoPayOptionSupported = bool Function( @@ -183,7 +185,7 @@ class _OpenCryptoPayViewState extends ConsumerState { ); if (widget.isDesktop) { - return _OpenCryptoPayDesktopFrame(title: "Open CryptoPay", child: body); + return OpenCryptoPayDesktopFrame(title: "Open CryptoPay", child: body); } return Background( @@ -205,39 +207,6 @@ class _OpenCryptoPayViewState extends ConsumerState { } } -class _OpenCryptoPayDesktopFrame extends StatelessWidget { - const _OpenCryptoPayDesktopFrame({required this.title, required this.child}); - - final String title; - final Widget child; - - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(context).height - 64, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text(title, style: STextStyles.desktopH3(context)), - ), - const DesktopDialogCloseButton(), - ], - ), - Flexible(child: child), - ], - ), - ); - } -} - class _OpenCryptoPayBody extends StatelessWidget { const _OpenCryptoPayBody({ required this.isLoading, @@ -446,11 +415,16 @@ class _OpenCryptoPayMethodCard extends StatelessWidget { ], ), ), - Icon( - Icons.chevron_right, - color: Theme.of( - context, - ).extension()!.textFieldDefaultSearchIconLeft, + SvgPicture.asset( + Assets.svg.chevronRight, + width: 20, + height: 20, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.textFieldDefaultSearchIconLeft, + BlendMode.srcIn, + ), ), ], ), diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 0388436be..cb47e9b1d 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -929,8 +929,6 @@ class _ConfirmTransactionViewState return "Could not build signed transaction"; } return null; - case OpenCryptoPaySubmissionFlow.external: - return "This Open CryptoPay method is not supported yet"; } } 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 eead48104..6c285e8b9 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 @@ -24,7 +24,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_view.dart'; +import '../../../../pages/open_crypto_pay/open_crypto_pay_dialog.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'; @@ -947,19 +947,11 @@ class _DesktopSendState extends ConsumerState { Future _showOpenCryptoPay(String qrUrl) async { if (!mounted) return; - await showDialog( + await showOpenCryptoPayDesktopDialog( context: context, - barrierDismissible: true, - builder: (_) => DesktopDialog( - maxHeight: MediaQuery.sizeOf(context).height - 64, - maxWidth: 580, - child: OpenCryptoPayView( - qrUrl: qrUrl, - walletId: walletId, - coin: coin, - isDesktop: true, - ), - ), + qrUrl: qrUrl, + walletId: walletId, + coin: coin, ); } 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 4be033e67..3d164f5fc 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 @@ -18,7 +18,7 @@ import 'package:flutter_riverpod/flutter_riverpod.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_view.dart'; +import '../../../../pages/open_crypto_pay/open_crypto_pay_dialog.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; import '../../../../providers/providers.dart'; @@ -506,19 +506,11 @@ class _DesktopTokenSendState extends ConsumerState { Future _showOpenCryptoPay(String qrUrl) async { if (!mounted) return; - await showDialog( + await showOpenCryptoPayDesktopDialog( context: context, - barrierDismissible: true, - builder: (_) => DesktopDialog( - maxHeight: MediaQuery.sizeOf(context).height - 64, - maxWidth: 580, - child: OpenCryptoPayView( - qrUrl: qrUrl, - walletId: walletId, - coin: coin, - isDesktop: true, - ), - ), + qrUrl: qrUrl, + walletId: walletId, + coin: coin, ); } diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart index 87a42c258..df0d10d98 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart @@ -23,7 +23,7 @@ import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/masternodes/masternodes_home_view.dart'; import '../../../../pages/monkey/monkey_view.dart'; import '../../../../pages/namecoin_names/namecoin_names_home_view.dart'; -import '../../../../pages/open_crypto_pay/open_crypto_pay_view.dart'; +import '../../../../pages/open_crypto_pay/open_crypto_pay_dialog.dart'; import '../../../../pages/paynym/paynym_claim_view.dart'; import '../../../../pages/paynym/paynym_home_view.dart'; import '../../../../pages/salvium_stake/salvium_create_stake_view.dart'; @@ -166,19 +166,11 @@ class _DesktopWalletFeaturesState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(widget.walletId); if (!mounted) return; - await showDialog( + await showOpenCryptoPayDesktopDialog( context: context, - barrierDismissible: true, - builder: (_) => DesktopDialog( - maxHeight: MediaQuery.sizeOf(context).height - 64, - maxWidth: 580, - child: OpenCryptoPayView( - qrUrl: qrResult, - walletId: widget.walletId, - coin: wallet.info.coin, - isDesktop: true, - ), - ), + qrUrl: qrResult, + walletId: widget.walletId, + coin: wallet.info.coin, ); } catch (e, s) { Logging.instance.e( diff --git a/lib/services/open_crypto_pay/method_support.dart b/lib/services/open_crypto_pay/method_support.dart index 2419d2897..7e514841b 100644 --- a/lib/services/open_crypto_pay/method_support.dart +++ b/lib/services/open_crypto_pay/method_support.dart @@ -24,18 +24,13 @@ class OpenCryptoPayMethodSupport { 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; + return null; default: return null; } diff --git a/lib/services/open_crypto_pay/models.dart b/lib/services/open_crypto_pay/models.dart index fc3fd1be5..ce06de197 100644 --- a/lib/services/open_crypto_pay/models.dart +++ b/lib/services/open_crypto_pay/models.dart @@ -10,9 +10,6 @@ enum OpenCryptoPaySubmissionFlow { /// The provider broadcasts after receiving raw signed transaction hex. rawHexToProvider, - - /// Payment is completed outside Stack Wallet, such as Lightning/BinancePay. - external, } class OpenCryptoPayRecipient { From 3941b17d7bfc56a0ce985aea91c15c073e5ff090 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 11 Jun 2026 03:21:09 +0800 Subject: [PATCH 13/20] Reduce Open CryptoPay integration surface --- .../open_crypto_pay_confirm_view.dart | 157 +++++++----------- .../open_crypto_pay/open_crypto_pay_view.dart | 30 +--- ...rame.dart => open_crypto_pay_widgets.dart} | 30 ++++ lib/pages/wallet_view/wallet_view.dart | 56 ------- .../wallet_view/sub_widgets/desktop_send.dart | 19 +-- .../sub_widgets/desktop_token_send.dart | 19 +-- .../sub_widgets/desktop_wallet_features.dart | 57 ------- lib/services/open_crypto_pay/lnurl_utils.dart | 2 +- .../open_crypto_pay/method_support.dart | 8 - 9 files changed, 107 insertions(+), 271 deletions(-) rename lib/pages/open_crypto_pay/{open_crypto_pay_desktop_frame.dart => open_crypto_pay_widgets.dart} (60%) 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 index 888fc1932..d4310addb 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -32,7 +32,7 @@ import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; -import 'open_crypto_pay_desktop_frame.dart'; +import 'open_crypto_pay_widgets.dart'; import '../send_view/send_view.dart'; import '../send_view/token_send_view.dart'; @@ -113,11 +113,9 @@ class _OpenCryptoPayConfirmViewState } } - /// 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) { + ({String? address, Decimal? amount, String? scheme}) _parseTransactionUri( + String uri, + ) { final evmUri = OpenCryptoPayEvmUri.tryParse(uri); if (evmUri != null && !evmUri.isTokenTransfer) { return ( @@ -125,7 +123,6 @@ class _OpenCryptoPayConfirmViewState amount: evmUri.isNativeTransfer ? evmUri.amount(fractionDigits: widget.coin.fractionDigits) : Decimal.tryParse(widget.selectedAsset.amount), - chainId: evmUri.chainId, scheme: evmUri.scheme, ); } @@ -133,13 +130,7 @@ class _OpenCryptoPayConfirmViewState 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 @@ -148,7 +139,6 @@ class _OpenCryptoPayConfirmViewState return ( address: address, amount: amount, - chainId: chainId, scheme: data?.scheme ?? parsedUri?.scheme, ); } @@ -173,6 +163,35 @@ class _OpenCryptoPayConfirmViewState return quotedAmount != null && amount.compareTo(quotedAmount) == 0; } + SendViewAutoFillData _autoFillData({ + required String address, + required Decimal amount, + required DateTime expiresAt, + required String recipient, + required OpenCryptoPaySubmissionFlow submissionFlow, + String? tokenContractAddress, + }) { + return SendViewAutoFillData( + address: address, + 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: address, + amount: amount, + tokenContractAddress: tokenContractAddress, + ), + ); + } + Future _loadTokenWallet(EthContract contract) async { final wallet = ref.read(pWallets).getWallet(widget.walletId); if (wallet is! EthereumWallet) { @@ -290,38 +309,18 @@ class _OpenCryptoPayConfirmViewState return; } - final autoFillData = SendViewAutoFillData( + final autoFillData = _autoFillData( 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!, - ), + amount: parsed.amount!, + expiresAt: expiresAt, + recipient: recipient, + submissionFlow: submissionFlow, ); if (!mounted) return; if (widget.isDesktop) { - await showDialog( - context: context, - barrierDismissible: true, - builder: (_) => DesktopDialog( - maxHeight: MediaQuery.sizeOf(context).height - 64, - maxWidth: 580, - child: DesktopSend( - walletId: widget.walletId, - autoFillData: autoFillData, - ), - ), + await _showDesktopSendForm( + DesktopSend(walletId: widget.walletId, autoFillData: autoFillData), ); return; } @@ -367,39 +366,19 @@ class _OpenCryptoPayConfirmViewState return; } - final autoFillData = SendViewAutoFillData( + final autoFillData = _autoFillData( 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, - ), + expiresAt: expiresAt, + recipient: recipient, + submissionFlow: submissionFlow, + tokenContractAddress: contract.address, ); if (!mounted) return; if (widget.isDesktop) { - await showDialog( - context: context, - barrierDismissible: true, - builder: (_) => DesktopDialog( - maxHeight: MediaQuery.sizeOf(context).height - 64, - maxWidth: 580, - child: DesktopTokenSend( - walletId: widget.walletId, - autoFillData: autoFillData, - ), - ), + await _showDesktopSendForm( + DesktopTokenSend(walletId: widget.walletId, autoFillData: autoFillData), ); return; } @@ -410,6 +389,18 @@ class _OpenCryptoPayConfirmViewState ); } + Future _showDesktopSendForm(Widget child) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (_) => DesktopDialog( + maxHeight: MediaQuery.sizeOf(context).height - 64, + maxWidth: 580, + child: child, + ), + ); + } + void _warn(String message) { unawaited( showFloatingFlushBar( @@ -486,7 +477,7 @@ class _OpenCryptoPayConfirmBody extends StatelessWidget { final error = errorMessage; if (error != null) { - return _OpenCryptoPayConfirmError(message: error, onRetry: onRetry); + return OpenCryptoPayErrorView(message: error, onRetry: onRetry); } return SingleChildScrollView( @@ -512,34 +503,6 @@ class _OpenCryptoPayConfirmBody extends StatelessWidget { } } -class _OpenCryptoPayConfirmError extends StatelessWidget { - const _OpenCryptoPayConfirmError({ - required this.message, - required this.onRetry, - }); - - final String message; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - message, - style: STextStyles.itemSubtitle(context), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - PrimaryButton(label: "Retry", onPressed: onRetry), - ], - ), - ); - } -} - class _OpenCryptoPaySummaryCard extends StatelessWidget { const _OpenCryptoPaySummaryCard({ required this.paymentDetails, diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart index 45003d15c..f5d8110c4 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart @@ -19,11 +19,10 @@ import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_dialog.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'; -import 'open_crypto_pay_desktop_frame.dart'; +import 'open_crypto_pay_widgets.dart'; typedef _OpenCryptoPayOptionSupported = bool Function( @@ -234,7 +233,7 @@ class _OpenCryptoPayBody extends StatelessWidget { final error = errorMessage; if (error != null) { - return _OpenCryptoPayError(message: error, onRetry: onRetry); + return OpenCryptoPayErrorView(message: error, onRetry: onRetry); } final details = this.details; @@ -298,31 +297,6 @@ class _OpenCryptoPayBody extends StatelessWidget { } } -class _OpenCryptoPayError extends StatelessWidget { - const _OpenCryptoPayError({required this.message, required this.onRetry}); - - final String message; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - message, - style: STextStyles.itemSubtitle(context), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - PrimaryButton(label: "Retry", onPressed: onRetry), - ], - ), - ); - } -} - class _OpenCryptoPayRecipientCard extends StatelessWidget { const _OpenCryptoPayRecipientCard({required this.recipient}); diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_desktop_frame.dart b/lib/pages/open_crypto_pay/open_crypto_pay_widgets.dart similarity index 60% rename from lib/pages/open_crypto_pay/open_crypto_pay_desktop_frame.dart rename to lib/pages/open_crypto_pay/open_crypto_pay_widgets.dart index b38a0dd42..d11b02dca 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_desktop_frame.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_widgets.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../utilities/text_styles.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; class OpenCryptoPayDesktopFrame extends StatelessWidget { const OpenCryptoPayDesktopFrame({ @@ -39,3 +40,32 @@ class OpenCryptoPayDesktopFrame extends StatelessWidget { ); } } + +class OpenCryptoPayErrorView extends StatelessWidget { + const OpenCryptoPayErrorView({ + super.key, + required this.message, + required this.onRetry, + }); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + message, + style: STextStyles.itemSubtitle(context), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + PrimaryButton(label: "Retry", onPressed: onRetry), + ], + ), + ); + } +} diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 8cce574a5..04c0888d5 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -34,8 +34,6 @@ 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 '../../services/open_crypto_pay/method_support.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; @@ -75,7 +73,6 @@ import '../../widgets/custom_loading_overlay.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/frost_scaffold.dart'; import '../../widgets/icon_widgets/credit_card_icon.dart'; -import '../../widgets/icon_widgets/qrcode_icon.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/small_tor_icon.dart'; import '../../widgets/stack_dialog.dart'; @@ -105,7 +102,6 @@ import '../more_view/gift_cards_view.dart'; import '../more_view/services_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'; @@ -433,51 +429,6 @@ 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( @@ -1430,13 +1381,6 @@ class _WalletViewState extends ConsumerState { ).pushNamed(GiftCardsView.routeName); }, ), - if (!viewOnly && - OpenCryptoPayMethodSupport.hasSupportedWalletCoin(coin)) - WalletNavigationBarItemData( - label: "Pay", - icon: const QrCodeIcon(), - onTap: () => _onOpenCryptoPayPressed(context), - ), ], ), ), 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 6c285e8b9..b523b9d69 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 @@ -925,7 +925,13 @@ class _DesktopSendState extends ConsumerState { paymentData.coin?.uriScheme == coin.uriScheme) { _applyUri(paymentData); } else if (LnurlUtils.isOpenCryptoPayUrl(qrCodeData)) { - await _showOpenCryptoPay(qrCodeData); + if (!mounted) return; + await showOpenCryptoPayDesktopDialog( + context: context, + qrUrl: qrCodeData, + walletId: walletId, + coin: coin, + ); } else { _address = qrCodeData.split("\n").first.trim(); sendToController.text = _address ?? ""; @@ -944,17 +950,6 @@ class _DesktopSendState extends ConsumerState { } } - Future _showOpenCryptoPay(String qrUrl) async { - if (!mounted) return; - - await showOpenCryptoPayDesktopDialog( - context: context, - qrUrl: qrUrl, - walletId: walletId, - coin: coin, - ); - } - void _setValidAddressProviders(String? address) { if (isPaynymSend) { ref.read(pValidSendToAddress.notifier).state = true; 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 3d164f5fc..f8dbcc42d 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 @@ -481,7 +481,13 @@ class _DesktopTokenSendState extends ConsumerState { _addressToggleFlag = sendToController.text.isNotEmpty; }); } else if (LnurlUtils.isOpenCryptoPayUrl(qrResult)) { - await _showOpenCryptoPay(qrResult); + if (!mounted) return; + await showOpenCryptoPayDesktopDialog( + context: context, + qrUrl: qrResult, + walletId: walletId, + coin: coin, + ); } else { // now check for non standard encoded basic address _address = qrResult.split("\n").first.trim(); @@ -503,17 +509,6 @@ class _DesktopTokenSendState extends ConsumerState { } } - Future _showOpenCryptoPay(String qrUrl) async { - if (!mounted) return; - - await showOpenCryptoPayDesktopDialog( - context: context, - qrUrl: qrUrl, - walletId: walletId, - coin: coin, - ); - } - Future pasteAddress() async { final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); if (data?.text != null && data!.text!.isNotEmpty) { diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart index df0d10d98..ca0a2ae0c 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart @@ -23,7 +23,6 @@ import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/masternodes/masternodes_home_view.dart'; import '../../../../pages/monkey/monkey_view.dart'; import '../../../../pages/namecoin_names/namecoin_names_home_view.dart'; -import '../../../../pages/open_crypto_pay/open_crypto_pay_dialog.dart'; import '../../../../pages/paynym/paynym_claim_view.dart'; import '../../../../pages/paynym/paynym_home_view.dart'; import '../../../../pages/salvium_stake/salvium_create_stake_view.dart'; @@ -33,8 +32,6 @@ import '../../../../providers/desktop/current_desktop_menu_item.dart'; import '../../../../providers/global/paynym_api_provider.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/wallet/my_paynym_account_state_provider.dart'; -import '../../../../services/open_crypto_pay/lnurl_utils.dart'; -import '../../../../services/open_crypto_pay/method_support.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../themes/theme_providers.dart'; import '../../../../utilities/amount/amount.dart'; @@ -65,7 +62,6 @@ import '../../../../widgets/custom_loading_overlay.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../../widgets/desktop/primary_button.dart'; -import '../../../../widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/loading_indicator.dart'; import '../../../../widgets/static_overflow_row/static_overflow_row.dart'; @@ -83,7 +79,6 @@ enum WalletFeature { anonymizeFunds("Privatize funds", "Privatize funds"), swap("Swap", ""), buy("Buy", "Buy cryptocurrency"), - openCryptoPay("Pay", "Open CryptoPay payments"), paynym("PayNym", "Increased address privacy using BIP47"), coinControl( "Coin control", @@ -137,50 +132,6 @@ class _DesktopWalletFeaturesState extends ConsumerState { ref.read(prevDesktopMenuItemProvider.state).state = DesktopMenuItemId.buy; } - Future _onOpenCryptoPayPressed() async { - try { - final qrResult = await showDialog( - context: context, - builder: (_) => const QrCodeScannerDialog(), - ); - - if (qrResult == null) { - Logging.instance.w("Qr scanning cancelled"); - return; - } - - if (!LnurlUtils.isOpenCryptoPayUrl(qrResult)) { - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: - "The scanned QR code is not an Open CryptoPay payment code.", - context: context, - ), - ); - } - return; - } - - final wallet = ref.read(pWallets).getWallet(widget.walletId); - if (!mounted) return; - - await showOpenCryptoPayDesktopDialog( - context: context, - qrUrl: qrResult, - walletId: widget.walletId, - coin: wallet.info.coin, - ); - } catch (e, s) { - Logging.instance.e( - "Failed to scan QR for OpenCryptoPay", - error: e, - stackTrace: s, - ); - } - } - Future _onMorePressed( List<(WalletFeature, String, FutureOr Function())> options, ) async { @@ -544,14 +495,6 @@ class _DesktopWalletFeaturesState extends ConsumerState { if (showExchange && AppConfig.hasFeature(AppFeature.buy)) (WalletFeature.buy, Assets.svg.swap, _onBuyPressed), - if (!isViewOnly && - OpenCryptoPayMethodSupport.hasSupportedWalletCoin(coin)) - ( - WalletFeature.openCryptoPay, - Assets.svg.qrcode, - _onOpenCryptoPayPressed, - ), - if (wallet is LibSalviumWallet) ( WalletFeature.salviumStaking, diff --git a/lib/services/open_crypto_pay/lnurl_utils.dart b/lib/services/open_crypto_pay/lnurl_utils.dart index a2a2ad50e..849446bac 100644 --- a/lib/services/open_crypto_pay/lnurl_utils.dart +++ b/lib/services/open_crypto_pay/lnurl_utils.dart @@ -4,7 +4,7 @@ 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 +/// 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 { diff --git a/lib/services/open_crypto_pay/method_support.dart b/lib/services/open_crypto_pay/method_support.dart index 7e514841b..1d7c086bc 100644 --- a/lib/services/open_crypto_pay/method_support.dart +++ b/lib/services/open_crypto_pay/method_support.dart @@ -6,14 +6,6 @@ import 'models.dart'; class OpenCryptoPayMethodSupport { const OpenCryptoPayMethodSupport._(); - static bool hasSupportedWalletCoin(CryptoCurrency coin) { - return coin is Bitcoin || - coin is Ethereum || - coin is Solana || - coin is Cardano || - coin is Firo; - } - static OpenCryptoPaySubmissionFlow? submissionFlowFor(String method) { switch (method) { case 'Solana': From 542ebcd31d0af7f17cef3c5ba0fc78ed8e4d5fa8 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 11 Jun 2026 04:45:08 +0800 Subject: [PATCH 14/20] Remove fixed Open CryptoPay summary label width --- .../open_crypto_pay/open_crypto_pay_confirm_view.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index d4310addb..1e97806d9 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -559,16 +559,16 @@ class _OpenCryptoPaySummaryRow extends StatelessWidget { return Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 100, - child: Text(label, style: STextStyles.label(context)), - ), + Text(label, style: STextStyles.label(context)), + const SizedBox(width: 16), Expanded( child: Text( value, style: STextStyles.itemSubtitle(context), overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, ), ), ], From f7fdf0f1444444b8284de450521d18e9cc2aed48 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 11 Jun 2026 05:10:05 +0800 Subject: [PATCH 15/20] Tighten Open CryptoPay settlement interop --- .../send_view/confirm_transaction_view.dart | 32 ++++++++++++++++--- lib/pages/send_view/send_view.dart | 1 + .../wallet_view/sub_widgets/desktop_send.dart | 1 + .../sub_widgets/desktop_token_send.dart | 1 + lib/services/open_crypto_pay/lnurl_utils.dart | 11 +++++-- .../open_crypto_pay/method_support.dart | 4 +-- lib/services/open_crypto_pay/models.dart | 27 +++++++++++++--- .../open_crypto_pay/open_crypto_pay_api.dart | 13 +++++--- test/open_crypto_pay_lnurl_utils_test.dart | 11 +++++-- test/open_crypto_pay_models_test.dart | 21 ++++++++++++ 10 files changed, 101 insertions(+), 21 deletions(-) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index cb47e9b1d..866da0870 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -114,6 +114,9 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { class _ConfirmTransactionViewState extends ConsumerState { + // OCP raw-hex commits are GET query params; keep Firo near common header caps. + static const int _openCryptoPayMaxRawHexQueryLength = 8000; + late final String walletId; late final String routeOnSuccessName; late final bool isDesktop; @@ -469,10 +472,16 @@ class _ConfirmTransactionViewState Future txDataFuture; final note = noteController.text; + final openCryptoPayTxIdFlow = _shouldCommitOpenCryptoPayTxId( + openCryptoPayCommit, + wallet, + widget.txData, + ); try { - if (openCryptoPayCommit?.submissionFlow == - OpenCryptoPaySubmissionFlow.rawHexToProvider) { + if (!openCryptoPayTxIdFlow && + openCryptoPayCommit?.submissionFlow == + OpenCryptoPaySubmissionFlow.rawHexToProvider) { final submitWallet = widget.isTokenTx ? ref.read(pCurrentTokenWallet)! : wallet; @@ -578,8 +587,7 @@ class _ConfirmTransactionViewState txids.add(confirmedTx.txid!); } - if (openCryptoPayCommit?.submissionFlow == - OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) { + if (openCryptoPayTxIdFlow) { final result = results.first as TxData; await _commitOpenCryptoPayTxId(openCryptoPayCommit!, result); } @@ -932,6 +940,22 @@ class _ConfirmTransactionViewState } } + bool _shouldCommitOpenCryptoPayTxId( + OpenCryptoPayCommit? commit, + Wallet wallet, + TxData txData, + ) { + if (commit == null) return false; + if (commit.submissionFlow == + OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) { + return true; + } + return commit.method == 'Firo' && + wallet is FiroWallet && + (txData.usedSparkCoins?.isNotEmpty == true || + (txData.raw?.length ?? 0) > _openCryptoPayMaxRawHexQueryLength); + } + String? _validateOpenCryptoPayTransaction( Wallet wallet, OpenCryptoPayCommit commit, diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 9b38639e2..5d230c7ed 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -1334,6 +1334,7 @@ class _SendViewState extends ConsumerState { } sendToController.text = _data.contactLabel; _address = _data.address.trim(); + noteController.text = _data.note; _addressToggleFlag = true; WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 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 b523b9d69..d551f9307 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 @@ -1247,6 +1247,7 @@ class _DesktopSendState extends ConsumerState { } sendToController.text = _data.contactLabel; _address = _data.address; + _note = _data.note; _addressToggleFlag = true; } 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 f8dbcc42d..cfe391007 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 @@ -626,6 +626,7 @@ class _DesktopTokenSendState extends ConsumerState { } sendToController.text = _data!.contactLabel; _address = _data!.address; + _note = _data!.note; _addressToggleFlag = true; } diff --git a/lib/services/open_crypto_pay/lnurl_utils.dart b/lib/services/open_crypto_pay/lnurl_utils.dart index 849446bac..eb0c4f177 100644 --- a/lib/services/open_crypto_pay/lnurl_utils.dart +++ b/lib/services/open_crypto_pay/lnurl_utils.dart @@ -14,8 +14,7 @@ class LnurlUtils { 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. + /// Returns true if [url] is an Open CryptoPay QR payload. static bool isOpenCryptoPayUrl(String url) { return extractLnurl(url)?.toUpperCase().startsWith('LNURL') ?? false; } @@ -23,6 +22,14 @@ class LnurlUtils { /// Returns the encoded LNURL payload, if any. static String? extractLnurl(String url) { final trimmed = url.trim(); + if (trimmed.toUpperCase().startsWith('LNURL')) return trimmed; + + const lightningScheme = 'lightning:'; + if (trimmed.toLowerCase().startsWith(lightningScheme)) { + final payload = trimmed.substring(lightningScheme.length); + if (payload.toUpperCase().startsWith('LNURL')) return payload; + } + try { return Uri.parse(trimmed).queryParameters['lightning']; } catch (_) { diff --git a/lib/services/open_crypto_pay/method_support.dart b/lib/services/open_crypto_pay/method_support.dart index 1d7c086bc..a83304ef1 100644 --- a/lib/services/open_crypto_pay/method_support.dart +++ b/lib/services/open_crypto_pay/method_support.dart @@ -11,9 +11,9 @@ class OpenCryptoPayMethodSupport { 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': + // Monero can be revisited on the txid flow in a follow-up; this PR + // keeps support scoped to methods already validated here. return null; case 'Ethereum': case 'Bitcoin': diff --git a/lib/services/open_crypto_pay/models.dart b/lib/services/open_crypto_pay/models.dart index ce06de197..03528ae7b 100644 --- a/lib/services/open_crypto_pay/models.dart +++ b/lib/services/open_crypto_pay/models.dart @@ -105,8 +105,11 @@ class OpenCryptoPayQuote { required this.expiration, }); - factory OpenCryptoPayQuote.fromJson(Map json) { - final paymentId = json['payment'] as String?; + factory OpenCryptoPayQuote.fromJson( + Map json, { + String? fallbackPaymentId, + }) { + final paymentId = json['payment'] as String? ?? fallbackPaymentId; if (paymentId == null || paymentId.isEmpty) { throw Exception('OpenCryptoPay: quote payment id is missing'); } @@ -159,6 +162,7 @@ class OpenCryptoPayPaymentDetails { }); factory OpenCryptoPayPaymentDetails.fromJson(Map json) { + final callback = json['callback'] as String? ?? ''; return OpenCryptoPayPaymentDetails( id: json['id'] as String, standard: json['standard'] as String?, @@ -168,7 +172,7 @@ class OpenCryptoPayPaymentDetails { .toList() ?? const [], displayName: json['displayName'] as String?, - callback: json['callback'] as String? ?? '', + callback: callback, recipient: json['recipient'] == null ? null : OpenCryptoPayRecipient.fromJson( @@ -176,7 +180,10 @@ class OpenCryptoPayPaymentDetails { ), quote: json['quote'] == null ? null - : OpenCryptoPayQuote.fromJson(json['quote'] as Map), + : OpenCryptoPayQuote.fromJson( + json['quote'] as Map, + fallbackPaymentId: _paymentIdFromCallback(callback), + ), requestedAmount: json['requestedAmount'] == null ? null : OpenCryptoPayRequestedAmount.fromJson( @@ -200,7 +207,17 @@ class OpenCryptoPayPaymentDetails { bool get supportsOpenCryptoPay => standard == 'OpenCryptoPay' || - possibleStandards.contains('OpenCryptoPay'); + possibleStandards.contains('OpenCryptoPay') || + (callback.isNotEmpty && quote != null && transferAmounts.isNotEmpty); + + static String? _paymentIdFromCallback(String callback) { + final segments = Uri.tryParse(callback)?.pathSegments; + final cbIndex = segments?.lastIndexOf('cb') ?? -1; + if (segments == null || cbIndex == -1 || cbIndex + 1 >= segments.length) { + return null; + } + return segments[cbIndex + 1]; + } } class OpenCryptoPayTransactionDetails { diff --git a/lib/services/open_crypto_pay/open_crypto_pay_api.dart b/lib/services/open_crypto_pay/open_crypto_pay_api.dart index c1b526667..fe61d4488 100644 --- a/lib/services/open_crypto_pay/open_crypto_pay_api.dart +++ b/lib/services/open_crypto_pay/open_crypto_pay_api.dart @@ -20,6 +20,7 @@ class OpenCryptoPayApi { final HTTP _client = const HTTP(); static const Duration _httpTimeout = Duration(seconds: 15); + static const Duration _commitTimeout = Duration(seconds: 30); ({InternetAddress host, int port})? get _proxyInfo => AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor @@ -132,7 +133,8 @@ class OpenCryptoPayApi { required String txId, }) async { if (commit.submissionFlow != - OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) { + OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast && + commit.method != 'Firo') { throw UnsupportedError( 'OpenCryptoPay method ${commit.method} cannot be committed with txid', ); @@ -167,12 +169,13 @@ class OpenCryptoPayApi { ...base.queryParameters, 'quote': commit.quoteId, 'method': commit.method, + 'asset': commit.asset, ...queryParameters, }, ); Logging.instance.d('OpenCryptoPay: GET ${_redactedUri(uri)}'); - final response = await _get(uri); + final response = await _get(uri, timeout: _commitTimeout); if (response.code != 200) { throw Exception( 'OpenCryptoPay commit ${response.code}: ${response.body}', @@ -202,10 +205,10 @@ class OpenCryptoPayApi { ); } - Future _get(Uri uri) { + Future _get(Uri uri, {Duration timeout = _httpTimeout}) { return _client - .get(url: uri, proxyInfo: _proxyInfo, connectionTimeout: _httpTimeout) - .timeout(_httpTimeout); + .get(url: uri, proxyInfo: _proxyInfo, connectionTimeout: timeout) + .timeout(timeout); } } diff --git a/test/open_crypto_pay_lnurl_utils_test.dart b/test/open_crypto_pay_lnurl_utils_test.dart index 99334b775..ee0470ed8 100644 --- a/test/open_crypto_pay_lnurl_utils_test.dart +++ b/test/open_crypto_pay_lnurl_utils_test.dart @@ -2,9 +2,14 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/services/open_crypto_pay/lnurl_utils.dart'; void main() { - test("ignores raw LNURL payloads", () { - expect(LnurlUtils.extractLnurl("LNURL1TEST"), isNull); - expect(LnurlUtils.isOpenCryptoPayUrl("lnurl1test"), false); + test("detects raw LNURL payloads", () { + expect(LnurlUtils.extractLnurl("LNURL1TEST"), "LNURL1TEST"); + expect(LnurlUtils.isOpenCryptoPayUrl("lnurl1test"), true); + }); + + test("detects lightning-scheme LNURL payloads", () { + expect(LnurlUtils.extractLnurl("lightning:LNURL1TEST"), "LNURL1TEST"); + expect(LnurlUtils.isOpenCryptoPayUrl("LIGHTNING:lnurl1test"), true); }); test("detects LNURL in lightning query parameter", () { diff --git a/test/open_crypto_pay_models_test.dart b/test/open_crypto_pay_models_test.dart index df5a48f5b..f0cb83e6b 100644 --- a/test/open_crypto_pay_models_test.dart +++ b/test/open_crypto_pay_models_test.dart @@ -22,4 +22,25 @@ void main() { throwsException, ); }); + + test("falls back to callback id when quote payment id is missing", () { + final details = OpenCryptoPayPaymentDetails.fromJson({ + "id": "payment-link-id", + "callback": "https://example.com/lnurl/cb/payment-id", + "quote": {"id": "quote-id", "expiration": "2026-04-28T12:00:00Z"}, + "transferAmounts": [ + { + "method": "Bitcoin", + "available": true, + "minFee": 1, + "assets": [ + {"asset": "BTC", "amount": "0.001"}, + ], + }, + ], + }); + + expect(details.quote!.paymentId, "payment-id"); + expect(details.supportsOpenCryptoPay, true); + }); } From 1ea9e60b3052d8a8883fb62bb26480a3069fe2da Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 11 Jun 2026 12:58:52 +0800 Subject: [PATCH 16/20] Clarify Open CryptoPay commit capabilities --- lib/services/open_crypto_pay/method_support.dart | 2 ++ lib/services/open_crypto_pay/models.dart | 7 +++++++ lib/services/open_crypto_pay/open_crypto_pay_api.dart | 8 +++----- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/services/open_crypto_pay/method_support.dart b/lib/services/open_crypto_pay/method_support.dart index a83304ef1..39ab77aac 100644 --- a/lib/services/open_crypto_pay/method_support.dart +++ b/lib/services/open_crypto_pay/method_support.dart @@ -18,6 +18,8 @@ class OpenCryptoPayMethodSupport { case 'Ethereum': case 'Bitcoin': case 'Firo': + // Firo starts here for transparent/provider-broadcast payments; Spark + // or oversized raw transactions fall back to txid at confirmation. return OpenCryptoPaySubmissionFlow.rawHexToProvider; case 'Lightning': case 'BinancePay': diff --git a/lib/services/open_crypto_pay/models.dart b/lib/services/open_crypto_pay/models.dart index 03528ae7b..a4a896144 100644 --- a/lib/services/open_crypto_pay/models.dart +++ b/lib/services/open_crypto_pay/models.dart @@ -274,4 +274,11 @@ class OpenCryptoPayCommit { }); bool get isExpired => expiresAt.isBefore(DateTime.now()); + + bool get canCommitRawHex => + submissionFlow == OpenCryptoPaySubmissionFlow.rawHexToProvider; + + bool get canCommitTxId => + submissionFlow == OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast || + method == 'Firo'; } diff --git a/lib/services/open_crypto_pay/open_crypto_pay_api.dart b/lib/services/open_crypto_pay/open_crypto_pay_api.dart index fe61d4488..0fcbdce53 100644 --- a/lib/services/open_crypto_pay/open_crypto_pay_api.dart +++ b/lib/services/open_crypto_pay/open_crypto_pay_api.dart @@ -44,7 +44,7 @@ class OpenCryptoPayApi { }) async { final lnurl = LnurlUtils.extractLnurl(qrUrl); if (lnurl == null) { - throw Exception('No lightning parameter found in URL'); + throw Exception('No LNURL payload found'); } final apiUrl = Uri.parse(LnurlUtils.decodeLnurl(lnurl)); @@ -132,9 +132,7 @@ class OpenCryptoPayApi { required OpenCryptoPayCommit commit, required String txId, }) async { - if (commit.submissionFlow != - OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast && - commit.method != 'Firo') { + if (!commit.canCommitTxId) { throw UnsupportedError( 'OpenCryptoPay method ${commit.method} cannot be committed with txid', ); @@ -149,7 +147,7 @@ class OpenCryptoPayApi { required OpenCryptoPayCommit commit, required String hex, }) async { - if (commit.submissionFlow != OpenCryptoPaySubmissionFlow.rawHexToProvider) { + if (!commit.canCommitRawHex) { throw UnsupportedError( 'OpenCryptoPay method ${commit.method} cannot be committed with hex', ); From 0cc1db3e8a5281241be1be7e3d6bc0476a14bddf Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 11 Jun 2026 14:40:14 +0800 Subject: [PATCH 17/20] Extract Open CryptoPay settlement flow --- lib/models/send_view_auto_fill_data.dart | 10 + .../open_crypto_pay_confirm_view.dart | 66 +--- .../open_crypto_pay_dialog.dart | 21 +- .../open_crypto_pay/open_crypto_pay_view.dart | 70 ++-- .../open_crypto_pay_widgets.dart | 50 +++ .../send_view/confirm_transaction_view.dart | 326 +----------------- .../wallet_view/sub_widgets/desktop_send.dart | 2 +- .../sub_widgets/desktop_token_send.dart | 2 +- .../open_crypto_pay/erc20_token_lookup.dart | 29 ++ .../open_crypto_pay/method_support.dart | 33 +- lib/services/open_crypto_pay/models.dart | 3 - .../open_crypto_pay/open_crypto_pay_api.dart | 7 +- lib/services/open_crypto_pay/settlement.dart | 275 +++++++++++++++ test/open_crypto_pay_models_test.dart | 1 - 14 files changed, 448 insertions(+), 447 deletions(-) create mode 100644 lib/services/open_crypto_pay/erc20_token_lookup.dart create mode 100644 lib/services/open_crypto_pay/settlement.dart diff --git a/lib/models/send_view_auto_fill_data.dart b/lib/models/send_view_auto_fill_data.dart index 0687af324..c97fba40e 100644 --- a/lib/models/send_view_auto_fill_data.dart +++ b/lib/models/send_view_auto_fill_data.dart @@ -31,11 +31,21 @@ class SendViewAutoFillData { }); Map toJson() { + final commit = openCryptoPayCommit; return { "address": address, "contactLabel": contactLabel, "amount": amount, "note": note, + if (commit != null) + "openCryptoPayCommit": { + "method": commit.method, + "asset": commit.asset, + "submissionFlow": commit.submissionFlow.name, + "quoteId": commit.quoteId, + "paymentId": commit.paymentId, + "expiresAt": commit.expiresAt.toIso8601String(), + }, }; } 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 index 1e97806d9..9c3b0a4ac 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -12,11 +12,11 @@ import '../../providers/db/main_db_provider.dart'; import '../../providers/providers.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart'; +import '../../services/open_crypto_pay/erc20_token_lookup.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'; @@ -26,9 +26,6 @@ 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/desktop_dialog.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; @@ -69,6 +66,7 @@ class _OpenCryptoPayConfirmViewState OpenCryptoPayTransactionDetails? _txDetails; bool _isLoading = true; String? _errorMessage; + late final Decimal? _quotedAmount; DateTime? get _expiresAt => _txDetails?.expiryDate ?? widget.paymentDetails.quote?.expiration; @@ -81,6 +79,7 @@ class _OpenCryptoPayConfirmViewState @override void initState() { super.initState(); + _quotedAmount = Decimal.tryParse(widget.selectedAsset.amount); _fetch(); } @@ -122,7 +121,7 @@ class _OpenCryptoPayConfirmViewState address: evmUri.targetAddress, amount: evmUri.isNativeTransfer ? evmUri.amount(fractionDigits: widget.coin.fractionDigits) - : Decimal.tryParse(widget.selectedAsset.amount), + : _quotedAmount, scheme: evmUri.scheme, ); } @@ -135,7 +134,7 @@ class _OpenCryptoPayConfirmViewState } final amount = data?.amount != null ? Decimal.tryParse(data!.amount!) - : Decimal.tryParse(widget.selectedAsset.amount); + : _quotedAmount; return ( address: address, amount: amount, @@ -144,22 +143,15 @@ class _OpenCryptoPayConfirmViewState } 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; + return OpenCryptoPayErc20TokenLookup.enabledToken( + ref.read(mainDBProvider), + ref.read(pWalletTokenAddresses(widget.walletId)), + contractAddress, + ); } bool _matchesQuotedAmount(Decimal amount) { - final quotedAmount = Decimal.tryParse(widget.selectedAsset.amount); + final quotedAmount = _quotedAmount; return quotedAmount != null && amount.compareTo(quotedAmount) == 0; } @@ -265,7 +257,8 @@ class _OpenCryptoPayConfirmViewState return; } if (evmUri.isTokenTransfer) { - if (evmUri.chainId != 1) { + // Native ETH may omit chainId, but token calls must be explicit mainnet. + if (evmUri.chainId == null) { _warn("Payment URI is for a different Ethereum network"); return; } @@ -390,15 +383,7 @@ class _OpenCryptoPayConfirmViewState } Future _showDesktopSendForm(Widget child) { - return showDialog( - context: context, - barrierDismissible: true, - builder: (_) => DesktopDialog( - maxHeight: MediaQuery.sizeOf(context).height - 64, - maxWidth: 580, - child: child, - ), - ); + return showOpenCryptoPayDesktopDialog(context: context, child: child); } void _warn(String message) { @@ -427,25 +412,10 @@ class _OpenCryptoPayConfirmViewState ), ); - if (widget.isDesktop) { - return OpenCryptoPayDesktopFrame(title: "Confirm Payment", child: body); - } - - 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: body), - ), + return OpenCryptoPayScaffold( + title: "Confirm Payment", + isDesktop: widget.isDesktop, + child: body, ); } } diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_dialog.dart b/lib/pages/open_crypto_pay/open_crypto_pay_dialog.dart index 21dabd74c..dfbce7607 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_dialog.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_dialog.dart @@ -1,27 +1,22 @@ import 'package:flutter/material.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; -import '../../widgets/desktop/desktop_dialog.dart'; import 'open_crypto_pay_view.dart'; +import 'open_crypto_pay_widgets.dart'; -Future showOpenCryptoPayDesktopDialog({ +Future showOpenCryptoPayPaymentDesktopDialog({ required BuildContext context, required String qrUrl, required String walletId, required CryptoCurrency coin, }) { - return showDialog( + return showOpenCryptoPayDesktopDialog( context: context, - barrierDismissible: true, - builder: (_) => DesktopDialog( - maxHeight: MediaQuery.sizeOf(context).height - 64, - maxWidth: 580, - child: OpenCryptoPayView( - qrUrl: qrUrl, - walletId: walletId, - coin: coin, - isDesktop: true, - ), + child: OpenCryptoPayView( + qrUrl: qrUrl, + walletId: walletId, + coin: coin, + isDesktop: true, ), ); } diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart index f5d8110c4..01d626236 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart @@ -7,6 +7,7 @@ import 'package:flutter_svg/svg.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/erc20_token_lookup.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'; @@ -16,9 +17,6 @@ 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/desktop_dialog.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'open_crypto_pay_confirm_view.dart'; @@ -105,13 +103,10 @@ class _OpenCryptoPayViewState extends ConsumerState { 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(); + return OpenCryptoPayErc20TokenLookup.enabledTokens( + ref.watch(mainDBProvider), + ref.watch(pWalletTokenAddresses(widget.walletId)), + ); } Future _onSelected( @@ -133,32 +128,22 @@ class _OpenCryptoPayViewState extends ConsumerState { return; } + final confirmView = OpenCryptoPayConfirmView( + paymentDetails: _details!, + selectedMethod: method, + selectedAsset: asset, + walletId: widget.walletId, + coin: widget.coin, + isDesktop: widget.isDesktop, + ); final result = widget.isDesktop - ? await showDialog( + ? await showOpenCryptoPayDesktopDialog( context: context, - barrierDismissible: true, - builder: (_) => DesktopDialog( - maxHeight: MediaQuery.sizeOf(context).height - 64, - maxWidth: 580, - child: OpenCryptoPayConfirmView( - paymentDetails: _details!, - selectedMethod: method, - selectedAsset: asset, - walletId: widget.walletId, - coin: widget.coin, - isDesktop: true, - ), - ), + child: confirmView, ) : await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => OpenCryptoPayConfirmView( - paymentDetails: _details!, - selectedMethod: method, - selectedAsset: asset, - walletId: widget.walletId, - coin: widget.coin, - ), + builder: (_) => confirmView, ), ); @@ -183,25 +168,10 @@ class _OpenCryptoPayViewState extends ConsumerState { ), ); - if (widget.isDesktop) { - return OpenCryptoPayDesktopFrame(title: "Open CryptoPay", child: body); - } - - 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: body), - ), + return OpenCryptoPayScaffold( + title: "Open CryptoPay", + isDesktop: widget.isDesktop, + child: body, ); } } diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_widgets.dart b/lib/pages/open_crypto_pay/open_crypto_pay_widgets.dart index d11b02dca..e86dc62bb 100644 --- a/lib/pages/open_crypto_pay/open_crypto_pay_widgets.dart +++ b/lib/pages/open_crypto_pay/open_crypto_pay_widgets.dart @@ -1,9 +1,59 @@ import 'package:flutter/material.dart'; +import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; +Future showOpenCryptoPayDesktopDialog({ + required BuildContext context, + required Widget child, +}) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (_) => DesktopDialog( + maxHeight: MediaQuery.sizeOf(context).height - 64, + maxWidth: 580, + child: child, + ), + ); +} + +class OpenCryptoPayScaffold extends StatelessWidget { + const OpenCryptoPayScaffold({ + super.key, + required this.title, + required this.isDesktop, + required this.child, + }); + + final String title; + final bool isDesktop; + final Widget child; + + @override + Widget build(BuildContext context) { + if (isDesktop) return OpenCryptoPayDesktopFrame(title: title, child: child); + + final colors = Theme.of(context).extension()!; + return Background( + child: Scaffold( + backgroundColor: colors.background, + appBar: AppBar( + backgroundColor: colors.backgroundAppBar, + leading: const AppBarBackButton(), + title: Text(title, style: STextStyles.navBarTitle(context)), + ), + body: SafeArea(child: child), + ), + ); + } +} + class OpenCryptoPayDesktopFrame extends StatelessWidget { const OpenCryptoPayDesktopFrame({ super.key, diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 866da0870..493019cea 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -12,14 +12,12 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:isar_community/isar.dart'; -import '../../models/isar/models/isar_models.dart'; import '../../models/input.dart'; +import '../../models/isar/models/isar_models.dart'; import '../../models/isar/models/transaction_note.dart'; import '../../models/isar/ordinal.dart'; import '../../notifications/show_flush_bar.dart'; @@ -30,7 +28,7 @@ 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 '../../services/open_crypto_pay/settlement.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; import '../../utilities/amount/amount.dart'; @@ -39,22 +37,18 @@ 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/ordinals_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; import '../../wallets/wallet/wallet.dart'; @@ -114,9 +108,6 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { class _ConfirmTransactionViewState extends ConsumerState { - // OCP raw-hex commits are GET query params; keep Firo near common header caps. - static const int _openCryptoPayMaxRawHexQueryLength = 8000; - late final String walletId; late final String routeOnSuccessName; late final bool isDesktop; @@ -433,10 +424,18 @@ class _ConfirmTransactionViewState return; } - final openCryptoPayError = _validateOpenCryptoPaySend( - wallet, - openCryptoPayCommit, - ); + final openCryptoPaySettlement = openCryptoPayCommit == null + ? null + : OpenCryptoPaySettlement( + wallet: wallet, + txData: widget.txData, + commit: openCryptoPayCommit, + mainDB: ref.read(mainDBProvider), + isTokenTx: widget.isTokenTx, + tokenWallet: ref.read(pCurrentTokenWallet), + ); + + final openCryptoPayError = openCryptoPaySettlement?.validate(); if (openCryptoPayError != null) { if (context.mounted) { unawaited( @@ -472,24 +471,15 @@ class _ConfirmTransactionViewState Future txDataFuture; final note = noteController.text; - final openCryptoPayTxIdFlow = _shouldCommitOpenCryptoPayTxId( - openCryptoPayCommit, - wallet, - widget.txData, - ); + final openCryptoPayTxIdFlow = + openCryptoPaySettlement?.shouldCommitTxId ?? false; try { - if (!openCryptoPayTxIdFlow && - openCryptoPayCommit?.submissionFlow == - OpenCryptoPaySubmissionFlow.rawHexToProvider) { + if (openCryptoPaySettlement?.shouldSubmitRawHex ?? false) { final submitWallet = widget.isTokenTx ? ref.read(pCurrentTokenWallet)! : wallet; - txDataFuture = _submitOpenCryptoPayRawHex( - submitWallet, - widget.txData, - openCryptoPayCommit!, - ); + txDataFuture = openCryptoPaySettlement!.submitRawHex(submitWallet); } else if (widget.isTokenTx) { if (wallet is SolanaWallet) { // For Solana tokens, use the Solana token wallet. @@ -589,7 +579,7 @@ class _ConfirmTransactionViewState if (openCryptoPayTxIdFlow) { final result = results.first as TxData; - await _commitOpenCryptoPayTxId(openCryptoPayCommit!, result); + await openCryptoPaySettlement!.commitTxId(result); } sendProgressController.triggerSuccess?.call(); @@ -900,284 +890,6 @@ 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; - } - } - - bool _shouldCommitOpenCryptoPayTxId( - OpenCryptoPayCommit? commit, - Wallet wallet, - TxData txData, - ) { - if (commit == null) return false; - if (commit.submissionFlow == - OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) { - return true; - } - return commit.method == 'Firo' && - wallet is FiroWallet && - (txData.usedSparkCoins?.isNotEmpty == true || - (txData.raw?.length ?? 0) > _openCryptoPayMaxRawHexQueryLength); - } - - 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() { 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 d551f9307..7601a2101 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 @@ -926,7 +926,7 @@ class _DesktopSendState extends ConsumerState { _applyUri(paymentData); } else if (LnurlUtils.isOpenCryptoPayUrl(qrCodeData)) { if (!mounted) return; - await showOpenCryptoPayDesktopDialog( + await showOpenCryptoPayPaymentDesktopDialog( context: context, qrUrl: qrCodeData, walletId: walletId, 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 cfe391007..d4e405b10 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 @@ -482,7 +482,7 @@ class _DesktopTokenSendState extends ConsumerState { }); } else if (LnurlUtils.isOpenCryptoPayUrl(qrResult)) { if (!mounted) return; - await showOpenCryptoPayDesktopDialog( + await showOpenCryptoPayPaymentDesktopDialog( context: context, qrUrl: qrResult, walletId: walletId, diff --git a/lib/services/open_crypto_pay/erc20_token_lookup.dart b/lib/services/open_crypto_pay/erc20_token_lookup.dart new file mode 100644 index 000000000..105a6a8cb --- /dev/null +++ b/lib/services/open_crypto_pay/erc20_token_lookup.dart @@ -0,0 +1,29 @@ +import '../../db/isar/main_db.dart'; +import '../../models/isar/models/ethereum/eth_contract.dart'; + +class OpenCryptoPayErc20TokenLookup { + const OpenCryptoPayErc20TokenLookup._(); + + static List enabledTokens( + MainDB mainDB, + Iterable addresses, + ) { + return addresses + .map(mainDB.getEthContractSync) + .whereType() + .where((e) => e.type == EthContractType.erc20) + .toList(); + } + + static EthContract? enabledToken( + MainDB mainDB, + Iterable addresses, + String contractAddress, + ) { + final normalized = contractAddress.toLowerCase(); + for (final contract in enabledTokens(mainDB, addresses)) { + if (contract.address.toLowerCase() == normalized) return contract; + } + return null; + } +} diff --git a/lib/services/open_crypto_pay/method_support.dart b/lib/services/open_crypto_pay/method_support.dart index 39ab77aac..f15faa3b2 100644 --- a/lib/services/open_crypto_pay/method_support.dart +++ b/lib/services/open_crypto_pay/method_support.dart @@ -6,6 +6,13 @@ import 'models.dart'; class OpenCryptoPayMethodSupport { const OpenCryptoPayMethodSupport._(); + static const _methodsByCoinType = { + Bitcoin: 'Bitcoin', + Solana: 'Solana', + Cardano: 'Cardano', + Firo: 'Firo', + }; + static OpenCryptoPaySubmissionFlow? submissionFlowFor(String method) { switch (method) { case 'Solana': @@ -21,11 +28,8 @@ class OpenCryptoPayMethodSupport { // Firo starts here for transparent/provider-broadcast payments; Spark // or oversized raw transactions fall back to txid at confirmation. return OpenCryptoPaySubmissionFlow.rawHexToProvider; - case 'Lightning': - case 'BinancePay': - case 'InternetComputer': - return null; default: + // Known unsupported methods include Lightning, BinancePay, and ICP. return null; } } @@ -38,27 +42,20 @@ class OpenCryptoPayMethodSupport { }) { final ticker = coin.ticker.toUpperCase(); final assetTicker = asset.asset.toUpperCase(); + final methodName = _methodForCoin(coin); + + if (methodName == null || method.method != methodName) return false; - 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; + return assetTicker == ticker; } + + static String? _methodForCoin(CryptoCurrency coin) => + coin is Ethereum ? 'Ethereum' : _methodsByCoinType[coin.runtimeType]; } diff --git a/lib/services/open_crypto_pay/models.dart b/lib/services/open_crypto_pay/models.dart index a4a896144..1e4130050 100644 --- a/lib/services/open_crypto_pay/models.dart +++ b/lib/services/open_crypto_pay/models.dart @@ -139,7 +139,6 @@ class OpenCryptoPayRequestedAmount { } class OpenCryptoPayPaymentDetails { - final String id; final String? standard; final List possibleStandards; final String? displayName; @@ -150,7 +149,6 @@ class OpenCryptoPayPaymentDetails { final List transferAmounts; OpenCryptoPayPaymentDetails({ - required this.id, this.standard, required this.possibleStandards, this.displayName, @@ -164,7 +162,6 @@ class OpenCryptoPayPaymentDetails { factory OpenCryptoPayPaymentDetails.fromJson(Map json) { final callback = json['callback'] as String? ?? ''; return OpenCryptoPayPaymentDetails( - id: json['id'] as String, standard: json['standard'] as String?, possibleStandards: (json['possibleStandards'] as List?) diff --git a/lib/services/open_crypto_pay/open_crypto_pay_api.dart b/lib/services/open_crypto_pay/open_crypto_pay_api.dart index 0fcbdce53..d4ac5a4d3 100644 --- a/lib/services/open_crypto_pay/open_crypto_pay_api.dart +++ b/lib/services/open_crypto_pay/open_crypto_pay_api.dart @@ -38,10 +38,7 @@ class OpenCryptoPayApi { /// Fetches the payment details (available methods, quote, recipient, etc) /// for the payment encoded in [qrUrl]. - Future getPaymentDetails( - String qrUrl, { - int timeout = 10, - }) async { + Future getPaymentDetails(String qrUrl) async { final lnurl = LnurlUtils.extractLnurl(qrUrl); if (lnurl == null) { throw Exception('No LNURL payload found'); @@ -52,7 +49,7 @@ class OpenCryptoPayApi { final uri = apiUrl.replace( queryParameters: { ...apiUrl.queryParameters, - 'timeout': timeout.toString(), + 'timeout': '10', }, ); diff --git a/lib/services/open_crypto_pay/settlement.dart b/lib/services/open_crypto_pay/settlement.dart new file mode 100644 index 000000000..5c56ea50c --- /dev/null +++ b/lib/services/open_crypto_pay/settlement.dart @@ -0,0 +1,275 @@ +import 'package:decimal/decimal.dart'; +import 'package:isar_community/isar.dart'; + +import '../../db/isar/main_db.dart'; +import '../../models/input.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/logger.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/models/spark_coin.dart'; +import '../../wallets/models/tx_data.dart'; +import '../../wallets/wallet/impl/ethereum_wallet.dart'; +import '../../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import '../../wallets/wallet/wallet.dart'; +import 'models.dart'; +import 'open_crypto_pay_api.dart'; + +class OpenCryptoPaySettlement { + OpenCryptoPaySettlement({ + required this.wallet, + required this.txData, + required this.commit, + required this.mainDB, + required this.isTokenTx, + this.tokenWallet, + }); + + // OCP raw-hex commits are GET query params; keep Firo near common header caps. + static const int maxRawHexQueryLength = 8000; + + final Wallet wallet; + final TxData txData; + final OpenCryptoPayCommit commit; + final MainDB mainDB; + final bool isTokenTx; + final EthTokenWallet? tokenWallet; + + bool get shouldCommitTxId { + if (commit.submissionFlow == + OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) { + return true; + } + return commit.method == 'Firo' && + wallet.cryptoCurrency is Firo && + (txData.usedSparkCoins?.isNotEmpty == true || + (txData.raw?.length ?? 0) > maxRawHexQueryLength); + } + + bool get shouldSubmitRawHex => !shouldCommitTxId && commit.canCommitRawHex; + + String? validate() { + final minFeeError = _validateMinFee(); + if (minFeeError != null) return minFeeError; + + final transactionError = _validateTransaction(); + if (transactionError != null) return transactionError; + + final tokenError = _validateToken(); + if (tokenError != null) return tokenError; + + switch (commit.submissionFlow) { + case OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast: + return null; + case OpenCryptoPaySubmissionFlow.rawHexToProvider: + if (shouldCommitTxId) return null; + if (wallet.cryptoCurrency is! Firo && + wallet.cryptoCurrency is! Ethereum && + wallet.cryptoCurrency is! Bitcoin) { + return "This Open CryptoPay method is not supported yet"; + } + if (wallet.cryptoCurrency is Ethereum) { + if (txData.web3dartTransaction == null || txData.chainId == null) { + return "Could not build signed Ethereum transaction"; + } + } else if (txData.raw == null || txData.raw!.isEmpty) { + return "Could not build signed transaction"; + } + return null; + } + } + + Future submitRawHex(Wallet submitWallet) async { + final signedTx = await _prepareRawHexTx(submitWallet, txData); + final raw = signedTx.raw; + if (raw == null || raw.isEmpty) { + throw Exception("Could not build signed transaction"); + } + + final txid = signedTx.tempTx?.txid ?? signedTx.txid ?? signedTx.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 = signedTx.usedUTXOs?.map((e) { + if (e is StandardInput) { + return StandardInput( + e.utxo.copyWith(used: true), + derivePathType: e.derivePathType, + ); + } + return e; + }).toList(); + + final updatedTxData = signedTx.copyWith( + usedUTXOs: updatedInputs, + txHash: txid, + txid: txid, + ); + + final updatedUtxos = updatedInputs + ?.whereType() + .map((e) => e.utxo) + .toList(); + 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 submitWallet.updateSentCachedTxData(txData: updatedTxData); + } + + Future commitTxId(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", + ); + } + } + + String? _validateTransaction() { + final recipients = _recipients(txData); + if (recipients.length != 1) { + return "Open CryptoPay requires exactly one recipient"; + } + + final actual = recipients.single; + if (_normalizeAddress(actual.address) != + _normalizeAddress(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? _validateToken() { + final tokenContractAddress = commit.tokenContractAddress; + if (tokenContractAddress == null) return null; + + if (!isTokenTx || commit.method != 'Ethereum') { + return "Open CryptoPay token payment is not supported here"; + } + + final wallet = tokenWallet; + if (wallet == null) { + return "Could not verify Open CryptoPay token wallet"; + } + + if (wallet.tokenContract.address.toLowerCase() != + tokenContractAddress.toLowerCase()) { + return "Open CryptoPay token contract changed. Please scan again."; + } + + if (wallet.tokenContract.symbol.toUpperCase() != + commit.asset.toUpperCase()) { + return "Open CryptoPay token asset changed. Please scan again."; + } + + return null; + } + + String? _validateMinFee() { + if (commit.minFee <= Decimal.zero) return null; + + if (wallet.cryptoCurrency is Ethereum) { + final gasPrice = 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.cryptoCurrency is Firo) { + final fee = txData.fee; + final vSize = 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) { + return value.ceil().toBigInt(); + } + + List<({String address, Amount amount})> _recipients(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 _normalizeAddress(String address) { + if (wallet.cryptoCurrency is Ethereum) return address.toLowerCase(); + return address; + } + + Future _prepareRawHexTx(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; + } +} diff --git a/test/open_crypto_pay_models_test.dart b/test/open_crypto_pay_models_test.dart index f0cb83e6b..741e7bd63 100644 --- a/test/open_crypto_pay_models_test.dart +++ b/test/open_crypto_pay_models_test.dart @@ -25,7 +25,6 @@ void main() { test("falls back to callback id when quote payment id is missing", () { final details = OpenCryptoPayPaymentDetails.fromJson({ - "id": "payment-link-id", "callback": "https://example.com/lnurl/cb/payment-id", "quote": {"id": "quote-id", "expiration": "2026-04-28T12:00:00Z"}, "transferAmounts": [ From b46e08e5762806c3df407d07dee0dca8775a950a Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 11 Jun 2026 23:53:47 +0800 Subject: [PATCH 18/20] Test Open CryptoPay settlement decisions --- lib/services/open_crypto_pay/settlement.dart | 90 +++++--- test/open_crypto_pay_settlement_test.dart | 217 +++++++++++++++++++ 2 files changed, 282 insertions(+), 25 deletions(-) create mode 100644 test/open_crypto_pay_settlement_test.dart diff --git a/lib/services/open_crypto_pay/settlement.dart b/lib/services/open_crypto_pay/settlement.dart index 5c56ea50c..b77c8d83d 100644 --- a/lib/services/open_crypto_pay/settlement.dart +++ b/lib/services/open_crypto_pay/settlement.dart @@ -34,15 +34,28 @@ class OpenCryptoPaySettlement { final bool isTokenTx; final EthTokenWallet? tokenWallet; - bool get shouldCommitTxId { - if (commit.submissionFlow == + bool get shouldCommitTxId => shouldCommitTxIdFor( + method: commit.method, + submissionFlow: commit.submissionFlow, + cryptoCurrency: wallet.cryptoCurrency, + hasSparkInputs: txData.usedSparkCoins?.isNotEmpty == true, + rawHexLength: txData.raw?.length ?? 0, + ); + + static bool shouldCommitTxIdFor({ + required String method, + required OpenCryptoPaySubmissionFlow submissionFlow, + required CryptoCurrency cryptoCurrency, + required bool hasSparkInputs, + required int rawHexLength, + }) { + if (submissionFlow == OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) { return true; } - return commit.method == 'Firo' && - wallet.cryptoCurrency is Firo && - (txData.usedSparkCoins?.isNotEmpty == true || - (txData.raw?.length ?? 0) > maxRawHexQueryLength); + return method == 'Firo' && + cryptoCurrency is Firo && + (hasSparkInputs || rawHexLength > maxRawHexQueryLength); } bool get shouldSubmitRawHex => !shouldCommitTxId && commit.canCommitRawHex; @@ -149,18 +162,31 @@ class OpenCryptoPaySettlement { } String? _validateTransaction() { - final recipients = _recipients(txData); + return validateTransaction( + cryptoCurrency: wallet.cryptoCurrency, + recipients: _recipients(txData), + recipientAddress: commit.recipientAddress, + amount: commit.amount, + ); + } + + static String? validateTransaction({ + required CryptoCurrency cryptoCurrency, + required List<({String address, Amount amount})> recipients, + required String recipientAddress, + required Decimal amount, + }) { if (recipients.length != 1) { return "Open CryptoPay requires exactly one recipient"; } final actual = recipients.single; - if (_normalizeAddress(actual.address) != - _normalizeAddress(commit.recipientAddress)) { + if (_normalizeAddress(cryptoCurrency, actual.address) != + _normalizeAddress(cryptoCurrency, recipientAddress)) { return "Open CryptoPay recipient changed. Please scan again."; } - if (actual.amount.decimal != commit.amount) { + if (actual.amount.decimal != amount) { return "Open CryptoPay amount changed. Please scan again."; } @@ -193,40 +219,51 @@ class OpenCryptoPaySettlement { return null; } - String? _validateMinFee() { - if (commit.minFee <= Decimal.zero) return null; - - if (wallet.cryptoCurrency is Ethereum) { - final gasPrice = txData.web3dartTransaction?.maxFeePerGas?.getInWei; + String? _validateMinFee() => validateMinFee( + cryptoCurrency: wallet.cryptoCurrency, + minFee: commit.minFee, + gasPrice: txData.web3dartTransaction?.maxFeePerGas?.getInWei, + fee: txData.fee, + vSize: txData.vSize, + ); + + static String? validateMinFee({ + required CryptoCurrency cryptoCurrency, + required Decimal minFee, + BigInt? gasPrice, + Amount? fee, + int? vSize, + }) { + if (minFee <= Decimal.zero) return null; + + if (cryptoCurrency is Ethereum) { if (gasPrice == null) { return "Could not verify Open CryptoPay minimum gas price"; } - if (gasPrice < _ceilDecimalToBigInt(commit.minFee)) { + if (gasPrice < _ceilDecimalToBigInt(minFee)) { return "Open CryptoPay requires at least " - "${commit.minFee} wei gas price"; + "$minFee wei gas price"; } return null; } - if (wallet.cryptoCurrency is Bitcoin || wallet.cryptoCurrency is Firo) { - final fee = txData.fee; - final vSize = txData.vSize; + if (cryptoCurrency is Bitcoin || cryptoCurrency is Firo) { if (fee == null || vSize == null || vSize <= 0) { return "Could not verify Open CryptoPay minimum fee"; } final minTotalFee = _ceilDecimalToBigInt( - commit.minFee * Decimal.fromInt(vSize), + minFee * Decimal.fromInt(vSize), ); if (fee.raw < minTotalFee) { return "Open CryptoPay requires at least " - "${commit.minFee} sat/vB fee"; + "$minFee sat/vB fee"; } } return null; } - BigInt _ceilDecimalToBigInt(Decimal value) { + static BigInt _ceilDecimalToBigInt(Decimal value) { return value.ceil().toBigInt(); } @@ -257,8 +294,11 @@ class OpenCryptoPaySettlement { return recipients; } - String _normalizeAddress(String address) { - if (wallet.cryptoCurrency is Ethereum) return address.toLowerCase(); + static String _normalizeAddress( + CryptoCurrency cryptoCurrency, + String address, + ) { + if (cryptoCurrency is Ethereum) return address.toLowerCase(); return address; } diff --git a/test/open_crypto_pay_settlement_test.dart b/test/open_crypto_pay_settlement_test.dart new file mode 100644 index 000000000..ae3589d5b --- /dev/null +++ b/test/open_crypto_pay_settlement_test.dart @@ -0,0 +1,217 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/open_crypto_pay/models.dart'; +import 'package:stackwallet/services/open_crypto_pay/settlement.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + final bitcoin = Bitcoin(CryptoCurrencyNetwork.main); + final cardano = Cardano(CryptoCurrencyNetwork.main); + final ethereum = Ethereum(CryptoCurrencyNetwork.main); + final firo = Firo(CryptoCurrencyNetwork.main); + + group("shouldCommitTxIdFor", () { + test("always uses txid for txid submission flows", () { + expect( + OpenCryptoPaySettlement.shouldCommitTxIdFor( + method: "Cardano", + submissionFlow: OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast, + cryptoCurrency: cardano, + hasSparkInputs: false, + rawHexLength: 0, + ), + true, + ); + }); + + test("falls back for Firo Spark spends", () { + expect( + OpenCryptoPaySettlement.shouldCommitTxIdFor( + method: "Firo", + submissionFlow: OpenCryptoPaySubmissionFlow.rawHexToProvider, + cryptoCurrency: firo, + hasSparkInputs: true, + rawHexLength: 100, + ), + true, + ); + }); + + test("falls back only above the Firo raw hex query limit", () { + expect( + OpenCryptoPaySettlement.shouldCommitTxIdFor( + method: "Firo", + submissionFlow: OpenCryptoPaySubmissionFlow.rawHexToProvider, + cryptoCurrency: firo, + hasSparkInputs: false, + rawHexLength: OpenCryptoPaySettlement.maxRawHexQueryLength, + ), + false, + ); + expect( + OpenCryptoPaySettlement.shouldCommitTxIdFor( + method: "Firo", + submissionFlow: OpenCryptoPaySubmissionFlow.rawHexToProvider, + cryptoCurrency: firo, + hasSparkInputs: false, + rawHexLength: OpenCryptoPaySettlement.maxRawHexQueryLength + 1, + ), + true, + ); + }); + + test("does not use the Firo fallback for other coins", () { + expect( + OpenCryptoPaySettlement.shouldCommitTxIdFor( + method: "Bitcoin", + submissionFlow: OpenCryptoPaySubmissionFlow.rawHexToProvider, + cryptoCurrency: bitcoin, + hasSparkInputs: false, + rawHexLength: OpenCryptoPaySettlement.maxRawHexQueryLength + 1, + ), + false, + ); + }); + }); + + group("validateMinFee", () { + test("ceil-checks Ethereum wei gas price", () { + expect( + OpenCryptoPaySettlement.validateMinFee( + cryptoCurrency: ethereum, + minFee: Decimal.parse("10.1"), + gasPrice: BigInt.from(10), + ), + "Open CryptoPay requires at least 10.1 wei gas price", + ); + expect( + OpenCryptoPaySettlement.validateMinFee( + cryptoCurrency: ethereum, + minFee: Decimal.parse("10.1"), + gasPrice: BigInt.from(11), + ), + isNull, + ); + }); + + test("requires Ethereum gas price when minFee is set", () { + expect( + OpenCryptoPaySettlement.validateMinFee( + cryptoCurrency: ethereum, + minFee: Decimal.fromInt(1), + ), + "Could not verify Open CryptoPay minimum gas price", + ); + }); + + test("ceil-checks Bitcoin sat/vB against total fee", () { + expect( + OpenCryptoPaySettlement.validateMinFee( + cryptoCurrency: bitcoin, + minFee: Decimal.parse("2.5"), + fee: _rawAmount(7), + vSize: 3, + ), + "Open CryptoPay requires at least 2.5 sat/vB fee", + ); + expect( + OpenCryptoPaySettlement.validateMinFee( + cryptoCurrency: bitcoin, + minFee: Decimal.parse("2.5"), + fee: _rawAmount(8), + vSize: 3, + ), + isNull, + ); + }); + + test("requires fee and vSize for sat/vB methods", () { + expect( + OpenCryptoPaySettlement.validateMinFee( + cryptoCurrency: firo, + minFee: Decimal.fromInt(1), + ), + "Could not verify Open CryptoPay minimum fee", + ); + }); + }); + + group("validateTransaction", () { + test("requires exactly one recipient", () { + expect( + OpenCryptoPaySettlement.validateTransaction( + cryptoCurrency: bitcoin, + recipients: <({String address, Amount amount})>[], + recipientAddress: "bc1qrecipient", + amount: Decimal.fromInt(1), + ), + "Open CryptoPay requires exactly one recipient", + ); + expect( + OpenCryptoPaySettlement.validateTransaction( + cryptoCurrency: bitcoin, + recipients: [ + (address: "bc1qone", amount: _amount("1")), + (address: "bc1qtwo", amount: _amount("1")), + ], + recipientAddress: "bc1qrecipient", + amount: Decimal.fromInt(1), + ), + "Open CryptoPay requires exactly one recipient", + ); + }); + + test("rejects recipient mismatch", () { + expect( + OpenCryptoPaySettlement.validateTransaction( + cryptoCurrency: bitcoin, + recipients: [(address: "bc1qactual", amount: _amount("1"))], + recipientAddress: "bc1qexpected", + amount: Decimal.fromInt(1), + ), + "Open CryptoPay recipient changed. Please scan again.", + ); + }); + + test("rejects amount mismatch", () { + expect( + OpenCryptoPaySettlement.validateTransaction( + cryptoCurrency: bitcoin, + recipients: [(address: "bc1qrecipient", amount: _amount("1.01"))], + recipientAddress: "bc1qrecipient", + amount: Decimal.fromInt(1), + ), + "Open CryptoPay amount changed. Please scan again.", + ); + }); + + test("normalizes Ethereum recipient case", () { + expect( + OpenCryptoPaySettlement.validateTransaction( + cryptoCurrency: ethereum, + recipients: [ + ( + address: "0x9C2242A0B71FD84661FD4BC56B75C90FAC6D10FC", + amount: _amount("1", fractionDigits: 18), + ), + ], + recipientAddress: "0x9c2242a0b71fd84661fd4bc56b75c90fac6d10fc", + amount: Decimal.fromInt(1), + ), + isNull, + ); + }); + }); +} + +Amount _amount(String value, {int fractionDigits = 8}) { + return Amount.fromDecimal( + Decimal.parse(value), + fractionDigits: fractionDigits, + ); +} + +Amount _rawAmount(int value, {int fractionDigits = 8}) { + return Amount(rawValue: BigInt.from(value), fractionDigits: fractionDigits); +} From 4b48d39a331dbcd107d332d709d078253f3acfc4 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Fri, 12 Jun 2026 01:18:01 +0800 Subject: [PATCH 19/20] Format Open CryptoPay files --- lib/services/open_crypto_pay/open_crypto_pay_api.dart | 5 +---- lib/services/open_crypto_pay/settlement.dart | 7 ++----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/services/open_crypto_pay/open_crypto_pay_api.dart b/lib/services/open_crypto_pay/open_crypto_pay_api.dart index d4ac5a4d3..82e10470b 100644 --- a/lib/services/open_crypto_pay/open_crypto_pay_api.dart +++ b/lib/services/open_crypto_pay/open_crypto_pay_api.dart @@ -47,10 +47,7 @@ class OpenCryptoPayApi { final apiUrl = Uri.parse(LnurlUtils.decodeLnurl(lnurl)); _requireHttps(apiUrl, 'decoded LNURL'); final uri = apiUrl.replace( - queryParameters: { - ...apiUrl.queryParameters, - 'timeout': '10', - }, + queryParameters: {...apiUrl.queryParameters, 'timeout': '10'}, ); Logging.instance.d('OpenCryptoPay: GET $uri'); diff --git a/lib/services/open_crypto_pay/settlement.dart b/lib/services/open_crypto_pay/settlement.dart index b77c8d83d..5abdfcd97 100644 --- a/lib/services/open_crypto_pay/settlement.dart +++ b/lib/services/open_crypto_pay/settlement.dart @@ -49,8 +49,7 @@ class OpenCryptoPaySettlement { required bool hasSparkInputs, required int rawHexLength, }) { - if (submissionFlow == - OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) { + if (submissionFlow == OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) { return true; } return method == 'Firo' && @@ -251,9 +250,7 @@ class OpenCryptoPaySettlement { if (fee == null || vSize == null || vSize <= 0) { return "Could not verify Open CryptoPay minimum fee"; } - final minTotalFee = _ceilDecimalToBigInt( - minFee * Decimal.fromInt(vSize), - ); + final minTotalFee = _ceilDecimalToBigInt(minFee * Decimal.fromInt(vSize)); if (fee.raw < minTotalFee) { return "Open CryptoPay requires at least " "$minFee sat/vB fee"; From 1609a7dcf1cc01f70314154e41143fffa17f911d Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Fri, 12 Jun 2026 01:38:46 +0800 Subject: [PATCH 20/20] Fix confirm view imports for CI --- lib/pages/send_view/confirm_transaction_view.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 493019cea..11e540212 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -12,9 +12,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:isar_community/isar.dart'; import '../../models/input.dart'; import '../../models/isar/models/isar_models.dart';