Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion packages/ndk/lib/domain_layer/usecases/metadatas/metadatas.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,28 @@ 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 {
final Requests _requests;
final CacheManager _cacheManager;
final Broadcast _broadcast;
final Accounts _accounts;
final UserRelayLists _userRelayLists;

/// create a new instance of Metadatas
Metadatas({
required Requests requests,
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) {
Expand All @@ -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
Expand All @@ -60,6 +65,7 @@ class Metadatas {
name: 'metadata',
cacheRead: !forceRefresh,
timeout: idleTimeout,
explicitRelays: writeRelays,
filters: [
Filter(
kinds: [Metadata.kKind],
Expand Down Expand Up @@ -92,6 +98,24 @@ class Metadatas {
return metadata;
}

/// NIP-65 outbox: metadata is expected on the author's write relays
Future<List<String>?> _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<List<Metadata>> loadMetadatas(
List<String> pubKeys,
Expand Down
5 changes: 3 additions & 2 deletions packages/ndk/lib/presentation_layer/init.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
37 changes: 17 additions & 20 deletions packages/ndk/test/mocks/mock_relay.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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 =
Expand All @@ -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
Expand Down
160 changes: 160 additions & 0 deletions packages/ndk/test/mocks/mock_relay_multi_kind_filter_test.dart
Original file line number Diff line number Diff line change
@@ -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],
),
);
Comment on lines +27 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Destroy each NDK instance through teardown.

If an assertion fails before Lines 155-156, neither destroy() call runs. The test can leave relay connections and retry work active.

  • packages/ndk/test/mocks/mock_relay_multi_kind_filter_test.dart#L27-L33: Register addTearDown(() => ndkWriter.destroy()) immediately after construction.
  • packages/ndk/test/mocks/mock_relay_multi_kind_filter_test.dart#L112-L119: Register addTearDown(() => ndkReader.destroy()) immediately after construction.
📍 Affects 1 file
  • packages/ndk/test/mocks/mock_relay_multi_kind_filter_test.dart#L27-L33 (this comment)
  • packages/ndk/test/mocks/mock_relay_multi_kind_filter_test.dart#L112-L119
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ndk/test/mocks/mock_relay_multi_kind_filter_test.dart` around lines
27 - 33, Register teardown callbacks immediately after constructing ndkWriter at
packages/ndk/test/mocks/mock_relay_multi_kind_filter_test.dart:27-33 and
ndkReader at
packages/ndk/test/mocks/mock_relay_multi_kind_filter_test.dart:112-119, calling
each instance’s destroy() method so cleanup runs even when assertions fail.


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();
},
);
});
}
70 changes: 70 additions & 0 deletions packages/ndk/test/usecases/metadatas/metadata_nip65_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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);
});
}
Loading