From 5a7cf1cead26055186effbc005a8c220ed90f189 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Tue, 28 Apr 2026 00:23:51 +0800 Subject: [PATCH 1/4] 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 bb8817ca22..4e185b7027 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 0000000000..42038386bd --- /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 0000000000..3dc4c0aaac --- /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 dfd6c98bd0..9ea6e4ca9d 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -24,6 +24,8 @@ import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/deskt import '../../providers/providers.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; +import '../../services/open_crypto_pay/models.dart'; +import '../../services/open_crypto_pay/open_crypto_pay_api.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; import '../../utilities/amount/amount.dart'; @@ -76,6 +78,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { this.isPaynymNotificationTransaction = false, this.isTokenTx = false, this.onSuccessInsteadOfRouteOnSuccess, + this.openCryptoPayCommit, }); static const String routeName = "/confirmTransactionView"; @@ -89,6 +92,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget { final bool isTokenTx; final VoidCallback? onSuccessInsteadOfRouteOnSuccess; final VoidCallback onSuccess; + final OpenCryptoPayCommit? openCryptoPayCommit; @override ConsumerState createState() => @@ -395,6 +399,25 @@ class _ConfirmTransactionViewState } else { txids.add((results.first as TxData).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 94b5663c82..0d4a646b77 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 74a129efd8..3d42f3dd37 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -34,6 +34,7 @@ import '../../services/event_bus/events/global/node_connection_status_changed_ev import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../services/event_bus/global_event_bus.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; +import '../../services/open_crypto_pay/lnurl_utils.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; @@ -71,6 +72,7 @@ import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/custom_loading_overlay.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/frost_scaffold.dart'; +import '../../widgets/icon_widgets/qrcode_icon.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/small_tor_icon.dart'; import '../../widgets/stack_dialog.dart'; @@ -98,6 +100,7 @@ import '../masternodes/masternodes_home_view.dart'; import '../monkey/monkey_view.dart'; import '../namecoin_names/namecoin_names_home_view.dart'; import '../notification_views/notifications_view.dart'; +import '../open_crypto_pay/open_crypto_pay_view.dart'; import '../ordinals/ordinals_view.dart'; import '../paynym/paynym_claim_view.dart'; import '../paynym/paynym_home_view.dart'; @@ -425,6 +428,51 @@ class _WalletViewState extends ConsumerState { } } + Future _onOpenCryptoPayPressed(BuildContext context) async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + + final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + + if (qrResult.rawContent == null) return; + + if (LnurlUtils.isOpenCryptoPayUrl(qrResult.rawContent!)) { + if (mounted) { + unawaited( + Navigator.of(context).pushNamed( + OpenCryptoPayView.routeName, + arguments: ( + qrUrl: qrResult.rawContent!, + walletId: walletId, + coin: coin, + ), + ), + ); + } + } else { + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "The scanned QR code is not an Open CryptoPay payment code.", + context: context, + ), + ); + } + } + } catch (e, s) { + Logging.instance.e( + "Failed to scan QR for OpenCryptoPay", + error: e, + stackTrace: s, + ); + } + } + Future attemptAnonymize() async { bool shouldPop = false; unawaited( @@ -1343,6 +1391,12 @@ class _WalletViewState extends ConsumerState { ); }, ), + if (!viewOnly) + WalletNavigationBarItemData( + label: "Pay", + icon: const QrCodeIcon(), + onTap: () => _onOpenCryptoPayPressed(context), + ), ], ), ), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index cad05cbcdb..753dcec0c7 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -88,6 +88,7 @@ import 'pages/namecoin_names/manage_domain_view.dart'; import 'pages/namecoin_names/namecoin_names_home_view.dart'; import 'pages/namecoin_names/sub_widgets/name_details.dart'; import 'pages/notification_views/notifications_view.dart'; +import 'pages/open_crypto_pay/open_crypto_pay_view.dart'; import 'pages/ordinals/ordinal_details_view.dart'; import 'pages/ordinals/ordinals_filter_view.dart'; import 'pages/ordinals/ordinals_view.dart'; @@ -1863,6 +1864,20 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case OpenCryptoPayView.routeName: + if (args is ({String qrUrl, String walletId, CryptoCurrency coin})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => OpenCryptoPayView( + qrUrl: args.qrUrl, + walletId: args.walletId, + coin: args.coin, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SendView.routeName: if (args is Tuple2) { return getRoute( 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 0000000000..ec8977e4be --- /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 0000000000..61939e53d1 --- /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 0000000000..ac5aa32882 --- /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 44be6d0a3ac3f15131032688a0b77d857bd45930 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Mon, 27 Apr 2026 19:02:04 +0800 Subject: [PATCH 2/4] 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 | 306 ++++++++++++++++-- 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, 644 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 4e185b7027..0687af324a 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 42038386bd..d020a2e4d5 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 3dc4c0aaac..511485c475 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 9ea6e4ca9d..c41c6545a4 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../models/input.dart'; import '../../models/isar/models/transaction_note.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; @@ -34,19 +35,23 @@ 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/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'; @@ -275,6 +280,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(); @@ -300,7 +336,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 @@ -390,9 +433,6 @@ class _ConfirmTransactionViewState final results = await Future.wait([txDataFuture, time]); - sendProgressController.triggerSuccess?.call(); - await Future.delayed(const Duration(seconds: 5)); - if (wallet is FiroWallet && (results.first as TxData).sparkMints != null) { txids.addAll((results.first as TxData).sparkMints!.map((e) => e.txid!)); @@ -400,24 +440,14 @@ class _ConfirmTransactionViewState txids.add((results.first as TxData).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); } @@ -468,7 +498,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) { @@ -543,6 +575,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() { isDesktop = Util.isDesktop; diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 0d4a646b77..a2b5bca599 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 0000000000..f78dba96a2 --- /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 61939e53d1..e65159f95f 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 ac5aa32882..4f4c322fee 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 829110651b..06b611ce8d 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 7dbf859ea68155fd18726dc317044077c2193e0e Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Tue, 28 Apr 2026 00:06:23 +0800 Subject: [PATCH 3/4] 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 d020a2e4d5..a114eeff05 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 511485c475..ea0e188a24 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 c41c6545a4..4bfbcc0751 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -50,6 +50,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/paynym_interface.dart'; import '../../wallets/wallet/wallet.dart'; import '../../widgets/background.dart'; @@ -338,8 +339,11 @@ class _ConfirmTransactionViewState try { if (openCryptoPayCommit?.submissionFlow == OpenCryptoPaySubmissionFlow.rawHexToProvider) { + final submitWallet = widget.isTokenTx + ? ref.read(pCurrentTokenWallet)! + : wallet; txDataFuture = _submitOpenCryptoPayRawHex( - wallet, + submitWallet, widget.txData, openCryptoPayCommit!, ); @@ -591,6 +595,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; @@ -637,6 +644,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, @@ -800,6 +833,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 3d30fc5f6a..49dc6e6441 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 753dcec0c7..cb6321452d 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -1927,6 +1927,23 @@ class RouteGenerator { ), settings: RouteSettings(name: settings.name), ); + } else if (args + is Tuple4< + String, + CryptoCurrency, + EthContract, + SendViewAutoFillData + >) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => TokenSendView( + walletId: args.item1, + coin: args.item2, + tokenContract: args.item3, + autoFillData: args.item4, + ), + settings: RouteSettings(name: settings.name), + ); } return _routeError("${settings.name} invalid args: ${args.toString()}"); diff --git a/lib/services/open_crypto_pay/evm_uri.dart b/lib/services/open_crypto_pay/evm_uri.dart new file mode 100644 index 0000000000..fe1746a80f --- /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 f78dba96a2..722cfdc077 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 e65159f95f..46629906a3 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 06b611ce8d..1bd924e07d 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 6aca5a0082..24065d801a 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 0000000000..1484a63fc2 --- /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 e2c139305ac5ac3797ccbbe9391693ecea3fd76d Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Tue, 28 Apr 2026 00:23:27 +0800 Subject: [PATCH 4/4] 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 a114eeff05..7a42c05b98 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 46629906a3..fc3fd1be5b 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 4f4c322fee..c1b5266671 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 0000000000..df5a48f5bb --- /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, + ); + }); +}