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