From 085fb480bce276aea22c135d8ec3f0148de46904 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Tue, 18 Aug 2026 13:15:09 +0200 Subject: [PATCH] feat: expose the outcome of a request on each relay --- .../entities/relay_request_outcome.dart | 52 ++++ .../entities/request_response.dart | 34 ++- .../domain_layer/entities/request_state.dart | 58 ++++ .../domain_layer/usecases/relay_manager.dart | 18 +- .../usecases/relay_sets_engine.dart | 7 +- .../usecases/requests/concurrency_check.dart | 4 +- .../usecases/requests/requests.dart | 29 +- packages/ndk/lib/entities.dart | 1 + packages/ndk/lib/ndk.dart | 1 + packages/ndk/test/mocks/mock_relay.dart | 22 ++ .../ndk/test/relays/relay_outcomes_test.dart | 258 ++++++++++++++++++ 11 files changed, 474 insertions(+), 10 deletions(-) create mode 100644 packages/ndk/lib/domain_layer/entities/relay_request_outcome.dart create mode 100644 packages/ndk/test/relays/relay_outcomes_test.dart diff --git a/packages/ndk/lib/domain_layer/entities/relay_request_outcome.dart b/packages/ndk/lib/domain_layer/entities/relay_request_outcome.dart new file mode 100644 index 000000000..33e8a3659 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/relay_request_outcome.dart @@ -0,0 +1,52 @@ +/// What a relay did with a request. +/// +/// Declared in collapse precedence order: one request can run on several +/// connections to the same relay, an anonymous one handed over to an +/// authenticated one, and the url reports the first of these its connections +/// reached. +enum RelayRequestOutcomeType { + /// the relay has not ended the request, it may still send events + pending, + + /// the relay sent an EOSE, it has given everything it stored + eose, + + /// the relay ended the request itself with a CLOSED + closed, + + /// the connection went away before the relay ended the request + disconnected, + + /// the request ran into its timeout before the relay ended it + timedOut, + + /// the request never reached the relay + notSent, +} + +/// What a request ended with on a single relay +class RelayRequestOutcome { + /// what the relay did with the request + final RelayRequestOutcomeType type; + + /// why, when there is a reason to give: the message of a CLOSED, or what + /// kept the request from being sent + final String? message; + + /// creates a new [RelayRequestOutcome] + const RelayRequestOutcome(this.type, {this.message}); + + @override + String toString() => message == null ? type.name : '${type.name}: $message'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RelayRequestOutcome && + runtimeType == other.runtimeType && + type == other.type && + message == other.message; + + @override + int get hashCode => Object.hash(type, message); +} diff --git a/packages/ndk/lib/domain_layer/entities/request_response.dart b/packages/ndk/lib/domain_layer/entities/request_response.dart index 7ce026602..9bf4f15a8 100644 --- a/packages/ndk/lib/domain_layer/entities/request_response.dart +++ b/packages/ndk/lib/domain_layer/entities/request_response.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'nip_01_event.dart'; +import 'relay_request_outcome.dart'; // coverage:ignore-start @@ -15,12 +16,43 @@ class NdkResponse { /// as they arrive from the nostr request. final Stream stream; + final Map Function() _relayOutcomes; + + final Future> _relayOutcomesDone; + /// A future that resolves to a list of all [Nip01Event] objects /// once the request is complete (EOSE rcv). Future> get future => stream.toList(); + /// What the request ended with on each relay it was sent to, as it stands + /// now, keyed by relay url. + /// + /// Reading it tells an exhausted relay from a silent one: a relay that has + /// not answered yet is [RelayRequestOutcomeType.pending], which is what a + /// live subscription shows for as long as it runs. + Map get relayOutcomes => _relayOutcomes(); + + /// [relayOutcomes] once the request is over, either because every relay + /// ended it or because it ran into its timeout. + /// + /// A subscription is only over once it is closed, so this resolves on + /// `closeSubscription` for one. + Future> get relayOutcomesDone => + _relayOutcomesDone; + /// Creates a new [NdkResponse] instance. - NdkResponse(this.requestId, this.stream); + NdkResponse( + this.requestId, + this.stream, { + Map Function()? relayOutcomes, + Future>? relayOutcomesDone, + }) : _relayOutcomes = relayOutcomes ?? _noOutcomes, + _relayOutcomesDone = + relayOutcomesDone ?? + Future.value(const {}); + + static Map _noOutcomes() => + const {}; } // coverage:ignore-end diff --git a/packages/ndk/lib/domain_layer/entities/request_state.dart b/packages/ndk/lib/domain_layer/entities/request_state.dart index 497f88db5..3b8891943 100644 --- a/packages/ndk/lib/domain_layer/entities/request_state.dart +++ b/packages/ndk/lib/domain_layer/entities/request_state.dart @@ -7,6 +7,7 @@ import 'filter.dart'; import 'ndk_request.dart'; import 'nip_01_event.dart'; import 'relay_connection_key.dart'; +import 'relay_request_outcome.dart'; /// Single relay request state class RelayRequestState { @@ -19,6 +20,12 @@ class RelayRequestState { bool receivedEOSE = false; bool receivedClosed = false; + /// message the relay sent with its CLOSED, null when it sent none + String? closedMessage; + + /// set when the connection was gone before the relay ended the request + bool connectionGone = false; + /// set while this connection authenticates to satisfy the request: the relay /// closed it, but it is on its way back and must not count as finished bool retryingAuth = false; @@ -71,6 +78,13 @@ class RequestState { /// timeout duration, closes all streams Duration? timeoutDuration; + /// set when the request ended on its timeout instead of on the relays + bool timedOut = false; + + /// request this one was merged into by the concurrency check, when its stream + /// got replaced by an identical request already in flight + RequestState? servedBy; + /// called when timeout is triggered Function(RequestState)? onTimeout; @@ -105,6 +119,7 @@ class RequestState { void _startTimeout(Duration duration) { _timeoutStartedAt = DateTime.now(); _timeout = Timer(duration, () { + timedOut = true; onTimeout?.call(this); close(); }); @@ -137,6 +152,49 @@ class RequestState { !element.retryingAuth, ); + /// What the request ended with on each relay it was sent to, as it stands now + /// + /// Keyed by relay url: several connections to one relay collapse into the + /// outcome that comes first in [RelayRequestOutcomeType]. + Map get relayOutcomes { + final served = servedBy; + if (served != null) { + return served.relayOutcomes; + } + + final outcomes = {}; + for (final request in requests.values) { + final outcome = _outcomeOf(request); + final current = outcomes[request.url]; + if (current == null || outcome.type.index < current.type.index) { + outcomes[request.url] = outcome; + } + } + return outcomes; + } + + RelayRequestOutcome _outcomeOf(RelayRequestState request) { + if (request.retryingAuth) { + return const RelayRequestOutcome(RelayRequestOutcomeType.pending); + } + if (request.receivedEOSE) { + return const RelayRequestOutcome(RelayRequestOutcomeType.eose); + } + if (request.receivedClosed) { + return RelayRequestOutcome( + RelayRequestOutcomeType.closed, + message: request.closedMessage, + ); + } + if (request.connectionGone) { + return const RelayRequestOutcome(RelayRequestOutcomeType.disconnected); + } + if (timedOut) { + return const RelayRequestOutcome(RelayRequestOutcomeType.timedOut); + } + return const RelayRequestOutcome(RelayRequestOutcomeType.pending); + } + /// Adds single relay request to the state void addRequest(RelayConnectionKey key, List filters) { if (!requests.containsKey(key)) { diff --git a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart index 84e132709..e255da960 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart @@ -1235,7 +1235,7 @@ class RelayManager { // Check if this is an auth-required CLOSED message if (message != null && message.startsWith("auth-required")) { - _handleClosedAuthRequired(id, relayConnectivity); + _handleClosedAuthRequired(id, relayConnectivity, message); return; } @@ -1247,7 +1247,7 @@ class RelayManager { ); RelayRequestState? request = state.requests[relayConnectivity.key]; if (request != null) { - _endRequestOnRelay(relayConnectivity, id, request); + _endRequestOnRelay(relayConnectivity, id, request, message); } _checkNetworkClose(state); @@ -1263,6 +1263,7 @@ class RelayManager { RelayRequestState request, ) { request.receivedClosed = false; + request.closedMessage = null; request.retryingAuth = false; send( relayConnectivity, @@ -1276,8 +1277,10 @@ class RelayManager { RelayConnectivity relayConnectivity, String reqId, RelayRequestState request, + String? message, ) { request.receivedClosed = true; + request.closedMessage = message; relayConnectivity.stats.openRequestIds.remove(reqId); } @@ -1295,6 +1298,7 @@ class RelayManager { void _handleClosedAuthRequired( String reqId, RelayConnectivity relayConnectivity, + String message, ) { final state = globalState.inFlightRequests[reqId]; if (state == null) { @@ -1314,7 +1318,7 @@ class RelayManager { } // whatever we do next, the relay just closed this one on this connection - _endRequestOnRelay(relayConnectivity, reqId, request); + _endRequestOnRelay(relayConnectivity, reqId, request, message); if (!key.isAnonymous) { if (_authenticatedConnections.contains(key)) { @@ -1376,6 +1380,8 @@ class RelayManager { } if (bound == null) { retry.receivedClosed = true; + retry.closedMessage = + "auth-required: no authenticated connection could be opened"; _checkNetworkClose(state); return; } @@ -1533,6 +1539,12 @@ class RelayManager { ); if (didAllRelaysFinish) { + for (final key in myNotConnectedRelays) { + final request = state.requests[key]!; + if (!request.receivedEOSE && !request.receivedClosed) { + request.connectionGone = true; + } + } state.networkController.close(); updateRelayConnectivity(); } 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 29c7ea8c0..9545307e9 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart @@ -333,7 +333,12 @@ class RelaySetsEngine implements NetworkEngine { }); } - return NdkResponse(state.id, state.stream); + return NdkResponse( + state.id, + state.stream, + relayOutcomes: () => state.relayOutcomes, + relayOutcomesDone: state.controller.done.then((_) => state.relayOutcomes), + ); } @override diff --git a/packages/ndk/lib/domain_layer/usecases/requests/concurrency_check.dart b/packages/ndk/lib/domain_layer/usecases/requests/concurrency_check.dart index b62034eb5..da72b181f 100644 --- a/packages/ndk/lib/domain_layer/usecases/requests/concurrency_check.dart +++ b/packages/ndk/lib/domain_layer/usecases/requests/concurrency_check.dart @@ -30,8 +30,10 @@ class ConcurrencyCheck { // add already running stream to duplicate request // When original stream ends, close the duplicate's controller + final served = _globalState.inFlightRequests[hash]!; + requestState.servedBy = served; requestState.controller - .addStream(_globalState.inFlightRequests[hash]!.stream) + .addStream(served.stream) .then((_) => requestState.controller.close()); return true; diff --git a/packages/ndk/lib/domain_layer/usecases/requests/requests.dart b/packages/ndk/lib/domain_layer/usecases/requests/requests.dart index d2dde043c..dbc9ab500 100644 --- a/packages/ndk/lib/domain_layer/usecases/requests/requests.dart +++ b/packages/ndk/lib/domain_layer/usecases/requests/requests.dart @@ -14,6 +14,7 @@ import '../../entities/ndk_request.dart'; import '../../entities/nip_01_event.dart'; import '../../entities/relay_connectivity.dart'; import '../../entities/relay_set.dart'; +import '../../entities/relay_request_outcome.dart'; import '../../entities/request_response.dart'; import '../../entities/request_state.dart'; import '../../repositories/event_verifier.dart'; @@ -302,7 +303,12 @@ class Requests { NdkResponse requestNostrEvent(NdkRequest request) { final state = RequestState(request); - final response = NdkResponse(state.id, state.stream); + final response = NdkResponse( + state.id, + state.stream, + relayOutcomes: () => state.relayOutcomes, + relayOutcomesDone: state.controller.done.then((_) => state.relayOutcomes), + ); final concurrency = ConcurrencyCheck(_globalState); @@ -409,6 +415,11 @@ class Requests { final aggregatedController = ReplaySubject(); final seenEventIds = {}; + // a relay is paginated by its own sequence of requests, so what it ended + // with is what its last page ended with + final relayOutcomes = {}; + final relayOutcomesDone = Completer>(); + Future paginate() async { final since = filter.since; @@ -432,6 +443,7 @@ class Requests { ); final initialEvents = await initialResponse.future; + relayOutcomes.addAll(initialResponse.relayOutcomes); // Emit initial events and discover relays final relayState = {}; @@ -506,7 +518,9 @@ class Requests { ), ); - return MapEntry(relay, await response.future); + final pageEvents = await response.future; + relayOutcomes.addAll(response.relayOutcomes); + return MapEntry(relay, pageEvents); }); final results = await Future.wait(futures); @@ -545,9 +559,16 @@ class Requests { } // Start pagination asynchronously - paginate(); + paginate().whenComplete( + () => relayOutcomesDone.complete(Map.of(relayOutcomes)), + ); - return NdkResponse(requestId, aggregatedController.stream); + return NdkResponse( + requestId, + aggregatedController.stream, + relayOutcomes: () => Map.of(relayOutcomes), + relayOutcomesDone: relayOutcomesDone.future, + ); } /// Records fetched ranges for each relay that received EOSE diff --git a/packages/ndk/lib/entities.dart b/packages/ndk/lib/entities.dart index 605ddab40..4bf877a3f 100644 --- a/packages/ndk/lib/entities.dart +++ b/packages/ndk/lib/entities.dart @@ -26,6 +26,7 @@ export 'domain_layer/entities/relay.dart'; export 'domain_layer/entities/relay_connection_key.dart'; export 'domain_layer/entities/relay_connectivity.dart'; export 'domain_layer/entities/relay_info.dart'; +export 'domain_layer/entities/relay_request_outcome.dart'; export 'domain_layer/entities/relay_set.dart'; export 'domain_layer/entities/relay_stats.dart'; export 'domain_layer/entities/request_response.dart'; diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index 99c1bf9d3..4add29dbe 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -27,6 +27,7 @@ export 'domain_layer/entities/contact_list.dart'; export 'domain_layer/entities/read_write.dart'; export 'domain_layer/entities/relay.dart'; export 'domain_layer/entities/relay_connection_key.dart'; +export 'domain_layer/entities/relay_request_outcome.dart'; export 'domain_layer/entities/relay_set.dart'; export 'domain_layer/entities/metadata.dart'; export 'domain_layer/entities/event_filter.dart'; diff --git a/packages/ndk/test/mocks/mock_relay.dart b/packages/ndk/test/mocks/mock_relay.dart index bd58da0f8..7e33f427b 100644 --- a/packages/ndk/test/mocks/mock_relay.dart +++ b/packages/ndk/test/mocks/mock_relay.dart @@ -95,6 +95,13 @@ class MockRelay { int rejectFirstEventPublishes; String rejectEventMessage; + /// when set, every REQ is answered with a CLOSED carrying this message + String? closeRequestsMessage; + + /// when true a REQ is recorded and left unanswered, the way a relay that goes + /// quiet on a request does + bool silenceRequests; + /// how many AUTH events are left unanswered, the way a relay that goes quiet /// in the middle of an authentication does. The next ones are answered int silenceFirstAuths; @@ -177,6 +184,8 @@ class MockRelay { this.signEventContentOverride, this.rejectFirstEventPublishes = 0, this.rejectEventMessage = 'rate-limited: retry later', + this.closeRequestsMessage, + this.silenceRequests = false, this.silenceFirstAuths = 0, int? explicitPort, }) : _nip65s = nip65s, @@ -457,6 +466,19 @@ class MockRelay { .putIfAbsent(webSocket, () => {}) .add(requestId); + final closeMessage = closeRequestsMessage; + if (closeMessage != null) { + _send( + webSocket, + jsonEncode(["CLOSED", requestId, closeMessage]), + ); + return; + } + + if (silenceRequests) { + return; + } + // Check auth: any authenticated user can access all data if (requireAuthForRequests && authenticatedPubkeys.isEmpty) { _send( diff --git a/packages/ndk/test/relays/relay_outcomes_test.dart b/packages/ndk/test/relays/relay_outcomes_test.dart new file mode 100644 index 000000000..dd0f7b96f --- /dev/null +++ b/packages/ndk/test/relays/relay_outcomes_test.dart @@ -0,0 +1,258 @@ +import 'package:ndk/entities.dart' as ndk_entities; +import 'package:ndk/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 '../mocks/mock_event_verifier.dart'; +import '../mocks/mock_relay.dart'; + +void main() { + for (final engine in NdkEngine.values) { + relayOutcomesTests(engine); + } + collapseTests(); +} + +/// What no relay can be made to do on demand: a connection that goes away, and +/// two connections to one relay ending differently. +void collapseTests() { + group('relay outcomes collapse', () { + const url = "wss://relay.example.com"; + final filter = Filter(kinds: [Nip01Event.kTextNodeKind]); + + ndk_entities.RequestState buildState() => ndk_entities.RequestState( + NdkRequest.subscription("outcome-test", filters: [filter]), + ); + + test('a connection that went away reports disconnected', () { + final state = buildState(); + final key = RelayConnectionKey.anonymous(url); + state.addRequest(key, [filter]); + state.requests[key]!.connectionGone = true; + + expect(state.relayOutcomes, { + url: RelayRequestOutcome(RelayRequestOutcomeType.disconnected), + }); + }); + + test('an eose on one connection wins over a closed on another', () { + final state = buildState(); + final anonymous = RelayConnectionKey.anonymous(url); + final authenticated = RelayConnectionKey.authenticated(url, "a" * 64); + state.addRequest(anonymous, [filter]); + state.addRequest(authenticated, [filter]); + state.requests[anonymous]! + ..receivedClosed = true + ..closedMessage = "auth-required: we only serve authenticated users"; + state.requests[authenticated]!.receivedEOSE = true; + + expect(state.relayOutcomes, { + url: RelayRequestOutcome(RelayRequestOutcomeType.eose), + }); + }); + + test('a connection retrying its authentication stays pending', () { + final state = buildState(); + final key = RelayConnectionKey.anonymous(url); + state.addRequest(key, [filter]); + state.requests[key]! + ..receivedClosed = true + ..retryingAuth = true; + + expect(state.relayOutcomes, { + url: RelayRequestOutcome(RelayRequestOutcomeType.pending), + }); + }); + }); +} + +/// Both engines track their requests in the same [RequestState], so both must +/// report the same outcomes. +void relayOutcomesTests(NdkEngine engine) { + group('relay outcomes [${engine.name}]', () { + final key1 = Bip340.generatePrivateKey(); + late MockRelay relay1; + late Ndk ndk; + + Nip01Event textNote(KeyPair key) => Nip01Utils.signWithPrivateKey( + event: Nip01Event( + kind: Nip01Event.kTextNodeKind, + pubKey: key.publicKey, + content: "some note from key1", + tags: [], + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + privateKey: key.privateKey!, + ); + + Ndk buildNdk() => Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: engine, + bootstrapRelays: [relay1.url], + ), + ); + + NdkResponse queryKey1({Duration? timeout}) => ndk.requests.query( + filter: Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ), + cacheRead: false, + timeout: timeout, + ); + + tearDown(() async { + await ndk.destroy(); + await relay1.stopServer(); + }); + + test('a relay that sends its EOSE reports eose', () async { + relay1 = MockRelay(name: "relay 1", signEvents: false); + await relay1.startServer(textNotes: {key1: textNote(key1)}); + ndk = buildNdk(); + + final response = queryKey1(); + final events = await response.future; + + expect(events, hasLength(1)); + expect(response.relayOutcomes, { + relay1.url: RelayRequestOutcome(RelayRequestOutcomeType.eose), + }); + expect(await response.relayOutcomesDone, response.relayOutcomes); + }); + + test('a request merged into an identical one reports its outcomes', () async { + relay1 = MockRelay(name: "relay 1", signEvents: false); + await relay1.startServer(textNotes: {key1: textNote(key1)}); + ndk = buildNdk(); + + final filter = Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ); + final first = ndk.requests.query(filter: filter); + final duplicate = ndk.requests.query(filter: filter); + + await first.future; + await duplicate.future; + + expect(duplicate.relayOutcomes, { + relay1.url: RelayRequestOutcome(RelayRequestOutcomeType.eose), + }); + }); + + test('a relay that closes the request reports closed and why', () async { + relay1 = MockRelay( + name: "relay 1", + signEvents: false, + closeRequestsMessage: "blocked: you are not whitelisted", + ); + await relay1.startServer(textNotes: {key1: textNote(key1)}); + ndk = buildNdk(); + + final response = queryKey1(); + await response.future; + + expect(response.relayOutcomes, { + relay1.url: RelayRequestOutcome( + RelayRequestOutcomeType.closed, + message: "blocked: you are not whitelisted", + ), + }); + }); + + test('a relay that stays silent reports timedOut', () async { + relay1 = MockRelay( + name: "relay 1", + signEvents: false, + silenceRequests: true, + ); + await relay1.startServer(textNotes: {key1: textNote(key1)}); + ndk = buildNdk(); + + final response = queryKey1(timeout: Duration(seconds: 1)); + await response.future; + + expect(response.relayOutcomes, { + relay1.url: RelayRequestOutcome(RelayRequestOutcomeType.timedOut), + }); + }); + + test('a subscription still waiting on a relay reports pending', () async { + relay1 = MockRelay( + name: "relay 1", + signEvents: false, + silenceRequests: true, + ); + await relay1.startServer(textNotes: {key1: textNote(key1)}); + ndk = buildNdk(); + + final response = ndk.requests.subscription( + filter: Filter( + kinds: [Nip01Event.kTextNodeKind], + authors: [key1.publicKey], + ), + cacheRead: false, + ); + await Future.delayed(Duration(seconds: 1)); + + expect(response.relayOutcomes, { + relay1.url: RelayRequestOutcome(RelayRequestOutcomeType.pending), + }); + + await ndk.requests.closeSubscription(response.requestId); + + expect(await response.relayOutcomesDone, { + relay1.url: RelayRequestOutcome(RelayRequestOutcomeType.pending), + }); + }); + + test( + 'a relay asking for auth we cannot answer reports its refusal', + () async { + relay1 = MockRelay( + name: "relay 1", + signEvents: false, + requireAuthForRequests: true, + ); + await relay1.startServer(textNotes: {key1: textNote(key1)}); + ndk = buildNdk(); + + final response = queryKey1(timeout: Duration(seconds: 5)); + await response.future; + + final outcome = response.relayOutcomes[relay1.url]; + expect(outcome?.type, RelayRequestOutcomeType.closed); + expect(outcome?.message, startsWith("auth-required")); + }, + ); + + test('the connections to one relay collapse into one outcome', () async { + relay1 = MockRelay( + name: "relay 1", + signEvents: false, + requireAuthForRequests: true, + ); + await relay1.startServer(textNotes: {key1: textNote(key1)}); + ndk = buildNdk(); + + ndk.accounts.loginPrivateKey( + pubkey: key1.publicKey, + privkey: key1.privateKey!, + ); + + final response = queryKey1(timeout: Duration(seconds: 5)); + final events = await response.future; + + // the anonymous attempt was closed with auth-required and handed over to + // an authenticated connection, which is the one that answered + expect(events, hasLength(1)); + expect(response.relayOutcomes, { + relay1.url: RelayRequestOutcome(RelayRequestOutcomeType.eose), + }); + }); + }); +}