1use std::collections::BTreeSet;
2use std::fs;
3use std::net::{IpAddr, Ipv6Addr};
4use std::path::Path;
5
6use chio_core::web3::trust_profile::Web3FinalityMode;
7use chio_egress_contract::HttpEgressContract;
8use reqwest::Url;
9use serde::{Deserialize, Serialize};
10
11use crate::SettlementError;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum EvidenceSubstrateMode {
16 #[default]
17 LocalKernelSignedCheckpointV1,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(deny_unknown_fields)]
22pub struct SettlementEvidenceConfig {
23 #[serde(default)]
24 pub mode: EvidenceSubstrateMode,
25 #[serde(default = "default_evidence_flag")]
26 pub durable_receipts: bool,
27 #[serde(default = "default_evidence_flag")]
28 pub checkpoint_statements: bool,
29 #[serde(default = "default_evidence_flag")]
30 pub signer_matches_receipts: bool,
31}
32
33impl Default for SettlementEvidenceConfig {
34 fn default() -> Self {
35 Self {
36 mode: EvidenceSubstrateMode::LocalKernelSignedCheckpointV1,
37 durable_receipts: true,
38 checkpoint_statements: true,
39 signer_matches_receipts: true,
40 }
41 }
42}
43
44impl SettlementEvidenceConfig {
45 pub fn validate(&self) -> Result<(), SettlementError> {
46 match self.mode {
47 EvidenceSubstrateMode::LocalKernelSignedCheckpointV1 => {}
48 }
49
50 if !self.durable_receipts {
51 return Err(SettlementError::invalid_input(
52 "web3 settlement requires durable local receipt storage",
53 ));
54 }
55 if !self.checkpoint_statements {
56 return Err(SettlementError::invalid_input(
57 "web3 settlement requires kernel-signed checkpoint statements",
58 ));
59 }
60 if !self.signer_matches_receipts {
61 return Err(SettlementError::invalid_input(
62 "web3 settlement requires checkpoint signer equality with receipt kernel keys",
63 ));
64 }
65 Ok(())
66 }
67}
68
69fn default_evidence_flag() -> bool {
70 true
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
74#[serde(rename_all = "snake_case")]
75pub enum SettlementOracleAuthority {
76 #[default]
77 ChioLinkReceiptEvidence,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81#[serde(deny_unknown_fields)]
82pub struct SettlementOracleConfig {
83 #[serde(default)]
84 pub authority: SettlementOracleAuthority,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub price_resolver_contract: Option<String>,
87}
88
89impl Default for SettlementOracleConfig {
90 fn default() -> Self {
91 Self {
92 authority: SettlementOracleAuthority::ChioLinkReceiptEvidence,
93 price_resolver_contract: None,
94 }
95 }
96}
97
98impl SettlementOracleConfig {
99 pub fn validate(&self) -> Result<(), SettlementError> {
100 match self.authority {
101 SettlementOracleAuthority::ChioLinkReceiptEvidence => {}
102 }
103
104 if self
105 .price_resolver_contract
106 .as_deref()
107 .is_some_and(|value| value.trim().is_empty())
108 {
109 return Err(SettlementError::invalid_input(
110 "settlement oracle price_resolver_contract must not be empty",
111 ));
112 }
113
114 Ok(())
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
119#[serde(deny_unknown_fields)]
120pub struct SettlementAmountTier {
121 pub upper_bound_units: u64,
122 pub dispute_window_secs: u64,
123 pub min_confirmations: u32,
124 pub finality_mode: Web3FinalityMode,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128#[serde(deny_unknown_fields)]
129pub struct SettlementPolicyConfig {
130 pub chio_minor_unit_decimals: u8,
131 pub token_minor_unit_decimals: u8,
132 pub tiers: Vec<SettlementAmountTier>,
133}
134
135impl Default for SettlementPolicyConfig {
136 fn default() -> Self {
137 Self {
138 chio_minor_unit_decimals: 2,
139 token_minor_unit_decimals: 6,
140 tiers: vec![
141 SettlementAmountTier {
142 upper_bound_units: 1_000,
143 dispute_window_secs: 0,
144 min_confirmations: 1,
145 finality_mode: Web3FinalityMode::OptimisticL2,
146 },
147 SettlementAmountTier {
148 upper_bound_units: 100_000,
149 dispute_window_secs: 3_600,
150 min_confirmations: 1,
151 finality_mode: Web3FinalityMode::OptimisticL2,
152 },
153 SettlementAmountTier {
154 upper_bound_units: 1_000_000,
155 dispute_window_secs: 14_400,
156 min_confirmations: 12,
157 finality_mode: Web3FinalityMode::L1Finalized,
158 },
159 SettlementAmountTier {
160 upper_bound_units: u64::MAX,
161 dispute_window_secs: 86_400,
162 min_confirmations: 64,
163 finality_mode: Web3FinalityMode::L1Finalized,
164 },
165 ],
166 }
167 }
168}
169
170impl SettlementPolicyConfig {
171 pub fn validate(&self) -> Result<(), SettlementError> {
172 if self.tiers.is_empty() {
173 return Err(SettlementError::invalid_input(
174 "settlement policy requires at least one amount tier",
175 ));
176 }
177 if self.token_minor_unit_decimals < self.chio_minor_unit_decimals {
178 return Err(SettlementError::invalid_input(
179 "token decimals must be >= Chio monetary minor-unit decimals",
180 ));
181 }
182 let mut last_bound = 0_u64;
183 for (index, tier) in self.tiers.iter().enumerate() {
184 if tier.upper_bound_units < last_bound {
185 return Err(SettlementError::invalid_input(format!(
186 "settlement tier {index} upper bound regresses"
187 )));
188 }
189 if tier.min_confirmations == 0 {
190 return Err(SettlementError::invalid_input(format!(
191 "settlement tier {index} must require at least one confirmation"
192 )));
193 }
194 last_bound = tier.upper_bound_units;
195 }
196 Ok(())
197 }
198
199 #[must_use]
200 pub fn tier_for_amount(&self, units: u64) -> &SettlementAmountTier {
201 self.tiers
202 .iter()
203 .find(|tier| units <= tier.upper_bound_units)
204 .unwrap_or_else(|| match self.tiers.last() {
205 Some(tier) => tier,
206 None => unreachable!("settlement policy is validated before use"),
207 })
208 }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212#[serde(deny_unknown_fields)]
213pub struct SettlementChainConfig {
214 pub chain_id: String,
215 pub network_name: String,
216 pub rpc_url: String,
217 pub egress_contract: HttpEgressContract,
218 pub escrow_contract: String,
219 pub bond_vault_contract: String,
220 pub identity_registry_contract: String,
221 pub root_registry_contract: String,
222 pub operator_address: String,
223 pub settlement_token_symbol: String,
224 pub settlement_token_address: String,
225 #[serde(default)]
226 pub oracle: SettlementOracleConfig,
227 #[serde(default)]
228 pub evidence_substrate: SettlementEvidenceConfig,
229 pub policy: SettlementPolicyConfig,
230}
231
232impl SettlementChainConfig {
233 pub fn validate(&self) -> Result<(), SettlementError> {
234 for (value, label) in [
235 (self.chain_id.as_str(), "chain_id"),
236 (self.network_name.as_str(), "network_name"),
237 (self.rpc_url.as_str(), "rpc_url"),
238 (self.escrow_contract.as_str(), "escrow_contract"),
239 (self.bond_vault_contract.as_str(), "bond_vault_contract"),
240 (
241 self.identity_registry_contract.as_str(),
242 "identity_registry_contract",
243 ),
244 (
245 self.root_registry_contract.as_str(),
246 "root_registry_contract",
247 ),
248 (self.operator_address.as_str(), "operator_address"),
249 (
250 self.settlement_token_symbol.as_str(),
251 "settlement_token_symbol",
252 ),
253 (
254 self.settlement_token_address.as_str(),
255 "settlement_token_address",
256 ),
257 ] {
258 if value.trim().is_empty() {
259 return Err(SettlementError::invalid_input(format!(
260 "settlement config {label} is required"
261 )));
262 }
263 }
264 self.oracle.validate()?;
265 self.evidence_substrate.validate()?;
266 self.policy.validate()?;
267 self.validate_rpc_egress_contract()?;
268 Ok(())
269 }
270
271 pub fn validate_rpc_egress_contract(&self) -> Result<(), SettlementError> {
272 self.egress_contract
273 .validate_dispatchable_with_pinned_dns()
274 .map_err(|error| {
275 SettlementError::invalid_input(format!(
276 "invalid settlement RPC HttpEgressContract: {error}"
277 ))
278 })?;
279 self.egress_contract
285 .enforce_url(&self.rpc_url, 0)
286 .map_err(|error| {
287 SettlementError::invalid_input(format!(
288 "settlement RPC URL is not allowed by HttpEgressContract: {error}"
289 ))
290 })?;
291 Ok(())
292 }
293}
294
295pub fn settlement_devnet_rpc_egress_contract(
296 rpc_url: &str,
297) -> Result<HttpEgressContract, SettlementError> {
298 devnet_rpc_egress_contract_for_url("chio-settle-devnet-rpc", rpc_url)
299}
300
301fn devnet_rpc_egress_contract_for_url(
302 namespace: &str,
303 rpc_url: &str,
304) -> Result<HttpEgressContract, SettlementError> {
305 let url = Url::parse(rpc_url)
306 .map_err(|error| SettlementError::invalid_input(format!("invalid RPC URL: {error}")))?;
307 let host = url
308 .host_str()
309 .ok_or_else(|| SettlementError::invalid_input("RPC URL must include a host"))?;
310 if !rpc_host_is_loopback(host) {
311 return Err(SettlementError::invalid_input(
312 "devnet settlement RPC egress contract requires a loopback RPC URL",
313 ));
314 }
315
316 let mut allowed_schemes = BTreeSet::new();
317 allowed_schemes.insert(url.scheme().to_ascii_lowercase());
318 let mut allowed_authority_set = BTreeSet::new();
319 allowed_authority_set.insert(normalized_rpc_authority(&url, host));
320 let contract = HttpEgressContract {
321 tenant_egress_namespace: namespace.to_string(),
322 allowed_schemes,
323 allowed_authority_set,
324 deny_loopback: false,
325 deny_link_local: true,
326 deny_ipv6_ula: true,
327 max_redirect_chain: 0,
328 max_response_bytes: 64 * 1024 * 1024,
329 };
330 contract
331 .validate_dispatchable_with_pinned_dns()
332 .map_err(|error| {
333 SettlementError::invalid_input(format!(
334 "invalid settlement RPC HttpEgressContract: {error}"
335 ))
336 })?;
337 contract.enforce_url_with_dns(rpc_url, 0).map_err(|error| {
338 SettlementError::invalid_input(format!(
339 "settlement RPC URL is not allowed by HttpEgressContract: {error}"
340 ))
341 })?;
342 Ok(contract)
343}
344
345fn normalized_rpc_authority(url: &Url, host: &str) -> String {
346 let host = if host.parse::<Ipv6Addr>().is_ok() {
347 format!("[{host}]")
348 } else {
349 host.trim_end_matches('.').to_ascii_lowercase()
350 };
351 match url.port() {
352 Some(port) => format!("{host}:{port}"),
353 None => host,
354 }
355}
356
357fn rpc_host_is_loopback(host: &str) -> bool {
358 matches!(
359 host.trim_end_matches('.').to_ascii_lowercase().as_str(),
360 "localhost" | "localhost.localdomain"
361 ) || host
362 .parse::<IpAddr>()
363 .is_ok_and(|address| address.is_loopback())
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
367#[serde(deny_unknown_fields)]
368pub struct DevnetContracts {
369 pub identity_registry: String,
370 pub root_registry: String,
371 pub escrow: String,
372 pub bond_vault: String,
373 pub price_resolver: String,
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
377#[serde(deny_unknown_fields)]
378pub struct DevnetMocks {
379 pub eth_usd_feed: String,
380 pub sequencer_uptime_feed: String,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
384#[serde(deny_unknown_fields)]
385pub struct DevnetAccounts {
386 pub admin: String,
387 pub operator: String,
388 pub delegate: String,
389 pub beneficiary: String,
390 pub depositor: String,
391 pub principal: String,
392 pub outsider: String,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
396#[serde(deny_unknown_fields)]
397pub struct LocalDevnetDeployment {
398 pub manifest_id: String,
399 pub network_name: String,
400 pub chain_id: String,
401 pub rpc_url: String,
402 pub deployed_at: String,
403 pub operator_address: String,
404 pub operator_epoch: u64,
405 pub delegate_address: String,
406 pub settlement_token_symbol: String,
407 pub settlement_token_address: String,
408 pub contracts: DevnetContracts,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub mocks: Option<DevnetMocks>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub accounts: Option<DevnetAccounts>,
413}
414
415impl LocalDevnetDeployment {
416 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SettlementError> {
417 let body = fs::read_to_string(path.as_ref())
418 .map_err(|error| SettlementError::Serialization(error.to_string()))?;
419 serde_json::from_str(&body)
420 .map_err(|error| SettlementError::Serialization(error.to_string()))
421 }
422
423 pub fn into_chain_config(self) -> Result<SettlementChainConfig, SettlementError> {
424 let egress_contract = settlement_devnet_rpc_egress_contract(&self.rpc_url)?;
425 Ok(SettlementChainConfig {
426 chain_id: self.chain_id,
427 network_name: self.network_name,
428 rpc_url: self.rpc_url,
429 egress_contract,
430 escrow_contract: self.contracts.escrow,
431 bond_vault_contract: self.contracts.bond_vault,
432 identity_registry_contract: self.contracts.identity_registry,
433 root_registry_contract: self.contracts.root_registry,
434 operator_address: self.operator_address,
435 settlement_token_symbol: self.settlement_token_symbol,
436 settlement_token_address: self.settlement_token_address,
437 oracle: SettlementOracleConfig {
438 authority: SettlementOracleAuthority::ChioLinkReceiptEvidence,
439 price_resolver_contract: Some(self.contracts.price_resolver),
440 },
441 evidence_substrate: SettlementEvidenceConfig::default(),
442 policy: SettlementPolicyConfig::default(),
443 })
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 use chio_test_support::prelude::*;
452
453 fn sample_chain_config() -> SettlementChainConfig {
454 SettlementChainConfig {
455 chain_id: "eip155:8453".to_string(),
456 network_name: "base-mainnet".to_string(),
457 rpc_url: "http://127.0.0.1:8545".to_string(),
458 egress_contract: settlement_devnet_rpc_egress_contract("http://127.0.0.1:8545")
459 .test_unwrap(),
460 escrow_contract: "0x1000000000000000000000000000000000000001".to_string(),
461 bond_vault_contract: "0x2000000000000000000000000000000000000001".to_string(),
462 identity_registry_contract: "0x3000000000000000000000000000000000000001".to_string(),
463 root_registry_contract: "0x4000000000000000000000000000000000000001".to_string(),
464 operator_address: "0x5000000000000000000000000000000000000001".to_string(),
465 settlement_token_symbol: "USDC".to_string(),
466 settlement_token_address: "0x6000000000000000000000000000000000000001".to_string(),
467 oracle: SettlementOracleConfig::default(),
468 evidence_substrate: SettlementEvidenceConfig::default(),
469 policy: SettlementPolicyConfig::default(),
470 }
471 }
472
473 #[test]
474 fn oracle_config_defaults_to_chio_link_receipt_evidence() {
475 let config = sample_chain_config();
476 assert_eq!(
477 config.oracle.authority,
478 SettlementOracleAuthority::ChioLinkReceiptEvidence
479 );
480 assert!(config.oracle.price_resolver_contract.is_none());
481 }
482
483 #[test]
484 fn evidence_substrate_requires_durable_receipts() {
485 let mut config = sample_chain_config();
486 config.evidence_substrate.durable_receipts = false;
487
488 let error = config.validate().test_unwrap_err();
489 assert!(error.to_string().contains("durable local receipt storage"));
490 }
491
492 #[test]
493 fn evidence_substrate_requires_checkpoint_statements() {
494 let mut config = sample_chain_config();
495 config.evidence_substrate.checkpoint_statements = false;
496
497 let error = config.validate().test_unwrap_err();
498 assert!(error
499 .to_string()
500 .contains("kernel-signed checkpoint statements"));
501 }
502
503 #[test]
504 fn local_devnet_maps_price_resolver_as_reference_contract() {
505 let deployment = LocalDevnetDeployment {
506 manifest_id: "chio.web3-deployment.local-devnet.v1".to_string(),
507 network_name: "ganache-devnet".to_string(),
508 chain_id: "eip155:31337".to_string(),
509 rpc_url: "http://127.0.0.1:8545".to_string(),
510 deployed_at: "2026-04-02T00:00:00Z".to_string(),
511 operator_address: "0x5000000000000000000000000000000000000001".to_string(),
512 operator_epoch: 1,
513 delegate_address: "0x5000000000000000000000000000000000000002".to_string(),
514 settlement_token_symbol: "USDC".to_string(),
515 settlement_token_address: "0x6000000000000000000000000000000000000001".to_string(),
516 contracts: DevnetContracts {
517 identity_registry: "0x1000000000000000000000000000000000000001".to_string(),
518 root_registry: "0x1000000000000000000000000000000000000002".to_string(),
519 escrow: "0x1000000000000000000000000000000000000003".to_string(),
520 bond_vault: "0x1000000000000000000000000000000000000004".to_string(),
521 price_resolver: "0x1000000000000000000000000000000000000005".to_string(),
522 },
523 mocks: None,
524 accounts: None,
525 };
526
527 let config = deployment.into_chain_config().test_unwrap();
528 assert_eq!(
529 config.oracle.authority,
530 SettlementOracleAuthority::ChioLinkReceiptEvidence
531 );
532 assert_eq!(
533 config.oracle.price_resolver_contract.as_deref(),
534 Some("0x1000000000000000000000000000000000000005")
535 );
536 }
537
538 #[test]
539 fn chain_config_requires_matching_rpc_egress_contract() {
540 let mut config = sample_chain_config();
541 config.egress_contract =
542 settlement_devnet_rpc_egress_contract("http://127.0.0.1:9545").test_unwrap();
543
544 let error = config.validate().test_unwrap_err();
545
546 assert!(error.to_string().contains("HttpEgressContract"));
547 }
548
549 #[test]
550 fn chain_config_does_not_self_authorize_rpc_url_authority() {
551 for rpc_url in [
552 "http://127.0.0.1:8545",
553 "http://10.0.0.5:8545",
554 "http://192.168.1.20:8545",
555 "http://203.0.113.10:8545",
556 ] {
557 let mut config = sample_chain_config();
558 config.rpc_url = rpc_url.to_string();
559 config.egress_contract =
560 settlement_devnet_rpc_egress_contract("http://127.0.0.1:9545").test_unwrap();
561
562 let error = config.validate().test_unwrap_err();
563
564 assert!(
565 error.to_string().contains("HttpEgressContract"),
566 "unexpected self-authorization denial for {rpc_url}: {error}"
567 );
568 }
569 }
570
571 #[test]
572 fn devnet_rpc_egress_contract_only_authorizes_loopback() {
573 assert!(settlement_devnet_rpc_egress_contract("http://127.0.0.1:8545").is_ok());
574 assert!(settlement_devnet_rpc_egress_contract("http://localhost:8545").is_ok());
575 for rpc_url in [
576 "http://10.0.0.5:8545",
577 "http://192.168.1.20:8545",
578 "http://172.16.0.2:8545",
579 "http://203.0.113.10:8545",
580 ] {
581 let error = settlement_devnet_rpc_egress_contract(rpc_url).test_unwrap_err();
582 assert!(
583 error.to_string().contains("requires a loopback RPC URL"),
584 "unexpected devnet egress error for {rpc_url}: {error}"
585 );
586 }
587 }
588}