From 81866b80937d1fb9e399adc46869b2c5bb0d0374 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Thu, 16 Jan 2025 20:15:30 +0300 Subject: [PATCH 01/12] Allow registering with a custom username --- lib/cubit/webhook/webhook_cubit.dart | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/cubit/webhook/webhook_cubit.dart b/lib/cubit/webhook/webhook_cubit.dart index e92b2932..c0eff8a8 100644 --- a/lib/cubit/webhook/webhook_cubit.dart +++ b/lib/cubit/webhook/webhook_cubit.dart @@ -32,13 +32,13 @@ class WebhookCubit extends Cubit { ); } - Future refreshWebhooks({WalletInfo? walletInfo}) async { + Future refreshWebhooks({WalletInfo? walletInfo, String? username}) async { _logger.info('Refreshing webhooks'); emit(WebhookState(isLoading: true)); try { walletInfo = walletInfo ?? (await _breezSdkLiquid.instance?.getInfo())?.walletInfo; if (walletInfo != null) { - await _registerWebhooks(walletInfo); + await _registerWebhooks(walletInfo, username: username); } else { throw Exception('Unable to retrieve wallet information.'); } @@ -55,12 +55,12 @@ class WebhookCubit extends Cubit { } } - Future _registerWebhooks(WalletInfo walletInfo) async { + Future _registerWebhooks(WalletInfo walletInfo, {String? username}) async { try { final String webhookUrl = await _generateWebhookURL(); await _breezSdkLiquid.instance?.registerWebhook(webhookUrl: webhookUrl); _logger.info('SDK webhook registered: $webhookUrl'); - final String lnurl = await _registerLnurlpay(walletInfo, webhookUrl); + final String lnurl = await _registerLnurlpay(walletInfo, webhookUrl, username: username); emit(WebhookState(lnurlPayUrl: lnurl)); } catch (err) { _logger.warning('Failed to register webhooks: $err'); @@ -71,16 +71,17 @@ class WebhookCubit extends Cubit { Future _registerLnurlpay( WalletInfo walletInfo, - String webhookUrl, - ) async { + String webhookUrl, { + String? username, + }) async { final String? lastUsedLnurlPay = await _breezPreferences.getLnUrlPayKey(); if (lastUsedLnurlPay != null && lastUsedLnurlPay != webhookUrl) { await _invalidateLnurlPay(walletInfo, lastUsedLnurlPay); } - String? username = await _breezPreferences.getProfileName(); - username = username?.replaceAll(' ', ''); + String? lnAddressUsername = username ?? await _breezPreferences.getProfileName(); + lnAddressUsername = lnAddressUsername?.replaceAll(' ', ''); final int currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final String optionalUsernameKey = username != null ? '-$username' : ''; + final String optionalUsernameKey = lnAddressUsername != null ? '-$lnAddressUsername' : ''; final SignMessageRequest req = SignMessageRequest(message: '$currentTime-$webhookUrl$optionalUsernameKey'); final SignMessageResponse? signMessageRes = _breezSdkLiquid.instance?.signMessage(req: req); @@ -95,7 +96,7 @@ class WebhookCubit extends Cubit { AddWebhookRequest( time: currentTime, webhookUrl: webhookUrl, - username: username, + username: lnAddressUsername, signature: signMessageRes.signature, ).toJson(), ), From a0f6d342a9f677dc87dc82e656a72d135e96fcf5 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Thu, 16 Jan 2025 20:15:59 +0300 Subject: [PATCH 02/12] Save both LNURLp & LN Address on WebhookState --- lib/cubit/webhook/webhook_cubit.dart | 12 ++++++------ lib/cubit/webhook/webhook_state.dart | 4 ++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/cubit/webhook/webhook_cubit.dart b/lib/cubit/webhook/webhook_cubit.dart index c0eff8a8..23360d43 100644 --- a/lib/cubit/webhook/webhook_cubit.dart +++ b/lib/cubit/webhook/webhook_cubit.dart @@ -60,8 +60,7 @@ class WebhookCubit extends Cubit { final String webhookUrl = await _generateWebhookURL(); await _breezSdkLiquid.instance?.registerWebhook(webhookUrl: webhookUrl); _logger.info('SDK webhook registered: $webhookUrl'); - final String lnurl = await _registerLnurlpay(walletInfo, webhookUrl, username: username); - emit(WebhookState(lnurlPayUrl: lnurl)); + await _registerLnurlpay(walletInfo, webhookUrl, username: username); } catch (err) { _logger.warning('Failed to register webhooks: $err'); emit(state.copyWith(lnurlPayErrorTitle: 'Failed to register webhooks:', lnurlPayError: err.toString())); @@ -69,7 +68,7 @@ class WebhookCubit extends Cubit { } } - Future _registerLnurlpay( + Future _registerLnurlpay( WalletInfo walletInfo, String webhookUrl, { String? username, @@ -103,10 +102,11 @@ class WebhookCubit extends Cubit { ); if (jsonResponse.statusCode == 200) { final Map data = jsonDecode(jsonResponse.body); - final String lnurl = data.containsKey('lightning_address') ? data['lightning_address'] : data['lnurl']; - _logger.info('lnurlpay webhook registered: $webhookUrl, lnurl = $lnurl'); + final String lnurl = data['lnurl']; + final String lnAddress = data.containsKey('lightning_address') ? data['lightning_address'] : ''; + _logger.info('lnurlpay webhook registered: $webhookUrl, lnurl = $lnurl, lnAddress = $lnAddress'); await _breezPreferences.setLnUrlPayKey(webhookUrl); - return lnurl; + emit(WebhookState(lnurlPayUrl: lnurl, lnAddress: lnAddress)); } else { throw jsonResponse.body; } diff --git a/lib/cubit/webhook/webhook_state.dart b/lib/cubit/webhook/webhook_state.dart index a4cadf42..73848399 100644 --- a/lib/cubit/webhook/webhook_state.dart +++ b/lib/cubit/webhook/webhook_state.dart @@ -1,11 +1,13 @@ class WebhookState { final String? lnurlPayUrl; + final String? lnAddress; final String? lnurlPayError; final String? lnurlPayErrorTitle; final bool isLoading; WebhookState({ this.lnurlPayUrl, + this.lnAddress, this.lnurlPayError, this.lnurlPayErrorTitle, this.isLoading = false, @@ -13,12 +15,14 @@ class WebhookState { WebhookState copyWith({ String? lnurlPayUrl, + String? lnAddress, String? lnurlPayError, String? lnurlPayErrorTitle, bool? isLoading, }) { return WebhookState( lnurlPayUrl: lnurlPayUrl ?? this.lnurlPayUrl, + lnAddress: lnAddress ?? this.lnAddress, lnurlPayError: lnurlPayError ?? this.lnurlPayError, lnurlPayErrorTitle: lnurlPayErrorTitle ?? this.lnurlPayErrorTitle, isLoading: isLoading ?? this.isLoading, From beddf9115493369a5c3c3b8a55ece3acfc1e3075 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Thu, 16 Jan 2025 20:24:59 +0300 Subject: [PATCH 03/12] Display LN Address on "Receive via LN Address" page - Check if lnAddress is not empty instead of relying on isLnAddress param - Display LN Address below action buttons - Set Copy actions value to LN Address - Open options menu on clicking LN Address - Open a bottom sheet to customize LN Address on clicking customize option Misc: - Rename DestinationActions's filename --- .../receive_lightning_address_page.dart | 2 +- .../destination_widget.dart | 9 +- ...n_header.dart => destination_actions.dart} | 14 +- .../widgets/destination_information.dart | 86 +++++++++ .../widgets/destination_qr_widget.dart | 8 +- ...date_ln_address_username_bottom_sheet.dart | 175 ++++++++++++++++++ .../destination_widget/widgets/widgets.dart | 4 +- 7 files changed, 288 insertions(+), 10 deletions(-) rename lib/routes/receive_payment/widgets/destination_widget/widgets/{destination_header.dart => destination_actions.dart} (90%) create mode 100644 lib/routes/receive_payment/widgets/destination_widget/widgets/destination_information.dart create mode 100644 lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart diff --git a/lib/routes/receive_payment/ln_address/receive_lightning_address_page.dart b/lib/routes/receive_payment/ln_address/receive_lightning_address_page.dart index f26aa149..2cfa8051 100644 --- a/lib/routes/receive_payment/ln_address/receive_lightning_address_page.dart +++ b/lib/routes/receive_payment/ln_address/receive_lightning_address_page.dart @@ -67,8 +67,8 @@ class ReceiveLightningAddressPageState extends State? snapshot; final String? destination; + final String? lnAddress; final String? paymentMethod; final void Function()? onLongPress; final Widget? infoWidget; - final bool isLnAddress; const DestinationWidget({ super.key, this.snapshot, this.destination, + this.lnAddress, this.paymentMethod, this.onLongPress, this.infoWidget, - this.isLnAddress = false, }); @override @@ -42,7 +42,7 @@ class _DestinationWidgetState extends State { @override void initState() { super.initState(); - if (widget.isLnAddress) { + if (widget.lnAddress != null && widget.lnAddress!.isNotEmpty) { // Ignore new payments for a duration upon generating LN Address. // This delay is added to avoid popping the page before user gets the chance to copy, // share or get their LN address scanned. @@ -56,7 +56,7 @@ class _DestinationWidgetState extends State { // For receive payment pages other than LN Address, user input is required before creating an invoice. // Therefore, they rely on `didUpdateWidget` instead of `initState` to capture updates after // initial widget setup. - if (!widget.isLnAddress) { + if (!(widget.lnAddress != null && widget.lnAddress!.isNotEmpty)) { _trackPaymentEvents(getUpdatedDestination(oldWidget)); } } @@ -152,6 +152,7 @@ class _DestinationWidgetState extends State { child: DestinationQRWidget( snapshot: widget.snapshot, destination: widget.destination, + lnAddress: widget.lnAddress, paymentMethod: widget.paymentMethod, onLongPress: widget.onLongPress, infoWidget: widget.infoWidget, diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_header.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_actions.dart similarity index 90% rename from lib/routes/receive_payment/widgets/destination_widget/widgets/destination_header.dart rename to lib/routes/receive_payment/widgets/destination_widget/widgets/destination_actions.dart index a57e8eac..e775c2c3 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_header.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_actions.dart @@ -10,13 +10,15 @@ import 'package:share_plus/share_plus.dart'; class DestinationActions extends StatelessWidget { final AsyncSnapshot? snapshot; final String? destination; + final String? lnAddress; final String? paymentMethod; const DestinationActions({ required this.snapshot, required this.destination, - super.key, + this.lnAddress, this.paymentMethod, + super.key, }); @override @@ -31,6 +33,7 @@ class DestinationActions extends StatelessWidget { Expanded( child: _CopyButton( destination: destination, + lnAddress: lnAddress, paymentMethod: paymentMethod, ), ), @@ -51,10 +54,12 @@ class DestinationActions extends StatelessWidget { class _CopyButton extends StatelessWidget { final String destination; final String? paymentMethod; + final String? lnAddress; const _CopyButton({ required this.destination, this.paymentMethod, + this.lnAddress, }); @override @@ -85,10 +90,13 @@ class _CopyButton extends StatelessWidget { style: balanceFiatConversionTextStyle, ), onPressed: () { - ServiceInjector().deviceClient.setClipboardText(destination); + ServiceInjector().deviceClient.setClipboardText( + (lnAddress != null && lnAddress!.isNotEmpty) ? lnAddress! : destination, + ); showFlushbar( context, - message: (paymentMethod != null && paymentMethod!.isNotEmpty) + message: (lnAddress != null && lnAddress!.isNotEmpty) || + (paymentMethod != null && paymentMethod!.isNotEmpty) ? texts.payment_details_dialog_copied(paymentMethod!) : texts.invoice_btc_address_deposit_address_copied, duration: const Duration(seconds: 3), diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_information.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_information.dart new file mode 100644 index 00000000..61104c73 --- /dev/null +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_information.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:l_breez/routes/receive_payment/widgets/widgets.dart'; +import 'package:l_breez/widgets/widgets.dart'; + +class DestinationInformation extends StatefulWidget { + final String lnAddress; + + const DestinationInformation({ + required this.lnAddress, + super.key, + }); + + @override + DestinationInformationState createState() => DestinationInformationState(); +} + +class DestinationInformationState extends State { + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + + return GestureDetector( + onTapDown: (TapDownDetails details) { + // TODO(erdemyerebasmaz): Display the dropdown menu in a static place that does not obstruct LN Address + final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + final Offset offset = details.globalPosition; + + showMenu( + context: context, + color: themeData.colorScheme.surface, + position: RelativeRect.fromRect( + Rect.fromPoints(offset, offset), + Offset.zero & overlay.size, + ), + items: >[ + const PopupMenuItem( + // TODO(erdemyerebasmaz): Replace with const var + value: 'customize', + child: Row( + children: [ + Icon(Icons.edit), + SizedBox( + width: 8.0, + ), + // TODO(erdemyerebasmaz): Add these messages to Breez-Translations + Text('Customize Address'), + ], + ), + ), + ], + ).then((String? value) { + // TODO(erdemyerebasmaz): Replace with const var + if (value == 'customize') { + if (context.mounted) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ), + isScrollControlled: true, + builder: (BuildContext context) => UpdateLnAddressUsernameBottomSheet( + lnAddress: widget.lnAddress, + ), + ); + } + } + }); + }, + child: WarningBox( + boxPadding: const EdgeInsets.only(bottom: 24.0), + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + borderColor: Colors.transparent, + backgroundColor: Theme.of(context).canvasColor, + child: Center( + child: Text( + widget.lnAddress, + style: themeData.primaryTextTheme.bodyMedium!.copyWith(fontSize: 18.0), + ), + ), + ), + ); + } +} diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_qr_widget.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_qr_widget.dart index 6268bf46..325f2c7d 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_qr_widget.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/destination_qr_widget.dart @@ -9,6 +9,7 @@ import 'package:l_breez/widgets/widgets.dart'; class DestinationQRWidget extends StatelessWidget { final AsyncSnapshot? snapshot; final String? destination; + final String? lnAddress; final String? paymentMethod; final void Function()? onLongPress; final Widget? infoWidget; @@ -16,10 +17,11 @@ class DestinationQRWidget extends StatelessWidget { const DestinationQRWidget({ required this.snapshot, required this.destination, + this.lnAddress, this.paymentMethod, - super.key, this.onLongPress, this.infoWidget, + super.key, }); @override @@ -49,7 +51,11 @@ class DestinationQRWidget extends StatelessWidget { snapshot: snapshot, destination: destination, paymentMethod: paymentMethod, + lnAddress: lnAddress, ), + if (lnAddress != null && lnAddress!.isNotEmpty) ...[ + DestinationInformation(lnAddress: lnAddress!), + ], if (infoWidget != null) ...[ SizedBox( width: MediaQuery.of(context).size.width, diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart new file mode 100644 index 00000000..72ef63bb --- /dev/null +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart @@ -0,0 +1,175 @@ +import 'package:breez_translations/breez_translations_locales.dart'; +import 'package:breez_translations/generated/breez_translations.dart'; +import 'package:email_validator/email_validator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:l_breez/cubit/cubit.dart'; +import 'package:l_breez/widgets/widgets.dart'; + +class UpdateLnAddressUsernameBottomSheet extends StatefulWidget { + final String lnAddress; + + const UpdateLnAddressUsernameBottomSheet({ + required this.lnAddress, + super.key, + }); + + @override + State createState() => _UpdateLnAddressUsernameBottomSheetState(); +} + +class _UpdateLnAddressUsernameBottomSheetState extends State { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _usernameController = TextEditingController(); + final FocusNode _usernameFocusNode = FocusNode(); + KeyboardDoneAction _doneAction = KeyboardDoneAction(); + + @override + void initState() { + super.initState(); + _usernameController.text = widget.lnAddress.split('@').first; + _usernameController.addListener(() => setState(() {})); + _doneAction = KeyboardDoneAction(focusNodes: [_usernameFocusNode]); + } + + @override + void dispose() { + super.dispose(); + _doneAction.dispose(); + FocusManager.instance.primaryFocus?.unfocus(); + } + + @override + Widget build(BuildContext context) { + final BreezTranslations texts = context.texts(); + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: BlocBuilder( + builder: (BuildContext context, WebhookState state) { + if (state.isLoading || + (state.lnurlPayError != null && state.lnurlPayError!.isNotEmpty) || + (state.lnurlPayUrl != null && state.lnurlPayUrl!.isEmpty)) { + return const Center(child: CircularProgressIndicator()); + } + + final ThemeData themeData = Theme.of(context); + final Color errorBorderColor = themeData.colorScheme.error; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + child: Container( + margin: const EdgeInsets.only(top: 8.0), + width: 40.0, + height: 6.5, + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(50), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + // TODO(erdemyerebasmaz): Add these messages to Breez-Translations + 'Customize Address:', + style: themeData.primaryTextTheme.headlineMedium!.copyWith( + fontSize: 18.0, + color: Colors.white, + ), + textAlign: TextAlign.left, + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: TextFormField( + controller: _usernameController, + focusNode: _usernameFocusNode, + decoration: InputDecoration( + // TODO(erdemyerebasmaz): Add these messages to Breez-Translations + labelText: 'Username', + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: errorBorderColor), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide(color: errorBorderColor), + ), + errorMaxLines: 2, + errorStyle: themeData.primaryTextTheme.bodySmall!.copyWith( + color: errorBorderColor, + ), + suffix: const Padding( + padding: EdgeInsets.only(right: 8.0), + // TODO(erdemyerebasmaz): Retrieve this from lnAddress itself & store it temporarily + child: Text( + '@breez.fun', + ), + ), + border: const OutlineInputBorder(), + ), + inputFormatters: [ + TextInputFormatter.withFunction( + (_, TextEditingValue newValue) => newValue.copyWith( + text: newValue.text.replaceAll(',', '.'), + ), + ), + ], + keyboardType: const TextInputType.numberWithOptions(decimal: true), + autofocus: true, + validator: (String? value) { + if (value == null || value.isEmpty) { + // TODO(erdemyerebasmaz): Add these messages to Breez-Translations + return 'Please enter a username'; + } + final String email = '$value@${widget.lnAddress.split('@').last}'; + // TODO(erdemyerebasmaz): Add these messages to Breez-Translations + return EmailValidator.validate(email) ? null : 'Invalid username.'; + }, + onEditingComplete: () => _usernameFocusNode.unfocus(), + ), + ), + ), + const SizedBox(height: 8.0), + Align( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 16.0, + ), + child: SingleButtonBottomBar( + text: texts.currency_converter_dialog_action_done, + expand: true, + onPressed: () { + if (_usernameController.text.isEmpty) { + Navigator.pop(context); + return; + } + + // TODO(erdemyerebasmaz): Implement validation & registration logic + // TODO(erdemyerebasmaz): Handle registration errors + if (_formKey.currentState?.validate() ?? false) { + final WebhookCubit webhookCubit = context.read(); + webhookCubit.refreshWebhooks(username: _usernameController.text); + Navigator.pop(context); + } + }, + ), + ), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/widgets.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/widgets.dart index 56409526..328a81d2 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/widgets.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/widgets.dart @@ -1,5 +1,7 @@ export 'compact_qr_image.dart'; -export 'destination_header.dart'; +export 'destination_actions.dart'; +export 'destination_information.dart'; export 'destination_qr_image.dart'; export 'destination_qr_widget.dart'; export 'loading_or_error.dart'; +export 'update_ln_address_username_bottom_sheet.dart'; From c527e33eda9c4d4880568fb89a3c9462d068087a Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Thu, 16 Jan 2025 20:38:40 +0300 Subject: [PATCH 04/12] Add TODO comments for LN Address --- lib/cubit/webhook/webhook_cubit.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/cubit/webhook/webhook_cubit.dart b/lib/cubit/webhook/webhook_cubit.dart index 23360d43..c0962b5d 100644 --- a/lib/cubit/webhook/webhook_cubit.dart +++ b/lib/cubit/webhook/webhook_cubit.dart @@ -68,6 +68,8 @@ class WebhookCubit extends Cubit { } } + // TODO(erdemyerebasmaz): Make this a public method so that it can be used to customize LN Addresses + // TODO(erdemyerebasmaz): Currently the only endpoint generates a webhook URL & registers to it beforehand, which is not necessary for customizing username Future _registerLnurlpay( WalletInfo walletInfo, String webhookUrl, { @@ -77,6 +79,8 @@ class WebhookCubit extends Cubit { if (lastUsedLnurlPay != null && lastUsedLnurlPay != webhookUrl) { await _invalidateLnurlPay(walletInfo, lastUsedLnurlPay); } + // TODO(erdemyerebasmaz): Utilize user's username(only when user has created a new wallet) + // TODO(erdemyerebasmaz): Handle multiple device setup cases String? lnAddressUsername = username ?? await _breezPreferences.getProfileName(); lnAddressUsername = lnAddressUsername?.replaceAll(' ', ''); final int currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -108,6 +112,10 @@ class WebhookCubit extends Cubit { await _breezPreferences.setLnUrlPayKey(webhookUrl); emit(WebhookState(lnurlPayUrl: lnurl, lnAddress: lnAddress)); } else { + // TODO(erdemyerebasmaz): Handle username conflicts(only when user has created a new wallet) + // Add a random four-digit identifier, a discriminator, as a suffix if user's username is taken(~1/600 probability of conflict) + // Add a retry & randomizer logic until first registration succeeds + // TODO(erdemyerebasmaz): Handle custom username conflicts throw jsonResponse.body; } } From f937e798b75b7137415dd847ffe9331261275397 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Tue, 21 Jan 2025 16:06:51 +0300 Subject: [PATCH 05/12] Refactor WebhookCubit Split WebhookCubit into two services, one for Webhook related logic one for LNURL service related logic. Other changes on ChangeLnAddressUsernameBottomSheet - fix: Use keyboard for email address - fix: remove input formatter - fix: trim the username value --- lib/app/view/app.dart | 10 +- lib/cubit/webhook/lnurl_pay_service.dart | 158 +++++++++++++++++ lib/cubit/webhook/webhook_cubit.dart | 166 +++++------------- lib/cubit/webhook/webhook_service.dart | 41 +++++ ...date_ln_address_username_bottom_sheet.dart | 14 +- 5 files changed, 254 insertions(+), 135 deletions(-) create mode 100644 lib/cubit/webhook/lnurl_pay_service.dart create mode 100644 lib/cubit/webhook/webhook_service.dart diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 24417a02..f9b35e04 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -52,8 +52,14 @@ class App extends StatelessWidget { lazy: false, create: (BuildContext context) => WebhookCubit( injector.breezSdkLiquid, - injector.breezPreferences, - injector.notifications, + WebhookService( + injector.breezSdkLiquid, + injector.notifications, + ), + LnUrlPayService( + injector.breezSdkLiquid, + injector.breezPreferences, + ), ), ), BlocProvider( diff --git a/lib/cubit/webhook/lnurl_pay_service.dart b/lib/cubit/webhook/lnurl_pay_service.dart new file mode 100644 index 00000000..418f6313 --- /dev/null +++ b/lib/cubit/webhook/lnurl_pay_service.dart @@ -0,0 +1,158 @@ +import 'dart:convert'; + +import 'package:breez_preferences/breez_preferences.dart'; +import 'package:breez_sdk_liquid/breez_sdk_liquid.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:http/http.dart' as http; +import 'package:l_breez/cubit/webhook/webhook_state.dart'; +import 'package:logging/logging.dart'; + +final Logger _logger = Logger('LnUrlPayService'); + +class LnUrlPayService { + static const String lnurlServiceURL = 'https://breez.fun'; + + final BreezSDKLiquid _breezSdkLiquid; + final BreezPreferences _breezPreferences; + + LnUrlPayService(this._breezSdkLiquid, this._breezPreferences); + + Future> registerLnurlpay( + WalletInfo walletInfo, + String webhookUrl, { + String? username, + }) async { + final String pubKey = walletInfo.pubkey; + + await _invalidatePreviousWebhookIfNeeded(pubKey, webhookUrl); + + final int currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // TODO(erdemyerebasmaz): Utilize user's username(only when user has created a new wallet) + // TODO(erdemyerebasmaz): Handle multiple device setup cases + final String? lnAddressUsername = + (username ?? await _breezPreferences.getProfileName())?.replaceAll(' ', ''); + + final SignMessageResponse? signMessageRes = await _generateSignature( + webhookUrl: webhookUrl, + currentTime: currentTime, + lnAddressUsername: lnAddressUsername, + ); + + if (signMessageRes == null) { + throw Exception('Missing signature'); + } + + final Map response = await _postWebhookRegistrationRequest( + pubKey: pubKey, + webhookUrl: webhookUrl, + currentTime: currentTime, + lnAddressUsername: lnAddressUsername, + signature: signMessageRes.signature, + ); + + await setLnUrlPayKey(webhookUrl: webhookUrl); + return response; + } + + Future _invalidatePreviousWebhookIfNeeded(String pubKey, String webhookUrl) async { + final String? lastUsedLnurlPay = await getLnUrlPayKey(); + if (lastUsedLnurlPay != null && lastUsedLnurlPay != webhookUrl) { + await _invalidateLnurlPay(pubKey, lastUsedLnurlPay); + } + } + + Future _generateSignature({ + required String webhookUrl, + required int currentTime, + String? lnAddressUsername, + }) async { + final String username = lnAddressUsername?.isNotEmpty == true ? '-$lnAddressUsername' : ''; + final String message = '$currentTime-$webhookUrl$username'; + + final SignMessageRequest req = SignMessageRequest(message: message); + return _breezSdkLiquid.instance?.signMessage(req: req); + } + + Future> _postWebhookRegistrationRequest({ + required String pubKey, + required String webhookUrl, + required int currentTime, + required String signature, + String? lnAddressUsername, + }) async { + final String lnurlWebhookUrl = '$lnurlServiceURL/lnurlpay/$pubKey'; + final Uri uri = Uri.parse(lnurlWebhookUrl); + + final http.Response jsonResponse = await http.post( + uri, + body: jsonEncode( + AddWebhookRequest( + time: currentTime, + webhookUrl: webhookUrl, + username: lnAddressUsername, + signature: signature, + ).toJson(), + ), + ); + + if (jsonResponse.statusCode == 200) { + final Map data = jsonDecode(jsonResponse.body); + final String lnurl = data['lnurl']; + final String lnAddress = data.containsKey('lightning_address') ? data['lightning_address'] : ''; + _logger.info('Registered LnUrl Webhook: $webhookUrl, lnurl = $lnurl, lnAddress = $lnAddress'); + return { + 'lnurl': lnurl, + 'lnAddress': lnAddress, + }; + } else { + // TODO(erdemyerebasmaz): Handle username conflicts(only when user has created a new wallet) + // Add a random four-digit identifier, a discriminator, as a suffix if user's username is taken(~1/600 probability of conflict) + // Add a retry & randomizer logic until first registration succeeds + // TODO(erdemyerebasmaz): Handle custom username conflicts + throw Exception('Failed to register LnUrl Webhook: ${jsonResponse.body}'); + } + } + + Future updateLnAddressUsername(WalletInfo walletInfo, String username) async { + final String? webhookUrl = await getLnUrlPayKey(); + if (webhookUrl != null) { + await registerLnurlpay(walletInfo, webhookUrl, username: username); + } + } + + Future _invalidateLnurlPay(String pubKey, String toInvalidate) async { + final String lnurlWebhookUrl = '$lnurlServiceURL/lnurlpay/$pubKey'; + final int currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final SignMessageRequest req = SignMessageRequest(message: '$currentTime-$toInvalidate'); + final SignMessageResponse? signMessageRes = _breezSdkLiquid.instance?.signMessage(req: req); + if (signMessageRes == null) { + throw Exception('Missing signature'); + } + final Uri uri = Uri.parse(lnurlWebhookUrl); + final http.Response response = await http.delete( + uri, + body: jsonEncode( + RemoveWebhookRequest( + time: currentTime, + webhookUrl: toInvalidate, + signature: signMessageRes.signature, + ).toJson(), + ), + ); + _logger.info('invalidate lnurl pay response: ${response.statusCode}'); + await resetLnUrlPayKey(); + } + + Future setLnUrlPayKey({required String webhookUrl}) async { + return await _breezPreferences.setLnUrlPayKey(webhookUrl); + } + + Future getLnUrlPayKey() async { + return await _breezPreferences.getLnUrlPayKey(); + } + + Future resetLnUrlPayKey() async { + return await _breezPreferences.resetLnUrlPayKey(); + } +} diff --git a/lib/cubit/webhook/webhook_cubit.dart b/lib/cubit/webhook/webhook_cubit.dart index c0962b5d..d09f5b27 100644 --- a/lib/cubit/webhook/webhook_cubit.dart +++ b/lib/cubit/webhook/webhook_cubit.dart @@ -1,162 +1,84 @@ -import 'dart:convert'; - -import 'package:breez_preferences/breez_preferences.dart'; import 'package:breez_sdk_liquid/breez_sdk_liquid.dart'; -import 'package:firebase_notifications_client/firebase_notifications_client.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; -import 'package:http/http.dart' as http; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:l_breez/cubit/cubit.dart'; import 'package:logging/logging.dart'; +export 'lnurl_pay_service.dart'; +export 'webhook_service.dart'; export 'webhook_state.dart'; final Logger _logger = Logger('WebhookCubit'); class WebhookCubit extends Cubit { - static const String notifierServiceURL = 'https://notifier.breez.technology'; - static const String lnurlServiceURL = 'https://breez.fun'; - final BreezSDKLiquid _breezSdkLiquid; - final BreezPreferences _breezPreferences; - final NotificationsClient _notifications; + final WebhookService _webhookService; + final LnUrlPayService _lnUrlPayService; - WebhookCubit( - this._breezSdkLiquid, - this._breezPreferences, - this._notifications, - ) : super(WebhookState()) { + WebhookCubit(this._breezSdkLiquid, this._webhookService, this._lnUrlPayService) : super(WebhookState()) { _breezSdkLiquid.walletInfoStream.first.then( (GetInfoResponse getInfoResponse) => refreshWebhooks(walletInfo: getInfoResponse.walletInfo), ); } Future refreshWebhooks({WalletInfo? walletInfo, String? username}) async { - _logger.info('Refreshing webhooks'); + _logger.info('Refreshing Webhooks'); emit(WebhookState(isLoading: true)); try { walletInfo = walletInfo ?? (await _breezSdkLiquid.instance?.getInfo())?.walletInfo; if (walletInfo != null) { - await _registerWebhooks(walletInfo, username: username); + final String webhookUrl = await _webhookService.generateWebhookURL(); + await _webhookService.registerWebhook(webhookUrl); + final Map lnUrlData = await _lnUrlPayService.registerLnurlpay( + walletInfo, + webhookUrl, + username: username, + ); + emit( + WebhookState( + lnurlPayUrl: lnUrlData['lnurl'], + lnAddress: lnUrlData['lnAddress'], + ), + ); } else { throw Exception('Unable to retrieve wallet information.'); } } catch (err) { - _logger.warning('Failed to refresh lnurlpay: $err'); + _logger.warning('Failed to refresh webhooks: $err'); emit( - state.copyWith( + WebhookState( lnurlPayErrorTitle: 'Failed to refresh Lightning Address:', lnurlPayError: err.toString(), ), ); - } finally { - emit(state.copyWith(isLoading: false)); } } - Future _registerWebhooks(WalletInfo walletInfo, {String? username}) async { + Future updateLnAddressUsername({required String username}) async { + emit(WebhookState(isLoading: true)); try { - final String webhookUrl = await _generateWebhookURL(); - await _breezSdkLiquid.instance?.registerWebhook(webhookUrl: webhookUrl); - _logger.info('SDK webhook registered: $webhookUrl'); - await _registerLnurlpay(walletInfo, webhookUrl, username: username); + final GetInfoResponse? walletInfo = await _breezSdkLiquid.instance?.getInfo(); + if (walletInfo != null) { + await _lnUrlPayService.updateLnAddressUsername(walletInfo.walletInfo, username); + final Map lnUrlData = await _lnUrlPayService.registerLnurlpay( + walletInfo.walletInfo, + await _lnUrlPayService.getLnUrlPayKey() ?? '', + username: username, + ); + emit( + WebhookState( + lnurlPayUrl: lnUrlData['lnurl'], + lnAddress: lnUrlData['lnAddress'], + ), + ); + } } catch (err) { - _logger.warning('Failed to register webhooks: $err'); - emit(state.copyWith(lnurlPayErrorTitle: 'Failed to register webhooks:', lnurlPayError: err.toString())); - rethrow; - } - } - - // TODO(erdemyerebasmaz): Make this a public method so that it can be used to customize LN Addresses - // TODO(erdemyerebasmaz): Currently the only endpoint generates a webhook URL & registers to it beforehand, which is not necessary for customizing username - Future _registerLnurlpay( - WalletInfo walletInfo, - String webhookUrl, { - String? username, - }) async { - final String? lastUsedLnurlPay = await _breezPreferences.getLnUrlPayKey(); - if (lastUsedLnurlPay != null && lastUsedLnurlPay != webhookUrl) { - await _invalidateLnurlPay(walletInfo, lastUsedLnurlPay); - } - // TODO(erdemyerebasmaz): Utilize user's username(only when user has created a new wallet) - // TODO(erdemyerebasmaz): Handle multiple device setup cases - String? lnAddressUsername = username ?? await _breezPreferences.getProfileName(); - lnAddressUsername = lnAddressUsername?.replaceAll(' ', ''); - final int currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final String optionalUsernameKey = lnAddressUsername != null ? '-$lnAddressUsername' : ''; - final SignMessageRequest req = - SignMessageRequest(message: '$currentTime-$webhookUrl$optionalUsernameKey'); - final SignMessageResponse? signMessageRes = _breezSdkLiquid.instance?.signMessage(req: req); - if (signMessageRes == null) { - throw Exception('Missing signature'); - } - final String lnurlWebhookUrl = '$lnurlServiceURL/lnurlpay/${walletInfo.pubkey}'; - final Uri uri = Uri.parse(lnurlWebhookUrl); - final http.Response jsonResponse = await http.post( - uri, - body: jsonEncode( - AddWebhookRequest( - time: currentTime, - webhookUrl: webhookUrl, - username: lnAddressUsername, - signature: signMessageRes.signature, - ).toJson(), - ), - ); - if (jsonResponse.statusCode == 200) { - final Map data = jsonDecode(jsonResponse.body); - final String lnurl = data['lnurl']; - final String lnAddress = data.containsKey('lightning_address') ? data['lightning_address'] : ''; - _logger.info('lnurlpay webhook registered: $webhookUrl, lnurl = $lnurl, lnAddress = $lnAddress'); - await _breezPreferences.setLnUrlPayKey(webhookUrl); - emit(WebhookState(lnurlPayUrl: lnurl, lnAddress: lnAddress)); - } else { - // TODO(erdemyerebasmaz): Handle username conflicts(only when user has created a new wallet) - // Add a random four-digit identifier, a discriminator, as a suffix if user's username is taken(~1/600 probability of conflict) - // Add a retry & randomizer logic until first registration succeeds - // TODO(erdemyerebasmaz): Handle custom username conflicts - throw jsonResponse.body; - } - } - - Future _invalidateLnurlPay( - WalletInfo walletInfo, - String toInvalidate, - ) async { - final String lnurlWebhookUrl = '$lnurlServiceURL/lnurlpay/${walletInfo.pubkey}'; - final int currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final SignMessageRequest req = SignMessageRequest(message: '$currentTime-$toInvalidate'); - final SignMessageResponse? signMessageRes = _breezSdkLiquid.instance?.signMessage(req: req); - if (signMessageRes == null) { - throw Exception('Missing signature'); - } - final Uri uri = Uri.parse(lnurlWebhookUrl); - final http.Response response = await http.delete( - uri, - body: jsonEncode( - RemoveWebhookRequest( - time: currentTime, - webhookUrl: toInvalidate, - signature: signMessageRes.signature, - ).toJson(), - ), - ); - _logger.info('invalidate lnurl pay response: ${response.statusCode}'); - await _breezPreferences.resetLnUrlPayKey(); - } - - Future _generateWebhookURL() async { - final String? token = await _notifications.getToken(); - _logger.info('Retrieved token, registering…'); - final String platform = defaultTargetPlatform == TargetPlatform.iOS - ? 'ios' - : defaultTargetPlatform == TargetPlatform.android - ? 'android' - : ''; - if (platform.isEmpty) { - throw Exception('Notifications for platform is not supported'); + emit( + WebhookState( + lnurlPayErrorTitle: 'Failed to update Lightning Address username:', + lnurlPayError: err.toString(), + ), + ); } - return '$notifierServiceURL/api/v1/notify?platform=$platform&token=$token'; } } diff --git a/lib/cubit/webhook/webhook_service.dart b/lib/cubit/webhook/webhook_service.dart new file mode 100644 index 00000000..09205f5c --- /dev/null +++ b/lib/cubit/webhook/webhook_service.dart @@ -0,0 +1,41 @@ +import 'package:breez_sdk_liquid/breez_sdk_liquid.dart'; +import 'package:firebase_notifications_client/firebase_notifications_client.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; + +final Logger _logger = Logger('WebhookService'); + +class WebhookService { + static const String notifierServiceURL = 'https://notifier.breez.technology'; + + final BreezSDKLiquid _breezSdkLiquid; + final NotificationsClient _notifications; + + WebhookService(this._breezSdkLiquid, this._notifications); + + Future generateWebhookURL() async { + final String? token = await _notifications.getToken(); + _logger.info('Retrieved token, generating webhook URL.'); + final String platform = defaultTargetPlatform == TargetPlatform.iOS + ? 'ios' + : defaultTargetPlatform == TargetPlatform.android + ? 'android' + : ''; + if (platform.isEmpty) { + throw Exception('Notifications for platform is not supported'); + } + final String webhookUrl = '$notifierServiceURL/api/v1/notify?platform=$platform&token=$token'; + _logger.info('Generated webhook URL: $webhookUrl'); + return webhookUrl; + } + + Future registerWebhook(String webhookUrl) async { + try { + await _breezSdkLiquid.instance?.registerWebhook(webhookUrl: webhookUrl); + _logger.info('Registered webhook: $webhookUrl'); + } catch (err) { + _logger.warning('Failed to register webhook: $err'); + rethrow; + } + } +} diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart index 72ef63bb..cc761377 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart @@ -2,7 +2,6 @@ import 'package:breez_translations/breez_translations_locales.dart'; import 'package:breez_translations/generated/breez_translations.dart'; import 'package:email_validator/email_validator.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:l_breez/cubit/cubit.dart'; import 'package:l_breez/widgets/widgets.dart'; @@ -116,21 +115,14 @@ class _UpdateLnAddressUsernameBottomSheetState extends State[ - TextInputFormatter.withFunction( - (_, TextEditingValue newValue) => newValue.copyWith( - text: newValue.text.replaceAll(',', '.'), - ), - ), - ], - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: TextInputType.emailAddress, autofocus: true, validator: (String? value) { if (value == null || value.isEmpty) { // TODO(erdemyerebasmaz): Add these messages to Breez-Translations return 'Please enter a username'; } - final String email = '$value@${widget.lnAddress.split('@').last}'; + final String email = '${value.trim()}@${widget.lnAddress.split('@').last}'; // TODO(erdemyerebasmaz): Add these messages to Breez-Translations return EmailValidator.validate(email) ? null : 'Invalid username.'; }, @@ -158,7 +150,7 @@ class _UpdateLnAddressUsernameBottomSheetState extends State(); - webhookCubit.refreshWebhooks(username: _usernameController.text); + webhookCubit.updateLnAddressUsername(username: _usernameController.text); Navigator.pop(context); } }, From 1bab4102b9459167c50bee10efa4637cf6a48362 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Thu, 23 Jan 2025 16:30:31 +0300 Subject: [PATCH 06/12] Differentiate webhook & lnurlpay service errors Add toString to WebhookState --- lib/cubit/webhook/webhook_cubit.dart | 16 +++++++++++---- lib/cubit/webhook/webhook_state.dart | 20 +++++++++++++++++++ .../receive_lightning_address_page.dart | 10 +++++----- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/lib/cubit/webhook/webhook_cubit.dart b/lib/cubit/webhook/webhook_cubit.dart index d09f5b27..8515a982 100644 --- a/lib/cubit/webhook/webhook_cubit.dart +++ b/lib/cubit/webhook/webhook_cubit.dart @@ -47,15 +47,21 @@ class WebhookCubit extends Cubit { _logger.warning('Failed to refresh webhooks: $err'); emit( WebhookState( - lnurlPayErrorTitle: 'Failed to refresh Lightning Address:', - lnurlPayError: err.toString(), + webhookError: 'Failed to refresh Lightning Address:', + webhookErrorTitle: err.toString(), ), ); } } Future updateLnAddressUsername({required String username}) async { - emit(WebhookState(isLoading: true)); + emit( + WebhookState( + isLoading: true, + lnAddress: state.lnAddress, + lnurlPayUrl: state.lnurlPayUrl, + ), + ); try { final GetInfoResponse? walletInfo = await _breezSdkLiquid.instance?.getInfo(); if (walletInfo != null) { @@ -74,11 +80,13 @@ class WebhookCubit extends Cubit { } } catch (err) { emit( - WebhookState( + state.copyWith( lnurlPayErrorTitle: 'Failed to update Lightning Address username:', lnurlPayError: err.toString(), ), ); + } finally { + emit(state.copyWith(isLoading: false)); } } } diff --git a/lib/cubit/webhook/webhook_state.dart b/lib/cubit/webhook/webhook_state.dart index 73848399..ecc0ba49 100644 --- a/lib/cubit/webhook/webhook_state.dart +++ b/lib/cubit/webhook/webhook_state.dart @@ -1,6 +1,8 @@ class WebhookState { final String? lnurlPayUrl; final String? lnAddress; + final String? webhookError; + final String? webhookErrorTitle; final String? lnurlPayError; final String? lnurlPayErrorTitle; final bool isLoading; @@ -8,6 +10,8 @@ class WebhookState { WebhookState({ this.lnurlPayUrl, this.lnAddress, + this.webhookError, + this.webhookErrorTitle, this.lnurlPayError, this.lnurlPayErrorTitle, this.isLoading = false, @@ -16,6 +20,8 @@ class WebhookState { WebhookState copyWith({ String? lnurlPayUrl, String? lnAddress, + String? webhookError, + String? webhookErrorTitle, String? lnurlPayError, String? lnurlPayErrorTitle, bool? isLoading, @@ -23,11 +29,25 @@ class WebhookState { return WebhookState( lnurlPayUrl: lnurlPayUrl ?? this.lnurlPayUrl, lnAddress: lnAddress ?? this.lnAddress, + webhookError: webhookError ?? this.webhookError, + webhookErrorTitle: webhookErrorTitle ?? this.webhookErrorTitle, lnurlPayError: lnurlPayError ?? this.lnurlPayError, lnurlPayErrorTitle: lnurlPayErrorTitle ?? this.lnurlPayErrorTitle, isLoading: isLoading ?? this.isLoading, ); } + + @override + String toString() { + return 'WebhookState(' + 'lnurlPayUrl: $lnurlPayUrl, ' + 'lnAddress: $lnAddress, ' + 'webhookError: $webhookError, ' + 'webhookErrorTitle: $webhookErrorTitle, ' + 'lnurlPayError: $lnurlPayError, ' + 'lnurlPayErrorTitle: $lnurlPayErrorTitle, ' + 'isLoading: $isLoading)'; + } } class AddWebhookRequest { diff --git a/lib/routes/receive_payment/ln_address/receive_lightning_address_page.dart b/lib/routes/receive_payment/ln_address/receive_lightning_address_page.dart index 2cfa8051..b760739e 100644 --- a/lib/routes/receive_payment/ln_address/receive_lightning_address_page.dart +++ b/lib/routes/receive_payment/ln_address/receive_lightning_address_page.dart @@ -45,11 +45,11 @@ class ReceiveLightningAddressPageState extends State( builder: (BuildContext context, PaymentLimitsState snapshot) { - return webhookState.lnurlPayError != null || snapshot.hasError + return webhookState.webhookError != null || snapshot.hasError ? SingleButtonBottomBar( stickToBottom: true, text: texts.invoice_ln_address_action_retry, - onPressed: webhookState.lnurlPayError != null + onPressed: webhookState.webhookError != null ? () => _refreshWebhooks() : () { final PaymentLimitsCubit paymentLimitsCubit = From 6c158c010fc5cab3d0f9dd66149b38f335bd5464 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Thu, 23 Jan 2025 16:31:26 +0300 Subject: [PATCH 07/12] Wait for update to finish before popping ln address sheet and show flushbars. Remove loader. --- ...date_ln_address_username_bottom_sheet.dart | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart index cc761377..80d9c05f 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart @@ -50,12 +50,6 @@ class _UpdateLnAddressUsernameBottomSheetState extends State( builder: (BuildContext context, WebhookState state) { - if (state.isLoading || - (state.lnurlPayError != null && state.lnurlPayError!.isNotEmpty) || - (state.lnurlPayUrl != null && state.lnurlPayUrl!.isEmpty)) { - return const Center(child: CircularProgressIndicator()); - } - final ThemeData themeData = Theme.of(context); final Color errorBorderColor = themeData.colorScheme.error; @@ -140,7 +134,7 @@ class _UpdateLnAddressUsernameBottomSheetState extends State(); - webhookCubit.updateLnAddressUsername(username: _usernameController.text); - Navigator.pop(context); + await webhookCubit.updateLnAddressUsername(username: _usernameController.text); + if (context.mounted) { + if (state.lnurlPayError == null) { + Navigator.pop(context); + showFlushbar( + context, + message: 'Successfully updated Lightning Address username.', + ); + } else { + Navigator.pop(context); + showFlushbar( + context, + message: 'Failed to update Lightning Address username.', + ); + } + } } }, ), From 5ae15215d770e78469613e2b00245cdcf457fcfa Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Thu, 23 Jan 2025 16:47:35 +0300 Subject: [PATCH 08/12] convert username to lowercase and trim whitespace --- .../widgets/update_ln_address_username_bottom_sheet.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart index 80d9c05f..4e3390e2 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart @@ -144,7 +144,9 @@ class _UpdateLnAddressUsernameBottomSheetState extends State(); - await webhookCubit.updateLnAddressUsername(username: _usernameController.text); + await webhookCubit.updateLnAddressUsername( + username: _usernameController.text.toLowerCase().trim(), + ); if (context.mounted) { if (state.lnurlPayError == null) { Navigator.pop(context); From 8714a29ace0f1d37d881ff0e400fe931eae0b9da Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Mon, 27 Jan 2025 07:31:17 +0300 Subject: [PATCH 09/12] Return lnurl data from updateLnAddressUsername --- lib/cubit/webhook/lnurl_pay_service.dart | 7 ++++--- lib/cubit/webhook/webhook_cubit.dart | 25 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/cubit/webhook/lnurl_pay_service.dart b/lib/cubit/webhook/lnurl_pay_service.dart index 418f6313..73263e82 100644 --- a/lib/cubit/webhook/lnurl_pay_service.dart +++ b/lib/cubit/webhook/lnurl_pay_service.dart @@ -114,11 +114,12 @@ class LnUrlPayService { } } - Future updateLnAddressUsername(WalletInfo walletInfo, String username) async { + Future> updateLnAddressUsername(WalletInfo walletInfo, String username) async { final String? webhookUrl = await getLnUrlPayKey(); - if (webhookUrl != null) { - await registerLnurlpay(walletInfo, webhookUrl, username: username); + if (webhookUrl == null) { + throw Exception('Failed to retrieve registered webhook.'); } + return await registerLnurlpay(walletInfo, webhookUrl, username: username); } Future _invalidateLnurlPay(String pubKey, String toInvalidate) async { diff --git a/lib/cubit/webhook/webhook_cubit.dart b/lib/cubit/webhook/webhook_cubit.dart index 8515a982..d59bf529 100644 --- a/lib/cubit/webhook/webhook_cubit.dart +++ b/lib/cubit/webhook/webhook_cubit.dart @@ -64,20 +64,19 @@ class WebhookCubit extends Cubit { ); try { final GetInfoResponse? walletInfo = await _breezSdkLiquid.instance?.getInfo(); - if (walletInfo != null) { - await _lnUrlPayService.updateLnAddressUsername(walletInfo.walletInfo, username); - final Map lnUrlData = await _lnUrlPayService.registerLnurlpay( - walletInfo.walletInfo, - await _lnUrlPayService.getLnUrlPayKey() ?? '', - username: username, - ); - emit( - WebhookState( - lnurlPayUrl: lnUrlData['lnurl'], - lnAddress: lnUrlData['lnAddress'], - ), - ); + if (walletInfo == null) { + throw Exception('Failed to retrieve wallet info.'); } + final Map lnUrlData = await _lnUrlPayService.updateLnAddressUsername( + walletInfo.walletInfo, + username, + ); + emit( + WebhookState( + lnurlPayUrl: lnUrlData['lnurl'], + lnAddress: lnUrlData['lnAddress'], + ), + ); } catch (err) { emit( state.copyWith( From 978c5958d56edaa2d1841d8860a4a4ade67479cd Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Mon, 27 Jan 2025 07:35:38 +0300 Subject: [PATCH 10/12] fix: correct the error & title value for webhook registration errors misc: change ordering of params of emitted states for readability. --- lib/cubit/webhook/webhook_cubit.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/cubit/webhook/webhook_cubit.dart b/lib/cubit/webhook/webhook_cubit.dart index d59bf529..77cb2536 100644 --- a/lib/cubit/webhook/webhook_cubit.dart +++ b/lib/cubit/webhook/webhook_cubit.dart @@ -47,8 +47,8 @@ class WebhookCubit extends Cubit { _logger.warning('Failed to refresh webhooks: $err'); emit( WebhookState( - webhookError: 'Failed to refresh Lightning Address:', - webhookErrorTitle: err.toString(), + webhookError: err.toString(), + webhookErrorTitle: 'Failed to refresh Lightning Address:', ), ); } @@ -58,8 +58,8 @@ class WebhookCubit extends Cubit { emit( WebhookState( isLoading: true, - lnAddress: state.lnAddress, lnurlPayUrl: state.lnurlPayUrl, + lnAddress: state.lnAddress, ), ); try { @@ -80,8 +80,8 @@ class WebhookCubit extends Cubit { } catch (err) { emit( state.copyWith( - lnurlPayErrorTitle: 'Failed to update Lightning Address username:', lnurlPayError: err.toString(), + lnurlPayErrorTitle: 'Failed to update Lightning Address username:', ), ); } finally { From 3e0b7a65f6665d18e81dcf8e32961076cca70bd0 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Mon, 27 Jan 2025 07:34:38 +0300 Subject: [PATCH 11/12] Store & use the LN Address username when refreshing webhooks --- lib/cubit/webhook/webhook_cubit.dart | 36 +++++++++---------- lib/cubit/webhook/webhook_state.dart | 2 ++ ...date_ln_address_username_bottom_sheet.dart | 2 +- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/lib/cubit/webhook/webhook_cubit.dart b/lib/cubit/webhook/webhook_cubit.dart index 77cb2536..d5f6100e 100644 --- a/lib/cubit/webhook/webhook_cubit.dart +++ b/lib/cubit/webhook/webhook_cubit.dart @@ -26,23 +26,22 @@ class WebhookCubit extends Cubit { emit(WebhookState(isLoading: true)); try { walletInfo = walletInfo ?? (await _breezSdkLiquid.instance?.getInfo())?.walletInfo; - if (walletInfo != null) { - final String webhookUrl = await _webhookService.generateWebhookURL(); - await _webhookService.registerWebhook(webhookUrl); - final Map lnUrlData = await _lnUrlPayService.registerLnurlpay( - walletInfo, - webhookUrl, - username: username, - ); - emit( - WebhookState( - lnurlPayUrl: lnUrlData['lnurl'], - lnAddress: lnUrlData['lnAddress'], - ), - ); - } else { - throw Exception('Unable to retrieve wallet information.'); + if (walletInfo == null) { + throw Exception('Failed to retrieve wallet info.'); } + final String webhookUrl = await _webhookService.generateWebhookURL(); + await _webhookService.registerWebhook(webhookUrl); + final Map lnUrlData = await _lnUrlPayService.registerLnurlpay( + walletInfo, + webhookUrl, + username: username ?? state.lnAddressUsername, + ); + emit( + WebhookState( + lnurlPayUrl: lnUrlData['lnurl'], + lnAddress: lnUrlData['lnAddress'], + ), + ); } catch (err) { _logger.warning('Failed to refresh webhooks: $err'); emit( @@ -54,7 +53,7 @@ class WebhookCubit extends Cubit { } } - Future updateLnAddressUsername({required String username}) async { + Future updateLnAddressUsername({required String lnAddressUsername}) async { emit( WebhookState( isLoading: true, @@ -69,12 +68,13 @@ class WebhookCubit extends Cubit { } final Map lnUrlData = await _lnUrlPayService.updateLnAddressUsername( walletInfo.walletInfo, - username, + lnAddressUsername, ); emit( WebhookState( lnurlPayUrl: lnUrlData['lnurl'], lnAddress: lnUrlData['lnAddress'], + lnAddressUsername: lnAddressUsername, ), ); } catch (err) { diff --git a/lib/cubit/webhook/webhook_state.dart b/lib/cubit/webhook/webhook_state.dart index ecc0ba49..2116cbe1 100644 --- a/lib/cubit/webhook/webhook_state.dart +++ b/lib/cubit/webhook/webhook_state.dart @@ -1,6 +1,7 @@ class WebhookState { final String? lnurlPayUrl; final String? lnAddress; + final String? lnAddressUsername; final String? webhookError; final String? webhookErrorTitle; final String? lnurlPayError; @@ -10,6 +11,7 @@ class WebhookState { WebhookState({ this.lnurlPayUrl, this.lnAddress, + this.lnAddressUsername, this.webhookError, this.webhookErrorTitle, this.lnurlPayError, diff --git a/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart index 4e3390e2..0bdbb6d9 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/update_ln_address_username_bottom_sheet.dart @@ -145,7 +145,7 @@ class _UpdateLnAddressUsernameBottomSheetState extends State(); await webhookCubit.updateLnAddressUsername( - username: _usernameController.text.toLowerCase().trim(), + lnAddressUsername: _usernameController.text.toLowerCase().trim(), ); if (context.mounted) { if (state.lnurlPayError == null) { From 71197071097915cfe41f0abec7806d70965627c4 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Mon, 27 Jan 2025 14:31:40 +0300 Subject: [PATCH 12/12] Store LN Address Username on secure storage --- lib/cubit/webhook/lnurl_pay_service.dart | 13 +++++++++++++ lib/cubit/webhook/webhook_cubit.dart | 4 ++-- lib/cubit/webhook/webhook_state.dart | 2 -- .../lib/src/breez_preferences.dart | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/lib/cubit/webhook/lnurl_pay_service.dart b/lib/cubit/webhook/lnurl_pay_service.dart index 73263e82..2d379c07 100644 --- a/lib/cubit/webhook/lnurl_pay_service.dart +++ b/lib/cubit/webhook/lnurl_pay_service.dart @@ -143,6 +143,7 @@ class LnUrlPayService { ); _logger.info('invalidate lnurl pay response: ${response.statusCode}'); await resetLnUrlPayKey(); + await resetLnAddressUsername(); } Future setLnUrlPayKey({required String webhookUrl}) async { @@ -156,4 +157,16 @@ class LnUrlPayService { Future resetLnUrlPayKey() async { return await _breezPreferences.resetLnUrlPayKey(); } + + Future setLnAddressUsername({required String lnAddressUsername}) async { + return await _breezPreferences.setLnAddressUsername(lnAddressUsername); + } + + Future getLnAddressUsername() async { + return await _breezPreferences.getLnAddressUsername(); + } + + Future resetLnAddressUsername() async { + return await _breezPreferences.resetLnAddressUsername(); + } } diff --git a/lib/cubit/webhook/webhook_cubit.dart b/lib/cubit/webhook/webhook_cubit.dart index d5f6100e..1056f9b2 100644 --- a/lib/cubit/webhook/webhook_cubit.dart +++ b/lib/cubit/webhook/webhook_cubit.dart @@ -34,7 +34,7 @@ class WebhookCubit extends Cubit { final Map lnUrlData = await _lnUrlPayService.registerLnurlpay( walletInfo, webhookUrl, - username: username ?? state.lnAddressUsername, + username: username ?? await _lnUrlPayService.getLnAddressUsername(), ); emit( WebhookState( @@ -70,11 +70,11 @@ class WebhookCubit extends Cubit { walletInfo.walletInfo, lnAddressUsername, ); + await _lnUrlPayService.setLnAddressUsername(lnAddressUsername: lnAddressUsername); emit( WebhookState( lnurlPayUrl: lnUrlData['lnurl'], lnAddress: lnUrlData['lnAddress'], - lnAddressUsername: lnAddressUsername, ), ); } catch (err) { diff --git a/lib/cubit/webhook/webhook_state.dart b/lib/cubit/webhook/webhook_state.dart index 2116cbe1..ecc0ba49 100644 --- a/lib/cubit/webhook/webhook_state.dart +++ b/lib/cubit/webhook/webhook_state.dart @@ -1,7 +1,6 @@ class WebhookState { final String? lnurlPayUrl; final String? lnAddress; - final String? lnAddressUsername; final String? webhookError; final String? webhookErrorTitle; final String? lnurlPayError; @@ -11,7 +10,6 @@ class WebhookState { WebhookState({ this.lnurlPayUrl, this.lnAddress, - this.lnAddressUsername, this.webhookError, this.webhookErrorTitle, this.lnurlPayError, diff --git a/packages/breez_preferences/lib/src/breez_preferences.dart b/packages/breez_preferences/lib/src/breez_preferences.dart index a08d069d..4a2deee5 100644 --- a/packages/breez_preferences/lib/src/breez_preferences.dart +++ b/packages/breez_preferences/lib/src/breez_preferences.dart @@ -14,6 +14,7 @@ const String _kPaymentOptionExemptFee = 'payment_options_exempt_fee'; const String _kPaymentOptionChannelSetupFeeLimit = 'payment_options_channel_setup_fee_limit'; const String _kReportPrefKey = 'report_preference_key'; const String _kLnUrlPayKey = 'lnurlpay_key'; +const String _kLnAddressUsername = 'ln_address_name'; const String _kProfileName = 'profile_name'; final Logger _logger = Logger('BreezPreferences'); @@ -124,4 +125,20 @@ class BreezPreferences { final SharedPreferences prefs = await SharedPreferences.getInstance(); await prefs.setString(_kProfileName, profileName); } + + Future getLnAddressUsername() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + return prefs.getString(_kLnAddressUsername); + } + + Future setLnAddressUsername(String lnAddressUsername) async { + _logger.info('Set LN Address Name: $lnAddressUsername'); + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kLnAddressUsername, lnAddressUsername); + } + + Future resetLnAddressUsername() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kLnAddressUsername); + } }