1use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::env;
9
10#[derive(Debug, Clone)]
12pub struct ConfigError {
13 pub message: String,
15}
16
17impl std::fmt::Display for ConfigError {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 write!(f, "Config error: {}", self.message)
20 }
21}
22
23impl std::error::Error for ConfigError {}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Config {
28 pub metadata: Metadata,
30
31 pub chains: Vec<ChainConfig>,
33
34 pub invariants: Vec<InvariantConfig>,
36
37 #[serde(default)]
39 pub alert: AlertConfig,
40
41 #[serde(default)]
43 pub evaluation: EvaluationConfig,
44
45 #[serde(default)]
47 pub daemon: DaemonConfig,
48
49 #[serde(default)]
51 pub logging: LoggingConfig,
52
53 #[serde(default)]
55 pub metrics: MetricsConfig,
56
57 #[serde(default)]
59 pub security: SecurityConfig,
60
61 #[serde(default)]
63 pub performance: PerformanceConfig,
64}
65
66impl Config {
67 pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
73 let content = std::fs::read_to_string(path)?;
74 Self::load_from_string(&content)
75 }
76
77 pub fn load_from_string(content: &str) -> anyhow::Result<Self> {
80 let expanded = Self::expand_env_vars(content).map_err(|e| anyhow::anyhow!("{}", e))?;
82
83 let config: Config = toml::from_str(&expanded)
85 .map_err(|e| anyhow::anyhow!("Failed to parse TOML: {}", e))?;
86
87 config.validate().map_err(|e| anyhow::anyhow!("{}", e))?;
89
90 Ok(config)
91 }
92
93 fn expand_env_vars(content: &str) -> Result<String, ConfigError> {
95 let mut result = content.to_string();
96 let mut last_pos = 0;
97
98 while let Some(start) = result[last_pos..].find("${") {
100 let start = last_pos + start;
101 if let Some(end) = result[start..].find('}') {
102 let end = start + end;
103 let var_ref = &result[start + 2..end];
104
105 match env::var(var_ref) {
107 Ok(value) => {
108 result.replace_range(start..=end, &value);
109 last_pos = start + value.len();
110 }
111 Err(_) => {
112 return Err(ConfigError {
113 message: format!(
114 "Environment variable not set: {}. \
115 Please export it before running Sentri. \
116 E.g.: export {}=<value>",
117 var_ref, var_ref
118 ),
119 });
120 }
121 }
122 } else {
123 return Err(ConfigError {
124 message: "Unclosed environment variable reference: ${".to_string(),
125 });
126 }
127 }
128
129 Ok(result)
130 }
131
132 pub fn validate(&self) -> Result<(), ConfigError> {
134 if self.chains.is_empty() {
136 return Err(ConfigError {
137 message: "At least one chain must be configured in [[chains]]".to_string(),
138 });
139 }
140
141 let chain_ids: Vec<&str> = self.chains.iter().map(|c| c.id.as_str()).collect();
142
143 for (i, id1) in chain_ids.iter().enumerate() {
145 for id2 in &chain_ids[i + 1..] {
146 if id1 == id2 {
147 return Err(ConfigError {
148 message: format!("Duplicate chain ID: {}", id1),
149 });
150 }
151 }
152 }
153
154 if self.invariants.is_empty() {
156 return Err(ConfigError {
157 message: "At least one invariant must be configured in [[invariants]]".to_string(),
158 });
159 }
160
161 for inv in &self.invariants {
163 if !chain_ids.contains(&inv.chain.as_str()) {
164 return Err(ConfigError {
165 message: format!(
166 "Invariant '{}' references unknown chain '{}'. \
167 Configured chains: {:?}",
168 inv.name, inv.chain, chain_ids
169 ),
170 });
171 }
172
173 let names: Vec<_> = self.invariants.iter().map(|i| &i.name).collect();
175 for (i, name1) in names.iter().enumerate() {
176 for name2 in &names[i + 1..] {
177 if name1 == name2 {
178 return Err(ConfigError {
179 message: format!("Duplicate invariant name: {}", name1),
180 });
181 }
182 }
183 }
184 }
185
186 for sink in &self.alert.sinks {
188 sink.validate()?;
189 }
190
191 Ok(())
192 }
193
194 pub fn get_chain(&self, id: &str) -> Option<&ChainConfig> {
196 self.chains.iter().find(|c| c.id == id)
197 }
198
199 pub fn invariants_for_chain(&self, chain_id: &str) -> Vec<&InvariantConfig> {
201 self.invariants
202 .iter()
203 .filter(|inv| inv.chain == chain_id)
204 .collect()
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct Metadata {
211 pub name: String,
213
214 pub version: String,
216
217 #[serde(default)]
219 pub description: Option<String>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct ChainConfig {
225 pub id: String,
227
228 #[serde(rename = "type")]
230 pub chain_type: String,
231
232 pub chain_id: u64,
234
235 pub rpc_urls: Vec<String>,
237
238 #[serde(default)]
240 pub ws_url: Option<String>,
241
242 #[serde(default = "default_poll_interval")]
244 pub poll_interval_ms: u64,
245
246 #[serde(default = "default_timeout")]
248 pub timeout_ms: u64,
249
250 #[serde(default)]
252 pub retry: RetryConfig,
253
254 #[serde(default)]
256 pub pool: PoolConfig,
257}
258
259fn default_poll_interval() -> u64 {
260 12000
261}
262
263fn default_timeout() -> u64 {
264 30000
265}
266
267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
269pub struct RetryConfig {
270 #[serde(default = "default_max_attempts")]
272 pub max_attempts: u32,
273
274 #[serde(default = "default_initial_backoff")]
276 pub initial_backoff_ms: u64,
277
278 #[serde(default = "default_max_backoff")]
280 pub max_backoff_ms: u64,
281}
282
283fn default_max_attempts() -> u32 {
284 3
285}
286
287fn default_initial_backoff() -> u64 {
288 100
289}
290
291fn default_max_backoff() -> u64 {
292 400
293}
294
295#[derive(Debug, Clone, Default, Serialize, Deserialize)]
297pub struct PoolConfig {
298 #[serde(default = "default_max_connections")]
300 pub max_connections: u32,
301
302 #[serde(default = "default_keepalive")]
304 pub keepalive_seconds: u64,
305}
306
307fn default_max_connections() -> u32 {
308 10
309}
310
311fn default_keepalive() -> u64 {
312 90
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct InvariantConfig {
318 pub name: String,
320
321 #[serde(default)]
323 pub description: Option<String>,
324
325 pub chain: String,
327
328 pub contract: String,
330
331 pub check: String,
333
334 #[serde(default)]
336 pub baseline_block: Option<u64>,
337
338 #[serde(default = "default_severity")]
340 pub severity: String,
341
342 #[serde(default)]
344 pub tags: Vec<String>,
345}
346
347fn default_severity() -> String {
348 "high".to_string()
349}
350
351#[derive(Debug, Clone, Default, Serialize, Deserialize)]
353pub struct AlertConfig {
354 #[serde(default = "default_throttle")]
356 pub throttle_seconds: u64,
357
358 #[serde(default = "default_alert_enabled")]
360 pub enabled: bool,
361
362 #[serde(default = "default_log_level")]
364 pub log_level: String,
365
366 #[serde(default)]
368 pub sinks: Vec<AlertSink>,
369}
370
371fn default_throttle() -> u64 {
372 300
373}
374
375fn default_alert_enabled() -> bool {
376 true
377}
378
379fn default_log_level() -> String {
380 "error".to_string()
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct AlertSink {
386 #[serde(rename = "type")]
388 pub sink_type: String,
389
390 #[serde(default)]
392 pub name: Option<String>,
393
394 #[serde(default)]
396 pub webhook_url: Option<String>,
397
398 #[serde(default)]
400 pub message_template: Option<String>,
401
402 #[serde(default)]
404 pub severities: Vec<String>,
405
406 #[serde(default)]
408 pub url: Option<String>,
409
410 #[serde(default)]
412 pub headers: BTreeMap<String, String>,
413
414 #[serde(default = "default_method")]
416 pub method: String,
417
418 #[serde(default)]
420 pub retry_enabled: bool,
421
422 #[serde(default)]
424 pub retry_max_attempts: u32,
425}
426
427fn default_method() -> String {
428 "post".to_string()
429}
430
431impl AlertSink {
432 pub fn validate(&self) -> Result<(), ConfigError> {
434 match self.sink_type.as_str() {
435 "slack" => {
436 if self.webhook_url.is_none() {
437 return Err(ConfigError {
438 message: "Slack sink requires 'webhook_url'".to_string(),
439 });
440 }
441 Ok(())
442 }
443 "webhook" => {
444 if self.url.is_none() {
445 return Err(ConfigError {
446 message: "Webhook sink requires 'url'".to_string(),
447 });
448 }
449 Ok(())
450 }
451 _ => Err(ConfigError {
452 message: format!("Unknown alert sink type: {}", self.sink_type),
453 }),
454 }
455 }
456}
457
458#[derive(Debug, Clone, Default, Serialize, Deserialize)]
460pub struct EvaluationConfig {
461 #[serde(default = "default_eval_interval")]
463 pub interval_secs: u64,
464
465 #[serde(default = "default_eval_timeout")]
467 pub timeout_secs: u64,
468
469 #[serde(default = "default_eval_mode")]
471 pub mode: String,
472}
473
474fn default_eval_interval() -> u64 {
475 12
476}
477
478fn default_eval_timeout() -> u64 {
479 30
480}
481
482fn default_eval_mode() -> String {
483 "block_based".to_string()
484}
485
486#[derive(Debug, Clone, Default, Serialize, Deserialize)]
488pub struct DaemonConfig {
489 #[serde(default)]
491 pub enabled: bool,
492
493 #[serde(default = "default_metrics_port")]
495 pub metrics_port: u16,
496
497 #[serde(default = "default_metrics_host")]
499 pub metrics_host: String,
500
501 #[serde(default = "default_health_check")]
503 pub health_check_enabled: bool,
504
505 #[serde(default)]
507 pub reload_on_sighup: bool,
508
509 #[serde(default = "default_shutdown_timeout")]
511 pub graceful_shutdown_timeout_secs: u64,
512}
513
514fn default_metrics_port() -> u16 {
515 9090
516}
517
518fn default_metrics_host() -> String {
519 "127.0.0.1".to_string()
520}
521
522fn default_health_check() -> bool {
523 true
524}
525
526fn default_shutdown_timeout() -> u64 {
527 5
528}
529
530#[derive(Debug, Clone, Default, Serialize, Deserialize)]
532pub struct LoggingConfig {
533 #[serde(default = "default_log_level")]
535 pub level: String,
536
537 #[serde(default = "default_format")]
539 pub format: String,
540
541 #[serde(default)]
543 pub file: Option<String>,
544
545 #[serde(default)]
547 pub max_size_mb: Option<u32>,
548
549 #[serde(default)]
551 pub max_backups: Option<u32>,
552
553 #[serde(default = "default_redact")]
555 pub redact_sensitive: bool,
556}
557
558fn default_format() -> String {
559 "pretty".to_string()
560}
561
562fn default_redact() -> bool {
563 true
564}
565
566#[derive(Debug, Clone, Default, Serialize, Deserialize)]
568pub struct MetricsConfig {
569 #[serde(default)]
571 pub enabled: bool,
572
573 #[serde(default = "default_metrics_format")]
575 pub format: String,
576
577 #[serde(default = "default_histogram_buckets")]
579 pub histogram_buckets_ms: Vec<u64>,
580
581 #[serde(default = "default_sample_rate")]
583 pub sample_rate: f64,
584}
585
586fn default_metrics_format() -> String {
587 "prometheus".to_string()
588}
589
590fn default_histogram_buckets() -> Vec<u64> {
591 vec![10, 50, 100, 500, 1000, 5000]
592}
593
594fn default_sample_rate() -> f64 {
595 1.0
596}
597
598#[derive(Debug, Clone, Default, Serialize, Deserialize)]
600pub struct SecurityConfig {
601 #[serde(default = "default_redact")]
603 pub redact_sensitive_in_logs: bool,
604}
605
606#[derive(Debug, Clone, Default, Serialize, Deserialize)]
608pub struct PerformanceConfig {
609 #[serde(default = "default_eval_workers")]
611 pub eval_workers: u32,
612
613 #[serde(default)]
615 pub cache_expressions: bool,
616
617 #[serde(default)]
619 pub cache_state: bool,
620
621 #[serde(default = "default_cache_ttl")]
623 pub cache_ttl_secs: u64,
624}
625
626fn default_eval_workers() -> u32 {
627 4
628}
629
630fn default_cache_ttl() -> u64 {
631 60
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 #[test]
639 fn test_config_validation() {
640 let minimal_toml = r#"
641 [metadata]
642 name = "test"
643 version = "1.0"
644
645 [[chains]]
646 id = "ethereum"
647 type = "evm"
648 chain_id = 1
649 rpc_urls = ["https://eth.example.com"]
650
651 [[invariants]]
652 name = "test_invariant"
653 chain = "ethereum"
654 contract = "0x1234"
655 check = "x > 0"
656 "#;
657
658 let config = Config::load_from_string(minimal_toml);
659 assert!(config.is_ok());
660 }
661
662 #[test]
663 fn test_missing_chain_reference() {
664 let invalid_toml = r#"
665 [metadata]
666 name = "test"
667 version = "1.0"
668
669 [[chains]]
670 id = "ethereum"
671 type = "evm"
672 chain_id = 1
673 rpc_urls = ["https://eth.example.com"]
674
675 [[invariants]]
676 name = "test_invariant"
677 chain = "solana"
678 contract = "0x1234"
679 check = "x > 0"
680 "#;
681
682 let config = Config::load_from_string(invalid_toml);
683 assert!(config.is_err());
684 }
685
686 #[test]
687 fn test_env_var_substitution() {
688 env::set_var("TEST_RPC_URL", "https://test.example.com");
689
690 let toml_with_var = r#"
691 [metadata]
692 name = "test"
693 version = "1.0"
694
695 [[chains]]
696 id = "ethereum"
697 type = "evm"
698 chain_id = 1
699 rpc_urls = ["${TEST_RPC_URL}"]
700
701 [[invariants]]
702 name = "test_invariant"
703 chain = "ethereum"
704 contract = "0x1234"
705 check = "x > 0"
706 "#;
707
708 let config = Config::load_from_string(toml_with_var);
709 assert!(config.is_ok());
710
711 let cfg = config.unwrap();
712 assert_eq!(cfg.chains[0].rpc_urls[0], "https://test.example.com");
713
714 env::remove_var("TEST_RPC_URL");
715 }
716
717 #[test]
718 fn test_missing_env_var() {
719 let toml_with_missing_var = r#"
720 [metadata]
721 name = "test"
722 version = "1.0"
723
724 [[chains]]
725 id = "ethereum"
726 type = "evm"
727 chain_id = 1
728 rpc_urls = ["${NONEXISTENT_VAR_12345"}"]
729
730 [[invariants]]
731 name = "test_invariant"
732 chain = "ethereum"
733 contract = "0x1234"
734 check = "x > 0"
735 "#;
736
737 let config = Config::load_from_string(toml_with_missing_var);
738 assert!(config.is_err());
739 }
740}