chio-settle 0.1.2

Settlement runtime for Chio web3 escrow and bond execution
Documentation
use std::collections::HashSet;

use chio_core::hashing::sha256;
use chio_core::web3::settlement::Web3SettlementExecutionReceiptArtifact;
use serde::{Deserialize, Serialize};

use crate::SettlementError;

pub const CHIO_CCIP_SETTLEMENT_MESSAGE_SCHEMA: &str = "chio.ccip-settlement-message.v1";

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CcipMessageStatus {
    Prepared,
    Reconciled,
    DuplicateSuppressed,
    Delayed,
    Unsupported,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CcipLaneConfig {
    pub source_chain_id: String,
    pub destination_chain_id: String,
    pub router_address: String,
    pub max_payload_bytes: usize,
    pub max_execution_gas: u64,
    pub expected_latency_secs: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CcipSettlementPayload {
    pub dispatch_id: String,
    pub execution_receipt_id: String,
    pub settlement_reference: String,
    pub lifecycle_state: String,
    pub settled_amount_units: u64,
    pub settled_amount_currency: String,
    pub beneficiary_address: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CcipSettlementMessage {
    pub schema: String,
    pub message_id: String,
    pub lane: CcipLaneConfig,
    pub payload: CcipSettlementPayload,
    pub payload_sha256: String,
    pub prepared_at: u64,
    pub expires_at: u64,
    pub min_validity_secs: u64,
    pub status: CcipMessageStatus,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CcipDeliveryObservation {
    pub message_id: String,
    pub destination_chain_id: String,
    pub delivered_at: u64,
    pub payload_sha256: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CcipReconciliationOutcome {
    pub message_id: String,
    pub status: CcipMessageStatus,
    pub canonical_receipt_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub note: Option<String>,
}

pub fn prepare_ccip_settlement_message(
    lane: CcipLaneConfig,
    receipt: &Web3SettlementExecutionReceiptArtifact,
    prepared_at: u64,
    expires_at: u64,
) -> Result<CcipSettlementMessage, SettlementError> {
    if lane.source_chain_id.trim().is_empty()
        || lane.destination_chain_id.trim().is_empty()
        || lane.router_address.trim().is_empty()
    {
        return Err(SettlementError::InvalidInput(
            "CCIP lane requires source chain, destination chain, and router address".to_string(),
        ));
    }
    if lane.source_chain_id == lane.destination_chain_id {
        return Err(SettlementError::InvalidInput(
            "CCIP transport requires distinct source and destination chains".to_string(),
        ));
    }
    if lane.max_payload_bytes == 0 || lane.max_execution_gas == 0 || lane.expected_latency_secs == 0
    {
        return Err(SettlementError::InvalidInput(
            "CCIP lane limits must be non-zero".to_string(),
        ));
    }
    if expires_at <= prepared_at {
        return Err(SettlementError::InvalidInput(
            "CCIP message expiry must be after preparation".to_string(),
        ));
    }
    let min_validity_secs = lane.expected_latency_secs.saturating_mul(2);
    if expires_at.saturating_sub(prepared_at) < min_validity_secs {
        return Err(SettlementError::InvalidInput(format!(
            "CCIP message validity {} is below required minimum {}",
            expires_at.saturating_sub(prepared_at),
            min_validity_secs
        )));
    }

    let payload = CcipSettlementPayload {
        dispatch_id: receipt.dispatch.dispatch_id.clone(),
        execution_receipt_id: receipt.execution_receipt_id.clone(),
        settlement_reference: receipt.settlement_reference.clone(),
        lifecycle_state: serde_json::to_string(&receipt.lifecycle_state)
            .map_err(|error| SettlementError::Serialization(error.to_string()))?
            .trim_matches('"')
            .to_string(),
        settled_amount_units: receipt.settled_amount.units,
        settled_amount_currency: receipt.settled_amount.currency.clone(),
        beneficiary_address: receipt.dispatch.beneficiary_address.clone(),
    };
    let payload_bytes = serde_json::to_vec(&payload)
        .map_err(|error| SettlementError::Serialization(error.to_string()))?;
    if payload_bytes.len() > lane.max_payload_bytes {
        return Err(SettlementError::InvalidInput(format!(
            "CCIP payload size {} exceeds bounded maximum {}",
            payload_bytes.len(),
            lane.max_payload_bytes
        )));
    }
    let payload_sha256 = sha256(&payload_bytes).to_hex_prefixed();
    let message_id = format!(
        "chio-ccip-{}-{}",
        lane.destination_chain_id.replace(':', "-"),
        &payload_sha256[2..18]
    );

    Ok(CcipSettlementMessage {
        schema: CHIO_CCIP_SETTLEMENT_MESSAGE_SCHEMA.to_string(),
        message_id,
        lane,
        payload,
        payload_sha256,
        prepared_at,
        expires_at,
        min_validity_secs,
        status: CcipMessageStatus::Prepared,
    })
}

pub fn reconcile_ccip_delivery(
    message: &CcipSettlementMessage,
    observation: &CcipDeliveryObservation,
    seen_messages: &mut HashSet<String>,
) -> Result<CcipReconciliationOutcome, SettlementError> {
    if message.message_id != observation.message_id {
        return Err(SettlementError::Verification(format!(
            "CCIP delivery {} does not match prepared message {}",
            observation.message_id, message.message_id
        )));
    }
    if observation.destination_chain_id != message.lane.destination_chain_id {
        return Ok(CcipReconciliationOutcome {
            message_id: message.message_id.clone(),
            status: CcipMessageStatus::Unsupported,
            canonical_receipt_id: message.payload.execution_receipt_id.clone(),
            note: Some("delivery arrived on an unsupported destination chain".to_string()),
        });
    }
    if observation.payload_sha256 != message.payload_sha256 {
        return Ok(CcipReconciliationOutcome {
            message_id: message.message_id.clone(),
            status: CcipMessageStatus::Unsupported,
            canonical_receipt_id: message.payload.execution_receipt_id.clone(),
            note: Some(
                "delivery payload hash does not match the prepared CCIP message".to_string(),
            ),
        });
    }
    if !seen_messages.insert(message.message_id.clone()) {
        return Ok(CcipReconciliationOutcome {
            message_id: message.message_id.clone(),
            status: CcipMessageStatus::DuplicateSuppressed,
            canonical_receipt_id: message.payload.execution_receipt_id.clone(),
            note: Some("duplicate CCIP delivery suppressed fail closed".to_string()),
        });
    }
    if observation.delivered_at > message.expires_at {
        return Ok(CcipReconciliationOutcome {
            message_id: message.message_id.clone(),
            status: CcipMessageStatus::Delayed,
            canonical_receipt_id: message.payload.execution_receipt_id.clone(),
            note: Some("delivery arrived after the bounded validity window".to_string()),
        });
    }
    Ok(CcipReconciliationOutcome {
        message_id: message.message_id.clone(),
        status: CcipMessageStatus::Reconciled,
        canonical_receipt_id: message.payload.execution_receipt_id.clone(),
        note: Some(
            "cross-chain coordination reconciled back to the canonical Chio execution receipt"
                .to_string(),
        ),
    })
}

#[cfg(test)]
mod tests {
    use std::collections::HashSet;

    use chio_core::web3::settlement::Web3SettlementExecutionReceiptArtifact;

    use super::{
        prepare_ccip_settlement_message, reconcile_ccip_delivery, CcipDeliveryObservation,
        CcipLaneConfig, CcipMessageStatus,
    };

    use chio_test_support::prelude::*;

    fn sample_receipt() -> Web3SettlementExecutionReceiptArtifact {
        serde_json::from_str(include_str!(
            "../../../../docs/standards/CHIO_WEB3_SETTLEMENT_RECEIPT_EXAMPLE.json"
        ))
        .test_unwrap()
    }

    fn sample_lane() -> CcipLaneConfig {
        CcipLaneConfig {
            source_chain_id: "eip155:8453".to_string(),
            destination_chain_id: "eip155:42161".to_string(),
            router_address: "0x1000000000000000000000000000000000000010".to_string(),
            max_payload_bytes: 30_000,
            max_execution_gas: 3_000_000,
            expected_latency_secs: 900,
        }
    }

    #[test]
    fn prepares_bounded_ccip_message() {
        let message = prepare_ccip_settlement_message(
            sample_lane(),
            &sample_receipt(),
            1_744_000_000,
            1_744_001_900,
        )
        .test_unwrap();

        assert_eq!(message.status, CcipMessageStatus::Prepared);
        assert_eq!(message.min_validity_secs, 1_800);
    }

    #[test]
    fn rejects_under_validity_window() {
        let error = prepare_ccip_settlement_message(
            sample_lane(),
            &sample_receipt(),
            1_744_000_000,
            1_744_000_100,
        )
        .test_unwrap_err();
        assert!(error.to_string().contains("below required minimum"));
    }

    #[test]
    fn reconciles_duplicate_and_delayed_delivery() {
        let message = prepare_ccip_settlement_message(
            sample_lane(),
            &sample_receipt(),
            1_744_000_000,
            1_744_001_900,
        )
        .test_unwrap();
        let mut seen = HashSet::new();

        let first = reconcile_ccip_delivery(
            &message,
            &CcipDeliveryObservation {
                message_id: message.message_id.clone(),
                destination_chain_id: message.lane.destination_chain_id.clone(),
                delivered_at: 1_744_000_600,
                payload_sha256: message.payload_sha256.clone(),
            },
            &mut seen,
        )
        .test_unwrap();
        assert_eq!(first.status, CcipMessageStatus::Reconciled);

        let duplicate = reconcile_ccip_delivery(
            &message,
            &CcipDeliveryObservation {
                message_id: message.message_id.clone(),
                destination_chain_id: message.lane.destination_chain_id.clone(),
                delivered_at: 1_744_000_900,
                payload_sha256: message.payload_sha256.clone(),
            },
            &mut seen,
        )
        .test_unwrap();
        assert_eq!(duplicate.status, CcipMessageStatus::DuplicateSuppressed);

        let delayed_message = prepare_ccip_settlement_message(
            sample_lane(),
            &sample_receipt(),
            1_744_000_000,
            1_744_001_900,
        )
        .test_unwrap();
        let delayed = reconcile_ccip_delivery(
            &delayed_message,
            &CcipDeliveryObservation {
                message_id: delayed_message.message_id.clone(),
                destination_chain_id: delayed_message.lane.destination_chain_id.clone(),
                delivered_at: 1_744_002_000,
                payload_sha256: delayed_message.payload_sha256.clone(),
            },
            &mut HashSet::new(),
        )
        .test_unwrap();
        assert_eq!(delayed.status, CcipMessageStatus::Delayed);
    }
}