diff --git a/doc/usecases/user-relay-lists.md b/doc/usecases/user-relay-lists.md index f25837b80..301dcc93f 100644 --- a/doc/usecases/user-relay-lists.md +++ b/doc/usecases/user-relay-lists.md @@ -8,6 +8,15 @@ icon: list-ordered :::code source="../../packages/ndk/example/user_relay_list_test.dart" language="dart" range="23-29" title="" ::: +## Private storage relays + +NIP-37 private storage relays are exposed through the same usecase. +The kind `10013` event is encrypted, so this requires a logged-in signer. + +```dart +final relays = await ndk.userRelayLists.getPrivateStorageRelays(); +``` + ## When to use User relay lists provides you with the relays for a given user. \ diff --git a/packages/bc_ur/lib/ur.dart b/packages/bc_ur/lib/ur.dart index deba9bff2..5ac487676 100644 --- a/packages/bc_ur/lib/ur.dart +++ b/packages/bc_ur/lib/ur.dart @@ -19,9 +19,7 @@ class UR { @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is UR && - other.type == type && - _listEquals(other.cbor, cbor); + return other is UR && other.type == type && _listEquals(other.cbor, cbor); } @override @@ -36,4 +34,4 @@ class UR { } return true; } -} \ No newline at end of file +} diff --git a/packages/ndk/lib/domain_layer/entities/nip_37.dart b/packages/ndk/lib/domain_layer/entities/nip_37.dart new file mode 100644 index 000000000..11889dc94 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/nip_37.dart @@ -0,0 +1,4 @@ +class Nip37 { + /// Relay list for private content. + static const int kPrivateStorageRelays = 10013; +} diff --git a/packages/ndk/lib/domain_layer/usecases/user_relay_lists/user_relay_lists.dart b/packages/ndk/lib/domain_layer/usecases/user_relay_lists/user_relay_lists.dart index 1a08966f2..5db101d36 100644 --- a/packages/ndk/lib/domain_layer/usecases/user_relay_lists/user_relay_lists.dart +++ b/packages/ndk/lib/domain_layer/usecases/user_relay_lists/user_relay_lists.dart @@ -1,9 +1,12 @@ +import 'dart:convert'; + import '../../../config/user_relay_list_defaults.dart'; import '../../../shared/helpers/relay_helper.dart'; import '../../../shared/logger/logger.dart'; import '../../../shared/nips/nip01/helpers.dart'; import '../../entities/contact_list.dart'; import '../../entities/filter.dart'; +import '../../entities/nip_37.dart'; import '../../entities/nip_01_event.dart'; import '../../entities/nip_51_list.dart'; import '../../entities/nip_65.dart'; @@ -213,6 +216,76 @@ class UserRelayLists { .toList(); } + /// Fetches the private storage relay list (NIP-37, kind 10013) for the + /// logged-in user. + /// + /// Returns the list of relay URLs where the user stores private content, + /// such as draft wraps. + /// + /// - Returns `null` if the user has not published a kind 10013 event. + /// + /// [forceRefresh] if true, bypass cache and query relays directly. + Future?> getPrivateStorageRelays({ + bool forceRefresh = false, + }) async { + final signer = _signer; + if (!signer.canSign()) { + throw "cannot decrypt private storage relay list without a signer"; + } + + final pubKey = signer.getPublicKey(); + List events = []; + + if (!forceRefresh) { + events = await _cacheManager.loadEvents( + pubKeys: [pubKey], + kinds: [Nip37.kPrivateStorageRelays], + ); + } + + if (events.isEmpty) { + events = await _requests + .query( + filter: Filter( + authors: [pubKey], + kinds: [Nip37.kPrivateStorageRelays], + limit: 1, + ), + ) + .future; + } + + if (events.isEmpty) return null; + + events.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final latest = events.first; + await _cacheManager.saveEvent(latest); + try { + return await _extractPrivateStorageRelayUrls(latest, signer); + } catch (e) { + Logger.log.w(() => e); + return null; + } + } + + Future> _extractPrivateStorageRelayUrls( + Nip01Event event, + EventSigner signer, + ) async { + final json = await signer.decryptNip44( + ciphertext: event.content, + senderPubKey: event.pubKey, + ); + final tags = jsonDecode(json ?? '') as List; + + return tags + .whereType>() + .where((tag) => tag.length >= 2 && tag[0] == Nip51List.kRelay) + .map((tag) => tag[1]) + .whereType() + .toList(); + } + /// ************************************************************************************************* Future _ensureUpToDateUserRelayList() async { diff --git a/packages/ndk/lib/entities.dart b/packages/ndk/lib/entities.dart index f4dcd406c..f303253f5 100644 --- a/packages/ndk/lib/entities.dart +++ b/packages/ndk/lib/entities.dart @@ -14,6 +14,7 @@ export 'domain_layer/entities/global_state.dart'; export 'domain_layer/entities/metadata.dart'; export 'domain_layer/entities/nip_01_event.dart'; export 'domain_layer/entities/nip_05.dart'; +export 'domain_layer/entities/nip_37.dart'; export 'domain_layer/entities/nip_51_list.dart'; export 'domain_layer/entities/nip_65.dart'; export 'domain_layer/entities/pubkey_mapping.dart'; diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index df4f7d7cf..f53e98b9f 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -22,6 +22,7 @@ export 'domain_layer/entities/broadcast_response.dart'; export 'domain_layer/entities/nip_01_event.dart'; export 'data_layer/models/nip_01_event_model.dart'; export 'domain_layer/entities/filter.dart'; +export 'domain_layer/entities/nip_37.dart'; export 'domain_layer/entities/nip_51_list.dart'; export 'domain_layer/entities/contact_list.dart'; export 'domain_layer/entities/read_write.dart'; diff --git a/packages/ndk/test/usecases/user_relay_lists/user_relay_lists_test.dart b/packages/ndk/test/usecases/user_relay_lists/user_relay_lists_test.dart index 439d218aa..5d170191a 100644 --- a/packages/ndk/test/usecases/user_relay_lists/user_relay_lists_test.dart +++ b/packages/ndk/test/usecases/user_relay_lists/user_relay_lists_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:ndk/entities.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk/shared/nips/nip01/bip340.dart'; @@ -110,6 +112,45 @@ void main() async { expect(dmRelays, ['wss://dm1.example', 'wss://dm2.example']); }); + test('getPrivateStorageRelays - returns null when no kind 10013 found', + () async { + ndk.accounts + .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + + final privateStorageRelays = + await ndk.userRelayLists.getPrivateStorageRelays(); + + expect(privateStorageRelays, isNull); + }); + + test('getPrivateStorageRelays - reads encrypted relays from cache', + () async { + ndk.accounts + .loginPrivateKey(pubkey: key1.publicKey, privkey: key1.privateKey!); + final signer = ndk.accounts.getLoggedAccount()!.signer; + final encryptedContent = await signer.encryptNip44( + plaintext: jsonEncode([ + ['relay', 'wss://private1.example'], + ['relay', 'wss://private2.example'], + ]), + recipientPubKey: key1.publicKey, + ); + final event = await signer.sign(Nip01Event( + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + pubKey: key1.publicKey, + kind: Nip37.kPrivateStorageRelays, + content: encryptedContent!, + tags: [], + )); + await ndk.config.cache.saveEvent(event); + + final privateStorageRelays = + await ndk.userRelayLists.getPrivateStorageRelays(); + + expect(privateStorageRelays, + ['wss://private1.example', 'wss://private2.example']); + }); + test('broadcastAdd/RemoveNip65Relay', () async { ndk.accounts .loginPrivateKey(pubkey: key3.publicKey, privkey: key3.privateKey!); diff --git a/packages/ndk_flutter/lib/data_layer/repositories/signers/amber_event_signer.dart b/packages/ndk_flutter/lib/data_layer/repositories/signers/amber_event_signer.dart index ad5295bd3..20e284588 100644 --- a/packages/ndk_flutter/lib/data_layer/repositories/signers/amber_event_signer.dart +++ b/packages/ndk_flutter/lib/data_layer/repositories/signers/amber_event_signer.dart @@ -13,9 +13,7 @@ class _PendingRequestEntry { } /// amber (external app) https://github.com/greenart7c3/Amber singer -class AmberEventSigner - with ConcurrencyLimiterMixin - implements EventSigner { +class AmberEventSigner with ConcurrencyLimiterMixin implements EventSigner { final AmberFlutterDS amberFlutterDS; final String publicKey; @@ -38,8 +36,7 @@ class AmberEventSigner required this.publicKey, required this.amberFlutterDS, this.maxConcurrentRequests = defaultMaxConcurrentRequests, - }) : assert(maxConcurrentRequests > 0, - 'maxConcurrentRequests must be > 0'); + }) : assert(maxConcurrentRequests > 0, 'maxConcurrentRequests must be > 0'); String get _npub => publicKey.startsWith('npub') ? publicKey : Nip19.encodePubKey(publicKey); @@ -94,11 +91,11 @@ class AmberEventSigner // `pendingRequests` so the UI sees the full backlog. If the request was // cancelled while queued, skip the Amber call entirely. runThrottled(() async { - if (!_pendingRequests.containsKey(requestId)) { - throw SignerRequestCancelledException(requestId); - } - return await operation(); - }) + if (!_pendingRequests.containsKey(requestId)) { + throw SignerRequestCancelledException(requestId); + } + return await operation(); + }) .then((result) { if (!completer.isCompleted) { completer.complete(result); diff --git a/packages/ndk_flutter/lib/widgets/login/nostr_connect_dialog_view.dart b/packages/ndk_flutter/lib/widgets/login/nostr_connect_dialog_view.dart index 273dbb33c..72e238c44 100644 --- a/packages/ndk_flutter/lib/widgets/login/nostr_connect_dialog_view.dart +++ b/packages/ndk_flutter/lib/widgets/login/nostr_connect_dialog_view.dart @@ -23,9 +23,7 @@ class NostrConnectDialogView extends StatelessWidget { data: nostrConnectURL, decoration: const PrettyQrDecoration( quietZone: PrettyQrQuietZone.standard, - shape: PrettyQrSmoothSymbol( - roundFactor: 0, - ), + shape: PrettyQrSmoothSymbol(roundFactor: 0), ), ), ), diff --git a/packages/nip07_event_signer/lib/src/nip07_event_signer_stub.dart b/packages/nip07_event_signer/lib/src/nip07_event_signer_stub.dart index f20c4aa3b..13808ba9b 100644 --- a/packages/nip07_event_signer/lib/src/nip07_event_signer_stub.dart +++ b/packages/nip07_event_signer/lib/src/nip07_event_signer_stub.dart @@ -8,8 +8,7 @@ class Nip07EventSigner implements EventSigner { BehaviorSubject>.seeded([]); Nip07EventSigner({this.cachedPublicKey, int maxConcurrentRequests = 100}) - : assert(maxConcurrentRequests > 0, - 'maxConcurrentRequests must be > 0'); + : assert(maxConcurrentRequests > 0, 'maxConcurrentRequests must be > 0'); @override bool canSign() { diff --git a/packages/nip07_event_signer/lib/src/nip07_event_signer_web.dart b/packages/nip07_event_signer/lib/src/nip07_event_signer_web.dart index 7a2fd9108..99019b270 100644 --- a/packages/nip07_event_signer/lib/src/nip07_event_signer_web.dart +++ b/packages/nip07_event_signer/lib/src/nip07_event_signer_web.dart @@ -11,9 +11,7 @@ class _PendingRequestEntry { _PendingRequestEntry(this.completer, this.request); } -class Nip07EventSigner - with ConcurrencyLimiterMixin - implements EventSigner { +class Nip07EventSigner with ConcurrencyLimiterMixin implements EventSigner { String? cachedPublicKey; final _pendingRequests = {}; @@ -33,8 +31,7 @@ class Nip07EventSigner Nip07EventSigner({ this.cachedPublicKey, this.maxConcurrentRequests = defaultMaxConcurrentRequests, - }) : assert(maxConcurrentRequests > 0, - 'maxConcurrentRequests must be > 0'); + }) : assert(maxConcurrentRequests > 0, 'maxConcurrentRequests must be > 0'); String _generateRequestId() { return 'nip07_${DateTime.now().millisecondsSinceEpoch}_${_requestCounter++}'; @@ -78,11 +75,11 @@ class Nip07EventSigner // appear in `pendingRequests` so the UI sees the full backlog. If the // request was cancelled while queued, skip the extension call entirely. runThrottled(() async { - if (!_pendingRequests.containsKey(requestId)) { - throw SignerRequestCancelledException(requestId); - } - return await operation(); - }) + if (!_pendingRequests.containsKey(requestId)) { + throw SignerRequestCancelledException(requestId); + } + return await operation(); + }) .then((result) { if (!completer.isCompleted) { completer.complete(result);