Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 166 additions & 1 deletion services/api/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use ipnet::IpNet;
use std::{
collections::HashMap,
env,
net::{IpAddr, SocketAddr},
str::FromStr,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -649,13 +652,99 @@ 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 {
Err(SchemaValidationError { errors })
}
}

/// 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<String> {
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<String, Vec<String>> = 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<String, u8> =
FIELDS.iter().map(|&f| (f.to_string(), 0)).collect();
let mut path: Vec<String> = 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<String, Vec<String>>,
colors: &mut HashMap<String, u8>,
path: &mut Vec<String>,
) -> Option<Vec<String>> {
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`.
Expand Down Expand Up @@ -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());
}
}