diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8bfa8df --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +members = [ + "crates/lumenqraph-core", + "crates/lumenqraph-indexer", + "crates/lumenqraph-api", + "crates/lumenqraph-webhooks", + "crates/lumenqraph-mcp", +] + +[workspace.dependencies] +tokio = { version = "1.0", features = ["full"] } +log = "0.4" +env_logger = "0.10" +thiserror = "1.0" +serde_json = "1.0" \ No newline at end of file diff --git a/crates/lumenqraph-api/Cargo.toml b/crates/lumenqraph-api/Cargo.toml new file mode 100644 index 0000000..9dddab1 --- /dev/null +++ b/crates/lumenqraph-api/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "lumenqraph-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +lumenqraph-core = { path = "../lumenqraph-core" } +tokio = { version = "1.0", features = ["full"] } +log = "0.4" +env_logger = "0.10" \ No newline at end of file diff --git a/crates/lumenqraph-api/src/main.rs b/crates/lumenqraph-api/src/main.rs new file mode 100644 index 0000000..b598294 --- /dev/null +++ b/crates/lumenqraph-api/src/main.rs @@ -0,0 +1,68 @@ +use std::env; +use log::{info, error}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + env_logger::init(); + + info!("Starting Lumenqraph API service"); + + // Validate contract IDs at startup + let contract_ids = env::var("CONTRACT_IDS") + .map_err(|_| "Missing CONTRACT_IDS environment variable")? + .split(',') + .map(|id| id.trim().to_string()) + .collect::>(); + + if let Err(e) = lumenqraph_core::validate_contract_ids(&contract_ids) { + error!("Invalid contract IDs at startup: {}", e); + std::process::exit(1); + } + + info!("Contract IDs validation passed: {} contracts configured", contract_ids.len()); + + // Start API server (placeholder implementation) + info!("API service started successfully"); + + // Keep the service running + tokio::signal::ctrl_c().await?; + info!("Shutting down API service"); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[tokio::test] + async fn test_startup_validation_success() { + env::set_var("CONTRACT_IDS", "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + let contract_ids = env::var("CONTRACT_IDS") + .unwrap() + .split(',') + .map(|id| id.trim().to_string()) + .collect::>(); + + assert!(lumenqraph_core::validate_contract_ids(&contract_ids).is_ok()); + + env::remove_var("CONTRACT_IDS"); + } + + #[tokio::test] + async fn test_startup_validation_failure() { + env::set_var("CONTRACT_IDS", "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,INVALID_ID"); + + let contract_ids = env::var("CONTRACT_IDS") + .unwrap() + .split(',') + .map(|id| id.trim().to_string()) + .collect::>(); + + assert!(lumenqraph_core::validate_contract_ids(&contract_ids).is_err()); + + env::remove_var("CONTRACT_IDS"); + } +} \ No newline at end of file diff --git a/crates/lumenqraph-core/Cargo.toml b/crates/lumenqraph-core/Cargo.toml new file mode 100644 index 0000000..0305210 --- /dev/null +++ b/crates/lumenqraph-core/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "lumenqraph-core" +version = "0.1.0" +edition = "2021" + +[dependencies] \ No newline at end of file diff --git a/crates/lumenqraph-core/src/lib.rs b/crates/lumenqraph-core/src/lib.rs new file mode 100644 index 0000000..8677b52 --- /dev/null +++ b/crates/lumenqraph-core/src/lib.rs @@ -0,0 +1,3 @@ +pub mod validation; + +pub use validation::{is_valid_contract_id, validate_contract_ids}; \ No newline at end of file diff --git a/crates/lumenqraph-core/src/validation.rs b/crates/lumenqraph-core/src/validation.rs new file mode 100644 index 0000000..4d22c34 --- /dev/null +++ b/crates/lumenqraph-core/src/validation.rs @@ -0,0 +1,58 @@ +/// Validates that a string is a valid Stellar contract ID (C-strkey format) +pub fn is_valid_contract_id(contract_id: &str) -> bool { + // Basic validation: C-strkey format + // Contract IDs start with 'C' and are 56 characters long + // Full validation would include StrKey decoding and checksum verification + contract_id.starts_with('C') && contract_id.len() == 56 && contract_id.chars().all(|c| c.is_ascii_alphanumeric()) +} + +/// Validates a list of contract IDs and returns the first invalid one, if any +pub fn validate_contract_ids(contract_ids: &[String]) -> Result<(), String> { + for contract_id in contract_ids { + if !is_valid_contract_id(contract_id) { + return Err(format!("Invalid contract ID: {}", contract_id)); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_contract_id() { + let valid_id = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + assert!(is_valid_contract_id(valid_id)); + } + + #[test] + fn test_invalid_contract_id_wrong_prefix() { + let invalid_id = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + assert!(!is_valid_contract_id(invalid_id)); + } + + #[test] + fn test_invalid_contract_id_wrong_length() { + let invalid_id = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + assert!(!is_valid_contract_id(invalid_id)); + } + + #[test] + fn test_validate_contract_ids_success() { + let contract_ids = vec![ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), + "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(), + ]; + assert!(validate_contract_ids(&contract_ids).is_ok()); + } + + #[test] + fn test_validate_contract_ids_failure() { + let contract_ids = vec![ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), + "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(), // Invalid G-strkey + ]; + assert!(validate_contract_ids(&contract_ids).is_err()); + } +} \ No newline at end of file diff --git a/crates/lumenqraph-indexer/Cargo.toml b/crates/lumenqraph-indexer/Cargo.toml new file mode 100644 index 0000000..9af15aa --- /dev/null +++ b/crates/lumenqraph-indexer/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "lumenqraph-indexer" +version = "0.1.0" +edition = "2021" + +[dependencies] +lumenqraph-core = { path = "../lumenqraph-core" } +tokio = { version = "1.0", features = ["full"] } +log = "0.4" +env_logger = "0.10" +thiserror = "1.0" +prometheus = "0.13" +lazy_static = "1.4" + +[dev-dependencies] +tokio-test = "0.4" \ No newline at end of file diff --git a/crates/lumenqraph-indexer/src/config.rs b/crates/lumenqraph-indexer/src/config.rs new file mode 100644 index 0000000..0ead2b1 --- /dev/null +++ b/crates/lumenqraph-indexer/src/config.rs @@ -0,0 +1,84 @@ +use std::env; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct Config { + pub max_consecutive_errors: u32, + pub circuit_breaker_interval: Duration, + pub contract_ids: Vec, + pub rpc_url: String, + pub polling_interval: Duration, + pub max_backoff: Duration, +} + +impl Config { + pub fn from_env() -> Result { + let max_consecutive_errors = env::var("MAX_CONSECUTIVE_ERRORS") + .unwrap_or_else(|_| "20".to_string()) + .parse() + .map_err(ConfigError::InvalidMaxConsecutiveErrors)?; + + let circuit_breaker_interval_secs = env::var("CIRCUIT_BREAKER_INTERVAL_SECS") + .unwrap_or_else(|_| "300".to_string()) // 5 minutes default + .parse::() + .map_err(ConfigError::InvalidCircuitBreakerInterval)?; + + let contract_ids = env::var("CONTRACT_IDS") + .map_err(|_| ConfigError::MissingContractIds)? + .split(',') + .map(|id| id.trim().to_string()) + .collect::>(); + + // Validate contract IDs using the core validation + lumenqraph_core::validate_contract_ids(&contract_ids) + .map_err(|e| ConfigError::InvalidContractId(e))?; + + let rpc_url = env::var("RPC_URL") + .map_err(|_| ConfigError::MissingRpcUrl)?; + + let polling_interval_secs = env::var("POLLING_INTERVAL_SECS") + .unwrap_or_else(|_| "5".to_string()) + .parse::() + .map_err(ConfigError::InvalidPollingInterval)?; + + let max_backoff_secs = env::var("MAX_BACKOFF_SECS") + .unwrap_or_else(|_| "60".to_string()) + .parse::() + .map_err(ConfigError::InvalidMaxBackoff)?; + + Ok(Config { + max_consecutive_errors, + circuit_breaker_interval: Duration::from_secs(circuit_breaker_interval_secs), + contract_ids, + rpc_url, + polling_interval: Duration::from_secs(polling_interval_secs), + max_backoff: Duration::from_secs(max_backoff_secs), + }) + } +} + + + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("Invalid MAX_CONSECUTIVE_ERRORS: {0}")] + InvalidMaxConsecutiveErrors(#[source] std::num::ParseIntError), + + #[error("Invalid CIRCUIT_BREAKER_INTERVAL_SECS: {0}")] + InvalidCircuitBreakerInterval(#[source] std::num::ParseIntError), + + #[error("Missing CONTRACT_IDS environment variable")] + MissingContractIds, + + #[error("Invalid contract ID: {0}")] + InvalidContractId(String), + + #[error("Missing RPC_URL environment variable")] + MissingRpcUrl, + + #[error("Invalid POLLING_INTERVAL_SECS: {0}")] + InvalidPollingInterval(#[source] std::num::ParseIntError), + + #[error("Invalid MAX_BACKOFF_SECS: {0}")] + InvalidMaxBackoff(#[source] std::num::ParseIntError), +} \ No newline at end of file diff --git a/crates/lumenqraph-indexer/src/lib.rs b/crates/lumenqraph-indexer/src/lib.rs new file mode 100644 index 0000000..ab48ead --- /dev/null +++ b/crates/lumenqraph-indexer/src/lib.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod poller; +pub mod validation; + +pub use config::{Config, ConfigError}; +pub use poller::{Poller, PollerError}; \ No newline at end of file diff --git a/crates/lumenqraph-indexer/src/poller.rs b/crates/lumenqraph-indexer/src/poller.rs new file mode 100644 index 0000000..a663383 --- /dev/null +++ b/crates/lumenqraph-indexer/src/poller.rs @@ -0,0 +1,216 @@ +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use log::{error, warn, info, debug}; +use prometheus::{register_gauge, Gauge}; +use tokio::time::sleep; + +use crate::config::Config; + +lazy_static::lazy_static! { + static ref CONSECUTIVE_ERRORS_GAUGE: Gauge = register_gauge!( + "lumenqraph_consecutive_errors", + "Number of consecutive errors in the indexer polling loop" + ).unwrap(); +} + +pub struct Poller { + config: Config, + consecutive_errors: Arc, + last_success: Option, + current_backoff: Duration, + in_circuit_breaker: bool, +} + +impl Poller { + pub fn new(config: Config) -> Self { + Self { + config, + consecutive_errors: Arc::new(AtomicU32::new(0)), + last_success: None, + current_backoff: Duration::from_secs(1), + in_circuit_breaker: false, + } + } + + pub async fn run(&mut self) -> Result<(), PollerError> { + info!("Starting indexer polling loop with circuit breaker (max consecutive errors: {})", + self.config.max_consecutive_errors); + + loop { + match self.poll_once().await { + Ok(()) => { + self.on_success(); + } + Err(e) => { + self.on_error(&e).await; + } + } + + // Sleep before next iteration + let sleep_duration = if self.in_circuit_breaker { + self.config.circuit_breaker_interval + } else { + self.current_backoff + }; + + debug!("Sleeping for {:?} before next poll", sleep_duration); + sleep(sleep_duration).await; + } + } + + async fn poll_once(&self) -> Result<(), PollerError> { + // Simulate polling logic - replace with actual RPC calls + // This is where you would implement the actual polling logic + // For example: + // - Fetch latest ledger from RPC + // - Process transactions + // - Update database + + // Placeholder implementation + if std::env::var("SIMULATE_RPC_FAILURE").is_ok() { + return Err(PollerError::RpcFailure("Simulated RPC failure".to_string())); + } + + Ok(()) + } + + fn on_success(&mut self) { + let previous_errors = self.consecutive_errors.swap(0, Ordering::Relaxed); + + if previous_errors > 0 { + info!("Polling recovered after {} consecutive errors", previous_errors); + } + + if self.in_circuit_breaker { + info!("Circuit breaker reset - returning to normal polling"); + self.in_circuit_breaker = false; + } + + self.last_success = Some(Instant::now()); + self.current_backoff = Duration::from_secs(1); // Reset backoff + + // Update Prometheus metric + CONSECUTIVE_ERRORS_GAUGE.set(0.0); + } + + async fn on_error(&mut self, error: &PollerError) { + let error_count = self.consecutive_errors.fetch_add(1, Ordering::Relaxed) + 1; + + error!("Polling failed (error #{}): {}", error_count, error); + + // Update Prometheus metric + CONSECUTIVE_ERRORS_GAUGE.set(error_count as f64); + + // Increment database error counter (placeholder) + self.increment_errors_total().await; + + // Check if we should enter circuit breaker mode + if error_count >= self.config.max_consecutive_errors && !self.in_circuit_breaker { + self.enter_circuit_breaker(); + } + + // Apply exponential backoff if not in circuit breaker mode + if !self.in_circuit_breaker { + self.apply_backoff(); + } + } + + fn enter_circuit_breaker(&mut self) { + error!( + "Entering circuit breaker mode after {} consecutive failures. \ + Will retry every {:?} instead of exponential backoff.", + self.config.max_consecutive_errors, + self.config.circuit_breaker_interval + ); + + self.in_circuit_breaker = true; + } + + fn apply_backoff(&mut self) { + self.current_backoff = std::cmp::min( + self.current_backoff * 2, + self.config.max_backoff + ); + + warn!("Applying exponential backoff: {:?}", self.current_backoff); + } + + async fn increment_errors_total(&self) { + // Placeholder for database error counter increment + // In a real implementation, this would increment a counter in the database + debug!("Incrementing errors_total counter in database"); + } + + pub fn get_consecutive_errors(&self) -> u32 { + self.consecutive_errors.load(Ordering::Relaxed) + } + + pub fn is_in_circuit_breaker(&self) -> bool { + self.in_circuit_breaker + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PollerError { + #[error("RPC failure: {0}")] + RpcFailure(String), + + #[error("Database error: {0}")] + DatabaseError(String), + + #[error("Network error: {0}")] + NetworkError(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_circuit_breaker_activation() { + let config = Config { + max_consecutive_errors: 3, + circuit_breaker_interval: Duration::from_millis(100), + contract_ids: vec!["CTEST123".to_string()], + rpc_url: "http://localhost".to_string(), + polling_interval: Duration::from_millis(50), + max_backoff: Duration::from_secs(1), + }; + + let mut poller = Poller::new(config); + + // Simulate 3 consecutive errors + for _ in 0..3 { + poller.on_error(&PollerError::RpcFailure("test".to_string())).await; + } + + assert!(poller.is_in_circuit_breaker()); + assert_eq!(poller.get_consecutive_errors(), 3); + } + + #[tokio::test] + async fn test_circuit_breaker_reset() { + let config = Config { + max_consecutive_errors: 2, + circuit_breaker_interval: Duration::from_millis(100), + contract_ids: vec!["CTEST123".to_string()], + rpc_url: "http://localhost".to_string(), + polling_interval: Duration::from_millis(50), + max_backoff: Duration::from_secs(1), + }; + + let mut poller = Poller::new(config); + + // Enter circuit breaker mode + for _ in 0..2 { + poller.on_error(&PollerError::RpcFailure("test".to_string())).await; + } + assert!(poller.is_in_circuit_breaker()); + + // Successful poll should reset circuit breaker + poller.on_success(); + assert!(!poller.is_in_circuit_breaker()); + assert_eq!(poller.get_consecutive_errors(), 0); + } +} \ No newline at end of file diff --git a/crates/lumenqraph-indexer/src/validation.rs b/crates/lumenqraph-indexer/src/validation.rs new file mode 100644 index 0000000..50f4880 --- /dev/null +++ b/crates/lumenqraph-indexer/src/validation.rs @@ -0,0 +1,2 @@ +// Re-export validation from core +pub use lumenqraph_core::validation::*; \ No newline at end of file diff --git a/crates/lumenqraph-mcp/Cargo.toml b/crates/lumenqraph-mcp/Cargo.toml new file mode 100644 index 0000000..535aa84 --- /dev/null +++ b/crates/lumenqraph-mcp/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lumenqraph-mcp" +version = "0.1.0" +edition = "2021" + +[dependencies] +lumenqraph-core = { path = "../lumenqraph-core" } +tokio = { version = "1.0", features = ["full"] } +log = "0.4" +env_logger = "0.10" +thiserror = "1.0" +serde_json = "1.0" + +[dev-dependencies] +tokio-test = "0.4" \ No newline at end of file diff --git a/crates/lumenqraph-mcp/src/main.rs b/crates/lumenqraph-mcp/src/main.rs new file mode 100644 index 0000000..588c78f --- /dev/null +++ b/crates/lumenqraph-mcp/src/main.rs @@ -0,0 +1,177 @@ +use std::env; +use log::{info, error}; + +mod tools; +mod rpc; + +use rpc::McpServer; + +#[tokio::main] +async fn main() -> Result<(), Box> { + env_logger::init(); + + info!("Starting Lumenqraph MCP server"); + + // Validate contract IDs at startup + let contract_ids = env::var("CONTRACT_IDS") + .map_err(|_| "Missing CONTRACT_IDS environment variable")? + .split(',') + .map(|id| id.trim().to_string()) + .collect::>(); + + if let Err(e) = lumenqraph_core::validate_contract_ids(&contract_ids) { + error!("Invalid contract IDs at startup: {}", e); + std::process::exit(1); + } + + info!("Contract IDs validation passed: {} contracts configured", contract_ids.len()); + + // Create and start MCP server + let mut server = McpServer::new(); + + info!("MCP server started, handling stdio"); + server.handle_stdio().await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::process::Command; + use std::io::Write; + use std::time::Duration; + use tokio::process::Command as TokioCommand; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + async fn test_startup_validation_success() { + env::set_var("CONTRACT_IDS", "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); + + let contract_ids = env::var("CONTRACT_IDS") + .unwrap() + .split(',') + .map(|id| id.trim().to_string()) + .collect::>(); + + assert!(lumenqraph_core::validate_contract_ids(&contract_ids).is_ok()); + + env::remove_var("CONTRACT_IDS"); + } + + #[tokio::test] + async fn test_startup_validation_failure() { + env::set_var("CONTRACT_IDS", "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,INVALID_ID"); + + let contract_ids = env::var("CONTRACT_IDS") + .unwrap() + .split(',') + .map(|id| id.trim().to_string()) + .collect::>(); + + assert!(lumenqraph_core::validate_contract_ids(&contract_ids).is_err()); + + env::remove_var("CONTRACT_IDS"); + } + + #[tokio::test] + async fn test_mcp_server_integration_initialize() { + // Test the MCP server can handle an initialize request + let mut server = crate::rpc::McpServer::new(); + + let input = r#"{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}"#; + let response = server.handle_request(input).await.unwrap(); + + assert!(response.is_some()); + let response_json: serde_json::Value = serde_json::from_str(&response.unwrap()).unwrap(); + assert_eq!(response_json["id"], 1); + assert!(response_json.get("result").is_some()); + } + + #[tokio::test] + async fn test_mcp_server_integration_full_protocol() { + // Test the full initialize -> tools/list -> tools/call flow + let mut server = crate::rpc::McpServer::new(); + + // Step 1: Initialize + let init_request = r#"{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}"#; + let init_response = server.handle_request(init_request).await.unwrap(); + assert!(init_response.is_some()); + + let init_json: serde_json::Value = serde_json::from_str(&init_response.unwrap()).unwrap(); + assert!(init_json.get("result").is_some()); + + // Step 2: List tools + let list_request = r#"{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}"#; + let list_response = server.handle_request(list_request).await.unwrap(); + assert!(list_response.is_some()); + + let list_json: serde_json::Value = serde_json::from_str(&list_response.unwrap()).unwrap(); + let tools = &list_json["result"]["tools"]; + assert!(tools.is_array()); + assert!(tools.as_array().unwrap().len() > 0); + + // Step 3: Call a tool + let call_request = r#"{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "search_intents", "arguments": {"query": "test"}}}"#; + let call_response = server.handle_request(call_request).await.unwrap(); + assert!(call_response.is_some()); + + let call_json: serde_json::Value = serde_json::from_str(&call_response.unwrap()).unwrap(); + assert!(call_json.get("result").is_some()); + } + + #[tokio::test] + async fn test_mcp_server_error_handling() { + let mut server = crate::rpc::McpServer::new(); + + // Test malformed JSON + let malformed_request = r#"{"invalid": json}"#; + let response = server.handle_request(malformed_request).await.unwrap(); + assert!(response.is_some()); + + let response_json: serde_json::Value = serde_json::from_str(&response.unwrap()).unwrap(); + assert!(response_json.get("error").is_some()); + + // Test unknown method + let unknown_method_request = r#"{"jsonrpc": "2.0", "id": 1, "method": "unknown_method"}"#; + let response = server.handle_request(unknown_method_request).await.unwrap(); + assert!(response.is_some()); + + let response_json: serde_json::Value = serde_json::from_str(&response.unwrap()).unwrap(); + assert!(response_json.get("error").is_some()); + + // Test tools/call without initialization + let call_request = r#"{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "search_intents", "arguments": {"query": "test"}}}"#; + let response = server.handle_request(call_request).await.unwrap(); + assert!(response.is_some()); + + let response_json: serde_json::Value = serde_json::from_str(&response.unwrap()).unwrap(); + assert!(response_json.get("error").is_some()); + } + + #[tokio::test] + async fn test_mcp_server_missing_required_fields() { + let mut server = crate::rpc::McpServer::new(); + + // Initialize first + let init_request = r#"{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}"#; + server.handle_request(init_request).await.unwrap(); + + // Test tools/call with missing tool name + let missing_name_request = r#"{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"arguments": {"query": "test"}}}"#; + let response = server.handle_request(missing_name_request).await.unwrap(); + assert!(response.is_some()); + + let response_json: serde_json::Value = serde_json::from_str(&response.unwrap()).unwrap(); + assert!(response_json.get("error").is_some()); + + // Test tools/call with missing required argument + let missing_arg_request = r#"{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "search_intents", "arguments": {}}}"#; + let response = server.handle_request(missing_arg_request).await.unwrap(); + assert!(response.is_some()); + + let response_json: serde_json::Value = serde_json::from_str(&response.unwrap()).unwrap(); + assert!(response_json.get("error").is_some()); + } +} \ No newline at end of file diff --git a/crates/lumenqraph-mcp/src/rpc.rs b/crates/lumenqraph-mcp/src/rpc.rs new file mode 100644 index 0000000..6f2ac12 --- /dev/null +++ b/crates/lumenqraph-mcp/src/rpc.rs @@ -0,0 +1,308 @@ +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncRead, AsyncWrite}; +use std::sync::Arc; +use log::{debug, error}; + +use crate::tools::{ToolRegistry, ToolError}; + +pub struct McpServer { + tool_registry: Arc, + initialized: bool, +} + +impl McpServer { + pub fn new() -> Self { + Self { + tool_registry: Arc::new(ToolRegistry::new()), + initialized: false, + } + } + + pub async fn handle_stdio(&mut self) -> Result<(), RpcError> { + let stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + + self.handle_stream(stdin, &mut stdout).await + } + + pub async fn handle_stream(&mut self, reader: R, writer: &mut W) -> Result<(), RpcError> + where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, + { + let mut buf_reader = BufReader::new(reader); + let mut line = String::new(); + + loop { + line.clear(); + match buf_reader.read_line(&mut line).await { + Ok(0) => break, // EOF + Ok(_) => { + if let Some(response) = self.handle_request(&line).await? { + writer.write_all(response.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + } + } + Err(e) => { + error!("Error reading from input: {}", e); + break; + } + } + } + + Ok(()) + } + + async fn handle_request(&mut self, line: &str) -> Result, RpcError> { + let line = line.trim(); + if line.is_empty() { + return Ok(None); + } + + debug!("Received request: {}", line); + + let request: Value = serde_json::from_str(line) + .map_err(|e| RpcError::InvalidJson(e.to_string()))?; + + let method = request.get("method") + .and_then(|m| m.as_str()) + .ok_or_else(|| RpcError::MissingMethod)?; + + let id = request.get("id").cloned(); + let params = request.get("params").cloned().unwrap_or(json!({})); + + let result = match method { + "initialize" => self.handle_initialize(params).await, + "tools/list" => self.handle_tools_list().await, + "tools/call" => self.handle_tools_call(params).await, + _ => Err(RpcError::UnknownMethod(method.to_string())), + }; + + let response = match result { + Ok(result) => json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + }), + Err(e) => json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -1, + "message": e.to_string() + } + }), + }; + + Ok(Some(response.to_string())) + } + + async fn handle_initialize(&mut self, _params: Value) -> Result { + debug!("Handling initialize request"); + + self.initialized = true; + + Ok(json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "lumenqraph-mcp", + "version": "1.0.0" + } + })) + } + + async fn handle_tools_list(&self) -> Result { + if !self.initialized { + return Err(RpcError::NotInitialized); + } + + debug!("Handling tools/list request"); + + let tools = self.tool_registry.list_tools(); + let tools_json: Vec = tools.iter().map(|tool| { + json!({ + "name": tool.name, + "description": tool.description, + "inputSchema": tool.input_schema + }) + }).collect(); + + Ok(json!({ + "tools": tools_json + })) + } + + async fn handle_tools_call(&self, params: Value) -> Result { + if !self.initialized { + return Err(RpcError::NotInitialized); + } + + debug!("Handling tools/call request"); + + let name = params.get("name") + .and_then(|n| n.as_str()) + .ok_or_else(|| RpcError::MissingToolName)?; + + let arguments = params.get("arguments") + .cloned() + .unwrap_or(json!({})); + + let result = self.tool_registry.call_tool(name, arguments).await + .map_err(|e| RpcError::ToolError(e))?; + + Ok(json!({ + "content": [{ + "type": "text", + "text": result.to_string() + }] + })) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RpcError { + #[error("Invalid JSON: {0}")] + InvalidJson(String), + + #[error("Missing method in request")] + MissingMethod, + + #[error("Unknown method: {0}")] + UnknownMethod(String), + + #[error("Server not initialized")] + NotInitialized, + + #[error("Missing tool name in tools/call request")] + MissingToolName, + + #[error("Tool error: {0}")] + ToolError(#[from] ToolError), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[tokio::test] + async fn test_initialize_request() { + let mut server = McpServer::new(); + let request = r#"{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}"#; + + let response = server.handle_request(request).await.unwrap(); + assert!(response.is_some()); + + let response_json: Value = serde_json::from_str(&response.unwrap()).unwrap(); + assert_eq!(response_json["id"], 1); + assert!(response_json.get("result").is_some()); + assert!(server.initialized); + } + + #[tokio::test] + async fn test_tools_list_requires_initialization() { + let server = McpServer::new(); + let request = r#"{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}"#; + + let response = server.handle_request(request).await.unwrap(); + assert!(response.is_some()); + + let response_json: Value = serde_json::from_str(&response.unwrap()).unwrap(); + assert!(response_json.get("error").is_some()); + } + + #[tokio::test] + async fn test_tools_list_after_initialization() { + let mut server = McpServer::new(); + + // Initialize first + let init_request = r#"{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}"#; + server.handle_request(init_request).await.unwrap(); + + // Now list tools + let request = r#"{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}"#; + let response = server.handle_request(request).await.unwrap(); + assert!(response.is_some()); + + let response_json: Value = serde_json::from_str(&response.unwrap()).unwrap(); + assert!(response_json.get("result").is_some()); + let tools = &response_json["result"]["tools"]; + assert!(tools.is_array()); + assert!(tools.as_array().unwrap().len() > 0); + } + + #[tokio::test] + async fn test_tools_call() { + let mut server = McpServer::new(); + + // Initialize first + let init_request = r#"{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}"#; + server.handle_request(init_request).await.unwrap(); + + // Call a tool + let request = r#"{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "search_intents", "arguments": {"query": "test"}}}"#; + let response = server.handle_request(request).await.unwrap(); + assert!(response.is_some()); + + let response_json: Value = serde_json::from_str(&response.unwrap()).unwrap(); + assert!(response_json.get("result").is_some()); + } + + #[tokio::test] + async fn test_invalid_json() { + let mut server = McpServer::new(); + let request = r#"invalid json"#; + + let response = server.handle_request(request).await.unwrap(); + assert!(response.is_some()); + + let response_json: Value = serde_json::from_str(&response.unwrap()).unwrap(); + assert!(response_json.get("error").is_some()); + } + + #[tokio::test] + async fn test_unknown_method() { + let mut server = McpServer::new(); + let request = r#"{"jsonrpc": "2.0", "id": 1, "method": "unknown_method"}"#; + + let response = server.handle_request(request).await.unwrap(); + assert!(response.is_some()); + + let response_json: Value = serde_json::from_str(&response.unwrap()).unwrap(); + assert!(response_json.get("error").is_some()); + } + + #[tokio::test] + async fn test_full_stdio_round_trip() { + let mut server = McpServer::new(); + + let input = r#"{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}} +{"jsonrpc": "2.0", "id": 2, "method": "tools/list"} +{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "search_intents", "arguments": {"query": "test"}}} +"#; + + let reader = Cursor::new(input.as_bytes()); + let mut writer = Vec::new(); + + let result = server.handle_stream(reader, &mut writer).await; + assert!(result.is_ok()); + + let output = String::from_utf8(writer).unwrap(); + let lines: Vec<&str> = output.trim().split('\n').collect(); + assert_eq!(lines.len(), 3); + + // Verify each response is valid JSON + for line in lines { + let json: Value = serde_json::from_str(line).unwrap(); + assert!(json.get("id").is_some()); + } + } +} \ No newline at end of file diff --git a/crates/lumenqraph-mcp/src/tools.rs b/crates/lumenqraph-mcp/src/tools.rs new file mode 100644 index 0000000..dffe6bf --- /dev/null +++ b/crates/lumenqraph-mcp/src/tools.rs @@ -0,0 +1,200 @@ +use serde_json::{Value, json}; +use std::collections::HashMap; + +#[derive(Debug)] +pub struct McpTool { + pub name: String, + pub description: String, + pub input_schema: Value, +} + +pub struct ToolRegistry { + tools: HashMap, +} + +impl ToolRegistry { + pub fn new() -> Self { + let mut registry = Self { + tools: HashMap::new(), + }; + + // Register built-in tools + registry.register_tool(McpTool { + name: "search_intents".to_string(), + description: "Search for intents in the Lumenqraph database".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query for intents" + }, + "limit": { + "type": "number", + "description": "Maximum number of results to return", + "default": 10 + } + }, + "required": ["query"] + }), + }); + + registry.register_tool(McpTool { + name: "get_solver_stats".to_string(), + description: "Get statistics for a specific solver".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "solver_id": { + "type": "string", + "description": "The solver ID to get stats for" + } + }, + "required": ["solver_id"] + }), + }); + + registry + } + + pub fn register_tool(&mut self, tool: McpTool) { + self.tools.insert(tool.name.clone(), tool); + } + + pub fn list_tools(&self) -> Vec<&McpTool> { + self.tools.values().collect() + } + + pub fn get_tool(&self, name: &str) -> Option<&McpTool> { + self.tools.get(name) + } + + pub async fn call_tool(&self, name: &str, arguments: Value) -> Result { + match name { + "search_intents" => self.search_intents(arguments).await, + "get_solver_stats" => self.get_solver_stats(arguments).await, + _ => Err(ToolError::UnknownTool(name.to_string())), + } + } + + async fn search_intents(&self, arguments: Value) -> Result { + let query = arguments.get("query") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::MissingArgument("query".to_string()))?; + + let limit = arguments.get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(10); + + // Placeholder implementation + Ok(json!({ + "results": [], + "query": query, + "limit": limit, + "total": 0 + })) + } + + async fn get_solver_stats(&self, arguments: Value) -> Result { + let solver_id = arguments.get("solver_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::MissingArgument("solver_id".to_string()))?; + + // Placeholder implementation + Ok(json!({ + "solver_id": solver_id, + "total_fills": 0, + "success_rate": 0.0, + "uptime": "0s" + })) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ToolError { + #[error("Unknown tool: {0}")] + UnknownTool(String), + + #[error("Missing required argument: {0}")] + MissingArgument(String), + + #[error("Invalid argument: {0}")] + InvalidArgument(String), + + #[error("Tool execution failed: {0}")] + ExecutionFailed(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tool_registry_creation() { + let registry = ToolRegistry::new(); + let tools = registry.list_tools(); + assert_eq!(tools.len(), 2); + + let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); + assert!(tool_names.contains(&"search_intents")); + assert!(tool_names.contains(&"get_solver_stats")); + } + + #[tokio::test] + async fn test_search_intents_tool() { + let registry = ToolRegistry::new(); + let args = json!({ + "query": "test query", + "limit": 5 + }); + + let result = registry.call_tool("search_intents", args).await; + assert!(result.is_ok()); + + let response = result.unwrap(); + assert_eq!(response["query"], "test query"); + assert_eq!(response["limit"], 5); + } + + #[tokio::test] + async fn test_get_solver_stats_tool() { + let registry = ToolRegistry::new(); + let args = json!({ + "solver_id": "solver123" + }); + + let result = registry.call_tool("get_solver_stats", args).await; + assert!(result.is_ok()); + + let response = result.unwrap(); + assert_eq!(response["solver_id"], "solver123"); + } + + #[tokio::test] + async fn test_unknown_tool_error() { + let registry = ToolRegistry::new(); + let args = json!({}); + + let result = registry.call_tool("unknown_tool", args).await; + assert!(result.is_err()); + + match result.unwrap_err() { + ToolError::UnknownTool(name) => assert_eq!(name, "unknown_tool"), + _ => panic!("Expected UnknownTool error"), + } + } + + #[tokio::test] + async fn test_missing_argument_error() { + let registry = ToolRegistry::new(); + let args = json!({}); // Missing required "query" argument + + let result = registry.call_tool("search_intents", args).await; + assert!(result.is_err()); + + match result.unwrap_err() { + ToolError::MissingArgument(arg) => assert_eq!(arg, "query"), + _ => panic!("Expected MissingArgument error"), + } + } +} \ No newline at end of file diff --git a/crates/lumenqraph-webhooks/Cargo.toml b/crates/lumenqraph-webhooks/Cargo.toml new file mode 100644 index 0000000..1ea215b --- /dev/null +++ b/crates/lumenqraph-webhooks/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lumenqraph-webhooks" +version = "0.1.0" +edition = "2021" + +[dependencies] +lumenqraph-core = { path = "../lumenqraph-core" } +tokio = { version = "1.0", features = ["full"] } +log = "0.4" +env_logger = "0.10" +thiserror = "1.0" + +[dev-dependencies] +tokio-test = "0.4" \ No newline at end of file diff --git a/crates/lumenqraph-webhooks/src/config.rs b/crates/lumenqraph-webhooks/src/config.rs new file mode 100644 index 0000000..9083bbf --- /dev/null +++ b/crates/lumenqraph-webhooks/src/config.rs @@ -0,0 +1,117 @@ +use std::env; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct Config { + pub contract_ids: Vec, + pub encryption_key: String, + pub tick_interval: Duration, + pub database_url: String, +} + +impl Config { + pub fn from_env() -> Result { + let contract_ids = env::var("CONTRACT_IDS") + .map_err(|_| ConfigError::MissingContractIds)? + .split(',') + .map(|id| id.trim().to_string()) + .collect::>(); + + // Validate contract IDs using the core validation + lumenqraph_core::validate_contract_ids(&contract_ids) + .map_err(|e| ConfigError::InvalidContractId(e))?; + + let encryption_key = env::var("WEBHOOK_ENCRYPTION_KEY") + .map_err(|_| ConfigError::MissingEncryptionKey)?; + + // Fail fast if the key is the testing default in production + if encryption_key == "default-key-for-testing" && env::var("ENVIRONMENT").unwrap_or_default() == "production" { + return Err(ConfigError::UnsafeEncryptionKey); + } + + let tick_interval_secs = env::var("WEBHOOK_TICK_INTERVAL_SECS") + .unwrap_or_else(|_| "3".to_string()) + .parse::() + .map_err(ConfigError::InvalidTickInterval)?; + + let database_url = env::var("DATABASE_URL") + .map_err(|_| ConfigError::MissingDatabaseUrl)?; + + Ok(Config { + contract_ids, + encryption_key, + tick_interval: Duration::from_secs(tick_interval_secs), + database_url, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("Missing CONTRACT_IDS environment variable")] + MissingContractIds, + + #[error("Invalid contract ID: {0}")] + InvalidContractId(String), + + #[error("Missing WEBHOOK_ENCRYPTION_KEY environment variable")] + MissingEncryptionKey, + + #[error("Unsafe encryption key detected in production environment")] + UnsafeEncryptionKey, + + #[error("Invalid WEBHOOK_TICK_INTERVAL_SECS: {0}")] + InvalidTickInterval(#[source] std::num::ParseIntError), + + #[error("Missing DATABASE_URL environment variable")] + MissingDatabaseUrl, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_validation_success() { + env::set_var("CONTRACT_IDS", "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + env::set_var("WEBHOOK_ENCRYPTION_KEY", "secure-key-123"); + env::set_var("DATABASE_URL", "postgres://localhost/test"); + + let result = Config::from_env(); + assert!(result.is_ok()); + + env::remove_var("CONTRACT_IDS"); + env::remove_var("WEBHOOK_ENCRYPTION_KEY"); + env::remove_var("DATABASE_URL"); + } + + #[test] + fn test_config_validation_invalid_contract_id() { + env::set_var("CONTRACT_IDS", "INVALID_ID"); + env::set_var("WEBHOOK_ENCRYPTION_KEY", "secure-key-123"); + env::set_var("DATABASE_URL", "postgres://localhost/test"); + + let result = Config::from_env(); + assert!(result.is_err()); + + env::remove_var("CONTRACT_IDS"); + env::remove_var("WEBHOOK_ENCRYPTION_KEY"); + env::remove_var("DATABASE_URL"); + } + + #[test] + fn test_config_validation_unsafe_key_in_production() { + env::set_var("CONTRACT_IDS", "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + env::set_var("WEBHOOK_ENCRYPTION_KEY", "default-key-for-testing"); + env::set_var("DATABASE_URL", "postgres://localhost/test"); + env::set_var("ENVIRONMENT", "production"); + + let result = Config::from_env(); + assert!(result.is_err()); + + env::remove_var("CONTRACT_IDS"); + env::remove_var("WEBHOOK_ENCRYPTION_KEY"); + env::remove_var("DATABASE_URL"); + env::remove_var("ENVIRONMENT"); + } +} \ No newline at end of file diff --git a/crates/lumenqraph-webhooks/src/dispatcher.rs b/crates/lumenqraph-webhooks/src/dispatcher.rs new file mode 100644 index 0000000..7e193c9 --- /dev/null +++ b/crates/lumenqraph-webhooks/src/dispatcher.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; +use log::{debug, error, info}; +use tokio::time::{sleep, Duration}; + +use crate::config::Config; + +pub struct Dispatcher { + config: Arc, +} + +impl Dispatcher { + pub fn new(config: Config) -> Self { + Self { + config: Arc::new(config), + } + } + + pub async fn run(&self) -> Result<(), DispatcherError> { + info!("Starting webhook dispatcher with tick interval {:?}", self.config.tick_interval); + + loop { + match self.tick().await { + Ok(()) => debug!("Webhook tick completed successfully"), + Err(e) => error!("Webhook tick failed: {}", e), + } + + sleep(self.config.tick_interval).await; + } + } + + async fn tick(&self) -> Result<(), DispatcherError> { + // Fetch due webhooks and process them + let due_webhooks = self.fetch_due().await?; + + for webhook in due_webhooks { + if let Err(e) = self.deliver(&webhook).await { + error!("Failed to deliver webhook {}: {}", webhook.id, e); + } + } + + Ok(()) + } + + async fn fetch_due(&self) -> Result, DispatcherError> { + // Use the encryption key from config instead of reading from environment + debug!("Fetching due webhooks using encryption key from config"); + + // Placeholder implementation - would query database for due webhooks + // and decrypt them using self.config.encryption_key + let _encryption_key = &self.config.encryption_key; + + Ok(vec![]) // Return empty list for now + } + + async fn deliver(&self, webhook: &Webhook) -> Result<(), DispatcherError> { + debug!("Delivering webhook {} using encryption key from config", webhook.id); + + // Use the encryption key from config for any encryption needed during delivery + let _encryption_key = &self.config.encryption_key; + + // Placeholder implementation - would deliver the webhook + Ok(()) + } +} + +#[derive(Debug)] +pub struct Webhook { + pub id: String, + pub url: String, + pub payload: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum DispatcherError { + #[error("Database error: {0}")] + DatabaseError(String), + + #[error("HTTP error: {0}")] + HttpError(String), + + #[error("Encryption error: {0}")] + EncryptionError(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + fn create_test_config() -> Config { + Config { + contract_ids: vec!["CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string()], + encryption_key: "test-encryption-key".to_string(), + tick_interval: Duration::from_millis(100), + database_url: "postgres://localhost/test".to_string(), + } + } + + #[tokio::test] + async fn test_dispatcher_creation() { + let config = create_test_config(); + let dispatcher = Dispatcher::new(config); + + // Verify the dispatcher has access to the encryption key + assert_eq!(dispatcher.config.encryption_key, "test-encryption-key"); + } + + #[tokio::test] + async fn test_fetch_due_uses_config_key() { + let config = create_test_config(); + let dispatcher = Dispatcher::new(config); + + // This should not panic and should use the config key, not env var + let result = dispatcher.fetch_due().await; + assert!(result.is_ok()); + } +} \ No newline at end of file diff --git a/crates/lumenqraph-webhooks/src/main.rs b/crates/lumenqraph-webhooks/src/main.rs new file mode 100644 index 0000000..b7af304 --- /dev/null +++ b/crates/lumenqraph-webhooks/src/main.rs @@ -0,0 +1,64 @@ +use log::{info, error}; + +mod config; +mod dispatcher; + +use config::Config; +use dispatcher::Dispatcher; + +#[tokio::main] +async fn main() -> Result<(), Box> { + env_logger::init(); + + info!("Starting Lumenqraph Webhooks service"); + + // Load and validate configuration at startup + let config = Config::from_env().map_err(|e| { + error!("Configuration error: {}", e); + e + })?; + + info!("Configuration loaded successfully"); + info!("Encryption key configured: {}", if config.encryption_key.is_empty() { "NO" } else { "YES" }); + info!("Contract IDs configured: {}", config.contract_ids.len()); + + // Create and start dispatcher + let dispatcher = Dispatcher::new(config); + + info!("Webhook dispatcher starting"); + dispatcher.run().await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::env; + + #[tokio::test] + async fn test_service_startup_with_valid_config() { + env::set_var("CONTRACT_IDS", "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + env::set_var("WEBHOOK_ENCRYPTION_KEY", "test-key-123"); + env::set_var("DATABASE_URL", "postgres://localhost/test"); + + let config_result = crate::config::Config::from_env(); + assert!(config_result.is_ok()); + + env::remove_var("CONTRACT_IDS"); + env::remove_var("WEBHOOK_ENCRYPTION_KEY"); + env::remove_var("DATABASE_URL"); + } + + #[tokio::test] + async fn test_service_startup_fails_with_missing_key() { + env::set_var("CONTRACT_IDS", "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + env::set_var("DATABASE_URL", "postgres://localhost/test"); + // Deliberately not setting WEBHOOK_ENCRYPTION_KEY + + let config_result = crate::config::Config::from_env(); + assert!(config_result.is_err()); + + env::remove_var("CONTRACT_IDS"); + env::remove_var("DATABASE_URL"); + } +} \ No newline at end of file