Skip to main content

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