From 58187b84f741352ebc6c9bf344e55fb9431126cf Mon Sep 17 00:00:00 2001 From: Ross Savage Date: Fri, 10 Jan 2025 14:22:56 +0100 Subject: [PATCH 01/29] Register user profile name as username for lightning address --- lib/app/view/app.dart | 2 +- .../user_profile/user_profile_cubit.dart | 22 ++++++++++++++++--- lib/cubit/webhook/webhook_cubit.dart | 15 ++++++++----- lib/cubit/webhook/webhook_state.dart | 3 +++ .../widgets/breez_avatar_dialog.dart | 10 ++++++--- .../widgets/breez_navigation_drawer.dart | 4 ++-- .../widgets/balance_text.dart | 8 +++---- .../receive_lightning_address_page.dart | 8 +++---- .../lib/src/breez_preferences.dart | 12 ++++++++++ 9 files changed, 62 insertions(+), 22 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index c476cedf..24417a02 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -46,7 +46,7 @@ class App extends StatelessWidget { ), ), BlocProvider( - create: (BuildContext context) => UserProfileCubit(), + create: (BuildContext context) => UserProfileCubit(injector.breezPreferences), ), BlocProvider( lazy: false, diff --git a/lib/cubit/user_profile/user_profile_cubit.dart b/lib/cubit/user_profile/user_profile_cubit.dart index 5e0d9f00..138f3452 100644 --- a/lib/cubit/user_profile/user_profile_cubit.dart +++ b/lib/cubit/user_profile/user_profile_cubit.dart @@ -3,6 +3,7 @@ import 'dart:io' as io; import 'dart:io'; import 'dart:typed_data'; +import 'package:breez_preferences/breez_preferences.dart'; import 'package:breez_translations/breez_translations_locales.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:l_breez/cubit/cubit.dart'; @@ -17,7 +18,11 @@ export 'user_profile_state.dart'; final Logger _logger = Logger('UserProfileCubit'); class UserProfileCubit extends Cubit with HydratedMixin { - UserProfileCubit() : super(UserProfileState.initial()) { + final BreezPreferences _breezPreferences; + + UserProfileCubit( + this._breezPreferences, + ) : super(UserProfileState.initial()) { hydrate(); UserProfileState profile = state; _logger.info('State: ${profile.profileSettings.toJson()}'); @@ -36,9 +41,17 @@ class UserProfileCubit extends Cubit with HydratedMixin _setProfileName(String name) async { + final String? profileName = await _breezPreferences.getProfileName(); + if (profileName == null) { + await _breezPreferences.setProfileName(name); + } + } + Future saveProfileImage(Uint8List bytes) async { try { _logger.info('Saving profile image, size: ${bytes.length} bytes'); @@ -60,7 +73,7 @@ class UserProfileCubit extends Cubit with HydratedMixin updateProfile({ String? name, String? color, String? animal, @@ -68,7 +81,7 @@ class UserProfileCubit extends Cubit with HydratedMixin with HydratedMixin { this._notifications, ) : super(WebhookState()) { _breezSdkLiquid.walletInfoStream.first.then( - (GetInfoResponse getInfoResponse) => refreshLnurlPay(walletInfo: getInfoResponse.walletInfo), + (GetInfoResponse getInfoResponse) => refreshWebhooks(walletInfo: getInfoResponse.walletInfo), ); } - Future refreshLnurlPay({WalletInfo? walletInfo}) async { - _logger.info('Refreshing Lightning Address'); + Future refreshWebhooks({WalletInfo? walletInfo}) async { + _logger.info('Refreshing webhooks'); emit(WebhookState(isLoading: true)); try { walletInfo = walletInfo ?? (await _breezSdkLiquid.instance?.getInfo())?.walletInfo; @@ -77,8 +77,12 @@ class WebhookCubit extends Cubit { if (lastUsedLnurlPay != null && lastUsedLnurlPay != webhookUrl) { await _invalidateLnurlPay(walletInfo, lastUsedLnurlPay); } + String? username = await _breezPreferences.getProfileName(); + username = username?.replaceAll(' ', ''); final int currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final SignMessageRequest req = SignMessageRequest(message: '$currentTime-$webhookUrl'); + final String optionalUsernameKey = username != null ? '-$username' : ''; + final SignMessageRequest req = + SignMessageRequest(message: '$currentTime-$webhookUrl$optionalUsernameKey'); final SignMessageResponse? signMessageRes = _breezSdkLiquid.instance?.signMessage(req: req); if (signMessageRes == null) { throw Exception('Missing signature'); @@ -91,13 +95,14 @@ class WebhookCubit extends Cubit { AddWebhookRequest( time: currentTime, webhookUrl: webhookUrl, + username: username, signature: signMessageRes.signature, ).toJson(), ), ); if (jsonResponse.statusCode == 200) { final Map data = jsonDecode(jsonResponse.body); - final String lnurl = data['lnurl']; + final String lnurl = data.containsKey('lightning_address') ? data['lightning_address'] : data['lnurl']; _logger.info('lnurlpay webhook registered: $webhookUrl, lnurl = $lnurl'); await _breezPreferences.setLnUrlPayKey(webhookUrl); return lnurl; diff --git a/lib/cubit/webhook/webhook_state.dart b/lib/cubit/webhook/webhook_state.dart index 7cba57f6..a4cadf42 100644 --- a/lib/cubit/webhook/webhook_state.dart +++ b/lib/cubit/webhook/webhook_state.dart @@ -29,17 +29,20 @@ class WebhookState { class AddWebhookRequest { final int time; final String webhookUrl; + final String? username; final String signature; AddWebhookRequest({ required this.time, required this.webhookUrl, + required this.username, required this.signature, }); Map toJson() => { 'time': time, 'webhook_url': webhookUrl, + 'username': username, 'signature': signature, }; } diff --git a/lib/routes/home/widgets/home_drawer/widgets/breez_avatar_dialog.dart b/lib/routes/home/widgets/home_drawer/widgets/breez_avatar_dialog.dart index 98f8179b..ad2e1012 100644 --- a/lib/routes/home/widgets/home_drawer/widgets/breez_avatar_dialog.dart +++ b/lib/routes/home/widgets/home_drawer/widgets/breez_avatar_dialog.dart @@ -26,6 +26,7 @@ class BreezAvatarDialog extends StatefulWidget { class BreezAvatarDialogState extends State { late UserProfileCubit userProfileCubit; + late WebhookCubit webhookCubit; final TextEditingController nameInputController = TextEditingController(); final AutoSizeGroup autoSizeGroup = AutoSizeGroup(); CroppedFile? pickedImage; @@ -36,6 +37,7 @@ class BreezAvatarDialogState extends State { void initState() { super.initState(); userProfileCubit = context.read(); + webhookCubit = context.read(); nameInputController.text = userProfileCubit.state.profileSettings.name ?? ''; } @@ -144,13 +146,15 @@ class BreezAvatarDialogState extends State { final String? userName = nameInputController.text.isNotEmpty ? nameInputController.text : userProfileCubit.state.profileSettings.name; - userProfileCubit.updateProfile(name: userName); + await userProfileCubit.updateProfile(name: userName); + await webhookCubit.refreshWebhooks(); await saveProfileImage(); setState(() { isUploading = false; }); navigator.pop(); } catch (e) { + await userProfileCubit.updateProfile(name: userProfileCubit.state.profileSettings.name); setState(() { isUploading = false; pickedImage = null; @@ -223,9 +227,9 @@ class BreezAvatarDialogState extends State { _logger.info('saveProfileImage ${pickedImage?.path} $randomAvatarPath'); if (pickedImage != null) { final String profileImageFilePath = await userProfileCubit.saveProfileImage(await scaleAndFormatPNG()); - userProfileCubit.updateProfile(image: profileImageFilePath); + await userProfileCubit.updateProfile(image: profileImageFilePath); } else if (randomAvatarPath != null) { - userProfileCubit.updateProfile(image: randomAvatarPath); + await userProfileCubit.updateProfile(image: randomAvatarPath); } } diff --git a/lib/routes/home/widgets/home_drawer/widgets/breez_navigation_drawer.dart b/lib/routes/home/widgets/home_drawer/widgets/breez_navigation_drawer.dart index 93619cee..cc88d953 100644 --- a/lib/routes/home/widgets/home_drawer/widgets/breez_navigation_drawer.dart +++ b/lib/routes/home/widgets/home_drawer/widgets/breez_navigation_drawer.dart @@ -380,9 +380,9 @@ class _ExpansionTile extends StatelessWidget { ), ) .toList(), - onExpansionChanged: (bool isExpanded) { + onExpansionChanged: (bool isExpanded) async { final UserProfileCubit userProfileCubit = context.read(); - userProfileCubit.updateProfile(expandPreferences: isExpanded); + await userProfileCubit.updateProfile(expandPreferences: isExpanded); if (isExpanded) { Timer( const Duration(milliseconds: 200), diff --git a/lib/routes/home/widgets/wallet_dashboard/widgets/balance_text.dart b/lib/routes/home/widgets/wallet_dashboard/widgets/balance_text.dart index 68287e34..3b3acf40 100644 --- a/lib/routes/home/widgets/wallet_dashboard/widgets/balance_text.dart +++ b/lib/routes/home/widgets/wallet_dashboard/widgets/balance_text.dart @@ -44,7 +44,7 @@ class _BalanceTextState extends State { }, ), ), - onPressed: () => _changeBtcCurrency(context), + onPressed: () async => await _changeBtcCurrency(context), child: widget.hiddenBalance ? Text( texts.wallet_dashboard_balance_hide, @@ -78,13 +78,13 @@ class _BalanceTextState extends State { ); } - void _changeBtcCurrency(BuildContext context) { + Future _changeBtcCurrency(BuildContext context) async { final UserProfileCubit userProfileCubit = context.read(); final CurrencyCubit currencyCubit = context.read(); final CurrencyState currencyState = currencyCubit.state; if (widget.hiddenBalance == true) { - userProfileCubit.updateProfile(hideBalance: false); + await userProfileCubit.updateProfile(hideBalance: false); return; } final List list = BitcoinCurrency.currencies; @@ -93,7 +93,7 @@ class _BalanceTextState extends State { ); final int nextCurrencyIndex = (index + 1) % list.length; if (nextCurrencyIndex == 1) { - userProfileCubit.updateProfile(hideBalance: true); + await userProfileCubit.updateProfile(hideBalance: true); } currencyCubit.setBitcoinTicker(list[nextCurrencyIndex].tickerSymbol); } 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 c933f7b9..f26aa149 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 @@ -23,12 +23,12 @@ class ReceiveLightningAddressPageState extends State(); - webhookCubit.refreshLnurlPay(); + webhookCubit.refreshWebhooks(); } @override @@ -84,7 +84,7 @@ class ReceiveLightningAddressPageState extends State _refreshLnurlPay() + ? () => _refreshWebhooks() : () { final PaymentLimitsCubit paymentLimitsCubit = context.read(); diff --git a/packages/breez_preferences/lib/src/breez_preferences.dart b/packages/breez_preferences/lib/src/breez_preferences.dart index 40de8067..a08d069d 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 _kProfileName = 'profile_name'; final Logger _logger = Logger('BreezPreferences'); @@ -112,4 +113,15 @@ class BreezPreferences { final SharedPreferences prefs = await SharedPreferences.getInstance(); await prefs.remove(_kLnUrlPayKey); } + + Future getProfileName() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + return prefs.getString(_kProfileName); + } + + Future setProfileName(String profileName) async { + _logger.info('set profile name: $profileName'); + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kProfileName, profileName); + } } From 7b86b7aee2b0425c6dd20507c32d023201500f21 Mon Sep 17 00:00:00 2001 From: Ross Savage Date: Fri, 10 Jan 2025 16:29:31 +0100 Subject: [PATCH 02/29] Decouple users Display Name from their LN Address username --- .../home/widgets/home_drawer/widgets/breez_avatar_dialog.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/routes/home/widgets/home_drawer/widgets/breez_avatar_dialog.dart b/lib/routes/home/widgets/home_drawer/widgets/breez_avatar_dialog.dart index ad2e1012..5ab01c37 100644 --- a/lib/routes/home/widgets/home_drawer/widgets/breez_avatar_dialog.dart +++ b/lib/routes/home/widgets/home_drawer/widgets/breez_avatar_dialog.dart @@ -26,7 +26,6 @@ class BreezAvatarDialog extends StatefulWidget { class BreezAvatarDialogState extends State { late UserProfileCubit userProfileCubit; - late WebhookCubit webhookCubit; final TextEditingController nameInputController = TextEditingController(); final AutoSizeGroup autoSizeGroup = AutoSizeGroup(); CroppedFile? pickedImage; @@ -37,7 +36,6 @@ class BreezAvatarDialogState extends State { void initState() { super.initState(); userProfileCubit = context.read(); - webhookCubit = context.read(); nameInputController.text = userProfileCubit.state.profileSettings.name ?? ''; } @@ -147,7 +145,6 @@ class BreezAvatarDialogState extends State { ? nameInputController.text : userProfileCubit.state.profileSettings.name; await userProfileCubit.updateProfile(name: userName); - await webhookCubit.refreshWebhooks(); await saveProfileImage(); setState(() { isUploading = false; From 81866b80937d1fb9e399adc46869b2c5bb0d0374 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Thu, 16 Jan 2025 20:15:30 +0300 Subject: [PATCH 03/29] 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 04/29] 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 05/29] 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 06/29] 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 07/29] 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 08/29] 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 09/29] 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 10/29] 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 11/29] 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 12/29] 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 13/29] 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 14/29] 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); + } } From 5bfd299ded1daf2861d9f459a43e0f699b03c85b Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Mon, 27 Jan 2025 22:30:18 +0300 Subject: [PATCH 15/29] [WIP] Refactor WebhookCubit & replace it with LnAddressCubit Fixes #318 - Store Ln Address setup information on secure storage - Change secure storage fn names for consistency - Display loader for Done button on Update LN Address Username bottom sheet while cubit is loading - Decouple LN Address & LN Address username update status - Show conflict errors on Update LN Address Username bottom sheet - Add retry & randomizer logic for username conflicts on registration - Create LN username formatter & text input formatter - Create LnAddressCubitFactory --- lib/app/view/app.dart | 15 +- lib/cubit/cubit.dart | 2 +- lib/cubit/ln_address/ln_address_cubit.dart | 228 +++++++++++++++ lib/cubit/ln_address/ln_address_state.dart | 35 +++ lib/cubit/ln_address/models/exceptions.dart | 49 ++++ .../models/ln_address_registration.dart | 76 +++++ .../models/ln_address_update_status.dart | 25 ++ lib/cubit/ln_address/models/models.dart | 3 + .../services/ln_address_service.dart | 122 ++++++++ lib/cubit/ln_address/services/services.dart | 2 + .../ln_address/services/webhook_service.dart | 54 ++++ .../ln_address/utils/username_formatter.dart | 51 ++++ .../ln_address/utils/username_generator.dart | 16 ++ lib/cubit/ln_address/utils/utils.dart | 2 + lib/cubit/webhook/lnurl_pay_service.dart | 172 ------------ lib/cubit/webhook/webhook_cubit.dart | 91 ------ lib/cubit/webhook/webhook_service.dart | 41 --- lib/cubit/webhook/webhook_state.dart | 90 ------ .../receive_lightning_address_page.dart | 72 +++-- ...date_ln_address_username_bottom_sheet.dart | 263 ++++++++++-------- lib/widgets/single_button_bottom_bar.dart | 26 +- .../lib/src/breez_preferences.dart | 21 +- 22 files changed, 881 insertions(+), 575 deletions(-) create mode 100644 lib/cubit/ln_address/ln_address_cubit.dart create mode 100644 lib/cubit/ln_address/ln_address_state.dart create mode 100644 lib/cubit/ln_address/models/exceptions.dart create mode 100644 lib/cubit/ln_address/models/ln_address_registration.dart create mode 100644 lib/cubit/ln_address/models/ln_address_update_status.dart create mode 100644 lib/cubit/ln_address/models/models.dart create mode 100644 lib/cubit/ln_address/services/ln_address_service.dart create mode 100644 lib/cubit/ln_address/services/services.dart create mode 100644 lib/cubit/ln_address/services/webhook_service.dart create mode 100644 lib/cubit/ln_address/utils/username_formatter.dart create mode 100644 lib/cubit/ln_address/utils/username_generator.dart create mode 100644 lib/cubit/ln_address/utils/utils.dart delete mode 100644 lib/cubit/webhook/lnurl_pay_service.dart delete mode 100644 lib/cubit/webhook/webhook_cubit.dart delete mode 100644 lib/cubit/webhook/webhook_service.dart delete mode 100644 lib/cubit/webhook/webhook_state.dart diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index f9b35e04..56d56beb 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -48,19 +48,8 @@ class App extends StatelessWidget { BlocProvider( create: (BuildContext context) => UserProfileCubit(injector.breezPreferences), ), - BlocProvider( - lazy: false, - create: (BuildContext context) => WebhookCubit( - injector.breezSdkLiquid, - WebhookService( - injector.breezSdkLiquid, - injector.notifications, - ), - LnUrlPayService( - injector.breezSdkLiquid, - injector.breezPreferences, - ), - ), + BlocProvider( + create: (BuildContext context) => LnAddressCubitFactory.create(injector), ), BlocProvider( create: (BuildContext context) => CurrencyCubit(injector.breezSdkLiquid), diff --git a/lib/cubit/cubit.dart b/lib/cubit/cubit.dart index 37d7b805..270ab9d7 100644 --- a/lib/cubit/cubit.dart +++ b/lib/cubit/cubit.dart @@ -8,6 +8,7 @@ export 'connectivity/connectivity_cubit.dart'; export 'csv_exporter.dart'; export 'currency/currency_cubit.dart'; export 'input/input_cubit.dart'; +export 'ln_address/ln_address_cubit.dart'; export 'lnurl/lnurl_cubit.dart'; export 'model/models.dart'; export 'payment_limits/payment_limits_cubit.dart'; @@ -15,4 +16,3 @@ export 'payments/payments_cubit.dart'; export 'refund/refund_cubit.dart'; export 'security/security_cubit.dart'; export 'user_profile/user_profile_cubit.dart'; -export 'webhook/webhook_cubit.dart'; diff --git a/lib/cubit/ln_address/ln_address_cubit.dart b/lib/cubit/ln_address/ln_address_cubit.dart new file mode 100644 index 00000000..e75a0858 --- /dev/null +++ b/lib/cubit/ln_address/ln_address_cubit.dart @@ -0,0 +1,228 @@ +import 'package:breez_preferences/breez_preferences.dart'; +import 'package:breez_sdk_liquid/breez_sdk_liquid.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; +import 'package:l_breez/cubit/cubit.dart'; +import 'package:logging/logging.dart'; +import 'package:service_injector/service_injector.dart'; + +export 'ln_address_state.dart'; +export 'models/models.dart'; +export 'services/services.dart'; +export 'utils/utils.dart'; + +final Logger _logger = Logger('LnAddressCubit'); + +class LnAddressCubit extends Cubit { + final BreezSDKLiquid breezSdkLiquid; + final BreezPreferences breezPreferences; + final LnAddressService lnAddressService; + final WebhookService webhookService; + + LnAddressCubit({ + required this.breezSdkLiquid, + required this.breezPreferences, + required this.lnAddressService, + required this.webhookService, + }) : super(const LnAddressState()); + + Future setupLightningAddress({String? username}) async { + final bool isUpdate = username != null; + _logger.info(isUpdate ? 'Updating username to: $username' : 'Initializing lightning address'); + + emit( + state.copyWith( + status: isUpdate ? state.status : LnAddressStatus.loading, + updateStatus: isUpdate ? const LnAddressUpdateStatus(status: UpdateStatus.loading) : null, + ), + ); + + try { + final LnAddressRegistrationResponse registrationResponse = await _setupAndRegisterLnAddress( + username: username, + ); + + emit( + state.copyWith( + status: LnAddressStatus.success, + lnurl: registrationResponse.lnurl, + lnAddress: registrationResponse.lnAddress, + updateStatus: isUpdate ? const LnAddressUpdateStatus(status: UpdateStatus.success) : null, + ), + ); + } catch (e, stackTrace) { + _logger.severe( + isUpdate ? 'Failed to update username' : 'Failed to initialize lightning address', + e, + stackTrace, + ); + + if (isUpdate) { + final String errorMessage = e is LnAddressRegistrationException + ? (e.responseBody?.isNotEmpty == true ? e.responseBody! : e.message) + : 'Failed to update username'; + + emit( + state.copyWith( + updateStatus: LnAddressUpdateStatus( + status: UpdateStatus.error, + error: e, + errorMessage: errorMessage, + ), + ), + ); + } else { + emit( + state.copyWith( + status: LnAddressStatus.error, + error: e, + ), + ); + } + } + } + + Future _setupAndRegisterLnAddress({String? username}) async { + final WalletInfo? walletInfo = (await breezSdkLiquid.instance?.getInfo())?.walletInfo; + if (walletInfo == null) { + throw Exception('Failed to retrieve wallet info'); + } + + final String webhookUrl = await webhookService.generateWebhookUrl(); + await _invalidateExistingWebhookIfNeeded(pubKey: walletInfo.pubkey, webhookUrl: webhookUrl); + await webhookService.register(webhookUrl); + await breezPreferences.setWebhookUrl(webhookUrl); + + final LnAddressRegistrationResponse registrationResponse = await _registerLnAddressWebhook( + pubKey: walletInfo.pubkey, + webhookUrl: webhookUrl, + username: username, + ); + + return registrationResponse; + } + + Future _invalidateExistingWebhookIfNeeded({ + required String pubKey, + required String webhookUrl, + }) async { + final String? existingWebhook = await breezPreferences.getWebhookUrl(); + if (existingWebhook != null && existingWebhook != webhookUrl) { + final int timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final String message = '$timestamp-$existingWebhook'; + final String signature = await _signMessage(message); + + final InvalidateWebhookRequest invalidateWebhookRequest = InvalidateWebhookRequest( + webhookUrl: existingWebhook, + signature: signature, + timestamp: timestamp, + ); + await lnAddressService.invalidateWebhook(pubKey, invalidateWebhookRequest); + breezPreferences.clearWebhookUrl(); + } + } + + Future _signMessage(String message) async { + _logger.info('Signing message: $message'); + + final SignMessageResponse? signMessageRes = breezSdkLiquid.instance?.signMessage( + req: SignMessageRequest(message: message), + ); + + if (signMessageRes == null) { + throw Exception('Failed to sign message'); + } + + _logger.info('Successfully signed message'); + return signMessageRes.signature; + } + + Future _registerLnAddressWebhook({ + required String pubKey, + required String webhookUrl, + String? username, + }) async { + _logger.info('Preparing LnAddressRegistrationRequest'); + _logger.info('Initial parameters: pubKey=$pubKey, webhookUrl=$webhookUrl, username=$username'); + + try { + if (username == null || username.isEmpty) { + if (await breezPreferences.isLnAddressSetup()) { + final String? profileName = await breezPreferences.getProfileName(); + username = UsernameFormatter.formatDefaultProfileName(profileName); + _logger.info('Initial setup: using formatted profile name: $username'); + } else { + username = await breezPreferences.getLnAddressUsername(); + _logger.info('Using stored username: $username'); + } + } + + final String signature = await _generateWebhookSignature(webhookUrl, username); + + final LnAddressRegistrationRequest request = LnAddressRegistrationRequest( + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + webhookUrl: webhookUrl, + signature: signature, + username: username, + ); + _logger.fine('Created LnAddressRegistrationRequest with timestamp: ${request.timestamp}'); + + final LnAddressRegistrationResponse registrationResponse = await lnAddressService.register( + pubKey: pubKey, + request: request, + ); + + if (username != null && username.isNotEmpty) { + await breezPreferences.setLnAddressUsername(username); + _logger.info('Stored username in secure storage: $username'); + } + + _logger.info( + 'Successfully registered Lightning Address: $registrationResponse', + ); + + await breezPreferences.completeLnAddressSetup(); + + return registrationResponse; + } catch (e, stackTrace) { + _logger.severe('Failed to register Lightning Address', e, stackTrace); + rethrow; + } + } + + Future _generateWebhookSignature(String webhookUrl, String? username) async { + _logger.info('Generating webhook signature'); + final String usernameComponent = username?.isNotEmpty == true ? '-$username' : ''; + final int timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final String message = '$timestamp-$webhookUrl$usernameComponent'; + + final String signature = await _signMessage(message); + _logger.info('Successfully generated webhook signature'); + return signature; + } + + void clearUpdateStatus() { + _logger.info('Clearing LnAddressUpdateStatus'); + emit( + state.copyWith( + updateStatus: const LnAddressUpdateStatus(), + ), + ); + } +} + +class LnAddressCubitFactory { + static LnAddressCubit create(ServiceInjector injector) { + final BreezSDKLiquid breezSdkLiquid = injector.breezSdkLiquid; + final BreezPreferences breezPreferences = injector.breezPreferences; + + final WebhookService webhookService = WebhookService(breezSdkLiquid, injector.notifications); + + return LnAddressCubit( + breezSdkLiquid: breezSdkLiquid, + breezPreferences: breezPreferences, + lnAddressService: LnAddressService(), + webhookService: webhookService, + ); + } +} diff --git a/lib/cubit/ln_address/ln_address_state.dart b/lib/cubit/ln_address/ln_address_state.dart new file mode 100644 index 00000000..e01b4007 --- /dev/null +++ b/lib/cubit/ln_address/ln_address_state.dart @@ -0,0 +1,35 @@ +import 'package:l_breez/cubit/cubit.dart'; + +enum LnAddressStatus { initial, loading, success, error } + +class LnAddressState { + final String? lnurl; + final String? lnAddress; + final LnAddressStatus status; + final LnAddressUpdateStatus updateStatus; + final Object? error; + + const LnAddressState({ + this.lnurl, + this.lnAddress, + this.status = LnAddressStatus.initial, + this.updateStatus = const LnAddressUpdateStatus(), + this.error, + }); + + LnAddressState copyWith({ + String? lnurl, + String? lnAddress, + LnAddressStatus? status, + LnAddressUpdateStatus? updateStatus, + Object? error, + }) { + return LnAddressState( + lnurl: lnurl ?? this.lnurl, + lnAddress: lnAddress ?? this.lnAddress, + status: status ?? this.status, + updateStatus: updateStatus ?? this.updateStatus, + error: error ?? this.error, + ); + } +} diff --git a/lib/cubit/ln_address/models/exceptions.dart b/lib/cubit/ln_address/models/exceptions.dart new file mode 100644 index 00000000..9e755490 --- /dev/null +++ b/lib/cubit/ln_address/models/exceptions.dart @@ -0,0 +1,49 @@ +class GenerateWebhookException implements Exception { + final String message; + GenerateWebhookException(this.message); + @override + String toString() => message; +} + +class WebhookRegistrationException implements Exception { + final String message; + WebhookRegistrationException(this.message); + @override + String toString() => message; +} + +class WebhookInvalidationException implements Exception { + final String message; + WebhookInvalidationException(this.message); + @override + String toString() => message; +} + +class InvalidateWebhookException implements Exception { + final String message; + InvalidateWebhookException(this.message); + @override + String toString() => message; +} + +class LnAddressRegistrationException implements Exception { + final String message; + final int? statusCode; + final String? responseBody; + + LnAddressRegistrationException(this.message, {this.statusCode, this.responseBody}); + + @override + String toString() => + 'LnAddressRegistrationException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}'; +} + +class UsernameConflictException implements Exception { + @override + String toString() => 'Username is already taken'; +} + +class MaxRetriesExceededException implements Exception { + @override + String toString() => 'Maximum retry attempts exceeded'; +} diff --git a/lib/cubit/ln_address/models/ln_address_registration.dart b/lib/cubit/ln_address/models/ln_address_registration.dart new file mode 100644 index 00000000..54754250 --- /dev/null +++ b/lib/cubit/ln_address/models/ln_address_registration.dart @@ -0,0 +1,76 @@ +class LnAddressRegistrationRequest { + final int timestamp; + final String webhookUrl; + final String signature; + final String? username; + + const LnAddressRegistrationRequest({ + required this.timestamp, + required this.webhookUrl, + required this.signature, + required this.username, + }); + + LnAddressRegistrationRequest copyWith({ + int? timestamp, + String? webhookUrl, + String? signature, + String? username, + }) { + return LnAddressRegistrationRequest( + timestamp: timestamp ?? this.timestamp, + webhookUrl: webhookUrl ?? this.webhookUrl, + signature: signature ?? this.signature, + username: username ?? this.username, + ); + } + + Map toJson() { + return { + 'time': timestamp, + 'webhook_url': webhookUrl, + 'signature': signature, + 'username': username, + }; + } +} + +class LnAddressRegistrationResponse { + final String lnurl; + final String lnAddress; + + const LnAddressRegistrationResponse({ + required this.lnurl, + required this.lnAddress, + }); + + factory LnAddressRegistrationResponse.fromJson(Map json) { + return LnAddressRegistrationResponse( + lnurl: json['lnurl'] as String, + lnAddress: json['lightning_address'] as String? ?? '', + ); + } + + @override + String toString() => 'lnurl=$lnurl, lnAddress=$lnAddress'; +} + +class InvalidateWebhookRequest { + final int timestamp; + final String webhookUrl; + final String signature; + + const InvalidateWebhookRequest({ + required this.timestamp, + required this.webhookUrl, + required this.signature, + }); + + Map toJson() { + return { + 'time': timestamp, + 'webhook_url': webhookUrl, + 'signature': signature, + }; + } +} diff --git a/lib/cubit/ln_address/models/ln_address_update_status.dart b/lib/cubit/ln_address/models/ln_address_update_status.dart new file mode 100644 index 00000000..6fbfa5dc --- /dev/null +++ b/lib/cubit/ln_address/models/ln_address_update_status.dart @@ -0,0 +1,25 @@ +enum UpdateStatus { initial, loading, success, error } + +class LnAddressUpdateStatus { + final UpdateStatus status; + final Object? error; + final String? errorMessage; + + const LnAddressUpdateStatus({ + this.status = UpdateStatus.initial, + this.error, + this.errorMessage, + }); + + LnAddressUpdateStatus copyWith({ + UpdateStatus? status, + Object? error, + String? errorMessage, + }) { + return LnAddressUpdateStatus( + status: status ?? this.status, + error: error ?? this.error, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} diff --git a/lib/cubit/ln_address/models/models.dart b/lib/cubit/ln_address/models/models.dart new file mode 100644 index 00000000..fc77b08c --- /dev/null +++ b/lib/cubit/ln_address/models/models.dart @@ -0,0 +1,3 @@ +export 'exceptions.dart'; +export 'ln_address_registration.dart'; +export 'ln_address_update_status.dart'; diff --git a/lib/cubit/ln_address/services/ln_address_service.dart b/lib/cubit/ln_address/services/ln_address_service.dart new file mode 100644 index 00000000..b15d455f --- /dev/null +++ b/lib/cubit/ln_address/services/ln_address_service.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:l_breez/cubit/cubit.dart'; +import 'package:logging/logging.dart'; + +final Logger _logger = Logger('LnAddressService'); + +class LnAddressService { + static const int _maxRetries = 3; + final String baseUrl; + final http.Client _client; + + LnAddressService({ + this.baseUrl = 'https://breez.fun', + http.Client? client, + }) : _client = client ?? http.Client(); + + // TODO(erdemyerebasmaz): Handle multiple device setup case + Future register({ + required String pubKey, + required LnAddressRegistrationRequest request, + }) async { + _logger.info('Attempting to register lightning address for pubkey: $pubKey'); + final String baseUsername = request.username ?? ''; + + // If this is an update (username is provided), don't retry + if (request.username != null && request.username!.isNotEmpty) { + return _attemptRegistration( + pubKey: pubKey, + request: request, + ); + } + + // Retry only on initial setup + int retryCount = 0; + while (retryCount < _maxRetries) { + final String currentUsername = UsernameGenerator.generateUsername(baseUsername, retryCount); + + try { + _logger.info('Attempt ${retryCount + 1}/$_maxRetries with username: $currentUsername'); + final LnAddressRegistrationResponse registrationResponse = await _attemptRegistration( + pubKey: pubKey, + request: request.copyWith(username: currentUsername), + ); + _logger.info('Successfully registered lightning address: ${registrationResponse.lnAddress}'); + return registrationResponse; + } on UsernameConflictException { + _logger.warning('Username conflict for: $currentUsername'); + retryCount++; + if (retryCount == _maxRetries) { + _logger.severe('Max retries exceeded for username registration'); + throw MaxRetriesExceededException(); + } + } + } + throw MaxRetriesExceededException(); + } + + Future _attemptRegistration({ + required String pubKey, + required LnAddressRegistrationRequest request, + }) async { + final Uri uri = Uri.parse('$baseUrl/lnurlpay/$pubKey'); + _logger.fine('Attempting registration at: $uri'); + + try { + final http.Response response = await _client.post( + uri, + body: jsonEncode(request.toJson()), + ); + + _logger.fine('Registration response status: ${response.statusCode}'); + _logger.fine('Registration response body: ${response.body}'); + + if (response.statusCode == 200) { + return LnAddressRegistrationResponse.fromJson( + jsonDecode(response.body) as Map, + ); + } + + if (response.statusCode == 409) { + throw UsernameConflictException(); + } + + throw LnAddressRegistrationException( + 'Server returned error response', + statusCode: response.statusCode, + responseBody: response.body, + ); + } catch (e, stackTrace) { + if (e is UsernameConflictException || e is LnAddressRegistrationException) { + rethrow; + } + + _logger.severe('Registration attempt failed', e, stackTrace); + throw LnAddressRegistrationException( + e.toString(), + ); + } + } + + Future invalidateWebhook(String pubKey, InvalidateWebhookRequest request) async { + _logger.info('Invalidating webhook: ${request.webhookUrl}'); + final Uri uri = Uri.parse('$baseUrl/lnurlpay/$pubKey'); + + try { + final http.Response response = await _client.delete( + uri, + body: jsonEncode(request.toJson()), + ); + + if (response.statusCode != 200) { + throw InvalidateWebhookException(response.body); + } + _logger.info('Successfully invalidated webhook'); + } catch (e, stackTrace) { + _logger.severe('Failed to invalidate webhook', e, stackTrace); + throw InvalidateWebhookException(e.toString()); + } + } +} diff --git a/lib/cubit/ln_address/services/services.dart b/lib/cubit/ln_address/services/services.dart new file mode 100644 index 00000000..0b84c455 --- /dev/null +++ b/lib/cubit/ln_address/services/services.dart @@ -0,0 +1,2 @@ +export 'ln_address_service.dart'; +export 'webhook_service.dart'; diff --git a/lib/cubit/ln_address/services/webhook_service.dart b/lib/cubit/ln_address/services/webhook_service.dart new file mode 100644 index 00000000..c084fe97 --- /dev/null +++ b/lib/cubit/ln_address/services/webhook_service.dart @@ -0,0 +1,54 @@ +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:l_breez/cubit/cubit.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 _notificationsClient; + + WebhookService(this._breezSdkLiquid, this._notificationsClient); + + Future generateWebhookUrl() async { + _logger.info('Generating webhook URL'); + final String? token = await _notificationsClient.getToken(); + if (token == null) { + _logger.severe('Failed to get notification token'); + throw GenerateWebhookException('Failed to get notification token'); + } + + final String platform = _getPlatform(); + final String webhookUrl = '$_notifierServiceURL/api/v1/notify?platform=$platform&token=$token'; + _logger.info('Generated webhook URL: $webhookUrl'); + + return webhookUrl; + } + + String _getPlatform() { + _logger.fine('Determining platform'); + if (defaultTargetPlatform == TargetPlatform.iOS) { + return 'ios'; + } + if (defaultTargetPlatform == TargetPlatform.android) { + return 'android'; + } + _logger.severe('Unsupported platform: $defaultTargetPlatform'); + throw GenerateWebhookException('Platform not supported'); + } + + Future register(String webhookUrl) async { + try { + _logger.info('Registering webhook: $webhookUrl'); + await _breezSdkLiquid.instance?.registerWebhook(webhookUrl: webhookUrl); + _logger.info('Successfully registered webhook'); + } catch (e, stackTrace) { + _logger.severe('Failed to register webhook', e, stackTrace); + throw WebhookRegistrationException('Failed to register webhook: $e'); + } + } +} diff --git a/lib/cubit/ln_address/utils/username_formatter.dart b/lib/cubit/ln_address/utils/username_formatter.dart new file mode 100644 index 00000000..dbe9e99f --- /dev/null +++ b/lib/cubit/ln_address/utils/username_formatter.dart @@ -0,0 +1,51 @@ +import 'package:flutter/services.dart'; + +class UsernameInputFormatter extends TextInputFormatter { + // Loosely comply with email standards, namely RFC 5322 + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + String formatted = newValue.text.trim(); + + // Remove invalid characters + formatted = formatted.replaceAll( + RegExp(r"[^a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]"), + '', + ); + + // Prevent consecutive dots (but allow trailing dots during typing) + formatted = formatted.replaceAll(RegExp(r'\.\.+'), '.'); + + return TextEditingValue( + text: formatted, + selection: newValue.selection, + ); + } +} + +class UsernameFormatter { + // Example: ". Satoshi Nakamoto." -> "satoshinakamoto" + static String sanitize(String rawUsername) { + if (rawUsername.isEmpty) { + return ''; + } + + // Ensure no leading or trailing dots + String sanitized = rawUsername.trim(); + if (sanitized.startsWith('.')) { + sanitized = sanitized.substring(1); + } + if (sanitized.endsWith('.')) { + sanitized = sanitized.substring(0, sanitized.length - 1); + } + + return sanitized; + } + + // Example: "Tomato Elephant" -> "tomatoelephant" + static String formatDefaultProfileName(String? profileName) { + return sanitize(profileName ?? ''); + } +} diff --git a/lib/cubit/ln_address/utils/username_generator.dart b/lib/cubit/ln_address/utils/username_generator.dart new file mode 100644 index 00000000..91e976be --- /dev/null +++ b/lib/cubit/ln_address/utils/username_generator.dart @@ -0,0 +1,16 @@ +import 'dart:math'; + +class UsernameGenerator { + static const int _discriminatorLength = 4; + static final Random _secureRandom = Random.secure(); + + static String generateUsername(String baseUsername, int attempt) { + if (attempt == 0) { + return baseUsername; + } + + final int discriminator = _secureRandom.nextInt(10000); + final String formattedDiscriminator = discriminator.toString().padLeft(_discriminatorLength, '0'); + return '$baseUsername$formattedDiscriminator'; + } +} diff --git a/lib/cubit/ln_address/utils/utils.dart b/lib/cubit/ln_address/utils/utils.dart new file mode 100644 index 00000000..78c59d08 --- /dev/null +++ b/lib/cubit/ln_address/utils/utils.dart @@ -0,0 +1,2 @@ +export 'username_formatter.dart'; +export 'username_generator.dart'; diff --git a/lib/cubit/webhook/lnurl_pay_service.dart b/lib/cubit/webhook/lnurl_pay_service.dart deleted file mode 100644 index 2d379c07..00000000 --- a/lib/cubit/webhook/lnurl_pay_service.dart +++ /dev/null @@ -1,172 +0,0 @@ -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) { - throw Exception('Failed to retrieve registered webhook.'); - } - return 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(); - await resetLnAddressUsername(); - } - - 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(); - } - - 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 deleted file mode 100644 index 1056f9b2..00000000 --- a/lib/cubit/webhook/webhook_cubit.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:breez_sdk_liquid/breez_sdk_liquid.dart'; -import 'package:flutter_breez_liquid/flutter_breez_liquid.dart'; -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 { - final BreezSDKLiquid _breezSdkLiquid; - final WebhookService _webhookService; - final LnUrlPayService _lnUrlPayService; - - 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'); - emit(WebhookState(isLoading: true)); - try { - walletInfo = walletInfo ?? (await _breezSdkLiquid.instance?.getInfo())?.walletInfo; - 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 ?? await _lnUrlPayService.getLnAddressUsername(), - ); - emit( - WebhookState( - lnurlPayUrl: lnUrlData['lnurl'], - lnAddress: lnUrlData['lnAddress'], - ), - ); - } catch (err) { - _logger.warning('Failed to refresh webhooks: $err'); - emit( - WebhookState( - webhookError: err.toString(), - webhookErrorTitle: 'Failed to refresh Lightning Address:', - ), - ); - } - } - - Future updateLnAddressUsername({required String lnAddressUsername}) async { - emit( - WebhookState( - isLoading: true, - lnurlPayUrl: state.lnurlPayUrl, - lnAddress: state.lnAddress, - ), - ); - try { - final GetInfoResponse? walletInfo = await _breezSdkLiquid.instance?.getInfo(); - if (walletInfo == null) { - throw Exception('Failed to retrieve wallet info.'); - } - final Map lnUrlData = await _lnUrlPayService.updateLnAddressUsername( - walletInfo.walletInfo, - lnAddressUsername, - ); - await _lnUrlPayService.setLnAddressUsername(lnAddressUsername: lnAddressUsername); - emit( - WebhookState( - lnurlPayUrl: lnUrlData['lnurl'], - lnAddress: lnUrlData['lnAddress'], - ), - ); - } catch (err) { - emit( - state.copyWith( - lnurlPayError: err.toString(), - lnurlPayErrorTitle: 'Failed to update Lightning Address username:', - ), - ); - } finally { - emit(state.copyWith(isLoading: false)); - } - } -} diff --git a/lib/cubit/webhook/webhook_service.dart b/lib/cubit/webhook/webhook_service.dart deleted file mode 100644 index 09205f5c..00000000 --- a/lib/cubit/webhook/webhook_service.dart +++ /dev/null @@ -1,41 +0,0 @@ -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/cubit/webhook/webhook_state.dart b/lib/cubit/webhook/webhook_state.dart deleted file mode 100644 index ecc0ba49..00000000 --- a/lib/cubit/webhook/webhook_state.dart +++ /dev/null @@ -1,90 +0,0 @@ -class WebhookState { - final String? lnurlPayUrl; - final String? lnAddress; - final String? webhookError; - final String? webhookErrorTitle; - final String? lnurlPayError; - final String? lnurlPayErrorTitle; - final bool isLoading; - - WebhookState({ - this.lnurlPayUrl, - this.lnAddress, - this.webhookError, - this.webhookErrorTitle, - this.lnurlPayError, - this.lnurlPayErrorTitle, - this.isLoading = false, - }); - - WebhookState copyWith({ - String? lnurlPayUrl, - String? lnAddress, - String? webhookError, - String? webhookErrorTitle, - String? lnurlPayError, - String? lnurlPayErrorTitle, - bool? isLoading, - }) { - 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 { - final int time; - final String webhookUrl; - final String? username; - final String signature; - - AddWebhookRequest({ - required this.time, - required this.webhookUrl, - required this.username, - required this.signature, - }); - - Map toJson() => { - 'time': time, - 'webhook_url': webhookUrl, - 'username': username, - 'signature': signature, - }; -} - -class RemoveWebhookRequest { - final int time; - final String webhookUrl; - final String signature; - - RemoveWebhookRequest({ - required this.time, - required this.webhookUrl, - required this.signature, - }); - - Map toJson() => { - 'time': time, - 'webhook_url': webhookUrl, - 'signature': signature, - }; -} 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 b760739e..6b333cd8 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 @@ -3,7 +3,7 @@ import 'package:breez_translations/generated/breez_translations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:l_breez/cubit/cubit.dart'; -import 'package:l_breez/routes/routes.dart'; +import 'package:l_breez/routes/receive_payment/widgets/widgets.dart'; import 'package:l_breez/utils/exceptions.dart'; import 'package:l_breez/widgets/widgets.dart'; @@ -14,21 +14,19 @@ class ReceiveLightningAddressPage extends StatefulWidget { const ReceiveLightningAddressPage({super.key}); @override - State createState() { - return ReceiveLightningAddressPageState(); - } + State createState() => ReceiveLightningAddressPageState(); } class ReceiveLightningAddressPageState extends State { @override void initState() { super.initState(); - _refreshWebhooks(); + setupLightningAddress(); } - void _refreshWebhooks() { - final WebhookCubit webhookCubit = context.read(); - webhookCubit.refreshWebhooks(); + void setupLightningAddress() { + final LnAddressCubit lnAddressCubit = context.read(); + lnAddressCubit.setupLightningAddress(); } @override @@ -36,22 +34,22 @@ class ReceiveLightningAddressPageState extends State( - builder: (BuildContext context, WebhookState webhookState) { + return BlocBuilder( + builder: (BuildContext context, LnAddressState lnAddressState) { return Scaffold( - body: webhookState.isLoading + body: lnAddressState.status == LnAddressStatus.loading ? Center( child: Loader( color: themeData.primaryColor.withValues(alpha: .5), ), ) - : webhookState.webhookError != null + : lnAddressState.status == LnAddressStatus.error ? ScrollableErrorMessageWidget( showIcon: true, - title: webhookState.webhookErrorTitle ?? texts.lightning_address_service_error_title, - message: extractExceptionMessage(webhookState.webhookError!, texts), + title: texts.lightning_address_service_error_title, + message: extractExceptionMessage(lnAddressState.error!, texts), ) - : webhookState.lnurlPayUrl != null + : lnAddressState.status == LnAddressStatus.success && lnAddressState.lnurl != null ? Padding( padding: const EdgeInsets.only(top: 32.0, bottom: 40.0), child: SingleChildScrollView( @@ -67,8 +65,8 @@ class ReceiveLightningAddressPageState extends State( - builder: (BuildContext context, PaymentLimitsState snapshot) { - return webhookState.webhookError != null || snapshot.hasError - ? SingleButtonBottomBar( - stickToBottom: true, - text: texts.invoice_ln_address_action_retry, - onPressed: webhookState.webhookError != null - ? () => _refreshWebhooks() - : () { - final PaymentLimitsCubit paymentLimitsCubit = - context.read(); - paymentLimitsCubit.fetchLightningLimits(); - }, - ) - : SingleButtonBottomBar( - stickToBottom: true, - text: texts.qr_code_dialog_action_close, - onPressed: () { - Navigator.of(context).pop(); - }, - ); + builder: (BuildContext context, PaymentLimitsState limitsState) { + final bool hasError = lnAddressState.status == LnAddressStatus.error || limitsState.hasError; + + return SingleButtonBottomBar( + stickToBottom: true, + text: hasError ? texts.invoice_ln_address_action_retry : texts.qr_code_dialog_action_close, + onPressed: + hasError ? _handleRetry(lnAddressState, limitsState) : () => Navigator.of(context).pop(), + ); }, ), ); }, ); } + + VoidCallback _handleRetry(LnAddressState state, PaymentLimitsState limitsState) { + return () { + if (state.status == LnAddressStatus.error) { + setupLightningAddress(); + } else if (limitsState.hasError) { + final PaymentLimitsCubit paymentLimitsCubit = context.read(); + paymentLimitsCubit.fetchLightningLimits(); + } + }; + } } 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 0bdbb6d9..bbdadf20 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,6 +2,7 @@ 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'; @@ -22,7 +23,7 @@ class _UpdateLnAddressUsernameBottomSheetState extends State _formKey = GlobalKey(); final TextEditingController _usernameController = TextEditingController(); final FocusNode _usernameFocusNode = FocusNode(); - KeyboardDoneAction _doneAction = KeyboardDoneAction(); + late final KeyboardDoneAction _doneAction; @override void initState() { @@ -30,148 +31,172 @@ class _UpdateLnAddressUsernameBottomSheetState extends State setState(() {})); _doneAction = KeyboardDoneAction(focusNodes: [_usernameFocusNode]); + + // Clear any previous error messages when opening the bottom sheet + final LnAddressCubit lnAddressCubit = context.read(); + lnAddressCubit.clearUpdateStatus(); } @override void dispose() { - super.dispose(); _doneAction.dispose(); FocusManager.instance.primaryFocus?.unfocus(); + super.dispose(); } @override Widget build(BuildContext context) { final BreezTranslations texts = context.texts(); + final ThemeData themeData = Theme.of(context); - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: BlocBuilder( - builder: (BuildContext context, WebhookState state) { - 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), - ), + return BlocListener( + listenWhen: (LnAddressState previous, LnAddressState current) => + current.updateStatus.status != previous.updateStatus.status, + listener: (BuildContext context, LnAddressState state) { + if (state.updateStatus.status == UpdateStatus.success) { + Navigator.pop(context); + showFlushbar( + context, + message: 'Successfully updated Lightning Address username.', + ); + } else if (state.updateStatus.status == UpdateStatus.error) { + if (state.updateStatus.error is! UsernameConflictException) { + showFlushbar( + context, + message: 'Failed to update Lightning Address username.', + ); + } + _formKey.currentState?.validate(); + } + }, + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: 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: Text( + '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', + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: BlocBuilder( + buildWhen: (LnAddressState previous, LnAddressState current) => + current.updateStatus != previous.updateStatus, + builder: (BuildContext context, LnAddressState state) { + final bool isConflict = state.updateStatus.error is UsernameConflictException; + return TextFormField( + controller: _usernameController, + focusNode: _usernameFocusNode, + decoration: InputDecoration( + labelText: 'Username', + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: themeData.colorScheme.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide(color: themeData.colorScheme.error), + ), + errorMaxLines: 2, + errorStyle: themeData.primaryTextTheme.bodySmall!.copyWith( + color: themeData.colorScheme.error, + height: 1.0, ), + suffix: const Padding( + padding: EdgeInsets.only(right: 8.0), + child: Text('@breez.fun'), + ), + border: const OutlineInputBorder(), + errorText: isConflict ? 'Username is already taken' : null, ), - border: const OutlineInputBorder(), - ), - 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.trim()}@${widget.lnAddress.split('@').last}'; - // TODO(erdemyerebasmaz): Add these messages to Breez-Translations - return EmailValidator.validate(email) ? null : 'Invalid username.'; - }, - onEditingComplete: () => _usernameFocusNode.unfocus(), - ), + keyboardType: TextInputType.emailAddress, + autofocus: true, + validator: _validateUsername, + inputFormatters: [ + UsernameInputFormatter(), + ], + 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: () async { - 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(); - await webhookCubit.updateLnAddressUsername( - lnAddressUsername: _usernameController.text.toLowerCase().trim(), - ); - 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.', - ); - } - } - } - }, - ), + ), + const SizedBox(height: 8.0), + Align( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 16.0, + ), + child: BlocBuilder( + buildWhen: (LnAddressState previous, LnAddressState current) => + current.updateStatus.status != previous.updateStatus.status, + builder: (BuildContext context, LnAddressState state) { + return SingleButtonBottomBar( + text: texts.currency_converter_dialog_action_done, + loading: state.updateStatus.status == UpdateStatus.loading, + expand: true, + onPressed: _handleSubmit, + ); + }, ), ), - ], - ); - }, + ), + ], + ), ), ), ); } + + String? _validateUsername(String? value) { + final String sanitized = UsernameFormatter.sanitize(value ?? ''); + if (sanitized.isEmpty) { + return 'Please enter a username'; + } + + final LnAddressState state = context.read().state; + if (state.updateStatus.error is UsernameConflictException) { + return 'Username is already taken'; + } + + final String email = '$sanitized@${widget.lnAddress.split('@').last}'; + return EmailValidator.validate(email) ? null : 'Invalid username.'; + } + + void _handleSubmit() { + if (_usernameController.text.isEmpty) { + Navigator.pop(context); + return; + } + + if (_formKey.currentState?.validate() ?? false) { + final LnAddressCubit lnAddressCubit = context.read(); + final String username = UsernameFormatter.sanitize(_usernameController.text); + lnAddressCubit.setupLightningAddress(username: username); + } + } } diff --git a/lib/widgets/single_button_bottom_bar.dart b/lib/widgets/single_button_bottom_bar.dart index b6adfb42..c5f1f5f1 100644 --- a/lib/widgets/single_button_bottom_bar.dart +++ b/lib/widgets/single_button_bottom_bar.dart @@ -7,6 +7,7 @@ class SingleButtonBottomBar extends StatelessWidget { final bool stickToBottom; final bool enabled; final bool expand; + final bool loading; const SingleButtonBottomBar({ required this.text, @@ -15,6 +16,7 @@ class SingleButtonBottomBar extends StatelessWidget { this.stickToBottom = false, this.enabled = true, this.expand = false, + this.loading = false, }); @override @@ -36,6 +38,7 @@ class SingleButtonBottomBar extends StatelessWidget { onPressed, enabled: enabled, expand: expand, + loading: loading, ), ), ], @@ -49,6 +52,7 @@ class SubmitButton extends StatelessWidget { final String text; final bool enabled; final bool expand; + final bool loading; const SubmitButton( this.text, @@ -56,6 +60,7 @@ class SubmitButton extends StatelessWidget { super.key, this.enabled = true, this.expand = false, + this.loading = false, }); @override @@ -78,12 +83,21 @@ class SubmitButton extends StatelessWidget { ), minimumSize: expand ? Size(screenWidth, 48) : null, ), - onPressed: enabled ? onPressed : null, - child: AutoSizeText( - text, - maxLines: 1, - style: themeData.textTheme.labelLarge, - ), + onPressed: (enabled && !loading) ? onPressed : null, + child: loading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : AutoSizeText( + text, + maxLines: 1, + style: themeData.textTheme.labelLarge, + ), ), ); } diff --git a/packages/breez_preferences/lib/src/breez_preferences.dart b/packages/breez_preferences/lib/src/breez_preferences.dart index 4a2deee5..1fdcdd4b 100644 --- a/packages/breez_preferences/lib/src/breez_preferences.dart +++ b/packages/breez_preferences/lib/src/breez_preferences.dart @@ -16,6 +16,7 @@ const String _kReportPrefKey = 'report_preference_key'; const String _kLnUrlPayKey = 'lnurlpay_key'; const String _kLnAddressUsername = 'ln_address_name'; const String _kProfileName = 'profile_name'; +const String _kCompletedLnAddressSetup = 'completed_ln_address_setup'; final Logger _logger = Logger('BreezPreferences'); @@ -99,18 +100,18 @@ class BreezPreferences { await prefs.setInt(_kReportPrefKey, behavior.index); } - Future getLnUrlPayKey() async { + Future getWebhookUrl() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); return prefs.getString(_kLnUrlPayKey); } - Future setLnUrlPayKey(String webhookUrl) async { + Future setWebhookUrl(String webhookUrl) async { _logger.info('set lnurl pay key: $webhookUrl'); final SharedPreferences prefs = await SharedPreferences.getInstance(); await prefs.setString(_kLnUrlPayKey, webhookUrl); } - Future resetLnUrlPayKey() async { + Future clearWebhookUrl() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); await prefs.remove(_kLnUrlPayKey); } @@ -132,13 +133,23 @@ class BreezPreferences { } Future setLnAddressUsername(String lnAddressUsername) async { - _logger.info('Set LN Address Name: $lnAddressUsername'); + _logger.info('Set LN Address username: $lnAddressUsername'); final SharedPreferences prefs = await SharedPreferences.getInstance(); await prefs.setString(_kLnAddressUsername, lnAddressUsername); } - Future resetLnAddressUsername() async { + Future clearLnAddressUsername() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); await prefs.remove(_kLnAddressUsername); } + + Future isLnAddressSetup() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + return !(prefs.getBool(_kCompletedLnAddressSetup) ?? false); + } + + Future completeLnAddressSetup() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kCompletedLnAddressSetup, true); + } } From 104edb82fa131942ad79ffaa35ccafe9371ad53d Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Tue, 28 Jan 2025 13:39:43 +0300 Subject: [PATCH 16/29] Validate username length for LN Address --- .../widgets/update_ln_address_username_bottom_sheet.dart | 8 ++++++++ 1 file changed, 8 insertions(+) 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 bbdadf20..2c1abce8 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 @@ -132,6 +132,10 @@ class _UpdateLnAddressUsernameBottomSheetState extends State 64) { + return 'Username must not be longer than 64 characters.'; + } + final LnAddressState state = context.read().state; if (state.updateStatus.error is UsernameConflictException) { return 'Username is already taken'; From 395c5661d461f9e545dad6a4b5e3b99f726c8de3 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Tue, 28 Jan 2025 14:12:05 +0300 Subject: [PATCH 17/29] chore: renaming to be consistent with LNURL service --- lib/cubit/ln_address/ln_address_cubit.dart | 32 ++++---- lib/cubit/ln_address/models/exceptions.dart | 49 ++++++----- .../models/ln_address_registration.dart | 76 ----------------- .../models/lnurl_pay_registration.dart | 82 +++++++++++++++++++ lib/cubit/ln_address/models/models.dart | 2 +- ...ss_service.dart => lnurl_pay_service.dart} | 32 ++++---- lib/cubit/ln_address/services/services.dart | 2 +- .../ln_address/services/webhook_service.dart | 6 +- 8 files changed, 142 insertions(+), 139 deletions(-) delete mode 100644 lib/cubit/ln_address/models/ln_address_registration.dart create mode 100644 lib/cubit/ln_address/models/lnurl_pay_registration.dart rename lib/cubit/ln_address/services/{ln_address_service.dart => lnurl_pay_service.dart} (78%) diff --git a/lib/cubit/ln_address/ln_address_cubit.dart b/lib/cubit/ln_address/ln_address_cubit.dart index e75a0858..55e5456b 100644 --- a/lib/cubit/ln_address/ln_address_cubit.dart +++ b/lib/cubit/ln_address/ln_address_cubit.dart @@ -16,7 +16,7 @@ final Logger _logger = Logger('LnAddressCubit'); class LnAddressCubit extends Cubit { final BreezSDKLiquid breezSdkLiquid; final BreezPreferences breezPreferences; - final LnAddressService lnAddressService; + final LnUrlPayService lnAddressService; final WebhookService webhookService; LnAddressCubit({ @@ -38,7 +38,7 @@ class LnAddressCubit extends Cubit { ); try { - final LnAddressRegistrationResponse registrationResponse = await _setupAndRegisterLnAddress( + final RegisterLnurlPayResponse registrationResponse = await _setupAndRegisterLnAddress( username: username, ); @@ -46,7 +46,7 @@ class LnAddressCubit extends Cubit { state.copyWith( status: LnAddressStatus.success, lnurl: registrationResponse.lnurl, - lnAddress: registrationResponse.lnAddress, + lnAddress: registrationResponse.lightningAddress, updateStatus: isUpdate ? const LnAddressUpdateStatus(status: UpdateStatus.success) : null, ), ); @@ -58,7 +58,7 @@ class LnAddressCubit extends Cubit { ); if (isUpdate) { - final String errorMessage = e is LnAddressRegistrationException + final String errorMessage = e is RegisterLnurlPayException ? (e.responseBody?.isNotEmpty == true ? e.responseBody! : e.message) : 'Failed to update username'; @@ -82,7 +82,7 @@ class LnAddressCubit extends Cubit { } } - Future _setupAndRegisterLnAddress({String? username}) async { + Future _setupAndRegisterLnAddress({String? username}) async { final WalletInfo? walletInfo = (await breezSdkLiquid.instance?.getInfo())?.walletInfo; if (walletInfo == null) { throw Exception('Failed to retrieve wallet info'); @@ -93,7 +93,7 @@ class LnAddressCubit extends Cubit { await webhookService.register(webhookUrl); await breezPreferences.setWebhookUrl(webhookUrl); - final LnAddressRegistrationResponse registrationResponse = await _registerLnAddressWebhook( + final RegisterLnurlPayResponse registrationResponse = await _registerLnurlWebhook( pubKey: walletInfo.pubkey, webhookUrl: webhookUrl, username: username, @@ -112,12 +112,12 @@ class LnAddressCubit extends Cubit { final String message = '$timestamp-$existingWebhook'; final String signature = await _signMessage(message); - final InvalidateWebhookRequest invalidateWebhookRequest = InvalidateWebhookRequest( + final UnregisterLnurlPayRequest invalidateWebhookRequest = UnregisterLnurlPayRequest( webhookUrl: existingWebhook, signature: signature, - timestamp: timestamp, + time: timestamp, ); - await lnAddressService.invalidateWebhook(pubKey, invalidateWebhookRequest); + await lnAddressService.unregister(pubKey, invalidateWebhookRequest); breezPreferences.clearWebhookUrl(); } } @@ -137,12 +137,12 @@ class LnAddressCubit extends Cubit { return signMessageRes.signature; } - Future _registerLnAddressWebhook({ + Future _registerLnurlWebhook({ required String pubKey, required String webhookUrl, String? username, }) async { - _logger.info('Preparing LnAddressRegistrationRequest'); + _logger.info('Preparing RegisterLnurlPayRequest'); _logger.info('Initial parameters: pubKey=$pubKey, webhookUrl=$webhookUrl, username=$username'); try { @@ -159,15 +159,15 @@ class LnAddressCubit extends Cubit { final String signature = await _generateWebhookSignature(webhookUrl, username); - final LnAddressRegistrationRequest request = LnAddressRegistrationRequest( - timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + final RegisterLnurlPayRequest request = RegisterLnurlPayRequest( + time: DateTime.now().millisecondsSinceEpoch ~/ 1000, webhookUrl: webhookUrl, signature: signature, username: username, ); - _logger.fine('Created LnAddressRegistrationRequest with timestamp: ${request.timestamp}'); + _logger.fine('Created RegisterLnurlPayRequest: $request'); - final LnAddressRegistrationResponse registrationResponse = await lnAddressService.register( + final RegisterLnurlPayResponse registrationResponse = await lnAddressService.register( pubKey: pubKey, request: request, ); @@ -221,7 +221,7 @@ class LnAddressCubitFactory { return LnAddressCubit( breezSdkLiquid: breezSdkLiquid, breezPreferences: breezPreferences, - lnAddressService: LnAddressService(), + lnAddressService: LnUrlPayService(), webhookService: webhookService, ); } diff --git a/lib/cubit/ln_address/models/exceptions.dart b/lib/cubit/ln_address/models/exceptions.dart index 9e755490..da971e43 100644 --- a/lib/cubit/ln_address/models/exceptions.dart +++ b/lib/cubit/ln_address/models/exceptions.dart @@ -1,49 +1,48 @@ -class GenerateWebhookException implements Exception { +class RegisterLnurlPayException implements Exception { final String message; - GenerateWebhookException(this.message); + final int? statusCode; + final String? responseBody; + + RegisterLnurlPayException(this.message, {this.statusCode, this.responseBody}); + @override - String toString() => message; + String toString() => + 'RegisterLnurlPayException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}'; } -class WebhookRegistrationException implements Exception { +class UnregisterLnurlPayException implements Exception { final String message; - WebhookRegistrationException(this.message); + + UnregisterLnurlPayException(this.message); + @override String toString() => message; } -class WebhookInvalidationException implements Exception { - final String message; - WebhookInvalidationException(this.message); +class UsernameConflictException implements Exception { @override - String toString() => message; + String toString() => 'Username is already taken'; } -class InvalidateWebhookException implements Exception { - final String message; - InvalidateWebhookException(this.message); +class MaxRetriesExceededException implements Exception { @override - String toString() => message; + String toString() => 'Maximum retry attempts exceeded'; } -class LnAddressRegistrationException implements Exception { +class GenerateWebhookUrlException implements Exception { final String message; - final int? statusCode; - final String? responseBody; - LnAddressRegistrationException(this.message, {this.statusCode, this.responseBody}); + GenerateWebhookUrlException(this.message); @override - String toString() => - 'LnAddressRegistrationException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}'; + String toString() => message; } -class UsernameConflictException implements Exception { - @override - String toString() => 'Username is already taken'; -} +class RegisterWebhookException implements Exception { + final String message; + + RegisterWebhookException(this.message); -class MaxRetriesExceededException implements Exception { @override - String toString() => 'Maximum retry attempts exceeded'; + String toString() => message; } diff --git a/lib/cubit/ln_address/models/ln_address_registration.dart b/lib/cubit/ln_address/models/ln_address_registration.dart deleted file mode 100644 index 54754250..00000000 --- a/lib/cubit/ln_address/models/ln_address_registration.dart +++ /dev/null @@ -1,76 +0,0 @@ -class LnAddressRegistrationRequest { - final int timestamp; - final String webhookUrl; - final String signature; - final String? username; - - const LnAddressRegistrationRequest({ - required this.timestamp, - required this.webhookUrl, - required this.signature, - required this.username, - }); - - LnAddressRegistrationRequest copyWith({ - int? timestamp, - String? webhookUrl, - String? signature, - String? username, - }) { - return LnAddressRegistrationRequest( - timestamp: timestamp ?? this.timestamp, - webhookUrl: webhookUrl ?? this.webhookUrl, - signature: signature ?? this.signature, - username: username ?? this.username, - ); - } - - Map toJson() { - return { - 'time': timestamp, - 'webhook_url': webhookUrl, - 'signature': signature, - 'username': username, - }; - } -} - -class LnAddressRegistrationResponse { - final String lnurl; - final String lnAddress; - - const LnAddressRegistrationResponse({ - required this.lnurl, - required this.lnAddress, - }); - - factory LnAddressRegistrationResponse.fromJson(Map json) { - return LnAddressRegistrationResponse( - lnurl: json['lnurl'] as String, - lnAddress: json['lightning_address'] as String? ?? '', - ); - } - - @override - String toString() => 'lnurl=$lnurl, lnAddress=$lnAddress'; -} - -class InvalidateWebhookRequest { - final int timestamp; - final String webhookUrl; - final String signature; - - const InvalidateWebhookRequest({ - required this.timestamp, - required this.webhookUrl, - required this.signature, - }); - - Map toJson() { - return { - 'time': timestamp, - 'webhook_url': webhookUrl, - 'signature': signature, - }; - } -} diff --git a/lib/cubit/ln_address/models/lnurl_pay_registration.dart b/lib/cubit/ln_address/models/lnurl_pay_registration.dart new file mode 100644 index 00000000..d97c0ab0 --- /dev/null +++ b/lib/cubit/ln_address/models/lnurl_pay_registration.dart @@ -0,0 +1,82 @@ +class RegisterLnurlPayRequest { + final String? username; + final int time; + final String webhookUrl; + final String signature; + + const RegisterLnurlPayRequest({ + required this.username, + required this.time, + required this.webhookUrl, + required this.signature, + }); + + RegisterLnurlPayRequest copyWith({ + String? username, + int? time, + String? webhookUrl, + String? signature, + }) { + return RegisterLnurlPayRequest( + username: username ?? this.username, + time: time ?? this.time, + webhookUrl: webhookUrl ?? this.webhookUrl, + signature: signature ?? this.signature, + ); + } + + Map toJson() { + return { + 'username': username, + 'time': time, + 'webhook_url': webhookUrl, + 'signature': signature, + }; + } + + @override + String toString() => 'username=$username, time=$time, webhook_url=$webhookUrl, signature=$signature'; +} + +class RegisterLnurlPayResponse { + final String lnurl; + final String lightningAddress; + + const RegisterLnurlPayResponse({ + required this.lnurl, + required this.lightningAddress, + }); + + factory RegisterLnurlPayResponse.fromJson(Map json) { + return RegisterLnurlPayResponse( + lnurl: json['lnurl'] as String, + lightningAddress: json['lightning_address'] as String? ?? '', + ); + } + + @override + String toString() => 'lnurl=$lnurl, lightning_address=$lightningAddress'; +} + +class UnregisterLnurlPayRequest { + final int time; + final String webhookUrl; + final String signature; + + const UnregisterLnurlPayRequest({ + required this.time, + required this.webhookUrl, + required this.signature, + }); + + Map toJson() { + return { + 'time': time, + 'webhook_url': webhookUrl, + 'signature': signature, + }; + } + + @override + String toString() => 'time=$time, webhook_url=$webhookUrl, signature=$signature'; +} diff --git a/lib/cubit/ln_address/models/models.dart b/lib/cubit/ln_address/models/models.dart index fc77b08c..2063b85d 100644 --- a/lib/cubit/ln_address/models/models.dart +++ b/lib/cubit/ln_address/models/models.dart @@ -1,3 +1,3 @@ export 'exceptions.dart'; -export 'ln_address_registration.dart'; export 'ln_address_update_status.dart'; +export 'lnurl_pay_registration.dart'; diff --git a/lib/cubit/ln_address/services/ln_address_service.dart b/lib/cubit/ln_address/services/lnurl_pay_service.dart similarity index 78% rename from lib/cubit/ln_address/services/ln_address_service.dart rename to lib/cubit/ln_address/services/lnurl_pay_service.dart index b15d455f..e09086a0 100644 --- a/lib/cubit/ln_address/services/ln_address_service.dart +++ b/lib/cubit/ln_address/services/lnurl_pay_service.dart @@ -6,20 +6,20 @@ import 'package:logging/logging.dart'; final Logger _logger = Logger('LnAddressService'); -class LnAddressService { +class LnUrlPayService { static const int _maxRetries = 3; final String baseUrl; final http.Client _client; - LnAddressService({ + LnUrlPayService({ this.baseUrl = 'https://breez.fun', http.Client? client, }) : _client = client ?? http.Client(); // TODO(erdemyerebasmaz): Handle multiple device setup case - Future register({ + Future register({ required String pubKey, - required LnAddressRegistrationRequest request, + required RegisterLnurlPayRequest request, }) async { _logger.info('Attempting to register lightning address for pubkey: $pubKey'); final String baseUsername = request.username ?? ''; @@ -39,11 +39,11 @@ class LnAddressService { try { _logger.info('Attempt ${retryCount + 1}/$_maxRetries with username: $currentUsername'); - final LnAddressRegistrationResponse registrationResponse = await _attemptRegistration( + final RegisterLnurlPayResponse registrationResponse = await _attemptRegistration( pubKey: pubKey, request: request.copyWith(username: currentUsername), ); - _logger.info('Successfully registered lightning address: ${registrationResponse.lnAddress}'); + _logger.info('Successfully registered lightning address: ${registrationResponse.lightningAddress}'); return registrationResponse; } on UsernameConflictException { _logger.warning('Username conflict for: $currentUsername'); @@ -57,9 +57,9 @@ class LnAddressService { throw MaxRetriesExceededException(); } - Future _attemptRegistration({ + Future _attemptRegistration({ required String pubKey, - required LnAddressRegistrationRequest request, + required RegisterLnurlPayRequest request, }) async { final Uri uri = Uri.parse('$baseUrl/lnurlpay/$pubKey'); _logger.fine('Attempting registration at: $uri'); @@ -74,7 +74,7 @@ class LnAddressService { _logger.fine('Registration response body: ${response.body}'); if (response.statusCode == 200) { - return LnAddressRegistrationResponse.fromJson( + return RegisterLnurlPayResponse.fromJson( jsonDecode(response.body) as Map, ); } @@ -83,24 +83,22 @@ class LnAddressService { throw UsernameConflictException(); } - throw LnAddressRegistrationException( + throw RegisterLnurlPayException( 'Server returned error response', statusCode: response.statusCode, responseBody: response.body, ); } catch (e, stackTrace) { - if (e is UsernameConflictException || e is LnAddressRegistrationException) { + if (e is UsernameConflictException || e is RegisterLnurlPayException) { rethrow; } _logger.severe('Registration attempt failed', e, stackTrace); - throw LnAddressRegistrationException( - e.toString(), - ); + throw RegisterLnurlPayException(e.toString()); } } - Future invalidateWebhook(String pubKey, InvalidateWebhookRequest request) async { + Future unregister(String pubKey, UnregisterLnurlPayRequest request) async { _logger.info('Invalidating webhook: ${request.webhookUrl}'); final Uri uri = Uri.parse('$baseUrl/lnurlpay/$pubKey'); @@ -111,12 +109,12 @@ class LnAddressService { ); if (response.statusCode != 200) { - throw InvalidateWebhookException(response.body); + throw UnregisterLnurlPayException(response.body); } _logger.info('Successfully invalidated webhook'); } catch (e, stackTrace) { _logger.severe('Failed to invalidate webhook', e, stackTrace); - throw InvalidateWebhookException(e.toString()); + throw UnregisterLnurlPayException(e.toString()); } } } diff --git a/lib/cubit/ln_address/services/services.dart b/lib/cubit/ln_address/services/services.dart index 0b84c455..0210e62b 100644 --- a/lib/cubit/ln_address/services/services.dart +++ b/lib/cubit/ln_address/services/services.dart @@ -1,2 +1,2 @@ -export 'ln_address_service.dart'; +export 'lnurl_pay_service.dart'; export 'webhook_service.dart'; diff --git a/lib/cubit/ln_address/services/webhook_service.dart b/lib/cubit/ln_address/services/webhook_service.dart index c084fe97..c4ea61e0 100644 --- a/lib/cubit/ln_address/services/webhook_service.dart +++ b/lib/cubit/ln_address/services/webhook_service.dart @@ -19,7 +19,7 @@ class WebhookService { final String? token = await _notificationsClient.getToken(); if (token == null) { _logger.severe('Failed to get notification token'); - throw GenerateWebhookException('Failed to get notification token'); + throw GenerateWebhookUrlException('Failed to get notification token'); } final String platform = _getPlatform(); @@ -38,7 +38,7 @@ class WebhookService { return 'android'; } _logger.severe('Unsupported platform: $defaultTargetPlatform'); - throw GenerateWebhookException('Platform not supported'); + throw GenerateWebhookUrlException('Platform not supported'); } Future register(String webhookUrl) async { @@ -48,7 +48,7 @@ class WebhookService { _logger.info('Successfully registered webhook'); } catch (e, stackTrace) { _logger.severe('Failed to register webhook', e, stackTrace); - throw WebhookRegistrationException('Failed to register webhook: $e'); + throw RegisterWebhookException('Failed to register webhook: $e'); } } } From 69beaaad8ce7cc565c2c5de2913bb98b67aa0211 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Tue, 28 Jan 2025 14:13:26 +0300 Subject: [PATCH 18/29] chore: rename webhook registration status on secure storage --- lib/cubit/ln_address/ln_address_cubit.dart | 10 ++++++---- .../breez_preferences/lib/src/breez_preferences.dart | 10 +++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/cubit/ln_address/ln_address_cubit.dart b/lib/cubit/ln_address/ln_address_cubit.dart index 55e5456b..85bf4069 100644 --- a/lib/cubit/ln_address/ln_address_cubit.dart +++ b/lib/cubit/ln_address/ln_address_cubit.dart @@ -147,13 +147,15 @@ class LnAddressCubit extends Cubit { try { if (username == null || username.isEmpty) { - if (await breezPreferences.isLnAddressSetup()) { + final bool hasRegisteredWebhook = await breezPreferences.hasRegisteredLnUrlWebhook(); + + if (!hasRegisteredWebhook) { final String? profileName = await breezPreferences.getProfileName(); username = UsernameFormatter.formatDefaultProfileName(profileName); - _logger.info('Initial setup: using formatted profile name: $username'); + _logger.info('Registering LNURL Webhook: Using formatted profile name: $username'); } else { username = await breezPreferences.getLnAddressUsername(); - _logger.info('Using stored username: $username'); + _logger.info('Refreshing LNURL Webhook: Using stored username: $username'); } } @@ -181,7 +183,7 @@ class LnAddressCubit extends Cubit { 'Successfully registered Lightning Address: $registrationResponse', ); - await breezPreferences.completeLnAddressSetup(); + await breezPreferences.setLnUrlWebhookAsRegistered(); return registrationResponse; } catch (e, stackTrace) { diff --git a/packages/breez_preferences/lib/src/breez_preferences.dart b/packages/breez_preferences/lib/src/breez_preferences.dart index 1fdcdd4b..d6bf46d5 100644 --- a/packages/breez_preferences/lib/src/breez_preferences.dart +++ b/packages/breez_preferences/lib/src/breez_preferences.dart @@ -16,7 +16,7 @@ const String _kReportPrefKey = 'report_preference_key'; const String _kLnUrlPayKey = 'lnurlpay_key'; const String _kLnAddressUsername = 'ln_address_name'; const String _kProfileName = 'profile_name'; -const String _kCompletedLnAddressSetup = 'completed_ln_address_setup'; +const String _kLnUrlWebhookRegistered = 'is_lnurl_webhook_registered'; final Logger _logger = Logger('BreezPreferences'); @@ -143,13 +143,13 @@ class BreezPreferences { await prefs.remove(_kLnAddressUsername); } - Future isLnAddressSetup() async { + Future hasRegisteredLnUrlWebhook() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); - return !(prefs.getBool(_kCompletedLnAddressSetup) ?? false); + return prefs.getBool(_kLnUrlWebhookRegistered) ?? false; } - Future completeLnAddressSetup() async { + Future setLnUrlWebhookAsRegistered() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_kCompletedLnAddressSetup, true); + await prefs.setBool(_kLnUrlWebhookRegistered, true); } } From 0a97d353f68056847f2f1b0bd785a2247ed2bae7 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Tue, 28 Jan 2025 14:14:15 +0300 Subject: [PATCH 19/29] fix: convert sanitezed username to lowercase & remove all whitespace --- lib/cubit/ln_address/utils/username_formatter.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cubit/ln_address/utils/username_formatter.dart b/lib/cubit/ln_address/utils/username_formatter.dart index dbe9e99f..d1bd9f06 100644 --- a/lib/cubit/ln_address/utils/username_formatter.dart +++ b/lib/cubit/ln_address/utils/username_formatter.dart @@ -41,7 +41,7 @@ class UsernameFormatter { sanitized = sanitized.substring(0, sanitized.length - 1); } - return sanitized; + return sanitized.toLowerCase().replaceAll(' ', ''); } // Example: "Tomato Elephant" -> "tomatoelephant" From 4d3eab3ed4f60082f33d3be03d40e055baa5cc4f Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Tue, 28 Jan 2025 14:21:13 +0300 Subject: [PATCH 20/29] add error handling on WebhookService extract getToken into a method --- .../ln_address/services/webhook_service.dart | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/lib/cubit/ln_address/services/webhook_service.dart b/lib/cubit/ln_address/services/webhook_service.dart index c4ea61e0..f1ce90d8 100644 --- a/lib/cubit/ln_address/services/webhook_service.dart +++ b/lib/cubit/ln_address/services/webhook_service.dart @@ -14,23 +14,32 @@ class WebhookService { WebhookService(this._breezSdkLiquid, this._notificationsClient); - Future generateWebhookUrl() async { - _logger.info('Generating webhook URL'); - final String? token = await _notificationsClient.getToken(); - if (token == null) { - _logger.severe('Failed to get notification token'); - throw GenerateWebhookUrlException('Failed to get notification token'); + Future register(String webhookUrl) async { + try { + _logger.info('Registering webhook: $webhookUrl'); + await _breezSdkLiquid.instance?.registerWebhook(webhookUrl: webhookUrl); + _logger.info('Successfully registered webhook'); + } catch (e, stackTrace) { + _logger.severe('Failed to register webhook', e, stackTrace); + throw RegisterWebhookException('Failed to register webhook: $e'); } + } - final String platform = _getPlatform(); - final String webhookUrl = '$_notifierServiceURL/api/v1/notify?platform=$platform&token=$token'; - _logger.info('Generated webhook URL: $webhookUrl'); - - return webhookUrl; + Future generateWebhookUrl() async { + try { + _logger.info('Generating webhook URL'); + final String platform = _getPlatform(); + final String token = await _getToken(); + final String webhookUrl = '$_notifierServiceURL/api/v1/notify?platform=$platform&token=$token'; + _logger.info('Generated webhook URL: $webhookUrl'); + return webhookUrl; + } catch (e) { + _logger.severe('Failed to generate webhook URL', e); + throw GenerateWebhookUrlException(e.toString()); + } } String _getPlatform() { - _logger.fine('Determining platform'); if (defaultTargetPlatform == TargetPlatform.iOS) { return 'ios'; } @@ -41,14 +50,12 @@ class WebhookService { throw GenerateWebhookUrlException('Platform not supported'); } - Future register(String webhookUrl) async { - try { - _logger.info('Registering webhook: $webhookUrl'); - await _breezSdkLiquid.instance?.registerWebhook(webhookUrl: webhookUrl); - _logger.info('Successfully registered webhook'); - } catch (e, stackTrace) { - _logger.severe('Failed to register webhook', e, stackTrace); - throw RegisterWebhookException('Failed to register webhook: $e'); + Future _getToken() async { + final String? token = await _notificationsClient.getToken(); + if (token != null) { + return token; } + _logger.severe('Failed to get notification token'); + throw GenerateWebhookUrlException('Failed to get notification token'); } } From 99799e68a3998286934792f38bb0b174d3b1d249 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Tue, 28 Jan 2025 14:42:45 +0300 Subject: [PATCH 21/29] refactor LnUrlPayService: extract retry logic to a function apply rename changes to logs --- .../services/lnurl_pay_service.dart | 77 +++++++++---------- 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/lib/cubit/ln_address/services/lnurl_pay_service.dart b/lib/cubit/ln_address/services/lnurl_pay_service.dart index e09086a0..a07e0ad3 100644 --- a/lib/cubit/ln_address/services/lnurl_pay_service.dart +++ b/lib/cubit/ln_address/services/lnurl_pay_service.dart @@ -4,74 +4,64 @@ import 'package:http/http.dart' as http; import 'package:l_breez/cubit/cubit.dart'; import 'package:logging/logging.dart'; -final Logger _logger = Logger('LnAddressService'); +final Logger _logger = Logger('LnUrlPayService'); class LnUrlPayService { static const int _maxRetries = 3; - final String baseUrl; - final http.Client _client; - - LnUrlPayService({ - this.baseUrl = 'https://breez.fun', - http.Client? client, - }) : _client = client ?? http.Client(); + static const String _baseUrl = 'https://breez.fun'; + static final http.Client _client = http.Client(); // TODO(erdemyerebasmaz): Handle multiple device setup case Future register({ required String pubKey, required RegisterLnurlPayRequest request, }) async { - _logger.info('Attempting to register lightning address for pubkey: $pubKey'); - final String baseUsername = request.username ?? ''; - - // If this is an update (username is provided), don't retry - if (request.username != null && request.username!.isNotEmpty) { - return _attemptRegistration( - pubKey: pubKey, - request: request, - ); + _logger.info('Registering lightning address for pubkey: $pubKey'); + + // Register without retries if this is an update to existing LNURL Webhook + if (request.username?.isNotEmpty ?? false) { + return _register(pubKey: pubKey, request: request); } - // Retry only on initial setup - int retryCount = 0; - while (retryCount < _maxRetries) { - final String currentUsername = UsernameGenerator.generateUsername(baseUsername, retryCount); + // Register with retries if LNURL Webhook hasn't been registered yet + return _registerWithRetries(pubKey: pubKey, username: request.username ?? '', request: request); + } + Future _registerWithRetries({ + required String pubKey, + required String username, + required RegisterLnurlPayRequest request, + }) async { + for (int retryCount = 0; retryCount < _maxRetries; retryCount++) { + final String currentUsername = UsernameGenerator.generateUsername(username, retryCount); try { _logger.info('Attempt ${retryCount + 1}/$_maxRetries with username: $currentUsername'); - final RegisterLnurlPayResponse registrationResponse = await _attemptRegistration( + return await _register( pubKey: pubKey, request: request.copyWith(username: currentUsername), ); - _logger.info('Successfully registered lightning address: ${registrationResponse.lightningAddress}'); - return registrationResponse; } on UsernameConflictException { _logger.warning('Username conflict for: $currentUsername'); - retryCount++; - if (retryCount == _maxRetries) { - _logger.severe('Max retries exceeded for username registration'); - throw MaxRetriesExceededException(); - } } } + + _logger.severe('Max retries exceeded for username registration'); throw MaxRetriesExceededException(); } - Future _attemptRegistration({ + Future _register({ required String pubKey, required RegisterLnurlPayRequest request, }) async { - final Uri uri = Uri.parse('$baseUrl/lnurlpay/$pubKey'); - _logger.fine('Attempting registration at: $uri'); + final Uri uri = Uri.parse('$_baseUrl/lnurlpay/$pubKey'); + _logger.fine('Sending registration request to: $uri'); try { final http.Response response = await _client.post( uri, body: jsonEncode(request.toJson()), ); - - _logger.fine('Registration response status: ${response.statusCode}'); - _logger.fine('Registration response body: ${response.body}'); + _logHttpResponse(response); if (response.statusCode == 200) { return RegisterLnurlPayResponse.fromJson( @@ -93,28 +83,35 @@ class LnUrlPayService { rethrow; } - _logger.severe('Registration attempt failed', e, stackTrace); + _logger.severe('Registration failed', e, stackTrace); throw RegisterLnurlPayException(e.toString()); } } Future unregister(String pubKey, UnregisterLnurlPayRequest request) async { - _logger.info('Invalidating webhook: ${request.webhookUrl}'); - final Uri uri = Uri.parse('$baseUrl/lnurlpay/$pubKey'); + _logger.info('Unregistering webhook: ${request.webhookUrl}'); + final Uri uri = Uri.parse('$_baseUrl/lnurlpay/$pubKey'); try { final http.Response response = await _client.delete( uri, body: jsonEncode(request.toJson()), ); + _logHttpResponse(response); if (response.statusCode != 200) { throw UnregisterLnurlPayException(response.body); } - _logger.info('Successfully invalidated webhook'); + + _logger.info('Successfully unregistered webhook'); } catch (e, stackTrace) { - _logger.severe('Failed to invalidate webhook', e, stackTrace); + _logger.severe('Failed to unregister webhook', e, stackTrace); throw UnregisterLnurlPayException(e.toString()); } } + + void _logHttpResponse(http.Response response) { + _logger.fine('Response status: ${response.statusCode}'); + _logger.fine('Response body: ${response.body}'); + } } From be54971377e650b6e55578eda43315e7923e2120 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Tue, 28 Jan 2025 15:11:19 +0300 Subject: [PATCH 22/29] refactor LnAddressCubit to improve readability Extracted methods for readability & maintainability --- lib/cubit/ln_address/ln_address_cubit.dart | 198 +++++++++--------- .../services/lnurl_pay_service.dart | 2 +- ...date_ln_address_username_bottom_sheet.dart | 2 +- 3 files changed, 105 insertions(+), 97 deletions(-) diff --git a/lib/cubit/ln_address/ln_address_cubit.dart b/lib/cubit/ln_address/ln_address_cubit.dart index 85bf4069..35c686f2 100644 --- a/lib/cubit/ln_address/ln_address_cubit.dart +++ b/lib/cubit/ln_address/ln_address_cubit.dart @@ -13,6 +13,22 @@ export 'utils/utils.dart'; final Logger _logger = Logger('LnAddressCubit'); +class LnAddressCubitFactory { + static LnAddressCubit create(ServiceInjector injector) { + final BreezSDKLiquid breezSdkLiquid = injector.breezSdkLiquid; + final BreezPreferences breezPreferences = injector.breezPreferences; + + final WebhookService webhookService = WebhookService(breezSdkLiquid, injector.notifications); + + return LnAddressCubit( + breezSdkLiquid: breezSdkLiquid, + breezPreferences: breezPreferences, + lnAddressService: LnUrlPayService(), + webhookService: webhookService, + ); + } +} + class LnAddressCubit extends Cubit { final BreezSDKLiquid breezSdkLiquid; final BreezPreferences breezPreferences; @@ -26,9 +42,9 @@ class LnAddressCubit extends Cubit { required this.webhookService, }) : super(const LnAddressState()); - Future setupLightningAddress({String? username}) async { - final bool isUpdate = username != null; - _logger.info(isUpdate ? 'Updating username to: $username' : 'Initializing lightning address'); + Future setupLightningAddress({String? baseUsername}) async { + final bool isUpdate = baseUsername != null; + _logger.info(isUpdate ? 'Updating username to: $baseUsername' : 'Initializing lightning address'); emit( state.copyWith( @@ -39,7 +55,7 @@ class LnAddressCubit extends Cubit { try { final RegisterLnurlPayResponse registrationResponse = await _setupAndRegisterLnAddress( - username: username, + baseUsername: baseUsername, ); emit( @@ -82,46 +98,62 @@ class LnAddressCubit extends Cubit { } } - Future _setupAndRegisterLnAddress({String? username}) async { + Future _setupAndRegisterLnAddress({String? baseUsername}) async { + final WalletInfo walletInfo = await _getWalletInfo(); + final String webhookUrl = await _setupWebhook(walletInfo.pubkey); + final String? username = baseUsername ?? await _resolveUsername(); + final String signature = await _generateWebhookSignature(webhookUrl, username); + return await _registerLnurlWebhook( + pubKey: walletInfo.pubkey, + webhookUrl: webhookUrl, + signature: signature, + username: username, + ); + } + + Future _getWalletInfo() async { final WalletInfo? walletInfo = (await breezSdkLiquid.instance?.getInfo())?.walletInfo; if (walletInfo == null) { throw Exception('Failed to retrieve wallet info'); } + return walletInfo; + } + Future _setupWebhook(String pubKey) async { + _logger.info('Setting up webhook'); final String webhookUrl = await webhookService.generateWebhookUrl(); - await _invalidateExistingWebhookIfNeeded(pubKey: walletInfo.pubkey, webhookUrl: webhookUrl); + await _unregisterExistingWebhookIfNeeded(pubKey: pubKey, webhookUrl: webhookUrl); await webhookService.register(webhookUrl); await breezPreferences.setWebhookUrl(webhookUrl); - - final RegisterLnurlPayResponse registrationResponse = await _registerLnurlWebhook( - pubKey: walletInfo.pubkey, - webhookUrl: webhookUrl, - username: username, - ); - - return registrationResponse; + return webhookUrl; } - Future _invalidateExistingWebhookIfNeeded({ + Future _unregisterExistingWebhookIfNeeded({ required String pubKey, required String webhookUrl, }) async { final String? existingWebhook = await breezPreferences.getWebhookUrl(); if (existingWebhook != null && existingWebhook != webhookUrl) { - final int timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final String message = '$timestamp-$existingWebhook'; - final String signature = await _signMessage(message); - - final UnregisterLnurlPayRequest invalidateWebhookRequest = UnregisterLnurlPayRequest( - webhookUrl: existingWebhook, - signature: signature, - time: timestamp, - ); - await lnAddressService.unregister(pubKey, invalidateWebhookRequest); + _logger.info('Unregistering existing webhook: $existingWebhook'); + await _unregisterWebhook(existingWebhook, pubKey); breezPreferences.clearWebhookUrl(); } } + Future _unregisterWebhook(String webhookUrl, String pubKey) async { + final int time = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final String message = '$time-$webhookUrl'; + final String signature = await _signMessage(message); + + final UnregisterLnurlPayRequest invalidateWebhookRequest = UnregisterLnurlPayRequest( + time: time, + webhookUrl: webhookUrl, + signature: signature, + ); + + await lnAddressService.unregister(pubKey, invalidateWebhookRequest); + } + Future _signMessage(String message) async { _logger.info('Signing message: $message'); @@ -137,72 +169,64 @@ class LnAddressCubit extends Cubit { return signMessageRes.signature; } - Future _registerLnurlWebhook({ - required String pubKey, - required String webhookUrl, - String? username, - }) async { - _logger.info('Preparing RegisterLnurlPayRequest'); - _logger.info('Initial parameters: pubKey=$pubKey, webhookUrl=$webhookUrl, username=$username'); - - try { - if (username == null || username.isEmpty) { - final bool hasRegisteredWebhook = await breezPreferences.hasRegisteredLnUrlWebhook(); - - if (!hasRegisteredWebhook) { - final String? profileName = await breezPreferences.getProfileName(); - username = UsernameFormatter.formatDefaultProfileName(profileName); - _logger.info('Registering LNURL Webhook: Using formatted profile name: $username'); - } else { - username = await breezPreferences.getLnAddressUsername(); - _logger.info('Refreshing LNURL Webhook: Using stored username: $username'); - } - } - - final String signature = await _generateWebhookSignature(webhookUrl, username); - - final RegisterLnurlPayRequest request = RegisterLnurlPayRequest( - time: DateTime.now().millisecondsSinceEpoch ~/ 1000, - webhookUrl: webhookUrl, - signature: signature, - username: username, - ); - _logger.fine('Created RegisterLnurlPayRequest: $request'); - - final RegisterLnurlPayResponse registrationResponse = await lnAddressService.register( - pubKey: pubKey, - request: request, - ); - - if (username != null && username.isNotEmpty) { - await breezPreferences.setLnAddressUsername(username); - _logger.info('Stored username in secure storage: $username'); - } - - _logger.info( - 'Successfully registered Lightning Address: $registrationResponse', - ); - - await breezPreferences.setLnUrlWebhookAsRegistered(); - - return registrationResponse; - } catch (e, stackTrace) { - _logger.severe('Failed to register Lightning Address', e, stackTrace); - rethrow; + Future _resolveUsername() async { + String? username = ''; + final bool hasRegisteredWebhook = await breezPreferences.hasRegisteredLnUrlWebhook(); + + if (!hasRegisteredWebhook) { + final String? profileName = await breezPreferences.getProfileName(); + username = UsernameFormatter.formatDefaultProfileName(profileName); + _logger.info('Registering LNURL Webhook: Using formatted profile name: $username'); + } else { + // TODO(erdemyerebasmaz): Add null-handling, revert back to profile name if necessary + username = await breezPreferences.getLnAddressUsername(); + _logger.info('Refreshing LNURL Webhook: Using stored username: $username'); } + return username; } Future _generateWebhookSignature(String webhookUrl, String? username) async { _logger.info('Generating webhook signature'); final String usernameComponent = username?.isNotEmpty == true ? '-$username' : ''; - final int timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final String message = '$timestamp-$webhookUrl$usernameComponent'; + final int time = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final String message = '$time-$webhookUrl$usernameComponent'; final String signature = await _signMessage(message); _logger.info('Successfully generated webhook signature'); return signature; } + Future _registerLnurlWebhook({ + required String pubKey, + required String webhookUrl, + required String signature, + String? username, + }) async { + final RegisterLnurlPayRequest request = RegisterLnurlPayRequest( + time: DateTime.now().millisecondsSinceEpoch ~/ 1000, + webhookUrl: webhookUrl, + signature: signature, + username: username, + ); + + final RegisterLnurlPayResponse registrationResponse = await lnAddressService.register( + pubKey: pubKey, + request: request, + ); + + if (username != null && username.isNotEmpty) { + await breezPreferences.setLnAddressUsername(username); + _logger.info('Stored username in secure storage: $username'); + } + + _logger.info( + 'Successfully registered LNURL Webhook: $registrationResponse', + ); + + await breezPreferences.setLnUrlWebhookAsRegistered(); + return registrationResponse; + } + void clearUpdateStatus() { _logger.info('Clearing LnAddressUpdateStatus'); emit( @@ -212,19 +236,3 @@ class LnAddressCubit extends Cubit { ); } } - -class LnAddressCubitFactory { - static LnAddressCubit create(ServiceInjector injector) { - final BreezSDKLiquid breezSdkLiquid = injector.breezSdkLiquid; - final BreezPreferences breezPreferences = injector.breezPreferences; - - final WebhookService webhookService = WebhookService(breezSdkLiquid, injector.notifications); - - return LnAddressCubit( - breezSdkLiquid: breezSdkLiquid, - breezPreferences: breezPreferences, - lnAddressService: LnUrlPayService(), - webhookService: webhookService, - ); - } -} diff --git a/lib/cubit/ln_address/services/lnurl_pay_service.dart b/lib/cubit/ln_address/services/lnurl_pay_service.dart index a07e0ad3..b8a8ae78 100644 --- a/lib/cubit/ln_address/services/lnurl_pay_service.dart +++ b/lib/cubit/ln_address/services/lnurl_pay_service.dart @@ -16,7 +16,7 @@ class LnUrlPayService { required String pubKey, required RegisterLnurlPayRequest request, }) async { - _logger.info('Registering lightning address for pubkey: $pubKey'); + _logger.info('Registering lightning address for pubkey: $pubKey with request: $request'); // Register without retries if this is an update to existing LNURL Webhook if (request.username?.isNotEmpty ?? false) { 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 2c1abce8..c018fdca 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 @@ -204,7 +204,7 @@ class _UpdateLnAddressUsernameBottomSheetState extends State(); final String username = UsernameFormatter.sanitize(_usernameController.text); - lnAddressCubit.setupLightningAddress(username: username); + lnAddressCubit.setupLightningAddress(baseUsername: username); } } } From 2e1c848dfd22a8e3bb2d9964d60d6c51dacdfa3d Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Wed, 29 Jan 2025 01:52:24 +0300 Subject: [PATCH 23/29] fix: use the same timestamp for generating the signature & registration request --- lib/cubit/ln_address/ln_address_cubit.dart | 27 +++++++++------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/lib/cubit/ln_address/ln_address_cubit.dart b/lib/cubit/ln_address/ln_address_cubit.dart index 35c686f2..cce78727 100644 --- a/lib/cubit/ln_address/ln_address_cubit.dart +++ b/lib/cubit/ln_address/ln_address_cubit.dart @@ -102,13 +102,18 @@ class LnAddressCubit extends Cubit { final WalletInfo walletInfo = await _getWalletInfo(); final String webhookUrl = await _setupWebhook(walletInfo.pubkey); final String? username = baseUsername ?? await _resolveUsername(); - final String signature = await _generateWebhookSignature(webhookUrl, username); - return await _registerLnurlWebhook( - pubKey: walletInfo.pubkey, + final int time = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final String signature = await _generateWebhookSignature(time, webhookUrl, username); + final RegisterLnurlPayRequest request = RegisterLnurlPayRequest( + time: time, webhookUrl: webhookUrl, signature: signature, username: username, ); + return await _registerLnurlWebhook( + pubKey: walletInfo.pubkey, + request: request, + ); } Future _getWalletInfo() async { @@ -185,12 +190,10 @@ class LnAddressCubit extends Cubit { return username; } - Future _generateWebhookSignature(String webhookUrl, String? username) async { + Future _generateWebhookSignature(int time, String webhookUrl, String? username) async { _logger.info('Generating webhook signature'); final String usernameComponent = username?.isNotEmpty == true ? '-$username' : ''; - final int time = DateTime.now().millisecondsSinceEpoch ~/ 1000; final String message = '$time-$webhookUrl$usernameComponent'; - final String signature = await _signMessage(message); _logger.info('Successfully generated webhook signature'); return signature; @@ -198,22 +201,14 @@ class LnAddressCubit extends Cubit { Future _registerLnurlWebhook({ required String pubKey, - required String webhookUrl, - required String signature, - String? username, + required RegisterLnurlPayRequest request, }) async { - final RegisterLnurlPayRequest request = RegisterLnurlPayRequest( - time: DateTime.now().millisecondsSinceEpoch ~/ 1000, - webhookUrl: webhookUrl, - signature: signature, - username: username, - ); - final RegisterLnurlPayResponse registrationResponse = await lnAddressService.register( pubKey: pubKey, request: request, ); + final String? username = request.username; if (username != null && username.isNotEmpty) { await breezPreferences.setLnAddressUsername(username); _logger.info('Stored username in secure storage: $username'); From 718b17da8ba635ddd25742c20803c52dcf6cd65f Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Wed, 29 Jan 2025 02:41:16 +0300 Subject: [PATCH 24/29] refactor: Remove unused values on BreezPreferences - Created a getter for SharedPreferences instance - Used getters for get functions - Added comments to section keys - Renamed keys & their fn's for consistency This change will cause a regression as the key value has changed for _kBugReportBehavior. --- lib/cubit/ln_address/ln_address_cubit.dart | 15 +- .../user_profile/user_profile_cubit.dart | 2 +- lib/routes/dev/developers_view.dart | 26 +-- .../lib/src/breez_preferences.dart | 181 ++++++------------ 4 files changed, 84 insertions(+), 140 deletions(-) diff --git a/lib/cubit/ln_address/ln_address_cubit.dart b/lib/cubit/ln_address/ln_address_cubit.dart index cce78727..863323f7 100644 --- a/lib/cubit/ln_address/ln_address_cubit.dart +++ b/lib/cubit/ln_address/ln_address_cubit.dart @@ -137,11 +137,11 @@ class LnAddressCubit extends Cubit { required String pubKey, required String webhookUrl, }) async { - final String? existingWebhook = await breezPreferences.getWebhookUrl(); + final String? existingWebhook = await breezPreferences.webhookUrl; if (existingWebhook != null && existingWebhook != webhookUrl) { _logger.info('Unregistering existing webhook: $existingWebhook'); await _unregisterWebhook(existingWebhook, pubKey); - breezPreferences.clearWebhookUrl(); + breezPreferences.removeWebhookUrl(); } } @@ -176,15 +176,15 @@ class LnAddressCubit extends Cubit { Future _resolveUsername() async { String? username = ''; - final bool hasRegisteredWebhook = await breezPreferences.hasRegisteredLnUrlWebhook(); + final bool isLnUrlWebhookRegistered = await breezPreferences.isLnUrlWebhookRegistered; - if (!hasRegisteredWebhook) { - final String? profileName = await breezPreferences.getProfileName(); + if (!isLnUrlWebhookRegistered) { + final String? profileName = await breezPreferences.profileName; username = UsernameFormatter.formatDefaultProfileName(profileName); _logger.info('Registering LNURL Webhook: Using formatted profile name: $username'); } else { // TODO(erdemyerebasmaz): Add null-handling, revert back to profile name if necessary - username = await breezPreferences.getLnAddressUsername(); + username = await breezPreferences.lnAddressUsername; _logger.info('Refreshing LNURL Webhook: Using stored username: $username'); } return username; @@ -211,14 +211,13 @@ class LnAddressCubit extends Cubit { final String? username = request.username; if (username != null && username.isNotEmpty) { await breezPreferences.setLnAddressUsername(username); - _logger.info('Stored username in secure storage: $username'); } _logger.info( 'Successfully registered LNURL Webhook: $registrationResponse', ); - await breezPreferences.setLnUrlWebhookAsRegistered(); + await breezPreferences.setLnUrlWebhookRegistered(); return registrationResponse; } diff --git a/lib/cubit/user_profile/user_profile_cubit.dart b/lib/cubit/user_profile/user_profile_cubit.dart index 138f3452..119721b9 100644 --- a/lib/cubit/user_profile/user_profile_cubit.dart +++ b/lib/cubit/user_profile/user_profile_cubit.dart @@ -46,7 +46,7 @@ class UserProfileCubit extends Cubit with HydratedMixin _setProfileName(String name) async { - final String? profileName = await _breezPreferences.getProfileName(); + final String? profileName = await _breezPreferences.profileName; if (profileName == null) { await _breezPreferences.setProfileName(name); } diff --git a/lib/routes/dev/developers_view.dart b/lib/routes/dev/developers_view.dart index 0515915a..54cbdf11 100644 --- a/lib/routes/dev/developers_view.dart +++ b/lib/routes/dev/developers_view.dart @@ -53,10 +53,10 @@ class _DevelopersViewState extends State { @override void initState() { super.initState(); - _preferences.getBugReportBehavior().then( - (BugReportBehavior value) => bugReportBehavior = value, - onError: (Object e) => _logger.warning(e), - ); + _preferences.bugReportBehavior.then( + (BugReportBehavior value) => bugReportBehavior = value, + onError: (Object e) => _logger.warning(e), + ); } @override @@ -100,15 +100,15 @@ class _DevelopersViewState extends State { Choice( title: texts.developers_page_menu_prompt_bug_report_title, icon: Icons.bug_report, - function: (_) { - _preferences.setBugReportBehavior(BugReportBehavior.prompt).then( - (void value) => setState( - () { - bugReportBehavior = BugReportBehavior.prompt; - }, - ), - onError: (Object e) => _logger.warning(e), - ); + function: (_) async { + try { + await _preferences.setBugReportBehavior(BugReportBehavior.prompt); + setState(() { + bugReportBehavior = BugReportBehavior.prompt; + }); + } catch (e) { + _logger.warning('Failed to set BugReportBehavior: $e'); + } }, ), ] diff --git a/packages/breez_preferences/lib/src/breez_preferences.dart b/packages/breez_preferences/lib/src/breez_preferences.dart index d6bf46d5..36898a61 100644 --- a/packages/breez_preferences/lib/src/breez_preferences.dart +++ b/packages/breez_preferences/lib/src/breez_preferences.dart @@ -1,155 +1,100 @@ import 'package:breez_preferences/src/model/bug_report_behavior.dart'; -import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; -import 'package:shared_preference_app_group/shared_preference_app_group.dart'; import 'package:shared_preferences/shared_preferences.dart'; -const double kDefaultProportionalFee = 1.0; -const int kDefaultExemptFeeMsat = 20000; -const int kDefaultChannelSetupFeeLimitMsat = 5000000; - -const String _mempoolSpaceUrlKey = 'mempool_space_url'; -const String _kPaymentOptionProportionalFee = 'payment_options_proportional_fee'; -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'; -const String _kLnUrlWebhookRegistered = 'is_lnurl_webhook_registered'; - final Logger _logger = Logger('BreezPreferences'); class BreezPreferences { - const BreezPreferences(); - - Future hasPaymentOptions() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getKeys().containsAll([ - _kPaymentOptionProportionalFee, - _kPaymentOptionExemptFee, - _kPaymentOptionChannelSetupFeeLimit, - ]); - } - - Future getMempoolSpaceUrl() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getString(_mempoolSpaceUrlKey); - } - - Future setMempoolSpaceUrl(String url) async { - _logger.info('set mempool space url: $url'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString(_mempoolSpaceUrlKey, url); - } + // Preference Keys + static const String _kBugReportBehavior = 'bug_report_behavior'; + static const String _kProfileName = 'profile_name'; + static const String _kWebhookUrl = 'webhook_url'; + static const String _kLnUrlWebhookRegistered = 'lnurl_webhook_registered'; + static const String _kLnAddressUsername = 'ln_address_username'; - Future resetMempoolSpaceUrl() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.remove(_mempoolSpaceUrlKey); - } - - Future getPaymentOptionsProportionalFee() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getDouble(_kPaymentOptionProportionalFee) ?? kDefaultProportionalFee; - } + const BreezPreferences(); - Future setPaymentOptionsProportionalFee(double fee) async { - _logger.info('set payment options proportional fee: $fee'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setDouble(_kPaymentOptionProportionalFee, fee); - } + Future get _preferences => SharedPreferences.getInstance(); - Future getPaymentOptionsExemptFee() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getInt(_kPaymentOptionExemptFee) ?? kDefaultExemptFeeMsat; - } + // Bug Report Behavior + Future get bugReportBehavior async { + final SharedPreferences prefs = await _preferences; + final int? value = prefs.getInt(_kBugReportBehavior); + final BugReportBehavior behavior = BugReportBehavior.values[value ?? BugReportBehavior.prompt.index]; - Future setPaymentOptionsExemptFee(int exemptFeeMsat) async { - _logger.info('set payment options exempt fee : $exemptFeeMsat'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_kPaymentOptionExemptFee, exemptFeeMsat); + _logger.info('Fetched BugReportBehavior: $behavior'); + return behavior; } - Future getPaymentOptionsChannelSetupFeeLimitMsat() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getInt(_kPaymentOptionChannelSetupFeeLimit) ?? kDefaultChannelSetupFeeLimitMsat; + Future setBugReportBehavior(BugReportBehavior behavior) async { + _logger.info('Setting BugReportBehavior: $behavior'); + final SharedPreferences prefs = await _preferences; + await prefs.setInt(_kBugReportBehavior, behavior.index); } - Future setPaymentOptionsChannelSetupFeeLimit(int channelFeeLimitMsat) async { - _logger.info('set payment options channel setup limit fee : $channelFeeLimitMsat'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_kPaymentOptionChannelSetupFeeLimit, channelFeeLimitMsat); - // iOS Extension requirement - if (defaultTargetPlatform == TargetPlatform.iOS) { - await SharedPreferenceAppGroup.setInt(_kPaymentOptionChannelSetupFeeLimit, channelFeeLimitMsat); - } - } + // Profile Name + Future get profileName async { + final SharedPreferences prefs = await _preferences; + final String? name = prefs.getString(_kProfileName); - Future getBugReportBehavior() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - final int? value = prefs.getInt(_kReportPrefKey); - if (value == null || value < 0 || value >= BugReportBehavior.values.length) { - return BugReportBehavior.prompt; - } - return BugReportBehavior.values[value]; + _logger.info('Fetched Profile Name: $name'); + return name; } - Future setBugReportBehavior(BugReportBehavior behavior) async { - _logger.info('set bug report behavior: $behavior'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_kReportPrefKey, behavior.index); + Future setProfileName(String name) async { + _logger.info('Setting Profile Name: $name'); + final SharedPreferences prefs = await _preferences; + await prefs.setString(_kProfileName, name); } - Future getWebhookUrl() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getString(_kLnUrlPayKey); - } + // Webhook URL + Future get webhookUrl async { + final SharedPreferences prefs = await _preferences; + final String? url = prefs.getString(_kWebhookUrl); - Future setWebhookUrl(String webhookUrl) async { - _logger.info('set lnurl pay key: $webhookUrl'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kLnUrlPayKey, webhookUrl); + _logger.info('Fetched Webhook URL: $url'); + return url; } - Future clearWebhookUrl() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.remove(_kLnUrlPayKey); + Future setWebhookUrl(String url) async { + _logger.info('Setting Webhook URL: $url'); + final SharedPreferences prefs = await _preferences; + await prefs.setString(_kWebhookUrl, url); } - Future getProfileName() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getString(_kProfileName); + Future removeWebhookUrl() async { + _logger.info('Removing Webhook URL'); + final SharedPreferences prefs = await _preferences; + await prefs.remove(_kWebhookUrl); } - Future setProfileName(String profileName) async { - _logger.info('set profile name: $profileName'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kProfileName, profileName); - } + // LN URL Webhook Registration + Future get isLnUrlWebhookRegistered async { + final SharedPreferences prefs = await _preferences; + final bool registered = prefs.getBool(_kLnUrlWebhookRegistered) ?? false; - Future getLnAddressUsername() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getString(_kLnAddressUsername); + _logger.info('LNURL Webhook Registered: $registered'); + return registered; } - Future setLnAddressUsername(String lnAddressUsername) async { - _logger.info('Set LN Address username: $lnAddressUsername'); - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kLnAddressUsername, lnAddressUsername); + Future setLnUrlWebhookRegistered() async { + _logger.info('Setting LNURL Webhook as Registered'); + final SharedPreferences prefs = await _preferences; + await prefs.setBool(_kLnUrlWebhookRegistered, true); } - Future clearLnAddressUsername() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.remove(_kLnAddressUsername); - } + // LN Address Username + Future get lnAddressUsername async { + final SharedPreferences prefs = await _preferences; + final String? username = prefs.getString(_kLnAddressUsername); - Future hasRegisteredLnUrlWebhook() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - return prefs.getBool(_kLnUrlWebhookRegistered) ?? false; + _logger.info('Fetched LN Address Username: $username'); + return username; } - Future setLnUrlWebhookAsRegistered() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_kLnUrlWebhookRegistered, true); + Future setLnAddressUsername(String username) async { + _logger.info('Setting LN Address Username: $username'); + final SharedPreferences prefs = await _preferences; + await prefs.setString(_kLnAddressUsername, username); } } From fb3acbf42b9095ed8138e999887cb14cd23a8311 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Wed, 29 Jan 2025 03:03:58 +0300 Subject: [PATCH 25/29] Add TODO comment for _registerWithRetries --- lib/cubit/ln_address/services/lnurl_pay_service.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/cubit/ln_address/services/lnurl_pay_service.dart b/lib/cubit/ln_address/services/lnurl_pay_service.dart index b8a8ae78..bda095af 100644 --- a/lib/cubit/ln_address/services/lnurl_pay_service.dart +++ b/lib/cubit/ln_address/services/lnurl_pay_service.dart @@ -27,6 +27,15 @@ class LnUrlPayService { return _registerWithRetries(pubKey: pubKey, username: request.username ?? '', request: request); } + // TODO(erdemyerebasmaz): Optimize if current retry logic is insufficient + // If initial registration fails, up to [_maxRetries] registration attempts will be made on opening [ReceiveLightningAddressPage]. + // If these attempts also fail, the user can retry manually via a button, which will trigger another registration attempt with [_maxRetries] retries. + // + // Future improvements could include: + // - Retrying indefinitely with intervals until registration succeeds + // - Explicit handling of [UsernameConflictException] and LNURL server connectivity issues + // - Randomizing the default profile name itself after a set number of failures + // - Adding additional digits to the discriminator Future _registerWithRetries({ required String pubKey, required String username, From 2b036eea4898cc15b5082e15adae986cce177228 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Wed, 29 Jan 2025 03:13:24 +0300 Subject: [PATCH 26/29] refactor: improve readability of setupLightningAddress function Reduced nesting and consolidated error handling & state emission for both actions --- lib/cubit/ln_address/ln_address_cubit.dart | 63 +++++++++++----------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/lib/cubit/ln_address/ln_address_cubit.dart b/lib/cubit/ln_address/ln_address_cubit.dart index 863323f7..b14d588e 100644 --- a/lib/cubit/ln_address/ln_address_cubit.dart +++ b/lib/cubit/ln_address/ln_address_cubit.dart @@ -43,13 +43,15 @@ class LnAddressCubit extends Cubit { }) : super(const LnAddressState()); Future setupLightningAddress({String? baseUsername}) async { - final bool isUpdate = baseUsername != null; - _logger.info(isUpdate ? 'Updating username to: $baseUsername' : 'Initializing lightning address'); + final bool isUpdating = baseUsername != null; + final String actionMessage = + isUpdating ? 'Update LN Address Username to: $baseUsername' : 'Setup Lightning Address'; + _logger.info(actionMessage); emit( state.copyWith( - status: isUpdate ? state.status : LnAddressStatus.loading, - updateStatus: isUpdate ? const LnAddressUpdateStatus(status: UpdateStatus.loading) : null, + status: isUpdating ? state.status : LnAddressStatus.loading, + updateStatus: isUpdating ? const LnAddressUpdateStatus(status: UpdateStatus.loading) : null, ), ); @@ -63,41 +65,38 @@ class LnAddressCubit extends Cubit { status: LnAddressStatus.success, lnurl: registrationResponse.lnurl, lnAddress: registrationResponse.lightningAddress, - updateStatus: isUpdate ? const LnAddressUpdateStatus(status: UpdateStatus.success) : null, + updateStatus: isUpdating ? const LnAddressUpdateStatus(status: UpdateStatus.success) : null, ), ); } catch (e, stackTrace) { - _logger.severe( - isUpdate ? 'Failed to update username' : 'Failed to initialize lightning address', - e, - stackTrace, + _logger.severe('Failed to $actionMessage', e, stackTrace); + final LnAddressStatus status = isUpdating ? state.status : LnAddressStatus.error; + final Object? error = isUpdating ? null : e; + final LnAddressUpdateStatus? updateStatus = + isUpdating ? _createErrorUpdateStatus(e, actionMessage) : null; + emit( + state.copyWith( + status: status, + error: error, + updateStatus: updateStatus, + ), ); - - if (isUpdate) { - final String errorMessage = e is RegisterLnurlPayException - ? (e.responseBody?.isNotEmpty == true ? e.responseBody! : e.message) - : 'Failed to update username'; - - emit( - state.copyWith( - updateStatus: LnAddressUpdateStatus( - status: UpdateStatus.error, - error: e, - errorMessage: errorMessage, - ), - ), - ); - } else { - emit( - state.copyWith( - status: LnAddressStatus.error, - error: e, - ), - ); - } } } + LnAddressUpdateStatus _createErrorUpdateStatus(Object e, String action) { + final String errorMessage = e is RegisterLnurlPayException + ? (e.responseBody?.isNotEmpty == true ? e.responseBody! : e.message) + : e is UsernameConflictException + ? e.toString() + : 'Failed to $action'; + + return LnAddressUpdateStatus( + status: UpdateStatus.error, + error: e, + errorMessage: errorMessage, + ); + } Future _setupAndRegisterLnAddress({String? baseUsername}) async { final WalletInfo walletInfo = await _getWalletInfo(); final String webhookUrl = await _setupWebhook(walletInfo.pubkey); From 89df12ff4a0c8712fd2f1966c06ccd85bea30ff0 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Wed, 29 Jan 2025 03:53:52 +0300 Subject: [PATCH 27/29] refactor: improve readability of _resolveUsername --- lib/cubit/ln_address/ln_address_cubit.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/cubit/ln_address/ln_address_cubit.dart b/lib/cubit/ln_address/ln_address_cubit.dart index b14d588e..b48e7f25 100644 --- a/lib/cubit/ln_address/ln_address_cubit.dart +++ b/lib/cubit/ln_address/ln_address_cubit.dart @@ -174,19 +174,19 @@ class LnAddressCubit extends Cubit { } Future _resolveUsername() async { - String? username = ''; final bool isLnUrlWebhookRegistered = await breezPreferences.isLnUrlWebhookRegistered; if (!isLnUrlWebhookRegistered) { final String? profileName = await breezPreferences.profileName; - username = UsernameFormatter.formatDefaultProfileName(profileName); - _logger.info('Registering LNURL Webhook: Using formatted profile name: $username'); - } else { - // TODO(erdemyerebasmaz): Add null-handling, revert back to profile name if necessary - username = await breezPreferences.lnAddressUsername; - _logger.info('Refreshing LNURL Webhook: Using stored username: $username'); + final String formattedUsername = UsernameFormatter.formatDefaultProfileName(profileName); + _logger.info('Registering LNURL Webhook: Using formatted profile name: $formattedUsername'); + return formattedUsername; } - return username; + + // TODO(erdemyerebasmaz): Add null-handling to revert to the profile name if the stored username is null. + final String? storedUsername = await breezPreferences.lnAddressUsername; + _logger.info('Refreshing LNURL Webhook: Using stored username: $storedUsername'); + return storedUsername; } Future _generateWebhookSignature(int time, String webhookUrl, String? username) async { From 72701f4dabd21104fc70d8b82b423124810d7e17 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Wed, 29 Jan 2025 03:54:32 +0300 Subject: [PATCH 28/29] docs: Add docstrings for LnAddressCubit functions --- lib/cubit/ln_address/ln_address_cubit.dart | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/cubit/ln_address/ln_address_cubit.dart b/lib/cubit/ln_address/ln_address_cubit.dart index b48e7f25..fd161b60 100644 --- a/lib/cubit/ln_address/ln_address_cubit.dart +++ b/lib/cubit/ln_address/ln_address_cubit.dart @@ -42,6 +42,10 @@ class LnAddressCubit extends Cubit { required this.webhookService, }) : super(const LnAddressState()); + /// Sets up or updates the Lightning Address. + /// + /// - If [baseUsername] is provided, the function updates the Lightning Address username. + /// - Otherwise, it initializes a new Lightning Address or refreshes an existing one. Future setupLightningAddress({String? baseUsername}) async { final bool isUpdating = baseUsername != null; final String actionMessage = @@ -97,6 +101,11 @@ class LnAddressCubit extends Cubit { errorMessage: errorMessage, ); } + + /// Registers or updates an LNURL webhook for a Lightning Address. + /// + /// - If [baseUsername] is provided, it updates the existing registration. + /// - Otherwise, it determines a suitable username and registers a new webhook. Future _setupAndRegisterLnAddress({String? baseUsername}) async { final WalletInfo walletInfo = await _getWalletInfo(); final String webhookUrl = await _setupWebhook(walletInfo.pubkey); @@ -123,6 +132,9 @@ class LnAddressCubit extends Cubit { return walletInfo; } + /// Sets up a webhook for the given public key. + /// - Generates a new webhook URL, unregisters any existing webhook if needed, + /// - Registers the new webhook, and stores the webhook URL in preferences. Future _setupWebhook(String pubKey) async { _logger.info('Setting up webhook'); final String webhookUrl = await webhookService.generateWebhookUrl(); @@ -132,6 +144,7 @@ class LnAddressCubit extends Cubit { return webhookUrl; } + /// Checks if there is an existing webhook URL and unregisters it if different from the provided one. Future _unregisterExistingWebhookIfNeeded({ required String pubKey, required String webhookUrl, @@ -144,8 +157,10 @@ class LnAddressCubit extends Cubit { } } + /// Unregisters a webhook for a given public key. Future _unregisterWebhook(String webhookUrl, String pubKey) async { final int time = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final String message = '$time-$webhookUrl'; final String signature = await _signMessage(message); @@ -158,6 +173,7 @@ class LnAddressCubit extends Cubit { await lnAddressService.unregister(pubKey, invalidateWebhookRequest); } + /// Signs the given message with the private key. Future _signMessage(String message) async { _logger.info('Signing message: $message'); @@ -173,6 +189,10 @@ class LnAddressCubit extends Cubit { return signMessageRes.signature; } + /// Resolves the appropriate username for LNURL registration. + /// + /// - If the webhook is not yet registered, it utilizes default profile name as username. + /// - If the webhook is already registered, it retrieves the stored username from [BreezPreferences]. Future _resolveUsername() async { final bool isLnUrlWebhookRegistered = await breezPreferences.isLnUrlWebhookRegistered; @@ -189,6 +209,7 @@ class LnAddressCubit extends Cubit { return storedUsername; } + /// Signs a webhook request message for authentication and validation purposes. Future _generateWebhookSignature(int time, String webhookUrl, String? username) async { _logger.info('Generating webhook signature'); final String usernameComponent = username?.isNotEmpty == true ? '-$username' : ''; @@ -198,6 +219,10 @@ class LnAddressCubit extends Cubit { return signature; } + /// Registers an LNURL webhook with the provided public key and request. + /// + /// - Saves the username to [BreezPreferences] if present and + /// - Sets webhook as registered on [BreezPreferences] if succeeds Future _registerLnurlWebhook({ required String pubKey, required RegisterLnurlPayRequest request, From af556035b756a01ba08475f72ac8fc56630d82fe Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Wed, 29 Jan 2025 14:21:43 +0300 Subject: [PATCH 29/29] Rename GetInfoResponse variables for consistency (#341) --- lib/cubit/account/account_cubit.dart | 8 ++++---- lib/cubit/chain_swap/chain_swap_cubit.dart | 2 +- lib/cubit/currency/currency_cubit.dart | 4 ++-- lib/cubit/payment_limits/payment_limits_cubit.dart | 2 +- lib/cubit/refund/refund_cubit.dart | 2 +- .../breez_sdk_liquid/lib/src/breez_sdk_liquid.dart | 10 +++++----- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/cubit/account/account_cubit.dart b/lib/cubit/account/account_cubit.dart index 7d582bd0..d274b77a 100644 --- a/lib/cubit/account/account_cubit.dart +++ b/lib/cubit/account/account_cubit.dart @@ -22,11 +22,11 @@ class AccountCubit extends Cubit with HydratedMixin void _listenAccountChanges() { _logger.info('Listening to account changes'); - breezSdkLiquid.walletInfoStream.distinct().listen( - (GetInfoResponse infoResponse) { + breezSdkLiquid.getInfoResponseStream.distinct().listen( + (GetInfoResponse getInfoResponse) { final AccountState newState = state.copyWith( - walletInfo: infoResponse.walletInfo, - blockchainInfo: infoResponse.blockchainInfo, + walletInfo: getInfoResponse.walletInfo, + blockchainInfo: getInfoResponse.blockchainInfo, ); _logger.info('AccountState changed: $newState'); emit(newState); diff --git a/lib/cubit/chain_swap/chain_swap_cubit.dart b/lib/cubit/chain_swap/chain_swap_cubit.dart index e5dc132c..c81c62f2 100644 --- a/lib/cubit/chain_swap/chain_swap_cubit.dart +++ b/lib/cubit/chain_swap/chain_swap_cubit.dart @@ -18,7 +18,7 @@ class ChainSwapCubit extends Cubit { } void _initializeChainSwapCubit() { - _breezSdkLiquid.walletInfoStream.first.then((_) => rescanOnchainSwaps()); + _breezSdkLiquid.getInfoResponseStream.first.then((_) => rescanOnchainSwaps()); } Future rescanOnchainSwaps() async { diff --git a/lib/cubit/currency/currency_cubit.dart b/lib/cubit/currency/currency_cubit.dart index 177b601e..24770bff 100644 --- a/lib/cubit/currency/currency_cubit.dart +++ b/lib/cubit/currency/currency_cubit.dart @@ -16,8 +16,8 @@ class CurrencyCubit extends Cubit with HydratedMixin { void _fetchPaymentLimits() { if (_breezSdkLiquid.instance != null) { - _breezSdkLiquid.walletInfoStream.first.then((GetInfoResponse walletInfo) { + _breezSdkLiquid.getInfoResponseStream.first.then((GetInfoResponse getInfoResponse) { fetchLightningLimits(); fetchOnchainLimits(); }).timeout( diff --git a/lib/cubit/refund/refund_cubit.dart b/lib/cubit/refund/refund_cubit.dart index 4885636f..11492bf6 100644 --- a/lib/cubit/refund/refund_cubit.dart +++ b/lib/cubit/refund/refund_cubit.dart @@ -23,7 +23,7 @@ class RefundCubit extends Cubit { } void _initializeRefundCubit() { - _breezSdkLiquid.walletInfoStream.first.then((_) => listRefundables()); + _breezSdkLiquid.getInfoResponseStream.first.then((_) => listRefundables()); } void listRefundables() async { diff --git a/packages/breez_sdk_liquid/lib/src/breez_sdk_liquid.dart b/packages/breez_sdk_liquid/lib/src/breez_sdk_liquid.dart index e6a14454..b0f2955a 100644 --- a/packages/breez_sdk_liquid/lib/src/breez_sdk_liquid.dart +++ b/packages/breez_sdk_liquid/lib/src/breez_sdk_liquid.dart @@ -55,9 +55,9 @@ class BreezSDKLiquid { } Future _getInfo(liquid_sdk.BindingLiquidSdk sdk) async { - final liquid_sdk.GetInfoResponse walletInfo = await sdk.getInfo(); - _walletInfoController.add(walletInfo); - return walletInfo; + final liquid_sdk.GetInfoResponse getInfoResponse = await sdk.getInfo(); + _getInfoResponseController.add(getInfoResponse); + return getInfoResponse; } Future> _listPayments({ @@ -94,10 +94,10 @@ class BreezSDKLiquid { _breezEventsStream ??= sdk.addEventListener().asBroadcastStream(); } - final StreamController _walletInfoController = + final StreamController _getInfoResponseController = BehaviorSubject(); - Stream get walletInfoStream => _walletInfoController.stream; + Stream get getInfoResponseStream => _getInfoResponseController.stream; final StreamController> _paymentsController = BehaviorSubject>();