From 4f83859986d1a6b477bf5cc543fa13296572b05b Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 22:16:24 +0100 Subject: [PATCH] fix ZapReceipt.isValid --- .../domain_layer/usecases/lnurl/lnurl.dart | 91 +-------- .../usecases/zaps/Invoice_response.dart | 9 + .../usecases/zaps/zap_receipt.dart | 56 ++---- .../lib/domain_layer/usecases/zaps/zaps.dart | 104 +++++++++- .../ndk/test/usecases/lnurl/lnurl_test.dart | 111 ----------- .../ndk/test/usecases/zaps/zaps_test.dart | 178 ++++++++++++++++++ 6 files changed, 308 insertions(+), 241 deletions(-) create mode 100644 packages/ndk/lib/domain_layer/usecases/zaps/Invoice_response.dart create mode 100644 packages/ndk/test/usecases/zaps/zaps_test.dart diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart index 67b13729..b2abfa4e 100644 --- a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart @@ -56,96 +56,9 @@ abstract class Lnurl { } } - /// creates an invoice with an optional zap request encoded if signer, pubKey & relays are non empty - static Future getInvoiceCode({ - required String lud16Link, - required int amountSats, - ZapRequest? zapRequest, - String? comment, - http.Client? client - }) async { - var lnurlResponse = await getLnurlResponse(lud16Link, client: client); - if (lnurlResponse == null) { - return null; - } - - var callback = lnurlResponse.callback!; - if (callback.contains("?")) { - callback += "&"; - } else { - callback += "?"; - } - - final amount = amountSats * 1000; - callback += "amount=$amount"; - - if (comment != null && comment.trim() != '') { - var commentNum = lnurlResponse.commentAllowed; - if (commentNum != null) { - if (commentNum < comment.length) { - comment = comment.substring(0, commentNum); - } - callback += "&comment=${Uri.encodeQueryComponent(comment)}"; - } - } - - // ZAP ? - if (lnurlResponse.doesAllowsNostr && zapRequest!=null && zapRequest.sig.isNotEmpty) { - Logger.log.d(jsonEncode(zapRequest)); - var eventStr = Uri.encodeQueryComponent(jsonEncode(zapRequest)); - callback += "&nostr=$eventStr"; - } - - Logger.log.d("getInvoice callback $callback"); - - Uri uri = Uri.parse(callback); - - try { - var response = await (client ?? http.Client()).get(uri); - final decodedResponse = - jsonDecode(utf8.decode(response.bodyBytes)) as Map; - return decodedResponse["pr"]; - } catch (e) { - Logger.log.d(e); - } - - return null; - } - - static Future zapRequest({ - required int amountSats, - required EventSigner signer, - required String pubKey, - String? eventId, - String? comment, - required Iterable relays, - String? pollOption, - }) async { - if (amountSats<0) { - throw ArgumentError("amount cannot be < 0"); - } - final amount = amountSats * 1000; - - var tags = [ - ["relays", ...relays], - ["amount", amount.toString()], - ["p", pubKey], - ]; - if (eventId != null) { - tags.add(["e", eventId]); - } - if (pollOption != null) { - tags.add(["poll_option", pollOption]); - } - var event = ZapRequest( - pubKey: signer.getPublicKey(), tags: tags, content: comment??''); - await signer.sign(event); - return event; - } - /// extract amount from bolt11 in sats static int getAmountFromBolt11(String bolt11) { - final numStr = subUntil(bolt11, "lnbc", "1p"); + final numStr = _subUntil(bolt11, "lnbc", "1p"); if (numStr.isNotEmpty) { var numStrLength = numStr.length; if (numStrLength > 1) { @@ -169,7 +82,7 @@ abstract class Lnurl { return 0; } - static String subUntil(String content, String before, String end) { + static String _subUntil(String content, String before, String end) { var beforeLength = before.length; var index = content.indexOf(before); if (index < 0) { diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/Invoice_response.dart b/packages/ndk/lib/domain_layer/usecases/zaps/Invoice_response.dart new file mode 100644 index 00000000..44ae1130 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/zaps/Invoice_response.dart @@ -0,0 +1,9 @@ +/// invoice response +class InvoiceResponse { + String invoice; + int amountSats; + String? nostrPubkey; + + /// . + InvoiceResponse({required this.invoice, this.nostrPubkey, required this.amountSats}); +} diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart index a6ccd07f..a1ba1d3f 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'package:ndk/domain_layer/entities/nip_01_event.dart'; import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; -import 'package:ndk/domain_layer/usecases/zaps/zap_request.dart'; import '../../../shared/logger/logger.dart'; @@ -19,9 +18,11 @@ class ZapReceipt { String? comment; String? sender; String? anon; + String? lnurl; ZapReceipt.fromEvent(Nip01Event event) { String? zapRequestJson; + pubKey = event.pubKey; if (event.kind == 9735) { for (var tag in event.tags) { if (tag[0] == 'bolt11') bolt11 = tag[1]; @@ -37,6 +38,7 @@ class ZapReceipt { Nip01Event event = Nip01Event.fromJson(jsonDecode(zapRequestJson)); comment = event.content; sender = event.pubKey; + lnurl = event.getFirstTag('lnurl'); String? amountString = event.getFirstTag('amount'); if (amountString != null && amountString.isNotEmpty) { try { @@ -54,50 +56,32 @@ class ZapReceipt { } List? splitStrings = anon?.split('_'); if (splitStrings != null && splitStrings.length == 2) { - // /// recipient decrypt - // try { - // String contentBech32 = splitStrings[0]; - // String ivBech32 = splitStrings[1]; - // String? encryptedContent = bech32Decode(contentBech32, - // maxLength: contentBech32.length)['data']; - // String? iv = - // bech32Decode(ivBech32, maxLength: ivBech32.length)['data']; - // - // String encryptedContentBase64 = - // base64Encode(hexToBytes(encryptedContent!)); - // String ivBase64 = base64Encode(hexToBytes(iv!)); - // - // String eventString = await Nip4.decryptContent( - // '$encryptedContentBase64?iv=$ivBase64', - // recipient!, - // myPubkey, - // privkey); - // - // /// try to use sender decrypt - // if (eventString.isEmpty) { - // String derivedPrivkey = - // generateKeyPair(recipient, event.createdAt, privkey); - // eventString = await Nip4.decryptContent('$encryptedContent?iv=$iv', - // recipient, bip340.getPublicKey(derivedPrivkey), derivedPrivkey); - // } - // if (eventString.isNotEmpty) { - // Event privEvent = await Event.fromJson(jsonDecode(eventString)); - // sender = privEvent.pubkey; - // content = privEvent.content; - // } - // } catch (_) {} + // TODO decrypt private zap } } else { throw Exception("${event.kind} is not nip57 compatible"); } } - bool isValid(String invoice) { - return bolt11 == invoice; - // TODO: + /// is valid + bool isValid({required String nostrPubKey, required String recipientLnurl}) { // - The zap receipt event's pubkey MUST be the same as the recipient's lnurl provider's nostrPubkey (retrieved in step 1 of the protocol flow). + if (pubKey != nostrPubKey) { + return false; + } // - The invoiceAmount contained in the bolt11 tag of the zap receipt MUST equal the amount tag of the zap request (if present). + if (bolt11!=null && bolt11!.isNotEmpty) { + if (amountSats != Lnurl.getAmountFromBolt11(bolt11!)) { + return false; + } + } // - The lnurl tag of the zap request (if present) SHOULD equal the recipient's lnurl. + if (lnurl!=null && lnurl!.isNotEmpty) { + if (lnurl != recipientLnurl) { + return false; + } + } + return true; } @override diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index 8a0f07a7..91993f46 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -1,6 +1,9 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart'; import 'package:ndk/domain_layer/usecases/nwc/nwc_connection.dart'; +import 'package:ndk/domain_layer/usecases/zaps/Invoice_response.dart'; import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; import 'package:ndk/domain_layer/usecases/zaps/zap_request.dart'; @@ -14,6 +17,8 @@ import '../nwc/nwc.dart'; import '../nwc/responses/pay_invoice_response.dart'; import '../requests/requests.dart'; +import 'package:http/http.dart' as http; + /// Zaps class Zaps { final Requests _requests; @@ -25,6 +30,94 @@ class Zaps { }) : _requests = requests, _nwc = nwc; + /// creates an invoice with an optional zap request encoded if signer, pubKey & relays are non empty + Future fecthInvoice({ + required String lud16Link, + required int amountSats, + ZapRequest? zapRequest, + String? comment, + http.Client? client + }) async { + var lnurlResponse = await Lnurl.getLnurlResponse(lud16Link, client: client); + if (lnurlResponse == null) { + return null; + } + + var callback = lnurlResponse.callback!; + if (callback.contains("?")) { + callback += "&"; + } else { + callback += "?"; + } + + final amount = amountSats * 1000; + callback += "amount=$amount"; + + if (comment != null && comment.trim() != '') { + var commentNum = lnurlResponse.commentAllowed; + if (commentNum != null) { + if (commentNum < comment.length) { + comment = comment.substring(0, commentNum); + } + callback += "&comment=${Uri.encodeQueryComponent(comment)}"; + } + } + + // ZAP ? + if (lnurlResponse.doesAllowsNostr && zapRequest!=null && zapRequest.sig.isNotEmpty) { + Logger.log.d(jsonEncode(zapRequest)); + var eventStr = Uri.encodeQueryComponent(jsonEncode(zapRequest)); + callback += "&nostr=$eventStr"; + } + + Logger.log.d("getInvoice callback $callback"); + + Uri uri = Uri.parse(callback); + + try { + var response = await (client ?? http.Client()).get(uri); + final decodedResponse = + jsonDecode(utf8.decode(response.bodyBytes)) as Map; + String invoice = decodedResponse["pr"]; + return InvoiceResponse(invoice: invoice, amountSats: amountSats, nostrPubkey: lnurlResponse.nostrPubkey); + + } catch (e) { + Logger.log.d(e); + } + return null; + } + + Future createZapRequest({ + required int amountSats, + required EventSigner signer, + required String pubKey, + String? eventId, + String? comment, + required Iterable relays, + String? pollOption, + }) async { + if (amountSats<0) { + throw ArgumentError("amount cannot be < 0"); + } + final amount = amountSats * 1000; + + var tags = [ + ["relays", ...relays], + ["amount", amount.toString()], + ["p", pubKey], + ]; + if (eventId != null) { + tags.add(["e", eventId]); + } + if (pollOption != null) { + tags.add(["poll_option", pollOption]); + } + var event = ZapRequest( + pubKey: signer.getPublicKey(), tags: tags, content: comment??''); + await signer.sign(event); + return event; + } + /// zap or pay some lnurl, for zap to be created it is necessary: /// - that the lnurl has the allowsNostr: true /// - non empty relays @@ -47,7 +140,7 @@ class Zaps { signer != null && relays != null && relays.isNotEmpty) { - zapRequest = await Lnurl.zapRequest( + zapRequest = await createZapRequest( amountSats: amountSats, signer: signer, pubKey: pubKey, @@ -55,7 +148,7 @@ class Zaps { relays: relays, eventId: eventId); } - String? invoice = await Lnurl.getInvoiceCode( + InvoiceResponse? invoice = await fecthInvoice( lud16Link: lud16Link!, comment: comment, amountSats: amountSats, @@ -66,11 +159,11 @@ class Zaps { } try { PayInvoiceResponse payResponse = - await _nwc.payInvoice(nwcConnection, invoice: invoice, timeout: Duration(seconds: 10)); + await _nwc.payInvoice(nwcConnection, invoice: invoice.invoice, timeout: Duration(seconds: 10)); if (payResponse.preimage.isNotEmpty && payResponse.errorCode == null) { ZapResponse zapResponse = ZapResponse( payInvoiceResponse: payResponse); - if (zapRequest != null && fetchZapReceipt) { + if (zapRequest != null && fetchZapReceipt && invoice.nostrPubkey!=null && invoice.nostrPubkey!.isNotEmpty) { // if it's a zap, try to find the zap receipt zapResponse.receiptResponse = _requests.subscription(filters: [ eventId != null @@ -90,7 +183,7 @@ class Zaps { if (bolt11!=null && bolt11 == invoice || preimage!=null && preimage==payResponse.preimage) { ZapReceipt receipt = ZapReceipt.fromEvent(event); Logger.log.d("Zap Receipt: $receipt"); - if (receipt.isValid(invoice)) { + if (receipt.isValid(nostrPubKey: invoice.nostrPubkey!, recipientLnurl: lnurl)) { zapResponse.emitReceipt(receipt); } else { Logger.log.w("Zap Receipt invalid: $receipt"); @@ -120,6 +213,7 @@ class Zaps { : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey]) ]); List events = await response.future; + // TODO how to check validity of zap receipts without nostrPubKey and recipientLnurl???? return events.map((event) => ZapReceipt.fromEvent(event)).toList(); } diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.dart index c465b720..7dc0d6b8 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.dart @@ -1,12 +1,9 @@ import 'dart:convert'; -import 'dart:math'; import 'package:http/http.dart' as http; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:ndk/data_layer/repositories/signers/bip340_event_signer.dart'; import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; -import 'package:ndk/domain_layer/usecases/zaps/zap_request.dart'; import 'package:ndk/shared/nips/nip01/bip340.dart'; import 'package:ndk/shared/nips/nip01/key_pair.dart'; import 'package:test/test.dart'; @@ -57,113 +54,5 @@ void main() { var lnurlResponse = await Lnurl.getLnurlResponse(link, client: client); expect(lnurlResponse, isNull); }); - - test('getInvoiceCode returns invoice code for valid input', () async { - final client = MockClient(); - final response = { - 'callback': 'https://domain.com/callback', - 'commentAllowed': 100, - 'allowsNostr': true, - }; - final link = 'https://domain.com/.well-known/lnurlp/name'; - - // Mock the client.get method - when(client.get(Uri.parse(link))) - .thenAnswer((_) async => http.Response(jsonEncode(response), 200)); - - when(client.get(argThat( - TypeMatcher().having((uri) => uri.toString(), 'uri', - startsWith('https://domain.com/callback')), - ))).thenAnswer((_) async => http.Response( - jsonEncode({ - "status": "OK", - "successAction": {"tag": "message", "message": "Payment Received!"}, - "routes": [], - "pr": "lnbc100...." - }), - 200)); - final amount = 1000; - - ZapRequest zapRequest = await Lnurl.zapRequest( - amountSats: amount, - eventId: 'eventId', - comment: 'comment', - signer: Bip340EventSigner( - privateKey: key.privateKey, publicKey: key.publicKey), - pubKey: 'pubKey', - relays: ['relay1', 'relay2']); - - var invoiceCode = await Lnurl.getInvoiceCode( - lud16Link: link, - amountSats: 1000, - zapRequest: zapRequest, - client: client); - expect(invoiceCode, startsWith("lnbc100")); - }); - - test('getInvoiceCode returns null for invalid input', () async { - var invoiceCode = await Lnurl.getInvoiceCode( - lud16Link: 'invalid', - amountSats: 1000, - ); - expect(invoiceCode, isNull); - }); - - test('zapRequest returns valid ZapRequest for correct inputs', () async { - final amount = 1000; - final eventId = 'eventId'; - final comment = 'comment'; - final pubKey = 'pubKey'; - final relays = ['relay1', 'relay2']; - - ZapRequest zapRequest = await Lnurl.zapRequest( - amountSats: amount, - eventId: eventId, - comment: comment, - signer: Bip340EventSigner( - privateKey: key.privateKey, publicKey: key.publicKey), - pubKey: pubKey, - relays: relays, - ); - - expect(zapRequest, isNotNull); - expect(zapRequest.tags, containsAll([ - ['amount', (amount*1000).toString()], - ['e', eventId], - ['p', pubKey], - ['relays', ...relays] - ])); - expect(zapRequest.content, comment); - expect(zapRequest.sig, isNotEmpty); - }); - - test('zapRequest throws error for negative amount', () async { - expect( - () async => await Lnurl.zapRequest( - amountSats: -1000, - eventId: 'eventId', - comment: 'comment', - signer: Bip340EventSigner( - privateKey: key.privateKey, publicKey: key.publicKey), - pubKey: 'pubKey', - relays: ['relay1', 'relay2'], - ), - throwsA(isA()), - ); - }); - - test('zapRequest handles empty relays list', () async { - ZapRequest zapRequest = await Lnurl.zapRequest( - amountSats: 1000, - eventId: 'eventId', - comment: 'comment', - signer: Bip340EventSigner( - privateKey: key.privateKey, publicKey: key.publicKey), - pubKey: 'pubKey', - relays: [], - ); - - expect(zapRequest, isNotNull); - }); }); } diff --git a/packages/ndk/test/usecases/zaps/zaps_test.dart b/packages/ndk/test/usecases/zaps/zaps_test.dart new file mode 100644 index 00000000..b11ce324 --- /dev/null +++ b/packages/ndk/test/usecases/zaps/zaps_test.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:ndk/data_layer/repositories/signers/bip340_event_signer.dart'; +import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; +import 'package:ndk/domain_layer/usecases/zaps/zap_request.dart'; +import 'package:ndk/domain_layer/usecases/zaps/zaps.dart'; +import 'package:ndk/presentation_layer/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip01/key_pair.dart'; +import 'package:test/test.dart'; + +import '../lnurl/lnurl_test.mocks.dart'; + +// Mock classes +@GenerateMocks([http.Client]) +void main() { + group('Lnurl', () { + KeyPair key = Bip340.generatePrivateKey(); + + test('getLud16LinkFromLud16 returns correct URL', () { + expect( + Lnurl.getLud16LinkFromLud16('name@domain.com'), + 'https://domain.com/.well-known/lnurlp/name', + ); + }); + + test('getLud16LinkFromLud16 returns null for invalid input', () { + expect(Lnurl.getLud16LinkFromLud16('invalid'), isNull); + }); + + test('getLnurlResponse returns LnurlResponse for valid link', () async { + final client = MockClient(); + final link = 'https://domain.com/.well-known/lnurlp/name'; + final response = { + 'callback': 'https://domain.com/callback', + 'commentAllowed': 100, + }; + + // Mock the client.get method + when(client.get(Uri.parse(link))) + .thenAnswer((_) async => http.Response(jsonEncode(response), 200)); + + var lnurlResponse = await Lnurl.getLnurlResponse(link, client: client); + expect(lnurlResponse, isNotNull); + expect(lnurlResponse!.callback, response['callback']); + }); + + test('getLnurlResponse returns null for invalid link', () async { + final client = MockClient(); + final link = 'https://invalid.com'; + + when(client.get(Uri.parse(link))) + .thenAnswer((_) async => http.Response('Not Found', 404)); + + var lnurlResponse = await Lnurl.getLnurlResponse(link, client: client); + expect(lnurlResponse, isNull); + }); + + test('fetchInvoidce returns invoice code for valid input', () async { + final client = MockClient(); + final response = { + 'callback': 'https://domain.com/callback', + 'commentAllowed': 100, + 'allowsNostr': true, + }; + final link = 'https://domain.com/.well-known/lnurlp/name'; + + // Mock the client.get method + when(client.get(Uri.parse(link))) + .thenAnswer((_) async => http.Response(jsonEncode(response), 200)); + + when(client.get(argThat( + TypeMatcher().having((uri) => uri.toString(), 'uri', + startsWith('https://domain.com/callback')), + ))).thenAnswer((_) async => http.Response( + jsonEncode({ + "status": "OK", + "successAction": {"tag": "message", "message": "Payment Received!"}, + "routes": [], + "pr": "lnbc100...." + }), + 200)); + final amount = 1000; + + Ndk ndk = Ndk.defaultConfig(); + + ZapRequest zapRequest = await ndk.zaps.createZapRequest( + amountSats: amount, + eventId: 'eventId', + comment: 'comment', + signer: Bip340EventSigner( + privateKey: key.privateKey, publicKey: key.publicKey), + pubKey: 'pubKey', + relays: ['relay1', 'relay2']); + + var invoiceCode = await ndk.zaps.fecthInvoice( + lud16Link: link, + amountSats: 1000, + zapRequest: zapRequest, + client: client); + expect(invoiceCode, startsWith("lnbc100")); + }); + + test('fetchInvoidce returns null for invalid input', () async { + Ndk ndk = Ndk.defaultConfig(); + var invoiceCode = await ndk.zaps.fecthInvoice( + lud16Link: 'invalid', + amountSats: 1000, + ); + expect(invoiceCode, isNull); + }); + + test('zapRequest returns valid ZapRequest for correct inputs', () async { + final amount = 1000; + final eventId = 'eventId'; + final comment = 'comment'; + final pubKey = 'pubKey'; + final relays = ['relay1', 'relay2']; + Ndk ndk = Ndk.defaultConfig(); + + ZapRequest zapRequest = await ndk.zaps.createZapRequest( + amountSats: amount, + eventId: eventId, + comment: comment, + signer: Bip340EventSigner( + privateKey: key.privateKey, publicKey: key.publicKey), + pubKey: pubKey, + relays: relays, + ); + + expect(zapRequest, isNotNull); + expect(zapRequest.tags, containsAll([ + ['amount', (amount*1000).toString()], + ['e', eventId], + ['p', pubKey], + ['relays', ...relays] + ])); + expect(zapRequest.content, comment); + expect(zapRequest.sig, isNotEmpty); + }); + + test('zapRequest throws error for negative amount', () async { + Ndk ndk = Ndk.defaultConfig(); + + expect( + () async => await ndk.zaps.createZapRequest( + amountSats: -1000, + eventId: 'eventId', + comment: 'comment', + signer: Bip340EventSigner( + privateKey: key.privateKey, publicKey: key.publicKey), + pubKey: 'pubKey', + relays: ['relay1', 'relay2'], + ), + throwsA(isA()), + ); + }); + + test('zapRequest handles empty relays list', () async { + Ndk ndk = Ndk.defaultConfig(); + + ZapRequest zapRequest = await ndk.zaps.createZapRequest( + amountSats: 1000, + eventId: 'eventId', + comment: 'comment', + signer: Bip340EventSigner( + privateKey: key.privateKey, publicKey: key.publicKey), + pubKey: 'pubKey', + relays: [], + ); + + expect(zapRequest, isNotNull); + }); + }); +}