Skip to main content

contextvm_sdk/transport/client/
relay_resolution.rs

1//! CEP-17 multi-stage relay resolution.
2//!
3//! Resolves operational relays via: config → nprofile hints → kind 10002
4//! discovery → fallback probing → bootstrap defaults.
5//! Mirrors the TS SDK's `resolveOperationalRelays()` and
6//! `connectFallbackOperationalRelays()`.
7
8use 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
21/// Inputs for multi-stage relay resolution.
22pub struct RelayResolutionConfig {
23    /// Explicitly configured relay URLs (stage 1).
24    pub configured_relay_urls: Vec<String>,
25    /// Relay hints from nprofile identity (stage 2).
26    pub hinted_relay_urls: Vec<String>,
27    /// Bootstrap relays for CEP-17 kind 10002 discovery (stage 4).
28    pub discovery_relay_urls: Vec<String>,
29    /// Fallback relays probed in parallel with discovery (stage 4).
30    pub fallback_operational_relay_urls: Vec<String>,
31    /// Server public key for kind 10002 filter.
32    pub server_pubkey: PublicKey,
33    /// Signer for temporary relay pool connections.
34    pub signer: Arc<dyn NostrSigner>,
35    /// Timeout for discovery and fallback probing.
36    pub timeout: Duration,
37}
38
39/// Resolve operational relay URLs through a multi-stage pipeline.
40///
41/// Stages (returns on first non-empty result):
42/// 1. Configured relays (explicit `relay_urls`)
43/// 2. Hinted relays (from nprofile)
44/// 3. If no discovery relays available: use fallback or return empty
45/// 4. Race CEP-17 discovery vs fallback probing (`tokio::select!`)
46/// 5. Sequential fallback if race winner was empty
47/// 6. Last resort: use discovery relay URLs as operational
48///
49/// Mirrors the TS SDK `resolveOperationalRelays()`.
50pub async fn resolve_operational_relays(config: RelayResolutionConfig) -> Vec<String> {
51    // Stage 1: configured relays
52    if !config.configured_relay_urls.is_empty() {
53        return config.configured_relay_urls;
54    }
55
56    // Stage 2: hinted relays (from nprofile)
57    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    // Stage 3: no discovery relays available
67    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    // Stage 4: race discovery vs fallback
80    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    // Race: first non-empty wins. If winner is empty, await the loser.
101    enum RaceWinner {
102        Discovery(Vec<String>),
103        Fallback(Vec<String>),
104    }
105
106    // When the winner is non-empty the losing future is dropped.
107    // If it was connect_fallback_operational_relays, the temporary
108    // pool disconnects when the Client drops -- resource leak is minimal.
109    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            // Discovery returned empty; await fallback
125            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            // Fallback returned empty; await discovery
133            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    // Stage 6: last resort — use discovery relays as operational
142    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
150/// Probe fallback operational relays for connectivity.
151///
152/// Creates a temporary pool, attempts to connect within `timeout`.
153/// Returns the fallback URLs on success, empty vec on failure.
154/// Mirrors the TS SDK `connectFallbackOperationalRelays()`.
155pub 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        // discovery_relay_urls are non-empty but the server has no kind 10002 event,
266        // and fallback is empty — last resort returns discovery URLs.
267        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        // Test the fetch path via fetch_relay_list_from_pool directly
287        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}