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}