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