diff --git a/.gitignore b/.gitignore index bcb27438..3f87fd1d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ /pkg/* .idea/ +.worktrees/ Cargo.lock diff --git a/crates/bcr-ebill-api/src/constants.rs b/crates/bcr-ebill-api/src/constants.rs index d90f735c..47edc3b2 100644 --- a/crates/bcr-ebill-api/src/constants.rs +++ b/crates/bcr-ebill-api/src/constants.rs @@ -8,3 +8,4 @@ pub const VALID_FILE_MIME_TYPES: [&str; 3] = ["image/jpeg", "image/png", "applic // When subscribing events we subtract this from the last received event time pub const NOSTR_EVENT_TIME_SLACK: u64 = 3600 * 24 * 7; // 1 week pub const DEFAULT_INITIAL_SUBSCRIPTION_DELAY_SECONDS: u32 = 1; +pub const NOSTR_MAX_RELAYS: usize = 200; diff --git a/crates/bcr-ebill-api/src/lib.rs b/crates/bcr-ebill-api/src/lib.rs index 3029c39b..5337fa46 100644 --- a/crates/bcr-ebill-api/src/lib.rs +++ b/crates/bcr-ebill-api/src/lib.rs @@ -84,12 +84,25 @@ pub struct PaymentConfig { } /// Nostr specific configuration -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct NostrConfig { /// Only known contacts can message us via DM. pub only_known_contacts: bool, /// All relays we want to publish our messages to and receive messages from. pub relays: Vec, + /// Maximum number of contact relays to add (in addition to user relays which are always included). + /// Defaults to 50 if not specified. + pub max_relays: Option, +} + +impl Default for NostrConfig { + fn default() -> Self { + Self { + only_known_contacts: false, + relays: vec![], + max_relays: Some(50), + } + } } /// Mint configuration diff --git a/crates/bcr-ebill-api/src/tests/mod.rs b/crates/bcr-ebill-api/src/tests/mod.rs index d35d180a..a5822d67 100644 --- a/crates/bcr-ebill-api/src/tests/mod.rs +++ b/crates/bcr-ebill-api/src/tests/mod.rs @@ -142,6 +142,7 @@ pub mod tests { impl NostrContactStoreApi for NostrContactStore { async fn by_node_id(&self, node_id: &NodeId) -> Result>; async fn by_node_ids(&self, node_ids: Vec) -> Result>; + async fn get_all(&self) -> Result>; async fn by_npub(&self, npub: &NostrPublicKey) -> Result>; async fn upsert(&self, data: &NostrContact) -> Result<()>; async fn delete(&self, node_id: &NodeId) -> Result<()>; @@ -492,6 +493,7 @@ pub mod tests { nostr_config: NostrConfig { only_known_contacts: false, relays: vec![url::Url::parse("ws://localhost:8080").unwrap()], + max_relays: Some(50), }, mint_config: MintConfig { default_mint_url: url::Url::parse("http://localhost:4242/").unwrap(), @@ -681,4 +683,35 @@ pub mod tests { pub fn test_ts() -> Timestamp { Timestamp::new(1731593928).unwrap() } + + #[cfg(test)] + mod config_tests { + use crate::NostrConfig; + + #[test] + fn test_nostr_config_default_max_relays() { + let config = NostrConfig::default(); + assert_eq!(config.max_relays, Some(50)); + } + + #[test] + fn test_nostr_config_with_custom_max_relays() { + let config = NostrConfig { + only_known_contacts: true, + relays: vec![], + max_relays: Some(100), + }; + assert_eq!(config.max_relays, Some(100)); + } + + #[test] + fn test_nostr_config_with_no_relay_limit() { + let config = NostrConfig { + only_known_contacts: false, + relays: vec![], + max_relays: None, + }; + assert_eq!(config.max_relays, None); + } + } } diff --git a/crates/bcr-ebill-persistence/src/db/nostr_contact_store.rs b/crates/bcr-ebill-persistence/src/db/nostr_contact_store.rs index e22dfe90..181a5858 100644 --- a/crates/bcr-ebill-persistence/src/db/nostr_contact_store.rs +++ b/crates/bcr-ebill-persistence/src/db/nostr_contact_store.rs @@ -19,6 +19,7 @@ use bcr_ebill_core::{ protocol::SecretKey, protocol::Timestamp, }; +use log::error; use serde::{Deserialize, Serialize}; use surrealdb::sql::Thing; @@ -65,6 +66,22 @@ impl NostrContactStoreApi for SurrealNostrContactStore { Ok(values.unwrap_or_default()) } + /// Get all Nostr contacts from the store. + async fn get_all(&self) -> Result> { + let result: Vec = self.db.select_all(Self::TABLE).await?; + let values = result + .into_iter() + .filter_map(|c| match c.try_into() { + Ok(v) => Some(v), + Err(e) => { + error!("Failed to convert NostrContactDb to NostrContact: {e}"); + None + } + }) + .collect::>(); + Ok(values) + } + /// Find a Nostr contact by the npub. This is the public Nostr key of the contact. async fn by_npub(&self, npub: &NostrPublicKey) -> Result> { let result: Option = self.db.select_one(Self::TABLE, npub.to_hex()).await?; diff --git a/crates/bcr-ebill-persistence/src/nostr.rs b/crates/bcr-ebill-persistence/src/nostr.rs index c9a8e1a6..822e7b11 100644 --- a/crates/bcr-ebill-persistence/src/nostr.rs +++ b/crates/bcr-ebill-persistence/src/nostr.rs @@ -84,6 +84,8 @@ pub trait NostrContactStoreApi: ServiceTraitBounds { async fn by_node_id(&self, node_id: &NodeId) -> Result>; /// Find multiple Nostr contacts by their node ids. async fn by_node_ids(&self, node_ids: Vec) -> Result>; + /// Get all Nostr contacts from the store. + async fn get_all(&self) -> Result>; /// Find a Nostr contact by the npub. This is the public Nostr key of the contact. async fn by_npub(&self, npub: &NostrPublicKey) -> Result>; /// Creates a new or updates an existing Nostr contact. diff --git a/crates/bcr-ebill-transport/src/handler/mod.rs b/crates/bcr-ebill-transport/src/handler/mod.rs index 81b339f4..6f6b0cb7 100644 --- a/crates/bcr-ebill-transport/src/handler/mod.rs +++ b/crates/bcr-ebill-transport/src/handler/mod.rs @@ -507,6 +507,7 @@ mod test_utils { impl NostrContactStoreApi for NostrContactStore { async fn by_node_id(&self, node_id: &NodeId) -> Result>; async fn by_node_ids(&self, node_ids: Vec) -> Result>; + async fn get_all(&self) -> Result>; async fn by_npub(&self, npub: &NostrPublicKey) -> Result>; async fn upsert(&self, data: &NostrContact) -> Result<()>; async fn delete(&self, node_id: &NodeId) -> Result<()>; diff --git a/crates/bcr-ebill-transport/src/handler/nostr_contact_processor.rs b/crates/bcr-ebill-transport/src/handler/nostr_contact_processor.rs index 8f5270c9..35b89629 100644 --- a/crates/bcr-ebill-transport/src/handler/nostr_contact_processor.rs +++ b/crates/bcr-ebill-transport/src/handler/nostr_contact_processor.rs @@ -17,6 +17,7 @@ pub struct NostrContactProcessor { transport: Arc, nostr_contact_store: Arc, bitcoin_network: bitcoin::Network, + nostr_client: Option>, } impl NostrContactProcessor { @@ -24,11 +25,13 @@ impl NostrContactProcessor { transport: Arc, nostr_contact_store: Arc, bitcoin_network: bitcoin::Network, + nostr_client: Option>, ) -> Self { Self { transport, nostr_contact_store, bitcoin_network, + nostr_client, } } } @@ -73,8 +76,19 @@ impl NostrContactProcessor { async fn upsert_contact(&self, node_id: &NodeId, contact: &NostrContact) { if let Err(e) = self.nostr_contact_store.upsert(contact).await { error!("Failed to save nostr contact information for node_id {node_id}: {e}"); - } else if let Err(e) = self.transport.add_contact_subscription(node_id).await { - error!("Failed to add nostr contact subscription for contact node_id {node_id}: {e}"); + } else { + if let Err(e) = self.transport.add_contact_subscription(node_id).await { + error!( + "Failed to add nostr contact subscription for contact node_id {node_id}: {e}" + ); + } + + // Trigger relay refresh to include new contact's relays + if let Some(ref client) = self.nostr_client + && let Err(e) = client.refresh_relays().await + { + warn!("Failed to refresh relays after contact update for {node_id}: {e}"); + } } } } diff --git a/crates/bcr-ebill-transport/src/lib.rs b/crates/bcr-ebill-transport/src/lib.rs index c844b75d..35a00ba8 100644 --- a/crates/bcr-ebill-transport/src/lib.rs +++ b/crates/bcr-ebill-transport/src/lib.rs @@ -21,7 +21,7 @@ use handler::{ IdentityChainEventHandler, IdentityChainEventProcessor, LoggingEventHandler, NostrContactProcessor, NotificationHandlerApi, }; -use log::{debug, error}; +use log::{debug, error, warn}; pub use nostr_transport::NostrTransportService; mod block_transport; @@ -54,6 +54,7 @@ pub async fn create_nostr_clients( config: &Config, identity_store: Arc, company_store: Arc, + nostr_contact_store: Arc, ) -> Result> { // primary identity is required to launch let keys = identity_store.get_or_create_key_pair().await.map_err(|e| { @@ -101,9 +102,17 @@ pub async fn create_nostr_clients( identities, config.nostr_config.relays.clone(), std::time::Duration::from_secs(20), + config.nostr_config.max_relays, + Some(nostr_contact_store), ) .await?; + // Initial relay refresh to include contact relays + if let Err(e) = client.refresh_relays().await { + warn!("Failed initial relay refresh: {}", e); + // Continue anyway - we have user relays at minimum + } + Ok(Arc::new(client)) } @@ -121,6 +130,7 @@ pub async fn create_transport_service( transport.clone(), db_context.nostr_contact_store.clone(), get_config().bitcoin_network(), + Some(client.clone()), )); let bill_processor = Arc::new(BillChainEventProcessor::new( db_context.bill_blockchain_store.clone(), @@ -214,6 +224,7 @@ pub async fn create_nostr_consumer( transport.clone(), db_context.nostr_contact_store.clone(), get_config().bitcoin_network(), + Some(client.clone()), )); let bill_processor = Arc::new(BillChainEventProcessor::new( @@ -329,6 +340,7 @@ pub async fn create_restore_account_service( nostr_client.clone(), db_context.nostr_contact_store.clone(), config.bitcoin_network(), + Some(nostr_client.clone()), )); let bill_processor = Arc::new(BillChainEventProcessor::new( diff --git a/crates/bcr-ebill-transport/src/nostr.rs b/crates/bcr-ebill-transport/src/nostr.rs index 1462ca5c..cc3e62a9 100644 --- a/crates/bcr-ebill-transport/src/nostr.rs +++ b/crates/bcr-ebill-transport/src/nostr.rs @@ -9,8 +9,12 @@ use crate::{ use async_trait::async_trait; use bcr_common::core::NodeId; use bcr_ebill_core::{ - protocol::Timestamp, protocol::blockchain::BlockchainType, - protocol::blockchain::bill::participant::BillParticipant, protocol::crypto::BcrKeys, + application::nostr_contact::{NostrContact, TrustLevel}, + protocol::{ + Timestamp, + blockchain::{BlockchainType, bill::participant::BillParticipant}, + crypto::BcrKeys, + }, }; use bitcoin::base58; use log::{debug, error, info, trace, warn}; @@ -20,10 +24,14 @@ use nostr_sdk::{ PublicKey, RelayPoolNotification, RelayUrl, SingleLetterTag, TagKind, TagStandard, ToBech32, }; use std::sync::{Arc, Mutex, atomic::Ordering}; -use std::{collections::HashMap, sync::atomic::AtomicBool, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + sync::atomic::AtomicBool, + time::Duration, +}; use bcr_ebill_api::{ - constants::NOSTR_EVENT_TIME_SLACK, + constants::{NOSTR_EVENT_TIME_SLACK, NOSTR_MAX_RELAYS}, service::{ contact_service::ContactServiceApi, transport_service::{ @@ -32,7 +40,7 @@ use bcr_ebill_api::{ }, }; use bcr_ebill_core::{application::ServiceTraitBounds, protocol::event::EventEnvelope}; -use bcr_ebill_persistence::{NostrEventOffset, NostrEventOffsetStoreApi}; +use bcr_ebill_persistence::{NostrContactStoreApi, NostrEventOffset, NostrEventOffsetStoreApi}; use tokio::task::JoinSet; use tokio_with_wasm::alias as tokio; @@ -64,6 +72,8 @@ pub struct NostrClient { relays: Vec, default_timeout: Duration, connected: Arc, + max_relays: Option, + nostr_contact_store: Option>, } impl NostrClient { @@ -72,6 +82,8 @@ impl NostrClient { identities: Vec<(NodeId, BcrKeys)>, relays: Vec, default_timeout: Duration, + max_relays: Option, + nostr_contact_store: Option>, ) -> Result { if identities.is_empty() { return Err(Error::Message("At least one identity required".to_string())); @@ -105,13 +117,22 @@ impl NostrClient { relays, default_timeout, connected: Arc::new(AtomicBool::new(false)), + max_relays, + nostr_contact_store, }) } /// Creates a new nostr client with the given config. pub async fn default(config: &NostrConfig) -> Result { let identities = vec![(config.node_id.clone(), config.keys.clone())]; - Self::new(identities, config.relays.clone(), config.default_timeout).await + Self::new( + identities, + config.relays.clone(), + config.default_timeout, + None, // max_relays not available in old config + None, // contact_store not available + ) + .await } /// Get the signer for a specific identity @@ -313,6 +334,75 @@ impl NostrClient { } Ok(&self.client) } + + /// Calculate the complete relay set from user relays + contact relays + async fn calculate_relay_set(&self) -> Result> { + // Get contacts from store if available + let contacts = if let Some(store) = &self.nostr_contact_store { + store.get_all().await.map_err(|e| { + error!("Failed to fetch contacts for relay calculation: {e}"); + Error::Message("Failed to fetch contacts".to_string()) + })? + } else { + vec![] + }; + + Ok(calculate_relay_set_internal( + &self.relays, + &contacts, + self.max_relays, + )) + } + + /// Update the client's relay connections to match the target set + async fn update_relays(&self, target_relays: HashSet) -> Result<()> { + let client = &self.client; + + // Get current relays + let current_relays: HashSet = client + .relays() + .await + .keys() + .map(|url| url.to_owned().into()) + .collect(); + + // Add new relays + for relay in target_relays.iter() { + if !current_relays.contains(relay) { + match client.add_relay(relay).await { + Ok(_) => debug!("Added relay: {}", relay), + Err(e) => warn!("Failed to add relay {}: {}", relay, e), + } + } + } + + // Remove old relays (relays not in target set) + for relay in current_relays.iter() { + if !target_relays.contains(relay) { + // Convert url::Url to RelayUrl + if let Ok(relay_url) = relay.as_str().parse::() { + match client.remove_relay(relay_url).await { + Ok(_) => debug!("Removed relay: {}", relay), + Err(e) => warn!("Failed to remove relay {}: {}", relay, e), + } + } + } + } + + Ok(()) + } + + /// Public method to refresh relay connections based on current contacts + pub async fn refresh_relays(&self) -> Result<()> { + info!("Refreshing relay connections based on contacts"); + let relay_set = self.calculate_relay_set().await?; + self.update_relays(relay_set).await?; + info!( + "Relay refresh complete, connected to {} relays", + self.client.relays().await.len() + ); + Ok(()) + } } impl ServiceTraitBounds for NostrClient {} @@ -1140,7 +1230,7 @@ mod tests { (node_id2.clone(), keys2.clone()), ]; - let client = NostrClient::new(identities, vec![url], Duration::from_secs(20)) + let client = NostrClient::new(identities, vec![url], Duration::from_secs(20), None, None) .await .expect("failed to create multi-identity client"); @@ -1168,9 +1258,15 @@ mod tests { (node_id2.clone(), keys2.clone()), ]; - let client = NostrClient::new(identities, vec![url.clone()], Duration::from_secs(20)) - .await - .expect("failed to create client"); + let client = NostrClient::new( + identities, + vec![url.clone()], + Duration::from_secs(20), + None, + None, + ) + .await + .expect("failed to create client"); client.connect().await.expect("failed to connect"); @@ -1215,9 +1311,15 @@ mod tests { ]; let client = Arc::new( - NostrClient::new(identities, vec![url.clone()], Duration::from_secs(20)) - .await - .expect("failed to create multi-identity client"), + NostrClient::new( + identities, + vec![url.clone()], + Duration::from_secs(20), + None, + None, + ) + .await + .expect("failed to create multi-identity client"), ); // Create mock services for NostrConsumer with expectations @@ -1251,3 +1353,263 @@ mod tests { tasks.abort_all(); } } + +/// Internal relay calculation function (pure function for testing) +fn calculate_relay_set_internal( + user_relays: &[url::Url], + contacts: &[NostrContact], + max_relays: Option, +) -> HashSet { + let mut relay_set = HashSet::new(); + + // Pass 1: Add all user relays (exempt from limit) + for relay in user_relays { + relay_set.insert(relay.clone()); + } + + // Filter and sort contacts by trust level + let mut eligible_contacts: Vec<&NostrContact> = contacts + .iter() + .filter(|c| matches!(c.trust_level, TrustLevel::Trusted | TrustLevel::Participant)) + .collect(); + + // Sort: Trusted (0) before Participant (1) + eligible_contacts.sort_by_key(|c| match c.trust_level { + TrustLevel::Trusted => 0, + TrustLevel::Participant => 1, + _ => 2, // unreachable due to filter + }); + + let contact_relay_limit = NOSTR_MAX_RELAYS.min(max_relays.unwrap_or(NOSTR_MAX_RELAYS)); + let user_relay_count = relay_set.len(); + + // Pass 2: Add first relay from each contact (priority order) + for contact in &eligible_contacts { + let contact_relays_added = relay_set.len() - user_relay_count; + if contact_relays_added >= contact_relay_limit { + break; + } + if let Some(first_relay) = contact.relays.first() { + relay_set.insert(first_relay.clone()); + } + } + + // Pass 3: Fill remaining slots with additional contact relays + for contact in &eligible_contacts { + for relay in contact.relays.iter().skip(1) { + let contact_relays_added = relay_set.len() - user_relay_count; + if contact_relays_added >= contact_relay_limit { + return relay_set; + } + relay_set.insert(relay.clone()); + } + } + + relay_set +} + +#[cfg(test)] +mod relay_calculation_tests { + use super::*; + use bcr_ebill_core::application::nostr_contact::{HandshakeStatus, NostrContact, TrustLevel}; + + fn create_test_contact(trust_level: TrustLevel, relays: Vec<&str>) -> NostrContact { + use bcr_ebill_core::protocol::crypto::BcrKeys; + let keys = BcrKeys::new(); + let node_id = NodeId::new(keys.pub_key(), bitcoin::Network::Testnet); + NostrContact { + npub: node_id.npub(), + node_id, + name: None, + relays: relays.iter().map(|r| url::Url::parse(r).unwrap()).collect(), + trust_level, + handshake_status: HandshakeStatus::None, + contact_private_key: None, + } + } + + #[test] + fn test_user_relays_always_included() { + let user_relays = vec![ + url::Url::parse("wss://relay1.com").unwrap(), + url::Url::parse("wss://relay2.com").unwrap(), + ]; + let contacts = vec![]; + let max_relays = Some(1); // Very low limit + + let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays); + + // User relays should all be present despite low limit + assert_eq!(result.len(), 2); + assert!(result.contains(&url::Url::parse("wss://relay1.com").unwrap())); + assert!(result.contains(&url::Url::parse("wss://relay2.com").unwrap())); + } + + #[test] + fn test_trusted_contacts_prioritized() { + let user_relays = vec![]; + let contacts = vec![ + create_test_contact(TrustLevel::Participant, vec!["wss://participant.com"]), + create_test_contact(TrustLevel::Trusted, vec!["wss://trusted.com"]), + ]; + let max_relays = Some(1); + + let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays); + + // Should only include trusted contact's relay (higher priority) + assert_eq!(result.len(), 1); + assert!(result.contains(&url::Url::parse("wss://trusted.com").unwrap())); + } + + #[test] + fn test_contact_relays_added_when_user_relays_exceed_limit() { + let user_relays = vec![ + url::Url::parse("wss://user1.com").unwrap(), + url::Url::parse("wss://user2.com").unwrap(), + url::Url::parse("wss://user3.com").unwrap(), + ]; + let contacts = vec![ + create_test_contact(TrustLevel::Trusted, vec!["wss://contact1.com"]), + create_test_contact(TrustLevel::Trusted, vec!["wss://contact2.com"]), + ]; + let max_relays = Some(2); // Lower than user relay count + + let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays); + + // Should have all 3 user relays + 2 contact relays (user relays exempt from limit) + assert_eq!(result.len(), 5); + assert!(result.contains(&url::Url::parse("wss://user1.com").unwrap())); + assert!(result.contains(&url::Url::parse("wss://user2.com").unwrap())); + assert!(result.contains(&url::Url::parse("wss://user3.com").unwrap())); + assert!(result.contains(&url::Url::parse("wss://contact1.com").unwrap())); + assert!(result.contains(&url::Url::parse("wss://contact2.com").unwrap())); + } + + #[test] + fn test_one_relay_per_contact_guaranteed() { + let user_relays = vec![]; + let contacts = vec![ + create_test_contact( + TrustLevel::Trusted, + vec!["wss://contact1-relay1.com", "wss://contact1-relay2.com"], + ), + create_test_contact( + TrustLevel::Trusted, + vec!["wss://contact2-relay1.com", "wss://contact2-relay2.com"], + ), + create_test_contact(TrustLevel::Trusted, vec!["wss://contact3-relay1.com"]), + ]; + let max_relays = Some(3); + + let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays); + + // Should have exactly 3 relays (first relay from each contact) + assert_eq!(result.len(), 3); + assert!(result.contains(&url::Url::parse("wss://contact1-relay1.com").unwrap())); + assert!(result.contains(&url::Url::parse("wss://contact2-relay1.com").unwrap())); + assert!(result.contains(&url::Url::parse("wss://contact3-relay1.com").unwrap())); + } + + #[test] + fn test_deduplication_across_contacts() { + let user_relays = vec![]; + let contacts = vec![ + create_test_contact( + TrustLevel::Trusted, + vec!["wss://shared.com", "wss://unique1.com"], + ), + create_test_contact( + TrustLevel::Trusted, + vec!["wss://shared.com", "wss://unique2.com"], + ), + ]; + let max_relays = Some(10); + + let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays); + + // Should only include shared.com once + assert_eq!(result.len(), 3); + assert!(result.contains(&url::Url::parse("wss://shared.com").unwrap())); + assert!(result.contains(&url::Url::parse("wss://unique1.com").unwrap())); + assert!(result.contains(&url::Url::parse("wss://unique2.com").unwrap())); + } + + #[test] + fn test_banned_contacts_excluded() { + let user_relays = vec![]; + let contacts = vec![ + create_test_contact(TrustLevel::Banned, vec!["wss://banned.com"]), + create_test_contact(TrustLevel::Trusted, vec!["wss://trusted.com"]), + ]; + let max_relays = Some(10); + + let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays); + + assert_eq!(result.len(), 1); + assert!(result.contains(&url::Url::parse("wss://trusted.com").unwrap())); + assert!(!result.contains(&url::Url::parse("wss://banned.com").unwrap())); + } + + #[test] + fn test_none_trust_level_excluded() { + let user_relays = vec![]; + let contacts = vec![ + create_test_contact(TrustLevel::None, vec!["wss://unknown.com"]), + create_test_contact(TrustLevel::Participant, vec!["wss://participant.com"]), + ]; + let max_relays = Some(10); + + let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays); + + assert_eq!(result.len(), 1); + assert!(result.contains(&url::Url::parse("wss://participant.com").unwrap())); + assert!(!result.contains(&url::Url::parse("wss://unknown.com").unwrap())); + } + + #[test] + fn test_no_limit_when_max_relays_none() { + let user_relays = vec![url::Url::parse("wss://user.com").unwrap()]; + let contacts = vec![ + create_test_contact( + TrustLevel::Trusted, + vec!["wss://relay1.com", "wss://relay2.com"], + ), + create_test_contact( + TrustLevel::Trusted, + vec!["wss://relay3.com", "wss://relay4.com"], + ), + ]; + let max_relays = None; + + let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays); + + // All relays should be included + assert_eq!(result.len(), 5); + } + + #[test] + fn test_empty_contacts() { + let user_relays = vec![url::Url::parse("wss://user.com").unwrap()]; + let contacts = vec![]; + let max_relays = Some(50); + + let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays); + + assert_eq!(result.len(), 1); + assert!(result.contains(&url::Url::parse("wss://user.com").unwrap())); + } + + #[test] + fn test_contact_with_no_relays() { + let user_relays = vec![]; + let mut contact = create_test_contact(TrustLevel::Trusted, vec![]); + contact.relays = vec![]; // Explicitly no relays + let contacts = vec![contact]; + let max_relays = Some(10); + + let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays); + + // Should handle gracefully + assert_eq!(result.len(), 0); + } +} diff --git a/crates/bcr-ebill-transport/src/test_utils.rs b/crates/bcr-ebill-transport/src/test_utils.rs index 21d2e9b0..bb269326 100644 --- a/crates/bcr-ebill-transport/src/test_utils.rs +++ b/crates/bcr-ebill-transport/src/test_utils.rs @@ -134,6 +134,7 @@ pub fn init_test_cfg() { nostr_config: bcr_ebill_api::NostrConfig { only_known_contacts: false, relays: vec![url::Url::parse("ws://localhost:8080").unwrap()], + max_relays: Some(50), }, mint_config: bcr_ebill_api::MintConfig { default_mint_url: url::Url::parse("http://localhost:4242/").unwrap(), @@ -842,6 +843,7 @@ mockall::mock! { impl NostrContactStoreApi for NostrContactStore { async fn by_node_id(&self, node_id: &NodeId) -> bcr_ebill_persistence::Result>; async fn by_node_ids(&self, node_ids: Vec) -> bcr_ebill_persistence::Result>; + async fn get_all(&self) -> bcr_ebill_persistence::Result>; async fn by_npub(&self, npub: &NostrPublicKey) -> bcr_ebill_persistence::Result>; async fn upsert(&self, data: &NostrContact) -> bcr_ebill_persistence::Result<()>; async fn delete(&self, node_id: &NodeId) -> bcr_ebill_persistence::Result<()>; diff --git a/crates/bcr-ebill-wasm/src/context.rs b/crates/bcr-ebill-wasm/src/context.rs index 1e16332a..00884de9 100644 --- a/crates/bcr-ebill-wasm/src/context.rs +++ b/crates/bcr-ebill-wasm/src/context.rs @@ -49,8 +49,13 @@ impl Context { let email_client = Arc::new(EmailClient::new()); let push_service = Arc::new(PushService::new()); - let nostr_client = - create_nostr_clients(&cfg, db.identity_store.clone(), db.company_store.clone()).await?; + let nostr_client = create_nostr_clients( + &cfg, + db.identity_store.clone(), + db.company_store.clone(), + db.nostr_contact_store.clone(), + ) + .await?; let transport_service = create_transport_service( nostr_client.clone(), db.clone(), diff --git a/crates/bcr-ebill-wasm/src/lib.rs b/crates/bcr-ebill-wasm/src/lib.rs index dd17aa73..9e5faf10 100644 --- a/crates/bcr-ebill-wasm/src/lib.rs +++ b/crates/bcr-ebill-wasm/src/lib.rs @@ -37,6 +37,7 @@ pub struct Config { pub esplora_base_url: String, pub nostr_relays: Vec, pub nostr_only_known_contacts: Option, + pub nostr_max_relays: Option, pub job_runner_initial_delay_seconds: u32, pub job_runner_check_interval_seconds: u32, pub transport_initial_subscription_delay_seconds: Option, @@ -131,6 +132,7 @@ pub async fn initialize_api( nostr_config: NostrConfig { relays: nostr_relays, only_known_contacts: config.nostr_only_known_contacts.unwrap_or(false), + max_relays: config.nostr_max_relays.or(Some(50)), }, mint_config: MintConfig::new(config.default_mint_url, mint_node_id)?, payment_config: PaymentConfig {