Skip to main content

chio_settle/
ccip.rs

1use std::collections::HashSet;
2
3use chio_core::hashing::sha256;
4use chio_core::web3::settlement::Web3SettlementExecutionReceiptArtifact;
5use serde::{Deserialize, Serialize};
6
7use crate::SettlementError;
8
9pub const CHIO_CCIP_SETTLEMENT_MESSAGE_SCHEMA: &str = "chio.ccip-settlement-message.v1";
10
11#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "snake_case")]
13pub enum CcipMessageStatus {
14    Prepared,
15    Reconciled,
16    DuplicateSuppressed,
17    Delayed,
18    Unsupported,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(deny_unknown_fields)]
23pub struct CcipLaneConfig {
24    pub source_chain_id: String,
25    pub destination_chain_id: String,
26    pub router_address: String,
27    pub max_payload_bytes: usize,
28    pub max_execution_gas: u64,
29    pub expected_latency_secs: u64,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(deny_unknown_fields)]
34pub struct CcipSettlementPayload {
35    pub dispatch_id: String,
36    pub execution_receipt_id: String,
37    pub settlement_reference: String,
38    pub lifecycle_state: String,
39    pub settled_amount_units: u64,
40    pub settled_amount_currency: String,
41    pub beneficiary_address: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(deny_unknown_fields)]
46pub struct CcipSettlementMessage {
47    pub schema: String,
48    pub message_id: String,
49    pub lane: CcipLaneConfig,
50    pub payload: CcipSettlementPayload,
51    pub payload_sha256: String,
52    pub prepared_at: u64,
53    pub expires_at: u64,
54    pub min_validity_secs: u64,
55    pub status: CcipMessageStatus,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(deny_unknown_fields)]
60pub struct CcipDeliveryObservation {
61    pub message_id: String,
62    pub destination_chain_id: String,
63    pub delivered_at: u64,
64    pub payload_sha256: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68#[serde(deny_unknown_fields)]
69pub struct CcipReconciliationOutcome {
70    pub message_id: String,
71    pub status: CcipMessageStatus,
72    pub canonical_receipt_id: String,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub note: Option<String>,
75}
76
77pub fn prepare_ccip_settlement_message(
78    lane: CcipLaneConfig,
79    receipt: &Web3SettlementExecutionReceiptArtifact,
80    prepared_at: u64,
81    expires_at: u64,
82) -> Result<CcipSettlementMessage, SettlementError> {
83    if lane.source_chain_id.trim().is_empty()
84        || lane.destination_chain_id.trim().is_empty()
85        || lane.router_address.trim().is_empty()
86    {
87        return Err(SettlementError::InvalidInput(
88            "CCIP lane requires source chain, destination chain, and router address".to_string(),
89        ));
90    }
91    if lane.source_chain_id == lane.destination_chain_id {
92        return Err(SettlementError::InvalidInput(
93            "CCIP transport requires distinct source and destination chains".to_string(),
94        ));
95    }
96    if lane.max_payload_bytes == 0 || lane.max_execution_gas == 0 || lane.expected_latency_secs == 0
97    {
98        return Err(SettlementError::InvalidInput(
99            "CCIP lane limits must be non-zero".to_string(),
100        ));
101    }
102    if expires_at <= prepared_at {
103        return Err(SettlementError::InvalidInput(
104            "CCIP message expiry must be after preparation".to_string(),
105        ));
106    }
107    let min_validity_secs = lane.expected_latency_secs.saturating_mul(2);
108    if expires_at.saturating_sub(prepared_at) < min_validity_secs {
109        return Err(SettlementError::InvalidInput(format!(
110            "CCIP message validity {} is below required minimum {}",
111            expires_at.saturating_sub(prepared_at),
112            min_validity_secs
113        )));
114    }
115
116    let payload = CcipSettlementPayload {
117        dispatch_id: receipt.dispatch.dispatch_id.clone(),
118        execution_receipt_id: receipt.execution_receipt_id.clone(),
119        settlement_reference: receipt.settlement_reference.clone(),
120        lifecycle_state: serde_json::to_string(&receipt.lifecycle_state)
121            .map_err(|error| SettlementError::Serialization(error.to_string()))?
122            .trim_matches('"')
123            .to_string(),
124        settled_amount_units: receipt.settled_amount.units,
125        settled_amount_currency: receipt.settled_amount.currency.clone(),
126        beneficiary_address: receipt.dispatch.beneficiary_address.clone(),
127    };
128    let payload_bytes = serde_json::to_vec(&payload)
129        .map_err(|error| SettlementError::Serialization(error.to_string()))?;
130    if payload_bytes.len() > lane.max_payload_bytes {
131        return Err(SettlementError::InvalidInput(format!(
132            "CCIP payload size {} exceeds bounded maximum {}",
133            payload_bytes.len(),
134            lane.max_payload_bytes
135        )));
136    }
137    let payload_sha256 = sha256(&payload_bytes).to_hex_prefixed();
138    let message_id = format!(
139        "chio-ccip-{}-{}",
140        lane.destination_chain_id.replace(':', "-"),
141        &payload_sha256[2..18]
142    );
143
144    Ok(CcipSettlementMessage {
145        schema: CHIO_CCIP_SETTLEMENT_MESSAGE_SCHEMA.to_string(),
146        message_id,
147        lane,
148        payload,
149        payload_sha256,
150        prepared_at,
151        expires_at,
152        min_validity_secs,
153        status: CcipMessageStatus::Prepared,
154    })
155}
156
157pub fn reconcile_ccip_delivery(
158    message: &CcipSettlementMessage,
159    observation: &CcipDeliveryObservation,
160    seen_messages: &mut HashSet<String>,
161) -> Result<CcipReconciliationOutcome, SettlementError> {
162    if message.message_id != observation.message_id {
163        return Err(SettlementError::Verification(format!(
164            "CCIP delivery {} does not match prepared message {}",
165            observation.message_id, message.message_id
166        )));
167    }
168    if observation.destination_chain_id != message.lane.destination_chain_id {
169        return Ok(CcipReconciliationOutcome {
170            message_id: message.message_id.clone(),
171            status: CcipMessageStatus::Unsupported,
172            canonical_receipt_id: message.payload.execution_receipt_id.clone(),
173            note: Some("delivery arrived on an unsupported destination chain".to_string()),
174        });
175    }
176    if observation.payload_sha256 != message.payload_sha256 {
177        return Ok(CcipReconciliationOutcome {
178            message_id: message.message_id.clone(),
179            status: CcipMessageStatus::Unsupported,
180            canonical_receipt_id: message.payload.execution_receipt_id.clone(),
181            note: Some(
182                "delivery payload hash does not match the prepared CCIP message".to_string(),
183            ),
184        });
185    }
186    if !seen_messages.insert(message.message_id.clone()) {
187        return Ok(CcipReconciliationOutcome {
188            message_id: message.message_id.clone(),
189            status: CcipMessageStatus::DuplicateSuppressed,
190            canonical_receipt_id: message.payload.execution_receipt_id.clone(),
191            note: Some("duplicate CCIP delivery suppressed fail closed".to_string()),
192        });
193    }
194    if observation.delivered_at > message.expires_at {
195        return Ok(CcipReconciliationOutcome {
196            message_id: message.message_id.clone(),
197            status: CcipMessageStatus::Delayed,
198            canonical_receipt_id: message.payload.execution_receipt_id.clone(),
199            note: Some("delivery arrived after the bounded validity window".to_string()),
200        });
201    }
202    Ok(CcipReconciliationOutcome {
203        message_id: message.message_id.clone(),
204        status: CcipMessageStatus::Reconciled,
205        canonical_receipt_id: message.payload.execution_receipt_id.clone(),
206        note: Some(
207            "cross-chain coordination reconciled back to the canonical Chio execution receipt"
208                .to_string(),
209        ),
210    })
211}
212
213#[cfg(test)]
214mod tests {
215    use std::collections::HashSet;
216
217    use chio_core::web3::settlement::Web3SettlementExecutionReceiptArtifact;
218
219    use super::{
220        prepare_ccip_settlement_message, reconcile_ccip_delivery, CcipDeliveryObservation,
221        CcipLaneConfig, CcipMessageStatus,
222    };
223
224    use chio_test_support::prelude::*;
225
226    fn sample_receipt() -> Web3SettlementExecutionReceiptArtifact {
227        serde_json::from_str(include_str!(
228            "../../../../docs/standards/CHIO_WEB3_SETTLEMENT_RECEIPT_EXAMPLE.json"
229        ))
230        .test_unwrap()
231    }
232
233    fn sample_lane() -> CcipLaneConfig {
234        CcipLaneConfig {
235            source_chain_id: "eip155:8453".to_string(),
236            destination_chain_id: "eip155:42161".to_string(),
237            router_address: "0x1000000000000000000000000000000000000010".to_string(),
238            max_payload_bytes: 30_000,
239            max_execution_gas: 3_000_000,
240            expected_latency_secs: 900,
241        }
242    }
243
244    #[test]
245    fn prepares_bounded_ccip_message() {
246        let message = prepare_ccip_settlement_message(
247            sample_lane(),
248            &sample_receipt(),
249            1_744_000_000,
250            1_744_001_900,
251        )
252        .test_unwrap();
253
254        assert_eq!(message.status, CcipMessageStatus::Prepared);
255        assert_eq!(message.min_validity_secs, 1_800);
256    }
257
258    #[test]
259    fn rejects_under_validity_window() {
260        let error = prepare_ccip_settlement_message(
261            sample_lane(),
262            &sample_receipt(),
263            1_744_000_000,
264            1_744_000_100,
265        )
266        .test_unwrap_err();
267        assert!(error.to_string().contains("below required minimum"));
268    }
269
270    #[test]
271    fn reconciles_duplicate_and_delayed_delivery() {
272        let message = prepare_ccip_settlement_message(
273            sample_lane(),
274            &sample_receipt(),
275            1_744_000_000,
276            1_744_001_900,
277        )
278        .test_unwrap();
279        let mut seen = HashSet::new();
280
281        let first = reconcile_ccip_delivery(
282            &message,
283            &CcipDeliveryObservation {
284                message_id: message.message_id.clone(),
285                destination_chain_id: message.lane.destination_chain_id.clone(),
286                delivered_at: 1_744_000_600,
287                payload_sha256: message.payload_sha256.clone(),
288            },
289            &mut seen,
290        )
291        .test_unwrap();
292        assert_eq!(first.status, CcipMessageStatus::Reconciled);
293
294        let duplicate = reconcile_ccip_delivery(
295            &message,
296            &CcipDeliveryObservation {
297                message_id: message.message_id.clone(),
298                destination_chain_id: message.lane.destination_chain_id.clone(),
299                delivered_at: 1_744_000_900,
300                payload_sha256: message.payload_sha256.clone(),
301            },
302            &mut seen,
303        )
304        .test_unwrap();
305        assert_eq!(duplicate.status, CcipMessageStatus::DuplicateSuppressed);
306
307        let delayed_message = prepare_ccip_settlement_message(
308            sample_lane(),
309            &sample_receipt(),
310            1_744_000_000,
311            1_744_001_900,
312        )
313        .test_unwrap();
314        let delayed = reconcile_ccip_delivery(
315            &delayed_message,
316            &CcipDeliveryObservation {
317                message_id: delayed_message.message_id.clone(),
318                destination_chain_id: delayed_message.lane.destination_chain_id.clone(),
319                delivered_at: 1_744_002_000,
320                payload_sha256: delayed_message.payload_sha256.clone(),
321            },
322            &mut HashSet::new(),
323        )
324        .test_unwrap();
325        assert_eq!(delayed.status, CcipMessageStatus::Delayed);
326    }
327}