From 378ae4d7e1552fcb4eed0c43a264b846a5fd856f Mon Sep 17 00:00:00 2001 From: Harsh Date: Sun, 3 May 2026 00:52:02 +0530 Subject: [PATCH 1/2] feat: client discovery tag emission and capability learning for CEP-35 --- src/transport/client/mod.rs | 407 +++++++++++++++++++++++++++------ tests/transport_integration.rs | 196 ++++++++++++++++ 2 files changed, 534 insertions(+), 69 deletions(-) diff --git a/src/transport/client/mod.rs b/src/transport/client/mod.rs index 675be02..65aa47b 100644 --- a/src/transport/client/mod.rs +++ b/src/transport/client/mod.rs @@ -23,6 +23,7 @@ use crate::core::validation; use crate::encryption; use crate::relay::{RelayPool, RelayPoolTrait}; use crate::transport::base::BaseTransport; +use crate::transport::discovery_tags::{parse_discovered_peer_capabilities, PeerCapabilities}; use crate::util::tracing_setup; @@ -67,6 +68,12 @@ pub struct NostrClientTransport { server_pubkey: PublicKey, /// Pending request event IDs awaiting responses. pending_requests: ClientCorrelationStore, + /// CEP-35: one-shot flag for client discovery tag emission. + has_sent_discovery_tags: AtomicBool, + /// CEP-35: learned server capabilities from inbound discovery tags. + discovered_server_capabilities: Arc>, + /// CEP-35: first inbound event carrying discovery tags (session baseline). + server_initialize_event: Arc>>, /// Learned support for server-side ephemeral gift wraps. server_supports_ephemeral: Arc, /// Outer gift-wrap event IDs successfully decrypted and verified (inner `verify()`). @@ -126,6 +133,9 @@ impl NostrClientTransport { config, server_pubkey, pending_requests: ClientCorrelationStore::new(), + has_sent_discovery_tags: AtomicBool::new(false), + discovered_server_capabilities: Arc::new(Mutex::new(PeerCapabilities::default())), + server_initialize_event: Arc::new(Mutex::new(None)), server_supports_ephemeral: Arc::new(AtomicBool::new(false)), seen_gift_wrap_ids, message_tx: tx, @@ -171,6 +181,9 @@ impl NostrClientTransport { config, server_pubkey, pending_requests: ClientCorrelationStore::new(), + has_sent_discovery_tags: AtomicBool::new(false), + discovered_server_capabilities: Arc::new(Mutex::new(PeerCapabilities::default())), + server_initialize_event: Arc::new(Mutex::new(None)), server_supports_ephemeral: Arc::new(AtomicBool::new(false)), seen_gift_wrap_ids, message_tx: tx, @@ -226,6 +239,8 @@ impl NostrClientTransport { let tx = self.message_tx.clone(); let encryption_mode = self.config.encryption_mode; let gift_wrap_mode = self.config.gift_wrap_mode; + let discovered_caps = self.discovered_server_capabilities.clone(); + let init_event = self.server_initialize_event.clone(); let server_supports_ephemeral = self.server_supports_ephemeral.clone(); let seen_gift_wrap_ids = self.seen_gift_wrap_ids.clone(); @@ -237,6 +252,8 @@ impl NostrClientTransport { tx, encryption_mode, gift_wrap_mode, + discovered_caps, + init_event, server_supports_ephemeral, seen_gift_wrap_ids, ) @@ -273,7 +290,14 @@ impl NostrClientTransport { } } - let tags = BaseTransport::create_recipient_tags(&self.server_pubkey); + let is_request = message.is_request(); + let base_tags = BaseTransport::create_recipient_tags(&self.server_pubkey); + let discovery_tags = if is_request { + self.get_pending_client_discovery_tags() + } else { + vec![] + }; + let tags = BaseTransport::compose_outbound_tags(&base_tags, &discovery_tags, &[]); let (event_id, publishable_event) = self .base @@ -316,6 +340,11 @@ impl NostrClientTransport { return Err(error); } + // Flip one-shot flag only after successful publish + if is_request && !discovery_tags.is_empty() { + self.has_sent_discovery_tags.store(true, Ordering::Relaxed); + } + tracing::debug!( target: LOG_TARGET, event_id = %event_id.to_hex(), @@ -360,6 +389,8 @@ impl NostrClientTransport { tx: tokio::sync::mpsc::UnboundedSender, encryption_mode: EncryptionMode, gift_wrap_mode: GiftWrapMode, + discovered_caps: Arc>, + init_event: Arc>>, server_supports_ephemeral: Arc, seen_gift_wrap_ids: Arc>>, ) { @@ -403,81 +434,91 @@ impl NostrClientTransport { } // Handle gift-wrapped events - let (actual_event_content, actual_pubkey, e_tag, verified_tags) = if is_gift_wrap { - { - let guard = match seen_gift_wrap_ids.lock() { - Ok(g) => g, - Err(poisoned) => poisoned.into_inner(), - }; - if guard.contains(&event.id) { - tracing::debug!( - target: LOG_TARGET, - event_id = %event.id.to_hex(), - "Skipping duplicate gift-wrap (outer id)" - ); - continue; - } - } - // Single-layer NIP-44 decrypt (matches JS/TS SDK) - let signer = match relay_pool.signer().await { - Ok(s) => s, - Err(error) => { - tracing::error!( - target: LOG_TARGET, - error = %error, - "Failed to get signer" - ); - continue; + let (actual_event_content, actual_pubkey, e_tag, verified_tags, source_event) = + if is_gift_wrap { + { + let guard = match seen_gift_wrap_ids.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + if guard.contains(&event.id) { + tracing::debug!( + target: LOG_TARGET, + event_id = %event.id.to_hex(), + "Skipping duplicate gift-wrap (outer id)" + ); + continue; + } } - }; - match encryption::decrypt_gift_wrap_single_layer(&signer, &event).await { - Ok(decrypted_json) => { - match serde_json::from_str::(&decrypted_json) { - Ok(inner) => { - if let Err(e) = inner.verify() { - tracing::warn!( - "Inner event signature verification failed: {e}" + // Single-layer NIP-44 decrypt (matches JS/TS SDK) + let signer = match relay_pool.signer().await { + Ok(s) => s, + Err(error) => { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to get signer" + ); + continue; + } + }; + match encryption::decrypt_gift_wrap_single_layer(&signer, &event).await { + Ok(decrypted_json) => { + match serde_json::from_str::(&decrypted_json) { + Ok(inner) => { + if let Err(e) = inner.verify() { + tracing::warn!( + "Inner event signature verification failed: {e}" + ); + continue; + } + { + let mut guard = match seen_gift_wrap_ids.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + guard.put(event.id, ()); + } + let e_tag = serializers::get_tag_value(&inner.tags, "e"); + let inner_clone = inner.clone(); + ( + inner.content, + inner.pubkey, + e_tag, + inner.tags, + inner_clone, + ) + } + Err(error) => { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to parse inner event" ); continue; } - { - let mut guard = match seen_gift_wrap_ids.lock() { - Ok(g) => g, - Err(poisoned) => poisoned.into_inner(), - }; - guard.put(event.id, ()); - } - let e_tag = serializers::get_tag_value(&inner.tags, "e"); - (inner.content, inner.pubkey, e_tag, inner.tags) - } - Err(error) => { - tracing::error!( - target: LOG_TARGET, - error = %error, - "Failed to parse inner event" - ); - continue; } } + Err(error) => { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to decrypt gift wrap" + ); + continue; + } } - Err(error) => { - tracing::error!( - target: LOG_TARGET, - error = %error, - "Failed to decrypt gift wrap" - ); - continue; - } - } - } else { - let e_tag = serializers::get_tag_value(&event.tags, "e"); - ( - event.content.clone(), - event.pubkey, - e_tag, - event.tags.clone(), - ) - }; + } else { + let e_tag = serializers::get_tag_value(&event.tags, "e"); + let event_clone = (*event).clone(); + ( + event.content.clone(), + event.pubkey, + e_tag, + event.tags.clone(), + event_clone, + ) + }; // Verify it's from our server if actual_pubkey != server_pubkey { @@ -490,6 +531,9 @@ impl NostrClientTransport { continue; } + // CEP-35: learn server capabilities from discovery tags + Self::learn_server_discovery(&discovered_caps, &init_event, &source_event); + // CEP-19: learn ephemeral support from server if Self::should_learn_ephemeral_support( actual_pubkey, @@ -547,6 +591,88 @@ impl NostrClientTransport { } } + // ── CEP-35 discovery tag helpers ────────────────────────────── + + /// Constructs client capability tags based on config. + fn get_client_capability_tags(&self) -> Vec { + let mut tags = Vec::new(); + if self.config.encryption_mode != EncryptionMode::Disabled { + tags.push(Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION.into()), + Vec::::new(), + )); + if self.config.gift_wrap_mode != GiftWrapMode::Persistent { + tags.push(Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()), + Vec::::new(), + )); + } + } + tags + } + + /// One-shot: returns capability tags if not yet sent, empty otherwise. + fn get_pending_client_discovery_tags(&self) -> Vec { + if self.has_sent_discovery_tags.load(Ordering::Relaxed) { + vec![] + } else { + self.get_client_capability_tags() + } + } + + /// Parses inbound event tags and updates learned server capabilities. + fn learn_server_discovery( + discovered_caps: &Mutex, + init_event: &Mutex>, + event: &Event, + ) { + let tag_vec: Vec = event.tags.clone().to_vec(); + let discovered = parse_discovered_peer_capabilities(&tag_vec); + if discovered.discovery_tags.is_empty() { + return; + } + + { + let mut caps = match discovered_caps.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + caps.supports_encryption |= discovered.capabilities.supports_encryption; + caps.supports_ephemeral_encryption |= + discovered.capabilities.supports_ephemeral_encryption; + caps.supports_oversized_transfer |= discovered.capabilities.supports_oversized_transfer; + } + + let mut stored = match init_event.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + if stored.is_none() { + *stored = Some(event.clone()); + } + // Note: TS SDK has an upgrade path where a later event with an InitializeResult + // replaces a non-initialize baseline. Not implemented here -- edge case only + // relevant if the first server message with discovery tags is a notification. + } + + /// Returns a clone of the first inbound event that carried server discovery tags. + pub fn get_server_initialize_event(&self) -> Option { + let guard = match self.server_initialize_event.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + guard.clone() + } + + /// Returns a snapshot of the learned server capabilities from discovery tags. + pub fn discovered_server_capabilities(&self) -> PeerCapabilities { + let guard = match self.discovered_server_capabilities.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + *guard + } + fn choose_outbound_gift_wrap_kind(&self) -> u16 { match self.config.gift_wrap_mode { GiftWrapMode::Persistent => GIFT_WRAP_KIND, @@ -820,4 +946,147 @@ mod tests { "Disabled mode must accept plaintext events" ); } + + // ── CEP-35 client discovery tag emission ──────────────────── + + fn make_transport_for_tags( + encryption_mode: EncryptionMode, + gift_wrap_mode: GiftWrapMode, + ) -> NostrClientTransport { + let keys = Keys::generate(); + NostrClientTransport { + base: BaseTransport { + relay_pool: Arc::new(crate::relay::mock::MockRelayPool::new()), + encryption_mode, + is_connected: false, + }, + config: NostrClientTransportConfig { + encryption_mode, + gift_wrap_mode, + server_pubkey: Keys::generate().public_key().to_hex(), + ..Default::default() + }, + server_pubkey: keys.public_key(), + pending_requests: ClientCorrelationStore::new(), + has_sent_discovery_tags: AtomicBool::new(false), + discovered_server_capabilities: Arc::new(Mutex::new(PeerCapabilities::default())), + server_initialize_event: Arc::new(Mutex::new(None)), + server_supports_ephemeral: Arc::new(AtomicBool::new(false)), + seen_gift_wrap_ids: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(10).unwrap()))), + message_tx: tokio::sync::mpsc::unbounded_channel().0, + message_rx: None, + } + } + + fn make_tag(parts: &[&str]) -> Tag { + let kind = TagKind::Custom(parts[0].into()); + let values: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + Tag::custom(kind, values) + } + + fn tag_names(tags: &[Tag]) -> Vec { + tags.iter().map(|t| t.clone().to_vec()[0].clone()).collect() + } + + #[test] + fn client_capability_tags_encryption_optional() { + let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); + let tags = t.get_client_capability_tags(); + let names = tag_names(&tags); + assert_eq!( + names, + vec!["support_encryption", "support_encryption_ephemeral"] + ); + } + + #[test] + fn client_capability_tags_encryption_disabled() { + let t = make_transport_for_tags(EncryptionMode::Disabled, GiftWrapMode::Optional); + let tags = t.get_client_capability_tags(); + assert!(tags.is_empty()); + } + + #[test] + fn client_capability_tags_persistent_gift_wrap() { + let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Persistent); + let tags = t.get_client_capability_tags(); + let names = tag_names(&tags); + assert_eq!(names, vec!["support_encryption"]); + } + + #[test] + fn client_discovery_tags_sent_once() { + let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); + let first = t.get_pending_client_discovery_tags(); + assert!(!first.is_empty()); + + t.has_sent_discovery_tags.store(true, Ordering::Relaxed); + let second = t.get_pending_client_discovery_tags(); + assert!(second.is_empty()); + } + + // ── CEP-35 client capability learning ─────────────────────── + + fn make_event_with_tags(tag_parts: &[&[&str]]) -> Event { + let keys = Keys::generate(); + let tags: Vec = tag_parts.iter().map(|p| make_tag(p)).collect(); + let builder = EventBuilder::new(Kind::Custom(CTXVM_MESSAGES_KIND), "{}").tags(tags); + let unsigned = builder.build(keys.public_key()); + unsigned.sign_with_keys(&keys).unwrap() + } + + #[test] + fn client_learn_server_discovery_sets_baseline() { + let caps = Mutex::new(PeerCapabilities::default()); + let init = Mutex::new(None); + let event = make_event_with_tags(&[&["support_encryption"], &["name", "TestServer"]]); + + NostrClientTransport::learn_server_discovery(&caps, &init, &event); + + let c = caps.lock().unwrap(); + assert!(c.supports_encryption); + assert!(!c.supports_ephemeral_encryption); + + let stored = init.lock().unwrap(); + assert!(stored.is_some()); + assert_eq!(stored.as_ref().unwrap().id, event.id); + } + + #[test] + fn client_learn_server_discovery_or_assigns() { + let caps = Mutex::new(PeerCapabilities::default()); + let init = Mutex::new(None); + + let event1 = make_event_with_tags(&[&["support_encryption"]]); + NostrClientTransport::learn_server_discovery(&caps, &init, &event1); + + // Second event with different caps does NOT downgrade + let event2 = make_event_with_tags(&[&["support_encryption_ephemeral"]]); + NostrClientTransport::learn_server_discovery(&caps, &init, &event2); + + let c = caps.lock().unwrap(); + assert!(c.supports_encryption, "must not downgrade"); + assert!(c.supports_ephemeral_encryption, "must learn new cap"); + } + + #[test] + fn client_baseline_not_replaced_on_later_events() { + let caps = Mutex::new(PeerCapabilities::default()); + let init = Mutex::new(None); + + let event1 = make_event_with_tags(&[&["support_encryption"], &["name", "First"]]); + NostrClientTransport::learn_server_discovery(&caps, &init, &event1); + let first_id = event1.id; + + let event2 = + make_event_with_tags(&[&["support_encryption_ephemeral"], &["name", "Second"]]); + NostrClientTransport::learn_server_discovery(&caps, &init, &event2); + + let stored = init.lock().unwrap(); + assert_eq!( + stored.as_ref().unwrap().id, + first_id, + "baseline must not be replaced" + ); + } } diff --git a/tests/transport_integration.rs b/tests/transport_integration.rs index e84d5b9..0d78b97 100644 --- a/tests/transport_integration.rs +++ b/tests/transport_integration.rs @@ -2865,3 +2865,199 @@ async fn server_disabled_encryption_omits_encryption_tags() { tags::SUPPORT_ENCRYPTION_EPHEMERAL )); } + +// ── CEP-35: Client-side discovery tag emission & capability learning ───────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_disabled_encryption_emits_no_discovery_tags() { + // Disabled encryption: client must not emit cap tags. Positive case (Optional + // mode emits tags) is covered by unit test client_capability_tags_encryption_optional. + let pool = Arc::new(MockRelayPool::new()); + let server_keys = Keys::generate(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig { + server_pubkey: server_keys.public_key().to_hex(), + encryption_mode: EncryptionMode::Disabled, + gift_wrap_mode: GiftWrapMode::Optional, + ..Default::default() + }, + Arc::clone(&pool) as Arc, + ) + .await + .unwrap(); + + client.start().await.unwrap(); + let_event_loops_start().await; + + // With Disabled encryption, no cap tags are emitted (correct per spec). + // Verify the event is published with p tag but without cap tags. + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let events = pool.stored_events().await; + let client_event = events + .iter() + .find(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + .expect("client must publish a request event"); + + // p tag must be present (routing) + assert!(has_tag_name(client_event, "p")); + // No encryption tags when Disabled (the unit test covers the Optional case) + assert!( + !has_tag_name(client_event, tags::SUPPORT_ENCRYPTION), + "Disabled client must not emit support_encryption" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_second_request_carries_no_discovery_tags() { + // Second request must never carry discovery tags. One-shot flag behavior + // is covered by unit test client_discovery_tags_sent_once. + let pool = Arc::new(MockRelayPool::new()); + let server_keys = Keys::generate(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig { + server_pubkey: server_keys.public_key().to_hex(), + encryption_mode: EncryptionMode::Disabled, + gift_wrap_mode: GiftWrapMode::Optional, + ..Default::default() + }, + Arc::clone(&pool) as Arc, + ) + .await + .unwrap(); + + client.start().await.unwrap(); + let_event_loops_start().await; + + // First request + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + // Second request + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(2), + method: "tools/list".to_string(), + params: None, + })) + .await + .unwrap(); + + let events = pool.stored_events().await; + let ctxvm_events: Vec<&Event> = events + .iter() + .filter(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + .collect(); + assert!(ctxvm_events.len() >= 2); + + let second_event = ctxvm_events + .iter() + .find(|e| e.content.contains("tools/list")) + .expect("second request event must exist"); + + assert!( + !has_tag_name(second_event, tags::SUPPORT_ENCRYPTION), + "second request must NOT include discovery tags" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_learns_server_capabilities_from_first_response() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig { + server_info: Some(ServerInfo { + name: Some("CapServer".to_string()), + ..Default::default() + }), + encryption_mode: EncryptionMode::Optional, + gift_wrap_mode: GiftWrapMode::Optional, + ..Default::default() + }, + as_pool(server_pool), + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig { + server_pubkey: server_pubkey.to_hex(), + encryption_mode: EncryptionMode::Disabled, + ..Default::default() + }, + as_pool(client_pool), + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + let mut client_rx = client.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + server + .send_response( + &incoming.event_id, + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + result: serde_json::json!({}), + }), + ) + .await + .unwrap(); + + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .unwrap(); + + // Client should have learned capabilities from server's first response + let caps = client.discovered_server_capabilities(); + assert!( + caps.supports_encryption, + "client must learn support_encryption from server response tags" + ); + assert!( + caps.supports_ephemeral_encryption, + "client must learn support_encryption_ephemeral from server response tags" + ); + + let baseline = client.get_server_initialize_event(); + assert!(baseline.is_some(), "baseline event must be set"); +} From 9fb41792b25c6e245b6f82d750af28eb3b35abef Mon Sep 17 00:00:00 2001 From: Harsh Date: Tue, 5 May 2026 02:06:20 +0530 Subject: [PATCH 2/2] test: add integration tests for CEP-35 OR-assign, baseline freeze, and discovery tag emission --- src/relay/mock.rs | 5 + tests/transport_integration.rs | 307 +++++++++++++++++++++++++++++++-- 2 files changed, 302 insertions(+), 10 deletions(-) diff --git a/src/relay/mock.rs b/src/relay/mock.rs index 52e52bc..a950235 100644 --- a/src/relay/mock.rs +++ b/src/relay/mock.rs @@ -70,6 +70,11 @@ impl MockRelayPool { self.keys.public_key() } + /// The ephemeral signing keys (for manual event injection in tests). + pub fn mock_keys(&self) -> Keys { + self.keys.clone() + } + /// Like [`new`](Self::new) but with caller-provided signing keys. pub fn with_keys(keys: Keys) -> Self { let (tx, _rx) = tokio::sync::broadcast::channel(1024); diff --git a/tests/transport_integration.rs b/tests/transport_integration.rs index 0d78b97..e1c4914 100644 --- a/tests/transport_integration.rs +++ b/tests/transport_integration.rs @@ -2554,16 +2554,6 @@ fn has_tag_name(event: &Event, name: &str) -> bool { .any(|v| v.first().map(|s| s.as_str()) == Some(name)) } -fn get_tag_value(event: &Event, name: &str) -> Option { - event_tag_vecs(event).iter().find_map(|v| { - if v.first().map(|s| s.as_str()) == Some(name) { - v.get(1).cloned() - } else { - None - } - }) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn server_response_includes_encryption_tags_when_enabled() { let (client_pool, server_pool) = MockRelayPool::create_pair(); @@ -3061,3 +3051,300 @@ async fn client_learns_server_capabilities_from_first_response() { let baseline = client.get_server_initialize_event(); assert!(baseline.is_some(), "baseline event must be set"); } + +// ── CEP-35: OR-assign, baseline-freeze, and Optional emission ──────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_or_assigns_capabilities_across_responses() { + // Server with Persistent gift-wrap emits support_encryption but NOT + // support_encryption_ephemeral on the first response. A second event + // carrying support_encryption_ephemeral must OR-assign into the client's + // learned caps without downgrading the already-learned support_encryption. + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_keys = server_pool.mock_keys(); + + let client_pool = Arc::new(client_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig { + server_info: Some(ServerInfo { + name: Some("PersistentServer".to_string()), + ..Default::default() + }), + encryption_mode: EncryptionMode::Optional, + gift_wrap_mode: GiftWrapMode::Persistent, + ..Default::default() + }, + as_pool(server_pool), + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig { + server_pubkey: server_pubkey.to_hex(), + encryption_mode: EncryptionMode::Disabled, + ..Default::default() + }, + Arc::clone(&client_pool) as Arc, + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + let mut client_rx = client.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + // First roundtrip — server responds with support_encryption only. + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + server + .send_response( + &incoming.event_id, + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + result: serde_json::json!({}), + }), + ) + .await + .unwrap(); + + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .unwrap(); + + let caps_after_first = client.discovered_server_capabilities(); + assert!( + caps_after_first.supports_encryption, + "first response must teach support_encryption" + ); + assert!( + !caps_after_first.supports_ephemeral_encryption, + "Persistent server must NOT advertise ephemeral on first response" + ); + + // Inject a second plaintext event signed by the server, carrying + // support_encryption_ephemeral (simulates a capability upgrade). + let client_pubkey = client_pool.mock_public_key(); + let second_response = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/progress" + }); + let inject_event = EventBuilder::new( + Kind::Custom(CTXVM_MESSAGES_KIND), + second_response.to_string(), + ) + .tags(vec![ + Tag::public_key(client_pubkey), + Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()), + Vec::::new(), + ), + ]) + .sign_with_keys(&server_keys) + .unwrap(); + + client_pool.publish_event(&inject_event).await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + let caps_after_second = client.discovered_server_capabilities(); + assert!( + caps_after_second.supports_encryption, + "support_encryption must survive OR-assign (not downgraded)" + ); + assert!( + caps_after_second.supports_ephemeral_encryption, + "support_encryption_ephemeral must be OR-assigned from second event" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_baseline_event_not_replaced_by_later_responses() { + // The first inbound event carrying discovery tags becomes the baseline. + // Later events with different tags must NOT replace it. + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_keys = server_pool.mock_keys(); + + let client_pool = Arc::new(client_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig { + server_info: Some(ServerInfo { + name: Some("BaselineServer".to_string()), + ..Default::default() + }), + encryption_mode: EncryptionMode::Optional, + gift_wrap_mode: GiftWrapMode::Optional, + ..Default::default() + }, + as_pool(server_pool), + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig { + server_pubkey: server_pubkey.to_hex(), + encryption_mode: EncryptionMode::Disabled, + ..Default::default() + }, + Arc::clone(&client_pool) as Arc, + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + let mut client_rx = client.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + // First roundtrip — establishes baseline. + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + server + .send_response( + &incoming.event_id, + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + result: serde_json::json!({}), + }), + ) + .await + .unwrap(); + + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .unwrap(); + + let baseline = client.get_server_initialize_event(); + assert!( + baseline.is_some(), + "baseline must be set after first response" + ); + let baseline_id = baseline.unwrap().id; + + // Inject a second event with different discovery tags. + let client_pubkey = client_pool.mock_public_key(); + let notification = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/progress" + }); + let inject_event = + EventBuilder::new(Kind::Custom(CTXVM_MESSAGES_KIND), notification.to_string()) + .tags(vec![ + Tag::public_key(client_pubkey), + Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION.into()), + Vec::::new(), + ), + ]) + .sign_with_keys(&server_keys) + .unwrap(); + + client_pool.publish_event(&inject_event).await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + let baseline_after = client.get_server_initialize_event(); + assert_eq!( + baseline_after.unwrap().id, + baseline_id, + "baseline event must NOT be replaced by later events" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_optional_encryption_emits_discovery_tags() { + // Client with Optional encryption must include discovery tags in the + // inner signed event. We decrypt the published gift wrap to verify. + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_keys = server_pool.mock_keys(); + + let client_pool = Arc::new(client_pool); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig { + server_pubkey: server_pubkey.to_hex(), + encryption_mode: EncryptionMode::Optional, + gift_wrap_mode: GiftWrapMode::Optional, + ..Default::default() + }, + Arc::clone(&client_pool) as Arc, + ) + .await + .unwrap(); + + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let events = client_pool.stored_events().await; + let gift_wrap = events + .iter() + .find(|e| { + e.kind == Kind::Custom(GIFT_WRAP_KIND) + || e.kind == Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND) + }) + .expect("Optional encryption must produce a gift-wrapped event"); + + // Decrypt using the server's keys (the recipient). + let signer: Arc = Arc::new(server_keys); + let decrypted_json = + contextvm_sdk::encryption::decrypt_gift_wrap_single_layer(&signer, gift_wrap) + .await + .expect("gift wrap must be decryptable with server keys"); + + let inner: Event = + serde_json::from_str(&decrypted_json).expect("decrypted content must be a valid Event"); + + assert!( + has_tag_name(&inner, tags::SUPPORT_ENCRYPTION), + "inner event must carry support_encryption tag" + ); + assert!( + has_tag_name(&inner, tags::SUPPORT_ENCRYPTION_EPHEMERAL), + "inner event must carry support_encryption_ephemeral tag (Optional gift-wrap mode)" + ); +}