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
27 changes: 18 additions & 9 deletions src/relay/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use tokio::sync::Mutex;
Expand Down Expand Up @@ -127,6 +128,13 @@ impl MockRelayPool {
pub async fn stored_events(&self) -> Vec<Event> {
self.inner.lock().await.events.clone()
}

/// Inject an externally-built event into the store without broadcasting.
///
/// Useful for seeding kind 10002 relay-list events for `fetch_events()` tests.
pub async fn inject_event(&self, event: Event) {
self.inner.lock().await.events.push(event);
}
}

impl Default for MockRelayPool {
Expand Down Expand Up @@ -235,19 +243,20 @@ impl RelayPoolTrait for MockRelayPool {
self.publish(builder).await
}

/// Return stored events matching the filter.
async fn fetch_events(
&self,
filter: Filter,
_timeout: std::time::Duration,
) -> Result<Vec<Event>> {
/// Return stored events that match the given filters' kind and author constraints.
async fn fetch_events(&self, filters: Vec<Filter>, _timeout: Duration) -> Result<Vec<Event>> {
let inner = self.inner.lock().await;
Ok(inner
let matched: Vec<Event> = inner
.events
.iter()
.filter(|e| filter.match_event(e, MatchEventOptions::default()))
.filter(|e| {
filters
.iter()
.any(|f| f.match_event(e, MatchEventOptions::default()))
})
.cloned()
.collect())
.collect();
Ok(matched)
}
}

Expand Down
32 changes: 20 additions & 12 deletions src/relay/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ pub trait RelayPoolTrait: Send + Sync {
async fn subscribe(&self, filters: Vec<Filter>) -> Result<()>;
/// Sign and publish an event to specific relay URLs.
async fn publish_to(&self, urls: &[String], builder: EventBuilder) -> Result<EventId>;
/// Fetch events matching a filter from connected relays.
async fn fetch_events(&self, filter: Filter, timeout: Duration) -> Result<Vec<Event>>;
/// Fetch events matching filters from connected relays.
async fn fetch_events(&self, filters: Vec<Filter>, timeout: Duration) -> Result<Vec<Event>>;
}

/// Relay pool wrapper for managing Nostr relay connections.
Expand Down Expand Up @@ -151,14 +151,22 @@ impl RelayPool {
Ok(output.val)
}

/// Fetch events matching a filter from connected relays.
pub async fn fetch_events(&self, filter: Filter, timeout: Duration) -> Result<Vec<Event>> {
let events = self
.client
.fetch_events(filter, timeout)
.await
.map_err(|e| Error::Transport(e.to_string()))?;
Ok(events.into_iter().collect())
/// Fetch events matching filters from connected relays.
pub async fn fetch_events(
&self,
filters: Vec<Filter>,
timeout: Duration,
) -> Result<Vec<Event>> {
let mut all_events = Vec::new();
for filter in filters {
let events = self
.client
.fetch_events(filter, timeout)
.await
.map_err(|e| Error::Transport(e.to_string()))?;
all_events.extend(events);
}
Ok(all_events)
}
}

Expand Down Expand Up @@ -207,7 +215,7 @@ impl RelayPoolTrait for RelayPool {
RelayPool::publish_to(self, urls, builder).await
}

async fn fetch_events(&self, filter: Filter, timeout: Duration) -> Result<Vec<Event>> {
RelayPool::fetch_events(self, filter, timeout).await
async fn fetch_events(&self, filters: Vec<Filter>, timeout: Duration) -> Result<Vec<Event>> {
RelayPool::fetch_events(self, filters, timeout).await
}
}
55 changes: 34 additions & 21 deletions src/transport/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
//! kind 25910 events, correlates responses via `e` tag.

pub mod correlation_store;
pub mod server_identity;
pub mod server_relay_discovery;

pub use correlation_store::ClientCorrelationStore;

Expand All @@ -17,7 +19,7 @@ use nostr_sdk::prelude::*;
use tokio_util::sync::CancellationToken;

use crate::core::constants::*;
use crate::core::error::{Error, Result};
use crate::core::error::Result;
use crate::core::serializers;
use crate::core::types::*;
use crate::core::validation;
Expand All @@ -34,7 +36,10 @@ const LOG_TARGET: &str = "contextvm_sdk::transport::client";
pub struct NostrClientTransportConfig {
/// Relay URLs to connect to.
pub relay_urls: Vec<String>,
/// The server's public key (hex).
/// The server's public key (hex, npub, or nprofile).
///
/// When an nprofile is provided, embedded relay hints are extracted and used
/// during CEP-17 relay resolution.
pub server_pubkey: String,
/// Encryption mode.
pub encryption_mode: EncryptionMode,
Expand Down Expand Up @@ -64,7 +69,7 @@ impl Default for NostrClientTransportConfig {
}

impl NostrClientTransportConfig {
/// Set the server's public key (hex).
/// Set the server's public key (hex, npub, or nprofile).
pub fn with_server_pubkey(mut self, pubkey: impl Into<String>) -> Self {
self.server_pubkey = pubkey.into();
self
Expand Down Expand Up @@ -101,6 +106,9 @@ pub struct NostrClientTransport {
base: BaseTransport,
config: NostrClientTransportConfig,
server_pubkey: PublicKey,
/// Populated from nprofile relay hints; used by relay resolution in `start()` (CEP-17).
#[allow(dead_code)]
hinted_relay_urls: Vec<String>,
/// Pending request event IDs awaiting responses.
pending_requests: ClientCorrelationStore,
/// CEP-35: one-shot flag for client discovery tag emission.
Expand Down Expand Up @@ -130,15 +138,16 @@ impl NostrClientTransport {
where
T: IntoNostrSigner,
{
let server_pubkey = PublicKey::from_hex(&config.server_pubkey).map_err(|error| {
tracing::error!(
target: LOG_TARGET,
error = %error,
server_pubkey = %config.server_pubkey,
"Invalid server pubkey"
);
Error::Other(format!("Invalid server pubkey: {error}"))
})?;
let (server_pubkey, hinted_relay_urls) =
server_identity::parse_server_identity(&config.server_pubkey).map_err(|error| {
tracing::error!(
target: LOG_TARGET,
error = %error,
server_pubkey = %config.server_pubkey,
"Invalid server pubkey"
);
error
})?;

let relay_pool: Arc<dyn RelayPoolTrait> =
Arc::new(RelayPool::new(signer).await.map_err(|error| {
Expand Down Expand Up @@ -169,6 +178,7 @@ impl NostrClientTransport {
},
config,
server_pubkey,
hinted_relay_urls,
pending_requests: ClientCorrelationStore::new(),
has_sent_discovery_tags: AtomicBool::new(false),
discovered_server_capabilities: Arc::new(Mutex::new(PeerCapabilities::default())),
Expand All @@ -187,15 +197,16 @@ impl NostrClientTransport {
config: NostrClientTransportConfig,
relay_pool: Arc<dyn RelayPoolTrait>,
) -> Result<Self> {
let server_pubkey = PublicKey::from_hex(&config.server_pubkey).map_err(|error| {
tracing::error!(
target: LOG_TARGET,
error = %error,
server_pubkey = %config.server_pubkey,
"Invalid server pubkey"
);
Error::Other(format!("Invalid server pubkey: {error}"))
})?;
let (server_pubkey, hinted_relay_urls) =
server_identity::parse_server_identity(&config.server_pubkey).map_err(|error| {
tracing::error!(
target: LOG_TARGET,
error = %error,
server_pubkey = %config.server_pubkey,
"Invalid server pubkey"
);
error
})?;

let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let seen_gift_wrap_ids = Arc::new(Mutex::new(LruCache::new(
Expand All @@ -217,6 +228,7 @@ impl NostrClientTransport {
},
config,
server_pubkey,
hinted_relay_urls,
pending_requests: ClientCorrelationStore::new(),
has_sent_discovery_tags: AtomicBool::new(false),
discovered_server_capabilities: Arc::new(Mutex::new(PeerCapabilities::default())),
Expand Down Expand Up @@ -1092,6 +1104,7 @@ mod tests {
..Default::default()
},
server_pubkey: keys.public_key(),
hinted_relay_urls: vec![],
pending_requests: ClientCorrelationStore::new(),
has_sent_discovery_tags: AtomicBool::new(false),
discovered_server_capabilities: Arc::new(Mutex::new(PeerCapabilities::default())),
Expand Down
102 changes: 102 additions & 0 deletions src/transport/client/server_identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//! Server identity parsing for CEP-17 client-side relay discovery.
//!
//! Accepts hex pubkeys, npub (NIP-19), or nprofile (NIP-19) strings and
//! extracts the server's public key and any embedded relay hints.
//! Mirrors the TS SDK's `parseServerIdentity()`.

use nostr_sdk::prelude::*;

use crate::core::error::{Error, Result};

/// Parse a server identity string into a public key and optional relay hints.
///
/// Supported formats:
/// - **Hex**: 64-character hex-encoded public key
/// - **npub**: NIP-19 bech32-encoded public key
/// - **nprofile**: NIP-19 bech32-encoded profile (pubkey + relay hints)
///
/// Returns `(PublicKey, Vec<String>)` where the second element contains relay
/// hint URLs extracted from an nprofile, or an empty vec for hex/npub.
pub fn parse_server_identity(input: &str) -> Result<(PublicKey, Vec<String>)> {
// Try hex first
if let Ok(pk) = PublicKey::from_hex(input) {
return Ok((pk, vec![]));
}

// Try bech32 (npub / nprofile)
match Nip19::from_bech32(input) {
Ok(Nip19::Pubkey(pk)) => Ok((pk, vec![])),
Ok(Nip19::Profile(profile)) => {
let relays: Vec<String> = profile.relays.into_iter().map(|r| r.to_string()).collect();
Ok((profile.public_key, relays))
}
_ => Err(Error::Other(format!(
"Invalid serverPubkey format: {input}. Expected hex pubkey, npub, or nprofile."
))),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn hex_pubkey_roundtrip() {
let keys = Keys::generate();
let hex = keys.public_key().to_hex();
let (pk, hints) = parse_server_identity(&hex).unwrap();
assert_eq!(pk, keys.public_key());
assert!(hints.is_empty());
}

#[test]
fn npub_roundtrip() {
let keys = Keys::generate();
let npub = keys.public_key().to_bech32().unwrap();
let (pk, hints) = parse_server_identity(&npub).unwrap();
assert_eq!(pk, keys.public_key());
assert!(hints.is_empty());
}

#[test]
fn nprofile_with_relays() {
let keys = Keys::generate();
let relay1 = RelayUrl::parse("wss://relay1.example.com").unwrap();
let relay2 = RelayUrl::parse("wss://relay2.example.com").unwrap();
let profile = Nip19Profile::new(keys.public_key(), vec![relay1.clone(), relay2.clone()]);
let nprofile = profile.to_bech32().unwrap();

let (pk, hints) = parse_server_identity(&nprofile).unwrap();
assert_eq!(pk, keys.public_key());
assert_eq!(hints.len(), 2);
assert!(hints.contains(&relay1.to_string()));
assert!(hints.contains(&relay2.to_string()));
}

#[test]
fn nprofile_without_relays() {
let keys = Keys::generate();
let profile = Nip19Profile::new(keys.public_key(), Vec::<RelayUrl>::new());
let nprofile = profile.to_bech32().unwrap();

let (pk, hints) = parse_server_identity(&nprofile).unwrap();
assert_eq!(pk, keys.public_key());
assert!(hints.is_empty());
}

#[test]
fn invalid_string_returns_error() {
let result = parse_server_identity("not-a-valid-key");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid serverPubkey format"));
assert!(err.contains("Expected hex pubkey, npub, or nprofile"));
}

#[test]
fn invalid_npub_checksum_returns_error() {
// Valid npub prefix but corrupted data
let result = parse_server_identity("npub1invalidchecksum");
assert!(result.is_err());
}
}
Loading
Loading