Skip to content

[NWC] add hold invoice support #147

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

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.flutter.plugins;

import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;

import io.flutter.embedding.engine.FlutterEngine;

/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
}
}
4 changes: 2 additions & 2 deletions packages/ndk/example/nwc/list_transactions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ void main() async {
final connection = await ndk.nwc.connect(nwcUri);

ListTransactionsResponse response =
await ndk.nwc.listTransactions(connection, unpaid: false);
await ndk.nwc.listTransactions(connection, unpaid: true);

for (final transaction in response.transactions) {
print(
"Transaction ${transaction.type} ${transaction.amountSat} sats ${transaction.description!}");
"Transaction ${transaction.type} state ${transaction.state} ${transaction.amountSat} sats ${transaction.description!}");
}
await ndk.destroy();
}
127 changes: 127 additions & 0 deletions packages/ndk/example/nwc/make_hold_invoice.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// ignore_for_file: avoid_print

import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:convert/convert.dart';
import 'package:ascii_qr/ascii_qr.dart';
import 'package:ndk/domain_layer/usecases/nwc/nwc_notification.dart';

import 'package:ndk/ndk.dart';

void main() async {
// We use an empty bootstrap relay list,
// since NWC will provide the relay we connect to so we don't need default relays
final ndk = Ndk.emptyBootstrapRelaysConfig();

// You need an NWC_URI env var or to replace with your NWC uri connection
final nwcUri = Platform.environment['NWC_URI']!;
final connection = await ndk.nwc.connect(nwcUri);

final amount = 29;
final description = "hello hold";

// Generate a random 32-byte preimage
final random = Random.secure();
final preimageBytes =
Uint8List.fromList(List<int>.generate(32, (_) => random.nextInt(256)));
final preimageHex =
hex.encode(preimageBytes); // Optional: hex encode for printing

// Calculate the payment hash (SHA256 of the preimage)
final paymentHashBytes = sha256.convert(preimageBytes).bytes;
final paymentHash = hex.encode(paymentHashBytes);

print("Generated Preimage: $preimageHex");
print("Generated Payment Hash: $paymentHash");

try {
// 1. Create the hold invoice
print("Creating hold invoice...");
final makeResponse = await ndk.nwc.makeHoldInvoice(connection,
amountSats: amount, description: description, paymentHash: paymentHash);

if (makeResponse.errorCode == null) {
// Check if errorCode is null for success
final invoice = makeResponse.invoice;
print(
"Hold invoice created successfully. Invoice: $invoice, Payment Hash: ${makeResponse.paymentHash}");

if (invoice.isNotEmpty) {
print("\nScan QR Code to pay/hold:");
try {
final asciiQr = AsciiQrGenerator.generate(
invoice.toUpperCase(),
);
print(asciiQr.toString());
} catch (e) {
print("Error generating ASCII QR code: $e");
}
print("\nOr copy Bolt11 invoice:\n$invoice\n");
}

final duration = makeResponse.expiresAt!-DateTime.now().millisecondsSinceEpoch ~/ 1000;
print(
"Waiting for hold invoice acceptance notification (max $duration seconds)...");
try {
final acceptedNotification = await connection.holdInvoiceStateStream
.firstWhere((notification) {
return notification.notificationType == NwcNotification.kHoldInvoiceAccepted;
}).timeout(Duration(seconds: duration.toInt()));

print(
"Hold invoice accepted by wallet! (Notification: ${acceptedNotification.notificationType})");

// 3. Ask user whether to settle or cancel
print("Settle this accepted invoice? (Y/N)");
String? input = stdin.readLineSync()?.trim().toLowerCase();

if (input == 'n') {
// 4a. Cancel the hold invoice
print("Canceling hold invoice with payment hash: $paymentHash...");
final cancelResponse = await ndk.nwc
.cancelHoldInvoice(connection, paymentHash: paymentHash);

if (cancelResponse.errorCode == null) {
// Check if errorCode is null for success
print("Hold invoice canceled successfully.");
} else {
print(
"Failed to cancel hold invoice. Error: ${cancelResponse.errorMessage} (Code: ${cancelResponse.errorCode})");
}
} else if (input == 'y') {
// 4b. Settle the hold invoice using the preimage
print("Settling hold invoice with preimage: $preimageHex...");
final settleResponse = await ndk.nwc
.settleHoldInvoice(connection, preimage: preimageHex);

if (settleResponse.errorCode == null) {
print(
"Hold invoice settled successfully. Preimage used: $preimageHex");
} else {
print(
"Failed to settle hold invoice. Error: ${settleResponse.errorMessage} (Code: ${settleResponse.errorCode})");
}
} else {
print("Invalid input. Not settling or canceling.");
}
} on TimeoutException {
print(
"Timed out waiting for hold invoice acceptance notification. The invoice might not be held by the wallet.");
// Optionally, try to cancel here as a fallback?
} catch (e) {
print("Error waiting for notification: $e");
}
} else {
print(
"Failed to create hold invoice. Error: ${makeResponse.errorMessage} (Code: ${makeResponse.errorCode})");
}
} catch (e) {
print("An error occurred: $e");
} finally {
// Ensure ndk.destroy() is called in the finally block
await ndk.destroy();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ class NwcMethod {
static const NwcMethod PAY_KEYSEND = NwcMethod('pay_keysend');
static const NwcMethod MULTI_PAY_KEYSEND = NwcMethod('multi_pay_keysend');
static const NwcMethod MAKE_INVOICE = NwcMethod('make_invoice');
static const NwcMethod MAKE_HOLD_INVOICE = NwcMethod('make_hold_invoice');
static const NwcMethod CANCEL_HOLD_INVOICE = NwcMethod('cancel_hold_invoice');
static const NwcMethod SETTLE_HOLD_INVOICE = NwcMethod('settle_hold_invoice');
static const NwcMethod LOOKUP_INVOICE = NwcMethod('lookup_invoice');
static const NwcMethod LIST_TRANSACTIONS = NwcMethod('list_transactions');
static const NwcMethod UNKNOWN = NwcMethod('unknown');
Expand All @@ -26,6 +29,9 @@ class NwcMethod {
PAY_KEYSEND.name: PAY_KEYSEND,
MULTI_PAY_KEYSEND.name: MULTI_PAY_KEYSEND,
MAKE_INVOICE.name: MAKE_INVOICE,
MAKE_HOLD_INVOICE.name: MAKE_HOLD_INVOICE,
CANCEL_HOLD_INVOICE.name: CANCEL_HOLD_INVOICE,
SETTLE_HOLD_INVOICE.name: SETTLE_HOLD_INVOICE,
LOOKUP_INVOICE.name: LOOKUP_INVOICE,
LIST_TRANSACTIONS.name: LIST_TRANSACTIONS,
GET_BALANCE.name: GET_BALANCE,
Expand Down
46 changes: 42 additions & 4 deletions packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
import 'requests/list_transactions.dart';
import 'requests/lookup_invoice.dart';
import 'requests/make_invoice.dart';
import 'requests/make_hold_invoice.dart'; // Add import for MakeHoldInvoiceRequest
import 'requests/cancel_hold_invoice.dart'; // Add import for CancelHoldInvoiceRequest
import 'requests/settle_hold_invoice.dart'; // Add import for SettleHoldInvoiceRequest
import 'requests/nwc_request.dart';
import 'requests/pay_invoice.dart';
import 'responses/nwc_response.dart';
Expand Down Expand Up @@ -150,14 +153,18 @@
response = GetBalanceResponse.deserialize(data);
} else if (data['result_type'] == NwcMethod.GET_BUDGET.name) {
response = GetBudgetResponse.deserialize(data);
} else if (data['result_type'] == NwcMethod.MAKE_INVOICE.name) {
} else if (data['result_type'] == NwcMethod.MAKE_INVOICE.name ||
data['result_type'] == NwcMethod.MAKE_HOLD_INVOICE.name) {

Check warning on line 157 in packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart#L156-L157

Added lines #L156 - L157 were not covered by tests
response = MakeInvoiceResponse.deserialize(data);
} else if (data['result_type'] == NwcMethod.PAY_INVOICE.name) {
response = PayInvoiceResponse.deserialize(data);
} else if (data['result_type'] == NwcMethod.LIST_TRANSACTIONS.name) {
response = ListTransactionsResponse.deserialize(data);
} else if (data['result_type'] == NwcMethod.LOOKUP_INVOICE.name) {
response = LookupInvoiceResponse.deserialize(data);
} else if (data['result_type'] == NwcMethod.CANCEL_HOLD_INVOICE.name || data['result_type'] == NwcMethod.SETTLE_HOLD_INVOICE.name) {

Check warning on line 165 in packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart#L165

Added line #L165 was not covered by tests
response =
NwcResponse(resultType: data['result_type']); // Generic response

Check warning on line 167 in packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart#L167

Added line #L167 was not covered by tests
}
} else {
response = NwcResponse(resultType: data['result_type']);
Expand Down Expand Up @@ -192,7 +199,7 @@
if (data.containsKey("notification_type") &&
data['notification'] != null) {
NwcNotification notification =
NwcNotification.fromMap(data['notification']);
NwcNotification.fromMap(data["notification_type"],data['notification']);

Check warning on line 202 in packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart#L202

Added line #L202 was not covered by tests
connection.notificationStream.add(notification);
} else if (data.containsKey("error")) {
// TODO: Define what to do when data has an error
Expand All @@ -213,7 +220,7 @@
if (data.containsKey("notification_type") &&
data['notification'] != null) {
NwcNotification notification =
NwcNotification.fromMap(data['notification']);
NwcNotification.fromMap(data["notification_type"],data['notification']);

Check warning on line 223 in packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart#L223

Added line #L223 was not covered by tests
connection.notificationStream.add(notification);
} else if (data.containsKey("error")) {
// TODO: Define what to do when data has an error
Expand Down Expand Up @@ -282,7 +289,7 @@
return _executeRequest<GetBudgetResponse>(connection, GetBudgetRequest());
}

/// Does a `make_invoie` request
/// Does a `make_invoice` request
Future<MakeInvoiceResponse> makeInvoice(NwcConnection connection,
{required int amountSats,
String? description,
Expand All @@ -297,6 +304,37 @@
expiry: expiry));
}

/// Does a `make_hold_invoice` request
Future<MakeInvoiceResponse> makeHoldInvoice(NwcConnection connection,

Check warning on line 308 in packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart#L308

Added line #L308 was not covered by tests
{required int amountSats,
String? description,
String? descriptionHash,
int? expiry,
required String paymentHash}) async {
return _executeRequest<MakeInvoiceResponse>(

Check warning on line 314 in packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart#L314

Added line #L314 was not covered by tests
connection,
MakeHoldInvoiceRequest(
amountMsat: amountSats * 1000,

Check warning on line 317 in packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart#L316-L317

Added lines #L316 - L317 were not covered by tests
description: description,
descriptionHash: descriptionHash,
expiry: expiry,
paymentHash: paymentHash));
}

/// Does a `cancel_hold_invoice` request
Future<NwcResponse> cancelHoldInvoice(NwcConnection connection,

Check warning on line 325 in packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart#L325

Added line #L325 was not covered by tests
{required String paymentHash}) async {
return _executeRequest<NwcResponse>(
connection, CancelHoldInvoiceRequest(paymentHash: paymentHash));

Check warning on line 328 in packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart#L327-L328

Added lines #L327 - L328 were not covered by tests
}

/// Does a `settle_hold_invoice` request
Future<NwcResponse> settleHoldInvoice(NwcConnection connection,

Check warning on line 332 in packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart#L332

Added line #L332 was not covered by tests
{required String preimage}) async {
return _executeRequest<NwcResponse>(
connection, SettleHoldInvoiceRequest(preimage: preimage));

Check warning on line 335 in packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart#L334-L335

Added lines #L334 - L335 were not covered by tests
}

/// Does a `pay_invoice` request
Future<PayInvoiceResponse> payInvoice(NwcConnection connection,
{required String invoice, Duration? timeout}) async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@

Stream<NwcNotification> get paymentsReceivedStream =>
notificationStream.stream
.where((notification) => notification.isIncoming)
.where((notification) => notification.isPaymentReceived)

Check warning on line 26 in packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart#L26

Added line #L26 was not covered by tests
.asBroadcastStream();


Stream<NwcNotification> get paymentsSentStream => notificationStream.stream
.where((notification) => !notification.isIncoming)
.where((notification) => notification.isPaymentSent)
.asBroadcastStream();

Check warning on line 32 in packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart#L31-L32

Added lines #L31 - L32 were not covered by tests

Stream<NwcNotification> get holdInvoiceStateStream => notificationStream.stream
.where((notification) => notification.isHoldInvoiceAccepted)

Check warning on line 35 in packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart#L34-L35

Added lines #L34 - L35 were not covered by tests
.asBroadcastStream();

/// listen
Expand Down
30 changes: 21 additions & 9 deletions packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,46 @@
class NwcNotification {
static const kPaymentReceived = "payment_received";
static const kPaymentSent = "payment_sent";
static const kHoldInvoiceAccepted = "hold_invoice_accepted";

String notificationType;
String type;
String invoice;
String? description;
String? descriptionHash;
String preimage;
String? preimage;
String paymentHash;
int amount;
int feesPaid;
int? feesPaid;
int createdAt;
int? expiresAt;
int settledAt;
int? settledAt;
Map<String, dynamic>? metadata;

get isIncoming => type == TransactionType.incoming.value;
get isPaymentReceived => notificationType == kPaymentReceived;
get isPaymentSent => notificationType == kPaymentSent;
get isHoldInvoiceAccepted => notificationType == kHoldInvoiceAccepted;

Check warning on line 25 in packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart#L23-L25

Added lines #L23 - L25 were not covered by tests

NwcNotification({
required this.notificationType,
required this.type,
required this.invoice,
this.description,
this.descriptionHash,
required this.preimage,
this.preimage,
required this.paymentHash,
required this.amount,
required this.feesPaid,
this.feesPaid,
required this.createdAt,
this.expiresAt,
required this.settledAt,
required this.metadata,
this.settledAt,
this.metadata,
});

factory NwcNotification.fromMap(Map<String, dynamic> map) {
factory NwcNotification.fromMap(String notificationType, Map<String, dynamic> map) {
return NwcNotification(
notificationType: notificationType,
type: map['type'] as String,
invoice: map['invoice'] as String,
description: map['description'] as String?,
Expand All @@ -46,10 +53,15 @@
feesPaid: map['fees_paid'] as int,
createdAt: map['created_at'] as int,
expiresAt: map['expires_at'] as int?,
settledAt: map['settled_at'] as int,
settledAt: map['settled_at'] as int?,
metadata: map.containsKey('metadata') && map['metadata'] != null
? Map<String, dynamic>.from(map['metadata'])
: null,
);
}

@override

Check warning on line 63 in packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart#L63

Added line #L63 was not covered by tests
toString() {
return 'NwcNotification{type: $type, invoice: $invoice, description: $description, descriptionHash: $descriptionHash, preimage: $preimage, paymentHash: $paymentHash, amount: $amount, feesPaid: $feesPaid, createdAt: $createdAt, expiresAt: $expiresAt, settledAt: $settledAt, metadata: $metadata}';

Check warning on line 65 in packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart#L65

Added line #L65 was not covered by tests
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart';

import 'nwc_request.dart';

class CancelHoldInvoiceRequest extends NwcRequest {
final String paymentHash;

const CancelHoldInvoiceRequest({

Check warning on line 8 in packages/ndk/lib/domain_layer/usecases/nwc/requests/cancel_hold_invoice.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/requests/cancel_hold_invoice.dart#L8

Added line #L8 was not covered by tests
required this.paymentHash,
}) : super(method: NwcMethod.CANCEL_HOLD_INVOICE);

Check warning on line 10 in packages/ndk/lib/domain_layer/usecases/nwc/requests/cancel_hold_invoice.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/requests/cancel_hold_invoice.dart#L10

Added line #L10 was not covered by tests

@override

Check warning on line 12 in packages/ndk/lib/domain_layer/usecases/nwc/requests/cancel_hold_invoice.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/requests/cancel_hold_invoice.dart#L12

Added line #L12 was not covered by tests
Map<String, dynamic> toMap() {
return {
...super.toMap(),
'params': {
'payment_hash': paymentHash,

Check warning on line 17 in packages/ndk/lib/domain_layer/usecases/nwc/requests/cancel_hold_invoice.dart

View check run for this annotation

Codecov / codecov/patch

packages/ndk/lib/domain_layer/usecases/nwc/requests/cancel_hold_invoice.dart#L14-L17

Added lines #L14 - L17 were not covered by tests
}
};
}
}
Loading