diff --git a/services/api/src/config.rs b/services/api/src/config.rs index decf596f..58898438 100644 --- a/services/api/src/config.rs +++ b/services/api/src/config.rs @@ -1,5 +1,6 @@ use ipnet::IpNet; use std::{ + collections::HashMap, env, net::{IpAddr, SocketAddr}, str::FromStr, @@ -615,7 +616,9 @@ impl ContractKeySchema { } /// Validate that all templates that require `{id}` actually contain it, - /// and that no template is empty. + /// and that no template is empty. Also detects circular dependencies + /// between template field references (e.g. `{market}` referring back to + /// `platform_stats` which refers to `market`). /// /// Returns `Err` with a list of every problem found so all issues are /// surfaced in a single startup log line rather than discovered one by one. @@ -649,6 +652,14 @@ impl ContractKeySchema { } } + // Circular dependency check over template field references. + if let Some(cycle_path) = self.detect_cycle() { + errors.push(format!( + "contract key schema contains circular dependency: {}", + cycle_path + )); + } + if errors.is_empty() { Ok(()) } else { @@ -656,6 +667,84 @@ impl ContractKeySchema { } } + /// Detect circular references between template fields. + /// + /// A template may reference another field via `{field_name}` placeholders + /// (excluding `{id}`, which is a runtime value, not a field reference). + /// This scans the dependency graph for cycles using DFS with 3-color + /// marking. Returns `Some(cycle_path)` when a cycle is found, or `None` + /// when the graph is acyclic. + fn detect_cycle(&self) -> Option { + const FIELDS: [&'static str; 5] = + ["market", "platform_stats", "user_bets", "oracle_result", "health_check"]; + + let templates = [ + ("market", &self.market), + ("platform_stats", &self.platform_stats), + ("user_bets", &self.user_bets), + ("oracle_result", &self.oracle_result), + ("health_check", &self.health_check), + ]; + + let mut deps: HashMap> = HashMap::new(); + for (name, template) in &templates { + let mut refs = Vec::new(); + for &field in &FIELDS { + let placeholder = format!("{{{}}}", field); + if template.contains(&placeholder) { + refs.push(field.to_string()); + } + } + deps.insert(name.to_string(), refs); + } + + let mut colors: HashMap = + FIELDS.iter().map(|&f| (f.to_string(), 0)).collect(); + let mut path: Vec = Vec::new(); + + for &field in &FIELDS { + if *colors.get(field).unwrap_or(&0) == 0 { + if let Some(cycle) = + Self::dfs(field, &deps, &mut colors, &mut path) + { + return Some(cycle.join(" → ")); + } + } + } + + None + } + + fn dfs( + node: &str, + deps: &HashMap>, + colors: &mut HashMap, + path: &mut Vec, + ) -> Option> { + colors.insert(node.to_string(), 1); + path.push(node.to_string()); + + if let Some(neighbors) = deps.get(node) { + for neighbor in neighbors { + let color = *colors.get(neighbor.as_str()).unwrap_or(&0); + if color == 1 { + if let Some(cycle_start) = path.iter().position(|x| x == neighbor) { + let mut cycle = path[cycle_start..].to_vec(); + cycle.push(neighbor.clone()); + return Some(cycle); + } + } + if let Some(result) = Self::dfs(neighbor, deps, colors, path) { + return Some(result); + } + } + } + + path.pop(); + colors.insert(node.to_string(), 2); + None + } + // ── Key builders ────────────────────────────────────────────────────────── /// Resolve the market key for `market_id`. @@ -961,4 +1050,80 @@ mod tests { }; assert!(config.validate().is_err()); } + + #[test] + fn test_validate_detects_two_node_cycle() { + let schema = ContractKeySchema { + version: "1.0.0".to_string(), + market: "stats:{platform_stats}:{id}".to_string(), + platform_stats: "markets:{market}:{id}".to_string(), + user_bets: "user_bets:{id}".to_string(), + oracle_result: "oracle_result:{id}".to_string(), + health_check: "health_check:{id}".to_string(), + }; + + let result = schema.validate(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.errors + .iter() + .any(|e| e.contains("circular dependency")), + "expected circular dependency error, got: {:?}", + err.errors + ); + assert!( + err.errors + .iter() + .any(|e| e.contains("market") && e.contains("platform_stats")), + "expected cycle path to mention market and platform_stats, got: {:?}", + err.errors + ); + } + + #[test] + fn test_validate_detects_three_node_cycle() { + let schema = ContractKeySchema { + version: "1.0.0".to_string(), + market: "mb:{user_bets}:{id}".to_string(), + platform_stats: "platform:stats".to_string(), + user_bets: "or:{oracle_result}:{id}".to_string(), + oracle_result: "mk:{market}:{id}".to_string(), + health_check: "health_check:{id}".to_string(), + }; + + let result = schema.validate(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.errors + .iter() + .any(|e| e.contains("circular dependency")), + "expected circular dependency error, got: {:?}", + err.errors + ); + assert!( + err.errors.iter().any(|e| { + e.contains("market") + && e.contains("user_bets") + && e.contains("oracle_result") + }), + "expected cycle path to mention market, user_bets, and oracle_result, got: {:?}", + err.errors + ); + } + + #[test] + fn test_validate_no_cycle_on_defaults() { + let schema = ContractKeySchema { + version: "1.0.0".to_string(), + market: "market:{id}".to_string(), + platform_stats: "platform:stats".to_string(), + user_bets: "user_bets:{id}".to_string(), + oracle_result: "oracle_result:{id}".to_string(), + health_check: "health_check:{id}".to_string(), + }; + + assert!(schema.validate().is_ok()); + } }