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}