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}