Skip to main content

chio_web3/
chain.rs

1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::Web3ContractError;
6use crate::validation::ensure_non_empty;
7
8pub const CHIO_WEB3_CHAIN_CONFIGURATION_SCHEMA: &str = "chio.web3-chain-configuration.v1";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum Web3ChainRole {
13    Primary,
14    Secondary,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct Web3ChainDeployment {
20    pub chain_id: String,
21    pub network_name: String,
22    pub role: Web3ChainRole,
23    pub deployment_status: String,
24    pub settlement_token_symbol: String,
25    pub live_external_addresses: Option<Web3ChainExternalAddresses>,
26    pub planned_contract_addresses: Option<Web3ChainContractAddresses>,
27    pub deployed_contract_addresses: Option<Web3ChainContractAddresses>,
28    pub planned_operator_address: Option<String>,
29    pub deployed_operator_address: Option<String>,
30    pub deployment_plan: Option<Web3ChainDeploymentPlan>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct Web3ChainExternalAddresses {
36    pub settlement_token_address: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct Web3ChainContractAddresses {
42    pub root_registry_address: String,
43    pub escrow_address: String,
44    pub bond_vault_address: String,
45    pub identity_registry_address: String,
46    pub price_resolver_address: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct Web3ChainDeploymentPlan {
52    pub source_manifest_path: String,
53    pub source_manifest_sha256: String,
54    pub create2_factory_address: Option<String>,
55    pub create2_factory_source: String,
56    pub salt_namespace: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct Web3ChainGasProfile {
62    pub chain_id: String,
63    pub publish_root_gas: u64,
64    pub dual_sign_settlement_gas: u64,
65    pub merkle_settlement_gas: u64,
66    pub bond_release_gas: u64,
67    pub price_read_gas: u64,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct Web3ChainConfiguration {
73    pub schema: String,
74    pub package_id: String,
75    pub primary_chain_id: String,
76    pub deployments: Vec<Web3ChainDeployment>,
77    pub gas_profiles: Vec<Web3ChainGasProfile>,
78}
79
80pub fn validate_web3_chain_configuration(
81    configuration: &Web3ChainConfiguration,
82) -> Result<(), Web3ContractError> {
83    if configuration.schema != CHIO_WEB3_CHAIN_CONFIGURATION_SCHEMA {
84        return Err(Web3ContractError::UnsupportedSchema(
85            configuration.schema.clone(),
86        ));
87    }
88    ensure_non_empty(
89        &configuration.package_id,
90        "web3_chain_configuration.package_id",
91    )?;
92    ensure_non_empty(
93        &configuration.primary_chain_id,
94        "web3_chain_configuration.primary_chain_id",
95    )?;
96    if configuration.deployments.is_empty() {
97        return Err(Web3ContractError::MissingField(
98            "web3_chain_configuration.deployments",
99        ));
100    }
101    if configuration.gas_profiles.is_empty() {
102        return Err(Web3ContractError::MissingField(
103            "web3_chain_configuration.gas_profiles",
104        ));
105    }
106
107    let mut deployment_ids = HashSet::new();
108    let mut primary_count = 0usize;
109    for deployment in &configuration.deployments {
110        ensure_non_empty(
111            &deployment.chain_id,
112            "web3_chain_configuration.deployments.chain_id",
113        )?;
114        ensure_non_empty(
115            &deployment.network_name,
116            "web3_chain_configuration.deployments.network_name",
117        )?;
118        ensure_non_empty(
119            &deployment.deployment_status,
120            "web3_chain_configuration.deployments.deployment_status",
121        )?;
122        ensure_non_empty(
123            &deployment.settlement_token_symbol,
124            "web3_chain_configuration.deployments.settlement_token_symbol",
125        )?;
126        let template_blocked =
127            deployment.deployment_status == "template_blocked_pending_external_assurance";
128        ensure_chain_deployment_addresses(deployment, template_blocked)?;
129        if !deployment_ids.insert(deployment.chain_id.as_str()) {
130            return Err(Web3ContractError::DuplicateValue(
131                deployment.chain_id.clone(),
132            ));
133        }
134        if deployment.role == Web3ChainRole::Primary {
135            primary_count += 1;
136            if deployment.chain_id != configuration.primary_chain_id {
137                return Err(Web3ContractError::InvalidBinding(format!(
138                    "primary deployment {} does not match primary_chain_id {}",
139                    deployment.chain_id, configuration.primary_chain_id
140                )));
141            }
142        }
143    }
144    if primary_count != 1 {
145        return Err(Web3ContractError::InvalidBinding(
146            "web3 chain configuration must declare exactly one primary deployment".to_string(),
147        ));
148    }
149
150    let mut gas_profiles = HashSet::new();
151    for gas in &configuration.gas_profiles {
152        ensure_non_empty(
153            &gas.chain_id,
154            "web3_chain_configuration.gas_profiles.chain_id",
155        )?;
156        if !deployment_ids.contains(gas.chain_id.as_str()) {
157            return Err(Web3ContractError::UnknownReference(gas.chain_id.clone()));
158        }
159        if !gas_profiles.insert(gas.chain_id.as_str()) {
160            return Err(Web3ContractError::DuplicateValue(gas.chain_id.clone()));
161        }
162        for metric in [
163            gas.publish_root_gas,
164            gas.dual_sign_settlement_gas,
165            gas.merkle_settlement_gas,
166            gas.bond_release_gas,
167            gas.price_read_gas,
168        ] {
169            if metric == 0 {
170                return Err(Web3ContractError::InvalidBinding(format!(
171                    "gas profile {} must not contain zero-valued gas assumptions",
172                    gas.chain_id
173                )));
174            }
175        }
176    }
177
178    Ok(())
179}
180
181fn ensure_chain_deployment_addresses(
182    deployment: &Web3ChainDeployment,
183    template_blocked: bool,
184) -> Result<(), Web3ContractError> {
185    let Some(external_addresses) = deployment.live_external_addresses.as_ref() else {
186        return Err(Web3ContractError::MissingField(
187            "web3_chain_configuration.deployments.live_external_addresses",
188        ));
189    };
190    ensure_evm_address(
191        &external_addresses.settlement_token_address,
192        "web3_chain_configuration.deployments.live_external_addresses.settlement_token_address",
193    )?;
194
195    if template_blocked {
196        if deployment.deployed_contract_addresses.is_some() {
197            return Err(Web3ContractError::InvalidBinding(
198                "template-blocked deployments must keep deployed_contract_addresses null"
199                    .to_string(),
200            ));
201        }
202        if deployment.deployed_operator_address.is_some() {
203            return Err(Web3ContractError::InvalidBinding(
204                "template-blocked deployments must keep deployed_operator_address null".to_string(),
205            ));
206        }
207        let Some(planned_addresses) = deployment.planned_contract_addresses.as_ref() else {
208            return Err(Web3ContractError::MissingField(
209                "web3_chain_configuration.deployments.planned_contract_addresses",
210            ));
211        };
212        ensure_planned_contract_addresses(planned_addresses)?;
213        let Some(operator_address) = deployment.planned_operator_address.as_deref() else {
214            return Err(Web3ContractError::MissingField(
215                "web3_chain_configuration.deployments.planned_operator_address",
216            ));
217        };
218        ensure_evm_address(
219            operator_address,
220            "web3_chain_configuration.deployments.planned_operator_address",
221        )?;
222        let Some(plan) = deployment.deployment_plan.as_ref() else {
223            return Err(Web3ContractError::MissingField(
224                "web3_chain_configuration.deployments.deployment_plan",
225            ));
226        };
227        ensure_deployment_plan(plan)?;
228        return Ok(());
229    }
230
231    if deployment.planned_contract_addresses.is_some()
232        || deployment.planned_operator_address.is_some()
233        || deployment.deployment_plan.is_some()
234    {
235        return Err(Web3ContractError::InvalidBinding(
236            "deployed chain configurations must omit planned contract, operator, and deployment-plan fields"
237                .to_string(),
238        ));
239    }
240    let Some(deployed_addresses) = deployment.deployed_contract_addresses.as_ref() else {
241        return Err(Web3ContractError::MissingField(
242            "web3_chain_configuration.deployments.deployed_contract_addresses",
243        ));
244    };
245    ensure_deployed_contract_addresses(deployed_addresses)?;
246    let Some(operator_address) = deployment.deployed_operator_address.as_deref() else {
247        return Err(Web3ContractError::MissingField(
248            "web3_chain_configuration.deployments.deployed_operator_address",
249        ));
250    };
251    ensure_evm_address(
252        operator_address,
253        "web3_chain_configuration.deployments.deployed_operator_address",
254    )
255}
256
257fn ensure_planned_contract_addresses(
258    addresses: &Web3ChainContractAddresses,
259) -> Result<(), Web3ContractError> {
260    ensure_evm_address(
261        &addresses.root_registry_address,
262        "web3_chain_configuration.deployments.planned_contract_addresses.root_registry_address",
263    )?;
264    ensure_evm_address(
265        &addresses.escrow_address,
266        "web3_chain_configuration.deployments.planned_contract_addresses.escrow_address",
267    )?;
268    ensure_evm_address(
269        &addresses.bond_vault_address,
270        "web3_chain_configuration.deployments.planned_contract_addresses.bond_vault_address",
271    )?;
272    ensure_evm_address(
273        &addresses.identity_registry_address,
274        "web3_chain_configuration.deployments.planned_contract_addresses.identity_registry_address",
275    )?;
276    ensure_evm_address(
277        &addresses.price_resolver_address,
278        "web3_chain_configuration.deployments.planned_contract_addresses.price_resolver_address",
279    )
280}
281
282fn ensure_deployed_contract_addresses(
283    addresses: &Web3ChainContractAddresses,
284) -> Result<(), Web3ContractError> {
285    ensure_evm_address(
286        &addresses.root_registry_address,
287        "web3_chain_configuration.deployments.deployed_contract_addresses.root_registry_address",
288    )?;
289    ensure_evm_address(
290        &addresses.escrow_address,
291        "web3_chain_configuration.deployments.deployed_contract_addresses.escrow_address",
292    )?;
293    ensure_evm_address(
294        &addresses.bond_vault_address,
295        "web3_chain_configuration.deployments.deployed_contract_addresses.bond_vault_address",
296    )?;
297    ensure_evm_address(
298        &addresses.identity_registry_address,
299        "web3_chain_configuration.deployments.deployed_contract_addresses.identity_registry_address",
300    )?;
301    ensure_evm_address(
302        &addresses.price_resolver_address,
303        "web3_chain_configuration.deployments.deployed_contract_addresses.price_resolver_address",
304    )
305}
306
307fn ensure_deployment_plan(plan: &Web3ChainDeploymentPlan) -> Result<(), Web3ContractError> {
308    ensure_non_empty(
309        &plan.source_manifest_path,
310        "web3_chain_configuration.deployments.deployment_plan.source_manifest_path",
311    )?;
312    ensure_sha256_hex(
313        &plan.source_manifest_sha256,
314        "web3_chain_configuration.deployments.deployment_plan.source_manifest_sha256",
315    )?;
316    if let Some(factory_address) = plan.create2_factory_address.as_deref() {
317        ensure_evm_address(
318            factory_address,
319            "web3_chain_configuration.deployments.deployment_plan.create2_factory_address",
320        )?;
321    }
322    ensure_non_empty(
323        &plan.create2_factory_source,
324        "web3_chain_configuration.deployments.deployment_plan.create2_factory_source",
325    )?;
326    ensure_non_empty(
327        &plan.salt_namespace,
328        "web3_chain_configuration.deployments.deployment_plan.salt_namespace",
329    )
330}
331
332fn ensure_sha256_hex(value: &str, field: &'static str) -> Result<(), Web3ContractError> {
333    ensure_non_empty(value, field)?;
334    if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
335        return Err(Web3ContractError::InvalidBinding(format!(
336            "{field} must be exactly 32 bytes of hex"
337        )));
338    }
339    Ok(())
340}
341
342fn ensure_evm_address(address: &str, field: &'static str) -> Result<(), Web3ContractError> {
343    ensure_non_empty(address, field)?;
344    let Some(hex) = address.strip_prefix("0x") else {
345        return Err(Web3ContractError::InvalidBinding(format!(
346            "{field} must be a 0x-prefixed EVM address"
347        )));
348    };
349    if hex.len() != 40 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
350        return Err(Web3ContractError::InvalidBinding(format!(
351            "{field} must be exactly 20 bytes of hex"
352        )));
353    }
354    let normalized = hex.to_ascii_lowercase();
355    if normalized.bytes().all(|byte| byte == b'0') {
356        return Err(Web3ContractError::InvalidBinding(format!(
357            "{field} must not be the zero address"
358        )));
359    }
360    if normalized
361        .as_bytes()
362        .windows(2)
363        .all(|window| window[0] == window[1])
364    {
365        return Err(Web3ContractError::InvalidBinding(format!(
366            "{field} must not use a repeated-byte sentinel address"
367        )));
368    }
369    let bytes = normalized.as_bytes();
370    if bytes[1..39].iter().all(|byte| *byte == b'0') {
371        return Err(Web3ContractError::InvalidBinding(format!(
372            "{field} must not use a low-numbered sentinel address"
373        )));
374    }
375    Ok(())
376}