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
112#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
113pub struct MintInputStateCreated {
114    pub(crate) amount: Amount,
115    pub(crate) spendable_note: SpendableNote,
116}
117
118impl MintInputStateCreated {
119    fn transitions(
120        common: MintInputCommon,
121        global_context: &DynGlobalClientContext,
122    ) -> Vec<StateTransition<MintInputStateMachine>> {
123        let global_context = global_context.clone();
124        vec![StateTransition::new(
125            Self::await_success(common, global_context.clone()),
126            move |dbtx, result, old_state| {
127                Box::pin(Self::transition_success(
128                    result,
129                    old_state,
130                    dbtx,
131                    global_context.clone(),
132                ))
133            },
134        )]
135    }
136
137    async fn await_success(
138        common: MintInputCommon,
139        global_context: DynGlobalClientContext,
140    ) -> Result<(), String> {
141        global_context
142            .await_tx_accepted(common.out_point_range.txid())
143            .await
144    }
145
146    #[allow(deprecated)]
147    async fn transition_success(
148        result: Result<(), String>,
149        old_state: MintInputStateMachine,
150        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
151        global_context: DynGlobalClientContext,
152    ) -> MintInputStateMachine {
153        assert_matches!(old_state.state, MintInputStates::Created(_));
154
155        match result {
156            Ok(()) => {
157                // Success case: containing transaction is accepted
158                MintInputStateMachine {
159                    common: old_state.common,
160                    state: MintInputStates::Success(MintInputStateSuccess {}),
161                }
162            }
163            Err(err) => {
164                // Transaction rejected: attempting to refund
165                debug!(target: LOG_CLIENT_MODULE_MINT, err = %err.as_str(), "Refunding mint transaction input due to transaction error");
166                Self::refund(dbtx, old_state, global_context).await
167            }
168        }
169    }
170
171    #[allow(deprecated)]
172    async fn refund(
173        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
174        old_state: MintInputStateMachine,
175        global_context: DynGlobalClientContext,
176    ) -> MintInputStateMachine {
177        let (amount, spendable_note) = match old_state.state {
178            MintInputStates::Created(created) => (created.amount, created.spendable_note),
179            _ => panic!("Invalid state transition"),
180        };
181
182        let refund_input = ClientInput::<MintInput> {
183            input: MintInput::new_v0(amount, spendable_note.note()),
184            keys: vec![spendable_note.spend_key],
185            amounts: Amounts::new_bitcoin(amount),
186        };
187
188        let change_range = global_context
189            .claim_inputs(
190                dbtx,
191                // The input of the refund tx is managed by this state machine, so no new state
192                // machines need to be created
193                ClientInputBundle::new_no_sm(vec![refund_input]),
194            )
195            .await
196            .expect("Cannot claim input, additional funding needed");
197
198        MintInputStateMachine {
199            common: old_state.common,
200            state: MintInputStates::Refund(MintInputStateRefund {
201                refund_txid: change_range.txid(),
202            }),
203        }
204    }
205}
206
207#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
208pub struct MintInputStateCreatedBundle {
209    pub(crate) notes: Vec<(Amount, SpendableNote)>,
210}
211
212impl MintInputStateCreatedBundle {
213    fn transitions(
214        common: MintInputCommon,
215        global_context: &DynGlobalClientContext,
216    ) -> Vec<StateTransition<MintInputStateMachine>> {
217        let global_context = global_context.clone();
218        vec![StateTransition::new(
219            Self::await_success(common, global_context.clone()),
220            move |dbtx, result, old_state| {
221                Box::pin(Self::transition_success(
222                    result,
223                    old_state,
224                    dbtx,
225                    global_context.clone(),
226                ))
227            },
228        )]
229    }
230
231    async fn await_success(
232        common: MintInputCommon,
233        global_context: DynGlobalClientContext,
234    ) -> Result<(), String> {
235        global_context
236            .await_tx_accepted(common.out_point_range.txid())
237            .await
238    }
239
240    async fn transition_success(
241        result: Result<(), String>,
242        old_state: MintInputStateMachine,
243        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
244        global_context: DynGlobalClientContext,
245    ) -> MintInputStateMachine {
246        assert_matches!(old_state.state, MintInputStates::CreatedBundle(_));
247
248        match result {
249            Ok(()) => {
250                // Success case: containing transaction is accepted
251                MintInputStateMachine {
252                    common: old_state.common,
253                    state: MintInputStates::Success(MintInputStateSuccess {}),
254                }
255            }
256            Err(err) => {
257                // Transaction rejected: attempting to refund
258                debug!(target: LOG_CLIENT_MODULE_MINT, err = %err.as_str(), "Refunding mint transaction input due to transaction error");
259                Self::refund(dbtx, old_state, global_context).await
260            }
261        }
262    }
263
264    async fn refund(
265        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
266        old_state: MintInputStateMachine,
267        global_context: DynGlobalClientContext,
268    ) -> MintInputStateMachine {
269        let spendable_notes = match old_state.state {
270            MintInputStates::CreatedBundle(created) => created.notes,
271            _ => panic!("Invalid state transition"),
272        };
273
274        let mut inputs = Vec::new();
275
276        for (amount, spendable_note) in spendable_notes.clone() {
277            inputs.push(ClientInput::<MintInput> {
278                input: MintInput::new_v0(amount, spendable_note.note()),
279                keys: vec![spendable_note.spend_key],
280                amounts: Amounts::new_bitcoin(amount),
281            });
282        }
283
284        let change_range = global_context
285            .claim_inputs(
286                dbtx,
287                // We are inside an input state machine, so no need to spawn new ones
288                ClientInputBundle::new_no_sm(inputs),
289            )
290            .await
291            .expect("Cannot claim input, additional funding needed");
292
293        let refund_txid = change_range.txid();
294        MintInputStateMachine {
295            common: old_state.common,
296            state: if spendable_notes.len() == 1 {
297                // if the bundle had 1 note, we did per-note refund already
298                MintInputStates::RefundedPerNote(MintInputStateRefundedPerNote {
299                    refund_txids: vec![refund_txid],
300                })
301            } else {
302                MintInputStates::RefundedBundle(MintInputStateRefundedBundle {
303                    refund_txid,
304                    spendable_notes,
305                })
306            },
307        }
308    }
309}
310
311#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
312pub struct MintInputStateRefundedBundle {
313    pub(crate) refund_txid: TransactionId,
314    pub(crate) spendable_notes: Vec<(Amount, SpendableNote)>,
315}
316
317impl MintInputStateRefundedBundle {
318    fn transitions(
319        state: &MintInputStateRefundedBundle,
320        global_context: &DynGlobalClientContext,
321    ) -> Vec<StateTransition<MintInputStateMachine>> {
322        let global_context = global_context.clone();
323        vec![StateTransition::new(
324            Self::await_success(state.refund_txid, global_context.clone()),
325            move |dbtx, result, old_state| {
326                Box::pin(Self::transition_success(
327                    result,
328                    old_state,
329                    dbtx,
330                    global_context.clone(),
331                ))
332            },
333        )]
334    }
335
336    async fn await_success(
337        txid: TransactionId,
338        global_context: DynGlobalClientContext,
339    ) -> Result<(), String> {
340        global_context.await_tx_accepted(txid).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!(old_state.state, MintInputStates::RefundedBundle(_));
350
351        match result {
352            Ok(()) => {
353                // Success case: containing transaction is accepted
354                MintInputStateMachine {
355                    common: old_state.common,
356                    state: MintInputStates::Success(MintInputStateSuccess {}),
357                }
358            }
359            Err(err) => {
360                // Transaction rejected: attempting to refund
361                debug!(target: LOG_CLIENT_MODULE_MINT, err = %err.as_str(), "Refunding mint transaction input due to transaction error on multi-note refund");
362                Self::refund(dbtx, old_state, global_context).await
363            }
364        }
365    }
366
367    async fn refund(
368        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
369        old_state: MintInputStateMachine,
370        global_context: DynGlobalClientContext,
371    ) -> MintInputStateMachine {
372        let spendable_notes = match old_state.state {
373            MintInputStates::RefundedBundle(created) => created.spendable_notes,
374            _ => panic!("Invalid state transition"),
375        };
376
377        let mut refund_txids = vec![];
378        for (amount, spendable_note) in spendable_notes {
379            let refund_input = ClientInput::<MintInput> {
380                input: MintInput::new_v0(amount, spendable_note.note()),
381                keys: vec![spendable_note.spend_key],
382                amounts: Amounts::new_bitcoin(amount),
383            };
384            match global_context
385                .claim_inputs(
386                    dbtx,
387                    // The input of the refund tx is managed by this state machine, so no new state
388                    // machines need to be created
389                    ClientInputBundle::new_no_sm(vec![refund_input.clone()]),
390                )
391                .await
392            {
393                Ok(change_range) => {
394                    refund_txids.push(change_range.txid());
395                }
396                Err(err) => {
397                    warn!(
398                        target: LOG_CLIENT_MODULE_MINT,
399                        err = %err.fmt_compact_anyhow(),
400                        refund_input_amounts = ?refund_input.amounts,
401                        input = %refund_input.input,
402                        "Failed to remint a single note"
403                    );
404                }
405            }
406        }
407
408        MintInputStateMachine {
409            common: old_state.common,
410            state: MintInputStates::RefundedPerNote(MintInputStateRefundedPerNote { refund_txids }),
411        }
412    }
413}
414
415#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
416pub struct MintInputStateRefund {
417    pub refund_txid: TransactionId,
418}
419
420impl MintInputStateRefund {
421    fn transitions(
422        &self,
423        global_context: &DynGlobalClientContext,
424    ) -> Vec<StateTransition<MintInputStateMachine>> {
425        vec![StateTransition::new(
426            Self::await_refund_success(global_context.clone(), self.refund_txid),
427            |_dbtx, result, old_state| {
428                Box::pin(async { Self::transition_refund_success(result, old_state) })
429            },
430        )]
431    }
432
433    async fn await_refund_success(
434        global_context: DynGlobalClientContext,
435        refund_txid: TransactionId,
436    ) -> Result<(), String> {
437        global_context.await_tx_accepted(refund_txid).await
438    }
439
440    fn transition_refund_success(
441        result: Result<(), String>,
442        old_state: MintInputStateMachine,
443    ) -> MintInputStateMachine {
444        let refund_txid = match old_state.state {
445            MintInputStates::Refund(refund) => refund.refund_txid,
446            _ => panic!("Invalid state transition"),
447        };
448
449        match result {
450            Ok(()) => {
451                // Refund successful
452                MintInputStateMachine {
453                    common: old_state.common,
454                    state: MintInputStates::RefundSuccess(MintInputStateRefundSuccess {
455                        refund_txid,
456                    }),
457                }
458            }
459            Err(err) => {
460                // Refund failed
461                // TODO: include e-cash notes for recovery? Although, they are in the log …
462                warn!(target: LOG_CLIENT_MODULE_MINT, err = %err.as_str(), %refund_txid, "Refund transaction rejected. Notes probably lost.");
463                MintInputStateMachine {
464                    common: old_state.common,
465                    state: MintInputStates::Error(MintInputStateError {
466                        error: format!("Refund transaction {refund_txid} was rejected"),
467                    }),
468                }
469            }
470        }
471    }
472}
473
474#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
475pub struct MintInputStateRefundedPerNote {
476    pub refund_txids: Vec<TransactionId>,
477}
478
479#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
480pub struct MintInputStateSuccess {}
481
482#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
483pub struct MintInputStateError {
484    error: String,
485}
486
487#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
488pub struct MintInputStateRefundSuccess {
489    refund_txid: TransactionId,
490}