Skip to main content

fedimint_mint_client/
input.rs

1use assert_matches::assert_matches;
2use fedimint_client_module::DynGlobalClientContext;
3use fedimint_client_module::module::OutPointRange;
4use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
5use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
6use fedimint_core::core::OperationId;
7use fedimint_core::encoding::{Decodable, Encodable};
8use fedimint_core::module::Amounts;
9use fedimint_core::util::FmtCompact;
10use fedimint_core::{Amount, TransactionId};
11use fedimint_logging::LOG_CLIENT_MODULE_MINT;
12use fedimint_mint_common::MintInput;
13use tracing::{debug, warn};
14
15use crate::{MintClientContext, SpendableNote};
16
17// TODO: add retry with valid subset of e-cash notes
18/// State machine managing the e-cash redemption process related to a mint
19/// input.
20///
21/// ```mermaid
22/// graph LR
23///     classDef virtual fill:#fff,stroke-dasharray: 5 5
24///
25///     Created -- containing tx accepted --> Success
26///     Created -- containing tx rejected --> Refund
27///     Refund -- refund tx rejected --> Error
28///     Refund -- refund tx accepted --> RS[Refund Success]
29/// ```
30#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
31pub enum MintInputStates {
32    #[deprecated(note = "Use CreateMulti instead")]
33    Created(MintInputStateCreated),
34    Refund(MintInputStateRefund),
35    Success(MintInputStateSuccess),
36    Error(MintInputStateError),
37    RefundSuccess(MintInputStateRefundSuccess),
38    /// A mint inputs bundle was submitted as a part of a general purpose tx,
39    /// and if the tx fails, the bundle should be reissued (refunded)
40    ///
41    /// Like [`Self::Created`], but tracks multiple notes at the same time
42    CreatedBundle(MintInputStateCreatedBundle),
43    /// Refunded multiple notes in a single tx, if fails, switch to
44    /// per-note-refund
45    RefundedBundle(MintInputStateRefundedBundle),
46    /// Refunded note via multiple single-note transactions
47    RefundedPerNote(MintInputStateRefundedPerNote),
48}
49
50#[derive(Debug, Clone, Eq, Hash, PartialEq, Decodable, Encodable)]
51pub struct MintInputCommonV0 {
52    pub(crate) operation_id: OperationId,
53    pub(crate) txid: TransactionId,
54    pub(crate) input_idx: u64,
55}
56
57#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Decodable, Encodable)]
58pub struct MintInputCommon {
59    pub(crate) operation_id: OperationId,
60    pub(crate) out_point_range: OutPointRange,
61}
62
63#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
64pub struct MintInputStateMachineV0 {
65    pub(crate) common: MintInputCommonV0,
66    pub(crate) state: MintInputStates,
67}
68
69#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
70pub struct MintInputStateMachine {
71    pub(crate) common: MintInputCommon,
72    pub(crate) state: MintInputStates,
73}
74
75impl State for MintInputStateMachine {
76    type ModuleContext = MintClientContext;
77
78    #[allow(deprecated)]
79    fn transitions(
80        &self,
81        _context: &Self::ModuleContext,
82        global_context: &DynGlobalClientContext,
83    ) -> Vec<StateTransition<Self>> {
84        match &self.state {
85            MintInputStates::Created(_) => {
86                MintInputStateCreated::transitions(self.common, global_context)
87            }
88            MintInputStates::CreatedBundle(_) => {
89                MintInputStateCreatedBundle::transitions(self.common, global_context)
90            }
91            MintInputStates::RefundedBundle(state) => {
92                MintInputStateRefundedBundle::transitions(state, global_context)
93            }
94            MintInputStates::Refund(refund) => refund.transitions(global_context),
95            MintInputStates::Success(_)
96            | MintInputStates::Error(_)
97            | MintInputStates::RefundSuccess(_)
98            // `RefundMulti` means that the refund was split between multiple new per-note state machines, so
99            // the current state machine has nothing more to do
100            | MintInputStates::RefundedPerNote(_) => {
101                vec![]
102            }
103        }
104    }
105
106    fn operation_id(&self) -> OperationId {
107        self.common.operation_id
108    }
109
110    fn fmt_visualization(&self, f: &mut dyn std::fmt::Write, indent: &str) -> std::fmt::Result {
111        let txid = self.common.out_point_range.txid();
112        let start = self.common.out_point_range.start_idx();
113        let count = self.common.out_point_range.count();
114        match &self.state {
115            #[allow(deprecated)]
116            MintInputStates::Created(c) => {
117                write!(
118                    f,
119                    "{indent}MintInputStateMachine\n{indent}  state: Created  tx={}:[{start},{end})  amount={}",
120                    txid.fmt_short(),
121                    c.amount,
122                    end = start + count as u64,
123                )
124            }
125            MintInputStates::CreatedBundle(c) => {
126                let total: Amount = c.notes.iter().map(|(a, _)| *a).sum();
127                write!(
128                    f,
129                    "{indent}MintInputStateMachine\n{indent}  state: CreatedBundle  tx={}:[{start},{end})  notes={}, total={total}",
130                    txid.fmt_short(),
131                    c.notes.len(),
132                    end = start + count as u64,
133                )
134            }
135            MintInputStates::Success(_) => {
136                write!(f, "{indent}MintInputStateMachine\n{indent}  state: Success",)
137            }
138            MintInputStates::Refund(r) => {
139                write!(
140                    f,
141                    "{indent}MintInputStateMachine\n{indent}  state: Refund  refund_txid={}",
142                    r.refund_txid.fmt_short(),
143                )
144            }
145            MintInputStates::RefundedBundle(r) => {
146                let total: Amount = r.spendable_notes.iter().map(|(a, _)| *a).sum();
147                write!(
148                    f,
149                    "{indent}MintInputStateMachine\n{indent}  state: RefundedBundle  refund_txid={}  notes={}, total={total}",
150                    r.refund_txid.fmt_short(),
151                    r.spendable_notes.len(),
152                )
153            }
154            MintInputStates::RefundedPerNote(r) => {
155                write!(
156                    f,
157                    "{indent}MintInputStateMachine\n{indent}  state: RefundedPerNote  txids={}",
158                    r.refund_txids.len(),
159                )
160            }
161            MintInputStates::RefundSuccess(r) => {
162                write!(
163                    f,
164                    "{indent}MintInputStateMachine\n{indent}  state: RefundSuccess  refund_txid={}",
165                    r.refund_txid.fmt_short(),
166                )
167            }
168            MintInputStates::Error(e) => {
169                write!(
170                    f,
171                    "{indent}MintInputStateMachine\n{indent}  state: Error  error={}",
172                    e.error,
173                )
174            }
175        }
176    }
177}
178
179#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
180pub struct MintInputStateCreated {
181    pub(crate) amount: Amount,
182    pub(crate) spendable_note: SpendableNote,
183}
184
185impl MintInputStateCreated {
186    fn transitions(
187        common: MintInputCommon,
188        global_context: &DynGlobalClientContext,
189    ) -> Vec<StateTransition<MintInputStateMachine>> {
190        let global_context = global_context.clone();
191        vec![StateTransition::new(
192            Self::await_success(common, global_context.clone()),
193            move |dbtx, result, old_state| {
194                Box::pin(Self::transition_success(
195                    result,
196                    old_state,
197                    dbtx,
198                    global_context.clone(),
199                ))
200            },
201        )]
202    }
203
204    async fn await_success(
205        common: MintInputCommon,
206        global_context: DynGlobalClientContext,
207    ) -> Result<(), String> {
208        global_context
209            .await_tx_accepted(common.out_point_range.txid())
210            .await
211    }
212
213    #[allow(deprecated)]
214    async fn transition_success(
215        result: Result<(), String>,
216        old_state: MintInputStateMachine,
217        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
218        global_context: DynGlobalClientContext,
219    ) -> MintInputStateMachine {
220        assert_matches!(old_state.state, MintInputStates::Created(_));
221
222        match result {
223            Ok(()) => {
224                // Success case: containing transaction is accepted
225                MintInputStateMachine {
226                    common: old_state.common,
227                    state: MintInputStates::Success(MintInputStateSuccess {}),
228                }
229            }
230            Err(err) => {
231                // Transaction rejected: attempting to refund
232                debug!(target: LOG_CLIENT_MODULE_MINT, err = %err.as_str(), "Refunding mint transaction input due to transaction error");
233                Self::refund(dbtx, old_state, global_context).await
234            }
235        }
236    }
237
238    #[allow(deprecated)]
239    async fn refund(
240        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
241        old_state: MintInputStateMachine,
242        global_context: DynGlobalClientContext,
243    ) -> MintInputStateMachine {
244        let (amount, spendable_note) = match old_state.state {
245            MintInputStates::Created(created) => (created.amount, created.spendable_note),
246            _ => panic!("Invalid state transition"),
247        };
248
249        let refund_input = ClientInput::<MintInput> {
250            input: MintInput::new_v0(amount, spendable_note.note()),
251            keys: vec![spendable_note.spend_key],
252            amounts: Amounts::new_bitcoin(amount),
253        };
254
255        let change_range = global_context
256            .claim_inputs(
257                dbtx,
258                // The input of the refund tx is managed by this state machine, so no new state
259                // machines need to be created
260                ClientInputBundle::new_no_sm(vec![refund_input]),
261            )
262            .await
263            .expect("Cannot claim input, additional funding needed");
264
265        MintInputStateMachine {
266            common: old_state.common,
267            state: MintInputStates::Refund(MintInputStateRefund {
268                refund_txid: change_range.txid(),
269            }),
270        }
271    }
272}
273
274#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
275pub struct MintInputStateCreatedBundle {
276    pub(crate) notes: Vec<(Amount, SpendableNote)>,
277}
278
279impl MintInputStateCreatedBundle {
280    fn transitions(
281        common: MintInputCommon,
282        global_context: &DynGlobalClientContext,
283    ) -> Vec<StateTransition<MintInputStateMachine>> {
284        let global_context = global_context.clone();
285        vec![StateTransition::new(
286            Self::await_success(common, global_context.clone()),
287            move |dbtx, result, old_state| {
288                Box::pin(Self::transition_success(
289                    result,
290                    old_state,
291                    dbtx,
292                    global_context.clone(),
293                ))
294            },
295        )]
296    }
297
298    async fn await_success(
299        common: MintInputCommon,
300        global_context: DynGlobalClientContext,
301    ) -> Result<(), String> {
302        global_context
303            .await_tx_accepted(common.out_point_range.txid())
304            .await
305    }
306
307    async fn transition_success(
308        result: Result<(), String>,
309        old_state: MintInputStateMachine,
310        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
311        global_context: DynGlobalClientContext,
312    ) -> MintInputStateMachine {
313        assert_matches!(old_state.state, MintInputStates::CreatedBundle(_));
314
315        match result {
316            Ok(()) => {
317                // Success case: containing transaction is accepted
318                MintInputStateMachine {
319                    common: old_state.common,
320                    state: MintInputStates::Success(MintInputStateSuccess {}),
321                }
322            }
323            Err(err) => {
324                // Transaction rejected: attempting to refund
325                debug!(target: LOG_CLIENT_MODULE_MINT, err = %err.as_str(), "Refunding mint transaction input due to transaction error");
326                Self::refund(dbtx, old_state, global_context).await
327            }
328        }
329    }
330
331    async fn refund(
332        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
333        old_state: MintInputStateMachine,
334        global_context: DynGlobalClientContext,
335    ) -> MintInputStateMachine {
336        let spendable_notes = match old_state.state {
337            MintInputStates::CreatedBundle(created) => created.notes,
338            _ => panic!("Invalid state transition"),
339        };
340
341        let mut inputs = Vec::new();
342
343        for (amount, spendable_note) in spendable_notes.clone() {
344            inputs.push(ClientInput::<MintInput> {
345                input: MintInput::new_v0(amount, spendable_note.note()),
346                keys: vec![spendable_note.spend_key],
347                amounts: Amounts::new_bitcoin(amount),
348            });
349        }
350
351        let change_range = global_context
352            .claim_inputs(
353                dbtx,
354                // We are inside an input state machine, so no need to spawn new ones
355                ClientInputBundle::new_no_sm(inputs),
356            )
357            .await
358            .expect("Cannot claim input, additional funding needed");
359
360        let refund_txid = change_range.txid();
361        MintInputStateMachine {
362            common: old_state.common,
363            state: if spendable_notes.len() == 1 {
364                // if the bundle had 1 note, we did per-note refund already
365                MintInputStates::RefundedPerNote(MintInputStateRefundedPerNote {
366                    refund_txids: vec![refund_txid],
367                })
368            } else {
369                MintInputStates::RefundedBundle(MintInputStateRefundedBundle {
370                    refund_txid,
371                    spendable_notes,
372                })
373            },
374        }
375    }
376}
377
378#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
379pub struct MintInputStateRefundedBundle {
380    pub(crate) refund_txid: TransactionId,
381    pub(crate) spendable_notes: Vec<(Amount, SpendableNote)>,
382}
383
384impl MintInputStateRefundedBundle {
385    fn transitions(
386        state: &MintInputStateRefundedBundle,
387        global_context: &DynGlobalClientContext,
388    ) -> Vec<StateTransition<MintInputStateMachine>> {
389        let global_context = global_context.clone();
390        vec![StateTransition::new(
391            Self::await_success(state.refund_txid, global_context.clone()),
392            move |dbtx, result, old_state| {
393                Box::pin(Self::transition_success(
394                    result,
395                    old_state,
396                    dbtx,
397                    global_context.clone(),
398                ))
399            },
400        )]
401    }
402
403    async fn await_success(
404        txid: TransactionId,
405        global_context: DynGlobalClientContext,
406    ) -> Result<(), String> {
407        global_context.await_tx_accepted(txid).await
408    }
409
410    async fn transition_success(
411        result: Result<(), String>,
412        old_state: MintInputStateMachine,
413        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
414        global_context: DynGlobalClientContext,
415    ) -> MintInputStateMachine {
416        assert_matches!(old_state.state, MintInputStates::RefundedBundle(_));
417
418        match result {
419            Ok(()) => {
420                // Success case: containing transaction is accepted
421                MintInputStateMachine {
422                    common: old_state.common,
423                    state: MintInputStates::Success(MintInputStateSuccess {}),
424                }
425            }
426            Err(err) => {
427                // Transaction rejected: attempting to refund
428                debug!(target: LOG_CLIENT_MODULE_MINT, err = %err.as_str(), "Refunding mint transaction input due to transaction error on multi-note refund");
429                Self::refund(dbtx, old_state, global_context).await
430            }
431        }
432    }
433
434    async fn refund(
435        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
436        old_state: MintInputStateMachine,
437        global_context: DynGlobalClientContext,
438    ) -> MintInputStateMachine {
439        let spendable_notes = match old_state.state {
440            MintInputStates::RefundedBundle(created) => created.spendable_notes,
441            _ => panic!("Invalid state transition"),
442        };
443
444        let mut refund_txids = vec![];
445        for (amount, spendable_note) in spendable_notes {
446            let refund_input = ClientInput::<MintInput> {
447                input: MintInput::new_v0(amount, spendable_note.note()),
448                keys: vec![spendable_note.spend_key],
449                amounts: Amounts::new_bitcoin(amount),
450            };
451            match global_context
452                .claim_inputs(
453                    dbtx,
454                    // The input of the refund tx is managed by this state machine, so no new state
455                    // machines need to be created
456                    ClientInputBundle::new_no_sm(vec![refund_input.clone()]),
457                )
458                .await
459            {
460                Ok(change_range) => {
461                    refund_txids.push(change_range.txid());
462                }
463                Err(err) => {
464                    warn!(
465                        target: LOG_CLIENT_MODULE_MINT,
466                        err = %err.fmt_compact(),
467                        refund_input_amounts = ?refund_input.amounts,
468                        input = %refund_input.input,
469                        "Failed to remint a single note"
470                    );
471                }
472            }
473        }
474
475        MintInputStateMachine {
476            common: old_state.common,
477            state: MintInputStates::RefundedPerNote(MintInputStateRefundedPerNote { refund_txids }),
478        }
479    }
480}
481
482#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
483pub struct MintInputStateRefund {
484    pub refund_txid: TransactionId,
485}
486
487impl MintInputStateRefund {
488    fn transitions(
489        &self,
490        global_context: &DynGlobalClientContext,
491    ) -> Vec<StateTransition<MintInputStateMachine>> {
492        vec![StateTransition::new(
493            Self::await_refund_success(global_context.clone(), self.refund_txid),
494            |_dbtx, result, old_state| {
495                Box::pin(async { Self::transition_refund_success(result, old_state) })
496            },
497        )]
498    }
499
500    async fn await_refund_success(
501        global_context: DynGlobalClientContext,
502        refund_txid: TransactionId,
503    ) -> Result<(), String> {
504        global_context.await_tx_accepted(refund_txid).await
505    }
506
507    fn transition_refund_success(
508        result: Result<(), String>,
509        old_state: MintInputStateMachine,
510    ) -> MintInputStateMachine {
511        let refund_txid = match old_state.state {
512            MintInputStates::Refund(refund) => refund.refund_txid,
513            _ => panic!("Invalid state transition"),
514        };
515
516        match result {
517            Ok(()) => {
518                // Refund successful
519                MintInputStateMachine {
520                    common: old_state.common,
521                    state: MintInputStates::RefundSuccess(MintInputStateRefundSuccess {
522                        refund_txid,
523                    }),
524                }
525            }
526            Err(err) => {
527                // Refund failed
528                // TODO: include e-cash notes for recovery? Although, they are in the log …
529                warn!(target: LOG_CLIENT_MODULE_MINT, err = %err.as_str(), %refund_txid, "Refund transaction rejected. Notes probably lost.");
530                MintInputStateMachine {
531                    common: old_state.common,
532                    state: MintInputStates::Error(MintInputStateError {
533                        error: format!("Refund transaction {refund_txid} was rejected"),
534                    }),
535                }
536            }
537        }
538    }
539}
540
541#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
542pub struct MintInputStateRefundedPerNote {
543    pub refund_txids: Vec<TransactionId>,
544}
545
546#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
547pub struct MintInputStateSuccess {}
548
549#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
550pub struct MintInputStateError {
551    error: String,
552}
553
554#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
555pub struct MintInputStateRefundSuccess {
556    refund_txid: TransactionId,
557}