Skip to main content

chio_settle/
automation.rs

1use chio_core::hashing::sha256;
2use chio_core::web3::settlement::Web3SettlementDispatchArtifact;
3use serde::{Deserialize, Serialize};
4
5use crate::SettlementError;
6
7pub const CHIO_SETTLEMENT_AUTOMATION_JOB_SCHEMA: &str = "chio.settlement-automation-job.v1";
8
9#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11pub enum SettlementAutomationTriggerKind {
12    Cron,
13    Log,
14    CustomLogic,
15}
16
17#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum SettlementWatchdogKind {
20    EscrowTimeout,
21    FinalityObservation,
22    BondExpiry,
23}
24
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum SettlementAutomationOutcome {
28    Executed,
29    DuplicateSuppressed,
30    DelayedButSafe,
31    ManualOverrideRequired,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(deny_unknown_fields)]
36pub struct SettlementWatchdogJob {
37    pub schema: String,
38    pub job_id: String,
39    pub kind: SettlementWatchdogKind,
40    pub trigger_kind: SettlementAutomationTriggerKind,
41    pub chain_id: String,
42    pub replay_window_secs: u64,
43    pub cron_expression: String,
44    pub state_fingerprint: String,
45    pub operator_override_required: bool,
46    pub reference_id: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(deny_unknown_fields)]
51pub struct SettlementAutomationExecution {
52    pub job_id: String,
53    pub fired_at: u64,
54    pub executed_at: u64,
55    pub observed_state_fingerprint: String,
56    pub duplicate_suppressed: bool,
57    pub operator_override_used: bool,
58    pub outcome: SettlementAutomationOutcome,
59}
60
61pub fn build_settlement_watchdog_job(
62    dispatch: &Web3SettlementDispatchArtifact,
63    cron_expression: &str,
64    replay_window_secs: u64,
65) -> Result<SettlementWatchdogJob, SettlementError> {
66    if cron_expression.trim().is_empty() {
67        return Err(SettlementError::InvalidInput(
68            "settlement watchdog cron expression is required".to_string(),
69        ));
70    }
71    if replay_window_secs == 0 {
72        return Err(SettlementError::InvalidInput(
73            "settlement watchdog replay window must be non-zero".to_string(),
74        ));
75    }
76    let state_fingerprint = sha256(
77        format!(
78            "dispatch_id={}\nchain_id={}\nescrow_id={}\nescrow_contract={}\nsettlement_token_address={}\nbeneficiary_address={}\noperator_key_hash={}\nsettlement_path={:?}\namount_units={}\namount_currency={}",
79            dispatch.dispatch_id,
80            dispatch.chain_id,
81            dispatch.escrow_id,
82            dispatch.escrow_contract,
83            dispatch.settlement_token_address,
84            dispatch.beneficiary_address,
85            dispatch.operator_key_hash,
86            dispatch.settlement_path,
87            dispatch.settlement_amount.units,
88            dispatch.settlement_amount.currency
89        )
90        .as_bytes(),
91    )
92    .to_hex_prefixed();
93    Ok(SettlementWatchdogJob {
94        schema: CHIO_SETTLEMENT_AUTOMATION_JOB_SCHEMA.to_string(),
95        job_id: format!("chio-settle-watchdog-{}", dispatch.dispatch_id),
96        kind: SettlementWatchdogKind::FinalityObservation,
97        trigger_kind: SettlementAutomationTriggerKind::Cron,
98        chain_id: dispatch.chain_id.clone(),
99        replay_window_secs,
100        cron_expression: cron_expression.to_string(),
101        state_fingerprint,
102        operator_override_required: true,
103        reference_id: dispatch.dispatch_id.clone(),
104    })
105}
106
107pub fn build_bond_watchdog_job(
108    chain_id: &str,
109    vault_id: &str,
110    expires_at: u64,
111    cron_expression: &str,
112    replay_window_secs: u64,
113) -> Result<SettlementWatchdogJob, SettlementError> {
114    if chain_id.trim().is_empty() || vault_id.trim().is_empty() || cron_expression.trim().is_empty()
115    {
116        return Err(SettlementError::InvalidInput(
117            "bond watchdog requires chain, vault id, and cron expression".to_string(),
118        ));
119    }
120    if expires_at == 0 || replay_window_secs == 0 {
121        return Err(SettlementError::InvalidInput(
122            "bond watchdog expiry and replay window must be non-zero".to_string(),
123        ));
124    }
125    let state_fingerprint =
126        sha256(format!("{chain_id}:{vault_id}:{expires_at}").as_bytes()).to_hex_prefixed();
127    Ok(SettlementWatchdogJob {
128        schema: CHIO_SETTLEMENT_AUTOMATION_JOB_SCHEMA.to_string(),
129        job_id: format!("chio-bond-watchdog-{vault_id}"),
130        kind: SettlementWatchdogKind::BondExpiry,
131        trigger_kind: SettlementAutomationTriggerKind::Cron,
132        chain_id: chain_id.to_string(),
133        replay_window_secs,
134        cron_expression: cron_expression.to_string(),
135        state_fingerprint,
136        operator_override_required: true,
137        reference_id: vault_id.to_string(),
138    })
139}
140
141pub fn assess_watchdog_execution(
142    job: &SettlementWatchdogJob,
143    execution: &SettlementAutomationExecution,
144) -> Result<(), SettlementError> {
145    if job.job_id != execution.job_id {
146        return Err(SettlementError::Verification(format!(
147            "watchdog execution {} does not match job {}",
148            execution.job_id, job.job_id
149        )));
150    }
151    if execution.executed_at < execution.fired_at {
152        return Err(SettlementError::Verification(
153            "watchdog execution cannot complete before it fires".to_string(),
154        ));
155    }
156    if execution.observed_state_fingerprint != job.state_fingerprint {
157        return Err(SettlementError::Verification(
158            "watchdog execution state fingerprint drifted".to_string(),
159        ));
160    }
161    let delay = execution.executed_at.saturating_sub(execution.fired_at);
162    if delay > job.replay_window_secs
163        && execution.outcome != SettlementAutomationOutcome::DelayedButSafe
164    {
165        return Err(SettlementError::Verification(format!(
166            "watchdog delay {} exceeds replay window {} without delayed-safe outcome",
167            delay, job.replay_window_secs
168        )));
169    }
170    if execution.duplicate_suppressed
171        && execution.outcome != SettlementAutomationOutcome::DuplicateSuppressed
172    {
173        return Err(SettlementError::Verification(
174            "watchdog duplicate suppression must be explicit".to_string(),
175        ));
176    }
177    if job.operator_override_required && !execution.operator_override_used {
178        return Err(SettlementError::Verification(
179            "watchdog execution must retain operator override control".to_string(),
180        ));
181    }
182    Ok(())
183}
184
185#[cfg(test)]
186mod tests {
187    use chio_core::web3::settlement::Web3SettlementDispatchArtifact;
188
189    use super::{
190        assess_watchdog_execution, build_bond_watchdog_job, build_settlement_watchdog_job,
191        SettlementAutomationExecution, SettlementAutomationOutcome,
192    };
193
194    use chio_test_support::prelude::*;
195
196    fn sample_dispatch() -> Web3SettlementDispatchArtifact {
197        serde_json::from_str(include_str!(
198            "../../../../docs/standards/CHIO_WEB3_SETTLEMENT_DISPATCH_EXAMPLE.json"
199        ))
200        .test_unwrap()
201    }
202
203    #[test]
204    fn builds_settlement_watchdog_job() {
205        let dispatch = sample_dispatch();
206        let job = build_settlement_watchdog_job(&dispatch, "*/5 * * * *", 600).test_unwrap();
207
208        assert_eq!(job.reference_id, dispatch.dispatch_id);
209        assert!(job.operator_override_required);
210
211        let mut changed_key = dispatch.clone();
212        changed_key.operator_key_hash =
213            "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string();
214        let changed_key_job =
215            build_settlement_watchdog_job(&changed_key, "*/5 * * * *", 600).test_unwrap();
216        assert_ne!(job.state_fingerprint, changed_key_job.state_fingerprint);
217
218        let mut changed_token = dispatch.clone();
219        changed_token.settlement_token_address =
220            "0x465F1Ba389D9D350501dB8FBbB5b52477DcaddA8".to_string();
221        let changed_token_job =
222            build_settlement_watchdog_job(&changed_token, "*/5 * * * *", 600).test_unwrap();
223        assert_ne!(job.state_fingerprint, changed_token_job.state_fingerprint);
224    }
225
226    #[test]
227    fn builds_bond_watchdog_job() {
228        let job = build_bond_watchdog_job(
229            "eip155:8453",
230            "vault-001",
231            1_744_000_600,
232            "*/10 * * * *",
233            900,
234        )
235        .test_unwrap();
236
237        assert_eq!(job.reference_id, "vault-001");
238    }
239
240    #[test]
241    fn validates_watchdog_execution() {
242        let dispatch = sample_dispatch();
243        let job = build_settlement_watchdog_job(&dispatch, "*/5 * * * *", 600).test_unwrap();
244        let execution = SettlementAutomationExecution {
245            job_id: job.job_id.clone(),
246            fired_at: 1_744_000_000,
247            executed_at: 1_744_000_060,
248            observed_state_fingerprint: job.state_fingerprint.clone(),
249            duplicate_suppressed: false,
250            operator_override_used: true,
251            outcome: SettlementAutomationOutcome::Executed,
252        };
253
254        assess_watchdog_execution(&job, &execution).test_unwrap();
255    }
256}