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}