contextvm_sdk/transport/client/
relay_resolution.rs1use std::sync::Arc;
9use std::time::Duration;
10
11use nostr_sdk::prelude::*;
12use tokio::pin;
13
14use crate::relay::RelayPool;
15use crate::transport::client::server_relay_discovery::{
16 fetch_server_relay_list, select_operational_relay_urls,
17};
18
19const LOG_TARGET: &str = "contextvm_sdk::transport::client::relay_resolution";
20
21pub struct RelayResolutionConfig {
23 pub configured_relay_urls: Vec<String>,
25 pub hinted_relay_urls: Vec<String>,
27 pub discovery_relay_urls: Vec<String>,
29 pub fallback_operational_relay_urls: Vec<String>,
31 pub server_pubkey: PublicKey,
33 pub signer: Arc<dyn NostrSigner>,
35 pub timeout: Duration,
37}
38
39pub async fn resolve_operational_relays(config: RelayResolutionConfig) -> Vec<String> {
51 if !config.configured_relay_urls.is_empty() {
53 return config.configured_relay_urls;
54 }
55
56 if !config.hinted_relay_urls.is_empty() {
58 tracing::info!(
59 target: LOG_TARGET,
60 relay_count = config.hinted_relay_urls.len(),
61 "Using relay hints from server identity"
62 );
63 return config.hinted_relay_urls;
64 }
65
66 if config.discovery_relay_urls.is_empty() {
68 if !config.fallback_operational_relay_urls.is_empty() {
69 tracing::info!(
70 target: LOG_TARGET,
71 relay_count = config.fallback_operational_relay_urls.len(),
72 "Using configured fallback operational relays"
73 );
74 return config.fallback_operational_relay_urls;
75 }
76 return vec![];
77 }
78
79 let discovery_urls = config.discovery_relay_urls;
81 let last_resort_urls = discovery_urls.clone();
82 let fallback_urls = config.fallback_operational_relay_urls;
83 let server_pubkey = config.server_pubkey;
84 let signer = config.signer;
85 let timeout = config.timeout;
86
87 let discovery_fut = async {
88 let entries =
89 fetch_server_relay_list(&server_pubkey, &discovery_urls, signer.clone(), timeout)
90 .await
91 .unwrap_or_default();
92 select_operational_relay_urls(&entries)
93 };
94
95 let fallback_fut = connect_fallback_operational_relays(&fallback_urls, signer.clone(), timeout);
96
97 pin!(discovery_fut);
98 pin!(fallback_fut);
99
100 enum RaceWinner {
102 Discovery(Vec<String>),
103 Fallback(Vec<String>),
104 }
105
106 let winner = tokio::select! {
110 result = &mut discovery_fut => RaceWinner::Discovery(result),
111 result = &mut fallback_fut => RaceWinner::Fallback(result),
112 };
113
114 match winner {
115 RaceWinner::Discovery(urls) if !urls.is_empty() => {
116 tracing::info!(target: LOG_TARGET, relay_count = urls.len(), "Resolved operational relays");
117 return urls;
118 }
119 RaceWinner::Fallback(urls) if !urls.is_empty() => {
120 tracing::info!(target: LOG_TARGET, relay_count = urls.len(), "Resolved operational relays");
121 return urls;
122 }
123 RaceWinner::Discovery(_) => {
124 let fallback_result = fallback_fut.await;
126 if !fallback_result.is_empty() {
127 tracing::info!(target: LOG_TARGET, relay_count = fallback_result.len(), "Using configured fallback operational relays");
128 return fallback_result;
129 }
130 }
131 RaceWinner::Fallback(_) => {
132 let discovery_result = discovery_fut.await;
134 if !discovery_result.is_empty() {
135 tracing::info!(target: LOG_TARGET, relay_count = discovery_result.len(), "Resolved operational relays from server relay list");
136 return discovery_result;
137 }
138 }
139 }
140
141 tracing::warn!(
143 target: LOG_TARGET,
144 relay_count = last_resort_urls.len(),
145 "No operational relays discovered from kind 10002; falling back to discovery relays"
146 );
147 last_resort_urls
148}
149
150pub async fn connect_fallback_operational_relays(
156 fallback_urls: &[String],
157 signer: Arc<dyn NostrSigner>,
158 timeout: Duration,
159) -> Vec<String> {
160 if fallback_urls.is_empty() {
161 return vec![];
162 }
163
164 let pool = match RelayPool::new(signer).await {
165 Ok(p) => p,
166 Err(_) => return vec![],
167 };
168
169 let connect_result = tokio::time::timeout(timeout, pool.connect(fallback_urls)).await;
170
171 let _ = pool.disconnect().await;
172
173 match connect_result {
174 Ok(Ok(())) => fallback_urls.to_vec(),
175 Ok(Err(e)) => {
176 tracing::warn!(
177 target: LOG_TARGET,
178 error = %e,
179 "Fallback operational relay connection failed"
180 );
181 vec![]
182 }
183 Err(_) => {
184 tracing::warn!(
185 target: LOG_TARGET,
186 "Fallback operational relay probing timed out"
187 );
188 vec![]
189 }
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::relay::MockRelayPool;
197
198 fn make_signer() -> Arc<dyn NostrSigner> {
199 let keys = Keys::generate();
200 Arc::new(keys) as Arc<dyn NostrSigner>
201 }
202
203 fn make_config(
204 configured: Vec<String>,
205 hinted: Vec<String>,
206 discovery: Vec<String>,
207 fallback: Vec<String>,
208 ) -> RelayResolutionConfig {
209 RelayResolutionConfig {
210 configured_relay_urls: configured,
211 hinted_relay_urls: hinted,
212 discovery_relay_urls: discovery,
213 fallback_operational_relay_urls: fallback,
214 server_pubkey: Keys::generate().public_key(),
215 signer: make_signer(),
216 timeout: Duration::from_secs(5),
217 }
218 }
219
220 #[tokio::test]
221 async fn configured_relays_returned_immediately() {
222 let config = make_config(
223 vec!["wss://configured.example.com".to_string()],
224 vec!["wss://hinted.example.com".to_string()],
225 vec!["wss://discovery.example.com".to_string()],
226 vec!["wss://fallback.example.com".to_string()],
227 );
228 let result = resolve_operational_relays(config).await;
229 assert_eq!(result, vec!["wss://configured.example.com"]);
230 }
231
232 #[tokio::test]
233 async fn hinted_relays_returned_when_no_configured() {
234 let config = make_config(
235 vec![],
236 vec!["wss://hint1.example.com".to_string()],
237 vec!["wss://discovery.example.com".to_string()],
238 vec![],
239 );
240 let result = resolve_operational_relays(config).await;
241 assert_eq!(result, vec!["wss://hint1.example.com"]);
242 }
243
244 #[tokio::test]
245 async fn no_discovery_with_fallback_returns_fallback() {
246 let config = make_config(
247 vec![],
248 vec![],
249 vec![],
250 vec!["wss://fallback.example.com".to_string()],
251 );
252 let result = resolve_operational_relays(config).await;
253 assert_eq!(result, vec!["wss://fallback.example.com"]);
254 }
255
256 #[tokio::test]
257 async fn no_discovery_no_fallback_returns_empty() {
258 let config = make_config(vec![], vec![], vec![], vec![]);
259 let result = resolve_operational_relays(config).await;
260 assert!(result.is_empty());
261 }
262
263 #[tokio::test]
264 async fn both_empty_falls_back_to_discovery_relays() {
265 let config = make_config(
268 vec![],
269 vec![],
270 vec!["wss://bootstrap.example.com".to_string()],
271 vec![],
272 );
273 let result = resolve_operational_relays(config).await;
274 assert_eq!(result, vec!["wss://bootstrap.example.com"]);
275 }
276
277 #[tokio::test]
278 async fn connect_fallback_empty_urls_returns_empty() {
279 let result =
280 connect_fallback_operational_relays(&[], make_signer(), Duration::from_secs(1)).await;
281 assert!(result.is_empty());
282 }
283
284 #[tokio::test]
285 async fn discovery_with_mock_returns_relay_entries() {
286 use crate::core::constants::{tags, RELAY_LIST_METADATA_KIND};
288 use crate::transport::client::server_relay_discovery::fetch_relay_list_from_pool;
289
290 let pool = MockRelayPool::new();
291 let server_keys = Keys::generate();
292
293 let tags = vec![Tag::custom(
294 TagKind::Custom(tags::RELAY.into()),
295 vec!["wss://discovered.example.com"],
296 )];
297 let event = EventBuilder::new(Kind::Custom(RELAY_LIST_METADATA_KIND), "")
298 .tags(tags)
299 .custom_created_at(Timestamp::from(1000u64))
300 .sign_with_keys(&server_keys)
301 .unwrap();
302 pool.inject_event(event).await;
303
304 let entries =
305 fetch_relay_list_from_pool(&server_keys.public_key(), &pool, Duration::from_secs(5))
306 .await
307 .unwrap();
308 let urls = select_operational_relay_urls(&entries);
309 assert_eq!(urls, vec!["wss://discovered.example.com"]);
310 }
311
312 #[tokio::test]
313 async fn marker_precedence_unmarked_preferred() {
314 use crate::core::constants::{tags, RELAY_LIST_METADATA_KIND};
315 use crate::transport::client::server_relay_discovery::fetch_relay_list_from_pool;
316
317 let pool = MockRelayPool::new();
318 let server_keys = Keys::generate();
319
320 let tags = vec![
321 Tag::custom(
322 TagKind::Custom(tags::RELAY.into()),
323 vec!["wss://unmarked.example.com"],
324 ),
325 Tag::custom(
326 TagKind::Custom(tags::RELAY.into()),
327 vec!["wss://read.example.com", "read"],
328 ),
329 ];
330 let event = EventBuilder::new(Kind::Custom(RELAY_LIST_METADATA_KIND), "")
331 .tags(tags)
332 .custom_created_at(Timestamp::from(1000u64))
333 .sign_with_keys(&server_keys)
334 .unwrap();
335 pool.inject_event(event).await;
336
337 let entries =
338 fetch_relay_list_from_pool(&server_keys.public_key(), &pool, Duration::from_secs(5))
339 .await
340 .unwrap();
341 let urls = select_operational_relay_urls(&entries);
342 assert_eq!(urls, vec!["wss://unmarked.example.com"]);
343 }
344
345 #[test]
346 fn with_discovery_relay_urls_overrides_bootstrap() {
347 use crate::transport::client::NostrClientTransportConfig;
348
349 let config = NostrClientTransportConfig::default()
350 .with_discovery_relay_urls(vec!["wss://custom-discovery.example.com".to_string()]);
351 assert_eq!(
352 config.discovery_relay_urls,
353 Some(vec!["wss://custom-discovery.example.com".to_string()])
354 );
355 }
356
357 #[test]
358 fn with_fallback_operational_relay_urls_stored_separately() {
359 use crate::transport::client::NostrClientTransportConfig;
360
361 let config = NostrClientTransportConfig::default()
362 .with_fallback_operational_relay_urls(vec!["wss://fallback.example.com".to_string()]);
363 assert_eq!(
364 config.fallback_operational_relay_urls,
365 Some(vec!["wss://fallback.example.com".to_string()])
366 );
367 assert!(config.relay_urls.is_empty());
368 }
369}