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 tracing::info!(
168 "First round tx detected for {}",
169 self.operator_data.xonly_pk
170 );
171 Transition(State::round_tx(*round_idx, HashSet::new(), false))
172 }
173 RoundEvent::SavedToDb => Handled,
174 RoundEvent::OperatorExit => Transition(State::operator_exit()),
175 _ => {
176 self.unhandled_event(context, event).await;
177 Handled
178 }
179 }
180 }
181
182 /// Entry action for the initial collateral state.
183 /// This method adds the matcher for the first round tx and the matcher if the operator exits
184 /// the protocol by not spending the collateral in the first round tx.
185 #[action]
186 #[allow(unused_variables)]
187 pub(crate) async fn on_initial_collateral_entry(&mut self, context: &mut StateContext<T>) {
188 context
189 .capture_error(async |context| {
190 {
191 self.matchers = HashMap::new();
192
193 // To determine if operator exited the protocol, we check if collateral was not spent in the first round tx.
194 let contract_context = ContractContext::new_context_for_round(
195 self.operator_data.xonly_pk,
196 RoundIndex::Round(0),
197 context.config.protocol_paramset,
198 );
199
200 let mut guard = context.shared_dbtx.lock().await;
201 let round_txhandlers = context
202 .owner
203 .create_txhandlers(&mut guard, TransactionType::Round, contract_context)
204 .await?;
205 drop(guard);
206 let round_txid = round_txhandlers
207 .get(&TransactionType::Round)
208 .ok_or(TxError::TxHandlerNotFound(TransactionType::Round))?
209 .get_txid();
210 // if round tx is sent, we can send the round sent event
211 self.matchers.insert(
212 matcher::Matcher::SentTx(*round_txid),
213 RoundEvent::RoundSent {
214 round_idx: RoundIndex::Round(0),
215 },
216 );
217 // If the tx the collateral is spent on is not the round tx, we dispatch the operator exit event.
218 self.matchers.insert(
219 matcher::Matcher::SpentUtxoButNotTxid(
220 self.operator_data.collateral_funding_outpoint,
221 vec![*round_txid],
222 ),
223 RoundEvent::OperatorExit,
224 );
225 Ok::<(), BridgeError>(())
226 }
227 .wrap_err(self.round_meta("on_initial_collateral_entry"))
228 })
229 .await;
230 }
231
232 /// State that represents a round tx.
233 /// This state is entered when a round tx is mined.
234 /// It is exited when the operator sends the ready to reimburse tx or exits the protocol.
235 #[state(entry_action = "on_round_tx_entry", exit_action = "on_round_tx_exit")]
236 #[allow(unused_variables)]
237 pub(crate) async fn round_tx(
238 &mut self,
239 event: &RoundEvent,
240 round_idx: &mut RoundIndex,
241 used_kickoffs: &mut HashSet<usize>,
242 challenged_before: &mut bool,
243 context: &mut StateContext<T>,
244 ) -> Response<State> {
245 match event {
246 // If a kickoff utxo is spent, we add it to the used kickoffs set.
247 // The set will be used to determine if the operator has used all kickoffs in the round.
248 // If the operator did not use all kickoffs, "Unspent Kickoff Connector" tx can potentially be sent, slashing the operator.
249 // Additionally, the owner will check if the kickoff utxo is used in a kickoff transaction.
250 // If so, the owner (if verifier) will do additional checks to determine if the kickoff is malicious or not.
251 RoundEvent::KickoffUtxoUsed {
252 kickoff_idx,
253 kickoff_outpoint,
254 } => {
255 tracing::info!(
256 "Kickoff utxo {} usage detected, operator: {}, round: {}",
257 kickoff_idx,
258 self.operator_data.xonly_pk,
259 round_idx
260 );
261 used_kickoffs.insert(*kickoff_idx);
262 let txid = context
263 .cache
264 .get_txid_of_utxo(kickoff_outpoint)
265 .expect("UTXO should be in block");
266
267 context
268 .capture_error(async |context| {
269 {
270 // acquire lock to ensure that CheckIfKickoff duties are not dispatched during resync checks on deposit_finalize
271 let _guard = CHECK_KICKOFF_LOCK.write().await;
272 let duty_result = context
273 .dispatch_duty(Duty::CheckIfKickoff {
274 txid,
275 block_height: context.cache.block_height,
276 witness: context
277 .cache
278 .get_witness_of_utxo(kickoff_outpoint)
279 .expect("UTXO should be in block"),
280 challenged_before: *challenged_before,
281 })
282 .await?;
283 if let DutyResult::CheckIfKickoff { challenged } = duty_result {
284 *challenged_before |= challenged;
285 }
286 Ok::<(), BridgeError>(())
287 }
288 .wrap_err(self.round_meta("round_tx kickoff_utxo_used"))
289 })
290 .await;
291 Handled
292 }
293 // If the ready to reimburse tx is mined, we transition to the ready to reimburse state.
294 RoundEvent::ReadyToReimburseSent { round_idx } => {
295 tracing::info!(
296 "Ready to reimburse tx sent for operator: {}, round: {}",
297 self.operator_data.xonly_pk,
298 round_idx
299 );
300 Transition(State::ready_to_reimburse(*round_idx))
301 }
302 RoundEvent::SavedToDb => Handled,
303 RoundEvent::OperatorExit => Transition(State::operator_exit()),
304 _ => {
305 self.unhandled_event(context, event).await;
306 Handled
307 }
308 }
309 }
310
311 /// State that represents the operator exiting the protocol.
312 #[state(entry_action = "on_operator_exit_entry")]
313 pub(crate) async fn operator_exit(
314 &mut self,
315 event: &RoundEvent,
316 context: &mut StateContext<T>,
317 ) -> Response<State> {
318 match event {
319 RoundEvent::SavedToDb => Handled,
320 _ => {
321 self.unhandled_event(context, event).await;
322 Handled
323 }
324 }
325 }
326
327 /// Entry action for the operator exit state.
328 /// This method removes all matchers for the round state machine.
329 /// We do not care about anything after the operator exits the protocol.
330 /// For example, even if operator sends a kickoff after exiting the protocol, that
331 /// kickoff is useless as reimburse connector utxo of that kickoff is in the next round,
332 /// which cannot be created anymore as the collateral is spent. So we do not want to challenge it, etc.
333 #[action]
334 pub(crate) async fn on_operator_exit_entry(&mut self) {
335 self.matchers = HashMap::new();
336 tracing::warn!(?self.operator_data, "Operator exited the protocol.");
337 }
338
339 /// Exit action for the round tx state.
340 /// This method will check if all kickoffs were used in the round.
341 /// If not, the owner will send a "Unspent Kickoff Connector" tx, slashing the operator.
342 #[action]
343 pub(crate) async fn on_round_tx_exit(
344 &mut self,
345 round_idx: &mut RoundIndex,
346 used_kickoffs: &mut HashSet<usize>,
347 context: &mut StateContext<T>,
348 ) {
349 context
350 .capture_error(async |context| {
351 {
352 context
353 .dispatch_duty(Duty::NewReadyToReimburse {
354 round_idx: *round_idx,
355 used_kickoffs: used_kickoffs.clone(),
356 operator_xonly_pk: self.operator_data.xonly_pk,
357 })
358 .await?;
359 Ok::<(), BridgeError>(())
360 }
361 .wrap_err(self.round_meta("on_round_tx_exit"))
362 })
363 .await;
364 }
365
366 /// Entry action for the round tx state.
367 /// This method adds the matchers for the round tx and the ready to reimburse tx.
368 /// It adds the matchers for the kickoff utxos in the round tx.
369 /// It also adds the matchers for the operator exit.
370 #[action]
371 pub(crate) async fn on_round_tx_entry(
372 &mut self,
373 round_idx: &mut RoundIndex,
374 challenged_before: &mut bool,
375 context: &mut StateContext<T>,
376 ) {
377 // ensure challenged_before starts at false for each round
378 // In a single round, a challenge is enough to slash all of the operators current kickoffs in the same round.
379 // This way, if the operator posts 50 different kickoffs, we only need one challenge.
380 // If that challenge is successful, operator will not be able to get reimbursement from all kickoffs.
381 *challenged_before = false;
382 context
383 .capture_error(async |context| {
384 {
385 self.matchers = HashMap::new();
386 // On the round after last round, do not care about anything,
387 // last round has index num_round_txs and is there only for reimbursement generators of previous round
388 // nothing is signed with them
389 if *round_idx
390 == RoundIndex::Round(context.config.protocol_paramset.num_round_txs)
391 {
392 Ok::<(), BridgeError>(())
393 } else {
394 let contract_context = ContractContext::new_context_for_round(
395 self.operator_data.xonly_pk,
396 *round_idx,
397 context.config.protocol_paramset,
398 );
399
400 let mut guard = context.shared_dbtx.lock().await;
401 let mut txhandlers = context
402 .owner
403 .create_txhandlers(&mut guard, TransactionType::Round, contract_context)
404 .await?;
405 drop(guard);
406
407 let round_txhandler = txhandlers
408 .remove(&TransactionType::Round)
409 .ok_or(TxError::TxHandlerNotFound(TransactionType::Round))?;
410 let ready_to_reimburse_txhandler = txhandlers
411 .remove(&TransactionType::ReadyToReimburse)
412 .ok_or(TxError::TxHandlerNotFound(
413 TransactionType::ReadyToReimburse,
414 ))?;
415 // Add a matcher for the ready to reimburse tx.
416 self.matchers.insert(
417 matcher::Matcher::SentTx(*ready_to_reimburse_txhandler.get_txid()),
418 RoundEvent::ReadyToReimburseSent {
419 round_idx: *round_idx,
420 },
421 );
422 // To determine if operator exited the protocol, we check if collateral was not spent in ready to reimburse tx.
423 self.matchers.insert(
424 matcher::Matcher::SpentUtxoButNotTxid(
425 OutPoint::new(
426 *round_txhandler.get_txid(),
427 UtxoVout::CollateralInRound.get_vout(),
428 ),
429 vec![*ready_to_reimburse_txhandler.get_txid()],
430 ),
431 RoundEvent::OperatorExit,
432 );
433 // Add a matcher for each kickoff utxo in the round tx.
434 for idx in 0..context.config.protocol_paramset.num_kickoffs_per_round {
435 let outpoint = *round_txhandler
436 .get_spendable_output(UtxoVout::Kickoff(idx))?
437 .get_prev_outpoint();
438 self.matchers.insert(
439 matcher::Matcher::SpentUtxo(outpoint),
440 RoundEvent::KickoffUtxoUsed {
441 kickoff_idx: idx,
442 kickoff_outpoint: outpoint,
443 },
444 );
445 }
446 Ok::<(), BridgeError>(())
447 }
448 }
449 .wrap_err(self.round_meta("on_round_tx_entry"))
450 })
451 .await;
452 }
453
454 #[state(entry_action = "on_ready_to_reimburse_entry")]
455 #[allow(unused_variables)]
456 pub(crate) async fn ready_to_reimburse(
457 &mut self,
458 event: &RoundEvent,
459 context: &mut StateContext<T>,
460 round_idx: &mut RoundIndex,
461 ) -> Response<State> {
462 match event {
463 // If the next round tx is mined, we transition to the round tx state.
464 RoundEvent::RoundSent {
465 round_idx: next_round_idx,
466 } => Transition(State::round_tx(*next_round_idx, HashSet::new(), false)),
467 RoundEvent::SavedToDb => Handled,
468 RoundEvent::OperatorExit => Transition(State::operator_exit()),
469 _ => {
470 self.unhandled_event(context, event).await;
471 Handled
472 }
473 }
474 }
475
476 /// Entry action for the ready to reimburse state.
477 /// This method adds the matchers for the next round tx and the operator exit.
478 #[action]
479 pub(crate) async fn on_ready_to_reimburse_entry(
480 &mut self,
481 context: &mut StateContext<T>,
482 round_idx: &mut RoundIndex,
483 ) {
484 context
485 .capture_error(async |context| {
486 {
487 self.matchers = HashMap::new();
488 // get next rounds Round tx
489 let next_round_context = ContractContext::new_context_for_round(
490 self.operator_data.xonly_pk,
491 round_idx.next_round(),
492 context.config.protocol_paramset,
493 );
494
495 let mut guard = context.shared_dbtx.lock().await;
496 let next_round_txhandlers = context
497 .owner
498 .create_txhandlers(&mut guard, TransactionType::Round, next_round_context)
499 .await?;
500 drop(guard);
501
502 let next_round_txid = next_round_txhandlers
503 .get(&TransactionType::Round)
504 .ok_or(TxError::TxHandlerNotFound(TransactionType::Round))?
505 .get_txid();
506 // Add a matcher for the next round tx.
507 self.matchers.insert(
508 matcher::Matcher::SentTx(*next_round_txid),
509 RoundEvent::RoundSent {
510 round_idx: round_idx.next_round(),
511 },
512 );
513 // calculate the current ready to reimburse txid
514 // to generate the SpentUtxoButNotTxid matcher for the operator exit
515 let current_round_context = ContractContext::new_context_for_round(
516 self.operator_data.xonly_pk,
517 *round_idx,
518 context.config.protocol_paramset,
519 );
520
521 let mut guard = context.shared_dbtx.lock().await;
522 let current_round_txhandlers = context
523 .owner
524 .create_txhandlers(
525 &mut guard,
526 TransactionType::Round,
527 current_round_context,
528 )
529 .await?;
530 drop(guard);
531
532 let current_ready_to_reimburse_txid = current_round_txhandlers
533 .get(&TransactionType::ReadyToReimburse)
534 .ok_or(TxError::TxHandlerNotFound(
535 TransactionType::ReadyToReimburse,
536 ))?
537 .get_txid();
538
539 // To determine if operator exited the protocol, we check if collateral was not spent in the next round tx.
540 self.matchers.insert(
541 matcher::Matcher::SpentUtxoButNotTxid(
542 OutPoint::new(
543 *current_ready_to_reimburse_txid,
544 UtxoVout::CollateralInReadyToReimburse.get_vout(),
545 ),
546 vec![*next_round_txid],
547 ),
548 RoundEvent::OperatorExit,
549 );
550 Ok::<(), BridgeError>(())
551 }
552 .wrap_err(self.round_meta("on_ready_to_reimburse_entry"))
553 })
554 .await;
555 }
556}