From f1c6095036cda3742da0a1e897ea880f55556fc9 Mon Sep 17 00:00:00 2001 From: Erdem Yerebasmaz Date: Tue, 21 Jan 2025 16:06:51 +0300 Subject: [PATCH] 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 +++++ ...ange_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/change_ln_address_username_bottom_sheet.dart b/lib/routes/receive_payment/widgets/destination_widget/widgets/change_ln_address_username_bottom_sheet.dart index 0f462f6e..e21127b9 100644 --- a/lib/routes/receive_payment/widgets/destination_widget/widgets/change_ln_address_username_bottom_sheet.dart +++ b/lib/routes/receive_payment/widgets/destination_widget/widgets/change_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'; @@ -115,21 +114,14 @@ class _ChangeLnAddressUsernameBottomSheetState 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.'; }, @@ -157,7 +149,7 @@ class _ChangeLnAddressUsernameBottomSheetState extends State(); - webhookCubit.refreshWebhooks(username: _usernameController.text); + webhookCubit.updateLnAddressUsername(username: _usernameController.text); Navigator.pop(context); } },