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