clementine_core/states/
round.rs

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