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