From 6ae6f4f82213dd9cde69de8b58ebf6fc522bfe87 Mon Sep 17 00:00:00 2001 From: Fmar Date: Wed, 18 Dec 2024 00:48:26 +0100 Subject: [PATCH 01/46] initial lnurl & zap support --- .../lib/domain_layer/entities/relay_info.dart | 4 +- .../domain_layer/usecases/lnurl/lnurl.dart | 168 ++++++++++++++++++ .../usecases/lnurl/lnurl_response.dart | 44 +++++ .../usecases/zaps/zap_receipt.dart | 8 + .../usecases/zaps/zap_request.dart | 8 + packages/objectbox/pubspec.lock | 20 +-- packages/rust_verifier/.flutter-plugins | 14 +- .../.flutter-plugins-dependencies | 2 +- packages/rust_verifier/pubspec.lock | 48 ++--- 9 files changed, 272 insertions(+), 44 deletions(-) create mode 100644 packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart create mode 100644 packages/ndk/lib/domain_layer/usecases/lnurl/lnurl_response.dart create mode 100644 packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart create mode 100644 packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart diff --git a/packages/ndk/lib/domain_layer/entities/relay_info.dart b/packages/ndk/lib/domain_layer/entities/relay_info.dart index a7e95b26f..a0b385f7e 100644 --- a/packages/ndk/lib/domain_layer/entities/relay_info.dart +++ b/packages/ndk/lib/domain_layer/entities/relay_info.dart @@ -30,7 +30,7 @@ class RelayInfo { RelayInfo._(this.name, this.description, this.pubKey, this.contact, this.nips, this.software, this.version, this.icon); - factory RelayInfo.fromJson(Map json, String url) { + factory RelayInfo.fromJson(Map json, String url) { final String name = json["name"] ?? ''; final String description = json["description"] ?? ""; final String pubKey = json["pubkey"] ?? ""; @@ -56,7 +56,7 @@ class RelayInfo { headers: {'Accept': 'application/nostr+json'}, ); final decodedResponse = - jsonDecode(utf8.decode(response.bodyBytes)) as Map; + jsonDecode(utf8.decode(response.bodyBytes)) as Map; return RelayInfo.fromJson(decodedResponse, uri.toString()); } catch (e) { Logger.log.d(e); diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart new file mode 100644 index 000000000..b73542d3e --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; + +import 'package:bech32/bech32.dart'; +import 'package:http/http.dart' as http; +import 'package:ndk/domain_layer/entities/nip_01_event.dart'; +import 'package:ndk/domain_layer/repositories/event_signer.dart'; +import 'package:ndk/domain_layer/usecases/zaps/zap_request.dart'; +import 'package:ndk/shared/nips/nip19/nip19.dart'; + +import '../../../shared/logger/logger.dart'; +import 'lnurl_response.dart'; + +// TODO make this an instance in ndk/Initialization +class Lnurl { + static String? getLud16LinkFromLud16(String lud16) { + var strs = lud16.split("@"); + if (strs.length < 2) { + return null; + } + + var username = strs[0]; + var domainname = strs[1]; + + return "https://$domainname/.well-known/lnurlp/$username"; + } + + static String? getLnurlFromLud16(String lud16) { + var link = getLud16LinkFromLud16(lud16); + var uint8List = utf8.encode(link!); + var data = Nip19.convertBits(uint8List, 8, 5, true); + + var encoder = Bech32Encoder(); + Bech32 input = Bech32("lnurl", data); + var lnurl = encoder.convert(input, 2000); + + return lnurl.toUpperCase(); + } + + static Future getLnurlResponse(String link) async { + Uri uri = Uri.parse(link).replace(scheme: 'https'); + + try { + var response = await http.get(uri); + final decodedResponse = + jsonDecode(utf8.decode(response.bodyBytes)) as Map; + return LnurlResponse.fromJson(decodedResponse); + } catch (e) { + Logger.log.d(e); + return null; + } + } + + static Future getInvoiceCode({ + required String lud16Link, + required int sats, + required String recipientPubkey, + required EventSigner signer, + String? eventId, + required Iterable relays, + String? pollOption, + String? comment, + }) async { + var lnurlResponse = await getLnurlResponse(lud16Link); + if (lnurlResponse == null) { + return null; + } + + var callback = lnurlResponse.callback!; + if (callback.contains("?")) { + callback += "&"; + } else { + callback += "?"; + } + + var amount = sats * 1000; + callback += "amount=$amount"; + + String eventContent = ""; + 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)}"; + eventContent = comment; + } + } + + var tags = [ + ["relays", ...relays], + ["amount", amount.toString()], + ["p", recipientPubkey], + ]; + if (eventId != null) { + tags.add(["e", eventId]); + } + if (pollOption != null) { + tags.add(["poll_option", pollOption]); + } + var event = Nip01Event( + pubKey: signer.getPublicKey(), + kind: ZapRequest.KIND, + tags: tags, + content: eventContent); + await signer.sign(event); + if (event.sig != '') { + return null; + } + Logger.log.d(jsonEncode(event)); + var eventStr = Uri.encodeQueryComponent(jsonEncode(event)); + callback += "&nostr=$eventStr"; + + Logger.log.d("getInvoice callback $callback"); + + Uri uri = Uri.parse(callback); + + try { + var response = await http.get(uri); + final decodedResponse = + jsonDecode(utf8.decode(response.bodyBytes)) as Map; + return decodedResponse["pr"]; + } catch (e) { + Logger.log.d(e); + } + + return null; + } + + static int getNumFromStr(String zapStr) { + var numStr = subUntil(zapStr, "lnbc", "1p"); + if (numStr != '') { + var numStrLength = numStr.length; + if (numStrLength > 1) { + var lastStr = numStr.substring(numStr.length - 1); + var pureNumStr = numStr.substring(0, numStr.length - 1); + var pureNum = int.tryParse(pureNumStr); + if (pureNum != null) { + if (lastStr == "p") { + return (pureNum * 0.0001).round(); + } else if (lastStr == "n") { + return (pureNum * 0.1).round(); + } else if (lastStr == "u") { + return (pureNum * 100).round(); + } else if (lastStr == "m") { + return (pureNum * 100000).round(); + } + } + } + } + return 0; + } + + static String subUntil(String content, String before, String end) { + var beforeLength = before.length; + var index = content.indexOf(before); + if (index < 0) { + return ""; + } + + var index2 = content.indexOf(end, index + beforeLength); + if (index2 <= 0) { + return ""; + } + + return content.substring(index + beforeLength, index2); + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl_response.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl_response.dart new file mode 100644 index 000000000..72fd1c42b --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl_response.dart @@ -0,0 +1,44 @@ +class LnurlResponse { + String? callback; + int? maxSendable; + int? minSendable; + String? metadata; + int? commentAllowed; + String? tag; + bool? allowsNostr; + String? nostrPubkey; + + LnurlResponse( + {this.callback, + this.maxSendable, + this.minSendable, + this.metadata, + this.commentAllowed, + this.tag, + this.allowsNostr, + this.nostrPubkey}); + + LnurlResponse.fromJson(Map json) { + callback = json['callback']; + maxSendable = json['maxSendable']; + minSendable = json['minSendable']; + metadata = json['metadata']; + commentAllowed = json['commentAllowed']; + tag = json['tag']; + allowsNostr = json['allowsNostr']; + nostrPubkey = json['nostrPubkey']; + } + + Map toJson() { + final Map data = new Map(); + data['callback'] = callback; + data['maxSendable'] = maxSendable; + data['minSendable'] = minSendable; + data['metadata'] = metadata; + data['commentAllowed'] = commentAllowed; + data['tag'] = tag; + data['allowsNostr'] = allowsNostr; + data['nostrPubkey'] = nostrPubkey; + return data; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart new file mode 100644 index 000000000..a1b0e9dcb --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart @@ -0,0 +1,8 @@ +import 'package:ndk/domain_layer/entities/nip_01_event.dart'; + +class ZapReceipt extends Nip01Event { + + static const int KIND = 9735; + + ZapReceipt({required super.pubKey, required super.kind, required super.tags, required super.content}); +} \ No newline at end of file diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart new file mode 100644 index 000000000..5fb41c65e --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart @@ -0,0 +1,8 @@ +import 'package:ndk/domain_layer/entities/nip_01_event.dart'; + +class ZapRequest extends Nip01Event { + + static const int KIND = 9734; + + ZapRequest({required super.pubKey, required super.kind, required super.tags, required super.content}); +} \ No newline at end of file diff --git a/packages/objectbox/pubspec.lock b/packages/objectbox/pubspec.lock index 652dd9184..8649c7f87 100644 --- a/packages/objectbox/pubspec.lock +++ b/packages/objectbox/pubspec.lock @@ -5,23 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "72.0.0" + version: "76.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.3.2" + version: "0.3.3" analyzer: dependency: transitive description: name: analyzer - sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.7.0" + version: "6.11.0" args: dependency: transitive description: @@ -154,10 +154,10 @@ packages: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" convert: dependency: transitive description: @@ -343,10 +343,10 @@ packages: dependency: transitive description: name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "0.1.2-main.4" + version: "0.1.3-main.0" matcher: dependency: transitive description: @@ -582,7 +582,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_gen: dependency: transitive description: diff --git a/packages/rust_verifier/.flutter-plugins b/packages/rust_verifier/.flutter-plugins index 206f100c0..522dd75ab 100644 --- a/packages/rust_verifier/.flutter-plugins +++ b/packages/rust_verifier/.flutter-plugins @@ -1,8 +1,8 @@ # This is a generated file; do not edit or check into version control. -integration_test=C:\\Users\\Beonde\\Documents\\flutterSDK\\flutter\\packages\\integration_test\\ -path_provider=C:\\Users\\Beonde\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider-2.1.5\\ -path_provider_android=C:\\Users\\Beonde\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_android-2.2.12\\ -path_provider_foundation=C:\\Users\\Beonde\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_foundation-2.4.0\\ -path_provider_linux=C:\\Users\\Beonde\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_linux-2.2.1\\ -path_provider_windows=C:\\Users\\Beonde\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_windows-2.3.0\\ -rust_lib_ndk=c:\\Users\\Beonde\\AndroidStudioProjects\\ndk\\packages\\rust_verifier\\rust_builder\\ +integration_test=/usr/bin/flutter/packages/integration_test/ +path_provider=/home/fmar/.pub-cache/hosted/pub.dev/path_provider-2.1.5/ +path_provider_android=/home/fmar/.pub-cache/hosted/pub.dev/path_provider_android-2.2.12/ +path_provider_foundation=/home/fmar/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.0/ +path_provider_linux=/home/fmar/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/ +path_provider_windows=/home/fmar/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/ +rust_lib_ndk=/home/fmar/workspace/ndk/packages/rust_verifier/rust_builder/ diff --git a/packages/rust_verifier/.flutter-plugins-dependencies b/packages/rust_verifier/.flutter-plugins-dependencies index 74b4c06af..b6416a023 100644 --- a/packages/rust_verifier/.flutter-plugins-dependencies +++ b/packages/rust_verifier/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"integration_test","path":"C:\\\\Users\\\\Beonde\\\\Documents\\\\flutterSDK\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[]},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\Beonde\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.4.0\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"rust_lib_ndk","path":"c:\\\\Users\\\\Beonde\\\\AndroidStudioProjects\\\\ndk\\\\packages\\\\rust_verifier\\\\rust_builder\\\\","native_build":true,"dependencies":[]}],"android":[{"name":"integration_test","path":"C:\\\\Users\\\\Beonde\\\\Documents\\\\flutterSDK\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[]},{"name":"path_provider_android","path":"C:\\\\Users\\\\Beonde\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.12\\\\","native_build":true,"dependencies":[]},{"name":"rust_lib_ndk","path":"c:\\\\Users\\\\Beonde\\\\AndroidStudioProjects\\\\ndk\\\\packages\\\\rust_verifier\\\\rust_builder\\\\","native_build":true,"dependencies":[]}],"macos":[{"name":"path_provider_foundation","path":"C:\\\\Users\\\\Beonde\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.4.0\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"rust_lib_ndk","path":"c:\\\\Users\\\\Beonde\\\\AndroidStudioProjects\\\\ndk\\\\packages\\\\rust_verifier\\\\rust_builder\\\\","native_build":true,"dependencies":[]}],"linux":[{"name":"path_provider_linux","path":"C:\\\\Users\\\\Beonde\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[]},{"name":"rust_lib_ndk","path":"c:\\\\Users\\\\Beonde\\\\AndroidStudioProjects\\\\ndk\\\\packages\\\\rust_verifier\\\\rust_builder\\\\","native_build":true,"dependencies":[]}],"windows":[{"name":"path_provider_windows","path":"C:\\\\Users\\\\Beonde\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[]},{"name":"rust_lib_ndk","path":"c:\\\\Users\\\\Beonde\\\\AndroidStudioProjects\\\\ndk\\\\packages\\\\rust_verifier\\\\rust_builder\\\\","native_build":true,"dependencies":[]}],"web":[]},"dependencyGraph":[{"name":"integration_test","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"rust_lib_ndk","dependencies":[]}],"date_created":"2024-12-11 17:39:48.464232","version":"3.24.5","swift_package_manager_enabled":false} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"integration_test","path":"/usr/bin/flutter/packages/integration_test/","native_build":true,"dependencies":[]},{"name":"path_provider_foundation","path":"/home/fmar/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.0/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"rust_lib_ndk","path":"/home/fmar/workspace/ndk/packages/rust_verifier/rust_builder/","native_build":true,"dependencies":[]}],"android":[{"name":"integration_test","path":"/usr/bin/flutter/packages/integration_test/","native_build":true,"dependencies":[]},{"name":"path_provider_android","path":"/home/fmar/.pub-cache/hosted/pub.dev/path_provider_android-2.2.12/","native_build":true,"dependencies":[]},{"name":"rust_lib_ndk","path":"/home/fmar/workspace/ndk/packages/rust_verifier/rust_builder/","native_build":true,"dependencies":[]}],"macos":[{"name":"path_provider_foundation","path":"/home/fmar/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.0/","shared_darwin_source":true,"native_build":true,"dependencies":[]},{"name":"rust_lib_ndk","path":"/home/fmar/workspace/ndk/packages/rust_verifier/rust_builder/","native_build":true,"dependencies":[]}],"linux":[{"name":"path_provider_linux","path":"/home/fmar/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[]},{"name":"rust_lib_ndk","path":"/home/fmar/workspace/ndk/packages/rust_verifier/rust_builder/","native_build":true,"dependencies":[]}],"windows":[{"name":"path_provider_windows","path":"/home/fmar/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[]},{"name":"rust_lib_ndk","path":"/home/fmar/workspace/ndk/packages/rust_verifier/rust_builder/","native_build":true,"dependencies":[]}],"web":[]},"dependencyGraph":[{"name":"integration_test","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"rust_lib_ndk","dependencies":[]}],"date_created":"2024-12-18 00:22:24.640813","version":"3.27.0","swift_package_manager_enabled":false} \ No newline at end of file diff --git a/packages/rust_verifier/pubspec.lock b/packages/rust_verifier/pubspec.lock index 1e91633d4..3b1fd7691 100644 --- a/packages/rust_verifier/pubspec.lock +++ b/packages/rust_verifier/pubspec.lock @@ -5,23 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "72.0.0" + version: "76.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.3.2" + version: "0.3.3" analyzer: dependency: transitive description: name: analyzer - sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.7.0" + version: "6.11.0" args: dependency: transitive description: @@ -170,10 +170,10 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" convert: dependency: "direct main" description: @@ -371,18 +371,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.7" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.8" leak_tracker_testing: dependency: transitive description: @@ -419,10 +419,10 @@ packages: dependency: transitive description: name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "0.1.2-main.4" + version: "0.1.3-main.0" matcher: dependency: transitive description: @@ -625,7 +625,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_gen: dependency: transitive description: @@ -646,10 +646,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.0" stream_channel: dependency: transitive description: @@ -670,10 +670,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" sync_http: dependency: transitive description: @@ -694,10 +694,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.3" timing: dependency: transitive description: @@ -726,10 +726,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.3.0" watcher: dependency: transitive description: @@ -766,10 +766,10 @@ packages: dependency: transitive description: name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.4" xdg_directories: dependency: transitive description: From b68c1f1ef0d790dbecc98792f91816527dbde5f8 Mon Sep 17 00:00:00 2001 From: Fmar Date: Tue, 17 Dec 2024 12:19:52 +0100 Subject: [PATCH 02/46] simplify cache read for authors/ids and fix tests / mark some as skip (cherry picked from commit 031acb9439e241b3cef51a2e8ec10704b30f0c2e) --- .../usecases/cache_read/cache_read.dart | 61 ++++++++++----- .../ndk/test/usecases/cache_read_test.dart | 78 +++++++++++++++---- 2 files changed, 106 insertions(+), 33 deletions(-) diff --git a/packages/ndk/lib/domain_layer/usecases/cache_read/cache_read.dart b/packages/ndk/lib/domain_layer/usecases/cache_read/cache_read.dart index bd878b0f9..808531d58 100644 --- a/packages/ndk/lib/domain_layer/usecases/cache_read/cache_read.dart +++ b/packages/ndk/lib/domain_layer/usecases/cache_read/cache_read.dart @@ -15,34 +15,49 @@ class CacheRead { required RequestState requestState, required StreamController outController, }) async { - final unresolved = requestState.unresolvedFilters; + final unresolved = requestState.unresolvedFilters.toSet(); for (final filter in unresolved) { final List foundEvents = []; // authors if (filter.authors != null) { - final foundAuthors = await cacheManager.loadEvents( + final cached = await cacheManager.loadEvents( pubKeys: filter.authors!, kinds: filter.kinds ?? [], since: filter.since, until: filter.until, ); - foundEvents.addAll(foundAuthors); - - // remove found authors from unresolved filter if it's not a subscription - if (!requestState.isSubscription && foundAuthors.isNotEmpty) { - if (filter.limit == null) { - filter.authors!.removeWhere( - (author) => foundEvents.any((event) => event.pubKey == author), - ); - } else if (foundEvents.length >= filter.limit!) { - // also ok to remove authors if limit is reached - filter.authors!.removeWhere( - (author) => foundEvents.any((event) => event.pubKey == author), - ); - } - } + foundEvents.addAll(cached); + // WE CANNOT DO THIS, BECAUSE 1) kinds.length > 1, 2) only replaceable events have 1 event per pubKey+kind, normal events can have many per pubKey+kind + // TODO if kind.length == 1 and kind IS replaceable AND there is not limit/until/since AND it is NOT a subscription, then we can do some shit + // + // // remove found authors from unresolved filter if it's not a subscription + // if (!requestState.isSubscription && cached.isNotEmpty) { + // if (filter.limit == null) { + // // Keep track of whether we've kept one item + // bool keptOne = false; + // filter.authors!.removeWhere((author) { + // if (!keptOne && + // foundEvents.any((event) => event.pubKey == author)) { + // keptOne = true; + // return false; // Keep the first matching item + // } + // return foundEvents.any((event) => event.pubKey == author); + // }); + // } else if (foundEvents.length >= filter.limit!) { + // // Keep track of whether we've kept one item + // bool keptOne = false; + // filter.authors!.removeWhere((author) { + // if (!keptOne && + // foundEvents.any((event) => event.pubKey == author)) { + // keptOne = true; + // return false; // Keep the first matching item + // } + // return foundEvents.any((event) => event.pubKey == author); + // }); + // } + // } } if (filter.ids != null) { @@ -55,11 +70,17 @@ class CacheRead { foundIdEvents.add(foundId); } - filter.ids!.removeWhere( - (id) => foundIdEvents.any((event) => event.id == id), - ); + filter.ids!.removeWhere((id) { + return foundIdEvents.any((event) => event.id == id); + }); + foundEvents.addAll(foundIdEvents); + if (filter.ids!.isEmpty) { + // if we have not more ids in filter, remove the filter entirely, + // otherwise it will send too broad filter to relay + requestState.unresolvedFilters.remove(filter); + } } // write found events to response stream diff --git a/packages/ndk/test/usecases/cache_read_test.dart b/packages/ndk/test/usecases/cache_read_test.dart index af13a25ac..84e3c735c 100644 --- a/packages/ndk/test/usecases/cache_read_test.dart +++ b/packages/ndk/test/usecases/cache_read_test.dart @@ -22,7 +22,7 @@ void main() async { await myCacheManager.saveEvents(myEvens); }); - test('cache read - all', () async { + test('cache read - all - BAD TEST', skip: true, () async { final NdkRequest myNdkRequest = NdkRequest.query( "id", filters: [ @@ -61,7 +61,7 @@ void main() async { } }); - test('cache read - some missing', () async { + test('cache read - some missing - BAD TEST', skip:true, () async { final NdkRequest myNdkRequest = NdkRequest.query("id", filters: [ Filter( @@ -98,7 +98,7 @@ void main() async { } }); - test('cache read - author removal based on limit - remove', () async { + test('cache read - author removal based on limit - remove - BAD TEST', skip:true, () async { final CacheRead myUsecase = CacheRead(myCacheManager); // Test with limit @@ -122,7 +122,7 @@ void main() async { expect(myRequestStateWithLimit.unresolvedFilters[0].authors, equals([])); }); - test('cache read - not all in cache', () async { + test('cache read - not all in cache - BAD TEST', skip:true, () async { final CacheRead myUsecase = CacheRead(myCacheManager); // Test with limit @@ -160,7 +160,7 @@ void main() async { ])); }); - test('cache read - id filter', () async { + test('cache read - id filter with one missing', () async { final CacheManager myCacheManager = MemCacheManager(); final CacheRead myUsecase = CacheRead(myCacheManager); @@ -212,13 +212,65 @@ void main() async { expect(myRequestState.unresolvedFilters[0].ids, equals(['id3'])); }); - test('cache read - all in cache, cannot leave unresolved filters with empty authors and kind 1', () async { - final NdkRequest myNdkRequest = NdkRequest.query("id", filters: [ - Filter( - authors: ['pubKey1', 'pubKey2'], - kinds: [1], - ) - ], timeoutDuration: Duration(seconds: 5)); + test('cache read - id filter all in cache', () async { + final CacheManager myCacheManager = MemCacheManager(); + final CacheRead myUsecase = CacheRead(myCacheManager); + + final eventId0 = + Nip01Event(pubKey: "pubKey0", kind: 1, tags: [], content: "content0"); + eventId0.id = "id0"; + final eventId1 = + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1"); + eventId1.id = "id1"; + final eventId2 = + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2"); + eventId2.id = "id2"; + + final List idEvents = [ + eventId0, + eventId1, + eventId2, + ]; + + await myCacheManager.saveEvents(idEvents); + + final NdkRequest myNdkRequest = NdkRequest.query( + "id-filter", + filters: [ + Filter( + ids: ['id0', 'id1', 'id2'], + kinds: [1], + ) + ], + timeoutDuration: Duration(seconds: 5), + ); + final RequestState myRequestState = RequestState(myNdkRequest); + + final streamController = StreamController(); + final response = streamController.stream.toList(); + + await myUsecase.resolveUnresolvedFilters( + requestState: myRequestState, + outController: streamController, + ); + + await streamController.close(); + + final foundEvents = await response; + expect(foundEvents.length, equals(3)); + expect( + foundEvents.map((e) => e.id).toSet(), equals({'id0', 'id1', 'id2'})); + + expect(myRequestState.unresolvedFilters, equals([])); + }); + + test('cache read - has events for all authors', () async { + // ...but we cannot remove them from the filter because only replaceable events have 1 event per pubKey+kind, normal events can have many per pubKey+kind + final filter = Filter( + authors: ['pubKey1', 'pubKey2'], + kinds: [1], + ); + final NdkRequest myNdkRequest = NdkRequest.query("id", filters: [ filter ], timeoutDuration: Duration(seconds: 5)); final RequestState myRequestState = RequestState(myNdkRequest); final CacheRead myUsecase = CacheRead(myCacheManager); @@ -240,7 +292,7 @@ void main() async { expect(data, equals(myEvens)); }); - expect(myRequestState.unresolvedFilters, equals([])); + expect(myRequestState.unresolvedFilters, equals([filter])); }); }); From e7f661e714f4cd41df5298f0b1afa9c5b0becda5 Mon Sep 17 00:00:00 2001 From: Fmar Date: Wed, 18 Dec 2024 14:54:16 +0100 Subject: [PATCH 03/46] fix missing cleanup of timers --- packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart index 7f954d245..912e9336a 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart @@ -233,6 +233,7 @@ class Nwc { "Timed out while executing NWC request ${request.method.name} with relay ${connection.uri.relay}"; completer.completeError(error); _inflighRequests.remove(event.id); + _inflighRequestTimers.remove(event.id); Logger.log.w(error); } }); From 651a5f4a3be6c25a54991d0676854b2ab7f4276f Mon Sep 17 00:00:00 2001 From: Fmar Date: Fri, 20 Dec 2024 02:48:36 +0100 Subject: [PATCH 04/46] web_socket_client transport --- .../data_sources/websocket_client.dart | 41 +++++++++ .../websocket_client_nostr_transport.dart | 89 +++++++++++++++++++ ...socket_client_nostr_transport_factory.dart | 25 ++++++ packages/ndk/lib/presentation_layer/init.dart | 5 +- packages/ndk/pubspec.lock | 8 ++ packages/ndk/pubspec.yaml | 1 + .../ndk/test/relays/relay_manager_test.dart | 5 +- 7 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 packages/ndk/lib/data_layer/data_sources/websocket_client.dart create mode 100644 packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart create mode 100644 packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart diff --git a/packages/ndk/lib/data_layer/data_sources/websocket_client.dart b/packages/ndk/lib/data_layer/data_sources/websocket_client.dart new file mode 100644 index 000000000..1207a0482 --- /dev/null +++ b/packages/ndk/lib/data_layer/data_sources/websocket_client.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:web_socket_client/web_socket_client.dart'; + +// coverage:ignore-start +class WebsocketDSClient { + final WebSocket ws; + final String url; + + WebsocketDSClient(this.ws, this.url); + + StreamSubscription listen( + void Function(dynamic) onData, { + Function? onError, + void Function()? onDone, + }) { + return ws.messages + .listen(onData, onDone: onDone, onError: onError); + } + + void send(dynamic data) { + return ws.send(data); + } + + void close() { + return ws.close(); + } + + bool isOpen() { + return ws.connection.state == Connected(); + } + + int? closeCode() { + return ws.connection.state == Disconnected ? (ws.connection.state as Disconnected).code : null; + } + + String? closeReason() { + return ws.connection.state == Disconnected ? (ws.connection.state as Disconnected).reason : null; + } +} +// coverage:ignore-end diff --git a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart new file mode 100644 index 000000000..087173363 --- /dev/null +++ b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:web_socket_client/web_socket_client.dart'; + +import '../../../domain_layer/repositories/nostr_transport.dart'; +import '../../../shared/logger/logger.dart'; +import '../../data_sources/websocket_client.dart'; + +/// A WebSocket-based implementation of the NostrTransport interface. +/// +/// This class provides a WebSocket transport layer for Nostr protocol +/// communications, wrapping a WebsocketDS instance to handle the underlying +/// WebSocket operations. +class WebSocketClientNostrTransport implements NostrTransport { + /// The underlying WebSocket data source. + final WebsocketDSClient _websocketDS; + + /// Creates a new WebSocketNostrTransport instance. + /// + /// [_websocketDS] is the WebSocket data source to be used for communication. + WebSocketClientNostrTransport(this._websocketDS) { + Completer completer = Completer(); + ready = completer.future; + _websocketDS.ws.connection.listen((state) { + Logger.log.t("${_websocketDS.url} connection state changed to $state"); + switch (state) { + case Connected() || Reconnected(): + completer.complete(); + } + }); + } + + /// A Future that completes when the WebSocket connection is ready. + @override + late Future ready; + + /// Closes the WebSocket connection. + /// + /// Returns a Future that completes when the connection has been closed. + @override + Future close() async { + return _websocketDS.close(); + } + + /// Listens for data on the WebSocket connection. + /// + /// [onData] is called whenever data is received. + /// [onError] is called if an error occurs (optional). + /// [onDone] is called when the stream is closed (optional). + /// + /// Returns a StreamSubscription that can be used to control the subscription. + @override + StreamSubscription listen(void Function(dynamic p1) onData, + {Function? onError, void Function()? onDone}) { + return _websocketDS.listen(onData, onError: onError, onDone: onDone); + } + + /// Sends data through the WebSocket connection. + /// + /// [data] is the data to be sent. + @override + void send(data) { + _websocketDS.send(data); + } + + /// Checks if the WebSocket connection is currently open. + /// + /// Returns true if the connection is open, false otherwise. + @override + bool isOpen() { + return _websocketDS.isOpen(); + } + + /// Gets the close code of the WebSocket connection. + /// + /// Returns the close code if the connection has been closed, null otherwise. + @override + int? closeCode() { + return _websocketDS.closeCode(); + } + + /// Gets the close reason of the WebSocket connection. + /// + /// Returns the close reason if the connection has been closed, null otherwise. + @override + String? closeReason() { + return _websocketDS.closeReason(); + } +} diff --git a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart new file mode 100644 index 000000000..0555f97a2 --- /dev/null +++ b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart @@ -0,0 +1,25 @@ +import 'package:ndk/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart'; +import 'package:web_socket_client/web_socket_client.dart'; + +import '../../../domain_layer/repositories/nostr_transport.dart'; +import '../../../shared/helpers/relay_helper.dart'; +import '../../../shared/logger/logger.dart'; +import '../../data_sources/websocket_client.dart'; + +class WebSocketClientNostrTransportFactory implements NostrTransportFactory { + @override + NostrTransport call(String url) { + final myUrl = cleanRelayUrl(url); + + if (myUrl == null) { + throw Exception("relayUrl is not parsable"); + } + + final backoff = BinaryExponentialBackoff( + initial: Duration(seconds: 1), maximumStep: 10); + final client = WebSocket(Uri.parse(myUrl), backoff: backoff); + + final WebsocketDSClient myDataSource = WebsocketDSClient(client,myUrl); + return WebSocketClientNostrTransport(myDataSource); + } +} diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index bd521b012..33d39f46b 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -2,6 +2,7 @@ import 'package:http/http.dart' as http; import '../data_layer/data_sources/http_request.dart'; import '../data_layer/repositories/nip_05_http_impl.dart'; +import '../data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart'; import '../data_layer/repositories/nostr_transport/websocket_nostr_transport_factory.dart'; import '../domain_layer/entities/global_state.dart'; import '../domain_layer/entities/jit_engine_relay_connectivity_data.dart'; @@ -35,8 +36,8 @@ class Initialization { /// repositories with no dependencies - final WebSocketNostrTransportFactory _webSocketNostrTransportFactory = - WebSocketNostrTransportFactory(); + final WebSocketClientNostrTransportFactory _webSocketNostrTransportFactory = + WebSocketClientNostrTransportFactory(); /// state obj diff --git a/packages/ndk/pubspec.lock b/packages/ndk/pubspec.lock index 0cc194a5b..7f750b9af 100644 --- a/packages/ndk/pubspec.lock +++ b/packages/ndk/pubspec.lock @@ -593,6 +593,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + web_socket_client: + dependency: "direct main" + description: + name: web_socket_client + sha256: "0ec5230852349191188c013112e4d2be03e3fc83dbe80139ead9bf3a136e53b5" + url: "https://pub.dev" + source: hosted + version: "0.1.5" webkit_inspection_protocol: dependency: transitive description: diff --git a/packages/ndk/pubspec.yaml b/packages/ndk/pubspec.yaml index 4a7b7852c..e1846a295 100644 --- a/packages/ndk/pubspec.yaml +++ b/packages/ndk/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: rxdart: ^0.28.0 isar: ^4.0.0-dev.14 equatable: ^2.0.7 + web_socket_client: ^0.1.5 dev_dependencies: build_runner: ^2.4.11 diff --git a/packages/ndk/test/relays/relay_manager_test.dart b/packages/ndk/test/relays/relay_manager_test.dart index ca428c37f..ea12e32a4 100644 --- a/packages/ndk/test/relays/relay_manager_test.dart +++ b/packages/ndk/test/relays/relay_manager_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:ndk/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart'; import 'package:ndk/domain_layer/entities/global_state.dart'; import 'package:ndk/domain_layer/usecases/relay_manager.dart'; import 'package:ndk/entities.dart'; @@ -12,8 +13,8 @@ import '../mocks/mock_relay.dart'; void main() async { group('Relay Manager', () { - final WebSocketNostrTransportFactory webSocketNostrTransportFactory = - WebSocketNostrTransportFactory(); + final WebSocketClientNostrTransportFactory webSocketNostrTransportFactory = + WebSocketClientNostrTransportFactory(); test('Connect to relay', () async { MockRelay relay1 = MockRelay(name: "relay 1", explicitPort: 5044); From acab94999cf442a04d93f197ec1a6aea799f0ae6 Mon Sep 17 00:00:00 2001 From: Fmar Date: Fri, 20 Dec 2024 02:54:21 +0100 Subject: [PATCH 05/46] fix completer --- .../nostr_transport/websocket_client_nostr_transport.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart index 087173363..7af29d942 100644 --- a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart +++ b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart @@ -26,6 +26,9 @@ class WebSocketClientNostrTransport implements NostrTransport { switch (state) { case Connected() || Reconnected(): completer.complete(); + default: + completer = Completer(); + ready = completer.future; } }); } From 2830804f917f3e7ac6c2737bfa99f06636c1cac2 Mon Sep 17 00:00:00 2001 From: Fmar Date: Fri, 20 Dec 2024 02:56:40 +0100 Subject: [PATCH 06/46] || ws.connection.state == Reconnected --- packages/ndk/lib/data_layer/data_sources/websocket_client.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ndk/lib/data_layer/data_sources/websocket_client.dart b/packages/ndk/lib/data_layer/data_sources/websocket_client.dart index 1207a0482..4651c63d5 100644 --- a/packages/ndk/lib/data_layer/data_sources/websocket_client.dart +++ b/packages/ndk/lib/data_layer/data_sources/websocket_client.dart @@ -27,7 +27,7 @@ class WebsocketDSClient { } bool isOpen() { - return ws.connection.state == Connected(); + return ws.connection.state == Connected() || ws.connection.state == Reconnected; } int? closeCode() { From 22fca90423a648df477f582a7de665f06816907b Mon Sep 17 00:00:00 2001 From: Fmar Date: Fri, 20 Dec 2024 03:05:22 +0100 Subject: [PATCH 07/46] onReconnect --- .../nostr_transport/websocket_client_nostr_transport.dart | 5 ++++- .../websocket_client_nostr_transport_factory.dart | 4 ++-- .../nostr_transport/websocket_nostr_transport_factory.dart | 2 +- .../ndk/lib/domain_layer/repositories/nostr_transport.dart | 2 +- .../ndk/lib/domain_layer/usecases/old_relay_manager.dart | 2 +- packages/ndk/lib/domain_layer/usecases/relay_manager.dart | 4 +++- 6 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart index 7af29d942..49a503de8 100644 --- a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart +++ b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart @@ -18,7 +18,7 @@ class WebSocketClientNostrTransport implements NostrTransport { /// Creates a new WebSocketNostrTransport instance. /// /// [_websocketDS] is the WebSocket data source to be used for communication. - WebSocketClientNostrTransport(this._websocketDS) { + WebSocketClientNostrTransport(this._websocketDS, [Function? onReconnect]) { Completer completer = Completer(); ready = completer.future; _websocketDS.ws.connection.listen((state) { @@ -26,6 +26,9 @@ class WebSocketClientNostrTransport implements NostrTransport { switch (state) { case Connected() || Reconnected(): completer.complete(); + if (state == Reconnected && onReconnect!=null) { + onReconnect.call(); + } default: completer = Completer(); ready = completer.future; diff --git a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart index 0555f97a2..5459698b0 100644 --- a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart +++ b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart @@ -8,7 +8,7 @@ import '../../data_sources/websocket_client.dart'; class WebSocketClientNostrTransportFactory implements NostrTransportFactory { @override - NostrTransport call(String url) { + NostrTransport call(String url, Function? onReconnect) { final myUrl = cleanRelayUrl(url); if (myUrl == null) { @@ -20,6 +20,6 @@ class WebSocketClientNostrTransportFactory implements NostrTransportFactory { final client = WebSocket(Uri.parse(myUrl), backoff: backoff); final WebsocketDSClient myDataSource = WebsocketDSClient(client,myUrl); - return WebSocketClientNostrTransport(myDataSource); + return WebSocketClientNostrTransport(myDataSource,onReconnect); } } diff --git a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_nostr_transport_factory.dart b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_nostr_transport_factory.dart index 17ba4b885..2353f0637 100644 --- a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_nostr_transport_factory.dart +++ b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_nostr_transport_factory.dart @@ -7,7 +7,7 @@ import 'websocket_nostr_transport.dart'; class WebSocketNostrTransportFactory implements NostrTransportFactory { @override - NostrTransport call(String url) { + NostrTransport call(String url, Function? onReconnect) { final myUrl = cleanRelayUrl(url); if (myUrl == null) { diff --git a/packages/ndk/lib/domain_layer/repositories/nostr_transport.dart b/packages/ndk/lib/domain_layer/repositories/nostr_transport.dart index 7fb9b2d1c..82f89c68c 100644 --- a/packages/ndk/lib/domain_layer/repositories/nostr_transport.dart +++ b/packages/ndk/lib/domain_layer/repositories/nostr_transport.dart @@ -16,5 +16,5 @@ abstract class NostrTransport { } abstract class NostrTransportFactory { - NostrTransport call(String url); + NostrTransport call(String url, Function? onReconnect); } diff --git a/packages/ndk/lib/domain_layer/usecases/old_relay_manager.dart b/packages/ndk/lib/domain_layer/usecases/old_relay_manager.dart index 706d53e35..1dc13286f 100644 --- a/packages/ndk/lib/domain_layer/usecases/old_relay_manager.dart +++ b/packages/ndk/lib/domain_layer/usecases/old_relay_manager.dart @@ -144,7 +144,7 @@ class OldRelayManager { return false; } - transports[url] = nostrTransportFactory(url); + transports[url] = nostrTransportFactory(url, null); await transports[url]!.ready.timeout(Duration(seconds: connectTimeout), onTimeout: () { print("timed out connecting to relay $url"); diff --git a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart index 95110d190..ad8af2600 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart @@ -150,7 +150,9 @@ class RelayManager { Logger.log.i("connecting to relay $dirtyUrl"); - relayConnectivity.relayTransport = nostrTransportFactory(url); + relayConnectivity.relayTransport = nostrTransportFactory(url, () { + _reSubscribeInFlightSubscriptions(relayConnectivity!); + }); await relayConnectivity.relayTransport!.ready .timeout(Duration(seconds: connectTimeout), onTimeout: () { Logger.log.w("timed out connecting to relay $url"); From 73f09966334d62a30c2eac3595037a7bcede6490 Mon Sep 17 00:00:00 2001 From: Fmar Date: Fri, 20 Dec 2024 03:11:57 +0100 Subject: [PATCH 08/46] Reconnected() --- .../ndk/lib/data_layer/data_sources/websocket_client.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ndk/lib/data_layer/data_sources/websocket_client.dart b/packages/ndk/lib/data_layer/data_sources/websocket_client.dart index 4651c63d5..87ebcdc5f 100644 --- a/packages/ndk/lib/data_layer/data_sources/websocket_client.dart +++ b/packages/ndk/lib/data_layer/data_sources/websocket_client.dart @@ -27,15 +27,15 @@ class WebsocketDSClient { } bool isOpen() { - return ws.connection.state == Connected() || ws.connection.state == Reconnected; + return ws.connection.state == Connected() || ws.connection.state == Reconnected(); } int? closeCode() { - return ws.connection.state == Disconnected ? (ws.connection.state as Disconnected).code : null; + return ws.connection.state == Disconnected() ? (ws.connection.state as Disconnected).code : null; } String? closeReason() { - return ws.connection.state == Disconnected ? (ws.connection.state as Disconnected).reason : null; + return ws.connection.state == Disconnected() ? (ws.connection.state as Disconnected).reason : null; } } // coverage:ignore-end From ef70824bd1d36ee3aaa1c53d1a2413e3504d0c41 Mon Sep 17 00:00:00 2001 From: Fmar Date: Fri, 20 Dec 2024 03:18:37 +0100 Subject: [PATCH 09/46] don't require socket to be open to send --- packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart b/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart index 8b1bf5c05..043d850b4 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart @@ -52,7 +52,8 @@ class RelaySetsEngine implements NetworkEngine { // ==================================================================================================================== bool doRelayRequest(String id, RelayRequestState request) { - if (_relayManager.isRelayConnected(request.url) && + if ( + // _relayManager.isRelayConnected(request.url) && (!_globalState.blockedRelays.contains(request.url))) { try { RelayConnectivity? relay = _globalState.relays[request.url]; From e643d3b87d6764afdf6bccb76704aec12cba07a1 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 00:16:54 +0100 Subject: [PATCH 10/46] fixed duplicated --- packages/ndk/pubspec.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ndk/pubspec.yaml b/packages/ndk/pubspec.yaml index e035f2a02..3715f6d02 100644 --- a/packages/ndk/pubspec.yaml +++ b/packages/ndk/pubspec.yaml @@ -30,7 +30,6 @@ dependencies: equatable: ^2.0.7 web_socket_client: ^0.1.5 meta: any - web_socket_client: ^0.1.5 dev_dependencies: build_runner: ^2.4.11 From 984abab0b594d5e13ac60ca4609acac87bad4c2f Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 00:41:05 +0100 Subject: [PATCH 11/46] fix --- packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart | 2 +- packages/sample-app/pubspec.lock | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart index b73542d3e..6dd71045e 100644 --- a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart @@ -104,7 +104,7 @@ class Lnurl { tags: tags, content: eventContent); await signer.sign(event); - if (event.sig != '') { + if (event.sig == '') { return null; } Logger.log.d(jsonEncode(event)); diff --git a/packages/sample-app/pubspec.lock b/packages/sample-app/pubspec.lock index 268009cfb..5341bbacc 100644 --- a/packages/sample-app/pubspec.lock +++ b/packages/sample-app/pubspec.lock @@ -577,6 +577,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + web_socket_client: + dependency: transitive + description: + name: web_socket_client + sha256: "0ec5230852349191188c013112e4d2be03e3fc83dbe80139ead9bf3a136e53b5" + url: "https://pub.dev" + source: hosted + version: "0.1.5" xdg_directories: dependency: transitive description: From b1a0551e061f4ea88ca166d838ab535d32987747 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 01:32:22 +0100 Subject: [PATCH 12/46] clean --- packages/amber/pubspec.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/amber/pubspec.yaml b/packages/amber/pubspec.yaml index 5fc6fafa9..d5ff3d322 100644 --- a/packages/amber/pubspec.yaml +++ b/packages/amber/pubspec.yaml @@ -30,7 +30,6 @@ dev_dependencies: mockito: ^5.0.17 integration_test: sdk: flutter - objectbox_generator: any flutter: plugin: From e3be4c7e880d145ccfca3356aa1aa3a0c399d807 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 15:42:25 +0100 Subject: [PATCH 13/46] nip55 event signer impl using chebizarro/flutter-signer-plugin --- packages/amber/android/build.gradle | 2 +- .../signers/nip55_event_signer.dart | 59 +++++++++++++++++++ packages/amber/pubspec.lock | 43 ++++++-------- packages/amber/pubspec.yaml | 10 +++- 4 files changed, 85 insertions(+), 29 deletions(-) create mode 100644 packages/amber/lib/data_layer/repositories/signers/nip55_event_signer.dart diff --git a/packages/amber/android/build.gradle b/packages/amber/android/build.gradle index ad162e05c..15aa7282e 100644 --- a/packages/amber/android/build.gradle +++ b/packages/amber/android/build.gradle @@ -48,7 +48,7 @@ android { } defaultConfig { - minSdkVersion 23 + minSdkVersion 21 } dependencies { diff --git a/packages/amber/lib/data_layer/repositories/signers/nip55_event_signer.dart b/packages/amber/lib/data_layer/repositories/signers/nip55_event_signer.dart new file mode 100644 index 000000000..fcafc7343 --- /dev/null +++ b/packages/amber/lib/data_layer/repositories/signers/nip55_event_signer.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; + +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip19/nip19.dart'; +import 'package:signer_plugin/signer_plugin.dart'; + +class Nip55EventSigner implements EventSigner { + SignerPlugin _signerPlugin = SignerPlugin(); + bool isAvailable = false; + + final String publicKey; + + /// get a amber event signer + Nip55EventSigner({ + required this.publicKey, + }); + + @override + Future sign(Nip01Event event) async { + final npub = publicKey.startsWith('npub') + ? publicKey + : Nip19.encodePubKey(publicKey); + final signedMessage = + await _signerPlugin.signEvent(jsonEncode(event.toJson()), "", npub); + final signedEvent = jsonDecode(signedMessage['event']); + + event.sig = signedEvent['sig']; + } + + @override + String getPublicKey() { + return publicKey; + } + + @override + Future decrypt(String msg, String destPubKey, {String? id}) async { + final npub = publicKey.startsWith('npub') + ? publicKey + : Nip19.encodePubKey(publicKey); + Map map = + await _signerPlugin.nip04Decrypt(msg, id!, npub, destPubKey); + return map['signature']; + } + + @override + Future encrypt(String msg, String destPubKey, {String? id}) async { + final npub = publicKey.startsWith('npub') + ? publicKey + : Nip19.encodePubKey(publicKey); + Map map = + await _signerPlugin.nip04Encrypt(msg, id!, npub, destPubKey); + return map['signature']; + } + + @override + bool canSign() { + return publicKey.isNotEmpty; + } +} diff --git a/packages/amber/pubspec.lock b/packages/amber/pubspec.lock index fb3e7edab..567c2b51e 100644 --- a/packages/amber/pubspec.lock +++ b/packages/amber/pubspec.lock @@ -238,14 +238,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - flat_buffers: - dependency: transitive - description: - name: flat_buffers - sha256: "380bdcba5664a718bfd4ea20a45d39e13684f5318fcd8883066a55e21f37f4c3" - url: "https://pub.dev" - source: hosted - version: "23.5.26" flutter: dependency: "direct main" description: flutter @@ -470,22 +462,6 @@ packages: relative: true source: path version: "0.2.0-dev005" - objectbox: - dependency: transitive - description: - name: objectbox - sha256: ea823f4bf1d0a636e7aa50b43daabb64dd0fbd80b85a033016ccc1bc4f76f432 - url: "https://pub.dev" - source: hosted - version: "4.0.3" - objectbox_generator: - dependency: "direct dev" - description: - name: objectbox_generator - sha256: "96da521f2cef455cd524f8854e31d64495c50711ad5f1e2cf3142a8e527bc75f" - url: "https://pub.dev" - source: hosted - version: "4.0.3" package_config: dependency: transitive description: @@ -582,6 +558,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + signer_plugin: + dependency: "direct main" + description: + path: "." + ref: baa5b2fcf7ba8db606a76794582348a1403438b0 + resolved-ref: baa5b2fcf7ba8db606a76794582348a1403438b0 + url: "https://github.com/chebizarro/flutter-signer-plugin" + source: git + version: "0.0.1" sky_engine: dependency: transitive description: flutter @@ -723,6 +708,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + web_socket_client: + dependency: transitive + description: + name: web_socket_client + sha256: "0ec5230852349191188c013112e4d2be03e3fc83dbe80139ead9bf3a136e53b5" + url: "https://pub.dev" + source: hosted + version: "0.1.5" webdriver: dependency: transitive description: @@ -740,5 +733,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.5.0 <4.0.0" + dart: ">=3.5.4 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/packages/amber/pubspec.yaml b/packages/amber/pubspec.yaml index d5ff3d322..268a13206 100644 --- a/packages/amber/pubspec.yaml +++ b/packages/amber/pubspec.yaml @@ -5,7 +5,6 @@ homepage: https://github.com/relaystr/ndk environment: sdk: ">=3.2.1 <4.0.0" - flutter: ">=3.3.0" platforms: android: @@ -17,9 +16,14 @@ dependencies: hex: ^0.2.0 plugin_platform_interface: ^2.1.8 ndk: ^0.2.0-dev002 + signer_plugin: + git: + url: https://github.com/chebizarro/flutter-signer-plugin + ref: baa5b2fcf7ba8db606a76794582348a1403438b0 + # signer_plugin: ^1.0.0 -#dependency_overrides: -# ndk: +dependency_overrides: + ndk: # path: ../ndk dev_dependencies: From f72f86947e6b3330eff25d65c9609df016e244dc Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 16:22:21 +0100 Subject: [PATCH 14/46] constructor --- .../ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart | 4 +++- .../ndk/lib/domain_layer/usecases/zaps/zap_request.dart | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) 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 a1b0e9dcb..8ce29a5aa 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart @@ -4,5 +4,7 @@ class ZapReceipt extends Nip01Event { static const int KIND = 9735; - ZapReceipt({required super.pubKey, required super.kind, required super.tags, required super.content}); + ZapReceipt( + {required super.pubKey, required super.tags, required super.content}) + : super(kind: KIND); } \ No newline at end of file diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart index 5fb41c65e..1f1c5e9ae 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart @@ -1,8 +1,9 @@ import 'package:ndk/domain_layer/entities/nip_01_event.dart'; class ZapRequest extends Nip01Event { - static const int KIND = 9734; - ZapRequest({required super.pubKey, required super.kind, required super.tags, required super.content}); -} \ No newline at end of file + ZapRequest( + {required super.pubKey, required super.tags, required super.content}) + : super(kind: KIND); +} From ef61b9227cb067f6afd8c92f4e23cce91c60393e Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 16:23:12 +0100 Subject: [PATCH 15/46] remove kind --- packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart index 6dd71045e..4036d21e0 100644 --- a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart @@ -98,9 +98,8 @@ class Lnurl { if (pollOption != null) { tags.add(["poll_option", pollOption]); } - var event = Nip01Event( + var event = ZapRequest( pubKey: signer.getPublicKey(), - kind: ZapRequest.KIND, tags: tags, content: eventContent); await signer.sign(event); From 0eabc87996db91c06015f8fb2dd6c4243e3d7684 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 16:29:52 +0100 Subject: [PATCH 16/46] remove kind 2 --- .../ndk/lib/domain_layer/usecases/zaps/zap_request.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart index 1f1c5e9ae..affebf00a 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart @@ -4,6 +4,8 @@ class ZapRequest extends Nip01Event { static const int KIND = 9734; ZapRequest( - {required super.pubKey, required super.tags, required super.content}) - : super(kind: KIND); + {required String pubKey, + required List> tags, + required String content}) + : super(kind: KIND, pubKey: pubKey, tags: tags, content: content); } From bccb7a2bfe3d97230e992f6801d07d4fe30ce700 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 16:34:36 +0100 Subject: [PATCH 17/46] remove kind 3 --- .../ndk/lib/domain_layer/usecases/zaps/zap_request.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart index affebf00a..1f1c5e9ae 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart @@ -4,8 +4,6 @@ class ZapRequest extends Nip01Event { static const int KIND = 9734; ZapRequest( - {required String pubKey, - required List> tags, - required String content}) - : super(kind: KIND, pubKey: pubKey, tags: tags, content: content); + {required super.pubKey, required super.tags, required super.content}) + : super(kind: KIND); } From e07f6fa3c294f31a00ba0acf5019a08e561d3a9e Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 21:25:51 +0100 Subject: [PATCH 18/46] make zaps usecase and lnurl tests --- .../domain_layer/usecases/lnurl/lnurl.dart | 114 ++++----- .../usecases/lnurl/lnurl_response.dart | 2 + .../lib/domain_layer/usecases/zaps/zaps.dart | 76 ++++++ packages/ndk/lib/presentation_layer/init.dart | 9 +- .../ndk/test/usecases/lnurl/lnurl_test.dart | 113 +++++++++ .../test/usecases/lnurl/lnurl_test.mocks.dart | 219 ++++++++++++++++++ 6 files changed, 462 insertions(+), 71 deletions(-) create mode 100644 packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart create mode 100644 packages/ndk/test/usecases/lnurl/lnurl_test.dart create mode 100644 packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart index 4036d21e0..bc94a26e3 100644 --- a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'package:bech32/bech32.dart'; import 'package:http/http.dart' as http; -import 'package:ndk/domain_layer/entities/nip_01_event.dart'; import 'package:ndk/domain_layer/repositories/event_signer.dart'; import 'package:ndk/domain_layer/usecases/zaps/zap_request.dart'; import 'package:ndk/shared/nips/nip19/nip19.dart'; @@ -10,8 +9,9 @@ import 'package:ndk/shared/nips/nip19/nip19.dart'; import '../../../shared/logger/logger.dart'; import 'lnurl_response.dart'; -// TODO make this an instance in ndk/Initialization -class Lnurl { +/// LN URL utilities +abstract class Lnurl { + /// transform a lud16 of format name@domain.com to https://domain.com/.well-known/lnurlp/name static String? getLud16LinkFromLud16(String lud16) { var strs = lud16.split("@"); if (strs.length < 2) { @@ -36,13 +36,19 @@ class Lnurl { return lnurl.toUpperCase(); } - static Future getLnurlResponse(String link) async { + /// fetch LNURL response from given link + static Future getLnurlResponse(String link, + {http.Client? client}) async { Uri uri = Uri.parse(link).replace(scheme: 'https'); try { - var response = await http.get(uri); + var response = await (client ?? http.Client()).get(uri); final decodedResponse = jsonDecode(utf8.decode(response.bodyBytes)) as Map; + if (client == null) { + // Only close if we created the client + client?.close(); + } return LnurlResponse.fromJson(decodedResponse); } catch (e) { Logger.log.d(e); @@ -50,17 +56,19 @@ 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 sats, - required String recipientPubkey, - required EventSigner signer, + EventSigner? signer, + String? pubKey, String? eventId, - required Iterable relays, + Iterable? relays, String? pollOption, String? comment, + http.Client? client }) async { - var lnurlResponse = await getLnurlResponse(lud16Link); + var lnurlResponse = await getLnurlResponse(lud16Link,client: client); if (lnurlResponse == null) { return null; } @@ -87,35 +95,40 @@ class Lnurl { } } - var tags = [ - ["relays", ...relays], - ["amount", amount.toString()], - ["p", recipientPubkey], - ]; - if (eventId != null) { - tags.add(["e", eventId]); - } - if (pollOption != null) { - tags.add(["poll_option", pollOption]); - } - var event = ZapRequest( - pubKey: signer.getPublicKey(), - tags: tags, - content: eventContent); - await signer.sign(event); - if (event.sig == '') { - return null; + if (lnurlResponse.doesAllowsNostr && + pubKey != null && + pubKey.isNotEmpty && + relays != null && + relays.isNotEmpty && + signer != null) { + 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: eventContent); + await signer.sign(event); + if (event.sig == '') { + return null; + } + Logger.log.d(jsonEncode(event)); + var eventStr = Uri.encodeQueryComponent(jsonEncode(event)); + callback += "&nostr=$eventStr"; } - Logger.log.d(jsonEncode(event)); - var eventStr = Uri.encodeQueryComponent(jsonEncode(event)); - callback += "&nostr=$eventStr"; Logger.log.d("getInvoice callback $callback"); Uri uri = Uri.parse(callback); try { - var response = await http.get(uri); + var response = await (client ?? http.Client()).get(uri); final decodedResponse = jsonDecode(utf8.decode(response.bodyBytes)) as Map; return decodedResponse["pr"]; @@ -125,43 +138,4 @@ class Lnurl { return null; } - - static int getNumFromStr(String zapStr) { - var numStr = subUntil(zapStr, "lnbc", "1p"); - if (numStr != '') { - var numStrLength = numStr.length; - if (numStrLength > 1) { - var lastStr = numStr.substring(numStr.length - 1); - var pureNumStr = numStr.substring(0, numStr.length - 1); - var pureNum = int.tryParse(pureNumStr); - if (pureNum != null) { - if (lastStr == "p") { - return (pureNum * 0.0001).round(); - } else if (lastStr == "n") { - return (pureNum * 0.1).round(); - } else if (lastStr == "u") { - return (pureNum * 100).round(); - } else if (lastStr == "m") { - return (pureNum * 100000).round(); - } - } - } - } - return 0; - } - - static String subUntil(String content, String before, String end) { - var beforeLength = before.length; - var index = content.indexOf(before); - if (index < 0) { - return ""; - } - - var index2 = content.indexOf(end, index + beforeLength); - if (index2 <= 0) { - return ""; - } - - return content.substring(index + beforeLength, index2); - } } diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl_response.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl_response.dart index 72fd1c42b..e7292ee53 100644 --- a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl_response.dart +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl_response.dart @@ -41,4 +41,6 @@ class LnurlResponse { data['nostrPubkey'] = nostrPubkey; return data; } + + bool get doesAllowsNostr => allowsNostr!=null && allowsNostr!; } diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart new file mode 100644 index 000000000..feb710e9b --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -0,0 +1,76 @@ +import 'package:ndk/domain_layer/usecases/nwc/nwc_connection.dart'; +import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; + +import '../../entities/filter.dart'; +import '../../entities/request_response.dart'; +import '../../repositories/event_signer.dart'; +import '../lnurl/lnurl.dart'; +import '../nwc/nwc.dart'; +import '../nwc/responses/pay_invoice_response.dart'; +import '../requests/requests.dart'; + +/// Zaps +class Zaps { + final EventSigner? _signer; + final Requests _requests; + final Nwc _nwc; + + Zaps({ + required Requests requests, + required Nwc nwc, + EventSigner? signer, + }) : _requests = requests, + _nwc = nwc, + _signer = signer; + + /// zap or pay some lnurl, for zap to be created it is necessary: + /// - that the lnurl has the allowsNostr: true + /// - non empty relays + /// - non empty pubKey + /// - non empty _signer + Future zap({ + required NwcConnection nwcConnection, + required String lnurl, + required int amount, + + Iterable? relays, + String? pubKey, + String? eventId, + }) async { + String? lud16Link = Lnurl.getLud16LinkFromLud16(lnurl); + String? invoice = await Lnurl.getInvoiceCode( + lud16Link: lud16Link!, + sats: amount, + pubKey: pubKey, + eventId: eventId, + signer: _signer, + relays: relays); + if (invoice == null) { + return ZapResponse(error: "couldn't get invoice from $lnurl"); + } + try { + PayInvoiceResponse payResponse = + await _nwc.payInvoice(nwcConnection, invoice: invoice); + if (payResponse.preimage.isNotEmpty && payResponse.errorCode != null) { + NdkResponse receiptResponse = _requests.query(filters: [ + Filter(kinds: [ZapReceipt.KIND]) + ]); + return ZapResponse( + receiptResponse: receiptResponse, payInvoiceResponse: payResponse); + } + return ZapResponse(error: payResponse.errorMessage); + } catch (e) { + return ZapResponse(error: e.toString()); + } + } +} + +/// zap response +class ZapResponse { + NdkResponse? receiptResponse; + PayInvoiceResponse? payInvoiceResponse; + String? error; + + /// + ZapResponse({this.receiptResponse, this.payInvoiceResponse, this.error}); +} diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index 75af3bb17..19d87adf0 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -3,7 +3,6 @@ import 'package:http/http.dart' as http; import '../data_layer/data_sources/http_request.dart'; import '../data_layer/repositories/nip_05_http_impl.dart'; import '../data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart'; -import '../data_layer/repositories/nostr_transport/websocket_nostr_transport_factory.dart'; import '../domain_layer/entities/global_state.dart'; import '../domain_layer/entities/jit_engine_relay_connectivity_data.dart'; import '../domain_layer/repositories/nip_05_repo.dart'; @@ -22,6 +21,7 @@ import '../domain_layer/usecases/relay_sets/relay_sets.dart'; import '../domain_layer/usecases/relay_sets_engine.dart'; import '../domain_layer/usecases/requests/requests.dart'; import '../domain_layer/usecases/user_relay_lists/user_relay_lists.dart'; +import '../domain_layer/usecases/zaps/zaps.dart'; import '../shared/logger/logger.dart'; import 'ndk_config.dart'; @@ -55,6 +55,7 @@ class Initialization { late RelaySets relaySets; late Broadcast broadcast; late Nwc nwc; + late Zaps zaps; late VerifyNip05 verifyNip05; @@ -168,6 +169,12 @@ class Initialization { nwc = Nwc(requests: requests, broadcast: broadcast); + zaps = Zaps( + requests: requests, + nwc: nwc, + signer: _ndkConfig.eventSigner, + ); + /// set the user configured log level Logger.setLogLevel(_ndkConfig.logLevel); } diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.dart new file mode 100644 index 000000000..71b619f23 --- /dev/null +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.dart @@ -0,0 +1,113 @@ +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/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip01/key_pair.dart'; +import 'package:test/test.dart'; + +import '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('getLnurlFromLud16 returns correct lnurl', () { + // Assuming the Nip19.convertBits and Bech32Encoder are working correctly + // This test would need to be adjusted based on the actual implementation + var lnurl = Lnurl.getLnurlFromLud16('name@domain.com'); + expect(lnurl, isNotNull); + expect(lnurl, startsWith('LNURL')); + }); + + 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('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)); + + var invoiceCode = await Lnurl.getInvoiceCode( + lud16Link: link, + sats: 1000, + signer: Bip340EventSigner(privateKey: key.privateKey, publicKey: key.publicKey), + pubKey: 'pubKey', + eventId: 'eventId', + relays: ['relay1', 'relay2'], + pollOption: 'option', + comment: 'comment', + client: client); + expect(invoiceCode, startsWith("lnbc100")); + }); + + test('getInvoiceCode returns null for invalid input', () async { + var invoiceCode = await Lnurl.getInvoiceCode( + lud16Link: 'invalid', + sats: 1000, + ); + expect(invoiceCode, isNull); + }); + }); +} diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart new file mode 100644 index 000000000..9076e3577 --- /dev/null +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart @@ -0,0 +1,219 @@ +// Mocks generated by Mockito 5.4.5 from annotations +// in ndk/test/usecases/lnurl/lnurl_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; +import 'dart:convert' as _i4; +import 'dart:typed_data' as _i6; + +import 'package:http/http.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i5; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { + _FakeResponse_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeStreamedResponse_1 extends _i1.SmartFake + implements _i2.StreamedResponse { + _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [Client]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockClient extends _i1.Mock implements _i2.Client { + MockClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => + (super.noSuchMethod( + Invocation.method(#head, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#head, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => + (super.noSuchMethod( + Invocation.method(#get, [url], {#headers: headers}), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#get, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> post( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #post, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> put( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #put, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> patch( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> delete( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + {#headers: headers, #body: body, #encoding: encoding}, + ), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future read(Uri? url, {Map? headers}) => + (super.noSuchMethod( + Invocation.method(#read, [url], {#headers: headers}), + returnValue: _i3.Future.value( + _i5.dummyValue( + this, + Invocation.method(#read, [url], {#headers: headers}), + ), + ), + ) + as _i3.Future); + + @override + _i3.Future<_i6.Uint8List> readBytes( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method(#readBytes, [url], {#headers: headers}), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + ) + as _i3.Future<_i6.Uint8List>); + + @override + _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => + (super.noSuchMethod( + Invocation.method(#send, [request]), + returnValue: _i3.Future<_i2.StreamedResponse>.value( + _FakeStreamedResponse_1( + this, + Invocation.method(#send, [request]), + ), + ), + ) + as _i3.Future<_i2.StreamedResponse>); + + @override + void close() => super.noSuchMethod( + Invocation.method(#close, []), + returnValueForMissingStub: null, + ); +} From b1ddaeb1252792670d2f7a261a20d1bfd1c94d43 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 21:27:35 +0100 Subject: [PATCH 19/46] remove unused method --- .../domain_layer/usecases/lnurl/lnurl.dart | 24 ++++++++++--------- .../ndk/test/usecases/lnurl/lnurl_test.dart | 8 ------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart index bc94a26e3..145daaa3f 100644 --- a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart @@ -11,6 +11,7 @@ import 'lnurl_response.dart'; /// LN URL utilities abstract class Lnurl { + /// transform a lud16 of format name@domain.com to https://domain.com/.well-known/lnurlp/name static String? getLud16LinkFromLud16(String lud16) { var strs = lud16.split("@"); @@ -24,17 +25,18 @@ abstract class Lnurl { return "https://$domainname/.well-known/lnurlp/$username"; } - static String? getLnurlFromLud16(String lud16) { - var link = getLud16LinkFromLud16(lud16); - var uint8List = utf8.encode(link!); - var data = Nip19.convertBits(uint8List, 8, 5, true); - - var encoder = Bech32Encoder(); - Bech32 input = Bech32("lnurl", data); - var lnurl = encoder.convert(input, 2000); - - return lnurl.toUpperCase(); - } + // static String? getLnurlFromLud16(String lud16) { + // var link = getLud16LinkFromLud16(lud16); + // var uint8List = utf8.encode(link!); + // var data = Nip19.convertBits(uint8List, 8, 5, true); + // + // var encoder = Bech32Encoder(); + // Bech32 input = Bech32("lnurl", data); + // var lnurl = encoder.convert(input, 2000); + // + // return lnurl.toUpperCase(); + // } + // /// fetch LNURL response from given link static Future getLnurlResponse(String link, diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.dart index 71b619f23..050127355 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.dart @@ -28,14 +28,6 @@ void main() { expect(Lnurl.getLud16LinkFromLud16('invalid'), isNull); }); - test('getLnurlFromLud16 returns correct lnurl', () { - // Assuming the Nip19.convertBits and Bech32Encoder are working correctly - // This test would need to be adjusted based on the actual implementation - var lnurl = Lnurl.getLnurlFromLud16('name@domain.com'); - expect(lnurl, isNotNull); - expect(lnurl, startsWith('LNURL')); - }); - test('getLnurlResponse returns LnurlResponse for valid link', () async { final client = MockClient(); final link = 'https://domain.com/.well-known/lnurlp/name'; From d1a5eff252c13b1aa84d9232043cb752b2f9c53f Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 21:31:38 +0100 Subject: [PATCH 20/46] remove unused method --- .../usecases/lnurl/lnurl_response.dart | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl_response.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl_response.dart index e7292ee53..1986dfa11 100644 --- a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl_response.dart +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl_response.dart @@ -8,16 +8,6 @@ class LnurlResponse { bool? allowsNostr; String? nostrPubkey; - LnurlResponse( - {this.callback, - this.maxSendable, - this.minSendable, - this.metadata, - this.commentAllowed, - this.tag, - this.allowsNostr, - this.nostrPubkey}); - LnurlResponse.fromJson(Map json) { callback = json['callback']; maxSendable = json['maxSendable']; @@ -29,18 +19,5 @@ class LnurlResponse { nostrPubkey = json['nostrPubkey']; } - Map toJson() { - final Map data = new Map(); - data['callback'] = callback; - data['maxSendable'] = maxSendable; - data['minSendable'] = minSendable; - data['metadata'] = metadata; - data['commentAllowed'] = commentAllowed; - data['tag'] = tag; - data['allowsNostr'] = allowsNostr; - data['nostrPubkey'] = nostrPubkey; - return data; - } - bool get doesAllowsNostr => allowsNostr!=null && allowsNostr!; } From 3fff6f5cbf2de123874c461b84c250e7d9692076 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 22:02:34 +0100 Subject: [PATCH 21/46] add zaps getter --- packages/ndk/lib/presentation_layer/ndk.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ndk/lib/presentation_layer/ndk.dart b/packages/ndk/lib/presentation_layer/ndk.dart index 34fa64462..417b45e9a 100644 --- a/packages/ndk/lib/presentation_layer/ndk.dart +++ b/packages/ndk/lib/presentation_layer/ndk.dart @@ -13,6 +13,7 @@ import '../domain_layer/usecases/relay_manager.dart'; import '../domain_layer/usecases/relay_sets/relay_sets.dart'; import '../domain_layer/usecases/requests/requests.dart'; import '../domain_layer/usecases/user_relay_lists/user_relay_lists.dart'; +import '../domain_layer/usecases/zaps/zaps.dart'; import 'init.dart'; import 'ndk_config.dart'; @@ -95,6 +96,9 @@ class Ndk { @experimental Nwc get nwc => _initialization.nwc; + @experimental + Zaps get zaps => _initialization.zaps; + /// Close all transports on relay manager Future destroy() async { await nwc.disconnectAll(); From 9b7ef32aceef73d064a81911489acb6e38d9b269 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 22:05:40 +0100 Subject: [PATCH 22/46] add zaps getter --- .../lib/domain_layer/usecases/zaps/zaps.dart | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index feb710e9b..619e70802 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -11,17 +11,14 @@ import '../requests/requests.dart'; /// Zaps class Zaps { - final EventSigner? _signer; final Requests _requests; final Nwc _nwc; Zaps({ required Requests requests, required Nwc nwc, - EventSigner? signer, }) : _requests = requests, - _nwc = nwc, - _signer = signer; + _nwc = nwc; /// zap or pay some lnurl, for zap to be created it is necessary: /// - that the lnurl has the allowsNostr: true @@ -32,7 +29,7 @@ class Zaps { required NwcConnection nwcConnection, required String lnurl, required int amount, - + EventSigner? signer, Iterable? relays, String? pubKey, String? eventId, @@ -43,7 +40,7 @@ class Zaps { sats: amount, pubKey: pubKey, eventId: eventId, - signer: _signer, + signer: signer, relays: relays); if (invoice == null) { return ZapResponse(error: "couldn't get invoice from $lnurl"); @@ -52,11 +49,23 @@ class Zaps { PayInvoiceResponse payResponse = await _nwc.payInvoice(nwcConnection, invoice: invoice); if (payResponse.preimage.isNotEmpty && payResponse.errorCode != null) { - NdkResponse receiptResponse = _requests.query(filters: [ - Filter(kinds: [ZapReceipt.KIND]) - ]); + NdkResponse? receiptResponse; + if (pubKey != null && relays != null && relays.isNotEmpty) { + // if it's a zap, try to find the zap receipt + receiptResponse = _requests.query(explicitRelays: relays, filters: [ + eventId != null + ? Filter( + kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey]) + : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey]), + ]); + + // TODO: + // - 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). + // - The invoiceAmount contained in the bolt11 tag of the zap receipt MUST equal the amount tag of the zap request (if present). + // - The lnurl tag of the zap request (if present) SHOULD equal the recipient's lnurl. + } return ZapResponse( - receiptResponse: receiptResponse, payInvoiceResponse: payResponse); + zapReceiptResponse: receiptResponse, payInvoiceResponse: payResponse); } return ZapResponse(error: payResponse.errorMessage); } catch (e) { @@ -67,10 +76,10 @@ class Zaps { /// zap response class ZapResponse { - NdkResponse? receiptResponse; + NdkResponse? zapReceiptResponse; PayInvoiceResponse? payInvoiceResponse; String? error; /// - ZapResponse({this.receiptResponse, this.payInvoiceResponse, this.error}); + ZapResponse({this.zapReceiptResponse, this.payInvoiceResponse, this.error}); } From 34a404cfad5471e18398f0c99020848ccce63923 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sat, 21 Dec 2024 22:06:04 +0100 Subject: [PATCH 23/46] remove signer --- packages/ndk/lib/presentation_layer/init.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index 19d87adf0..e24029b27 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -172,7 +172,6 @@ class Initialization { zaps = Zaps( requests: requests, nwc: nwc, - signer: _ndkConfig.eventSigner, ); /// set the user configured log level From 9ce578b2e28db470a7d55724e27867e5efd8d8b8 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 13:59:55 +0100 Subject: [PATCH 24/46] more tests and zap receipt initial code --- packages/ndk/example/zaps/README.md | 11 ++ packages/ndk/example/zaps/zap.dart | 49 +++++++++ .../domain_layer/usecases/lnurl/lnurl.dart | 80 +++++++------- .../usecases/zaps/zap_receipt.dart | 104 +++++++++++++++++- .../lib/domain_layer/usecases/zaps/zaps.dart | 87 +++++++++++---- packages/ndk/lib/presentation_layer/ndk.dart | 1 + .../ndk/test/usecases/lnurl/lnurl_test.dart | 80 ++++++++++++-- 7 files changed, 340 insertions(+), 72 deletions(-) create mode 100644 packages/ndk/example/zaps/README.md create mode 100644 packages/ndk/example/zaps/zap.dart diff --git a/packages/ndk/example/zaps/README.md b/packages/ndk/example/zaps/README.md new file mode 100644 index 000000000..9a859b2a9 --- /dev/null +++ b/packages/ndk/example/zaps/README.md @@ -0,0 +1,11 @@ +# running the examples + +You need a `nostr+walletconnect://...` uri from your NWC wallet service provider. + +see https://github.com/getAlby/awesome-nwc for more info how to get a wallet supporting NWC + +`NWC_URI=nostr+walletconnect://.... dart zap.dart` + +for more logging + +`NWC_URI=nostr+walletconnect://.... dart --enable-asserts zap.dart` diff --git a/packages/ndk/example/zaps/zap.dart b/packages/ndk/example/zaps/zap.dart new file mode 100644 index 000000000..b7b281ed3 --- /dev/null +++ b/packages/ndk/example/zaps/zap.dart @@ -0,0 +1,49 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:ndk/config/bootstrap_relays.dart'; +import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; +import 'package:ndk/domain_layer/usecases/zaps/zaps.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip01/key_pair.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); + KeyPair key = Bip340.generatePrivateKey(); + final amount = 21; + final lnurl = "fmar@getalby.com"; + final comment = "enjoy this zap from NDK"; + + ZapResponse response = await ndk.zaps.zap( + nwcConnection: connection, + lnurl: lnurl, + comment: comment, + amountSats: amount, + + signer: Bip340EventSigner(privateKey: key.privateKey, publicKey: key.publicKey), + relays: DEFAULT_BOOTSTRAP_RELAYS, + pubKey: "30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177", + eventId: "d7bc29fa3c55ac525a3d5f2021211edb672b58565225dec423479a0875feea9d" + ); + + if (response.payInvoiceResponse!=null && response.payInvoiceResponse!.preimage.isNotEmpty) { + print("Payed $amount to $lnurl, preimage = ${response.payInvoiceResponse! + .preimage}"); + + print("Waiting for Zap Receipt..."); + ZapReceipt? receipt = await response.zapReceipt; + if (receipt!=null) { + print("Receipt : $receipt"); + } + } + + await ndk.destroy(); +} diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart index 145daaa3f..d2a9c64c4 100644 --- a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart @@ -1,10 +1,8 @@ import 'dart:convert'; -import 'package:bech32/bech32.dart'; import 'package:http/http.dart' as http; import 'package:ndk/domain_layer/repositories/event_signer.dart'; import 'package:ndk/domain_layer/usecases/zaps/zap_request.dart'; -import 'package:ndk/shared/nips/nip19/nip19.dart'; import '../../../shared/logger/logger.dart'; import 'lnurl_response.dart'; @@ -46,7 +44,7 @@ abstract class Lnurl { try { var response = await (client ?? http.Client()).get(uri); final decodedResponse = - jsonDecode(utf8.decode(response.bodyBytes)) as Map; + jsonDecode(utf8.decode(response.bodyBytes)) as Map; if (client == null) { // Only close if we created the client client?.close(); @@ -61,16 +59,12 @@ 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 sats, - EventSigner? signer, - String? pubKey, - String? eventId, - Iterable? relays, - String? pollOption, + required int amountSats, + ZapRequest? zapRequest, String? comment, http.Client? client }) async { - var lnurlResponse = await getLnurlResponse(lud16Link,client: client); + var lnurlResponse = await getLnurlResponse(lud16Link, client: client); if (lnurlResponse == null) { return null; } @@ -82,10 +76,9 @@ abstract class Lnurl { callback += "?"; } - var amount = sats * 1000; + final amount = amountSats * 1000; callback += "amount=$amount"; - String eventContent = ""; if (comment != null && comment.trim() != '') { var commentNum = lnurlResponse.commentAllowed; if (commentNum != null) { @@ -93,35 +86,13 @@ abstract class Lnurl { comment = comment.substring(0, commentNum); } callback += "&comment=${Uri.encodeQueryComponent(comment)}"; - eventContent = comment; } } - if (lnurlResponse.doesAllowsNostr && - pubKey != null && - pubKey.isNotEmpty && - relays != null && - relays.isNotEmpty && - signer != null) { - 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: eventContent); - await signer.sign(event); - if (event.sig == '') { - return null; - } - Logger.log.d(jsonEncode(event)); - var eventStr = Uri.encodeQueryComponent(jsonEncode(event)); + // ZAP ? + if (lnurlResponse.doesAllowsNostr && zapRequest!=null && zapRequest.sig.isNotEmpty) { + Logger.log.d(jsonEncode(zapRequest)); + var eventStr = Uri.encodeQueryComponent(jsonEncode(zapRequest)); callback += "&nostr=$eventStr"; } @@ -132,7 +103,7 @@ abstract class Lnurl { try { var response = await (client ?? http.Client()).get(uri); final decodedResponse = - jsonDecode(utf8.decode(response.bodyBytes)) as Map; + jsonDecode(utf8.decode(response.bodyBytes)) as Map; return decodedResponse["pr"]; } catch (e) { Logger.log.d(e); @@ -140,4 +111,35 @@ abstract class Lnurl { 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; + } } 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 8ce29a5aa..154b0a03e 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart @@ -1,10 +1,104 @@ +import 'dart:convert'; + import 'package:ndk/domain_layer/entities/nip_01_event.dart'; -class ZapReceipt extends Nip01Event { +class ZapReceipt { + static const KIND = 9735; - static const int KIND = 9735; + int? paidAt; + String? pubKey; + String? bolt11; + String? preimage; + String? zapRequestJson; + String? recipient; + String? eventId; + String? zapContent; + String? sender; + String? anon; ZapReceipt( - {required super.pubKey, required super.tags, required super.content}) - : super(kind: KIND); -} \ No newline at end of file + this.paidAt, + this.pubKey, + this.bolt11, + this.preimage, + this.zapRequestJson, + this.recipient, + this.eventId, + this.zapContent, + this.sender); + + ZapReceipt.fromEvent(Nip01Event event) { + if (event.kind == 9735) { + for (var tag in event.tags) { + if (tag[0] == 'bolt11') bolt11 = tag[1]; + if (tag[0] == 'preimage') preimage = tag[1]; + if (tag[0] == 'description') zapRequestJson = tag[1]; + if (tag[0] == 'p') recipient = tag[1]; + if (tag[0] == 'e') eventId = tag[1]; + if (tag[0] == 'anon') anon = tag[1]; + if (tag[0] == 'P') sender = tag[1]; + } + paidAt = event.createdAt; + if (zapRequestJson != null) { + try { + Map map = jsonDecode(zapRequestJson!); + zapContent = map['content']; + sender = map['pubkey']; + } catch (_) { + zapContent = ''; + } + } + 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 (_) {} + } + } else { + throw Exception("${event.kind} is not nip57 compatible"); + } + } + + bool isValid(String invoice) { + return bolt11 == invoice; + // TODO: + // - 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). + // - The invoiceAmount contained in the bolt11 tag of the zap receipt MUST equal the amount tag of the zap request (if present). + // - The lnurl tag of the zap request (if present) SHOULD equal the recipient's lnurl. + // - SHA256(description) MUST match the description hash in the bolt11 invoice. + } + + @override + String toString() { + return 'ZapReceipt(paidAt: $paidAt, pubKey: $pubKey, bolt11: $bolt11, preimage: $preimage, description: $zapRequestJson, recipient: $recipient, eventId: $eventId, zapContent: $zapContent, sender: $sender, anon: $anon)'; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index 619e70802..ba4ce289a 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -1,7 +1,12 @@ +import 'dart:async'; + import 'package:ndk/domain_layer/usecases/nwc/nwc_connection.dart'; import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; +import 'package:ndk/domain_layer/usecases/zaps/zap_request.dart'; +import '../../../shared/logger/logger.dart'; import '../../entities/filter.dart'; +import '../../entities/nip_01_event.dart'; import '../../entities/request_response.dart'; import '../../repositories/event_signer.dart'; import '../lnurl/lnurl.dart'; @@ -28,44 +33,72 @@ class Zaps { Future zap({ required NwcConnection nwcConnection, required String lnurl, - required int amount, + required int amountSats, EventSigner? signer, Iterable? relays, String? pubKey, + String? comment, String? eventId, }) async { String? lud16Link = Lnurl.getLud16LinkFromLud16(lnurl); + ZapRequest? zapRequest; + if (pubKey != null && + signer != null && + relays != null && + relays.isNotEmpty) { + zapRequest = await Lnurl.zapRequest( + amountSats: amountSats, + signer: signer, + pubKey: pubKey, + comment: comment, + relays: relays, + eventId: eventId); + } String? invoice = await Lnurl.getInvoiceCode( - lud16Link: lud16Link!, - sats: amount, - pubKey: pubKey, - eventId: eventId, - signer: signer, - relays: relays); + lud16Link: lud16Link!, + comment: comment, + amountSats: amountSats, + zapRequest: zapRequest, + ); if (invoice == null) { return ZapResponse(error: "couldn't get invoice from $lnurl"); } try { PayInvoiceResponse payResponse = await _nwc.payInvoice(nwcConnection, invoice: invoice); - if (payResponse.preimage.isNotEmpty && payResponse.errorCode != null) { + if (payResponse.preimage.isNotEmpty && payResponse.errorCode == null) { NdkResponse? receiptResponse; - if (pubKey != null && relays != null && relays.isNotEmpty) { + if (zapRequest != null) { // if it's a zap, try to find the zap receipt receiptResponse = _requests.query(explicitRelays: relays, filters: [ - eventId != null - ? Filter( - kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey]) - : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey]), + Filter( + kinds: [ZapReceipt.KIND], + eTags: [eventId!], + pTags: [pubKey!]) ]); - - // TODO: - // - 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). - // - The invoiceAmount contained in the bolt11 tag of the zap receipt MUST equal the amount tag of the zap request (if present). - // - The lnurl tag of the zap request (if present) SHOULD equal the recipient's lnurl. } - return ZapResponse( - zapReceiptResponse: receiptResponse, payInvoiceResponse: payResponse); + ZapResponse zapResponse = ZapResponse( + zapReceiptResponse: receiptResponse, + payInvoiceResponse: payResponse); + if (receiptResponse != null) { + receiptResponse.future.then((events) { + Nip01Event? event = events.where((event) { + ZapReceipt receipt = ZapReceipt.fromEvent(event); + return receipt.bolt11 == invoice; + }).firstOrNull; + if (event!=null) { + ZapReceipt receipt = ZapReceipt.fromEvent(event); + Logger.log.d("Zap Receipt: $receipt"); + if (receipt.isValid(invoice)) { + zapResponse.emitReceipt(receipt); + return; + } + Logger.log.w("Zap Receipt invalid: $receipt"); + } + zapResponse.emitReceipt(null); + }); + } + return zapResponse; } return ZapResponse(error: payResponse.errorMessage); } catch (e) { @@ -79,6 +112,20 @@ class ZapResponse { NdkResponse? zapReceiptResponse; PayInvoiceResponse? payInvoiceResponse; String? error; + final _receiptCompleter = Completer(); + + /// the validated zap receipt + Future get zapReceipt { + if (zapReceiptResponse == null) { + return Future.value(null); + } + return _receiptCompleter.future; + } + + /// emit the receipt + emitReceipt(ZapReceipt? receipt) { + _receiptCompleter.complete(receipt); + } /// ZapResponse({this.zapReceiptResponse, this.payInvoiceResponse, this.error}); diff --git a/packages/ndk/lib/presentation_layer/ndk.dart b/packages/ndk/lib/presentation_layer/ndk.dart index 417b45e9a..13b92500c 100644 --- a/packages/ndk/lib/presentation_layer/ndk.dart +++ b/packages/ndk/lib/presentation_layer/ndk.dart @@ -96,6 +96,7 @@ class Ndk { @experimental Nwc get nwc => _initialization.nwc; + /// Zaps @experimental Zaps get zaps => _initialization.zaps; diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.dart index 050127355..c465b7209 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.dart @@ -1,10 +1,12 @@ 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'; @@ -80,16 +82,21 @@ void main() { "pr": "lnbc100...." }), 200)); + final amount = 1000; - var invoiceCode = await Lnurl.getInvoiceCode( - lud16Link: link, - sats: 1000, - signer: Bip340EventSigner(privateKey: key.privateKey, publicKey: key.publicKey), - pubKey: 'pubKey', + ZapRequest zapRequest = await Lnurl.zapRequest( + amountSats: amount, eventId: 'eventId', - relays: ['relay1', 'relay2'], - pollOption: 'option', 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")); }); @@ -97,9 +104,66 @@ void main() { test('getInvoiceCode returns null for invalid input', () async { var invoiceCode = await Lnurl.getInvoiceCode( lud16Link: 'invalid', - sats: 1000, + 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); + }); }); } From 12245e9a927d1382adb53f552bd0030ecb56afd4 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 15:11:40 +0100 Subject: [PATCH 25/46] add zaps.fetchZappedReceipts + examples for receipts --- .../ndk/example/zaps/receipts_for_event.dart | 36 ++++++++++++++ .../example/zaps/receipts_for_profile.dart | 30 ++++++++++++ .../domain_layer/entities/nip_01_event.dart | 19 +++----- .../domain_layer/usecases/lnurl/lnurl.dart | 42 +++++++++++++++++ .../usecases/zaps/zap_receipt.dart | 47 ++++++++++--------- .../usecases/zaps/zap_request.dart | 2 + .../lib/domain_layer/usecases/zaps/zaps.dart | 17 +++++++ 7 files changed, 159 insertions(+), 34 deletions(-) create mode 100644 packages/ndk/example/zaps/receipts_for_event.dart create mode 100644 packages/ndk/example/zaps/receipts_for_profile.dart diff --git a/packages/ndk/example/zaps/receipts_for_event.dart b/packages/ndk/example/zaps/receipts_for_event.dart new file mode 100644 index 000000000..6164cd5e4 --- /dev/null +++ b/packages/ndk/example/zaps/receipts_for_event.dart @@ -0,0 +1,36 @@ +// ignore_for_file: avoid_print + +import 'package:ndk/ndk.dart'; + +void main() async { + final ndk = Ndk.defaultConfig(); + + print("fetching zap receipts for single event "); + final receipts = await ndk.zaps.fetchZappedReceipts( + pubKey: + "30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177", + eventId: + "d7bc29fa3c55ac525a3d5f2021211edb672b58565225dec423479a0875feea9d"); + + // Sort eventReceipts by amountSats in descending order + receipts + .sort((a, b) => (b.amountSats ?? 0).compareTo(a.amountSats ?? 0)); + + // Sort profileReceipts by amountSats in descending order + receipts + .sort((a, b) => (b.amountSats ?? 0).compareTo(a.amountSats ?? 0)); + + int eventSum = 0; + for (var receipt in receipts) { + String? sender; + if (receipt.sender!=null) { + Metadata? metadata = await ndk.metadata.loadMetadata(receipt.sender!); + sender = metadata?.name; + } + print("${sender!=null?"from ${sender} ":""} ${receipt.amountSats} sats ${receipt.comment}"); + eventSum += receipt.amountSats ?? 0; + } + print("${receipts.length} receipts, total of $eventSum sats"); + + await ndk.destroy(); +} diff --git a/packages/ndk/example/zaps/receipts_for_profile.dart b/packages/ndk/example/zaps/receipts_for_profile.dart new file mode 100644 index 000000000..b9438c8c8 --- /dev/null +++ b/packages/ndk/example/zaps/receipts_for_profile.dart @@ -0,0 +1,30 @@ +// ignore_for_file: avoid_print + +import 'package:ndk/ndk.dart'; + +void main() async { + final ndk = Ndk.defaultConfig(); + + print("fetching zap receipts for profile "); + final profileReceipts = await ndk.zaps.fetchZappedReceipts( + pubKey: "30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177", + ); + + // Sort profileReceipts by amountSats in descending order + profileReceipts + .sort((a, b) => (b.amountSats ?? 0).compareTo(a.amountSats ?? 0)); + + int profileSum = 0; + for (var receipt in profileReceipts) { + String? sender; + if (receipt.sender!=null) { + Metadata? metadata = await ndk.metadata.loadMetadata(receipt.sender!); + sender = metadata?.name; + } + print("${sender!=null?"from ${sender} ":""} ${receipt.amountSats} sats ${receipt.comment}"); + profileSum += receipt.amountSats ?? 0; + } + print("${profileReceipts.length} receipts, total of $profileSum sats"); + + await ndk.destroy(); +} diff --git a/packages/ndk/lib/domain_layer/entities/nip_01_event.dart b/packages/ndk/lib/domain_layer/entities/nip_01_event.dart index f082b1030..df95d2d49 100644 --- a/packages/ndk/lib/domain_layer/entities/nip_01_event.dart +++ b/packages/ndk/lib/domain_layer/entities/nip_01_event.dart @@ -131,17 +131,7 @@ class Nip01Event { /// return first `e` tag found String? getEId() { - for (var tag in tags) { - if (tag.length > 1) { - var key = tag[0]; - var value = tag[1]; - - if (key == "e") { - return value; - } - } - } - return null; + return getFirstTag("e"); } /// return all tags that match given `tag` @@ -189,12 +179,17 @@ class Nip01Event { /// return first found `d` tag String? getDtag() { + return getFirstTag("d"); + } + + /// Get first tag matching given name + String? getFirstTag(String name) { for (var tag in tags) { if (tag.length > 1) { var key = tag[0]; var value = tag[1]; - if (key == "d") { + if (key == name) { return value; } } diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart index d2a9c64c4..67b137294 100644 --- a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart @@ -142,4 +142,46 @@ abstract class Lnurl { await signer.sign(event); return event; } + + /// extract amount from bolt11 in sats + static int getAmountFromBolt11(String bolt11) { + final numStr = subUntil(bolt11, "lnbc", "1p"); + if (numStr.isNotEmpty) { + var numStrLength = numStr.length; + if (numStrLength > 1) { + var lastStr = numStr.substring(numStr.length - 1); + var pureNumStr = numStr.substring(0, numStr.length - 1); + var pureNum = int.tryParse(pureNumStr); + if (pureNum != null) { + if (lastStr == "p") { + return (pureNum * 0.0001).round(); + } else if (lastStr == "n") { + return (pureNum * 0.1).round(); + } else if (lastStr == "u") { + return (pureNum * 100).round(); + } else if (lastStr == "m") { + return (pureNum * 100000).round(); + } + } + } + } + + return 0; + } + + static String subUntil(String content, String before, String end) { + var beforeLength = before.length; + var index = content.indexOf(before); + if (index < 0) { + return ""; + } + + var index2 = content.indexOf(end, index + beforeLength); + if (index2 <= 0) { + return ""; + } + + return content.substring(index + beforeLength, index2); + } + } 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 154b0a03e..a6ccd07ff 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart @@ -1,33 +1,27 @@ 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'; class ZapReceipt { static const KIND = 9735; int? paidAt; + int? amountSats; String? pubKey; String? bolt11; String? preimage; - String? zapRequestJson; String? recipient; String? eventId; - String? zapContent; + String? comment; String? sender; String? anon; - ZapReceipt( - this.paidAt, - this.pubKey, - this.bolt11, - this.preimage, - this.zapRequestJson, - this.recipient, - this.eventId, - this.zapContent, - this.sender); - ZapReceipt.fromEvent(Nip01Event event) { + String? zapRequestJson; if (event.kind == 9735) { for (var tag in event.tags) { if (tag[0] == 'bolt11') bolt11 = tag[1]; @@ -39,15 +33,25 @@ class ZapReceipt { if (tag[0] == 'P') sender = tag[1]; } paidAt = event.createdAt; - if (zapRequestJson != null) { - try { - Map map = jsonDecode(zapRequestJson!); - zapContent = map['content']; - sender = map['pubkey']; - } catch (_) { - zapContent = ''; + if (zapRequestJson != null && zapRequestJson.isNotEmpty) { + Nip01Event event = Nip01Event.fromJson(jsonDecode(zapRequestJson)); + comment = event.content; + sender = event.pubKey; + String? amountString = event.getFirstTag('amount'); + if (amountString != null && amountString.isNotEmpty) { + try { + double? amount = double.tryParse(amountString); + if (amount != null) { + amountSats = (amount / 1000).round(); + } + } catch (e) { + Logger.log.w(e); + } } } + if (amountSats == null && bolt11 != null) { + amountSats = Lnurl.getAmountFromBolt11(bolt11!); + } List? splitStrings = anon?.split('_'); if (splitStrings != null && splitStrings.length == 2) { // /// recipient decrypt @@ -94,11 +98,10 @@ class ZapReceipt { // - 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). // - The invoiceAmount contained in the bolt11 tag of the zap receipt MUST equal the amount tag of the zap request (if present). // - The lnurl tag of the zap request (if present) SHOULD equal the recipient's lnurl. - // - SHA256(description) MUST match the description hash in the bolt11 invoice. } @override String toString() { - return 'ZapReceipt(paidAt: $paidAt, pubKey: $pubKey, bolt11: $bolt11, preimage: $preimage, description: $zapRequestJson, recipient: $recipient, eventId: $eventId, zapContent: $zapContent, sender: $sender, anon: $anon)'; + return 'ZapReceipt(paidAt: $paidAt, pubKey: $pubKey, bolt11: $bolt11, preimage: $preimage, amount: $amountSats, recipient: $recipient, eventId: $eventId, comment: $comment, sender: $sender, anon: $anon)'; } } diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart index 1f1c5e9ae..27d815993 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart @@ -1,8 +1,10 @@ import 'package:ndk/domain_layer/entities/nip_01_event.dart'; +/// Zap Request class ZapRequest extends Nip01Event { static const int KIND = 9734; + /// Zap Request ZapRequest( {required super.pubKey, required super.tags, required super.content}) : super(kind: KIND); diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index ba4ce289a..2eb65217b 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -105,6 +105,23 @@ class Zaps { return ZapResponse(error: e.toString()); } } + + /// fetch all zap receipts matching given pubKey and optional event id, in sats + Future> fetchZappedReceipts({required String pubKey, String? eventId, Duration? timeout} ) async { + NdkResponse? response = _requests.query(filters: [ + eventId!=null? + Filter( + kinds: [ZapReceipt.KIND], + eTags: [eventId], + pTags: [pubKey]) + : + Filter( + kinds: [ZapReceipt.KIND], + pTags: [pubKey]) + ], timeout: timeout?? Duration(seconds: 20)); + List events = await response.future; + return events.map((event) => ZapReceipt.fromEvent(event)).toList(); + } } /// zap response From 36c606245ce64cb3fe9f02fd2a4d0e8fad03ae79 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 15:25:02 +0100 Subject: [PATCH 26/46] parameterize timeout of nwc requests --- packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart index f674195dc..1a661a086 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart @@ -203,7 +203,7 @@ class Nwc { } Future _executeRequest( - NwcConnection connection, NwcRequest request) async { + NwcConnection connection, NwcRequest request, {Duration? timeout}) async { if (connection.permissions.contains(request.method.name)) { var json = request.toMap(); var content = jsonEncode(json); @@ -226,7 +226,7 @@ class Nwc { Completer completer = Completer(); _inflighRequests[event.id] = completer; - _inflighRequestTimers[event.id] = Timer(Duration(seconds: 3), () { + _inflighRequestTimers[event.id] = Timer(timeout??Duration(seconds: 5), () { if (!completer.isCompleted) { final error = "Timed out while executing NWC request ${request.method.name} with relay ${connection.uri.relay}"; @@ -273,9 +273,9 @@ class Nwc { /// Does a `pay_invoice` request Future payInvoice(NwcConnection connection, - {required String invoice}) async { + {required String invoice, Duration? timeout}) async { return _executeRequest( - connection, PayInvoiceRequest(invoice: invoice)); + connection, PayInvoiceRequest(invoice: invoice), timeout: timeout); } /// Does a `lookup_invoice` request From d3c777cff0f89f2f975353a17b2e908b3d63546a Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 18:31:34 +0100 Subject: [PATCH 27/46] make waiting for zap receipt a subscription since there might be delay in the zapper to publish the receipt --- .../ndk/example/zaps/receipts_for_event.dart | 4 -- packages/ndk/example/zaps/zap.dart | 13 ++-- .../lib/domain_layer/usecases/zaps/zaps.dart | 67 ++++++++++--------- 3 files changed, 43 insertions(+), 41 deletions(-) diff --git a/packages/ndk/example/zaps/receipts_for_event.dart b/packages/ndk/example/zaps/receipts_for_event.dart index 6164cd5e4..be5d8b4f3 100644 --- a/packages/ndk/example/zaps/receipts_for_event.dart +++ b/packages/ndk/example/zaps/receipts_for_event.dart @@ -16,10 +16,6 @@ void main() async { receipts .sort((a, b) => (b.amountSats ?? 0).compareTo(a.amountSats ?? 0)); - // Sort profileReceipts by amountSats in descending order - receipts - .sort((a, b) => (b.amountSats ?? 0).compareTo(a.amountSats ?? 0)); - int eventSum = 0; for (var receipt in receipts) { String? sender; diff --git a/packages/ndk/example/zaps/zap.dart b/packages/ndk/example/zaps/zap.dart index b7b281ed3..f305cc58e 100644 --- a/packages/ndk/example/zaps/zap.dart +++ b/packages/ndk/example/zaps/zap.dart @@ -12,9 +12,12 @@ import 'package:ndk/shared/nips/nip01/key_pair.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(); + final ndk = Ndk(NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + logLevel: Logger.logLevels.info)); - // You need an NWC_URI env var or to replace with your NWC uri connection + // 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); KeyPair key = Bip340.generatePrivateKey(); @@ -27,9 +30,9 @@ void main() async { lnurl: lnurl, comment: comment, amountSats: amount, - + fetchZapReceipt: true, signer: Bip340EventSigner(privateKey: key.privateKey, publicKey: key.publicKey), - relays: DEFAULT_BOOTSTRAP_RELAYS, + relays: ["wss://relay.damus.io"], pubKey: "30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177", eventId: "d7bc29fa3c55ac525a3d5f2021211edb672b58565225dec423479a0875feea9d" ); @@ -42,6 +45,8 @@ void main() async { ZapReceipt? receipt = await response.zapReceipt; if (receipt!=null) { print("Receipt : $receipt"); + } else { + print("No Receipt"); } } diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index 2eb65217b..155188f64 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -34,6 +34,7 @@ class Zaps { required NwcConnection nwcConnection, required String lnurl, required int amountSats, + bool fetchZapReceipt = false, EventSigner? signer, Iterable? relays, String? pubKey, @@ -67,36 +68,37 @@ class Zaps { PayInvoiceResponse payResponse = await _nwc.payInvoice(nwcConnection, invoice: invoice); if (payResponse.preimage.isNotEmpty && payResponse.errorCode == null) { - NdkResponse? receiptResponse; - if (zapRequest != null) { - // if it's a zap, try to find the zap receipt - receiptResponse = _requests.query(explicitRelays: relays, filters: [ - Filter( - kinds: [ZapReceipt.KIND], - eTags: [eventId!], - pTags: [pubKey!]) - ]); - } ZapResponse zapResponse = ZapResponse( - zapReceiptResponse: receiptResponse, payInvoiceResponse: payResponse); - if (receiptResponse != null) { - receiptResponse.future.then((events) { - Nip01Event? event = events.where((event) { - ZapReceipt receipt = ZapReceipt.fromEvent(event); - return receipt.bolt11 == invoice; - }).firstOrNull; - if (event!=null) { + if (zapRequest != null && fetchZapReceipt) { + // if it's a zap, try to find the zap receipt + zapResponse.receiptResponse = _requests.subscription(filters: [ + eventId != null + ? Filter(kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey!]) + : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey!]) + ]); + // TODO make timeout waiting for receipt parameterizable somehow + zapResponse.zapReceiptResponse!.stream.timeout(Duration(seconds: 30)).listen((event) { + String? bolt11 = event.getFirstTag("bolt11"); + String? preimage = event.getFirstTag("preimage"); + 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)) { zapResponse.emitReceipt(receipt); - return; + } else { + Logger.log.w("Zap Receipt invalid: $receipt"); } - Logger.log.w("Zap Receipt invalid: $receipt"); + _requests.closeSubscription( + zapResponse.zapReceiptResponse!.requestId); } - zapResponse.emitReceipt(null); + }).onError((error) { + _requests.closeSubscription( + zapResponse.zapReceiptResponse!.requestId); + Logger.log.w("timed out waiting for zap receipt for invoice $invoice"); }); + } else { + zapResponse.emitReceipt(null); } return zapResponse; } @@ -107,18 +109,13 @@ class Zaps { } /// fetch all zap receipts matching given pubKey and optional event id, in sats - Future> fetchZappedReceipts({required String pubKey, String? eventId, Duration? timeout} ) async { - NdkResponse? response = _requests.query(filters: [ - eventId!=null? - Filter( - kinds: [ZapReceipt.KIND], - eTags: [eventId], - pTags: [pubKey]) - : - Filter( - kinds: [ZapReceipt.KIND], - pTags: [pubKey]) - ], timeout: timeout?? Duration(seconds: 20)); + Future> fetchZappedReceipts( + {required String pubKey, String? eventId, Duration? timeout}) async { + NdkResponse? response = _requests.subscription(filters: [ + eventId != null + ? Filter(kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey]) + : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey]) + ]); List events = await response.future; return events.map((event) => ZapReceipt.fromEvent(event)).toList(); } @@ -146,4 +143,8 @@ class ZapResponse { /// ZapResponse({this.zapReceiptResponse, this.payInvoiceResponse, this.error}); + + set receiptResponse(NdkResponse receiptResponse) { + zapReceiptResponse = receiptResponse; + } } From 3cb8452c9fa6b761e124447733a8e8e77e52011c Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 18:35:03 +0100 Subject: [PATCH 28/46] make waiting for zap receipt a subscription since there might be delay in the zapper to publish the receipt --- packages/ndk/example/zaps/zap.dart | 2 +- .../ndk/lib/domain_layer/usecases/zaps/zaps.dart | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/ndk/example/zaps/zap.dart b/packages/ndk/example/zaps/zap.dart index f305cc58e..7e12b4658 100644 --- a/packages/ndk/example/zaps/zap.dart +++ b/packages/ndk/example/zaps/zap.dart @@ -15,7 +15,7 @@ void main() async { final ndk = Ndk(NdkConfig( eventVerifier: Bip340EventVerifier(), cache: MemCacheManager(), - logLevel: Logger.logLevels.info)); + logLevel: Logger.logLevels.trace)); // You need an NWC_URI env var or to replace with your NWC uri connection final nwcUri = Platform.environment['NWC_URI']!; diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index 155188f64..f5849c3ac 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -78,7 +78,13 @@ class Zaps { : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey!]) ]); // TODO make timeout waiting for receipt parameterizable somehow - zapResponse.zapReceiptResponse!.stream.timeout(Duration(seconds: 30)).listen((event) { + final timeout = Timer(Duration(seconds: 30), () { + _requests.closeSubscription( + zapResponse.zapReceiptResponse!.requestId); + Logger.log.w("timed out waiting for zap receipt for invoice $invoice"); + }); + + zapResponse.zapReceiptResponse!.stream.listen((event) { String? bolt11 = event.getFirstTag("bolt11"); String? preimage = event.getFirstTag("preimage"); if (bolt11!=null && bolt11 == invoice || preimage!=null && preimage==payResponse.preimage) { @@ -89,13 +95,10 @@ class Zaps { } else { Logger.log.w("Zap Receipt invalid: $receipt"); } + timeout.cancel(); _requests.closeSubscription( zapResponse.zapReceiptResponse!.requestId); } - }).onError((error) { - _requests.closeSubscription( - zapResponse.zapReceiptResponse!.requestId); - Logger.log.w("timed out waiting for zap receipt for invoice $invoice"); }); } else { zapResponse.emitReceipt(null); From d596d35616a97f243ed16f94f32f4204a71b3864 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 21:15:11 +0100 Subject: [PATCH 29/46] timeout on payments 10s --- .../ndk/lib/domain_layer/usecases/zaps/zaps.dart | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index f5849c3ac..8a0f07a75 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -66,7 +66,7 @@ class Zaps { } try { PayInvoiceResponse payResponse = - await _nwc.payInvoice(nwcConnection, invoice: invoice); + await _nwc.payInvoice(nwcConnection, invoice: invoice, timeout: Duration(seconds: 10)); if (payResponse.preimage.isNotEmpty && payResponse.errorCode == null) { ZapResponse zapResponse = ZapResponse( payInvoiceResponse: payResponse); @@ -114,7 +114,7 @@ class Zaps { /// fetch all zap receipts matching given pubKey and optional event id, in sats Future> fetchZappedReceipts( {required String pubKey, String? eventId, Duration? timeout}) async { - NdkResponse? response = _requests.subscription(filters: [ + NdkResponse? response = _requests.query(timeout: timeout??Duration(seconds:10), filters: [ eventId != null ? Filter(kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey]) : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey]) @@ -122,6 +122,17 @@ class Zaps { List events = await response.future; return events.map((event) => ZapReceipt.fromEvent(event)).toList(); } + + /// fetch all zap receipts matching given pubKey and optional event id, in sats + NdkResponse subscribeToZapReceipts( + {required String pubKey, String? eventId}) { + NdkResponse? response = _requests.subscription(filters: [ + eventId != null + ? Filter(kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey]) + : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey]) + ]); + return response; + } } /// zap response From dfd0fc65286abaf15524930314bb20fdbdb4dec8 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 21:49:21 +0100 Subject: [PATCH 30/46] use opensats for receipts/zaps --- packages/ndk/example/zaps/receipts_for_event.dart | 4 ++-- packages/ndk/example/zaps/zap.dart | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ndk/example/zaps/receipts_for_event.dart b/packages/ndk/example/zaps/receipts_for_event.dart index be5d8b4f3..c685077cf 100644 --- a/packages/ndk/example/zaps/receipts_for_event.dart +++ b/packages/ndk/example/zaps/receipts_for_event.dart @@ -8,9 +8,9 @@ void main() async { print("fetching zap receipts for single event "); final receipts = await ndk.zaps.fetchZappedReceipts( pubKey: - "30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177", + "787338757fc25d65cd929394d5e7713cf43638e8d259e8dcf5c73b834eb851f2", eventId: - "d7bc29fa3c55ac525a3d5f2021211edb672b58565225dec423479a0875feea9d"); + "906a0c5920b59e5754d0df5164bfea2a8d48ce5d73beaa1e854b3e6725e3288a"); // Sort eventReceipts by amountSats in descending order receipts diff --git a/packages/ndk/example/zaps/zap.dart b/packages/ndk/example/zaps/zap.dart index 7e12b4658..3b09559c5 100644 --- a/packages/ndk/example/zaps/zap.dart +++ b/packages/ndk/example/zaps/zap.dart @@ -22,7 +22,7 @@ void main() async { final connection = await ndk.nwc.connect(nwcUri); KeyPair key = Bip340.generatePrivateKey(); final amount = 21; - final lnurl = "fmar@getalby.com"; + final lnurl = "opensats@vlt.ge"; final comment = "enjoy this zap from NDK"; ZapResponse response = await ndk.zaps.zap( @@ -33,8 +33,8 @@ void main() async { fetchZapReceipt: true, signer: Bip340EventSigner(privateKey: key.privateKey, publicKey: key.publicKey), relays: ["wss://relay.damus.io"], - pubKey: "30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177", - eventId: "d7bc29fa3c55ac525a3d5f2021211edb672b58565225dec423479a0875feea9d" + pubKey: "787338757fc25d65cd929394d5e7713cf43638e8d259e8dcf5c73b834eb851f2", + eventId: "906a0c5920b59e5754d0df5164bfea2a8d48ce5d73beaa1e854b3e6725e3288a" ); if (response.payInvoiceResponse!=null && response.payInvoiceResponse!.preimage.isNotEmpty) { From aa4758565a53001c61fc016f49bba1eace027a42 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 21:53:23 +0100 Subject: [PATCH 31/46] misc --- packages/ndk/example/zaps/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ndk/example/zaps/README.md b/packages/ndk/example/zaps/README.md index 9a859b2a9..5acd8025a 100644 --- a/packages/ndk/example/zaps/README.md +++ b/packages/ndk/example/zaps/README.md @@ -1,6 +1,6 @@ # running the examples -You need a `nostr+walletconnect://...` uri from your NWC wallet service provider. +For zapping example `zap.dart` you need a `nostr+walletconnect://...` uri from your NWC wallet service provider. see https://github.com/getAlby/awesome-nwc for more info how to get a wallet supporting NWC From 4f83859986d1a6b477bf5cc543fa13296572b05b Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 22:16:24 +0100 Subject: [PATCH 32/46] 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 67b137294..b2abfa4e9 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 000000000..44ae11300 --- /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 a6ccd07ff..a1ba1d3f0 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 8a0f07a75..91993f46d 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 c465b7209..7dc0d6b8b 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 000000000..b11ce3243 --- /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); + }); + }); +} From cdced086faf7c055e964c33ef5c854ef44391279 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 22:23:23 +0100 Subject: [PATCH 33/46] chanege fetchZappedReceipts to Stream --- packages/ndk/example/zaps/receipts_for_event.dart | 2 +- packages/ndk/example/zaps/receipts_for_profile.dart | 2 +- packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/ndk/example/zaps/receipts_for_event.dart b/packages/ndk/example/zaps/receipts_for_event.dart index c685077cf..a34062fed 100644 --- a/packages/ndk/example/zaps/receipts_for_event.dart +++ b/packages/ndk/example/zaps/receipts_for_event.dart @@ -10,7 +10,7 @@ void main() async { pubKey: "787338757fc25d65cd929394d5e7713cf43638e8d259e8dcf5c73b834eb851f2", eventId: - "906a0c5920b59e5754d0df5164bfea2a8d48ce5d73beaa1e854b3e6725e3288a"); + "906a0c5920b59e5754d0df5164bfea2a8d48ce5d73beaa1e854b3e6725e3288a").toList(); // Sort eventReceipts by amountSats in descending order receipts diff --git a/packages/ndk/example/zaps/receipts_for_profile.dart b/packages/ndk/example/zaps/receipts_for_profile.dart index b9438c8c8..b9d10a882 100644 --- a/packages/ndk/example/zaps/receipts_for_profile.dart +++ b/packages/ndk/example/zaps/receipts_for_profile.dart @@ -8,7 +8,7 @@ void main() async { print("fetching zap receipts for profile "); final profileReceipts = await ndk.zaps.fetchZappedReceipts( pubKey: "30782a8323b7c98b172c5a2af7206bb8283c655be6ddce11133611a03d5f1177", - ); + ).toList(); // Sort profileReceipts by amountSats in descending order profileReceipts diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index 91993f46d..e565d1359 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -205,16 +205,17 @@ class Zaps { } /// fetch all zap receipts matching given pubKey and optional event id, in sats - Future> fetchZappedReceipts( - {required String pubKey, String? eventId, Duration? timeout}) async { + Stream fetchZappedReceipts( + {required String pubKey, String? eventId, Duration? timeout}) { NdkResponse? response = _requests.query(timeout: timeout??Duration(seconds:10), filters: [ eventId != null ? Filter(kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey]) : 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(); + return response.stream.map((event) => ZapReceipt.fromEvent(event)); + // List events = await response.future; + // return events.map((event) => ZapReceipt.fromEvent(event)).toList(); } /// fetch all zap receipts matching given pubKey and optional event id, in sats From 553665554978454c07655bab7d09d31fcdb9b029 Mon Sep 17 00:00:00 2001 From: Fmar Date: Sun, 22 Dec 2024 23:51:31 +0100 Subject: [PATCH 34/46] clean --- packages/amber/pubspec.yaml | 1 - packages/ndk/README.md | 2 +- packages/ndk/example/zaps/zap.dart | 1 - .../websocket_client_nostr_transport_factory.dart | 1 - packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart | 2 -- packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart | 6 ++---- packages/ndk/lib/presentation_layer/ndk.dart | 4 ++-- packages/ndk/test/relays/relay_manager_test.dart | 2 -- packages/ndk/test/usecases/lnurl/lnurl_test.dart | 3 --- packages/ndk/test/usecases/zaps/zaps_test.dart | 1 - 10 files changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/amber/pubspec.yaml b/packages/amber/pubspec.yaml index 268a13206..4b1a9aa3d 100644 --- a/packages/amber/pubspec.yaml +++ b/packages/amber/pubspec.yaml @@ -20,7 +20,6 @@ dependencies: git: url: https://github.com/chebizarro/flutter-signer-plugin ref: baa5b2fcf7ba8db606a76794582348a1403438b0 - # signer_plugin: ^1.0.0 dependency_overrides: ndk: diff --git a/packages/ndk/README.md b/packages/ndk/README.md index 71405c4f1..c2f31d8ae 100644 --- a/packages/ndk/README.md +++ b/packages/ndk/README.md @@ -170,8 +170,8 @@ await for (final event in response.stream) { - [x] Lists ([NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md)) - [x] Relay List Metadata ([NIP-65](https://github.com/nostr-protocol/nips/blob/master/65.md)) - [x] Wallet Connect API ([NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md)) +- [X] Zaps ([NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md)) - [ ] Bech Encoding support ([NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md)) -- [ ] Zaps (private, public, anon, non-zap) ([NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md)) - [ ] Badges ([NIP-58](https://github.com/nostr-protocol/nips/blob/master/58.md)) ## Performance diff --git a/packages/ndk/example/zaps/zap.dart b/packages/ndk/example/zaps/zap.dart index 3b09559c5..22cb3cbc3 100644 --- a/packages/ndk/example/zaps/zap.dart +++ b/packages/ndk/example/zaps/zap.dart @@ -2,7 +2,6 @@ import 'dart:io'; -import 'package:ndk/config/bootstrap_relays.dart'; import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; import 'package:ndk/domain_layer/usecases/zaps/zaps.dart'; import 'package:ndk/ndk.dart'; diff --git a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart index 5459698b0..669284956 100644 --- a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart +++ b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart @@ -3,7 +3,6 @@ import 'package:web_socket_client/web_socket_client.dart'; import '../../../domain_layer/repositories/nostr_transport.dart'; import '../../../shared/helpers/relay_helper.dart'; -import '../../../shared/logger/logger.dart'; import '../../data_sources/websocket_client.dart'; class WebSocketClientNostrTransportFactory implements NostrTransportFactory { diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart index b2abfa4e9..855157944 100644 --- a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart @@ -1,8 +1,6 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import 'package:ndk/domain_layer/repositories/event_signer.dart'; -import 'package:ndk/domain_layer/usecases/zaps/zap_request.dart'; import '../../../shared/logger/logger.dart'; import 'lnurl_response.dart'; diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index e565d1359..0de3bc3f9 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import 'package:http/http.dart'; +import 'package:http/http.dart' as http; 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'; @@ -9,7 +9,6 @@ import 'package:ndk/domain_layer/usecases/zaps/zap_request.dart'; import '../../../shared/logger/logger.dart'; import '../../entities/filter.dart'; -import '../../entities/nip_01_event.dart'; import '../../entities/request_response.dart'; import '../../repositories/event_signer.dart'; import '../lnurl/lnurl.dart'; @@ -17,13 +16,12 @@ 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; final Nwc _nwc; + /// . Zaps({ required Requests requests, required Nwc nwc, diff --git a/packages/ndk/lib/presentation_layer/ndk.dart b/packages/ndk/lib/presentation_layer/ndk.dart index 13b92500c..6900da532 100644 --- a/packages/ndk/lib/presentation_layer/ndk.dart +++ b/packages/ndk/lib/presentation_layer/ndk.dart @@ -93,11 +93,11 @@ class Ndk { VerifyNip05 get nip05 => _initialization.verifyNip05; /// Nostr Wallet connect - @experimental + @experimental // needs more docs & tests Nwc get nwc => _initialization.nwc; /// Zaps - @experimental + @experimental // needs more docs & tests Zaps get zaps => _initialization.zaps; /// Close all transports on relay manager diff --git a/packages/ndk/test/relays/relay_manager_test.dart b/packages/ndk/test/relays/relay_manager_test.dart index ea12e32a4..b878e27b9 100644 --- a/packages/ndk/test/relays/relay_manager_test.dart +++ b/packages/ndk/test/relays/relay_manager_test.dart @@ -3,11 +3,9 @@ import 'dart:async'; import 'package:ndk/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart'; -import 'package:ndk/domain_layer/entities/global_state.dart'; import 'package:ndk/domain_layer/usecases/relay_manager.dart'; import 'package:ndk/entities.dart'; import 'package:test/test.dart'; -import 'package:ndk/data_layer/repositories/nostr_transport/websocket_nostr_transport_factory.dart'; import '../mocks/mock_relay.dart'; diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.dart index 7dc0d6b8b..1b18f42ca 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.dart @@ -4,8 +4,6 @@ import 'package:http/http.dart' as http; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:ndk/domain_layer/usecases/lnurl/lnurl.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_test.mocks.dart'; @@ -14,7 +12,6 @@ import 'lnurl_test.mocks.dart'; @GenerateMocks([http.Client]) void main() { group('Lnurl', () { - KeyPair key = Bip340.generatePrivateKey(); test('getLud16LinkFromLud16 returns correct URL', () { expect( diff --git a/packages/ndk/test/usecases/zaps/zaps_test.dart b/packages/ndk/test/usecases/zaps/zaps_test.dart index b11ce3243..83d0df4bd 100644 --- a/packages/ndk/test/usecases/zaps/zaps_test.dart +++ b/packages/ndk/test/usecases/zaps/zaps_test.dart @@ -6,7 +6,6 @@ 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'; From 41f49c2bbe62e7a5f4dab5a90410a4e07989528a Mon Sep 17 00:00:00 2001 From: Fmar Date: Mon, 23 Dec 2024 00:02:40 +0100 Subject: [PATCH 35/46] remove nip55 signer --- .../signers/nip55_event_signer.dart | 59 ------------------- packages/amber/pubspec.lock | 11 +--- packages/amber/pubspec.yaml | 4 -- 3 files changed, 1 insertion(+), 73 deletions(-) delete mode 100644 packages/amber/lib/data_layer/repositories/signers/nip55_event_signer.dart diff --git a/packages/amber/lib/data_layer/repositories/signers/nip55_event_signer.dart b/packages/amber/lib/data_layer/repositories/signers/nip55_event_signer.dart deleted file mode 100644 index fcafc7343..000000000 --- a/packages/amber/lib/data_layer/repositories/signers/nip55_event_signer.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'dart:convert'; - -import 'package:ndk/ndk.dart'; -import 'package:ndk/shared/nips/nip19/nip19.dart'; -import 'package:signer_plugin/signer_plugin.dart'; - -class Nip55EventSigner implements EventSigner { - SignerPlugin _signerPlugin = SignerPlugin(); - bool isAvailable = false; - - final String publicKey; - - /// get a amber event signer - Nip55EventSigner({ - required this.publicKey, - }); - - @override - Future sign(Nip01Event event) async { - final npub = publicKey.startsWith('npub') - ? publicKey - : Nip19.encodePubKey(publicKey); - final signedMessage = - await _signerPlugin.signEvent(jsonEncode(event.toJson()), "", npub); - final signedEvent = jsonDecode(signedMessage['event']); - - event.sig = signedEvent['sig']; - } - - @override - String getPublicKey() { - return publicKey; - } - - @override - Future decrypt(String msg, String destPubKey, {String? id}) async { - final npub = publicKey.startsWith('npub') - ? publicKey - : Nip19.encodePubKey(publicKey); - Map map = - await _signerPlugin.nip04Decrypt(msg, id!, npub, destPubKey); - return map['signature']; - } - - @override - Future encrypt(String msg, String destPubKey, {String? id}) async { - final npub = publicKey.startsWith('npub') - ? publicKey - : Nip19.encodePubKey(publicKey); - Map map = - await _signerPlugin.nip04Encrypt(msg, id!, npub, destPubKey); - return map['signature']; - } - - @override - bool canSign() { - return publicKey.isNotEmpty; - } -} diff --git a/packages/amber/pubspec.lock b/packages/amber/pubspec.lock index 567c2b51e..0d98bbd9f 100644 --- a/packages/amber/pubspec.lock +++ b/packages/amber/pubspec.lock @@ -558,15 +558,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" - signer_plugin: - dependency: "direct main" - description: - path: "." - ref: baa5b2fcf7ba8db606a76794582348a1403438b0 - resolved-ref: baa5b2fcf7ba8db606a76794582348a1403438b0 - url: "https://github.com/chebizarro/flutter-signer-plugin" - source: git - version: "0.0.1" sky_engine: dependency: transitive description: flutter @@ -733,5 +724,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.5.4 <4.0.0" + dart: ">=3.5.0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/packages/amber/pubspec.yaml b/packages/amber/pubspec.yaml index 4b1a9aa3d..0b6329648 100644 --- a/packages/amber/pubspec.yaml +++ b/packages/amber/pubspec.yaml @@ -16,10 +16,6 @@ dependencies: hex: ^0.2.0 plugin_platform_interface: ^2.1.8 ndk: ^0.2.0-dev002 - signer_plugin: - git: - url: https://github.com/chebizarro/flutter-signer-plugin - ref: baa5b2fcf7ba8db606a76794582348a1403438b0 dependency_overrides: ndk: From 899365cc820b792d0146bbbd01c5a96d788beaef Mon Sep 17 00:00:00 2001 From: Fmar Date: Mon, 23 Dec 2024 00:03:37 +0100 Subject: [PATCH 36/46] clean --- packages/amber/pubspec.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/amber/pubspec.yaml b/packages/amber/pubspec.yaml index 0b6329648..c05108c6d 100644 --- a/packages/amber/pubspec.yaml +++ b/packages/amber/pubspec.yaml @@ -17,8 +17,8 @@ dependencies: plugin_platform_interface: ^2.1.8 ndk: ^0.2.0-dev002 -dependency_overrides: - ndk: +#dependency_overrides: +# ndk: # path: ../ndk dev_dependencies: From c4ce884f2ac615ce3c8c8a1f02f798d96c6ed62d Mon Sep 17 00:00:00 2001 From: Fmar Date: Mon, 23 Dec 2024 00:05:45 +0100 Subject: [PATCH 37/46] clean --- .../usecases/old_relay_manager.dart | 511 ------------------ 1 file changed, 511 deletions(-) delete mode 100644 packages/ndk/lib/domain_layer/usecases/old_relay_manager.dart diff --git a/packages/ndk/lib/domain_layer/usecases/old_relay_manager.dart b/packages/ndk/lib/domain_layer/usecases/old_relay_manager.dart deleted file mode 100644 index 1dc13286f..000000000 --- a/packages/ndk/lib/domain_layer/usecases/old_relay_manager.dart +++ /dev/null @@ -1,511 +0,0 @@ -// ignore_for_file: avoid_print - -import 'dart:async'; -import 'dart:convert'; -import 'dart:core'; -import 'dart:developer' as developer; - -import 'package:ndk/domain_layer/entities/connection_source.dart'; - -import '../../config/bootstrap_relays.dart'; -import '../entities/event_filter.dart'; -import '../../shared/helpers/relay_helper.dart'; -import '../../shared/logger/logger.dart'; -import '../entities/global_state.dart'; -import '../entities/nip_01_event.dart'; -import '../entities/pubkey_mapping.dart'; -import '../entities/read_write_marker.dart'; -import '../entities/relay.dart'; -import '../entities/relay_info.dart'; -import '../entities/request_state.dart'; -import '../repositories/event_signer.dart'; -import '../repositories/event_verifier.dart'; -import '../repositories/nostr_transport.dart'; - -class OldRelayManager { - static const int DEFAULT_STREAM_IDLE_TIMEOUT = 5; - static const int DEFAULT_WEB_SOCKET_CONNECT_TIMEOUT = 3; - static const int DEFAULT_BEST_RELAYS_MIN_COUNT = 2; - static const int FAIL_RELAY_CONNECT_TRY_AFTER_SECONDS = 60; - static const int WEB_SOCKET_PING_INTERVAL_SECONDS = 3; - - late List bootstrapRelays; - late EventVerifier eventVerifier; - late GlobalState globalState; - - final NostrTransportFactory nostrTransportFactory; - - /// Global relay registry by url - Map relays = {}; - - /// Global transport registry by url - Map transports = {}; - - List blockedRelays = []; - - int get blockedRelaysCount => blockedRelays.length; - - List eventFilters = []; - - bool allowReconnectRelays = true; - - final Completer _seedRelaysCompleter = Completer(); - - get seedRelaysConnected => _seedRelaysCompleter.future; - - OldRelayManager({ - required this.nostrTransportFactory, - List? bootstrapRelays, - GlobalState? globalState, - }) { - this.bootstrapRelays = bootstrapRelays ?? DEFAULT_BOOTSTRAP_RELAYS; - this.globalState = globalState ?? GlobalState(); - } - - // ==================================================================================================================== - - /// This will initialize the manager with bootstrap relays. - /// If you don't give any, will use some predefined - Future connect( - {Iterable urls = DEFAULT_BOOTSTRAP_RELAYS}) async { - bootstrapRelays = []; - for (String url in urls) { - String? clean = cleanRelayUrl(url); - if (clean != null) { - bootstrapRelays.add(clean); - } - } - // if (bootstrapRelays.isEmpty) { - // bootstrapRelays = DEFAULT_BOOTSTRAP_RELAYS; - // } - await Future.wait(urls.map((url) => connectRelay(url)).toList()) - .whenComplete(() { - if (!_seedRelaysCompleter.isCompleted) { - _seedRelaysCompleter.complete(); - } - }); - } - - void send(String url, dynamic data) { - transports[url]!.send(data); - Logger.log.d("send message to $url: $data"); - } - - Future closeTransport(url) async { - NostrTransport? transport = transports[url]; - if (transport != null) { - Logger.log.d("Disconnecting $url..."); - transports.remove(url); - return transport.close().timeout(const Duration(seconds: 3), - onTimeout: () { - Logger.log.w("timeout while trying to close socket $url"); - }); - } - } - - Future closeAllTransports() async { - Iterable keys = transports.keys.toList(); - try { - await Future.wait(keys.map((url) => closeTransport(url))); - } catch (e) { - print(e); - } - } - - bool isWebSocketOpen(String url) { - NostrTransport? transport = transports[cleanRelayUrl(url)]; - return transport != null && transport.isOpen(); - } - - bool isRelayConnecting(String url) { - Relay? relay = relays[url]; - return relay != null && relay.connecting; - } - - /// Connect a new relay - Future connectRelay(String dirtyUrl, - {int connectTimeout = DEFAULT_WEB_SOCKET_CONNECT_TIMEOUT}) async { - String? url = cleanRelayUrl(dirtyUrl); - if (url == null) { - return false; - } - if (blockedRelays.contains(url)) { - return false; - } - try { - if (relays[url] == null) { - relays[url] = - Relay(url: url, connectionSource: ConnectionSource.UNKNOWN); - } - relays[url]!.tryingToConnect(); - if (url.startsWith("wss://brb.io")) { - relays[url]!.failedToConnect(); -// relays[url]!.stats.connectionErrors++; - return false; - } - - transports[url] = nostrTransportFactory(url, null); - await transports[url]!.ready.timeout(Duration(seconds: connectTimeout), - onTimeout: () { - print("timed out connecting to relay $url"); - }); - - startListeningToSocket(url); - - developer.log("connected to relay: $url"); - relays[url]!.succeededToConnect(); - // relays[url]!.stats.connections++; - getRelayInfo(url); - return true; - } catch (e) { - print("!! could not connect to $url -> $e"); - transports.remove(url); - } - relays[url]!.failedToConnect(); - //relays[url]!.stats.connectionErrors++; - return false; - } - - void startListeningToSocket(String url) { - transports[url]!.listen((message) { - _handleIncommingMessage(message, url); - }, onError: (error) async { - /// todo: handle this better, should clean subscription stuff - // relays[url]!.stats.connectionErrors++; - print("onError $url on listen $error"); - throw Exception("Error in socket"); - }, onDone: () { - if (allowReconnectRelays && transports[url] != null) { - print( - "onDone $url on listen (close: ${transports[url]!.closeCode()} ${transports[url]!.closeReason()}), trying to reconnect"); - if (isWebSocketOpen(url)) { - print("closing $url webSocket"); - transports[url]!.close(); - print("closed $url. Reconnecting"); - } - reconnectRelay(url).then((connected) { - if (connected) { - _reSubscribeInFlightSubscriptions(url); - } - }); - } - }); - } - - List getConnectedRelays(Iterable urls) { - return urls - .where((url) => isRelayConnected(url)) - .map((url) => relays[url]!) - .toList(); - } - - Future broadcastEvent( - Nip01Event event, Iterable relays, EventSigner signer) async { - await signer.sign(event); - await Future.wait(relays.map((url) => broadcastSignedEvent(event, url))); - } - - Future broadcastSignedEvent(Nip01Event event, String url) async { - if (isWebSocketOpen(url) && (!blockedRelays.contains(url))) { - try { - Logger.log.i( - "🛈 BROADCASTING to $url : kind: ${event.kind} author: ${event.pubKey}"); - var webSocket = transports[url]; - if (webSocket != null) { - send(url, jsonEncode(["EVENT", event.toJson()])); - } - } catch (e) { - print("ERROR BROADCASTING $url -> $e"); - } - } - } - - void removeInFlightRequest(RequestState state) async { - return removeInFlightRequestById(state.id); - } - - void closeSubscription(String subscriptionId) async { - RequestState? state = globalState.inFlightRequests[subscriptionId]; - if (state != null) { - for (var url in state.requests.keys) { - sendCloseToRelay(url, state.id); - } - await removeInFlightRequestById(subscriptionId); - } - } - - Future removeInFlightRequestById(String id) async { - RequestState? state = globalState.inFlightRequests[id]; - if (state != null) { - try { - await state.close(); - } catch (e) { - Logger.log.e(e); - } - globalState.inFlightRequests.remove(id); - } - logActiveRequests(); - } - - // ===================================================================================== - - _handleIncommingMessage(dynamic message, String url) { - List eventJson = json.decode(message); - - if (eventJson[0] == 'OK') { - //nip 20 used to notify clients if an EVENT was successful - if (eventJson.length >= 2 && eventJson[2] == false) { - Logger.log.e("NOT OK from $url: $eventJson"); - } - return; - } - - if (eventJson[0] == 'NOTICE') { - Logger.log.w("NOTICE from $url: ${eventJson[1]}"); - logActiveRequests(); - } else if (eventJson[0] == 'EVENT') { - handleIncomingEvent(eventJson, url, message); - } else if (eventJson[0] == 'EOSE') { - handleEOSE(eventJson, url); - } else if (eventJson[0] == 'CLOSED') { - Logger.log.w( - " CLOSED subscription url: $url id: ${eventJson[1]} msg: ${eventJson.length > 2 ? eventJson[2] : ''}"); - globalState.inFlightRequests.remove(eventJson[1]); - } - // TODO - // if (eventJson[0] == 'AUTH') { - // log("AUTH: ${eventJson[1]}"); - // // nip 42 used to send authentication challenges - // return; - // } - // - // if (eventJson[0] == 'COUNT') { - // log("COUNT: ${eventJson[1]}"); - // // nip 45 used to send requested event counts to clients - // return; - // } - } - - void handleEOSE(List eventJson, String url) { - String id = eventJson[1]; - RequestState? state = globalState.inFlightRequests[id]; - if (state != null && state.request.closeOnEOSE) { - Logger.log.t( - "⛁ received EOSE from $url for REQ id $id, remaining requests from :${state.requests.keys} kind:${state.requests.values.first.filters.first.kinds}"); - RelayRequestState? request = state.requests[url]; - if (request != null) { - request.receivedEOSE = true; - closeIfAllEventsVerified(request, state, url); - } - } - return; - } - - void sendCloseToRelay(String url, String id) { - if (isWebSocketOpen(url)) { - try { - Relay? relay = getRelay(url); - if (relay != null) { -// relay.stats.activeRequests--; - } - send(url, jsonEncode(["CLOSE", id])); - } catch (e) { - print(e); - } - } - } - - void closeIfAllEventsVerified( - RelayRequestState request, RequestState state, String url) { - if (request.receivedEOSE) { - if (state.request.closeOnEOSE) { - sendCloseToRelay(url, state.id); - if (state.requests.isEmpty || state.didAllRequestsReceivedEOSE) { - removeInFlightRequest(state); - } - } - state.requests.remove(url); - } - } - - void handleIncomingEvent(List eventJson, String url, message) { - var id = eventJson[1]; - if (globalState.inFlightRequests[id] == null) { - Logger.log.w( - "RECEIVED EVENT from $url for id $id, not in globalState inFlightRequests"); - // send(url, jsonEncode(["CLOSE", id])); - return; - } - - Nip01Event event = Nip01Event.fromJson(eventJson[2]); - if (!filterEvent(event)) { - return; - } - // check signature is valid - // if (!event.isIdValid) { - // Logger.log.e("RECEIVED $id INVALID EVENT $event"); - // return; - // } - RequestState? state = globalState.inFlightRequests[id]; - if (state != null) { - RelayRequestState? request = state.requests[url]; - if (request != null) { - // request.eventIdsToBeVerified.add(event.id); - // eventVerifier.verify(event).then((validSig) { - // if (validSig) { - event.sources.add(url); - // event.validSig = true; - // if (relays[url] != null) { - // relays[url]!.incStatsByNewEvent(event, message.toString().codeUnits.length); - // } - if (state.networkController.isClosed) { - Logger.log.e( - "TRIED to add event to an already closed STREAM ${state.request.id} ${state.request.filters}"); - } else { - state.networkController.add(event); - } - // } else { - // Logger.log.f("INVALID EVENT SIGNATURE: $event"); - // } - // request.eventIdsToBeVerified.remove(event.id); - // closeIfAllEventsVerified(request, state, url); - // }); - } - } - return; - } - - Relay? getRelay(String url) { - Relay? r = relays[url]; - r ??= relays[cleanRelayUrl(url)]; - return r; - } - - // /// does relay support given nip - // bool doesRelaySupportNip(String url, int nip) { - // Relay? relay = relays[cleanRelayUrl(url)]; - // return relay != null && relay.supportsNip(nip); - // } - - // ===================================================================================== - - Map> allConnectedRelays(List pubKeys) { - Map> map = {}; - for (var relay in relays.keys) { - if (isWebSocketOpen(relay)) { - map[relay] = pubKeys - .map((pubKey) => PubkeyMapping( - pubKey: pubKey, rwMarker: ReadWriteMarker.readWrite)) - .toList(); - } - } - return map; - } - - bool isRelayConnected(String url) { - Relay? relay = relays[url]; - return relay != null && isWebSocketOpen(url); - } - - Future reconnectRelays(Iterable urls) async { - final startTime = DateTime.now(); - Logger.log.d("connecting ${urls.length} relays in parallel"); - List connected = - await Future.wait(urls.map((url) => reconnectRelay(url, force: true))); - final endTime = DateTime.now(); - final duration = endTime.difference(startTime); - Logger.log.d( - "CONNECTED ${connected.where((element) => element).length} , ${connected.where((element) => !element).length} FAILED, took ${duration.inMilliseconds} ms"); - } - - Future reconnectRelay(String url, {bool force = false}) async { - Relay? relay = getRelay(url); - if (allowReconnectRelays) { - NostrTransport? transport = transports[cleanRelayUrl(url)]; - if (transport != null) { - await transport.ready - .timeout(Duration(seconds: DEFAULT_WEB_SOCKET_CONNECT_TIMEOUT)) - .onError((error, stackTrace) { - print("error connecting to relay $url: $error"); - return []; // Return an empty list in case of error - }); - } - if (!isWebSocketOpen(url)) { - if (relay != null && - !force && - !relay.wasLastConnectTryLongerThanSeconds( - FAIL_RELAY_CONNECT_TRY_AFTER_SECONDS)) { - // don't try too often - return false; - } - - if (!await connectRelay(url)) { - // could not connect - return false; - } - if (!isWebSocketOpen(url)) { - // web socket is not open - return false; - } - } - } - return true; - } - - Future getRelayInfo(String url) async { - // if (relays[url] != null) { - // relays[url]!.info ??= await RelayInfo.get(url); - // return relays[url]!.info; - // } - return null; - } - - bool filterEvent(Nip01Event event) { - for (var filter in eventFilters) { - if (!filter.filter(event)) { - return false; - } - } - return true; - } - - void logActiveRequests() { - // Map kindsMap = {}; - Map namesMap = {}; - globalState.inFlightRequests.forEach((key, state) { - // int? kind; - // if (state.requests.isNotEmpty && - // state.requests.values.first.filters.first.kinds != null && - // state.requests.values.first.filters.first.kinds!.isNotEmpty) { - // kind = state.requests.values.first.filters.first.kinds!.first; - // } - // int? kindCount = kindsMap[kind]; - int? nameCount = namesMap[state.request.name]; - // kindCount ??= 0; - // kindCount++; - nameCount ??= 0; - nameCount++; - // kindsMap[kind] = kindCount; - namesMap[state.request.name] = nameCount; - }); - Logger.log.d( - "------------ IN FLIGHT REQUESTS: ${globalState.inFlightRequests.length} || $namesMap"); - } - - void _reSubscribeInFlightSubscriptions(String url) { - globalState.inFlightRequests.forEach((key, state) { - state.requests.values.where((req) => req.url == url).forEach((req) { - if (!state.request.closeOnEOSE) { - List list = ["REQ", state.id]; - list.addAll(req.filters.map((filter) => filter.toMap())); - Relay? relay = getRelay(req.url); - if (relay != null) { - // relay.stats.activeRequests++; - send(url, jsonEncode(list)); - // TODO not sure if this works, since there are old streams on the ndk response??? - } - } - }); - }); - } -} From 23e4afbcbc97946b99fec7d3119719e3c2af7215 Mon Sep 17 00:00:00 2001 From: Fmar Date: Mon, 23 Dec 2024 00:12:58 +0100 Subject: [PATCH 38/46] fix tests --- .../ndk/test/usecases/zaps/zaps_test.dart | 51 +++---------------- 1 file changed, 6 insertions(+), 45 deletions(-) diff --git a/packages/ndk/test/usecases/zaps/zaps_test.dart b/packages/ndk/test/usecases/zaps/zaps_test.dart index 83d0df4bd..55fc9db11 100644 --- a/packages/ndk/test/usecases/zaps/zaps_test.dart +++ b/packages/ndk/test/usecases/zaps/zaps_test.dart @@ -4,7 +4,6 @@ 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/presentation_layer/ndk.dart'; import 'package:ndk/shared/nips/nip01/bip340.dart'; @@ -16,48 +15,9 @@ import '../lnurl/lnurl_test.mocks.dart'; // Mock classes @GenerateMocks([http.Client]) void main() { - group('Lnurl', () { + group('Zaps', () { 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 = { @@ -79,7 +39,7 @@ void main() { "status": "OK", "successAction": {"tag": "message", "message": "Payment Received!"}, "routes": [], - "pr": "lnbc100...." + "pr": "lnbc1000...." }), 200)); final amount = 1000; @@ -95,12 +55,13 @@ void main() { pubKey: 'pubKey', relays: ['relay1', 'relay2']); - var invoiceCode = await ndk.zaps.fecthInvoice( + var invoiceResponse = await ndk.zaps.fecthInvoice( lud16Link: link, - amountSats: 1000, + amountSats: amount, zapRequest: zapRequest, client: client); - expect(invoiceCode, startsWith("lnbc100")); + expect(invoiceResponse!.amountSats, amount); + expect(invoiceResponse.invoice, startsWith("lnbc$amount")); }); test('fetchInvoidce returns null for invalid input', () async { From 4c95367dc4139a61923e17bc120234fbcc941ced Mon Sep 17 00:00:00 2001 From: Fmar Date: Mon, 23 Dec 2024 07:23:10 +0100 Subject: [PATCH 39/46] start zaps page --- packages/sample-app/lib/main.dart | 7 +- packages/sample-app/lib/zaps_page.dart | 209 +++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 packages/sample-app/lib/zaps_page.dart diff --git a/packages/sample-app/lib/main.dart b/packages/sample-app/lib/main.dart index 11a201ad6..47a22cc0e 100644 --- a/packages/sample-app/lib/main.dart +++ b/packages/sample-app/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk_demo/nwc_page.dart'; import 'package:ndk_demo/relays_page.dart'; +import 'package:ndk_demo/zaps_page.dart'; import 'package:ndk_rust_verifier/ndk_rust_verifier.dart'; import 'amber_page.dart'; @@ -55,7 +56,7 @@ class _MyHomePageState extends State { @override Widget build(BuildContext context) { return DefaultTabController( - length: 4, + length: 5, child: Scaffold( appBar: AppBar( title: const Text('Nostr Development Kit Demo'), @@ -65,6 +66,7 @@ class _MyHomePageState extends State { Tab(text: 'Amber'), Tab(text: 'Relays'), Tab(text: 'NWC'), + Tab(text: 'Zaps'), ], ), ), @@ -76,7 +78,8 @@ class _MyHomePageState extends State { : const AmberPage() , const RelaysPage(), - const NwcPage() + const NwcPage(), + const ZapsPage() ], ), ), diff --git a/packages/sample-app/lib/zaps_page.dart b/packages/sample-app/lib/zaps_page.dart new file mode 100644 index 000000000..fb4f7127b --- /dev/null +++ b/packages/sample-app/lib/zaps_page.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; +import 'package:ndk/ndk.dart'; + +import 'main.dart'; + +class ZapsPage extends StatefulWidget { + const ZapsPage({super.key}); + + @override + State createState() => _ZapsPageState(); +} + +class _ZapsPageState extends State { + TextEditingController uri = TextEditingController(); + TextEditingController amount = TextEditingController(); + TextEditingController invoice = TextEditingController(); + NwcConnection? connection; + GetBalanceResponse? balance; + MakeInvoiceResponse? makeInvoice; + PayInvoiceResponse? payInvoice; + + @override + void initState() { + super.initState(); + uri.addListener(() { + setState(() { + if (uri.text == '') { + connection = null; + } + }); + }); + amount.addListener(() { + setState(() {}); + }); + invoice.addListener(() { + setState(() {}); + }); + } + + @override + Widget build(BuildContext context) { + List widgets = []; + widgets.add(Container( + padding: const EdgeInsets.all(20), + width: 400, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: TextField( + controller: uri, + onEditingComplete: () { + setState(() {}); + }, + decoration: InputDecoration( + prefixIcon: IconButton( + onPressed: () { + Clipboard.getData(Clipboard.kTextPlain) + .then((clipboardData) { + if (clipboardData != null && + clipboardData.text != null) { + setState(() { + uri.text = clipboardData.text!; + }); + } + }); + }, + icon: const Icon(Icons.paste)), + hintText: "nostr+wallet://... url", + hintStyle: const TextStyle(color: Colors.grey), + ), + style: const TextStyle(fontSize: 14), + ), + ) + ], + ))); + widgets.add( + FilledButton( + onPressed: uri.text.isNotEmpty + ? () async { + connection = + await ndk.nwc.connect(uri.text, doGetInfoMethod: true); + setState(() { + balance = null; + }); + } + : null, + child: const Text('Connect and get info'), + ), + ); + widgets.add(connection != null && connection!.info != null + ? Text("Methods ${connection!.info!.methods}") + : Container()); + + widgets.add( + FilledButton( + onPressed: connection != null + ? () async { + final b = await ndk.nwc.getBalance(connection!); + setState(() { + balance = b; + }); + } + : null, + child: const Text('Get Balance'), + ), + ); + widgets.add(connection != null && balance != null + ? Text("Balance ${balance!.balanceSats} sats") + : Container()); + + bool canMakeInvoice = connection != null && + connection!.info!.methods.contains(NwcMethod.MAKE_INVOICE.name) && + amount.text != '' && + (int.tryParse(amount.text) ?? 0) > 0; + widgets.add(Row( + children: [ + const SizedBox(width: 30), + SizedBox( + width: 200, + child: TextField( + controller: amount, + decoration: const InputDecoration( + hintText: "amount in sats", + hintStyle: TextStyle(color: Colors.grey), + ), + style: const TextStyle(fontSize: 14), + ), + ), + FilledButton( + onPressed: canMakeInvoice + ? () async { + final invoice = await ndk.nwc.makeInvoice(connection!, + amountSats: int.tryParse(amount.text) ?? 0); + setState(() { + makeInvoice = invoice; + }); + } + : null, + child: const Text('Make invoice'), + ), + ], + )); + widgets.add(makeInvoice != null + ? SelectableText("bolt11 invoice: ${makeInvoice!.invoice}") + : Container()); + + bool canPayInvoice = connection != null && + connection!.info!.methods.contains(NwcMethod.PAY_INVOICE.name) && + invoice.text != ''; + widgets.add(Row( + children: [ + const SizedBox(width: 30), + SizedBox( + width: 200, + child: TextField( + controller: invoice, + decoration: const InputDecoration( + hintText: "invoice to pay", + hintStyle: TextStyle(color: Colors.grey), + ), + style: const TextStyle(fontSize: 14), + ), + ), + FilledButton( + onPressed: canPayInvoice + ? () async { + final p = await ndk.nwc + .payInvoice(connection!, invoice: invoice.text); + setState(() { + payInvoice = p; + }); + } + : null, + child: const Text('Pay invoice'), + ), + ], + )); + widgets.add(payInvoice != null + ? SelectableText("preimage: ${payInvoice!.preimage}") + : Container()); + + widgets.add( + FilledButton( + onPressed: connection != null + ? () async { + await ndk.nwc.disconnect(connection!); + setState(() { + connection = null; + balance = null; + makeInvoice = null; + }); + } + : null, + child: const Text('Disconnect'), + ), + ); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, children: widgets), + ), + ); + } +} From e02ac37c86fb82cdada1abd32a8bd2aea25e57b5 Mon Sep 17 00:00:00 2001 From: LeoLox <58687994+leo-lox@users.noreply.github.com> Date: Mon, 23 Dec 2024 18:21:33 +0100 Subject: [PATCH 40/46] local imports --- .../lib/domain_layer/usecases/zaps/zaps.dart | 74 +++++++++++-------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index 0de3bc3f9..679071ef5 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -2,10 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; -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'; import '../../../shared/logger/logger.dart'; import '../../entities/filter.dart'; @@ -13,8 +9,12 @@ import '../../entities/request_response.dart'; import '../../repositories/event_signer.dart'; import '../lnurl/lnurl.dart'; import '../nwc/nwc.dart'; +import '../nwc/nwc_connection.dart'; import '../nwc/responses/pay_invoice_response.dart'; import '../requests/requests.dart'; +import 'Invoice_response.dart'; +import 'zap_receipt.dart'; +import 'zap_request.dart'; /// Zaps class Zaps { @@ -29,13 +29,12 @@ class Zaps { _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 { + 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; @@ -62,7 +61,9 @@ class Zaps { } // ZAP ? - if (lnurlResponse.doesAllowsNostr && zapRequest!=null && zapRequest.sig.isNotEmpty) { + if (lnurlResponse.doesAllowsNostr && + zapRequest != null && + zapRequest.sig.isNotEmpty) { Logger.log.d(jsonEncode(zapRequest)); var eventStr = Uri.encodeQueryComponent(jsonEncode(zapRequest)); callback += "&nostr=$eventStr"; @@ -75,10 +76,12 @@ class Zaps { try { var response = await (client ?? http.Client()).get(uri); final decodedResponse = - jsonDecode(utf8.decode(response.bodyBytes)) as Map; + jsonDecode(utf8.decode(response.bodyBytes)) as Map; String invoice = decodedResponse["pr"]; - return InvoiceResponse(invoice: invoice, amountSats: amountSats, nostrPubkey: lnurlResponse.nostrPubkey); - + return InvoiceResponse( + invoice: invoice, + amountSats: amountSats, + nostrPubkey: lnurlResponse.nostrPubkey); } catch (e) { Logger.log.d(e); } @@ -94,7 +97,7 @@ class Zaps { required Iterable relays, String? pollOption, }) async { - if (amountSats<0) { + if (amountSats < 0) { throw ArgumentError("amount cannot be < 0"); } final amount = amountSats * 1000; @@ -111,7 +114,7 @@ class Zaps { tags.add(["poll_option", pollOption]); } var event = ZapRequest( - pubKey: signer.getPublicKey(), tags: tags, content: comment??''); + pubKey: signer.getPublicKey(), tags: tags, content: comment ?? ''); await signer.sign(event); return event; } @@ -156,39 +159,47 @@ class Zaps { return ZapResponse(error: "couldn't get invoice from $lnurl"); } try { - PayInvoiceResponse payResponse = - await _nwc.payInvoice(nwcConnection, invoice: invoice.invoice, timeout: Duration(seconds: 10)); + PayInvoiceResponse payResponse = 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 && invoice.nostrPubkey!=null && invoice.nostrPubkey!.isNotEmpty) { + ZapResponse zapResponse = ZapResponse(payInvoiceResponse: payResponse); + 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 - ? Filter(kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey!]) + ? Filter( + kinds: [ZapReceipt.KIND], + eTags: [eventId], + pTags: [pubKey!]) : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey!]) ]); // TODO make timeout waiting for receipt parameterizable somehow final timeout = Timer(Duration(seconds: 30), () { - _requests.closeSubscription( - zapResponse.zapReceiptResponse!.requestId); - Logger.log.w("timed out waiting for zap receipt for invoice $invoice"); + _requests + .closeSubscription(zapResponse.zapReceiptResponse!.requestId); + Logger.log + .w("timed out waiting for zap receipt for invoice $invoice"); }); zapResponse.zapReceiptResponse!.stream.listen((event) { String? bolt11 = event.getFirstTag("bolt11"); String? preimage = event.getFirstTag("preimage"); - if (bolt11!=null && bolt11 == invoice || preimage!=null && preimage==payResponse.preimage) { + if (bolt11 != null && bolt11 == invoice || + preimage != null && preimage == payResponse.preimage) { ZapReceipt receipt = ZapReceipt.fromEvent(event); Logger.log.d("Zap Receipt: $receipt"); - if (receipt.isValid(nostrPubKey: invoice.nostrPubkey!, recipientLnurl: lnurl)) { + if (receipt.isValid( + nostrPubKey: invoice.nostrPubkey!, recipientLnurl: lnurl)) { zapResponse.emitReceipt(receipt); } else { Logger.log.w("Zap Receipt invalid: $receipt"); } timeout.cancel(); - _requests.closeSubscription( - zapResponse.zapReceiptResponse!.requestId); + _requests + .closeSubscription(zapResponse.zapReceiptResponse!.requestId); } }); } else { @@ -205,7 +216,8 @@ class Zaps { /// fetch all zap receipts matching given pubKey and optional event id, in sats Stream fetchZappedReceipts( {required String pubKey, String? eventId, Duration? timeout}) { - NdkResponse? response = _requests.query(timeout: timeout??Duration(seconds:10), filters: [ + NdkResponse? response = + _requests.query(timeout: timeout ?? Duration(seconds: 10), filters: [ eventId != null ? Filter(kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey]) : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey]) From 8857ff41953e47a7e63ec9a52c7fee73e8593f5d Mon Sep 17 00:00:00 2001 From: LeoLox <58687994+leo-lox@users.noreply.github.com> Date: Mon, 23 Dec 2024 18:25:58 +0100 Subject: [PATCH 41/46] naming, imports --- .../usecases/zaps/Invoice_response.dart | 3 +- .../usecases/zaps/zap_receipt.dart | 31 ++++++++++++++++--- .../usecases/zaps/zap_request.dart | 2 +- .../lib/domain_layer/usecases/zaps/zaps.dart | 2 +- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/Invoice_response.dart b/packages/ndk/lib/domain_layer/usecases/zaps/Invoice_response.dart index 44ae11300..35fd4d430 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/Invoice_response.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/Invoice_response.dart @@ -5,5 +5,6 @@ class InvoiceResponse { String? nostrPubkey; /// . - InvoiceResponse({required this.invoice, this.nostrPubkey, required this.amountSats}); + 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 a1ba1d3f0..5ef239e3c 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_receipt.dart @@ -1,23 +1,44 @@ import 'dart:convert'; -import 'package:ndk/domain_layer/entities/nip_01_event.dart'; -import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; - import '../../../shared/logger/logger.dart'; +import '../../entities/nip_01_event.dart'; +import '../lnurl/lnurl.dart'; class ZapReceipt { + /// zap receipt kind static const KIND = 9735; + /// time payment happend int? paidAt; + + /// amount in sats int? amountSats; + + /// pubKey String? pubKey; + + /// invoice String? bolt11; + + /// invoice preimage String? preimage; + + /// pubkey of recipient String? recipient; + + /// nostr eventId String? eventId; + + /// user defined comment String? comment; + + /// pubkey sender String? sender; + + /// String? anon; + + /// lnurl String? lnurl; ZapReceipt.fromEvent(Nip01Event event) { @@ -70,13 +91,13 @@ class ZapReceipt { 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 (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 != null && lnurl!.isNotEmpty) { if (lnurl != recipientLnurl) { return false; } diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart index 27d815993..ad0d92969 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zap_request.dart @@ -1,4 +1,4 @@ -import 'package:ndk/domain_layer/entities/nip_01_event.dart'; +import '../../entities/nip_01_event.dart'; /// Zap Request class ZapRequest extends Nip01Event { diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index 679071ef5..2ca99f49c 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -12,7 +12,7 @@ import '../nwc/nwc.dart'; import '../nwc/nwc_connection.dart'; import '../nwc/responses/pay_invoice_response.dart'; import '../requests/requests.dart'; -import 'Invoice_response.dart'; +import 'invoice_response.dart'; import 'zap_receipt.dart'; import 'zap_request.dart'; From bab3325002191a6b50eeefbd3b329f1f45e84e62 Mon Sep 17 00:00:00 2001 From: LeoLox <58687994+leo-lox@users.noreply.github.com> Date: Mon, 23 Dec 2024 18:28:35 +0100 Subject: [PATCH 42/46] export zap usecase --- packages/ndk/lib/ndk.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index f5dbcd0d7..0d1e69f56 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -66,6 +66,10 @@ export 'domain_layer/usecases/lists/lists.dart'; export 'domain_layer/usecases/relay_sets/relay_sets.dart'; export 'domain_layer/usecases/broadcast/broadcast.dart'; export 'domain_layer/usecases/nwc/nwc.dart'; +export 'domain_layer/usecases/zaps/zaps.dart'; +export 'domain_layer/usecases/zaps/zap_request.dart'; +export 'domain_layer/usecases/zaps/zap_receipt.dart'; +export 'domain_layer/usecases/zaps/invoice_response.dart'; /** * other stuff From ea36e55f560ef5b0a471edc6281c379dc077ef93 Mon Sep 17 00:00:00 2001 From: LeoLox <58687994+leo-lox@users.noreply.github.com> Date: Mon, 23 Dec 2024 18:37:17 +0100 Subject: [PATCH 43/46] var to final --- packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index 2ca99f49c..c8a9b736b 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -35,7 +35,8 @@ class Zaps { ZapRequest? zapRequest, String? comment, http.Client? client}) async { - var lnurlResponse = await Lnurl.getLnurlResponse(lud16Link, client: client); + final lnurlResponse = + await Lnurl.getLnurlResponse(lud16Link, client: client); if (lnurlResponse == null) { return null; } From b549c543a96552804c028fa10539c8699fd8187e Mon Sep 17 00:00:00 2001 From: Fmar Date: Tue, 24 Dec 2024 10:46:18 +0100 Subject: [PATCH 44/46] fix typo --- .../zaps/{Invoice_response.dart => invoice_response.dart} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/ndk/lib/domain_layer/usecases/zaps/{Invoice_response.dart => invoice_response.dart} (100%) diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/Invoice_response.dart b/packages/ndk/lib/domain_layer/usecases/zaps/invoice_response.dart similarity index 100% rename from packages/ndk/lib/domain_layer/usecases/zaps/Invoice_response.dart rename to packages/ndk/lib/domain_layer/usecases/zaps/invoice_response.dart From b864cd2f842b6774dcce3decb9fe75c6c7887172 Mon Sep 17 00:00:00 2001 From: Fmar Date: Tue, 24 Dec 2024 12:13:51 +0100 Subject: [PATCH 45/46] LnurlTransportHttpImpl --- .../data_layer/data_sources/http_request.dart | 3 +- .../repositories/lnurl_http_impl.dart | 36 +++++ .../repositories/lnurl_transport.dart | 11 ++ .../domain_layer/usecases/lnurl/lnurl.dart | 63 ++++++--- .../lib/domain_layer/usecases/zaps/zaps.dart | 125 ++++-------------- packages/ndk/lib/presentation_layer/init.dart | 14 +- .../lib/presentation_layer/ndk_config.dart | 1 + packages/ndk/pubspec.lock | 6 +- .../ndk/test/usecases/lnurl/lnurl_test.dart | 20 ++- .../ndk/test/usecases/zaps/zaps_test.dart | 21 ++- 10 files changed, 164 insertions(+), 136 deletions(-) create mode 100644 packages/ndk/lib/data_layer/repositories/lnurl_http_impl.dart create mode 100644 packages/ndk/lib/domain_layer/repositories/lnurl_transport.dart diff --git a/packages/ndk/lib/data_layer/data_sources/http_request.dart b/packages/ndk/lib/data_layer/data_sources/http_request.dart index 7e0cb18c9..d5c4c766c 100644 --- a/packages/ndk/lib/data_layer/data_sources/http_request.dart +++ b/packages/ndk/lib/data_layer/data_sources/http_request.dart @@ -11,8 +11,9 @@ class HttpRequestDS { /// make a get request to the given url Future> jsonRequest(String url) async { http.Response response = await _client - .get(Uri.parse(url), headers: {"Accept": "application/json"}); + .get(Uri.parse(url).replace(scheme: 'https'), headers: {"Accept": "application/json"}); + print(response); if (response.statusCode != 200) { return throw Exception( "error fetching STATUS: ${response.statusCode}, Link: $url"); diff --git a/packages/ndk/lib/data_layer/repositories/lnurl_http_impl.dart b/packages/ndk/lib/data_layer/repositories/lnurl_http_impl.dart new file mode 100644 index 000000000..7fae1272a --- /dev/null +++ b/packages/ndk/lib/data_layer/repositories/lnurl_http_impl.dart @@ -0,0 +1,36 @@ +import 'package:ndk/domain_layer/repositories/lnurl_transport.dart'; +import 'package:ndk/domain_layer/usecases/lnurl/lnurl_response.dart'; + +import '../../domain_layer/repositories/nip_05_repo.dart'; +import '../../shared/logger/logger.dart'; +import '../data_sources/http_request.dart'; + +/// implementation of the [Nip05Repository] interface with http +class LnurlTransportHttpImpl implements LnurlTransport { + final HttpRequestDS httpDS; + + /// constructor + LnurlTransportHttpImpl(this.httpDS); + + @override + Future requestLnurlResponse(String lnurl) async { + try { + final response = await httpDS.jsonRequest(lnurl); + return LnurlResponse.fromJson(response); + } catch (e) { + Logger.log.w(e); + return null; + } + } + + @override + Future?> fetchInvoice(String callbacklink) async { + try { + return await httpDS.jsonRequest(callbacklink); + } catch (e) { + Logger.log.d(e); + return null; + } + } + +} diff --git a/packages/ndk/lib/domain_layer/repositories/lnurl_transport.dart b/packages/ndk/lib/domain_layer/repositories/lnurl_transport.dart new file mode 100644 index 000000000..8b09a98e8 --- /dev/null +++ b/packages/ndk/lib/domain_layer/repositories/lnurl_transport.dart @@ -0,0 +1,11 @@ +import 'package:ndk/domain_layer/usecases/lnurl/lnurl_response.dart'; +import 'package:ndk/ndk.dart'; + +/// transport to get the lnurl response +abstract class LnurlTransport { + /// network request to get theLnurl response and invoices + Future requestLnurlResponse(String lnurl); + + /// fetch an invoice from lnurl callback endpoint + Future?> fetchInvoice(String callbacklink); +} diff --git a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart index 855157944..6cd834e6f 100644 --- a/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart +++ b/packages/ndk/lib/domain_layer/usecases/lnurl/lnurl.dart @@ -1,12 +1,18 @@ import 'dart:convert'; -import 'package:http/http.dart' as http; +import 'package:ndk/domain_layer/repositories/lnurl_transport.dart'; +import 'package:ndk/ndk.dart'; -import '../../../shared/logger/logger.dart'; import 'lnurl_response.dart'; /// LN URL utilities -abstract class Lnurl { +class Lnurl { + LnurlTransport _transport; + + /// + Lnurl({ + required LnurlTransport transport, + }) : _transport = transport; /// transform a lud16 of format name@domain.com to https://domain.com/.well-known/lnurlp/name static String? getLud16LinkFromLud16(String lud16) { @@ -35,23 +41,49 @@ abstract class Lnurl { // /// fetch LNURL response from given link - static Future getLnurlResponse(String link, - {http.Client? client}) async { - Uri uri = Uri.parse(link).replace(scheme: 'https'); + Future getLnurlResponse(String link) async { + return await _transport.requestLnurlResponse(link); + } - try { - var response = await (client ?? http.Client()).get(uri); - final decodedResponse = - jsonDecode(utf8.decode(response.bodyBytes)) as Map; - if (client == null) { - // Only close if we created the client - client?.close(); + /// fetch invoice from callback + Future fetchInvoice({required LnurlResponse lnurlResponse, required int amountSats, ZapRequest? zapRequest, String? comment}) async { + 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)}"; } - return LnurlResponse.fromJson(decodedResponse); + } + + // 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"); + + try { + var response = await _transport.fetchInvoice(callback); + String invoice = response!["pr"]; + return InvoiceResponse(invoice: invoice, amountSats: amountSats, nostrPubkey: lnurlResponse.nostrPubkey); } catch (e) { Logger.log.d(e); - return null; } + return null; } /// extract amount from bolt11 in sats @@ -94,5 +126,4 @@ abstract class Lnurl { return content.substring(index + beforeLength, index2); } - } diff --git a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart index c8a9b736b..3d8762443 100644 --- a/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart +++ b/packages/ndk/lib/domain_layer/usecases/zaps/zaps.dart @@ -1,7 +1,4 @@ import 'dart:async'; -import 'dart:convert'; - -import 'package:http/http.dart' as http; import '../../../shared/logger/logger.dart'; import '../../entities/filter.dart'; @@ -20,75 +17,34 @@ import 'zap_request.dart'; class Zaps { final Requests _requests; final Nwc _nwc; + final Lnurl _lnurl; /// . Zaps({ required Requests requests, required Nwc nwc, + required Lnurl lnurl, }) : _requests = requests, - _nwc = nwc; + _nwc = nwc, + _lnurl = lnurl; /// 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 { - final lnurlResponse = - await Lnurl.getLnurlResponse(lud16Link, client: client); + {required String lud16Link, required int amountSats, ZapRequest? zapRequest, String? comment}) async { + final lnurlResponse = await _lnurl.getLnurlResponse(lud16Link); 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); + return _lnurl.fetchInvoice(lnurlResponse: lnurlResponse, amountSats: amountSats); } catch (e) { Logger.log.d(e); + return null; } - return null; } + /// creates a zap request Future createZapRequest({ required int amountSats, required EventSigner signer, @@ -114,8 +70,7 @@ class Zaps { if (pollOption != null) { tags.add(["poll_option", pollOption]); } - var event = ZapRequest( - pubKey: signer.getPublicKey(), tags: tags, content: comment ?? ''); + var event = ZapRequest(pubKey: signer.getPublicKey(), tags: tags, content: comment ?? ''); await signer.sign(event); return event; } @@ -138,17 +93,8 @@ class Zaps { }) async { String? lud16Link = Lnurl.getLud16LinkFromLud16(lnurl); ZapRequest? zapRequest; - if (pubKey != null && - signer != null && - relays != null && - relays.isNotEmpty) { - zapRequest = await createZapRequest( - amountSats: amountSats, - signer: signer, - pubKey: pubKey, - comment: comment, - relays: relays, - eventId: eventId); + if (pubKey != null && signer != null && relays != null && relays.isNotEmpty) { + zapRequest = await createZapRequest(amountSats: amountSats, signer: signer, pubKey: pubKey, comment: comment, relays: relays, eventId: eventId); } InvoiceResponse? invoice = await fecthInvoice( lud16Link: lud16Link!, @@ -160,47 +106,33 @@ class Zaps { return ZapResponse(error: "couldn't get invoice from $lnurl"); } try { - PayInvoiceResponse payResponse = await _nwc.payInvoice(nwcConnection, - invoice: invoice.invoice, timeout: Duration(seconds: 10)); + PayInvoiceResponse payResponse = 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 && - invoice.nostrPubkey != null && - invoice.nostrPubkey!.isNotEmpty) { + 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 - ? Filter( - kinds: [ZapReceipt.KIND], - eTags: [eventId], - pTags: [pubKey!]) - : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey!]) + eventId != null ? Filter(kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey!]) : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey!]) ]); // TODO make timeout waiting for receipt parameterizable somehow final timeout = Timer(Duration(seconds: 30), () { - _requests - .closeSubscription(zapResponse.zapReceiptResponse!.requestId); - Logger.log - .w("timed out waiting for zap receipt for invoice $invoice"); + _requests.closeSubscription(zapResponse.zapReceiptResponse!.requestId); + Logger.log.w("timed out waiting for zap receipt for invoice $invoice"); }); zapResponse.zapReceiptResponse!.stream.listen((event) { String? bolt11 = event.getFirstTag("bolt11"); String? preimage = event.getFirstTag("preimage"); - if (bolt11 != null && bolt11 == invoice || - preimage != null && preimage == payResponse.preimage) { + if (bolt11 != null && bolt11 == invoice || preimage != null && preimage == payResponse.preimage) { ZapReceipt receipt = ZapReceipt.fromEvent(event); Logger.log.d("Zap Receipt: $receipt"); - if (receipt.isValid( - nostrPubKey: invoice.nostrPubkey!, recipientLnurl: lnurl)) { + if (receipt.isValid(nostrPubKey: invoice.nostrPubkey!, recipientLnurl: lnurl)) { zapResponse.emitReceipt(receipt); } else { Logger.log.w("Zap Receipt invalid: $receipt"); } timeout.cancel(); - _requests - .closeSubscription(zapResponse.zapReceiptResponse!.requestId); + _requests.closeSubscription(zapResponse.zapReceiptResponse!.requestId); } }); } else { @@ -215,13 +147,9 @@ class Zaps { } /// fetch all zap receipts matching given pubKey and optional event id, in sats - Stream fetchZappedReceipts( - {required String pubKey, String? eventId, Duration? timeout}) { - NdkResponse? response = - _requests.query(timeout: timeout ?? Duration(seconds: 10), filters: [ - eventId != null - ? Filter(kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey]) - : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey]) + Stream fetchZappedReceipts({required String pubKey, String? eventId, Duration? timeout}) { + NdkResponse? response = _requests.query(timeout: timeout ?? Duration(seconds: 10), filters: [ + eventId != null ? Filter(kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey]) : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey]) ]); // TODO how to check validity of zap receipts without nostrPubKey and recipientLnurl???? return response.stream.map((event) => ZapReceipt.fromEvent(event)); @@ -230,12 +158,9 @@ class Zaps { } /// fetch all zap receipts matching given pubKey and optional event id, in sats - NdkResponse subscribeToZapReceipts( - {required String pubKey, String? eventId}) { + NdkResponse subscribeToZapReceipts({required String pubKey, String? eventId}) { NdkResponse? response = _requests.subscription(filters: [ - eventId != null - ? Filter(kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey]) - : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey]) + eventId != null ? Filter(kinds: [ZapReceipt.KIND], eTags: [eventId], pTags: [pubKey]) : Filter(kinds: [ZapReceipt.KIND], pTags: [pubKey]) ]); return response; } diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index e24029b27..ba60e6ada 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -1,4 +1,6 @@ import 'package:http/http.dart' as http; +import 'package:ndk/data_layer/repositories/lnurl_http_impl.dart'; +import 'package:ndk/domain_layer/repositories/lnurl_transport.dart'; import '../data_layer/data_sources/http_request.dart'; import '../data_layer/repositories/nip_05_http_impl.dart'; @@ -13,6 +15,7 @@ import '../domain_layer/usecases/engines/network_engine.dart'; import '../domain_layer/usecases/follows/follows.dart'; import '../domain_layer/usecases/jit_engine/jit_engine.dart'; import '../domain_layer/usecases/lists/lists.dart'; +import '../domain_layer/usecases/lnurl/lnurl.dart'; import '../domain_layer/usecases/metadatas/metadatas.dart'; import '../domain_layer/usecases/nip05/verify_nip_05.dart'; import '../domain_layer/usecases/nwc/nwc.dart'; @@ -37,8 +40,7 @@ class Initialization { /// repositories with no dependencies - final WebSocketClientNostrTransportFactory _webSocketNostrTransportFactory = - WebSocketClientNostrTransportFactory(); + final WebSocketClientNostrTransportFactory _webSocketNostrTransportFactory = WebSocketClientNostrTransportFactory(); /// state obj @@ -56,6 +58,7 @@ class Initialization { late Broadcast broadcast; late Nwc nwc; late Zaps zaps; + late Lnurl lnurl; late VerifyNip05 verifyNip05; @@ -102,8 +105,7 @@ class Initialization { } /// repositories - final Nip05Repository nip05repository = - Nip05HttpRepositoryImpl(httpDS: _httpRequestDS); + final Nip05Repository nip05repository = Nip05HttpRepositoryImpl(httpDS: _httpRequestDS); /// use cases cacheWrite = CacheWrite(_ndkConfig.cache); @@ -169,9 +171,13 @@ class Initialization { nwc = Nwc(requests: requests, broadcast: broadcast); + final LnurlTransport lnurlTransport = LnurlTransportHttpImpl(_httpRequestDS); + + lnurl = Lnurl(transport: lnurlTransport); zaps = Zaps( requests: requests, nwc: nwc, + lnurl: lnurl, ); /// set the user configured log level diff --git a/packages/ndk/lib/presentation_layer/ndk_config.dart b/packages/ndk/lib/presentation_layer/ndk_config.dart index ce44bc719..7ab3c900b 100644 --- a/packages/ndk/lib/presentation_layer/ndk_config.dart +++ b/packages/ndk/lib/presentation_layer/ndk_config.dart @@ -44,6 +44,7 @@ class NdkConfig { /// this value is used if no individual timeout is set for a query Duration defaultQueryTimeout; + /// log level lib_logger.Level logLevel; /// Creates a new instance of [NdkConfig]. diff --git a/packages/ndk/pubspec.lock b/packages/ndk/pubspec.lock index 4a26be815..290dc3c40 100644 --- a/packages/ndk/pubspec.lock +++ b/packages/ndk/pubspec.lock @@ -309,10 +309,10 @@ packages: dependency: transitive description: name: lints - sha256: "4a16b3f03741e1252fda5de3ce712666d010ba2122f8e912c94f9f7b90e1a4c3" + sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" url: "https://pub.dev" source: hosted - version: "5.1.0" + version: "5.0.0" logger: dependency: "direct main" description: @@ -634,4 +634,4 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.6.0-0 <4.0.0" + dart: ">=3.5.0 <4.0.0" diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.dart index 1b18f42ca..95c356013 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.dart @@ -3,7 +3,11 @@ 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/data_sources/http_request.dart'; +import 'package:ndk/data_layer/repositories/lnurl_http_impl.dart'; import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; +import 'package:ndk/presentation_layer/ndk.dart'; +import 'package:ndk/shared/logger/logger.dart'; import 'package:test/test.dart'; import 'lnurl_test.mocks.dart'; @@ -26,6 +30,10 @@ void main() { test('getLnurlResponse returns LnurlResponse for valid link', () async { final client = MockClient(); + final transport = LnurlTransportHttpImpl(HttpRequestDS(client)); + final Lnurl lnurl = Lnurl(transport: transport); + Logger.setLogLevel(Logger.logLevels.trace); + final link = 'https://domain.com/.well-known/lnurlp/name'; final response = { 'callback': 'https://domain.com/callback', @@ -33,22 +41,22 @@ void main() { }; // Mock the client.get method - when(client.get(Uri.parse(link))) + when(client.get(Uri.parse(link), headers: {"Accept": "application/json"})) .thenAnswer((_) async => http.Response(jsonEncode(response), 200)); - var lnurlResponse = await Lnurl.getLnurlResponse(link, client: client); + var lnurlResponse = await lnurl.getLnurlResponse(link); expect(lnurlResponse, isNotNull); expect(lnurlResponse!.callback, response['callback']); }); test('getLnurlResponse returns null for invalid link', () async { final client = MockClient(); - final link = 'https://invalid.com'; + final transport = LnurlTransportHttpImpl(HttpRequestDS(client)); + final Lnurl lnurl = Lnurl(transport: transport); - when(client.get(Uri.parse(link))) - .thenAnswer((_) async => http.Response('Not Found', 404)); + final link = 'https://invalid.com'; - var lnurlResponse = await Lnurl.getLnurlResponse(link, client: client); + var lnurlResponse = await lnurl.getLnurlResponse(link); expect(lnurlResponse, isNull); }); }); diff --git a/packages/ndk/test/usecases/zaps/zaps_test.dart b/packages/ndk/test/usecases/zaps/zaps_test.dart index 55fc9db11..ef4b2b93f 100644 --- a/packages/ndk/test/usecases/zaps/zaps_test.dart +++ b/packages/ndk/test/usecases/zaps/zaps_test.dart @@ -3,9 +3,14 @@ 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/data_sources/http_request.dart'; +import 'package:ndk/data_layer/repositories/lnurl_http_impl.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/logger/logger.dart'; import 'package:ndk/shared/nips/nip01/bip340.dart'; import 'package:ndk/shared/nips/nip01/key_pair.dart'; import 'package:test/test.dart'; @@ -28,13 +33,13 @@ void main() { final link = 'https://domain.com/.well-known/lnurlp/name'; // Mock the client.get method - when(client.get(Uri.parse(link))) + when(client.get(Uri.parse(link), headers: {"Accept": "application/json"})) .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( + ), headers: {"Accept": "application/json"})).thenAnswer((_) async => http.Response( jsonEncode({ "status": "OK", "successAction": {"tag": "message", "message": "Payment Received!"}, @@ -44,9 +49,13 @@ void main() { 200)); final amount = 1000; - Ndk ndk = Ndk.defaultConfig(); + final ndk = Ndk.defaultConfig(); + final transport = LnurlTransportHttpImpl(HttpRequestDS(client)); + final Lnurl lnurl = Lnurl(transport: transport); + final zaps = Zaps(requests: ndk.requests, nwc: ndk.nwc, lnurl: lnurl); + Logger.setLogLevel(Logger.logLevels.trace); - ZapRequest zapRequest = await ndk.zaps.createZapRequest( + ZapRequest zapRequest = await zaps.createZapRequest( amountSats: amount, eventId: 'eventId', comment: 'comment', @@ -55,11 +64,11 @@ void main() { pubKey: 'pubKey', relays: ['relay1', 'relay2']); - var invoiceResponse = await ndk.zaps.fecthInvoice( + var invoiceResponse = await zaps.fecthInvoice( lud16Link: link, amountSats: amount, zapRequest: zapRequest, - client: client); + ); expect(invoiceResponse!.amountSats, amount); expect(invoiceResponse.invoice, startsWith("lnbc$amount")); }); From 5942daec871c860c6f25fba3df15c9115b237aac Mon Sep 17 00:00:00 2001 From: Fmar Date: Tue, 24 Dec 2024 13:03:26 +0100 Subject: [PATCH 46/46] test getAmountFromBolt11 --- packages/ndk/test/usecases/lnurl/lnurl_test.dart | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.dart index 95c356013..c9c423c1b 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.dart @@ -6,7 +6,6 @@ import 'package:mockito/mockito.dart'; import 'package:ndk/data_layer/data_sources/http_request.dart'; import 'package:ndk/data_layer/repositories/lnurl_http_impl.dart'; import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; -import 'package:ndk/presentation_layer/ndk.dart'; import 'package:ndk/shared/logger/logger.dart'; import 'package:test/test.dart'; @@ -16,7 +15,6 @@ import 'lnurl_test.mocks.dart'; @GenerateMocks([http.Client]) void main() { group('Lnurl', () { - test('getLud16LinkFromLud16 returns correct URL', () { expect( Lnurl.getLud16LinkFromLud16('name@domain.com'), @@ -59,5 +57,16 @@ void main() { var lnurlResponse = await lnurl.getLnurlResponse(link); expect(lnurlResponse, isNull); }); + + test('getAmountFromBolt11 returns correct amount for valid input', () { + final amount = Lnurl.getAmountFromBolt11( + 'lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdqsvfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfuvqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs'); // Replace with a valid Bolt11 string + expect(amount, 1500); // Replace with the expected amount + }); + + test('getAmountFromBolt11 returns null for invalid input', () { + final amount = Lnurl.getAmountFromBolt11('invalid_bolt11_string'); + expect(amount, 0); + }); }); }