fedimint_mint_client/
input.rs

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