diff --git a/lib/models/send_view_auto_fill_data.dart b/lib/models/send_view_auto_fill_data.dart index bb8817ca2..4e185b702 100644 --- a/lib/models/send_view_auto_fill_data.dart +++ b/lib/models/send_view_auto_fill_data.dart @@ -10,17 +10,25 @@ import 'package:decimal/decimal.dart'; +import '../services/open_crypto_pay/models.dart'; + class SendViewAutoFillData { final String address; final String contactLabel; final Decimal? amount; final String note; + /// When set, ConfirmTransactionView will notify the OpenCryptoPay provider + /// with the broadcast tx ID (and raw hex, where available) after a + /// successful send. + final OpenCryptoPayCommit? openCryptoPayCommit; + SendViewAutoFillData({ required this.address, required this.contactLabel, this.amount, this.note = "", + this.openCryptoPayCommit, }); Map toJson() { diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart new file mode 100644 index 000000000..42038386b --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart @@ -0,0 +1,252 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tuple/tuple.dart'; + +import '../../models/send_view_auto_fill_data.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../services/open_crypto_pay/models.dart'; +import '../../services/open_crypto_pay/open_crypto_pay_api.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/address_utils.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/loading_indicator.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../send_view/send_view.dart'; + +/// Fetches the transaction details for the selected method/asset, shows a +/// summary, then forwards to the standard [SendView] prefilled with the +/// payment address and amount. +class OpenCryptoPayConfirmView extends ConsumerStatefulWidget { + const OpenCryptoPayConfirmView({ + super.key, + required this.paymentDetails, + required this.selectedMethod, + required this.selectedAsset, + required this.walletId, + required this.coin, + }); + + final OpenCryptoPayPaymentDetails paymentDetails; + final OpenCryptoPayTransferMethod selectedMethod; + final OpenCryptoPayAsset selectedAsset; + final String walletId; + final CryptoCurrency coin; + + @override + ConsumerState createState() => + _OpenCryptoPayConfirmViewState(); +} + +class _OpenCryptoPayConfirmViewState + extends ConsumerState { + OpenCryptoPayTransactionDetails? _txDetails; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _fetch(); + } + + Future _fetch() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + _txDetails = await OpenCryptoPayApi.instance.getTransactionDetails( + callbackUrl: widget.paymentDetails.callback, + quoteId: widget.paymentDetails.quote!.id, + method: widget.selectedMethod.method, + asset: widget.selectedAsset.asset, + ); + } catch (e, s) { + Logging.instance.e("OpenCryptoPay tx fetch failed", error: e, stackTrace: s); + _errorMessage = 'Failed to fetch transaction details: $e'; + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + /// Parses address and amount from the transaction URI. Strips the EVM + /// `@chainId` suffix that [AddressUtils] leaves attached. + ({String? address, Decimal? amount}) _parseTransactionUri(String uri) { + final data = AddressUtils.parsePaymentUri(uri, logging: Logging.instance); + var address = data?.address ?? Uri.tryParse(uri)?.path; + if (address != null) { + final at = address.indexOf('@'); + if (at != -1) address = address.substring(0, at); + if (address.isEmpty) address = null; + } + final amount = data?.amount != null + ? Decimal.tryParse(data!.amount!) + : Decimal.tryParse(widget.selectedAsset.amount); + return (address: address, amount: amount); + } + + Future _proceedToSend() async { + final uri = _txDetails?.uri; + if (uri == null) { + _warn("No transaction URI provided by the payment provider"); + return; + } + + final parsed = _parseTransactionUri(uri); + if (parsed.address == null) { + _warn("Could not parse payment address"); + return; + } + + final recipient = widget.paymentDetails.recipient?.name ?? + widget.paymentDetails.displayName ?? + "OpenCryptoPay"; + + if (!mounted) return; + await Navigator.of(context).pushNamed( + SendView.routeName, + arguments: Tuple3( + widget.walletId, + widget.coin, + SendViewAutoFillData( + address: parsed.address!, + contactLabel: recipient, + amount: parsed.amount, + note: "OpenCryptoPay: $recipient", + openCryptoPayCommit: OpenCryptoPayCommit( + callbackUrl: widget.paymentDetails.callback, + quoteId: widget.paymentDetails.quote!.id, + method: widget.selectedMethod.method, + asset: widget.selectedAsset.asset, + ), + ), + ), + ); + } + + void _warn(String message) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: message, + context: context, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + backgroundColor: + Theme.of(context).extension()!.backgroundAppBar, + leading: const AppBarBackButton(), + title: Text( + "Confirm Payment", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: _body()), + ), + ), + ); + } + + Widget _body() { + if (_isLoading) return const Center(child: LoadingIndicator()); + + if (_errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _errorMessage!, + style: STextStyles.itemSubtitle(context), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + PrimaryButton(label: "Retry", onPressed: _fetch), + ], + ), + ); + } + + final details = widget.paymentDetails; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Payment Summary", + style: STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 8), + if (details.recipient?.name != null) + _row("To", details.recipient!.name!), + if (details.requestedAmount != null) + _row( + "Fiat amount", + "${details.requestedAmount!.amount} " + "${details.requestedAmount!.asset}", + ), + _row( + "Crypto amount", + "${widget.selectedAsset.amount} " + "${widget.selectedAsset.asset}", + ), + _row("Network", widget.selectedMethod.method), + ], + ), + ), + if (_txDetails?.hint != null) ...[ + const SizedBox(height: 16), + RoundedWhiteContainer( + child: Text(_txDetails!.hint!, style: STextStyles.label(context)), + ), + ], + const SizedBox(height: 24), + PrimaryButton(label: "Proceed to Send", onPressed: _proceedToSend), + ], + ), + ); + } + + Widget _row(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + SizedBox( + width: 100, + child: Text(label, style: STextStyles.label(context)), + ), + Expanded( + child: Text( + value, + style: STextStyles.itemSubtitle(context), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/open_crypto_pay/open_crypto_pay_view.dart b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart new file mode 100644 index 000000000..3dc4c0aaa --- /dev/null +++ b/lib/pages/open_crypto_pay/open_crypto_pay_view.dart @@ -0,0 +1,287 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../notifications/show_flush_bar.dart'; +import '../../services/open_crypto_pay/models.dart'; +import '../../services/open_crypto_pay/open_crypto_pay_api.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/loading_indicator.dart'; +import '../../widgets/rounded_white_container.dart'; +import 'open_crypto_pay_confirm_view.dart'; + +/// Shows the payment details from an Open CryptoPay QR code and lets the user +/// choose a payment method/asset that is supported by this wallet. +class OpenCryptoPayView extends ConsumerStatefulWidget { + const OpenCryptoPayView({ + super.key, + required this.qrUrl, + required this.walletId, + required this.coin, + }); + + static const String routeName = "/openCryptoPayView"; + + final String qrUrl; + + /// Only assets matching this coin's ticker are offered. + final String walletId; + final CryptoCurrency coin; + + @override + ConsumerState createState() => _OpenCryptoPayViewState(); +} + +class _OpenCryptoPayViewState extends ConsumerState { + OpenCryptoPayPaymentDetails? _details; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _fetch(); + } + + Future _fetch() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final details = await OpenCryptoPayApi.instance.getPaymentDetails( + widget.qrUrl, + ); + if (mounted) setState(() => _details = details); + } on OpenCryptoPayNoPendingPaymentException catch (e) { + if (mounted) setState(() => _errorMessage = e.message); + } catch (e, s) { + Logging.instance.e("OpenCryptoPay fetch failed", error: e, stackTrace: s); + if (mounted) setState(() => _errorMessage = 'Failed to fetch: $e'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + bool _matchesWalletCoin(String asset) => + widget.coin.ticker.toUpperCase() == asset.toUpperCase(); + + void _onSelected( + OpenCryptoPayTransferMethod method, + OpenCryptoPayAsset asset, + ) { + final quote = _details?.quote; + if (quote == null) return; + + if (quote.isExpired) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Quote expired, refreshing…", + context: context, + ), + ); + _fetch(); + return; + } + + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => OpenCryptoPayConfirmView( + paymentDetails: _details!, + selectedMethod: method, + selectedAsset: asset, + walletId: widget.walletId, + coin: widget.coin, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + backgroundColor: + Theme.of(context).extension()!.backgroundAppBar, + leading: const AppBarBackButton(), + title: Text( + "Open CryptoPay", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: _body()), + ), + ), + ); + } + + Widget _body() { + if (_isLoading) return const Center(child: LoadingIndicator()); + + if (_errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _errorMessage!, + style: STextStyles.itemSubtitle(context), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + PrimaryButton(label: "Retry", onPressed: _fetch), + ], + ), + ); + } + + final details = _details; + if (details == null) { + return const Center(child: Text("No payment data")); + } + + // Flatten into (method, asset) pairs that this wallet actually supports. + final options = [ + for (final m in details.availableMethods) + for (final a in m.assets) + if (_matchesWalletCoin(a.asset)) (method: m, asset: a), + ]; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (details.recipient != null) ...[ + _recipientCard(details.recipient!), + const SizedBox(height: 16), + ], + if (details.requestedAmount != null) ...[ + _amountCard(details), + const SizedBox(height: 16), + ], + Text( + "Select Payment Method", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + if (options.isEmpty) + RoundedWhiteContainer( + child: Text( + "No payment option available for ${widget.coin.prettyName}.", + style: STextStyles.itemSubtitle(context), + ), + ) + else + ...options.map( + (o) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _methodCard(o.method, o.asset), + ), + ), + if (details.quote != null) ...[ + const SizedBox(height: 8), + Text( + "Quote expires: ${details.quote!.expiration.toLocal()}", + style: STextStyles.label(context), + ), + ], + ], + ), + ); + } + + Widget _recipientCard(OpenCryptoPayRecipient recipient) { + return RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Recipient", style: STextStyles.itemSubtitle12(context)), + if (recipient.name != null) ...[ + const SizedBox(height: 4), + Text(recipient.name!, style: STextStyles.titleBold12(context)), + ], + if (recipient.formattedAddress.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + recipient.formattedAddress, + style: STextStyles.itemSubtitle(context), + ), + ], + ], + ), + ); + } + + Widget _amountCard(OpenCryptoPayPaymentDetails details) { + return RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Amount Due", style: STextStyles.itemSubtitle12(context)), + const SizedBox(height: 4), + Text( + "${details.requestedAmount!.amount} ${details.requestedAmount!.asset}", + style: STextStyles.pageTitleH2(context), + ), + if (details.displayName != null) ...[ + const SizedBox(height: 4), + Text( + details.displayName!, + style: STextStyles.itemSubtitle(context), + ), + ], + ], + ), + ); + } + + Widget _methodCard( + OpenCryptoPayTransferMethod method, + OpenCryptoPayAsset asset, + ) { + return GestureDetector( + onTap: () => _onSelected(method, asset), + child: RoundedWhiteContainer( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${asset.amount} ${asset.asset}", + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 2), + Text( + "via ${method.method}", + style: STextStyles.itemSubtitle(context), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: Theme.of(context) + .extension()! + .textFieldDefaultSearchIconLeft, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index dfd6c98bd..9ea6e4ca9 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 94b5663c8..0d4a646b7 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -29,6 +29,7 @@ import '../../providers/ui/fee_rate_type_state_provider.dart'; import '../../providers/ui/preview_tx_button_state_provider.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; +import '../../services/open_crypto_pay/lnurl_utils.dart'; import '../../services/spark_names_service.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; @@ -81,6 +82,7 @@ import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../address_book_views/address_book_view.dart'; import '../coin_control/coin_control_view.dart'; +import '../open_crypto_pay/open_crypto_pay_view.dart'; import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/dual_balance_selection_sheet.dart'; @@ -296,6 +298,21 @@ class _SendViewState extends ConsumerState { Logging.instance.d("qrResult content: ${qrResult.rawContent}"); if (qrResult.rawContent == null) return; + // Check for OpenCryptoPay QR code. + if (LnurlUtils.isOpenCryptoPayUrl(qrResult.rawContent!)) { + if (mounted) { + await Navigator.of(context).pushNamed( + OpenCryptoPayView.routeName, + arguments: ( + qrUrl: qrResult.rawContent!, + walletId: walletId, + coin: coin, + ), + ); + } + return; + } + final paymentData = AddressUtils.parsePaymentUri( qrResult.rawContent!, logging: Logging.instance, @@ -1074,6 +1091,7 @@ class _SendViewState extends ConsumerState { walletId: walletId, isPaynymTransaction: isPaynymSend, onSuccess: clearSendForm, + openCryptoPayCommit: _data?.openCryptoPayCommit, ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 74a129efd..3d42f3dd3 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -34,6 +34,7 @@ import '../../services/event_bus/events/global/node_connection_status_changed_ev import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../services/event_bus/global_event_bus.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; +import '../../services/open_crypto_pay/lnurl_utils.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; @@ -71,6 +72,7 @@ import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/custom_loading_overlay.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/frost_scaffold.dart'; +import '../../widgets/icon_widgets/qrcode_icon.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/small_tor_icon.dart'; import '../../widgets/stack_dialog.dart'; @@ -98,6 +100,7 @@ import '../masternodes/masternodes_home_view.dart'; import '../monkey/monkey_view.dart'; import '../namecoin_names/namecoin_names_home_view.dart'; import '../notification_views/notifications_view.dart'; +import '../open_crypto_pay/open_crypto_pay_view.dart'; import '../ordinals/ordinals_view.dart'; import '../paynym/paynym_claim_view.dart'; import '../paynym/paynym_home_view.dart'; @@ -425,6 +428,51 @@ class _WalletViewState extends ConsumerState { } } + Future _onOpenCryptoPayPressed(BuildContext context) async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + + final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + + if (qrResult.rawContent == null) return; + + if (LnurlUtils.isOpenCryptoPayUrl(qrResult.rawContent!)) { + if (mounted) { + unawaited( + Navigator.of(context).pushNamed( + OpenCryptoPayView.routeName, + arguments: ( + qrUrl: qrResult.rawContent!, + walletId: walletId, + coin: coin, + ), + ), + ); + } + } else { + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "The scanned QR code is not an Open CryptoPay payment code.", + context: context, + ), + ); + } + } + } catch (e, s) { + Logging.instance.e( + "Failed to scan QR for OpenCryptoPay", + error: e, + stackTrace: s, + ); + } + } + Future attemptAnonymize() async { bool shouldPop = false; unawaited( @@ -1343,6 +1391,12 @@ class _WalletViewState extends ConsumerState { ); }, ), + if (!viewOnly) + WalletNavigationBarItemData( + label: "Pay", + icon: const QrCodeIcon(), + onTap: () => _onOpenCryptoPayPressed(context), + ), ], ), ), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index cad05cbcd..753dcec0c 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 000000000..ec8977e4b --- /dev/null +++ b/lib/services/open_crypto_pay/lnurl_utils.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:bech32/bech32.dart'; + +/// LNURL (LUD-01) helpers scoped to Open CryptoPay QR handling. +/// +/// Stack does not support Lightning in general — this lives under +/// `services/open_crypto_pay/` because OCP is currently the sole consumer. +/// If broader LNURL support is ever added, promote this to `utilities/`. +class LnurlUtils { + /// Decodes a bech32-encoded LNURL string back to a URL. + static String decodeLnurl(String lnurl) { + final decoded = const Bech32Codec().decode(lnurl, lnurl.length); + return utf8.decode(_fromBase32(decoded.data)); + } + + /// Returns true if [url] is an Open CryptoPay QR payload, i.e. has a + /// `lightning` query parameter containing a bech32 LNURL. + static bool isOpenCryptoPayUrl(String url) { + return extractLnurl(url)?.toUpperCase().startsWith('LNURL') ?? false; + } + + /// Returns the `lightning` query parameter, if any. + static String? extractLnurl(String url) { + try { + return Uri.parse(url).queryParameters['lightning']; + } catch (_) { + return null; + } + } + + /// Regroups 5-bit bech32 data into 8-bit bytes. + static List _fromBase32(List data) { + int acc = 0; + int bits = 0; + final result = []; + for (final value in data) { + if (value < 0 || (value >> 5) != 0) { + throw const FormatException('Invalid bech32 data'); + } + acc = (acc << 5) | value; + bits += 5; + while (bits >= 8) { + bits -= 8; + result.add((acc >> bits) & 0xff); + } + } + if (bits >= 5 || ((acc << (8 - bits)) & 0xff) != 0) { + throw const FormatException('Invalid bech32 padding'); + } + return result; + } +} diff --git a/lib/services/open_crypto_pay/models.dart b/lib/services/open_crypto_pay/models.dart new file mode 100644 index 000000000..61939e53d --- /dev/null +++ b/lib/services/open_crypto_pay/models.dart @@ -0,0 +1,194 @@ +/// Data models for the Open CryptoPay standard. +/// +/// See https://github.com/openCryptoPay/landingPage + +class OpenCryptoPayRecipient { + final String? name; + final String? street; + final String? houseNumber; + final String? zip; + final String? city; + final String? country; + + OpenCryptoPayRecipient({ + this.name, + this.street, + this.houseNumber, + this.zip, + this.city, + this.country, + }); + + factory OpenCryptoPayRecipient.fromJson(Map json) { + final address = json['address'] as Map?; + return OpenCryptoPayRecipient( + name: json['name'] as String?, + street: address?['street'] as String?, + houseNumber: address?['houseNumber'] as String?, + zip: address?['zip'] as String?, + city: address?['city'] as String?, + country: address?['country'] as String?, + ); + } + + String get formattedAddress { + final parts = []; + if (street != null) { + parts.add(houseNumber != null ? '$street $houseNumber' : street!); + } + if (zip != null || city != null) { + parts.add([zip, city].whereType().join(' ')); + } + if (country != null) parts.add(country!); + return parts.join(', '); + } +} + +class OpenCryptoPayAsset { + final String asset; + final String amount; + + OpenCryptoPayAsset({required this.asset, required this.amount}); + + factory OpenCryptoPayAsset.fromJson(Map json) { + return OpenCryptoPayAsset( + asset: json['asset'] as String, + amount: json['amount'].toString(), + ); + } +} + +class OpenCryptoPayTransferMethod { + final String method; + final List assets; + final bool available; + + OpenCryptoPayTransferMethod({ + required this.method, + required this.assets, + required this.available, + }); + + factory OpenCryptoPayTransferMethod.fromJson(Map json) { + return OpenCryptoPayTransferMethod( + method: json['method'] as String, + assets: (json['assets'] as List) + .map((e) => OpenCryptoPayAsset.fromJson(e as Map)) + .toList(), + available: json['available'] as bool, + ); + } +} + +class OpenCryptoPayQuote { + final String id; + final DateTime expiration; + + OpenCryptoPayQuote({required this.id, required this.expiration}); + + factory OpenCryptoPayQuote.fromJson(Map json) { + return OpenCryptoPayQuote( + id: json['id'] as String, + expiration: DateTime.parse(json['expiration'] as String), + ); + } + + bool get isExpired => expiration.isBefore(DateTime.now()); +} + +class OpenCryptoPayRequestedAmount { + final String asset; + final num amount; + + OpenCryptoPayRequestedAmount({required this.asset, required this.amount}); + + factory OpenCryptoPayRequestedAmount.fromJson(Map json) { + return OpenCryptoPayRequestedAmount( + asset: json['asset'] as String, + amount: json['amount'] as num, + ); + } +} + +class OpenCryptoPayPaymentDetails { + final String id; + final String? displayName; + final String callback; + final OpenCryptoPayRecipient? recipient; + final OpenCryptoPayQuote? quote; + final OpenCryptoPayRequestedAmount? requestedAmount; + final List transferAmounts; + + OpenCryptoPayPaymentDetails({ + required this.id, + this.displayName, + required this.callback, + this.recipient, + this.quote, + this.requestedAmount, + required this.transferAmounts, + }); + + factory OpenCryptoPayPaymentDetails.fromJson(Map json) { + return OpenCryptoPayPaymentDetails( + id: json['id'] as String, + displayName: json['displayName'] as String?, + callback: json['callback'] as String? ?? '', + recipient: json['recipient'] == null + ? null + : OpenCryptoPayRecipient.fromJson( + json['recipient'] as Map, + ), + quote: json['quote'] == null + ? null + : OpenCryptoPayQuote.fromJson(json['quote'] as Map), + requestedAmount: json['requestedAmount'] == null + ? null + : OpenCryptoPayRequestedAmount.fromJson( + json['requestedAmount'] as Map, + ), + transferAmounts: (json['transferAmounts'] as List?) + ?.map( + (e) => OpenCryptoPayTransferMethod.fromJson( + e as Map, + ), + ) + .toList() ?? + [], + ); + } + + /// Methods that are available and have at least one asset. + List get availableMethods => + transferAmounts.where((m) => m.available && m.assets.isNotEmpty).toList(); +} + +class OpenCryptoPayTransactionDetails { + final String? uri; + final String? hint; + + OpenCryptoPayTransactionDetails({this.uri, this.hint}); + + factory OpenCryptoPayTransactionDetails.fromJson(Map json) { + return OpenCryptoPayTransactionDetails( + uri: json['uri'] as String?, + hint: json['hint'] as String?, + ); + } +} + +/// Context required to notify the provider of a broadcast transaction via +/// the `/tx/` endpoint (derived from the payment details callback URL). +class OpenCryptoPayCommit { + final String callbackUrl; + final String quoteId; + final String method; + final String asset; + + const OpenCryptoPayCommit({ + required this.callbackUrl, + required this.quoteId, + required this.method, + required this.asset, + }); +} diff --git a/lib/services/open_crypto_pay/open_crypto_pay_api.dart b/lib/services/open_crypto_pay/open_crypto_pay_api.dart new file mode 100644 index 000000000..ac5aa3288 --- /dev/null +++ b/lib/services/open_crypto_pay/open_crypto_pay_api.dart @@ -0,0 +1,175 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../../app_config.dart'; +import '../../networking/http.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/prefs.dart'; +import '../tor_service.dart'; +import 'lnurl_utils.dart'; +import 'models.dart'; + +/// Client for the Open CryptoPay standard. +/// +/// See https://github.com/openCryptoPay/landingPage +class OpenCryptoPayApi { + OpenCryptoPayApi._(); + + static final OpenCryptoPayApi instance = OpenCryptoPayApi._(); + + final HTTP _client = const HTTP(); + + static const Duration _httpTimeout = Duration(seconds: 15); + + ({InternetAddress host, int port})? get _proxyInfo => + AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null; + + /// Throws if [uri] is not an absolute https URL. LUD-01 mandates HTTPS; + /// rejecting plain http also closes off MITM and SSRF-into-loopback risks + /// from a malicious QR. + void _requireHttps(Uri uri, String label) { + if (uri.scheme != 'https' || !uri.hasAuthority) { + throw Exception('OpenCryptoPay: $label must be an https URL'); + } + } + + /// Fetches the payment details (available methods, quote, recipient, etc) + /// for the payment encoded in [qrUrl]. + Future getPaymentDetails( + String qrUrl, { + int timeout = 10, + }) async { + final lnurl = LnurlUtils.extractLnurl(qrUrl); + if (lnurl == null) { + throw Exception('No lightning parameter found in URL'); + } + + final apiUrl = Uri.parse(LnurlUtils.decodeLnurl(lnurl)); + _requireHttps(apiUrl, 'decoded LNURL'); + final uri = apiUrl.replace( + queryParameters: { + ...apiUrl.queryParameters, + 'timeout': timeout.toString(), + }, + ); + + Logging.instance.d('OpenCryptoPay: GET $uri'); + final response = await _client.get( + url: uri, + proxyInfo: _proxyInfo, + connectionTimeout: _httpTimeout, + ); + + if (response.code == 404) { + String message = 'No pending payment found'; + try { + final json = jsonDecode(response.body) as Map; + message = json['message'] as String? ?? message; + } catch (_) {} + throw OpenCryptoPayNoPendingPaymentException(message); + } + if (response.code != 200) { + throw Exception('OpenCryptoPay ${response.code}: ${response.body}'); + } + + final details = OpenCryptoPayPaymentDetails.fromJson( + jsonDecode(response.body) as Map, + ); + + // Pin all subsequent calls (callback fetch + commit) to the same host as + // the LNURL we already trusted. Otherwise a malicious provider response + // could redirect the txid + raw hex to an attacker-controlled host. + final callback = Uri.tryParse(details.callback); + if (callback == null) { + throw Exception('OpenCryptoPay: invalid callback URL'); + } + _requireHttps(callback, 'callback'); + if (callback.host != apiUrl.host) { + throw Exception( + 'OpenCryptoPay: callback host ${callback.host} does not match ' + 'LNURL host ${apiUrl.host}', + ); + } + + return details; + } + + /// Fetches the transaction details (payment address URI) for the chosen + /// [method] and [asset]. + Future getTransactionDetails({ + required String callbackUrl, + required String quoteId, + required String method, + required String asset, + }) async { + final base = Uri.parse(callbackUrl); + _requireHttps(base, 'callback'); + final uri = base.replace( + queryParameters: { + ...base.queryParameters, + 'quote': quoteId, + 'method': method, + 'asset': asset, + }, + ); + + Logging.instance.d('OpenCryptoPay: GET $uri'); + final response = await _client.get( + url: uri, + proxyInfo: _proxyInfo, + connectionTimeout: _httpTimeout, + ); + + if (response.code != 200) { + throw Exception('OpenCryptoPay ${response.code}: ${response.body}'); + } + + return OpenCryptoPayTransactionDetails.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Notifies the provider of a signed (and broadcast) transaction so the + /// merchant-side can settle the payment. The `/tx/` endpoint is derived + /// from the payment details callback URL. + Future commit({ + required OpenCryptoPayCommit commit, + required String txId, + String? hex, + }) async { + final base = Uri.parse(commit.callbackUrl.replaceAll('/cb/', '/tx/')); + _requireHttps(base, 'commit endpoint'); + final uri = base.replace( + queryParameters: { + ...base.queryParameters, + 'quote': commit.quoteId, + 'method': commit.method, + 'asset': commit.asset, + 'tx': txId, + if (hex != null && hex.isNotEmpty) 'hex': hex, + }, + ); + + Logging.instance.d('OpenCryptoPay: GET $uri'); + final response = await _client.get( + url: uri, + proxyInfo: _proxyInfo, + connectionTimeout: _httpTimeout, + ); + if (response.code != 200) { + throw Exception( + 'OpenCryptoPay commit ${response.code}: ${response.body}', + ); + } + } +} + +class OpenCryptoPayNoPendingPaymentException implements Exception { + final String message; + OpenCryptoPayNoPendingPaymentException(this.message); + + @override + String toString() => message; +}