Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
56 changes: 30 additions & 26 deletions packages/ndk/lib/domain_layer/usecases/requests/requests.dart
Original file line number Diff line number Diff line change
Expand Up @@ -328,17 +328,27 @@ class Requests {
writeToCache: request.cacheWrite,
);

final networkEvents = <Nip01Event>[];
final trackedNetworkStream = _fetchedRanges == null
Comment thread
nogringo marked this conversation as resolved.
? preparedNetworkStream
: preparedNetworkStream.map((event) {
networkEvents.add(event);
return event;
});

// register listener
StreamResponseCleaner(
inputStreams: [preparedNetworkStream, state.cacheController.stream],
inputStreams: [trackedNetworkStream, state.cacheController.stream],
trackingSet: state.returnedIds,
outController: state.controller,
eventOutFilters: _eventOutFilters,
)();

// Record fetched ranges when network requests complete (EOSE received)
state.networkController.done.then((_) {
_recordFetchedRanges(state);
// Record fetched ranges once the response stream is closed, meaning the
// network stream has been fully drained. Closing on networkController.done
// would run before verification finished pushing events downstream.
state.controller.done.then((_) {
_recordFetchedRanges(state, networkEvents);
});

// cleanup on close
Expand Down Expand Up @@ -550,17 +560,17 @@ class Requests {
}

/// Records fetched ranges for each relay that received EOSE
/// - If events received: use min/max of event timestamps
/// - If no events + filter has since/until: use filter bounds
/// - If no events + no bounds: use 0 to now
void _recordFetchedRanges(RequestState state) {
/// - If events received: coverage starts at the oldest event received
/// - If no events: use the filter bounds (0 to now when unbounded)
///
/// [events] must only contain events received from relays during this
/// request. Cache hits would make the recorded range claim coverage the
/// relay never actually served.
void _recordFetchedRanges(RequestState state, List<Nip01Event> events) {
if (_fetchedRanges == null) return;

final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;

// Get all events from the replay subject
final events = state.controller.values.toList();

// Group events by source relay
final eventsByRelay = <String, List<Nip01Event>>{};
for (final event in events) {
Expand All @@ -579,23 +589,17 @@ class Requests {

// Record fetched range for each filter sent to this relay
for (final filter in relayState.filters) {
int since;
int until;
int since = filter.since ?? 0;
final int until = filter.until ?? now;

if (relayEvents != null && relayEvents.isNotEmpty) {
// Use oldest event timestamp for since, filter.until or now for until
// EOSE means relay has no more events, so fetched range extends to query end
final timestamps = relayEvents.map((e) => e.createdAt).toList();
since = timestamps.reduce((a, b) => a < b ? a : b);
until = filter.until ?? now;
} else if (filter.since != null || filter.until != null) {
// No events but filter has explicit bounds
since = filter.since ?? 0;
until = filter.until ?? now;
} else {
// No events, no bounds - relay has nothing, record 0 to now
since = 0;
until = now;
// A relay can cap a response below the requested limit, or with no
// limit in the filter at all (NIP-11 max_limit, which we don't read),
// so a full response is indistinguishable from a truncated one. Only
// claim coverage down to the oldest event received.
since = relayEvents
.map((e) => e.createdAt)
.reduce((a, b) => a < b ? a : b);
}

_fetchedRanges!.addRange(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,10 @@ void main() async {
);
expect(
relayFetchedRanges.ranges.first.since,
equals(since),
reason: 'Range since should match filter since',
equals(textNotes[key1]!.createdAt),
reason:
'Range since should start at the oldest event received, the '
'relay may have truncated anything older',
);
expect(
relayFetchedRanges.ranges.first.until,
Expand Down Expand Up @@ -297,6 +299,221 @@ void main() async {
},
);

test(
'limited query only records the range actually covered by the returned events',
timeout: const Timeout(Duration(seconds: 30)),
() async {
MockRelay relay1 = MockRelay(
name: "relay limit test",
explicitPort: 4204,
);
await relay1.startServer();

final ndk = Ndk(
NdkConfig(
eventVerifier: Bip340EventVerifier(),
cache: MemCacheManager(),
bootstrapRelays: [relay1.url],
fetchedRangesEnabled: true,
),
);
addTearDown(() async {
await relay1.stopServer();
await ndk.destroy();
});

final author = Bip340.generatePrivateKey();
ndk.accounts.loginPrivateKey(
privkey: author.privateKey!,
pubkey: author.publicKey,
);

await ndk.relays.seedRelaysConnected;

// 30 events, one per second, oldest first
final baseCreatedAt =
(DateTime.now().millisecondsSinceEpoch ~/ 1000) - 1000;
final createdAts = List.generate(30, (i) => baseCreatedAt + i);

for (final createdAt in createdAts) {
final broadcast = ndk.broadcast.broadcast(
nostrEvent: Nip01Event(
pubKey: author.publicKey,
kind: Nip01Event.kTextNodeKind,
content: "note $createdAt",
tags: [],
createdAt: createdAt,
),
specificRelays: [relay1.url],
);
await broadcast.broadcastDoneFuture;
}

final filter = Filter(
kinds: [Nip01Event.kTextNodeKind],
authors: [author.publicKey],
limit: 10,
);

final events = await ndk.requests
.query(filter: filter, cacheRead: false)
.future;

await Future.delayed(const Duration(milliseconds: 100));

final fetchedRanges = await ndk.fetchedRanges.getForFilter(filter);
final relayFetchedRanges = fetchedRanges[relay1.url];

expect(relayFetchedRanges, isNotNull);
expect(relayFetchedRanges!.ranges.length, equals(1));

final range = relayFetchedRanges.ranges.first;
final oldestReturned = events
.map((e) => e.createdAt)
.reduce((a, b) => a < b ? a : b);

expect(
oldestReturned,
equals(createdAts[20]),
reason: 'the 10 newest events are the ones returned',
);
expect(
range.since,
equals(oldestReturned),
reason:
'range must start at the oldest returned event, the 20 older '
'events were never fetched',
);
expect(
range.until,
greaterThanOrEqualTo(createdAts.last),
reason: 'range must cover up to the newest event',
);
expect(
relayFetchedRanges.reachedOldest,
isFalse,
reason: 'a limited query does not prove we reached the oldest event',
);

final gaps = await ndk.fetchedRanges.findGaps(
filter: filter,
since: createdAts.first,
until: createdAts.last,
);

expect(gaps.length, equals(1), reason: 'the 20 older events are a gap');
expect(gaps.first.since, equals(createdAts.first));
expect(gaps.first.until, equals(oldestReturned - 1));
},
);

test(
'relay capping below the requested limit still records the covered range only',
timeout: const Timeout(Duration(seconds: 30)),
() async {
// Relay serves at most 20 events, well below the 50 we ask for, the
// way a relay enforces its own NIP-11 max_limit
MockRelay relay1 = MockRelay(
name: "relay max limit test",
explicitPort: 4205,
maxEventsPerRequest: 20,
);
await relay1.startServer();

final ndk = Ndk(
NdkConfig(
eventVerifier: Bip340EventVerifier(),
cache: MemCacheManager(),
bootstrapRelays: [relay1.url],
fetchedRangesEnabled: true,
),
);
addTearDown(() async {
await relay1.stopServer();
await ndk.destroy();
});

final author = Bip340.generatePrivateKey();
ndk.accounts.loginPrivateKey(
privkey: author.privateKey!,
pubkey: author.publicKey,
);

await ndk.relays.seedRelaysConnected;

// 60 events, one per second, oldest first
final baseCreatedAt =
(DateTime.now().millisecondsSinceEpoch ~/ 1000) - 1000;
final createdAts = List.generate(60, (i) => baseCreatedAt + i);

for (final createdAt in createdAts) {
final broadcast = ndk.broadcast.broadcast(
nostrEvent: Nip01Event(
pubKey: author.publicKey,
kind: Nip01Event.kTextNodeKind,
content: "note $createdAt",
tags: [],
createdAt: createdAt,
),
specificRelays: [relay1.url],
);
await broadcast.broadcastDoneFuture;
}

final filter = Filter(
kinds: [Nip01Event.kTextNodeKind],
authors: [author.publicKey],
limit: 50,
);

final events = await ndk.requests
.query(filter: filter, cacheRead: false)
.future;

expect(
events.length,
equals(20),
reason: 'the relay caps the response below the requested limit',
);

await Future.delayed(const Duration(milliseconds: 100));

final fetchedRanges = await ndk.fetchedRanges.getForFilter(filter);
final relayFetchedRanges = fetchedRanges[relay1.url];

expect(relayFetchedRanges, isNotNull);
expect(relayFetchedRanges!.ranges.length, equals(1));

final oldestReturned = events
.map((e) => e.createdAt)
.reduce((a, b) => a < b ? a : b);

expect(
oldestReturned,
equals(createdAts[40]),
reason: 'the 20 newest events are the ones returned',
);
expect(
relayFetchedRanges.ranges.first.since,
equals(oldestReturned),
reason:
'getting fewer events than the requested limit does not mean the '
'response was complete, the relay may cap it on its own',
);
expect(relayFetchedRanges.reachedOldest, isFalse);

final gaps = await ndk.fetchedRanges.findGaps(
filter: filter,
since: createdAts.first,
until: createdAts.last,
);

expect(gaps.length, equals(1), reason: 'the 40 older events are a gap');
expect(gaps.first.since, equals(createdAts.first));
expect(gaps.first.until, equals(oldestReturned - 1));
},
);

test(
'should NOT record fetched ranges when relay requires auth and client cannot authenticate',
timeout: const Timeout(Duration(seconds: 5)),
Expand Down
Loading