diff --git a/packages/ndk/lib/domain_layer/usecases/metadatas/metadatas.dart b/packages/ndk/lib/domain_layer/usecases/metadatas/metadatas.dart index 36320248c..b0ad0f064 100644 --- a/packages/ndk/lib/domain_layer/usecases/metadatas/metadatas.dart +++ b/packages/ndk/lib/domain_layer/usecases/metadatas/metadatas.dart @@ -12,6 +12,7 @@ import '../../repositories/event_signer.dart'; import '../accounts/accounts.dart'; import '../broadcast/broadcast.dart'; import '../requests/requests.dart'; +import '../user_relay_lists/user_relay_lists.dart'; /// nostr metadata usecase class Metadatas { @@ -19,6 +20,7 @@ class Metadatas { final CacheManager _cacheManager; final Broadcast _broadcast; final Accounts _accounts; + final UserRelayLists _userRelayLists; /// create a new instance of Metadatas Metadatas({ @@ -26,10 +28,12 @@ class Metadatas { required CacheManager cacheManager, required Broadcast broadcast, required Accounts accounts, + required UserRelayLists userRelayLists, }) : _cacheManager = cacheManager, _requests = requests, _accounts = accounts, - _broadcast = broadcast; + _broadcast = broadcast, + _userRelayLists = userRelayLists; void _checkSigner() { if (!_accounts.canSign) { @@ -52,6 +56,7 @@ class Metadatas { ? await _cacheManager.loadMetadata(pubKey) : null; if (metadata == null || forceRefresh) { + final writeRelays = await _authorWriteRelays(pubKey); Metadata? loadedMetadata; try { await for (final event @@ -60,6 +65,7 @@ class Metadatas { name: 'metadata', cacheRead: !forceRefresh, timeout: idleTimeout, + explicitRelays: writeRelays, filters: [ Filter( kinds: [Metadata.kKind], @@ -92,6 +98,24 @@ class Metadatas { return metadata; } + /// NIP-65 outbox: metadata is expected on the author's write relays + Future?> _authorWriteRelays(String pubKey) async { + try { + final relayList = await _userRelayLists.getSingleUserRelayList(pubKey); + final writeRelays = relayList?.relays.entries + .where((entry) => entry.value.isWrite) + .map((entry) => entry.key) + .toList(); + if (writeRelays == null || writeRelays.isEmpty) { + return null; + } + return writeRelays; + } catch (e) { + Logger.log.w(() => "could not load relay list for $pubKey", error: e); + return null; + } + } + // TODO try to use generic query with cacheRead/Write mechanism Future> loadMetadatas( List pubKeys, diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index 2bd9614cc..023ef7c0a 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -276,18 +276,19 @@ class Initialization { accounts: accounts, ); - metadatas = Metadatas( + userRelayLists = UserRelayLists( requests: requests, cacheManager: _ndkConfig.cache, broadcast: broadcast, accounts: accounts, ); - userRelayLists = UserRelayLists( + metadatas = Metadatas( requests: requests, cacheManager: _ndkConfig.cache, broadcast: broadcast, accounts: accounts, + userRelayLists: userRelayLists, ); lists = Lists( diff --git a/packages/ndk/test/mocks/mock_relay.dart b/packages/ndk/test/mocks/mock_relay.dart index 6aa8cb3b5..aaa835738 100644 --- a/packages/ndk/test/mocks/mock_relay.dart +++ b/packages/ndk/test/mocks/mock_relay.dart @@ -482,7 +482,7 @@ class MockRelay { ); } // Match against metadatas - else if (filter.kinds != null && + if (filter.kinds != null && filter.kinds!.contains(Metadata.kKind) && filter.authors != null && filter.authors!.isNotEmpty) { @@ -497,7 +497,7 @@ class MockRelay { ); } // Match against NIP-85 assertions (kinds 30382-30385) - else if (filter.kinds != null && + if (filter.kinds != null && filter.kinds!.any((k) => k >= 30382 && k <= 30385) && filter.authors != null && filter.authors!.isNotEmpty) { @@ -514,9 +514,22 @@ class MockRelay { ); } // General event matching (storedEvents and textNotes) - else { + eventsForThisFilter.addAll( + _storedEvents.where((event) { + bool kindMatches = + filter.kinds == null || filter.kinds!.contains(event.kind); + bool authorMatches = + filter.authors == null || filter.authors!.contains(event.pubKey); + bool idsMatches = + filter.ids == null || filter.ids!.contains(event.id); + bool timeMatches = _matchesTimeFilter(event, filter); + return kindMatches && authorMatches && idsMatches && timeMatches; + }).toList(), + ); + + if (textNotes != null) { eventsForThisFilter.addAll( - _storedEvents.where((event) { + textNotes!.values.where((event) { bool kindMatches = filter.kinds == null || filter.kinds!.contains(event.kind); bool authorMatches = @@ -528,22 +541,6 @@ class MockRelay { return kindMatches && authorMatches && idsMatches && timeMatches; }).toList(), ); - - if (textNotes != null) { - eventsForThisFilter.addAll( - textNotes!.values.where((event) { - bool kindMatches = - filter.kinds == null || filter.kinds!.contains(event.kind); - bool authorMatches = - filter.authors == null || - filter.authors!.contains(event.pubKey); - bool idsMatches = - filter.ids == null || filter.ids!.contains(event.id); - bool timeMatches = _matchesTimeFilter(event, filter); - return kindMatches && authorMatches && idsMatches && timeMatches; - }).toList(), - ); - } } // Match against NIP-65s diff --git a/packages/ndk/test/mocks/mock_relay_multi_kind_filter_test.dart b/packages/ndk/test/mocks/mock_relay_multi_kind_filter_test.dart new file mode 100644 index 000000000..83780e4c1 --- /dev/null +++ b/packages/ndk/test/mocks/mock_relay_multi_kind_filter_test.dart @@ -0,0 +1,160 @@ +import 'package:ndk/domain_layer/entities/nip_65.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:test/test.dart'; + +import 'mock_event_verifier.dart'; +import 'mock_relay.dart'; + +void main() { + group('MockRelay multi-kind filter matching', () { + late MockRelay mockRelay; + + setUp(() async { + mockRelay = MockRelay(name: 'multi-kind-filter-test-relay'); + await mockRelay.startServer(); + }); + + tearDown(() async { + await mockRelay.stopServer(); + }); + + test( + 'a filter with kinds [10002, 3, 0, 30382] should return stored events of all kinds', + () async { + final keyPair = Bip340.generatePrivateKey(); + + final ndkWriter = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + final relayListEvent = Nip01Event( + pubKey: keyPair.publicKey, + kind: Nip65.kKind, + tags: [ + ['r', 'wss://write.example.com'], + ], + content: '', + createdAt: now - 1, + ); + final signedRelayList = Nip01Utils.signWithPrivateKey( + event: relayListEvent, + privateKey: keyPair.privateKey!, + ); + await ndkWriter.broadcast + .broadcast( + nostrEvent: signedRelayList, + specificRelays: [mockRelay.url], + ) + .broadcastDoneFuture; + + final contactListEvent = Nip01Event( + pubKey: keyPair.publicKey, + kind: ContactList.kKind, + tags: [ + ['p', keyPair.publicKey], + ], + content: '', + createdAt: now, + ); + final signedContactList = Nip01Utils.signWithPrivateKey( + event: contactListEvent, + privateKey: keyPair.privateKey!, + ); + await ndkWriter.broadcast + .broadcast( + nostrEvent: signedContactList, + specificRelays: [mockRelay.url], + ) + .broadcastDoneFuture; + + final metadataEvent = Metadata( + pubKey: keyPair.publicKey, + name: 'multi-kind-profile', + ).toEvent(); + final signedMetadata = Nip01Utils.signWithPrivateKey( + event: metadataEvent, + privateKey: keyPair.privateKey!, + ); + await ndkWriter.broadcast + .broadcast( + nostrEvent: signedMetadata, + specificRelays: [mockRelay.url], + ) + .broadcastDoneFuture; + + final assertionEvent = Nip01Event( + pubKey: keyPair.publicKey, + kind: 30382, + tags: [ + ['d', keyPair.publicKey], + ], + content: '', + createdAt: now, + ); + final signedAssertion = Nip01Utils.signWithPrivateKey( + event: assertionEvent, + privateKey: keyPair.privateKey!, + ); + await ndkWriter.broadcast + .broadcast( + nostrEvent: signedAssertion, + specificRelays: [mockRelay.url], + ) + .broadcastDoneFuture; + + // Fresh client to bypass any local cache + final ndkReader = Ndk( + NdkConfig( + cache: MemCacheManager(), + eventVerifier: MockEventVerifier(), + bootstrapRelays: [mockRelay.url], + ), + ); + + final received = await ndkReader.requests + .query( + filter: Filter( + kinds: [Nip65.kKind, ContactList.kKind, Metadata.kKind, 30382], + authors: [keyPair.publicKey], + ), + ) + .future; + + expect( + received.map((event) => event.id), + contains(signedContactList.id), + reason: 'The contact list should match the combined filter.', + ); + expect( + received.map((event) => event.id), + contains(signedMetadata.id), + reason: 'The metadata should match the combined filter.', + ); + expect( + received.map((event) => event.id), + contains(signedAssertion.id), + reason: + 'The stored kind 30382 event should match the combined filter.', + ); + expect( + received.map((event) => event.id), + contains(signedRelayList.id), + reason: + 'The stored kind 10002 event should match the combined filter; ' + 'the mock skips general event matching when a specialized ' + 'branch handled the filter.', + ); + + await ndkWriter.destroy(); + await ndkReader.destroy(); + }, + ); + }); +} diff --git a/packages/ndk/test/usecases/metadatas/metadata_nip65_test.dart b/packages/ndk/test/usecases/metadatas/metadata_nip65_test.dart new file mode 100644 index 000000000..e4ad203b1 --- /dev/null +++ b/packages/ndk/test/usecases/metadatas/metadata_nip65_test.dart @@ -0,0 +1,70 @@ +import 'package:test/test.dart'; +import 'package:ndk/domain_layer/entities/nip_65.dart'; +import 'package:ndk/domain_layer/entities/read_write_marker.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; + +import '../../mocks/mock_relay.dart'; + +void main() { + Future loadMetadataFollowsWriteRelay(NdkEngine engine) async { + final key = Bip340.generatePrivateKey(); + final metadata = Metadata(pubKey: key.publicKey, name: 'nip65-profile'); + + final profileRelay = MockRelay(name: 'profile relay'); + final signedMetadataEvent = Nip01Utils.signWithPrivateKey( + event: metadata.toEvent(), + privateKey: key.privateKey!, + ); + await profileRelay.startServer( + metadatas: {key.publicKey: signedMetadataEvent}, + ); + + final relayList = Nip65.fromMap(key.publicKey, { + profileRelay.url: ReadWriteMarker.readWrite, + }); + final signedRelayListEvent = Nip01Utils.signWithPrivateKey( + event: relayList.toEvent(), + privateKey: key.privateKey!, + ); + final relayListBootstrap = MockRelay(name: 'relay list bootstrap'); + await relayListBootstrap.startServer(); + + final ndk = Ndk( + NdkConfig( + eventVerifier: Bip340EventVerifier(), + cache: MemCacheManager(), + bootstrapRelays: [relayListBootstrap.url], + engine: engine, + ), + ); + + await ndk.relays.seedRelaysConnected; + + addTearDown(() async { + await ndk.destroy(); + await relayListBootstrap.stopServer(); + await profileRelay.stopServer(); + }); + + await ndk.broadcast + .broadcast( + nostrEvent: signedRelayListEvent, + specificRelays: [relayListBootstrap.url], + saveToCache: false, + ) + .broadcastDoneFuture; + + final loaded = await ndk.metadata.loadMetadata(key.publicKey); + + expect(loaded?.name, equals(metadata.name)); + } + + test('loadMetadata follows the author write relay (RELAY_SETS)', () async { + await loadMetadataFollowsWriteRelay(NdkEngine.RELAY_SETS); + }); + + test('loadMetadata follows the author write relay (JIT)', () async { + await loadMetadataFollowsWriteRelay(NdkEngine.JIT); + }); +}