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}