fedimint_mint_client/
oob.rs

1use std::sync::Arc;
2use std::time::SystemTime;
3
4use fedimint_client_module::DynGlobalClientContext;
5use fedimint_client_module::module::OutPointRange;
6use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
7use fedimint_client_module::transaction::{ClientInput, ClientInputBundle, ClientInputSM};
8use fedimint_core::core::OperationId;
9use fedimint_core::encoding::{Decodable, Encodable};
10use fedimint_core::{Amount, TransactionId, runtime};
11use fedimint_mint_common::MintInput;
12
13use crate::input::{
14    MintInputCommon, MintInputStateMachine, MintInputStateRefundedBundle, MintInputStates,
15};
16use crate::{MintClientContext, MintClientStateMachines, SpendableNote};
17
18#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
19pub enum MintOOBStatesV0 {
20    /// The e-cash has been taken out of the wallet and we are waiting for the
21    /// recipient to reissue it or the user to trigger a refund.
22    Created(MintOOBStatesCreated),
23    /// The user has triggered a refund.
24    UserRefund(MintOOBStatesUserRefund),
25    /// The timeout of this out-of-band transaction was hit and we attempted to
26    /// refund. This refund *failing* is the expected behavior since the
27    /// recipient is supposed to have already reissued it.
28    TimeoutRefund(MintOOBStatesTimeoutRefund),
29}
30
31#[cfg_attr(doc, aquamarine::aquamarine)]
32/// State machine managing e-cash that has been taken out of the wallet for
33/// out-of-band transmission.
34///
35/// ```mermaid
36/// graph LR
37///     Created -- User triggered refund --> RefundU["User Refund"]
38///     Created -- Timeout triggered refund --> RefundT["Timeout Refund"]
39/// ```
40#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
41pub enum MintOOBStates {
42    /// The e-cash has been taken out of the wallet and we are waiting for the
43    /// recipient to reissue it or the user to trigger a refund.
44    CreatedMulti(MintOOBStatesCreatedMulti),
45    /// The user has triggered a refund.
46    UserRefundMulti(MintOOBStatesUserRefundMulti),
47    /// The timeout of this out-of-band transaction was hit and we attempted to
48    /// refund. This refund *failing* is the expected behavior since the
49    /// recipient is supposed to have already reissued it.
50    TimeoutRefund(MintOOBStatesTimeoutRefund),
51
52    // States we want to drop eventually (that's why they are last)
53    // -
54    /// Obsoleted, legacy from [`MintOOBStatesV0`], like
55    /// [`MintOOBStates::CreatedMulti`] but for a single note only.
56    Created(MintOOBStatesCreated),
57    /// Obsoleted, legacy from [`MintOOBStatesV0`], like
58    /// [`MintOOBStates::UserRefundMulti`] but for single note only
59    UserRefund(MintOOBStatesUserRefund),
60}
61
62#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
63pub struct MintOOBStateMachineV0 {
64    pub(crate) operation_id: OperationId,
65    pub(crate) state: MintOOBStatesV0,
66}
67
68#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
69pub struct MintOOBStateMachine {
70    pub(crate) operation_id: OperationId,
71    pub(crate) state: MintOOBStates,
72}
73
74#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
75pub struct MintOOBStatesCreated {
76    pub(crate) amount: Amount,
77    pub(crate) spendable_note: SpendableNote,
78    pub(crate) timeout: SystemTime,
79}
80
81#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
82pub struct MintOOBStatesCreatedMulti {
83    pub(crate) spendable_notes: Vec<(Amount, SpendableNote)>,
84    pub(crate) timeout: SystemTime,
85}
86
87#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
88pub struct MintOOBStatesUserRefundMulti {
89    /// The txid we are hoping succeeds refunding all notes in one go
90    pub(crate) refund_txid: TransactionId,
91    /// The notes we are going to refund individually if it doesn't
92    pub(crate) spendable_notes: Vec<(Amount, SpendableNote)>,
93}
94
95#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
96pub struct MintOOBStatesUserRefund {
97    pub(crate) refund_txid: TransactionId,
98}
99
100#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
101pub struct MintOOBStatesTimeoutRefund {
102    pub(crate) refund_txid: TransactionId,
103}
104
105impl State for MintOOBStateMachine {
106    type ModuleContext = MintClientContext;
107
108    fn transitions(
109        &self,
110        context: &Self::ModuleContext,
111        global_context: &DynGlobalClientContext,
112    ) -> Vec<StateTransition<Self>> {
113        match &self.state {
114            MintOOBStates::Created(created) => {
115                created.transitions(self.operation_id, context, global_context)
116            }
117            MintOOBStates::CreatedMulti(created) => {
118                created.transitions(self.operation_id, context, global_context)
119            }
120            MintOOBStates::UserRefund(_)
121            | MintOOBStates::TimeoutRefund(_)
122            | MintOOBStates::UserRefundMulti(_) => {
123                vec![]
124            }
125        }
126    }
127
128    fn operation_id(&self) -> OperationId {
129        self.operation_id
130    }
131}
132
133impl MintOOBStatesCreated {
134    fn transitions(
135        &self,
136        operation_id: OperationId,
137        context: &MintClientContext,
138        global_context: &DynGlobalClientContext,
139    ) -> Vec<StateTransition<MintOOBStateMachine>> {
140        let user_cancel_gc = global_context.clone();
141        let timeout_cancel_gc = global_context.clone();
142        vec![
143            StateTransition::new(
144                context.await_cancel_oob_payment(operation_id),
145                move |dbtx, (), state| {
146                    Box::pin(transition_user_cancel(state, dbtx, user_cancel_gc.clone()))
147                },
148            ),
149            StateTransition::new(
150                await_timeout_cancel(self.timeout),
151                move |dbtx, (), state| {
152                    Box::pin(transition_timeout_cancel(
153                        state,
154                        dbtx,
155                        timeout_cancel_gc.clone(),
156                    ))
157                },
158            ),
159        ]
160    }
161}
162
163impl MintOOBStatesCreatedMulti {
164    fn transitions(
165        &self,
166        operation_id: OperationId,
167        context: &MintClientContext,
168        global_context: &DynGlobalClientContext,
169    ) -> Vec<StateTransition<MintOOBStateMachine>> {
170        let user_cancel_gc = global_context.clone();
171        let timeout_cancel_gc = global_context.clone();
172        vec![
173            StateTransition::new(
174                context.await_cancel_oob_payment(operation_id),
175                move |dbtx, (), state| {
176                    Box::pin(transition_user_cancel_multi(
177                        state,
178                        dbtx,
179                        user_cancel_gc.clone(),
180                    ))
181                },
182            ),
183            StateTransition::new(
184                await_timeout_cancel(self.timeout),
185                move |dbtx, (), state| {
186                    Box::pin(transition_timeout_cancel_multi(
187                        state,
188                        dbtx,
189                        timeout_cancel_gc.clone(),
190                    ))
191                },
192            ),
193        ]
194    }
195}
196
197async fn transition_user_cancel(
198    prev_state: MintOOBStateMachine,
199    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
200    global_context: DynGlobalClientContext,
201) -> MintOOBStateMachine {
202    let (amount, spendable_note) = match prev_state.state {
203        MintOOBStates::Created(created) => (created.amount, created.spendable_note),
204        _ => panic!("Invalid previous state: {prev_state:?}"),
205    };
206
207    let refund_txid = try_cancel_oob_spend(
208        dbtx,
209        prev_state.operation_id,
210        amount,
211        spendable_note,
212        global_context,
213    )
214    .await;
215    MintOOBStateMachine {
216        operation_id: prev_state.operation_id,
217        state: MintOOBStates::UserRefund(MintOOBStatesUserRefund { refund_txid }),
218    }
219}
220
221async fn transition_user_cancel_multi(
222    prev_state: MintOOBStateMachine,
223    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
224    global_context: DynGlobalClientContext,
225) -> MintOOBStateMachine {
226    let spendable_notes = match prev_state.state {
227        MintOOBStates::CreatedMulti(created) => created.spendable_notes,
228        _ => panic!("Invalid previous state: {prev_state:?}"),
229    };
230
231    let refund_txid = try_cancel_oob_spend_multi(
232        dbtx,
233        prev_state.operation_id,
234        spendable_notes.clone(),
235        global_context,
236    )
237    .await;
238    MintOOBStateMachine {
239        operation_id: prev_state.operation_id,
240        state: MintOOBStates::UserRefundMulti(MintOOBStatesUserRefundMulti {
241            refund_txid,
242            spendable_notes,
243        }),
244    }
245}
246
247async fn await_timeout_cancel(deadline: SystemTime) {
248    if let Ok(time_until_deadline) = deadline.duration_since(fedimint_core::time::now()) {
249        runtime::sleep(time_until_deadline).await;
250    }
251}
252
253async fn transition_timeout_cancel(
254    prev_state: MintOOBStateMachine,
255    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
256    global_context: DynGlobalClientContext,
257) -> MintOOBStateMachine {
258    let (amount, spendable_note) = match prev_state.state {
259        MintOOBStates::Created(created) => (created.amount, created.spendable_note),
260        _ => panic!("Invalid previous state: {prev_state:?}"),
261    };
262
263    let refund_txid = try_cancel_oob_spend(
264        dbtx,
265        prev_state.operation_id,
266        amount,
267        spendable_note,
268        global_context,
269    )
270    .await;
271    MintOOBStateMachine {
272        operation_id: prev_state.operation_id,
273        state: MintOOBStates::TimeoutRefund(MintOOBStatesTimeoutRefund { refund_txid }),
274    }
275}
276
277async fn transition_timeout_cancel_multi(
278    prev_state: MintOOBStateMachine,
279    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
280    global_context: DynGlobalClientContext,
281) -> MintOOBStateMachine {
282    let spendable_notes = match prev_state.state {
283        MintOOBStates::CreatedMulti(created) => created.spendable_notes,
284        _ => panic!("Invalid previous state: {prev_state:?}"),
285    };
286
287    let refund_txid = try_cancel_oob_spend_multi(
288        dbtx,
289        prev_state.operation_id,
290        spendable_notes,
291        global_context,
292    )
293    .await;
294    MintOOBStateMachine {
295        operation_id: prev_state.operation_id,
296        state: MintOOBStates::TimeoutRefund(MintOOBStatesTimeoutRefund { refund_txid }),
297    }
298}
299
300async fn try_cancel_oob_spend(
301    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
302    operation_id: OperationId,
303    amount: Amount,
304    spendable_note: SpendableNote,
305    global_context: DynGlobalClientContext,
306) -> TransactionId {
307    try_cancel_oob_spend_multi(
308        dbtx,
309        operation_id,
310        vec![(amount, spendable_note)],
311        global_context,
312    )
313    .await
314}
315
316async fn try_cancel_oob_spend_multi(
317    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
318    operation_id: OperationId,
319    spendable_notes: Vec<(Amount, SpendableNote)>,
320    global_context: DynGlobalClientContext,
321) -> TransactionId {
322    let inputs = spendable_notes
323        .clone()
324        .into_iter()
325        .map(|(amount, spendable_note)| ClientInput {
326            input: MintInput::new_v0(amount, spendable_note.note()),
327            keys: vec![spendable_note.spend_key],
328            amount,
329        })
330        .collect();
331
332    let sm = ClientInputSM {
333        state_machines: Arc::new(move |out_point_range: OutPointRange| {
334            debug_assert_eq!(out_point_range.count(), spendable_notes.len());
335            vec![MintClientStateMachines::Input(MintInputStateMachine {
336                common: MintInputCommon {
337                    operation_id,
338                    out_point_range,
339                },
340                // When canceling OOB, we are reating the multi-refund input already here.
341                // So  we can cut straight to
342                // `MintInputStates::RefundedMulti` state. If the reund tx fails, it will
343                // retry using per-note refund again.
344                state: MintInputStates::RefundedBundle(MintInputStateRefundedBundle {
345                    refund_txid: out_point_range.txid(),
346                    spendable_notes: spendable_notes.clone(),
347                }),
348            })]
349        }),
350    };
351
352    global_context
353        .claim_inputs(dbtx, ClientInputBundle::new(inputs, vec![sm]))
354        .await
355        .expect("Cannot claim input, additional funding needed")
356        .txid()
357}