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