Skip to main content

chio_settle/
config.rs

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        // Validate scheme/authority and reject IP-literal loopback/link-local
280        // hosts here. Hostname address-class is enforced at connect time by the
281        // contract's pinned ContractDnsResolver (see client_builder_with_contract),
282        // so this validation does not resolve DNS itself: a config-time lookup
283        // would be redundant, fail offline, and be open to TOCTOU drift.
284        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}