Skip to content

Commit

Permalink
Handle LNURL's (#38)
Browse files Browse the repository at this point in the history
* Remove nodeID from valid input type list when pasted

* Handle LNURL-Pay

* Handle LNURL-Withdraw

* Handle LNURL-Auth

* Wait for input to be parsed

* Fix title of destination pubkey

* Move LnUrl API's to LnUrlBloc

* Validate LnUrl payments against lightning limits
* Rename isLnurlPayment to isLnUrlPayment for consistency of capitalization of LnUrl
  • Loading branch information
erdemyerebasmaz authored Jul 12, 2024
1 parent 5ca0c5b commit a79f575
Show file tree
Hide file tree
Showing 20 changed files with 1,291 additions and 25 deletions.
56 changes: 56 additions & 0 deletions lib/bloc/lnurl/lnurl_bloc.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:l_breez/bloc/account/breez_sdk_liquid.dart';
import 'package:l_breez/bloc/lnurl/lnurl_state.dart';
import 'package:logging/logging.dart';

class LnUrlBloc extends Cubit<LnUrlState> {
final _log = Logger("LnUrlBloc");
final BreezSDKLiquid _liquidSdk;

LnUrlBloc(this._liquidSdk) : super(LnUrlState.initial());

Future<LightningPaymentLimitsResponse> fetchLightningLimits() async {
try {
final limits = await _liquidSdk.instance!.fetchLightningLimits();
emit(state.copyWith(limits: limits));
return limits;
} catch (e) {
_log.severe("fetchLightningLimits error", e);
rethrow;
}
}

Future<LnUrlWithdrawResult> lnurlWithdraw({
required LnUrlWithdrawRequest req,
}) async {
try {
return await _liquidSdk.instance!.lnurlWithdraw(req: req);
} catch (e) {
_log.severe("lnurlWithdraw error", e);
rethrow;
}
}

Future<LnUrlPayResult> lnurlPay({
required LnUrlPayRequest req,
}) async {
try {
return await _liquidSdk.instance!.lnurlPay(req: req);
} catch (e) {
_log.severe("lnurlPay error", e);
rethrow;
}
}

Future<LnUrlCallbackStatus> lnurlAuth({
required LnUrlAuthRequestData reqData,
}) async {
try {
return await _liquidSdk.instance!.lnurlAuth(reqData: reqData);
} catch (e) {
_log.severe("lnurlAuth error", e);
rethrow;
}
}
}
17 changes: 17 additions & 0 deletions lib/bloc/lnurl/lnurl_state.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';

