clementine_core/states/round.rs
1use statig::prelude::*;
2use std::collections::{HashMap, HashSet};
3use std::sync::LazyLock;
4use tokio::sync::RwLock;
5
6use crate::builder::transaction::{input::UtxoVout, ContractContext};
7use crate::deposit::OperatorData;
8use bitcoin::OutPoint;
9use clementine_errors::BridgeError;
10use clementine_errors::TxError;
11use clementine_primitives::RoundIndex;
12use clementine_primitives::TransactionType;
13use serde_with::serde_as;
14
15use super::{
16 block_cache::BlockCache,
17 context::{Duty, DutyResult, StateContext},
18 matcher::{self, BlockMatcher},
19 Owner, StateMachineError,
20};
21
22/// Lock to ensure that CheckIfKickoff duties are not dispatched during resync checks on deposit_finalize,
23/// where new deposit signatures are being inserted (because checking a kickoff is done by using deposit_signatures table)
24pub static CHECK_KICKOFF_LOCK: LazyLock<RwLock<()>> = LazyLock::new(|| RwLock::new(()));
25
26/// Events that change the state of the round state machine.
27#[derive(
28 Debug, Clone, Eq, PartialEq, Hash, Ord, PartialOrd, serde::Serialize, serde::Deserialize,
29)]
30pub enum RoundEvent {
31 /// Event that is dispatched when a kickoff utxo in a round tx is spent.
32 KickoffUtxoUsed {
33 kickoff_idx: usize,
34 kickoff_outpoint: OutPoint,
35 },
36 /// Event that is dispatched when the next ready to reimburse tx is mined.
37 ReadyToReimburseSent { round_idx: RoundIndex },
38 /// Event that is dispatched when the next round tx is mined.
39 RoundSent { round_idx: RoundIndex },
40 /// This event is sent if operators collateral was spent in any way other than default behaviour (default is round -> ready to reimburse -> round -> ready to reimburse -> ...)
41 /// It means operator stopped participating in the protocol and can no longer participate in clementine bridge protocol.
42 OperatorExit,
43 /// Special event that is used to indicate that the state machine has been saved to the database and the dirty flag should be reset to false
44 SavedToDb,
45}
46
47/// State machine for the round state.
48/// It has following states:
49/// - `initial_collateral`: The initial collateral state, when the operator didn't create the first round tx yet.
50/// - `round_tx`: The round tx state, when the operator's collateral utxo is currently in a round tx.
51/// - `ready_to_reimburse`: The ready to reimburse state, when the operator's collateral utxo is currently in a ready to reimburse tx.
52/// - `operator_exit`: The operator exit state, when the operator exited the protocol (collateral spent in a non-bridge tx).
53///
54/// It has following events:
55/// - `KickoffUtxoUsed`: The kickoff utxo is used in a round tx. The state machine stores this utxo as used, and additionally calls the owner to check if this kickoff utxo was used in a kickoff tx (If so, that will result in creation of a kickoff state machine).
56/// - `ReadyToReimburseSent`: The ready to reimburse tx is sent. The state machine transitions to the ready to reimburse state. Additionally, if there are unused kickoff utxos, this information is passed to the owner which can then create a "Unspent Kickoff Connector" tx.
57/// - `RoundSent`: The round tx is sent. The state machine transitions to the round tx state.
58/// - `OperatorExit`: The operator exited the protocol. The state machine transitions to the operator exit state. In this state, all tracking of the operator is stopped as operator is no longer participating in the protocol.
59/// - `SavedToDb`: The state machine has been saved to the database and the dirty flag should be reset to false.
60///
61#[serde_as]
62#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub struct RoundStateMachine<T: Owner> {
64 /// Maps matchers to the resulting round events.
65 #[serde_as(as = "Vec<(_, _)>")]
66 pub(crate) matchers: HashMap<matcher::Matcher, RoundEvent>,
67 /// Data of the operator that is being tracked.
68 pub(crate) operator_data: OperatorData,
69 /// Indicates if the state machine has unsaved changes that need to be persisted on db.
70 /// dirty flag is set if any matcher matches the current block.
71 /// the flag is set to true in on_transition and on_dispatch
72 /// the flag is set to false after the state machine is saved to db and the event SavedToDb is dispatched
73 pub(crate) dirty: bool,
74 phantom: std::marker::PhantomData<T>,
75}
76
77impl<T: Owner> BlockMatcher for RoundStateMachine<T> {
78 type StateEvent = RoundEvent;
79
80 fn match_block(&self, block: &BlockCache) -> Vec<Self::StateEvent> {
81 self.matchers
82 .iter()
83 .filter_map(|(matcher, round_event)| {
84 matcher.matches(block).map(|ord| (ord, round_event))
85 })
86 .min()
87 .map(|(_, round_event)| round_event)
88 .into_iter()
89 .cloned()
90 .collect()
91 }
92}
93
94impl<T: Owner> RoundStateMachine<T> {
95 pub fn new(operator_data: OperatorData) -> Self {
96 Self {
97 matchers: HashMap::new(),
98 operator_data,
99 dirty: true,
100 phantom: std::marker::PhantomData,
101 }
102 }
103}
104use eyre::Context;
105
106#[state_machine(
107 initial = "State::initial_collateral()",
108 on_dispatch = "Self::on_dispatch",
109 on_transition = "Self::on_transition",
110 state(derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize))
111)]
112impl<T: Owner> RoundStateMachine<T> {
113 #[action]
114 pub(crate) fn on_transition(&mut self, state_a: &State, state_b: &State) {
115 tracing::trace!(?self.operator_data, "Transitioning from {:?} to {:?}", state_a, state_b);
116 self.dirty = true;
117 }
118
119 pub fn round_meta(&self, method: &'static str) -> StateMachineError {
120 eyre::eyre!(
121 "Error in round state machine for operator {} in {}",
122 self.operator_data.xonly_pk,
123 method
124 )
125 .into()
126 }
127
128 async fn unhandled_event(&mut self, context: &mut StateContext<T>, event: &RoundEvent) {
129 context
130 .capture_error(async |_context| {
131 let event_str = format!("{event:?}");
132 Err(StateMachineError::UnhandledEvent(event_str))
133 .wrap_err(self.round_meta("round unhandled event"))
134 })
135 .await;
136 }
137
138 #[action]
139 pub(crate) fn on_dispatch(
140 &mut self,
141 _state: StateOrSuperstate<'_, '_, Self>,
142 evt: &RoundEvent,
143 ) {
144 if matches!(evt, RoundEvent::SavedToDb) {
145 self.dirty = false;
146 } else {
147 tracing::trace!(?self.operator_data, "Dispatching event {:?}", evt);
148 self.dirty = true;
149
150 // Remove the matcher corresponding to the event.
151 if let Some((matcher, _)) = self.matchers.iter().find(|(_, ev)| ev == &evt) {
152 let matcher = matcher.clone();
153 self.matchers.remove(&matcher);
154 }
155 }
156 }
157
158 #[state(entry_action = "on_initial_collateral_entry")]
159 pub(crate) async fn initial_collateral(
160 &mut self,
161 event: &RoundEvent,
162 context: &mut StateContext<T>,
163 ) -> Response<State> {
164 match event {
165 // If the initial collateral is spent, we can transition to the first round tx.
166 RoundEvent::RoundSent { round_idx } => {
167 Transition(State::round_tx(*round_idx, HashSet::new(), false))
168 }
169 RoundEvent::SavedToDb => Handled,
170 RoundEvent::OperatorExit => Transition(State::operator_exit()),
171 _ => {
172 self.unhandled_event(context, event).await;
173 Handled
174 }
175 }
176 }
177
178 /// Entry action for the initial collateral state.
179 /// This method adds the matcher for the first round tx and the matcher if the operator exits
180 /// the protocol by not spending the collateral in the first round tx.
181 #[action]
182 #[allow(unused_variables)]
183 pub(crate) async fn on_initial_collateral_entry(&mut self, context: &mut StateContext<T>) {
184 context
185 .capture_error(async |context| {
186 {
187 self.matchers = HashMap::new();
188
189 // To determine if operator exited the protocol, we check if collateral was not spent in the first round tx.
190 let contract_context = ContractContext::new_context_for_round(
191 self.operator_data.xonly_pk,
192 RoundIndex::Round(0),
193 context.config.protocol_paramset,
194 );
195
196 let mut guard = context.shared_dbtx.lock().await;
197 let round_txhandlers = context
198 .owner
199 .create_txhandlers(&mut guard, TransactionType::Round, contract_context)
200 .await?;
201 drop(guard);
202 let round_txid = round_txhandlers
203 .get(&TransactionType::Round)
204 .ok_or(TxError::TxHandlerNotFound(TransactionType::Round))?
205 .get_txid();
206 // if round tx is sent, we can send the round sent event
207 self.matchers.insert(
208 matcher::Matcher::SentTx(*round_txid),
209 RoundEvent::RoundSent {
210 round_idx: RoundIndex::Round(0),
211 },
212 );
213 // If the tx the collateral is spent on is not the round tx, we dispatch the operator exit event.
214 self.matchers.insert(
215 matcher::Matcher::SpentUtxoButNotTxid(
216 self.operator_data.collateral_funding_outpoint,
217 vec![*round_txid],
218 ),
219 RoundEvent::OperatorExit,
220 );
221 Ok::<(), BridgeError>(())
222 }
223 .wrap_err(self.round_meta("on_initial_collateral_entry"))
224 })
225 .await;
226 }
227
228 /// State that represents a round tx.
229 /// This state is entered when a round tx is mined.
230 /// It is exited when the operator sends the ready to reimburse tx or exits the protocol.
231 #[state(entry_action = "on_round_tx_entry", exit_action = "on_round_tx_exit")]
232 #[allow(unused_variables)]
233 pub(crate) async fn round_tx(
234 &mut self,
235 event: &RoundEvent,
236 round_idx: &mut RoundIndex,
237 used_kickoffs: &mut HashSet<usize>,
238 challenged_before: &mut bool,
239 context: &mut StateContext<T>,
240 ) -> Response<State> {
241 match event {
242 // If a kickoff utxo is spent, we add it to the used kickoffs set.
243 // The set will be used to determine if the operator has used all kickoffs in the round.
244 // If the operator did not use all kickoffs, "Unspent Kickoff Connector" tx can potentially be sent, slashing the operator.
245 // Additionally, the owner will check if the kickoff utxo is used in a kickoff transaction.
246 // If so, the owner (if verifier) will do additional checks to determine if the kickoff is malicious or not.
247 RoundEvent::KickoffUtxoUsed {
248 kickoff_idx,
249 kickoff_outpoint,
250 } => {
251 used_kickoffs.insert(*kickoff_idx);
252 let txid = context
253 .cache
254 .get_txid_of_utxo(kickoff_outpoint)
255 .expect("UTXO should be in block");
256
257 context
258 .capture_error(async |context| {
259 {
260 // acquire lock to ensure that CheckIfKickoff duties are not dispatched during resync checks on deposit_finalize
261 let _guard = CHECK_KICKOFF_LOCK.write().await;
262 let duty_result = context
263 .dispatch_duty(Duty::CheckIfKickoff {
264 txid,
265 block_height: context.cache.block_height,
266 witness: context
267 .cache
268 .get_witness_of_utxo(kickoff_outpoint)
269 .expect("UTXO should be in block"),
270 challenged_before: *challenged_before,
271 })
272 .await?;
273 if let DutyResult::CheckIfKickoff { challenged } = duty_result {
274 *challenged_before |= challenged;
275 }
276 Ok::<(), BridgeError>(())
277 }
278 .wrap_err(self.round_meta("round_tx kickoff_utxo_used"))
279 })
280 .await;
281 Handled
282 }
283 // If the ready to reimburse tx is mined, we transition to the ready to reimburse state.
284 RoundEvent::ReadyToReimburseSent { round_idx } => {
285 Transition(State::ready_to_reimburse(*round_idx))
286 }
287 RoundEvent::SavedToDb => Handled,
288 RoundEvent::OperatorExit => Transition(State::operator_exit()),
289 _ => {
290 self.unhandled_event(context, event).await;
291 Handled
292 }
293 }
294 }
295
296 /// State that represents the operator exiting the protocol.
297 #[state(entry_action = "on_operator_exit_entry")]
298 pub(crate) async fn operator_exit(
299 &mut self,
300 event: &RoundEvent,
301 context: &mut StateContext<T>,
302 ) -> Response<State> {
303 match event {
304 RoundEvent::SavedToDb => Handled,
305 _ => {
306 self.unhandled_event(context, event).await;
307 Handled
308 }
309 }
310 }
311
312 /// Entry action for the operator exit state.
313 /// This method removes all matchers for the round state machine.
314 /// We do not care about anything after the operator exits the protocol.
315 /// For example, even if operator sends a kickoff after exiting the protocol, that
316 /// kickoff is useless as reimburse connector utxo of that kickoff is in the next round,
317 /// which cannot be created anymore as the collateral is spent. So we do not want to challenge it, etc.
318 #[action]
319 pub(crate) async fn on_operator_exit_entry(&mut self) {
320 self.matchers = HashMap::new();
321 tracing::warn!(?self.operator_data, "Operator exited the protocol.");
322 }
323
324 /// Exit action for the round tx state.
325 /// This method will check if all kickoffs were used in the round.
326 /// If not, the owner will send a "Unspent Kickoff Connector" tx, slashing the operator.
327 #[action]
328 pub(crate) async fn on_round_tx_exit(
329 &mut self,
330 round_idx: &mut RoundIndex,
331 used_kickoffs: &mut HashSet<usize>,
332 context: &mut StateContext<T>,
333 ) {
334 context
335 .capture_error(async |context| {
336 {
337 context
338 .dispatch_duty(Duty::NewReadyToReimburse {
339 round_idx: *round_idx,
340 used_kickoffs: used_kickoffs.clone(),
341 operator_xonly_pk: self.operator_data.xonly_pk,
342 })
343 .await?;
344 Ok::<(), BridgeError>(())
345 }
346 .wrap_err(self.round_meta("on_round_tx_exit"))
347 })
348 .await;
349 }
350
351 /// Entry action for the round tx state.
352 /// This method adds the matchers for the round tx and the ready to reimburse tx.
353 /// It adds the matchers for the kickoff utxos in the round tx.
354 /// It also adds the matchers for the operator exit.
355 #[action]
356 pub(crate) async fn on_round_tx_entry(
357 &mut self,
358 round_idx: &mut RoundIndex,
359 challenged_before: &mut bool,
360 context: &mut StateContext<T>,
361 ) {
362 // ensure challenged_before starts at false for each round
363 // In a single round, a challenge is enough to slash all of the operators current kickoffs in the same round.
364 // This way, if the operator posts 50 different kickoffs, we only need one challenge.
365 // If that challenge is successful, operator will not be able to get reimbursement from all kickoffs.
366 *challenged_before = false;
367 context
368 .capture_error(async |context| {
369 {
370 self.matchers = HashMap::new();
371 // On the round after last round, do not care about anything,
372 // last round has index num_round_txs and is there only for reimbursement generators of previous round
373 // nothing is signed with them
374 if *round_idx
375 == RoundIndex::Round(context.config.protocol_paramset.num_round_txs)
376 {
377 Ok::<(), BridgeError>(())
378 } else {
379 let contract_context = ContractContext::new_context_for_round(
380 self.operator_data.xonly_pk,
381 *round_idx,
382 context.config.protocol_paramset,
383 );
384
385 let mut guard = context.shared_dbtx.lock().await;
386 let mut txhandlers = context
387 .owner
388 .create_txhandlers(&mut guard, TransactionType::Round, contract_context)
389 .await?;
390 drop(guard);
391
392 let round_txhandler = txhandlers
393 .remove(&TransactionType::Round)
394 .ok_or(TxError::TxHandlerNotFound(TransactionType::Round))?;
395 let ready_to_reimburse_txhandler = txhandlers
396 .remove(&TransactionType::ReadyToReimburse)
397 .ok_or(TxError::TxHandlerNotFound(
398 TransactionType::ReadyToReimburse,
399 ))?;
400 // Add a matcher for the ready to reimburse tx.
401 self.matchers.insert(
402 matcher::Matcher::SentTx(*ready_to_reimburse_txhandler.get_txid()),
403 RoundEvent::ReadyToReimburseSent {
404 round_idx: *round_idx,
405 },
406 );
407 // To determine if operator exited the protocol, we check if collateral was not spent in ready to reimburse tx.
408 self.matchers.insert(
409 matcher::Matcher::SpentUtxoButNotTxid(
410 OutPoint::new(
411 *round_txhandler.get_txid(),
412 UtxoVout::CollateralInRound.get_vout(),
413 ),
414 vec![*ready_to_reimburse_txhandler.get_txid()],
415 ),
416 RoundEvent::OperatorExit,
417 );
418 // Add a matcher for each kickoff utxo in the round tx.
419 for idx in 0..context.config.protocol_paramset.num_kickoffs_per_round {
420 let outpoint = *round_txhandler
421 .get_spendable_output(UtxoVout::Kickoff(idx))?
422 .get_prev_outpoint();
423 self.matchers.insert(
424 matcher::Matcher::SpentUtxo(outpoint),
425 RoundEvent::KickoffUtxoUsed {
426 kickoff_idx: idx,
427 kickoff_outpoint: outpoint,
428 },
429 );
430 }
431 Ok::<(), BridgeError>(())
432 }
433 }
434 .wrap_err(self.round_meta("on_round_tx_entry"))
435 })
436 .await;
437 }
438
439 #[state(entry_action = "on_ready_to_reimburse_entry")]
440 #[allow(unused_variables)]
441 pub(crate) async fn ready_to_reimburse(
442 &mut self,
443 event: &RoundEvent,
444 context: &mut StateContext<T>,
445 round_idx: &mut RoundIndex,
446 ) -> Response<State> {
447 match event {
448 // If the next round tx is mined, we transition to the round tx state.
449 RoundEvent::RoundSent {
450 round_idx: next_round_idx,
451 } => Transition(State::round_tx(*next_round_idx, HashSet::new(), false)),
452 RoundEvent::SavedToDb => Handled,
453 RoundEvent::OperatorExit => Transition(State::operator_exit()),
454 _ => {
455 self.unhandled_event(context, event).await;
456 Handled
457 }
458 }
459 }
460
461 /// Entry action for the ready to reimburse state.
462 /// This method adds the matchers for the next round tx and the operator exit.
463 #[action]
464 pub(crate) async fn on_ready_to_reimburse_entry(
465 &mut self,
466 context: &mut StateContext<T>,
467 round_idx: &mut RoundIndex,
468 ) {
469 context
470 .capture_error(async |context| {
471 {
472 self.matchers = HashMap::new();
473 // get next rounds Round tx
474 let next_round_context = ContractContext::new_context_for_round(
475 self.operator_data.xonly_pk,
476 round_idx.next_round(),
477 context.config.protocol_paramset,
478 );
479
480 let mut guard = context.shared_dbtx.lock().await;
481 let next_round_txhandlers = context
482 .owner
483 .create_txhandlers(&mut guard, TransactionType::Round, next_round_context)
484 .await?;
485 drop(guard);
486
487 let next_round_txid = next_round_txhandlers
488 .get(&TransactionType::Round)
489 .ok_or(TxError::TxHandlerNotFound(TransactionType::Round))?
490 .get_txid();
491 // Add a matcher for the next round tx.
492 self.matchers.insert(
493 matcher::Matcher::SentTx(*next_round_txid),
494 RoundEvent::RoundSent {
495 round_idx: round_idx.next_round(),
496 },
497 );
498 // calculate the current ready to reimburse txid
499 // to generate the SpentUtxoButNotTxid matcher for the operator exit
500 let current_round_context = ContractContext::new_context_for_round(
501 self.operator_data.xonly_pk,
502 *round_idx,
503 context.config.protocol_paramset,
504 );
505
506 let mut guard = context.shared_dbtx.lock().await;
507 let current_round_txhandlers = context
508 .owner
509 .create_txhandlers(
510 &mut guard,
511 TransactionType::Round,
512 current_round_context,
513 )
514 .await?;
515 drop(guard);
516
517 let current_ready_to_reimburse_txid = current_round_txhandlers
518 .get(&TransactionType::ReadyToReimburse)
519 .ok_or(TxError::TxHandlerNotFound(
520 TransactionType::ReadyToReimburse,
521 ))?
522 .get_txid();
523
524 // To determine if operator exited the protocol, we check if collateral was not spent in the next round tx.
525 self.matchers.insert(
526 matcher::Matcher::SpentUtxoButNotTxid(
527 OutPoint::new(
528 *current_ready_to_reimburse_txid,
529 UtxoVout::CollateralInReadyToReimburse.get_vout(),
530 ),
531 vec![*next_round_txid],
532 ),
533 RoundEvent::OperatorExit,
534 );
535 Ok::<(), BridgeError>(())
536 }
537 .wrap_err(self.round_meta("on_ready_to_reimburse_entry"))
538 })
539 .await;
540 }
541}