From a79f575bd1b1b64123a464c0da6d27cd16516987 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Fri, 12 Jul 2024 18:39:01 +0300 Subject: [PATCH] Handle LNURL's (#38) * Remove nodeID from valid input type list when pasted * Handle LNURL-Pay * Handle LNURL-Withdraw * Handle LNURL-Auth * Wait for input to be parsed * Fix title of destination pubkey * Move LnUrl API's to LnUrlBloc * Validate LnUrl payments against lightning limits * Rename isLnurlPayment to isLnUrlPayment for consistency of capitalization of LnUrl --- lib/bloc/lnurl/lnurl_bloc.dart | 56 ++++ lib/bloc/lnurl/lnurl_state.dart | 17 ++ lib/handlers/input_handler.dart | 9 + lib/main.dart | 4 + .../create_invoice/create_invoice_page.dart | 59 +++- .../enter_payment_info_dialog.dart | 5 +- ...ent_details_dialog_destination_pubkey.dart | 24 +- lib/routes/lnurl/auth/lnurl_auth_handler.dart | 69 +++++ lib/routes/lnurl/auth/login_text.dart | 30 ++ lib/routes/lnurl/lnurl_invoice_delegate.dart | 26 ++ .../lnurl/payment/lnurl_payment_dialog.dart | 163 +++++++++++ .../lnurl/payment/lnurl_payment_handler.dart | 122 ++++++++ .../lnurl/payment/lnurl_payment_info.dart | 9 + .../lnurl/payment/lnurl_payment_page.dart | 271 ++++++++++++++++++ .../success_action/success_action_dialog.dart | 105 +++++++ lib/routes/lnurl/widgets/lnurl_metadata.dart | 56 ++++ .../lnurl/widgets/lnurl_page_result.dart | 43 +++ .../lnurl/withdraw/lnurl_withdraw_dialog.dart | 184 ++++++++++++ .../withdraw/lnurl_withdraw_handler.dart | 56 ++++ .../processing_payment_dialog.dart | 8 +- 20 files changed, 1291 insertions(+), 25 deletions(-) create mode 100644 lib/bloc/lnurl/lnurl_bloc.dart create mode 100644 lib/bloc/lnurl/lnurl_state.dart create mode 100644 lib/routes/lnurl/auth/lnurl_auth_handler.dart create mode 100644 lib/routes/lnurl/auth/login_text.dart create mode 100644 lib/routes/lnurl/lnurl_invoice_delegate.dart create mode 100644 lib/routes/lnurl/payment/lnurl_payment_dialog.dart create mode 100644 lib/routes/lnurl/payment/lnurl_payment_handler.dart create mode 100644 lib/routes/lnurl/payment/lnurl_payment_info.dart create mode 100644 lib/routes/lnurl/payment/lnurl_payment_page.dart create mode 100644 lib/routes/lnurl/payment/success_action/success_action_dialog.dart create mode 100644 lib/routes/lnurl/widgets/lnurl_metadata.dart create mode 100644 lib/routes/lnurl/widgets/lnurl_page_result.dart create mode 100644 lib/routes/lnurl/withdraw/lnurl_withdraw_dialog.dart create mode 100644 lib/routes/lnurl/withdraw/lnurl_withdraw_handler.dart diff --git a/lib/bloc/lnurl/lnurl_bloc.dart b/lib/bloc/lnurl/lnurl_bloc.dart new file mode 100644 index 00000000..20aa86fe --- /dev/null +++ b/lib/bloc/lnurl/lnurl_bloc.dart @@ -0,0 +1,56 @@ +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:l_breez/bloc/account/breez_sdk_liquid.dart'; +import 'package:l_breez/bloc/lnurl/lnurl_state.dart'; +import 'package:logging/logging.dart'; + +class LnUrlBloc extends Cubit { + final _log = Logger("LnUrlBloc"); + final BreezSDKLiquid _liquidSdk; + + LnUrlBloc(this._liquidSdk) : super(LnUrlState.initial()); + + Future fetchLightningLimits() async { + try { + final limits = await _liquidSdk.instance!.fetchLightningLimits(); + emit(state.copyWith(limits: limits)); + return limits; + } catch (e) { + _log.severe("fetchLightningLimits error", e); + rethrow; + } + } + + Future lnurlWithdraw({ + required LnUrlWithdrawRequest req, + }) async { + try { + return await _liquidSdk.instance!.lnurlWithdraw(req: req); + } catch (e) { + _log.severe("lnurlWithdraw error", e); + rethrow; + } + } + + Future lnurlPay({ + required LnUrlPayRequest req, + }) async { + try { + return await _liquidSdk.instance!.lnurlPay(req: req); + } catch (e) { + _log.severe("lnurlPay error", e); + rethrow; + } + } + + Future lnurlAuth({ + required LnUrlAuthRequestData reqData, + }) async { + try { + return await _liquidSdk.instance!.lnurlAuth(reqData: reqData); + } catch (e) { + _log.severe("lnurlAuth error", e); + rethrow; + } + } +} diff --git a/lib/bloc/lnurl/lnurl_state.dart b/lib/bloc/lnurl/lnurl_state.dart new file mode 100644 index 00000000..c01d515c --- /dev/null +++ b/lib/bloc/lnurl/lnurl_state.dart @@ -0,0 +1,17 @@ +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; + +class LnUrlState { + final LightningPaymentLimitsResponse? limits; + + LnUrlState({this.limits}); + + LnUrlState.initial() : this(); + + LnUrlState copyWith({ + LightningPaymentLimitsResponse? limits, + }) { + return LnUrlState( + limits: limits ?? this.limits, + ); + } +} diff --git a/lib/handlers/input_handler.dart b/lib/handlers/input_handler.dart index 94fce55d..d0f9d8de 100644 --- a/lib/handlers/input_handler.dart +++ b/lib/handlers/input_handler.dart @@ -9,6 +9,9 @@ import 'package:l_breez/bloc/input/input_state.dart'; import 'package:l_breez/handlers/handler.dart'; import 'package:l_breez/handlers/handler_context_provider.dart'; import 'package:l_breez/models/invoice.dart'; +import 'package:l_breez/routes/lnurl/auth/lnurl_auth_handler.dart'; +import 'package:l_breez/routes/lnurl/payment/lnurl_payment_handler.dart'; +import 'package:l_breez/routes/lnurl/withdraw/lnurl_withdraw_handler.dart'; import 'package:l_breez/utils/exceptions.dart'; import 'package:l_breez/widgets/flushbar.dart'; import 'package:l_breez/widgets/loader.dart'; @@ -85,6 +88,12 @@ class InputHandler extends Handler { if (inputState is InvoiceInputState) { return handleInvoice(context, inputState.invoice); + } else if (inputState is LnUrlPayInputState) { + handlePayRequest(context, firstPaymentItemKey, inputState.data); + } else if (inputState is LnUrlWithdrawInputState) { + handleWithdrawRequest(context, inputState.data); + } else if (inputState is LnUrlAuthInputState) { + handleAuthRequest(context, inputState.data); } else if (inputState is LnUrlErrorInputState) { throw inputState.data.reason; } else if (inputState is BitcoinAddressInputState) { diff --git a/lib/main.dart b/lib/main.dart index eb183691..d5e3e7c1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'package:l_breez/bloc/account/credentials_manager.dart'; import 'package:l_breez/bloc/backup/backup_bloc.dart'; import 'package:l_breez/bloc/currency/currency_bloc.dart'; import 'package:l_breez/bloc/input/input_bloc.dart'; +import 'package:l_breez/bloc/lnurl/lnurl_bloc.dart'; import 'package:l_breez/bloc/security/security_bloc.dart'; import 'package:l_breez/bloc/user_profile/user_profile_bloc.dart'; import 'package:l_breez/services/injector.dart'; @@ -86,6 +87,9 @@ void main() async { BlocProvider( create: (BuildContext context) => BackupBloc(injector.liquidSDK), ), + BlocProvider( + create: (BuildContext context) => LnUrlBloc(injector.liquidSDK), + ), ], child: UserApp(), ), diff --git a/lib/routes/create_invoice/create_invoice_page.dart b/lib/routes/create_invoice/create_invoice_page.dart index 7b34accd..7e47e625 100644 --- a/lib/routes/create_invoice/create_invoice_page.dart +++ b/lib/routes/create_invoice/create_invoice_page.dart @@ -3,11 +3,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_breez_liquid/flutter_breez_liquid.dart' as liquid_sdk; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; import 'package:l_breez/bloc/account/account_bloc.dart'; import 'package:l_breez/bloc/currency/currency_bloc.dart'; import 'package:l_breez/bloc/currency/currency_state.dart'; import 'package:l_breez/routes/create_invoice/qr_code_dialog.dart'; import 'package:l_breez/routes/create_invoice/widgets/successful_payment.dart'; +import 'package:l_breez/routes/lnurl/widgets/lnurl_page_result.dart'; +import 'package:l_breez/routes/lnurl/withdraw/lnurl_withdraw_dialog.dart'; import 'package:l_breez/theme/theme_provider.dart' as theme; import 'package:l_breez/utils/payment_validator.dart'; import 'package:l_breez/widgets/amount_form_field/amount_form_field.dart'; @@ -21,7 +24,14 @@ import 'package:logging/logging.dart'; final _log = Logger("CreateInvoicePage"); class CreateInvoicePage extends StatefulWidget { - const CreateInvoicePage({super.key}); + final Function(LNURLPageResult? result)? onFinish; + final LnUrlWithdrawRequestData? requestData; + + const CreateInvoicePage({super.key, this.onFinish, this.requestData}) + : assert( + requestData == null || (onFinish != null), + "If you are using LNURL withdraw, you must provide an onFinish callback.", + ); @override State createState() { @@ -41,6 +51,20 @@ class CreateInvoicePageState extends State { void initState() { super.initState(); _doneAction = KeyboardDoneAction(focusNodes: [_amountFocusNode]); + + WidgetsBinding.instance.addPostFrameCallback( + (_) { + final data = widget.requestData; + if (data != null) { + final currencyState = context.read().state; + _amountController.text = currencyState.bitcoinCurrency.format( + data.maxWithdrawable.toInt() ~/ 1000, + includeDisplayName: false, + ); + _descriptionController.text = data.defaultDescription; + } + }, + ); } @override @@ -102,16 +126,45 @@ class CreateInvoicePageState extends State { ), bottomNavigationBar: SingleButtonBottomBar( stickToBottom: true, - text: texts.invoice_action_create, + text: widget.requestData != null ? texts.invoice_action_redeem : texts.invoice_action_create, onPressed: () { if (_formKey.currentState?.validate() ?? false) { - _createInvoice(); + final data = widget.requestData; + if (data != null) { + _withdraw(data); + } else { + _createInvoice(); + } } }, ), ); } + Future _withdraw( + LnUrlWithdrawRequestData data, + ) async { + _log.info("Withdraw request: description=${data.defaultDescription}, k1=${data.k1}, " + "min=${data.minWithdrawable}, max=${data.maxWithdrawable}"); + final CurrencyBloc currencyBloc = context.read(); + + final navigator = Navigator.of(context); + navigator.pop(); + + showDialog( + useRootNavigator: false, + context: context, + barrierDismissible: false, + builder: (_) => LNURLWithdrawDialog( + requestData: data, + amountSats: currencyBloc.state.bitcoinCurrency.parse( + _amountController.text, + ), + onFinish: widget.onFinish!, + ), + ); + } + Future _createInvoice() async { _log.info("Create invoice: description=${_descriptionController.text}, amount=${_amountController.text}"); final navigator = Navigator.of(context); diff --git a/lib/routes/home/widgets/bottom_actions_bar/enter_payment_info_dialog.dart b/lib/routes/home/widgets/bottom_actions_bar/enter_payment_info_dialog.dart index 20a83e1f..54dabc6b 100644 --- a/lib/routes/home/widgets/bottom_actions_bar/enter_payment_info_dialog.dart +++ b/lib/routes/home/widgets/bottom_actions_bar/enter_payment_info_dialog.dart @@ -208,15 +208,14 @@ class EnterPaymentInfoDialogState extends State { final texts = context.texts(); try { _setValidatorErrorMessage(""); - final inputType = context.read().parseInput(input: input); + final inputType = await context.read().parseInput(input: input); _log.info("Parsed input type: '${inputType.runtimeType.toString()}"); // Can't compare against a list of InputType as runtime type comparison is a bit tricky with binding generated enums if (!(inputType is InputType_Bolt11 || inputType is InputType_LnUrlPay || inputType is InputType_LnUrlWithdraw || inputType is InputType_LnUrlAuth || - inputType is InputType_LnUrlError || - inputType is InputType_NodeId)) { + inputType is InputType_LnUrlError)) { _setValidatorErrorMessage(texts.payment_info_dialog_error_unsupported_input); } } catch (e) { diff --git a/lib/routes/home/widgets/payments_list/dialog/payment_details_dialog_destination_pubkey.dart b/lib/routes/home/widgets/payments_list/dialog/payment_details_dialog_destination_pubkey.dart index 537953fb..3e5c8e94 100644 --- a/lib/routes/home/widgets/payments_list/dialog/payment_details_dialog_destination_pubkey.dart +++ b/lib/routes/home/widgets/payments_list/dialog/payment_details_dialog_destination_pubkey.dart @@ -1,27 +1,21 @@ -import 'package:breez_translations/breez_translations_locales.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:l_breez/models/payment_minutiae.dart'; import 'package:l_breez/routes/home/widgets/payments_list/dialog/shareable_payment_row.dart'; class PaymentDetailsDestinationPubkey extends StatelessWidget { final PaymentMinutiae paymentMinutiae; - const PaymentDetailsDestinationPubkey({ - super.key, - required this.paymentMinutiae, - }); + const PaymentDetailsDestinationPubkey({required this.paymentMinutiae, super.key}); @override Widget build(BuildContext context) { - final texts = context.texts(); final destinationPubkey = paymentMinutiae.swapId; - if (destinationPubkey.isNotEmpty) { - return ShareablePaymentRow( - title: texts.payment_details_dialog_single_info_node_id, - sharedValue: destinationPubkey, - ); - } else { - return Container(); - } + return destinationPubkey.isNotEmpty + ? ShareablePaymentRow( + // TODO: Move this message to Breez-Translations + title: "Swap ID", + sharedValue: destinationPubkey, + ) + : const SizedBox.shrink(); } } diff --git a/lib/routes/lnurl/auth/lnurl_auth_handler.dart b/lib/routes/lnurl/auth/lnurl_auth_handler.dart new file mode 100644 index 00000000..7d4fb052 --- /dev/null +++ b/lib/routes/lnurl/auth/lnurl_auth_handler.dart @@ -0,0 +1,69 @@ +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/bloc/lnurl/lnurl_bloc.dart'; +import 'package:l_breez/routes/lnurl/auth/login_text.dart'; +import 'package:l_breez/routes/lnurl/widgets/lnurl_page_result.dart'; +import 'package:l_breez/widgets/error_dialog.dart'; +import 'package:l_breez/widgets/loader.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger("HandleLNURLAuthRequest"); + +Future handleAuthRequest( + BuildContext context, + LnUrlAuthRequestData reqData, +) async { + return promptAreYouSure(context, null, LoginText(domain: reqData.domain)).then( + (permitted) async { + if (permitted == true) { + final texts = context.texts(); + final navigator = Navigator.of(context); + final loaderRoute = createLoaderRoute(context); + navigator.push(loaderRoute); + try { + final lnurlBloc = context.read(); + final resp = await lnurlBloc.lnurlAuth(reqData: reqData); + if (resp is LnUrlCallbackStatus_Ok) { + _log.info("LNURL auth success"); + return const LNURLPageResult(protocol: LnUrlProtocol.Auth); + } else if (resp is LnUrlCallbackStatus_ErrorStatus) { + _log.info("LNURL auth failed: ${resp.data.reason}"); + return LNURLPageResult(protocol: LnUrlProtocol.Auth, error: resp.data.reason); + } else { + _log.warning("Unknown response from lnurlAuth: $resp"); + return LNURLPageResult( + protocol: LnUrlProtocol.Auth, + error: texts.lnurl_payment_page_unknown_error, + ); + } + } catch (e) { + _log.warning("Error authenticating LNURL auth", e); + if (loaderRoute.isActive) { + navigator.removeRoute(loaderRoute); + } + return LNURLPageResult(protocol: LnUrlProtocol.Auth, error: e); + } finally { + if (loaderRoute.isActive) { + navigator.removeRoute(loaderRoute); + } + } + } + return Future.value(); + }, + ); +} + +void handleLNURLAuthPageResult(BuildContext context, LNURLPageResult result) { + if (result.hasError) { + _log.info("Handle LNURL auth page result with error '${result.error}'"); + promptError( + context, + context.texts().lnurl_webview_error_title, + Text(result.errorMessage), + okFunc: () => Navigator.of(context).pop(), + ); + throw result.error!; + } +} diff --git a/lib/routes/lnurl/auth/login_text.dart b/lib/routes/lnurl/auth/login_text.dart new file mode 100644 index 00000000..68108912 --- /dev/null +++ b/lib/routes/lnurl/auth/login_text.dart @@ -0,0 +1,30 @@ +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter/material.dart'; + +class LoginText extends StatelessWidget { + final String domain; + + const LoginText({super.key, required this.domain}); + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + final themeData = Theme.of(context); + return RichText( + text: TextSpan( + style: themeData.dialogTheme.contentTextStyle, + text: texts.handler_lnurl_login_anonymously, + children: [ + TextSpan( + text: domain, + style: themeData.dialogTheme.contentTextStyle!.copyWith(fontWeight: FontWeight.bold), + ), + TextSpan( + text: "?", + style: themeData.dialogTheme.contentTextStyle, + ), + ], + ), + ); + } +} diff --git a/lib/routes/lnurl/lnurl_invoice_delegate.dart b/lib/routes/lnurl/lnurl_invoice_delegate.dart new file mode 100644 index 00000000..952a397c --- /dev/null +++ b/lib/routes/lnurl/lnurl_invoice_delegate.dart @@ -0,0 +1,26 @@ +import 'package:l_breez/routes/lnurl/auth/lnurl_auth_handler.dart'; +import 'package:l_breez/routes/lnurl/payment/lnurl_payment_handler.dart'; +import 'package:l_breez/routes/lnurl/withdraw/lnurl_withdraw_handler.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +import 'widgets/lnurl_page_result.dart'; + +final _log = Logger("HandleLNURL"); + +void handleLNURLPageResult(BuildContext context, LNURLPageResult result) { + _log.info("handle $result"); + switch (result.protocol) { + case LnUrlProtocol.Pay: + handleLNURLPaymentPageResult(context, result); + break; + case LnUrlProtocol.Withdraw: + handleLNURLWithdrawPageResult(context, result); + break; + case LnUrlProtocol.Auth: + handleLNURLAuthPageResult(context, result); + break; + default: + break; + } +} diff --git a/lib/routes/lnurl/payment/lnurl_payment_dialog.dart b/lib/routes/lnurl/payment/lnurl_payment_dialog.dart new file mode 100644 index 00000000..617e09c4 --- /dev/null +++ b/lib/routes/lnurl/payment/lnurl_payment_dialog.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; + +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/bloc/currency/currency_bloc.dart'; +import 'package:l_breez/models/currency.dart'; +import 'package:l_breez/routes/lnurl/payment/lnurl_payment_info.dart'; +import 'package:l_breez/utils/fiat_conversion.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger("LNURLPaymentDialog"); + +class LNURLPaymentDialog extends StatefulWidget { + final LnUrlPayRequestData data; + + const LNURLPaymentDialog({ + required this.data, + super.key, + }); + + @override + State createState() { + return LNURLPaymentDialogState(); + } +} + +class LNURLPaymentDialogState extends State { + bool _showFiatCurrency = false; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + final texts = context.texts(); + final currencyState = context.read().state; + final metadataMap = { + for (var v in json.decode(widget.data.metadataStr)) v[0] as String: v[1], + }; + final description = metadataMap['text/long-desc'] ?? metadataMap['text/plain']; + FiatConversion? fiatConversion; + if (currencyState.fiatEnabled) { + fiatConversion = FiatConversion( + currencyState.fiatCurrency!, + currencyState.fiatExchangeRate!, + ); + } + + return AlertDialog( + title: Text( + Uri.parse(widget.data.callback).host, + style: themeData.primaryTextTheme.headlineMedium!.copyWith(fontSize: 16), + textAlign: TextAlign.center, + ), + content: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + texts.payment_request_dialog_requesting, + style: themeData.primaryTextTheme.displaySmall!.copyWith(fontSize: 16), + textAlign: TextAlign.center, + ), + GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPressStart: (_) { + setState(() { + _showFiatCurrency = true; + }); + }, + onLongPressEnd: (_) { + setState(() { + _showFiatCurrency = false; + }); + }, + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: double.infinity, + ), + child: Text( + _showFiatCurrency && fiatConversion != null + ? fiatConversion.format(widget.data.maxSendable.toInt() ~/ 1000) + : BitcoinCurrency.fromTickerSymbol(currencyState.bitcoinTicker) + .format(widget.data.maxSendable.toInt() ~/ 1000), + style: themeData.primaryTextTheme.headlineSmall, + textAlign: TextAlign.center, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0, left: 16.0, right: 16.0), + child: Container( + constraints: const BoxConstraints( + maxHeight: 200, + minWidth: double.infinity, + ), + child: Scrollbar( + child: SingleChildScrollView( + child: Text( + description, + style: themeData.primaryTextTheme.displaySmall!.copyWith( + fontSize: 16, + ), + textAlign: description.length > 40 && !description.contains("\n") + ? TextAlign.start + : TextAlign.center, + ), + ), + ), + ), + ), + ], + ), + actions: [ + TextButton( + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return Colors.transparent; + } + // Defer to the widget's default. + return themeData.textTheme.labelLarge!.color!; + }), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + texts.payment_request_dialog_action_cancel, + style: themeData.primaryTextTheme.labelLarge, + ), + ), + TextButton( + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return Colors.transparent; + } + // Defer to the widget's default. + return themeData.textTheme.labelLarge!.color!; + }), + ), + onPressed: () { + final amount = widget.data.maxSendable.toInt() ~/ 1000; + _log.info("LNURL payment of $amount sats where " + "min is ${widget.data.minSendable} msats " + "and max is ${widget.data.maxSendable} msats."); + Navigator.pop(context, LNURLPaymentInfo(amount: amount)); + }, + child: Text( + texts.spontaneous_payment_action_pay, + style: themeData.primaryTextTheme.labelLarge, + ), + ), + ], + ); + } +} diff --git a/lib/routes/lnurl/payment/lnurl_payment_handler.dart b/lib/routes/lnurl/payment/lnurl_payment_handler.dart new file mode 100644 index 00000000..da2ba227 --- /dev/null +++ b/lib/routes/lnurl/payment/lnurl_payment_handler.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/bloc/lnurl/lnurl_bloc.dart'; +import 'package:l_breez/routes/lnurl/payment/lnurl_payment_dialog.dart'; +import 'package:l_breez/routes/lnurl/payment/lnurl_payment_info.dart'; +import 'package:l_breez/routes/lnurl/payment/lnurl_payment_page.dart'; +import 'package:l_breez/routes/lnurl/payment/success_action/success_action_dialog.dart'; +import 'package:l_breez/routes/lnurl/widgets/lnurl_page_result.dart'; +import 'package:l_breez/widgets/payment_dialogs/processing_payment_dialog.dart'; +import 'package:l_breez/widgets/route.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger("HandleLNURLPayRequest"); + +Future handlePayRequest( + BuildContext context, + GlobalKey firstPaymentItemKey, + LnUrlPayRequestData data, +) async { + LNURLPaymentInfo? paymentInfo; + bool fixedAmount = data.minSendable == data.maxSendable; + if (fixedAmount && !(data.commentAllowed > 0)) { + // Show dialog if payment is of fixed amount with no payer comment allowed + paymentInfo = await showDialog( + useRootNavigator: false, + context: context, + barrierDismissible: false, + builder: (_) => LNURLPaymentDialog(data: data), + ); + } else { + paymentInfo = await Navigator.of(context).push( + FadeInRoute( + builder: (_) => LNURLPaymentPage(data: data), + ), + ); + } + if (paymentInfo == null || !context.mounted) { + return Future.value(); + } + // Show Processing Payment Dialog + return await showDialog( + useRootNavigator: false, + context: context, + barrierDismissible: false, + builder: (_) => ProcessingPaymentDialog( + isLnUrlPayment: true, + firstPaymentItemKey: firstPaymentItemKey, + paymentFunc: () { + final lnurlBloc = context.read(); + final req = LnUrlPayRequest( + amountMsat: BigInt.from(paymentInfo!.amount * 1000), + comment: paymentInfo.comment, + data: data, + ); + return lnurlBloc.lnurlPay(req: req); + }, + ), + ).then((result) { + if (result is LnUrlPayResult) { + if (result is LnUrlPayResult_EndpointSuccess) { + _log.info("LNURL payment success, action: ${result.data}"); + return LNURLPageResult( + protocol: LnUrlProtocol.Pay, + successAction: result.data.successAction, + ); + } else if (result is LnUrlPayResult_PayError) { + _log.info("LNURL payment for ${result.data.paymentHash} failed: ${result.data.reason}"); + return LNURLPageResult( + protocol: LnUrlProtocol.Pay, + error: result.data.reason, + ); + } else if (result is LnUrlPayResult_EndpointError) { + _log.info("LNURL payment failed: ${result.data.reason}"); + return LNURLPageResult( + protocol: LnUrlProtocol.Pay, + error: result.data.reason, + ); + } + } + _log.warning("Error sending LNURL payment", result); + throw LNURLPageResult(error: result).errorMessage; + }); +} + +void handleLNURLPaymentPageResult(BuildContext context, LNURLPageResult result) { + if (result.successAction != null) { + _handleSuccessAction(context, result.successAction!); + } else if (result.hasError) { + _log.info("Handle LNURL payment page result with error '${result.error}'"); + throw Exception(result.errorMessage); + } +} + +Future _handleSuccessAction(BuildContext context, SuccessActionProcessed successAction) { + String message = ''; + String? url; + if (successAction is SuccessActionProcessed_Message) { + message = successAction.data.message; + _log.info("Handle LNURL payment page result with message action '$message'"); + } else if (successAction is SuccessActionProcessed_Url) { + message = successAction.data.description; + url = successAction.data.url; + _log.info("Handle LNURL payment page result with url action '$message', '$url'"); + } else if (successAction is SuccessActionProcessed_Aes) { + final result = successAction.result; + if (result is AesSuccessActionDataResult_Decrypted) { + message = "${result.data.description} ${result.data.plaintext}"; + _log.info("Handle LNURL payment page result with aes action '$message'"); + } else if (result is AesSuccessActionDataResult_ErrorStatus) { + throw Exception(result.reason); + } + } + return showDialog( + useRootNavigator: false, + context: context, + builder: (_) => SuccessActionDialog( + message: message, + url: url, + ), + ); +} diff --git a/lib/routes/lnurl/payment/lnurl_payment_info.dart b/lib/routes/lnurl/payment/lnurl_payment_info.dart new file mode 100644 index 00000000..f5be9669 --- /dev/null +++ b/lib/routes/lnurl/payment/lnurl_payment_info.dart @@ -0,0 +1,9 @@ +class LNURLPaymentInfo { + final int amount; + final String? comment; + + const LNURLPaymentInfo({ + required this.amount, + this.comment, + }); +} diff --git a/lib/routes/lnurl/payment/lnurl_payment_page.dart b/lib/routes/lnurl/payment/lnurl_payment_page.dart new file mode 100644 index 00000000..38554830 --- /dev/null +++ b/lib/routes/lnurl/payment/lnurl_payment_page.dart @@ -0,0 +1,271 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/bloc/account/account_bloc.dart'; +import 'package:l_breez/bloc/currency/currency_bloc.dart'; +import 'package:l_breez/bloc/lnurl/lnurl_bloc.dart'; +import 'package:l_breez/routes/lnurl/payment/lnurl_payment_info.dart'; +import 'package:l_breez/routes/lnurl/widgets/lnurl_metadata.dart'; +import 'package:l_breez/theme/theme_provider.dart' as theme; +import 'package:l_breez/utils/payment_validator.dart'; +import 'package:l_breez/widgets/amount_form_field/amount_form_field.dart'; +import 'package:l_breez/widgets/back_button.dart' as back_button; +import 'package:l_breez/widgets/single_button_bottom_bar.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger("LNURLPaymentPage"); + +class LNURLPaymentPage extends StatefulWidget { + final LnUrlPayRequestData data; + /*TODO: Add domain information to parse results #118(https://github.com/breez/breez-sdk/issues/118) + final String domain; + TODO: Add support for LUD-18: Payer identity in payRequest protocol(https://github.com/breez/breez-sdk/issues/117) + final PayerDataRecordField? name; + final AuthRecord? auth; + final PayerDataRecordField? email; + final PayerDataRecordField? identifier; + */ + + const LNURLPaymentPage({ + required this.data, + /* + required this.domain, + this.name, + this.auth, + this.email, + this.identifier, + */ + + super.key, + }); + + @override + State createState() { + return LNURLPaymentPageState(); + } +} + +class LNURLPaymentPageState extends State { + final _formKey = GlobalKey(); + final _scaffoldKey = GlobalKey(); + final _amountController = TextEditingController(); + final _commentController = TextEditingController(); + /* + final _nameController = TextEditingController(); + final _k1Controller = TextEditingController(); + final _emailController = TextEditingController(); + final _identifierController = TextEditingController(); + */ + late final bool fixedAmount; + + @override + void initState() { + super.initState(); + fixedAmount = widget.data.minSendable == widget.data.maxSendable; + WidgetsBinding.instance.addPostFrameCallback( + (_) async { + if (fixedAmount) { + final currencyState = context.read().state; + _amountController.text = currencyState.bitcoinCurrency.format( + (widget.data.maxSendable.toInt() ~/ 1000), + includeDisplayName: false, + ); + } + final lnurlBloc = context.read(); + await lnurlBloc.fetchLightningLimits(); + }, + ); + } + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + final currencyState = context.read().state; + final metadataMap = { + for (var v in json.decode(widget.data.metadataStr)) v[0] as String: v[1], + }; + String? base64String = metadataMap['image/png;base64'] ?? metadataMap['image/jpeg;base64']; + + return Scaffold( + key: _scaffoldKey, + appBar: AppBar( + leading: const back_button.BackButton(), + // Todo: Use domain from request data + title: Text(texts.lnurl_fetch_invoice_pay_to_payee(Uri.parse(widget.data.callback).host)), + ), + body: Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.fromLTRB(16.0, 24.0, 16.0, 0.0), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.data.commentAllowed > 0) ...[ + TextFormField( + controller: _commentController, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.done, + maxLines: null, + maxLength: widget.data.commentAllowed.toInt(), + maxLengthEnforcement: MaxLengthEnforcement.enforced, + decoration: InputDecoration( + labelText: texts.lnurl_payment_page_comment, + ), + ) + ], + AmountFormField( + context: context, + texts: texts, + bitcoinCurrency: currencyState.bitcoinCurrency, + controller: _amountController, + validatorFn: validatePayment, + enabled: !fixedAmount, + readOnly: fixedAmount, + ), + if (!fixedAmount) ...[ + Padding( + padding: const EdgeInsets.only( + top: 8, + ), + child: RichText( + text: TextSpan( + style: theme.FieldTextStyle.labelStyle, + children: [ + TextSpan( + text: texts.lnurl_fetch_invoice_min( + currencyState.bitcoinCurrency.format( + (widget.data.minSendable.toInt() ~/ 1000), + ), + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + _amountController.text = currencyState.bitcoinCurrency.format( + (widget.data.minSendable.toInt() ~/ 1000), + includeDisplayName: false, + ); + }, + ), + TextSpan( + text: texts.lnurl_fetch_invoice_and( + currencyState.bitcoinCurrency.format( + (widget.data.maxSendable.toInt() ~/ 1000), + ), + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + _amountController.text = currencyState.bitcoinCurrency.format( + (widget.data.maxSendable.toInt() ~/ 1000), + includeDisplayName: false, + ); + }, + ) + ], + ), + )), + ], + /* + if (widget.name?.mandatory == true) ...[ + TextFormField( + controller: _nameController, + keyboardType: TextInputType.name, + validator: (value) => value != null ? null : texts.breez_avatar_dialog_your_name, + ) + ], + if (widget.auth?.mandatory == true) ...[ + TextFormField( + controller: _k1Controller, + keyboardType: TextInputType.text, + validator: (value) => value != null ? null : texts.lnurl_payment_page_enter_k1, + ) + ], + if (widget.email?.mandatory == true) ...[ + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + validator: (value) => value != null + ? EmailValidator.validate(value) + ? null + : texts.order_card_country_email_invalid + : texts.order_card_country_email_empty, + ) + ], + if (widget.identifier?.mandatory == true) ...[ + TextFormField( + controller: _identifierController, + ) + ], + */ + Container( + width: MediaQuery.of(context).size.width, + height: 48, + padding: const EdgeInsets.only(top: 16.0), + child: LNURLMetadataText(metadataMap: metadataMap), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 10, 0, 22), + child: Center( + child: LNURLMetadataImage( + base64String: base64String, + ), + ), + ), + ), + ], + ), + ), + ), + bottomNavigationBar: SingleButtonBottomBar( + stickToBottom: true, + text: texts.lnurl_fetch_invoice_action_continue, + onPressed: () async { + if (_formKey.currentState!.validate()) { + final currencyBloc = context.read(); + final amount = currencyBloc.state.bitcoinCurrency.parse(_amountController.text); + final comment = _commentController.text; + _log.info("LNURL payment of $amount sats where " + "min is ${widget.data.minSendable} msats " + "and max is ${widget.data.maxSendable} msats." + "with comment $comment"); + Navigator.pop(context, LNURLPaymentInfo(amount: amount, comment: comment)); + } + }, + ), + ); + } + + String? validatePayment(int amount) { + final texts = context.texts(); + final accBloc = context.read(); + final currencyState = context.read().state; + final lnurlState = context.read().state; + final limits = lnurlState.limits?.send; + + final maxSendable = (limits != null) + ? min(limits.maxSat.toInt(), widget.data.maxSendable.toInt() ~/ 1000) + : widget.data.maxSendable.toInt() ~/ 1000; + if (amount > maxSendable) { + return texts.lnurl_payment_page_error_exceeds_limit(maxSendable); + } + + final minSendable = (limits != null) + ? max(limits.maxSat.toInt(), widget.data.minSendable.toInt() ~/ 1000) + : widget.data.minSendable.toInt() ~/ 1000; + if (amount < minSendable) { + return texts.lnurl_payment_page_error_below_limit(minSendable); + } + + return PaymentValidator( + validatePayment: accBloc.validatePayment, + currency: currencyState.bitcoinCurrency, + texts: context.texts(), + ).validateOutgoing(amount); + } +} diff --git a/lib/routes/lnurl/payment/success_action/success_action_dialog.dart b/lib/routes/lnurl/payment/success_action/success_action_dialog.dart new file mode 100644 index 00000000..4dc897e0 --- /dev/null +++ b/lib/routes/lnurl/payment/success_action/success_action_dialog.dart @@ -0,0 +1,105 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:l_breez/routes/home/widgets/payments_list/dialog/shareable_payment_row.dart'; +import 'package:flutter/material.dart'; + +class SuccessActionDialog extends StatefulWidget { + final String message; + final String? url; + + const SuccessActionDialog({super.key, required this.message, this.url}); + + @override + State createState() { + return SuccessActionDialogState(); + } +} + +class SuccessActionDialogState extends State { + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + final texts = context.texts(); + + return AlertDialog( + title: Text(texts.ln_url_success_action_title), + content: SizedBox( + width: MediaQuery.of(context).size.width, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.url == null) Message(widget.message), + if (widget.url != null) ...[ + ShareablePaymentRow( + title: widget.message, + sharedValue: widget.url!, + isURL: true, + isExpanded: true, + titleTextStyle: themeData.primaryTextTheme.displaySmall!.copyWith(fontSize: 16), + childrenTextStyle: themeData.primaryTextTheme.displaySmall!.copyWith( + fontSize: 12, + height: 1.5, + color: Colors.blue, + ), + iconPadding: EdgeInsets.zero, + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + ), + ], + ], + ), + ), + contentPadding: const EdgeInsets.only(top: 16.0, left: 32.0, right: 32.0), + actions: [ + TextButton( + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return Colors.transparent; + } + // Defer to the widget's default. + return themeData.textTheme.labelLarge!.color!; + }), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + texts.lnurl_withdraw_dialog_action_close, + style: themeData.primaryTextTheme.labelLarge, + ), + ), + ], + ); + } +} + +class Message extends StatelessWidget { + final String message; + + const Message(this.message, {super.key}); + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Container( + constraints: const BoxConstraints( + maxHeight: 200, + minWidth: double.infinity, + ), + child: Scrollbar( + child: SingleChildScrollView( + child: AutoSizeText( + message, + style: themeData.primaryTextTheme.displaySmall!.copyWith(fontSize: 16), + textAlign: message.length > 40 && !message.contains("\n") ? TextAlign.start : TextAlign.left, + ), + ), + ), + ), + ); + } +} diff --git a/lib/routes/lnurl/widgets/lnurl_metadata.dart b/lib/routes/lnurl/widgets/lnurl_metadata.dart new file mode 100644 index 00000000..f9e8d370 --- /dev/null +++ b/lib/routes/lnurl/widgets/lnurl_metadata.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:l_breez/theme/theme_provider.dart' as theme; +import 'package:l_breez/utils/min_font_size.dart'; +import 'package:flutter/material.dart'; + +class LNURLMetadataText extends StatelessWidget { + const LNURLMetadataText({ + super.key, + required this.metadataMap, + }); + + final Map metadataMap; + + @override + Widget build(BuildContext context) { + return AutoSizeText( + metadataMap['text/long-desc'] ?? metadataMap['text/plain'], + style: theme.FieldTextStyle.textStyle, + maxLines: 1, + minFontSize: MinFontSize(context).minFontSize, + ); + } +} + +class LNURLMetadataImage extends StatelessWidget { + final String? base64String; + + const LNURLMetadataImage({ + super.key, + this.base64String, + }); + + @override + Widget build(BuildContext context) { + if (base64String != null) { + final bytes = base64Decode(base64String!); + if (bytes.isNotEmpty) { + const imageSize = 128.0; + return ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: imageSize, + maxHeight: imageSize, + ), + child: Image.memory( + bytes, + width: imageSize, + fit: BoxFit.fitWidth, + ), + ); + } + } + return Container(); + } +} diff --git a/lib/routes/lnurl/widgets/lnurl_page_result.dart b/lib/routes/lnurl/widgets/lnurl_page_result.dart new file mode 100644 index 00000000..a88a0104 --- /dev/null +++ b/lib/routes/lnurl/widgets/lnurl_page_result.dart @@ -0,0 +1,43 @@ +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/utils/exceptions.dart'; + +class LNURLPageResult { + final LnUrlProtocol? protocol; + final SuccessActionProcessed? successAction; + final Object? error; + + const LNURLPageResult({ + this.protocol, + this.successAction, + this.error, + }); + + bool get hasError => error != null; + + String get errorMessage => extractExceptionMessage( + error ?? "", + getSystemAppLocalizations(), + defaultErrorMsg: getSystemAppLocalizations().lnurl_payment_page_unknown_error, + ); + + @override + String toString() { + return 'LNURLPageResult{protocol: $protocol, successAction: $successAction, error: $error}'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LNURLPageResult && + runtimeType == other.runtimeType && + protocol == other.protocol && + successAction == other.successAction && + error == other.error; + + @override + int get hashCode => protocol.hashCode ^ successAction.hashCode ^ error.hashCode; +} + +// Supported LNURL specs +enum LnUrlProtocol { Auth, Pay, Withdraw } diff --git a/lib/routes/lnurl/withdraw/lnurl_withdraw_dialog.dart b/lib/routes/lnurl/withdraw/lnurl_withdraw_dialog.dart new file mode 100644 index 00000000..11a1b7ce --- /dev/null +++ b/lib/routes/lnurl/withdraw/lnurl_withdraw_dialog.dart @@ -0,0 +1,184 @@ +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/bloc/lnurl/lnurl_bloc.dart'; +import 'package:l_breez/routes/lnurl/widgets/lnurl_page_result.dart'; +import 'package:l_breez/theme/theme_provider.dart' as theme; +import 'package:l_breez/utils/exceptions.dart'; +import 'package:l_breez/widgets/loading_animated_text.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger("LNURLWithdrawDialog"); + +class LNURLWithdrawDialog extends StatefulWidget { + final Function(LNURLPageResult? result) onFinish; + final LnUrlWithdrawRequestData requestData; + final int amountSats; + + const LNURLWithdrawDialog({ + super.key, + required this.requestData, + required this.amountSats, + required this.onFinish, + }); + + @override + State createState() => _LNURLWithdrawDialogState(); +} + +class _LNURLWithdrawDialogState extends State with SingleTickerProviderStateMixin { + late Animation _opacityAnimation; + Future? _future; + var finishCalled = false; + + @override + void initState() { + super.initState(); + final controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + ); + _opacityAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: controller, + curve: Curves.ease, + )); + controller.value = 1.0; + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _future = _withdraw(context).then((result) { + if (result.error == null && mounted) { + controller.addStatusListener((status) { + _log.info("Animation status $status"); + if (status == AnimationStatus.dismissed && mounted) { + finishCalled = true; + widget.onFinish(result); + } + }); + controller.reverse(); + } + return result; + }); + }); + }); + } + + @override + void dispose() { + if (!finishCalled) { + _onFinish(null); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final texts = context.texts(); + final themeData = Theme.of(context); + + return FadeTransition( + opacity: _opacityAnimation, + child: AlertDialog( + title: Text( + texts.lnurl_withdraw_dialog_title, + style: themeData.dialogTheme.titleTextStyle, + textAlign: TextAlign.center, + ), + content: FutureBuilder( + future: _future, + builder: (context, snapshot) { + final data = snapshot.data; + final error = snapshot.error ?? data?.error; + _log.info("Building with data $data, error $error"); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + error != null + ? Text( + texts.lnurl_withdraw_dialog_error(extractExceptionMessage(error, texts)), + style: themeData.dialogTheme.contentTextStyle, + textAlign: TextAlign.center, + ) + : LoadingAnimatedText( + loadingMessage: texts.lnurl_withdraw_dialog_wait, + textStyle: themeData.dialogTheme.contentTextStyle, + textAlign: TextAlign.center, + ), + error != null + ? const SizedBox(height: 16.0) + : Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 0), + child: Image.asset( + themeData.customData.loaderAssetPath, + gaplessPlayback: true, + ), + ), + TextButton( + onPressed: () => _onFinish(null), + child: Text( + texts.lnurl_withdraw_dialog_action_close, + style: themeData.primaryTextTheme.labelLarge, + ), + ) + ], + ); + }, + ), + ), + ); + } + + Future _withdraw(BuildContext context) async { + _log.info("Withdraw ${widget.amountSats} sats"); + final texts = context.texts(); + final lnurlBloc = context.read(); + final description = widget.requestData.defaultDescription; + + try { + _log.info("LNURL withdraw of ${widget.amountSats} sats where " + "min is ${widget.requestData.minWithdrawable} msats " + "and max is ${widget.requestData.maxWithdrawable} msats."); + final req = LnUrlWithdrawRequest( + amountMsat: BigInt.from(widget.amountSats * 1000), + data: widget.requestData, + description: description, + ); + final resp = await lnurlBloc.lnurlWithdraw(req: req); + if (resp is LnUrlWithdrawResult_Ok) { + final paymentHash = resp.data.invoice.paymentHash; + _log.info("LNURL withdraw success for $paymentHash"); + return const LNURLPageResult(protocol: LnUrlProtocol.Withdraw); + } else if (resp is LnUrlWithdrawResult_ErrorStatus) { + final reason = resp.data.reason; + _log.info("LNURL withdraw failed: $reason"); + return LNURLPageResult( + protocol: LnUrlProtocol.Withdraw, + error: reason, + ); + } else { + _log.warning("Unknown response from lnurlWithdraw: $resp"); + return LNURLPageResult( + protocol: LnUrlProtocol.Withdraw, + error: texts.lnurl_payment_page_unknown_error, + ); + } + } catch (e) { + _log.warning("Error withdrawing LNURL payment", e); + return LNURLPageResult(protocol: LnUrlProtocol.Withdraw, error: e); + } + } + + void _onFinish(LNURLPageResult? result) { + if (finishCalled) { + return; + } + finishCalled = true; + _log.info("Finishing with result $result"); + widget.onFinish(result); + } +} diff --git a/lib/routes/lnurl/withdraw/lnurl_withdraw_handler.dart b/lib/routes/lnurl/withdraw/lnurl_withdraw_handler.dart new file mode 100644 index 00000000..ee755bb4 --- /dev/null +++ b/lib/routes/lnurl/withdraw/lnurl_withdraw_handler.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/routes/create_invoice/create_invoice_page.dart'; +import 'package:l_breez/routes/create_invoice/widgets/successful_payment.dart'; +import 'package:l_breez/routes/lnurl/widgets/lnurl_page_result.dart'; +import 'package:l_breez/widgets/error_dialog.dart'; +import 'package:l_breez/widgets/transparent_page_route.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger("HandleLNURLWithdrawPageResult"); + +Future handleWithdrawRequest( + BuildContext context, + LnUrlWithdrawRequestData requestData, +) async { + Completer completer = Completer(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CreateInvoicePage( + requestData: requestData, + onFinish: (LNURLPageResult? response) { + completer.complete(response); + Navigator.of(context).popUntil((route) => route.settings.name == "/"); + }, + ), + ), + ); + + return completer.future; +} + +void handleLNURLWithdrawPageResult(BuildContext context, LNURLPageResult result) { + _log.info("handle $result"); + if (result.hasError) { + _log.info("Handle LNURL withdraw page result with error '${result.error}'"); + final texts = context.texts(); + final themeData = Theme.of(context); + promptError( + context, + texts.invoice_receive_fail, + Text( + texts.invoice_receive_fail_message(result.errorMessage), + style: themeData.dialogTheme.contentTextStyle, + ), + ); + throw result.error!; + } else { + _log.info("Handle LNURL withdraw page result with success"); + Navigator.of(context).push( + TransparentPageRoute((ctx) => const SuccessfulPaymentRoute()), + ); + } +} diff --git a/lib/widgets/payment_dialogs/processing_payment_dialog.dart b/lib/widgets/payment_dialogs/processing_payment_dialog.dart index f487c54d..714e1ce1 100644 --- a/lib/widgets/payment_dialogs/processing_payment_dialog.dart +++ b/lib/widgets/payment_dialogs/processing_payment_dialog.dart @@ -16,7 +16,7 @@ class ProcessingPaymentDialog extends StatefulWidget { final GlobalKey? firstPaymentItemKey; final double minHeight; final bool popOnCompletion; - final bool isLnurlPayment; + final bool isLnUrlPayment; final Future Function() paymentFunc; final Function(PaymentRequestState state)? onStateChange; @@ -24,7 +24,7 @@ class ProcessingPaymentDialog extends StatefulWidget { this.firstPaymentItemKey, this.minHeight = 220, this.popOnCompletion = false, - this.isLnurlPayment = false, + this.isLnUrlPayment = false, required this.paymentFunc, this.onStateChange, super.key, @@ -95,7 +95,7 @@ class ProcessingPaymentDialogState extends State final texts = getSystemAppLocalizations(); widget.paymentFunc().then((payResult) async { await _animateClose(); - if (widget.isLnurlPayment) { + if (widget.isLnUrlPayment) { navigator.pop(payResult); } }).catchError((err) { @@ -103,7 +103,7 @@ class ProcessingPaymentDialogState extends State navigator.removeRoute(_currentRoute!); } widget.onStateChange?.call(PaymentRequestState.PAYMENT_COMPLETED); - if (widget.isLnurlPayment) { + if (widget.isLnUrlPayment) { navigator.pop(err); } if (err is FrbException || err is PaymentError_PaymentTimeout) {