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}