-
Notifications
You must be signed in to change notification settings - Fork 9
feat: bip321 & bolt12 support for NWC & wallets #724
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
base: master
Are you sure you want to change the base?
Changes from 1 commit
d489697
0c09ee7
ac5fd08
1d19078
fe41c63
ece5cf5
5b90677
809a755
3416e63
484f931
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| // ignore_for_file: avoid_print | ||
|
|
||
| import 'dart:io'; | ||
|
|
||
| import 'package:ndk/ndk.dart'; | ||
|
|
||
| void main() async { | ||
| final ndk = Ndk.emptyBootstrapRelaysConfig(); | ||
|
|
||
| // Provide an NWC connection URI and the BOLT11 invoice to pay. | ||
| final nwcUri = Platform.environment['NWC_URI']!; | ||
| final invoice = Platform.environment['INVOICE']!; | ||
| final amountMsat = int.tryParse( | ||
| Platform.environment['AMOUNT_MSAT'] ?? '', | ||
| ); | ||
|
|
||
| final connection = await ndk.nwc.connect(nwcUri); | ||
|
|
||
| // NWC-321 expects a BIP-321 URI. This example contains only a BOLT11 | ||
| // `lightning` instruction. | ||
| final payment = Bip321.fromBolt11(invoice); | ||
|
|
||
| final response = await ndk.nwc.pay( | ||
| connection, | ||
| payment: payment, | ||
| // Required only when the BOLT11 invoice has no amount. | ||
| amountMsat: amountMsat, | ||
| payerNote: Platform.environment['PAYER_NOTE'], | ||
| ); | ||
|
|
||
| print('transaction id: ${response.transactionId}'); | ||
| print('state: ${response.state}'); | ||
| print('instruction type: ${response.instructionType}'); | ||
| print('amount: ${response.amountMsat} msats'); | ||
| print('fees paid: ${response.feesPaid} msats'); | ||
| print('preimage: ${response.preimage}'); | ||
|
|
||
| await ndk.destroy(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| // ignore_for_file: avoid_print | ||
|
|
||
| import 'dart:io'; | ||
|
|
||
| import 'package:ndk/ndk.dart'; | ||
|
|
||
| void main() async { | ||
| final ndk = Ndk.emptyBootstrapRelaysConfig(); | ||
|
|
||
| // Provide an NWC connection URI. Omit AMOUNT_MSAT for a variable amount. | ||
| final nwcUri = Platform.environment['NWC_URI']!; | ||
| final amountMsat = int.tryParse( | ||
| Platform.environment['AMOUNT_MSAT'] ?? '', | ||
| ); | ||
|
|
||
| final connection = await ndk.nwc.connect(nwcUri); | ||
| final response = await ndk.nwc.receive( | ||
| connection, | ||
| amountMsat: amountMsat, | ||
| description: Platform.environment['DESCRIPTION'], | ||
| ); | ||
|
|
||
| // For now, use a wallet whose `receive` implementation returns a BOLT11 | ||
| // `lightning` instruction in this BIP-321 URI. | ||
| print('BIP-321 URI: ${response.bip321}'); | ||
| print('transaction id: ${response.transactionId}'); | ||
|
|
||
| await ndk.destroy(); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,86 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Helpers for BIP-321 URIs containing BOLT11 payment instructions. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class Bip321 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Bip321._(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Creates a BIP-321 URI containing one BOLT11 `lightning` instruction. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| static String fromBolt11(String invoice) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!invoice.toLowerCase().startsWith('ln')) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw ArgumentError.value(invoice, 'invoice', 'Must be a BOLT11 invoice'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Uri( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| scheme: 'bitcoin', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| queryParameters: {'lightning': invoice}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ).toString(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Selects the single BOLT11 `lightning` instruction from [payment]. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| static String getBolt11(String payment) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| final Uri uri; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| uri = Uri.parse(payment); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } on FormatException { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw FormatException('Invalid BIP-321 URI', payment); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (uri.scheme.toLowerCase() != 'bitcoin') { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw FormatException('BIP-321 URI must use the bitcoin scheme', payment); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| final requiredParameters = uri.queryParametersAll.keys.where( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (key) => key.startsWith('req-'), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (requiredParameters.isNotEmpty) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw UnsupportedError( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Unsupported required BIP-321 parameter: ' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '${requiredParameters.first}', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| final instructions = uri.queryParametersAll['lightning']; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (instructions == null || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| instructions.length != 1 || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| instructions.single.isEmpty) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+29
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Normalize BIP-321 query keys before validation. Line 30 checks Normalize and merge keys before checking required parameters and selecting Proposed fix- final requiredParameters = uri.queryParametersAll.keys.where(
+ final parameters = <String, List<String>>{};
+ uri.queryParametersAll.forEach((key, values) {
+ parameters.putIfAbsent(key.toLowerCase(), () => []).addAll(values);
+ });
+
+ final requiredParameters = parameters.keys.where(
(key) => key.startsWith('req-'),
);
@@
- final instructions = uri.queryParametersAll['lightning'];
+ final instructions = parameters['lightning'];📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw const FormatException( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'BIP-321 URI must contain one lightning instruction', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| final invoice = instructions.single; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!invoice.toLowerCase().startsWith('ln')) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw const FormatException('Invalid BOLT11 lightning instruction'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return invoice; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /// Returns the BOLT11 amount in millisatoshis, or null when amountless. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| static int? getBolt11AmountMsat(String invoice) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| final separator = invoice.toLowerCase().lastIndexOf('1'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (separator < 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw const FormatException('Invalid BOLT11 invoice'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| final hrp = invoice.toLowerCase().substring(0, separator); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| final match = RegExp( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| r'^ln(?:bcrt|bc|tb|sb)([0-9]*)([munp]?)$', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ).firstMatch(hrp); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (match == null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw const FormatException('Invalid BOLT11 invoice prefix'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| final digits = match.group(1)!; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (digits.isEmpty) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| final amount = int.parse(digits); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return switch (match.group(2)!) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '' => amount * 100000000000, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'm' => amount * 100000000, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'u' => amount * 100000, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'n' => amount * 100, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'p' when amount % 10 == 0 => amount ~/ 10, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'p' => throw const FormatException( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'BOLT11 pico-bitcoin amount is not a whole millisatoshi', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _ => throw const FormatException('Invalid BOLT11 amount multiplier'), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,10 @@ import 'dart:async'; | |
|
|
||
| import '../../../../usecases/cashu/cashu.dart'; | ||
| import '../../../../usecases/nwc/responses/pay_invoice_response.dart'; | ||
| import '../../../../usecases/nwc/responses/pay_response.dart'; | ||
| import '../../../../usecases/nwc/responses/receive_response.dart'; | ||
| import '../../../cashu/cashu_mint_info.dart'; | ||
| import '../../bip321.dart'; | ||
| import '../../wallet.dart'; | ||
| import '../../wallet_balance.dart'; | ||
| import '../../wallet_provider.dart'; | ||
|
|
@@ -119,16 +122,39 @@ class CashuWalletProvider implements WalletProvider { | |
| throw ArgumentError('Expected a CashuWallet'); | ||
| } | ||
|
|
||
| final result = await _payBolt11(wallet, invoice, timeout: timeout); | ||
| return result.legacyResponse; | ||
| } | ||
|
|
||
| Future<_CashuBolt11Payment> _payBolt11( | ||
| CashuWallet wallet, | ||
| String invoice, { | ||
| int? expectedAmountMsat, | ||
| Duration? timeout, | ||
| }) async { | ||
| final draftTransaction = await _cashuUseCase.initiateRedeem( | ||
| mintUrl: wallet.mintUrl, | ||
| request: invoice, | ||
| unit: 'sat', | ||
| method: 'bolt11', | ||
| ); | ||
|
|
||
| await for (final transaction in _cashuUseCase.redeem( | ||
| final amountMsat = draftTransaction.qouteMelt!.amount * 1000; | ||
| if (expectedAmountMsat != null && amountMsat != expectedAmountMsat) { | ||
| throw ArgumentError( | ||
| 'BIP-321 amount $expectedAmountMsat msats conflicts with ' | ||
| 'the BOLT11 invoice amount $amountMsat msats', | ||
| ); | ||
| } | ||
|
|
||
| var transactions = _cashuUseCase.redeem( | ||
| draftRedeemTransaction: draftTransaction, | ||
| )) { | ||
| ); | ||
| if (timeout != null) { | ||
| transactions = transactions.timeout(timeout); | ||
| } | ||
|
|
||
| await for (final transaction in transactions) { | ||
| if (transaction.state == WalletTransactionState.completed) { | ||
| final int feesPaid; | ||
| if (draftTransaction.qouteMelt?.feeReserve != null) { | ||
|
|
@@ -137,11 +163,19 @@ class CashuWalletProvider implements WalletProvider { | |
| feesPaid = 0; | ||
| } | ||
|
|
||
| return PayInvoiceResponse( | ||
| final legacyResponse = PayInvoiceResponse( | ||
| resultType: 'pay_invoice', | ||
| preimage: null, | ||
| feesPaid: feesPaid, | ||
| ); | ||
| return _CashuBolt11Payment( | ||
| legacyResponse: legacyResponse, | ||
| transactionId: draftTransaction.id, | ||
| amountMsat: amountMsat, | ||
| createdAt: draftTransaction.initiatedDate ?? | ||
| DateTime.now().millisecondsSinceEpoch ~/ 1000, | ||
| settledAt: transaction.transactionDate, | ||
| ); | ||
| } else if (transaction.state == WalletTransactionState.failed) { | ||
| throw Exception('Cashu payment failed: ${transaction.completionMsg}'); | ||
| } | ||
|
|
@@ -195,4 +229,111 @@ class CashuWalletProvider implements WalletProvider { | |
|
|
||
| return invoice; | ||
| } | ||
|
|
||
| @override | ||
| Future<PayResponse> payBip321( | ||
| Wallet wallet, { | ||
| required String payment, | ||
| int? amountMsat, | ||
| String? payerNote, | ||
| Map<String, dynamic>? metadata, | ||
| Duration? timeout, | ||
| }) async { | ||
| if (wallet is! CashuWallet) { | ||
| throw ArgumentError('Expected a CashuWallet'); | ||
| } | ||
| if (payerNote?.isNotEmpty == true) { | ||
| throw UnsupportedError('BOLT11 does not support payer notes'); | ||
| } | ||
|
|
||
| final invoice = Bip321.getBolt11(payment); | ||
| final invoiceAmountMsat = Bip321.getBolt11AmountMsat(invoice); | ||
| if (invoiceAmountMsat == null) { | ||
| throw UnsupportedError( | ||
| 'Cashu does not support paying amountless BOLT11 invoices', | ||
| ); | ||
| } | ||
| if (invoiceAmountMsat % 1000 != 0) { | ||
| throw UnsupportedError( | ||
| 'Cashu only supports whole-satoshi BOLT11 amounts', | ||
| ); | ||
| } | ||
| if (amountMsat != null && amountMsat != invoiceAmountMsat) { | ||
| throw ArgumentError( | ||
| 'BIP-321 amount $amountMsat msats conflicts with ' | ||
| 'the BOLT11 invoice amount $invoiceAmountMsat msats', | ||
| ); | ||
| } | ||
|
|
||
| final result = await _payBolt11( | ||
| wallet, | ||
| invoice, | ||
| expectedAmountMsat: invoiceAmountMsat, | ||
| timeout: timeout, | ||
| ); | ||
| return PayResponse( | ||
| resultType: 'pay', | ||
| transactionId: result.transactionId, | ||
| state: 'settled', | ||
| instructionType: 'bolt11', | ||
| amountMsat: result.amountMsat, | ||
| feesPaid: result.legacyResponse.feesPaid, | ||
| preimage: result.legacyResponse.preimage, | ||
| createdAt: result.createdAt, | ||
| settledAt: result.settledAt, | ||
| ); | ||
| } | ||
|
Comment on lines
+234
to
+285
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Reject unsupported metadata instead of silently discarding it.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| @override | ||
| Future<ReceiveResponse> receiveBip321( | ||
| Wallet wallet, { | ||
| int? amountMsat, | ||
| String? description, | ||
| Map<String, dynamic>? metadata, | ||
| Duration? timeout, | ||
| }) async { | ||
| if (amountMsat == null) { | ||
| throw UnsupportedError( | ||
| 'Cashu does not support variable-amount BOLT11 invoices', | ||
| ); | ||
| } | ||
| if (amountMsat <= 0 || amountMsat % 1000 != 0) { | ||
| throw ArgumentError.value( | ||
| amountMsat, | ||
| 'amountMsat', | ||
| 'Cashu requires a positive whole-satoshi amount', | ||
| ); | ||
| } | ||
| if (description?.isNotEmpty == true) { | ||
| throw UnsupportedError( | ||
| 'Cashu does not support setting a BOLT11 description', | ||
| ); | ||
| } | ||
|
|
||
| var invoiceFuture = receive(wallet, amountMsat ~/ 1000); | ||
| if (timeout != null) { | ||
| invoiceFuture = invoiceFuture.timeout(timeout); | ||
| } | ||
| final invoice = await invoiceFuture; | ||
| return ReceiveResponse( | ||
| resultType: 'receive', | ||
| bip321: Bip321.fromBolt11(invoice), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| class _CashuBolt11Payment { | ||
| final PayInvoiceResponse legacyResponse; | ||
| final String transactionId; | ||
| final int amountMsat; | ||
| final int createdAt; | ||
| final int? settledAt; | ||
|
|
||
| const _CashuBolt11Payment({ | ||
| required this.legacyResponse, | ||
| required this.transactionId, | ||
| required this.amountMsat, | ||
| required this.createdAt, | ||
| required this.settledAt, | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Wrap the NWC example workflows in
try/finallyand callawait ndk.destroy()from thefinallyblock. If connection or payment/receive processing fails, cleanup must still run so subscriptions and other NDK resources are not left active.📍 Affects 2 files
packages/ndk/example/nwc/pay.dart#L17-L38(this comment)packages/ndk/example/nwc/receive.dart#L16-L28🤖 Prompt for AI Agents