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