class LnUrlState {
final LightningPaymentLimitsResponse? limits;

LnUrlState({this.limits});

LnUrlState.initial() : this();

LnUrlState copyWith({
LightningPaymentLimitsResponse? limits,
}) {
return LnUrlState(
limits: limits ?? this.limits,
);
}
}
9 changes: 9 additions & 0 deletions lib/handlers/input_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import 'package:l_breez/bloc/input/input_state.dart';
import 'package:l_breez/handlers/handler.dart';
import 'package:l_breez/handlers/handler_context_provider.dart';
import 'package:l_breez/models/invoice.dart';
import 'package:l_breez/routes/lnurl/auth/lnurl_auth_handler.dart';
import 'package:l_breez/routes/lnurl/payment/lnurl_payment_handler.dart';
import 'package:l_breez/routes/lnurl/withdraw/lnurl_withdraw_handler.dart';
import 'package:l_breez/utils/exceptions.dart';
import 'package:l_breez/widgets/flushbar.dart';
import 'package:l_breez/widgets/loader.dart';
Expand Down Expand Up @@ -85,6 +88,12 @@ class InputHandler extends Handler {

if (inputState is InvoiceInputState) {
return handleInvoice(context, inputState.invoice);
} else if (inputState is LnUrlPayInputState) {
handlePayRequest(context, firstPaymentItemKey, inputState.data);
} else if (inputState is LnUrlWithdrawInputState) {
handleWithdrawRequest(context, inputState.data);
} else if (inputState is LnUrlAuthInputState) {
handleAuthRequest(context, inputState.data);
} else if (inputState is LnUrlErrorInputState) {
throw inputState.data.reason;
} else if (inputState is BitcoinAddressInputState) {
Expand Down
4 changes: 4 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'package:l_breez/bloc/account/credentials_manager.dart';
import 'package:l_breez/bloc/backup/backup_bloc.dart';
import 'package:l_breez/bloc/currency/currency_bloc.dart';
import 'package:l_breez/bloc/input/input_bloc.dart';
import 'package:l_breez/bloc/lnurl/lnurl_bloc.dart';
import 'package:l_breez/bloc/security/security_bloc.dart';
import 'package:l_breez/bloc/user_profile/user_profile_bloc.dart';
import 'package:l_breez/services/injector.dart';
Expand Down Expand Up @@ -86,6 +87,9 @@ void main() async {
BlocProvider<BackupBloc>(
create: (BuildContext context) => BackupBloc(injector.liquidSDK),
),
BlocProvider<LnUrlBloc>(
create: (BuildContext context) => LnUrlBloc(injector.liquidSDK),
),
],
child: UserApp(),
),
Expand Down
59 changes: 56 additions & 3 deletions lib/routes/create_invoice/create_invoice_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart' as liquid_sdk;
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
import 'package:l_breez/bloc/account/account_bloc.dart';
import 'package:l_breez/bloc/currency/currency_bloc.dart';
import 'package:l_breez/bloc/currency/currency_state.dart';
import 'package:l_breez/routes/create_invoice/qr_code_dialog.dart';
import 'package:l_breez/routes/create_invoice/widgets/successful_payment.dart';
import 'package:l_breez/routes/lnurl/widgets/lnurl_page_result.dart';
import 'package:l_breez/routes/lnurl/withdraw/lnurl_withdraw_dialog.dart';
import 'package:l_breez/theme/theme_provider.dart' as theme;
import 'package:l_breez/utils/payment_validator.dart';
import 'package:l_breez/widgets/amount_form_field/amount_form_field.dart';
Expand All @@ -21,7 +24,14 @@ import 'package:logging/logging.dart';
final _log = Logger("CreateInvoicePage");

class CreateInvoicePage extends StatefulWidget {
const CreateInvoicePage({super.key});
final Function(LNURLPageResult? result)? onFinish;
final LnUrlWithdrawRequestData? requestData;

const CreateInvoicePage({super.key, this.onFinish, this.requestData})
: assert(
requestData == null || (onFinish != null),
"If you are using LNURL withdraw, you must provide an onFinish callback.",
);

@override
State<StatefulWidget> createState() {
Expand All @@ -41,6 +51,20 @@ class CreateInvoicePageState extends State<CreateInvoicePage> {
void initState() {
super.initState();
_doneAction = KeyboardDoneAction(focusNodes: [_amountFocusNode]);

WidgetsBinding.instance.addPostFrameCallback(
(_) {
final data = widget.requestData;
if (data != null) {
final currencyState = context.read<CurrencyBloc>().state;
_amountController.text = currencyState.bitcoinCurrency.format(
data.maxWithdrawable.toInt() ~/ 1000,
includeDisplayName: false,
);
_descriptionController.text = data.defaultDescription;
}
},
);
}

@override
Expand Down Expand Up @@ -102,16 +126,45 @@ class CreateInvoicePageState extends State<CreateInvoicePage> {
),
bottomNavigationBar: SingleButtonBottomBar(
stickToBottom: true,
text: texts.invoice_action_create,
text: widget.requestData != null ? texts.invoice_action_redeem : texts.invoice_action_create,
onPressed: () {
if (_formKey.currentState?.validate() ?? false) {
_createInvoice();
final data = widget.requestData;
if (data != null) {
_withdraw(data);
} else {
_createInvoice();
}
}
},
),
);
}

Future<void> _withdraw(
LnUrlWithdrawRequestData data,
) async {
_log.info("Withdraw request: description=${data.defaultDescription}, k1=${data.k1}, "
"min=${data.minWithdrawable}, max=${data.maxWithdrawable}");
final CurrencyBloc currencyBloc = context.read<CurrencyBloc>();

final navigator = Navigator.of(context);
navigator.pop();

showDialog(
useRootNavigator: false,
context: context,
barrierDismissible: false,
builder: (_) => LNURLWithdrawDialog(
requestData: data,
amountSats: currencyBloc.state.bitcoinCurrency.parse(
_amountController.text,
),
onFinish: widget.onFinish!,
),
);
}

Future _createInvoice() async {
_log.info("Create invoice: description=${_descriptionController.text}, amount=${_amountController.text}");
final navigator = Navigator.of(context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,15 +208,14 @@ class EnterPaymentInfoDialogState extends State<EnterPaymentInfoDialog> {
final texts = context.texts();
try {
_setValidatorErrorMessage("");
final inputType = context.read<InputBloc>().parseInput(input: input);
final inputType = await context.read<InputBloc>().parseInput(input: input);
_log.info("Parsed input type: '${inputType.runtimeType.toString()}");
// Can't compare against a list of InputType as runtime type comparison is a bit tricky with binding generated enums
if (!(inputType is InputType_Bolt11 ||
inputType is InputType_LnUrlPay ||
inputType is InputType_LnUrlWithdraw ||
inputType is InputType_LnUrlAuth ||
inputType is InputType_LnUrlError ||
inputType is InputType_NodeId)) {
inputType is InputType_LnUrlError)) {
_setValidatorErrorMessage(texts.payment_info_dialog_error_unsupported_input);
}
} catch (e) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
import 'package:breez_translations/breez_translations_locales.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:l_breez/models/payment_minutiae.dart';
import 'package:l_breez/routes/home/widgets/payments_list/dialog/shareable_payment_row.dart';

class PaymentDetailsDestinationPubkey extends StatelessWidget {
final PaymentMinutiae paymentMinutiae;

const PaymentDetailsDestinationPubkey({
super.key,
required this.paymentMinutiae,
});
const PaymentDetailsDestinationPubkey({required this.paymentMinutiae, super.key});

@override
Widget build(BuildContext context) {
final texts = context.texts();
final destinationPubkey = paymentMinutiae.swapId;
if (destinationPubkey.isNotEmpty) {
return ShareablePaymentRow(
title: texts.payment_details_dialog_single_info_node_id,
sharedValue: destinationPubkey,
);
} else {
return Container();
}
return destinationPubkey.isNotEmpty
? ShareablePaymentRow(
// TODO: Move this message to Breez-Translations
title: "Swap ID",
sharedValue: destinationPubkey,
)
: const SizedBox.shrink();
}
}
69 changes: 69 additions & 0 deletions lib/routes/lnurl/auth/lnurl_auth_handler.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import 'package:breez_translations/breez_translations_locales.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
import 'package:l_breez/bloc/lnurl/lnurl_bloc.dart';
import 'package:l_breez/routes/lnurl/auth/login_text.dart';
import 'package:l_breez/routes/lnurl/widgets/lnurl_page_result.dart';
import 'package:l_breez/widgets/error_dialog.dart';
import 'package:l_breez/widgets/loader.dart';
import 'package:logging/logging.dart';

final _log = Logger("HandleLNURLAuthRequest");

Future<LNURLPageResult?> handleAuthRequest(
BuildContext context,
LnUrlAuthRequestData reqData,
) async {
return promptAreYouSure(context, null, LoginText(domain: reqData.domain)).then(
(permitted) async {
if (permitted == true) {
final texts = context.texts();
final navigator = Navigator.of(context);
final loaderRoute = createLoaderRoute(context);
navigator.push(loaderRoute);
try {
final lnurlBloc = context.read<LnUrlBloc>();
final resp = await lnurlBloc.lnurlAuth(reqData: reqData);
if (resp is LnUrlCallbackStatus_Ok) {
_log.info("LNURL auth success");
return const LNURLPageResult(protocol: LnUrlProtocol.Auth);
} else if (resp is LnUrlCallbackStatus_ErrorStatus) {
_log.info("LNURL auth failed: ${resp.data.reason}");
return LNURLPageResult(protocol: LnUrlProtocol.Auth, error: resp.data.reason);
} else {
_log.warning("Unknown response from lnurlAuth: $resp");
return LNURLPageResult(
protocol: LnUrlProtocol.Auth,
error: texts.lnurl_payment_page_unknown_error,
);
}
} catch (e) {
_log.warning("Error authenticating LNURL auth", e);
if (loaderRoute.isActive) {
navigator.removeRoute(loaderRoute);
}
return LNURLPageResult(protocol: LnUrlProtocol.Auth, error: e);
} finally {
if (loaderRoute.isActive) {
navigator.removeRoute(loaderRoute);
}
}
}
return Future.value();
},
);
}

void handleLNURLAuthPageResult(BuildContext context, LNURLPageResult result) {
if (result.hasError) {
_log.info("Handle LNURL auth page result with error '${result.error}'");
promptError(
context,
context.texts().lnurl_webview_error_title,
Text(result.errorMessage),
okFunc: () => Navigator.of(context).pop(),
);
throw result.error!;
}
}
30 changes: 30 additions & 0 deletions lib/routes/lnurl/auth/login_text.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'package:breez_translations/breez_translations_locales.dart';
import 'package:flutter/material.dart';

class LoginText extends StatelessWidget {
final String domain;

const LoginText({super.key, required this.domain});

@override
Widget build(BuildContext context) {
final texts = context.texts();
final themeData = Theme.of(context);
return RichText(
text: TextSpan(
style: themeData.dialogTheme.contentTextStyle,
text: texts.handler_lnurl_login_anonymously,
children: [
TextSpan(
text: domain,
style: themeData.dialogTheme.contentTextStyle!.copyWith(fontWeight: FontWeight.bold),
),
TextSpan(
text: "?",
style: themeData.dialogTheme.contentTextStyle,
),
],
),
);
}
}
26 changes: 26 additions & 0 deletions lib/routes/lnurl/lnurl_invoice_delegate.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import 'package:l_breez/routes/lnurl/auth/lnurl_auth_handler.dart';
import 'package:l_breez/routes/lnurl/payment/lnurl_payment_handler.dart';
import 'package:l_breez/routes/lnurl/withdraw/lnurl_withdraw_handler.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';

import 'widgets/lnurl_page_result.dart';

final _log = Logger("HandleLNURL");

void handleLNURLPageResult(BuildContext context, LNURLPageResult result) {
_log.info("handle $result");
switch (result.protocol) {
case LnUrlProtocol.Pay:
handleLNURLPaymentPageResult(context, result);
break;
case LnUrlProtocol.Withdraw:
handleLNURLWithdrawPageResult(context, result);
break;
case LnUrlProtocol.Auth:
handleLNURLAuthPageResult(context, result);
break;
default:
break;
}
}
Loading

0 comments on commit a79f575

Please sign in to comment.