Skip to main content

chio_settle/
ops.rs

1use chio_core::web3::settlement::{
2    CHIO_SETTLE_CONTROL_STATE_SCHEMA, CHIO_SETTLE_CONTROL_TRACE_SCHEMA,
3};
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    settlement_completion_flow_receipt_id, SettlementError, SettlementFinalityStatus,
8    SettlementRecoveryAction,
9};
10
11pub const CHIO_SETTLE_RUNTIME_REPORT_SCHEMA: &str = "chio.settle-runtime-report.v1";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum SettlementAlertSeverity {
16    Info,
17    Warning,
18    Critical,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SettlementIndexerStatus {
24    Healthy,
25    Lagging,
26    Drifted,
27    Replaying,
28    Failed,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum SettlementRuntimeStatus {
34    Healthy,
35    AwaitingFinality,
36    Recovering,
37    Paused,
38    Failed,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum SettlementEmergencyMode {
44    Normal,
45    DispatchPaused,
46    RefundOnly,
47    RecoveryOnly,
48    Halted,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum SettlementOperationKind {
54    DispatchEscrow,
55    ReleaseEscrow,
56    RefundEscrow,
57    LockBond,
58    ReleaseBond,
59    ImpairBond,
60    ExpireBond,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase", deny_unknown_fields)]
65pub struct SettlementControlChangeRecord {
66    pub schema: String,
67    pub actor: String,
68    pub source: String,
69    pub changed_at: u64,
70    pub before: SettlementEmergencyControls,
71    pub after: SettlementEmergencyControls,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase", deny_unknown_fields)]
76pub struct SettlementEmergencyControls {
77    pub mode: SettlementEmergencyMode,
78    pub changed_at: u64,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub reason: Option<String>,
81}
82
83impl SettlementEmergencyControls {
84    #[must_use]
85    pub fn normal(changed_at: u64) -> Self {
86        Self {
87            mode: SettlementEmergencyMode::Normal,
88            changed_at,
89            reason: None,
90        }
91    }
92
93    #[must_use]
94    pub fn allows(&self, operation: SettlementOperationKind) -> bool {
95        match self.mode {
96            SettlementEmergencyMode::Normal => true,
97            SettlementEmergencyMode::DispatchPaused => !matches!(
98                operation,
99                SettlementOperationKind::DispatchEscrow | SettlementOperationKind::LockBond
100            ),
101            SettlementEmergencyMode::RefundOnly => matches!(
102                operation,
103                SettlementOperationKind::RefundEscrow
104                    | SettlementOperationKind::ImpairBond
105                    | SettlementOperationKind::ExpireBond
106            ),
107            SettlementEmergencyMode::RecoveryOnly => !matches!(
108                operation,
109                SettlementOperationKind::DispatchEscrow | SettlementOperationKind::LockBond
110            ),
111            SettlementEmergencyMode::Halted => false,
112        }
113    }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase", deny_unknown_fields)]
118pub struct SettlementControlState {
119    pub schema: String,
120    pub updated_at: u64,
121    pub controls: SettlementEmergencyControls,
122    pub history: Vec<SettlementControlChangeRecord>,
123}
124
125impl SettlementControlState {
126    #[must_use]
127    pub fn new(updated_at: u64, controls: SettlementEmergencyControls) -> Self {
128        Self {
129            schema: CHIO_SETTLE_CONTROL_STATE_SCHEMA.to_string(),
130            updated_at,
131            controls,
132            history: Vec::new(),
133        }
134    }
135
136    pub fn apply_change(
137        &mut self,
138        mode: SettlementEmergencyMode,
139        changed_at: u64,
140        actor: impl Into<String>,
141        reason: Option<String>,
142        source: impl Into<String>,
143    ) {
144        let before = self.controls.clone();
145        self.controls = SettlementEmergencyControls {
146            mode,
147            changed_at,
148            reason,
149        };
150        self.updated_at = changed_at;
151        self.history.push(SettlementControlChangeRecord {
152            schema: CHIO_SETTLE_CONTROL_TRACE_SCHEMA.to_string(),
153            actor: actor.into(),
154            source: source.into(),
155            changed_at,
156            before,
157            after: self.controls.clone(),
158        });
159    }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase", deny_unknown_fields)]
164pub struct SettlementIndexerCursor {
165    pub service_id: String,
166    pub chain_id: String,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub last_indexed_block_number: Option<u64>,
169    pub canonical_block_number: u64,
170    pub lag_blocks: u64,
171    pub status: SettlementIndexerStatus,
172    pub checked_at: u64,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub note: Option<String>,
175}
176
177impl SettlementIndexerCursor {
178    #[must_use]
179    pub fn from_blocks(input: SettlementIndexerCursorInput) -> Self {
180        let lag_blocks = input
181            .canonical_block_number
182            .saturating_sub(input.last_indexed_block_number.unwrap_or(0));
183        let status = if input.failed {
184            SettlementIndexerStatus::Failed
185        } else if input.replaying {
186            SettlementIndexerStatus::Replaying
187        } else if lag_blocks == 0 {
188            SettlementIndexerStatus::Healthy
189        } else if lag_blocks <= 12 {
190            SettlementIndexerStatus::Lagging
191        } else {
192            SettlementIndexerStatus::Drifted
193        };
194        Self {
195            service_id: input.service_id,
196            chain_id: input.chain_id,
197            last_indexed_block_number: input.last_indexed_block_number,
198            canonical_block_number: input.canonical_block_number,
199            lag_blocks,
200            status,
201            checked_at: input.checked_at,
202            note: input.note,
203        }
204    }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct SettlementIndexerCursorInput {
209    pub service_id: String,
210    pub chain_id: String,
211    pub last_indexed_block_number: Option<u64>,
212    pub canonical_block_number: u64,
213    pub replaying: bool,
214    pub failed: bool,
215    pub checked_at: u64,
216    pub note: Option<String>,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase", deny_unknown_fields)]
221pub struct SettlementRecoveryRecord {
222    pub execution_receipt_id: String,
223    pub chain_id: String,
224    pub tx_hash: String,
225    pub finality_status: SettlementFinalityStatus,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub recovery_action: Option<SettlementRecoveryAction>,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub reorg_depth: Option<u32>,
230    pub observed_at: u64,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub note: Option<String>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "camelCase", deny_unknown_fields)]
237pub struct SettlementLaneRuntimeStatus {
238    pub chain_id: String,
239    pub network_name: String,
240    pub status: SettlementRuntimeStatus,
241    pub indexer_status: SettlementIndexerStatus,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub finality_status: Option<SettlementFinalityStatus>,
244    pub queued_recoveries: usize,
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub last_observed_at: Option<u64>,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub note: Option<String>,
249}
250
251impl SettlementLaneRuntimeStatus {
252    #[must_use]
253    pub fn new(input: SettlementLaneRuntimeStatusInput) -> Self {
254        let status =
255            classify_settlement_lane(input.indexer_status, input.finality_status, input.controls);
256        Self {
257            chain_id: input.chain_id,
258            network_name: input.network_name,
259            status,
260            indexer_status: input.indexer_status,
261            finality_status: input.finality_status,
262            queued_recoveries: input.queued_recoveries,
263            last_observed_at: input.last_observed_at,
264            note: input.note,
265        }
266    }
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct SettlementLaneRuntimeStatusInput {
271    pub chain_id: String,
272    pub network_name: String,
273    pub indexer_status: SettlementIndexerStatus,
274    pub finality_status: Option<SettlementFinalityStatus>,
275    pub controls: SettlementEmergencyControls,
276    pub queued_recoveries: usize,
277    pub last_observed_at: Option<u64>,
278    pub note: Option<String>,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "camelCase", deny_unknown_fields)]
283pub struct SettlementIncidentAlert {
284    pub code: String,
285    pub severity: SettlementAlertSeverity,
286    pub chain_id: String,
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub execution_receipt_id: Option<String>,
289    pub observed_at: u64,
290    pub message: String,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase", deny_unknown_fields)]
295pub struct SettlementRuntimeReport {
296    pub schema: String,
297    pub generated_at: u64,
298    pub controls: SettlementEmergencyControls,
299    pub lanes: Vec<SettlementLaneRuntimeStatus>,
300    pub indexers: Vec<SettlementIndexerCursor>,
301    pub recoveries: Vec<SettlementRecoveryRecord>,
302    pub incidents: Vec<SettlementIncidentAlert>,
303}
304
305impl SettlementRuntimeReport {
306    #[must_use]
307    pub fn new(generated_at: u64, controls: SettlementEmergencyControls) -> Self {
308        Self {
309            schema: CHIO_SETTLE_RUNTIME_REPORT_SCHEMA.to_string(),
310            generated_at,
311            controls,
312            lanes: Vec::new(),
313            indexers: Vec::new(),
314            recoveries: Vec::new(),
315            incidents: Vec::new(),
316        }
317    }
318}
319
320#[must_use]
321pub fn classify_settlement_lane(
322    indexer_status: SettlementIndexerStatus,
323    finality_status: Option<SettlementFinalityStatus>,
324    controls: SettlementEmergencyControls,
325) -> SettlementRuntimeStatus {
326    if indexer_status == SettlementIndexerStatus::Failed {
327        return SettlementRuntimeStatus::Failed;
328    }
329    match controls.mode {
330        SettlementEmergencyMode::Halted
331        | SettlementEmergencyMode::DispatchPaused
332        | SettlementEmergencyMode::RefundOnly => {
333            return SettlementRuntimeStatus::Paused;
334        }
335        SettlementEmergencyMode::RecoveryOnly => return SettlementRuntimeStatus::Recovering,
336        SettlementEmergencyMode::Normal => {}
337    }
338    if indexer_status == SettlementIndexerStatus::Replaying
339        || finality_status == Some(SettlementFinalityStatus::Reorged)
340    {
341        SettlementRuntimeStatus::Recovering
342    } else if matches!(
343        finality_status,
344        Some(SettlementFinalityStatus::AwaitingConfirmations)
345            | Some(SettlementFinalityStatus::AwaitingDisputeWindow)
346    ) || matches!(
347        indexer_status,
348        SettlementIndexerStatus::Lagging | SettlementIndexerStatus::Drifted
349    ) {
350        SettlementRuntimeStatus::AwaitingFinality
351    } else {
352        SettlementRuntimeStatus::Healthy
353    }
354}
355
356pub fn ensure_settlement_operation_allowed(
357    controls: SettlementEmergencyControls,
358    operation: SettlementOperationKind,
359) -> Result<(), SettlementError> {
360    if controls.allows(operation) {
361        return Ok(());
362    }
363    Err(SettlementError::InvalidInput(format!(
364        "settlement operation {operation:?} denied while emergency mode {:?} is active",
365        controls.mode
366    )))
367}
368
369pub fn ensure_settlement_completion_flow_binding(
370    row_id: &str,
371    receipt_id: &str,
372) -> Result<(), SettlementError> {
373    let resolved_receipt_id = settlement_completion_flow_receipt_id(row_id)?;
374    if resolved_receipt_id != receipt_id {
375        return Err(SettlementError::InvalidBinding(format!(
376            "completion-flow row `{row_id}` resolved receipt `{resolved_receipt_id}` but settlement receipt is `{receipt_id}`"
377        )));
378    }
379    Ok(())
380}
381
382#[cfg(test)]
383mod tests {
384    use super::{
385        classify_settlement_lane, ensure_settlement_completion_flow_binding,
386        ensure_settlement_operation_allowed, SettlementControlState, SettlementEmergencyControls,
387        SettlementEmergencyMode, SettlementIndexerCursor, SettlementIndexerCursorInput,
388        SettlementIndexerStatus, SettlementOperationKind, SettlementRuntimeReport,
389        SettlementRuntimeStatus, CHIO_SETTLE_RUNTIME_REPORT_SCHEMA,
390    };
391    use crate::{
392        settlement_completion_flow_row_id, SettlementFinalityStatus,
393        SETTLEMENT_COMPLETION_FLOW_ROW_ID_PREFIX,
394    };
395
396    use chio_test_support::prelude::*;
397
398    #[test]
399    fn indexer_cursor_classifies_lagging() {
400        let cursor = SettlementIndexerCursor::from_blocks(SettlementIndexerCursorInput {
401            service_id: "escrow-event-indexer".to_string(),
402            chain_id: "eip155:8453".to_string(),
403            last_indexed_block_number: Some(23_456_789),
404            canonical_block_number: 23_456_797,
405            replaying: false,
406            failed: false,
407            checked_at: 1_712_337_200,
408            note: Some("eight blocks behind canonical head".to_string()),
409        });
410        assert_eq!(cursor.lag_blocks, 8);
411        assert_eq!(cursor.status, SettlementIndexerStatus::Lagging);
412    }
413
414    #[test]
415    fn refund_only_mode_denies_new_dispatch() {
416        let controls = SettlementEmergencyControls {
417            mode: SettlementEmergencyMode::RefundOnly,
418            changed_at: 1_712_337_200,
419            reason: Some("beneficiary release halted pending replay review".to_string()),
420        };
421        let error =
422            ensure_settlement_operation_allowed(controls, SettlementOperationKind::DispatchEscrow)
423                .test_expect_err("dispatch should be denied");
424        assert!(error
425            .to_string()
426            .contains("settlement operation DispatchEscrow denied"));
427    }
428
429    #[test]
430    fn reorged_lane_is_marked_recovering() {
431        let controls = SettlementEmergencyControls::normal(1_712_337_200);
432        let status = classify_settlement_lane(
433            SettlementIndexerStatus::Healthy,
434            Some(SettlementFinalityStatus::Reorged),
435            controls,
436        );
437        assert_eq!(status, SettlementRuntimeStatus::Recovering);
438    }
439
440    #[test]
441    fn runtime_report_example_round_trips() {
442        let report: SettlementRuntimeReport = serde_json::from_str(include_str!(
443            "../../../../docs/standards/CHIO_SETTLE_RUNTIME_REPORT_EXAMPLE.json"
444        ))
445        .test_expect("example report");
446        assert_eq!(report.schema, CHIO_SETTLE_RUNTIME_REPORT_SCHEMA);
447        assert_eq!(report.controls.mode, SettlementEmergencyMode::RefundOnly);
448        assert_eq!(report.recoveries.len(), 1);
449        assert!(report
450            .incidents
451            .iter()
452            .any(|incident| incident.code == "settlement_reorg"));
453    }
454
455    #[test]
456    fn control_state_tracks_mode_history() {
457        let mut state = SettlementControlState::new(
458            1_764_825_600,
459            SettlementEmergencyControls::normal(1_764_825_600),
460        );
461        state.apply_change(
462            SettlementEmergencyMode::DispatchPaused,
463            1_764_825_620,
464            "settlement-operator",
465            Some("pause new dispatch".to_string()),
466            "unit_test",
467        );
468        state.apply_change(
469            SettlementEmergencyMode::RefundOnly,
470            1_764_825_640,
471            "settlement-operator",
472            Some("refund-first recovery".to_string()),
473            "unit_test",
474        );
475        assert_eq!(state.controls.mode, SettlementEmergencyMode::RefundOnly);
476        assert_eq!(state.history.len(), 2);
477        assert_eq!(
478            state.history[1].after.reason.as_deref(),
479            Some("refund-first recovery")
480        );
481    }
482
483    #[test]
484    fn completion_flow_binding_round_trips() {
485        let row_id = settlement_completion_flow_row_id("rcpt-1").test_expect("row id");
486        assert_eq!(
487            row_id,
488            format!("{SETTLEMENT_COMPLETION_FLOW_ROW_ID_PREFIX}{}", "rcpt-1")
489        );
490        ensure_settlement_completion_flow_binding(&row_id, "rcpt-1")
491            .test_expect("matching binding");
492    }
493
494    #[test]
495    fn completion_flow_binding_rejects_mismatch() {
496        let error =
497            ensure_settlement_completion_flow_binding("economic-completion-flow:rcpt-1", "rcpt-2")
498                .test_expect_err("binding mismatch should fail");
499        assert!(error.to_string().contains("resolved receipt"));
500    }
501}