Skip to main content

chio_link/
control.rs

1use chio_core::web3::settlement::{CHIO_LINK_CONTROL_STATE_SCHEMA, CHIO_LINK_CONTROL_TRACE_SCHEMA};
2use serde::{Deserialize, Serialize};
3use serde_json::{json, Value};
4
5use crate::config::{OperatorConfig, PairRuntimeOverride};
6use crate::PriceOracleError;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ChioLinkControlChangeKind {
11    GlobalPause,
12    ChainEnabled,
13    PairOverride,
14}
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase", deny_unknown_fields)]
18pub struct ChioLinkControlChangeRecord {
19    pub schema: String,
20    pub kind: ChioLinkControlChangeKind,
21    pub actor: String,
22    pub source: String,
23    pub changed_at: u64,
24    pub note: String,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub chain_id: Option<u64>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub pair: Option<String>,
29    pub before: Value,
30    pub after: Value,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase", deny_unknown_fields)]
35pub struct ChioLinkControlState {
36    pub schema: String,
37    pub updated_at: u64,
38    pub operator: OperatorConfig,
39    pub history: Vec<ChioLinkControlChangeRecord>,
40}
41
42impl ChioLinkControlState {
43    #[must_use]
44    pub fn new(updated_at: u64, operator: OperatorConfig) -> Self {
45        Self {
46            schema: CHIO_LINK_CONTROL_STATE_SCHEMA.to_string(),
47            updated_at,
48            operator,
49            history: Vec::new(),
50        }
51    }
52
53    pub fn record_global_pause(
54        &mut self,
55        paused: bool,
56        reason: Option<String>,
57        actor: impl Into<String>,
58        source: impl Into<String>,
59        changed_at: u64,
60        note: impl Into<String>,
61    ) {
62        let before = json!({
63            "globalPause": self.operator.global_pause,
64            "pauseReason": self.operator.pause_reason
65        });
66        self.operator.global_pause = paused;
67        self.operator.pause_reason = reason;
68        let after = json!({
69            "globalPause": self.operator.global_pause,
70            "pauseReason": self.operator.pause_reason
71        });
72        self.updated_at = changed_at;
73        self.history.push(ChioLinkControlChangeRecord {
74            schema: CHIO_LINK_CONTROL_TRACE_SCHEMA.to_string(),
75            kind: ChioLinkControlChangeKind::GlobalPause,
76            actor: actor.into(),
77            source: source.into(),
78            changed_at,
79            note: note.into(),
80            chain_id: None,
81            pair: None,
82            before,
83            after,
84        });
85    }
86
87    pub fn record_chain_enabled(
88        &mut self,
89        chain_id: u64,
90        enabled: bool,
91        actor: impl Into<String>,
92        source: impl Into<String>,
93        changed_at: u64,
94        note: impl Into<String>,
95    ) -> Result<(), PriceOracleError> {
96        let index = self
97            .operator
98            .chains
99            .iter()
100            .position(|chain| chain.chain_id == chain_id)
101            .ok_or_else(|| {
102                PriceOracleError::InvalidConfiguration(format!(
103                    "cannot persist unknown operator chain_id {}",
104                    chain_id
105                ))
106            })?;
107        let before = serde_json::to_value(&self.operator.chains[index]).map_err(|error| {
108            PriceOracleError::InvalidConfiguration(format!(
109                "failed to serialize prior chain state for {}: {error}",
110                chain_id
111            ))
112        })?;
113        self.operator.chains[index].enabled = enabled;
114        let after = serde_json::to_value(&self.operator.chains[index]).map_err(|error| {
115            PriceOracleError::InvalidConfiguration(format!(
116                "failed to serialize updated chain state for {}: {error}",
117                chain_id
118            ))
119        })?;
120        self.updated_at = changed_at;
121        self.history.push(ChioLinkControlChangeRecord {
122            schema: CHIO_LINK_CONTROL_TRACE_SCHEMA.to_string(),
123            kind: ChioLinkControlChangeKind::ChainEnabled,
124            actor: actor.into(),
125            source: source.into(),
126            changed_at,
127            note: note.into(),
128            chain_id: Some(chain_id),
129            pair: None,
130            before,
131            after,
132        });
133        Ok(())
134    }
135
136    pub fn record_pair_override(
137        &mut self,
138        pair_override: PairRuntimeOverride,
139        actor: impl Into<String>,
140        source: impl Into<String>,
141        changed_at: u64,
142        note: impl Into<String>,
143    ) -> Result<(), PriceOracleError> {
144        let pair = pair_override.pair();
145        let existing_index = self
146            .operator
147            .pair_overrides
148            .iter()
149            .position(|current| current.pair() == pair);
150        let before =
151            serde_json::to_value(existing_index.map(|index| &self.operator.pair_overrides[index]))
152                .map_err(|error| {
153                    PriceOracleError::InvalidConfiguration(format!(
154                        "failed to serialize prior pair override for {pair}: {error}"
155                    ))
156                })?;
157        if let Some(index) = existing_index {
158            self.operator.pair_overrides[index] = pair_override;
159        } else {
160            self.operator.pair_overrides.push(pair_override);
161        }
162        let after = serde_json::to_value(
163            self.operator
164                .pair_overrides
165                .iter()
166                .find(|current| current.pair() == pair),
167        )
168        .map_err(|error| {
169            PriceOracleError::InvalidConfiguration(format!(
170                "failed to serialize updated pair override for {pair}: {error}"
171            ))
172        })?;
173        self.updated_at = changed_at;
174        self.history.push(ChioLinkControlChangeRecord {
175            schema: CHIO_LINK_CONTROL_TRACE_SCHEMA.to_string(),
176            kind: ChioLinkControlChangeKind::PairOverride,
177            actor: actor.into(),
178            source: source.into(),
179            changed_at,
180            note: note.into(),
181            chain_id: None,
182            pair: Some(pair),
183            before,
184            after,
185        });
186        Ok(())
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::ChioLinkControlState;
193    use crate::config::{PairRuntimeOverride, PriceOracleConfig};
194    use crate::test_support::TestUnwrap;
195
196    #[test]
197    fn control_state_tracks_pause_chain_and_pair_changes() {
198        let mut config = PriceOracleConfig::base_mainnet_default("https://example.invalid");
199        for chain in &mut config.operator.chains {
200            chain.sequencer_uptime_feed = None;
201        }
202
203        let btc_pair = config
204            .pairs
205            .iter()
206            .find(|pair| pair.base == "BTC" && pair.quote == "USD")
207            .test_unwrap("btc/usd pair")
208            .clone();
209        let mut state = ChioLinkControlState::new(1_764_825_600, config.operator);
210        state.record_global_pause(
211            true,
212            Some("manual stop".to_string()),
213            "test-operator",
214            "unit_test",
215            1_764_825_610,
216            "pause all conversions",
217        );
218        state
219            .record_chain_enabled(
220                42_161,
221                false,
222                "test-operator",
223                "unit_test",
224                1_764_825_620,
225                "leave standby chain disabled",
226            )
227            .test_unwrap("chain update");
228        state
229            .record_pair_override(
230                PairRuntimeOverride {
231                    enabled: false,
232                    ..PairRuntimeOverride::from_pair(&btc_pair)
233                },
234                "test-operator",
235                "unit_test",
236                1_764_825_630,
237                "disable pair",
238            )
239            .test_unwrap("pair override");
240        assert_eq!(state.history.len(), 3);
241        assert!(state.operator.global_pause);
242        assert!(
243            state
244                .operator
245                .pair_overrides
246                .iter()
247                .any(|override_config| override_config.pair() == "BTC/USD"
248                    && !override_config.enabled)
249        );
250    }
251}