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}