Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display LN Address on "Receive via Lightning Address" page #313

Closed
wants to merge 12 commits into from
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions lib/app/view/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<CurrencyCubit>(
Expand Down
159 changes: 159 additions & 0 deletions lib/cubit/webhook/lnurl_pay_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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<Map<String, String>> 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<String, String> response = await _postWebhookRegistrationRequest(
pubKey: pubKey,
webhookUrl: webhookUrl,
currentTime: currentTime,
lnAddressUsername: lnAddressUsername,
signature: signMessageRes.signature,
);

await setLnUrlPayKey(webhookUrl: webhookUrl);
return response;
}

Future<void> _invalidatePreviousWebhookIfNeeded(String pubKey, String webhookUrl) async {
final String? lastUsedLnurlPay = await getLnUrlPayKey();
if (lastUsedLnurlPay != null && lastUsedLnurlPay != webhookUrl) {
await _invalidateLnurlPay(pubKey, lastUsedLnurlPay);
}
}

Future<SignMessageResponse?> _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<Map<String, String>> _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<String, dynamic> 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 <String, String>{
'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<Map<String, String>> 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<void> _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<void> setLnUrlPayKey({required String webhookUrl}) async {
return await _breezPreferences.setLnUrlPayKey(webhookUrl);
}

Future<String?> getLnUrlPayKey() async {
return await _breezPreferences.getLnUrlPayKey();
}

Future<void> resetLnUrlPayKey() async {
return await _breezPreferences.resetLnUrlPayKey();
}
}
176 changes: 57 additions & 119 deletions lib/cubit/webhook/webhook_cubit.dart
Original file line number Diff line number Diff line change
@@ -1,153 +1,91 @@
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<WebhookState> {
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<void> refreshWebhooks({WalletInfo? walletInfo}) async {
_logger.info('Refreshing webhooks');
Future<void> refreshWebhooks({WalletInfo? walletInfo, String? username}) async {
erdemyerebasmaz marked this conversation as resolved.
Show resolved Hide resolved
_logger.info('Refreshing Webhooks');
emit(WebhookState(isLoading: true));
try {
walletInfo = walletInfo ?? (await _breezSdkLiquid.instance?.getInfo())?.walletInfo;
if (walletInfo != null) {
await _registerWebhooks(walletInfo);
} else {
throw Exception('Unable to retrieve wallet information.');
if (walletInfo == null) {
throw Exception('Failed to retrieve wallet info.');
}
} catch (err) {
_logger.warning('Failed to refresh lnurlpay: $err');
final String webhookUrl = await _webhookService.generateWebhookURL();
await _webhookService.registerWebhook(webhookUrl);
final Map<String, String> lnUrlData = await _lnUrlPayService.registerLnurlpay(
walletInfo,
webhookUrl,
username: username ?? state.lnAddressUsername,
);
emit(
state.copyWith(
lnurlPayErrorTitle: 'Failed to refresh Lightning Address:',
lnurlPayError: err.toString(),
WebhookState(
lnurlPayUrl: lnUrlData['lnurl'],
lnAddress: lnUrlData['lnAddress'],
),
);
} finally {
emit(state.copyWith(isLoading: false));
}
}

Future<void> _registerWebhooks(WalletInfo walletInfo) 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);
emit(WebhookState(lnurlPayUrl: lnurl));
} catch (err) {
_logger.warning('Failed to register webhooks: $err');
emit(state.copyWith(lnurlPayErrorTitle: 'Failed to register webhooks:', lnurlPayError: err.toString()));
rethrow;
}
}

Future<String> _registerLnurlpay(
WalletInfo walletInfo,
String webhookUrl,
) async {
final String? lastUsedLnurlPay = await _breezPreferences.getLnUrlPayKey();
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 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');
}
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: username,
signature: signMessageRes.signature,
).toJson(),
),
);
if (jsonResponse.statusCode == 200) {
final Map<String, dynamic> data = jsonDecode(jsonResponse.body);
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;
} else {
throw jsonResponse.body;
_logger.warning('Failed to refresh webhooks: $err');
emit(
WebhookState(
webhookError: err.toString(),
webhookErrorTitle: 'Failed to refresh Lightning Address:',
),
);
}
}

Future<void> _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(),
Future<void> updateLnAddressUsername({required String lnAddressUsername}) async {
emit(
WebhookState(
isLoading: true,
lnurlPayUrl: state.lnurlPayUrl,
lnAddress: state.lnAddress,
),
);
_logger.info('invalidate lnurl pay response: ${response.statusCode}');
await _breezPreferences.resetLnUrlPayKey();
}

Future<String> _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');
try {
final GetInfoResponse? walletInfo = await _breezSdkLiquid.instance?.getInfo();
if (walletInfo == null) {
throw Exception('Failed to retrieve wallet info.');
}
final Map<String, String> lnUrlData = await _lnUrlPayService.updateLnAddressUsername(
walletInfo.walletInfo,
lnAddressUsername,
);
emit(
WebhookState(
lnurlPayUrl: lnUrlData['lnurl'],
lnAddress: lnUrlData['lnAddress'],
lnAddressUsername: lnAddressUsername,
),
);
} catch (err) {
emit(
state.copyWith(
lnurlPayError: err.toString(),
lnurlPayErrorTitle: 'Failed to update Lightning Address username:',
),
);
} finally {
emit(state.copyWith(isLoading: false));
}
return '$notifierServiceURL/api/v1/notify?platform=$platform&token=$token';
}
}
Loading