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}