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
134impl MintOOBStatesCreated {
135    fn transitions(
136        &self,
137        operation_id: OperationId,
138        context: &MintClientContext,
139        global_context: &DynGlobalClientContext,
140    ) -> Vec<StateTransition<MintOOBStateMachine>> {
141        let user_cancel_gc = global_context.clone();
142        let timeout_cancel_gc = global_context.clone();
143        vec![
144            StateTransition::new(
145                context.await_cancel_oob_payment(operation_id),
146                move |dbtx, (), state| {
147                    Box::pin(transition_user_cancel(state, dbtx, user_cancel_gc.clone()))
148                },
149            ),
150            StateTransition::new(
151                await_timeout_cancel(self.timeout),
152                move |dbtx, (), state| {
153                    Box::pin(transition_timeout_cancel(
154                        state,
155                        dbtx,
156                        timeout_cancel_gc.clone(),
157                    ))
158                },
159            ),
160        ]
161    }
162}
163
164impl MintOOBStatesCreatedMulti {
165    fn transitions(
166        &self,
167        operation_id: OperationId,
168        context: &MintClientContext,
169        global_context: &DynGlobalClientContext,
170    ) -> Vec<StateTransition<MintOOBStateMachine>> {
171        let user_cancel_gc = global_context.clone();
172        let timeout_cancel_gc = global_context.clone();
173        vec![
174            StateTransition::new(
175                context.await_cancel_oob_payment(operation_id),
176                move |dbtx, (), state| {
177                    Box::pin(transition_user_cancel_multi(
178                        state,
179                        dbtx,
180                        user_cancel_gc.clone(),
181                    ))
182                },
183            ),
184            StateTransition::new(
185                await_timeout_cancel(self.timeout),
186                move |dbtx, (), state| {
187                    Box::pin(transition_timeout_cancel_multi(
188                        state,
189                        dbtx,
190                        timeout_cancel_gc.clone(),
191                    ))
192                },
193            ),
194        ]
195    }
196}
197
198async fn transition_user_cancel(
199    prev_state: MintOOBStateMachine,
200    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
201    global_context: DynGlobalClientContext,
202) -> MintOOBStateMachine {
203    let (amount, spendable_note) = match prev_state.state {
204        MintOOBStates::Created(created) => (created.amount, created.spendable_note),
205        _ => panic!("Invalid previous state: {prev_state:?}"),
206    };
207
208    let refund_txid = try_cancel_oob_spend(
209        dbtx,
210        prev_state.operation_id,
211        amount,
212        spendable_note,
213        global_context,
214    )
215    .await;
216    MintOOBStateMachine {
217        operation_id: prev_state.operation_id,
218        state: MintOOBStates::UserRefund(MintOOBStatesUserRefund { refund_txid }),
219    }
220}
221
222async fn transition_user_cancel_multi(
223    prev_state: MintOOBStateMachine,
224    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
225    global_context: DynGlobalClientContext,
226) -> MintOOBStateMachine {
227    let spendable_notes = match prev_state.state {
228        MintOOBStates::CreatedMulti(created) => created.spendable_notes,
229        _ => panic!("Invalid previous state: {prev_state:?}"),
230    };
231
232    let refund_txid = try_cancel_oob_spend_multi(
233        dbtx,
234        prev_state.operation_id,
235        spendable_notes.clone(),
236        global_context,
237    )
238    .await;
239    MintOOBStateMachine {
240        operation_id: prev_state.operation_id,
241        state: MintOOBStates::UserRefundMulti(MintOOBStatesUserRefundMulti {
242            refund_txid,
243            spendable_notes,
244        }),
245    }
246}
247
248async fn await_timeout_cancel(deadline: SystemTime) {
249    if let Ok(time_until_deadline) = deadline.duration_since(fedimint_core::time::now()) {
250        runtime::sleep(time_until_deadline).await;
251    }
252}
253
254async fn transition_timeout_cancel(
255    prev_state: MintOOBStateMachine,
256    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
257    global_context: DynGlobalClientContext,
258) -> MintOOBStateMachine {
259    let (amount, spendable_note) = match prev_state.state {
260        MintOOBStates::Created(created) => (created.amount, created.spendable_note),
261        _ => panic!("Invalid previous state: {prev_state:?}"),
262    };
263
264    let refund_txid = try_cancel_oob_spend(
265        dbtx,
266        prev_state.operation_id,
267        amount,
268        spendable_note,
269        global_context,
270    )
271    .await;
272    MintOOBStateMachine {
273        operation_id: prev_state.operation_id,
274        state: MintOOBStates::TimeoutRefund(MintOOBStatesTimeoutRefund { refund_txid }),
275    }
276}
277
278async fn transition_timeout_cancel_multi(
279    prev_state: MintOOBStateMachine,
280    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
281    global_context: DynGlobalClientContext,
282) -> MintOOBStateMachine {
283    let spendable_notes = match prev_state.state {
284        MintOOBStates::CreatedMulti(created) => created.spendable_notes,
285        _ => panic!("Invalid previous state: {prev_state:?}"),
286    };
287
288    let refund_txid = try_cancel_oob_spend_multi(
289        dbtx,
290        prev_state.operation_id,
291        spendable_notes,
292        global_context,
293    )
294    .await;
295    MintOOBStateMachine {
296        operation_id: prev_state.operation_id,
297        state: MintOOBStates::TimeoutRefund(MintOOBStatesTimeoutRefund { refund_txid }),
298    }
299}
300
301async fn try_cancel_oob_spend(
302    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
303    operation_id: OperationId,
304    amount: Amount,
305    spendable_note: SpendableNote,
306    global_context: DynGlobalClientContext,
307) -> TransactionId {
308    try_cancel_oob_spend_multi(
309        dbtx,
310        operation_id,
311        vec![(amount, spendable_note)],
312        global_context,
313    )
314    .await
315}
316
317async fn try_cancel_oob_spend_multi(
318    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
319    operation_id: OperationId,
320    spendable_notes: Vec<(Amount, SpendableNote)>,
321    global_context: DynGlobalClientContext,
322) -> TransactionId {
323    let inputs = spendable_notes
324        .clone()
325        .into_iter()
326        .map(|(amount, spendable_note)| ClientInput {
327            input: MintInput::new_v0(amount, spendable_note.note()),
328            keys: vec![spendable_note.spend_key],
329            amounts: Amounts::new_bitcoin(amount),
330        })
331        .collect();
332
333    let sm = ClientInputSM {
334        state_machines: Arc::new(move |out_point_range: OutPointRange| {
335            debug_assert_eq!(out_point_range.count(), spendable_notes.len());
336            vec![MintClientStateMachines::Input(MintInputStateMachine {
337                common: MintInputCommon {
338                    operation_id,
339                    out_point_range,
340                },
341                // When canceling OOB, we are reating the multi-refund input already here.
342                // So  we can cut straight to
343                // `MintInputStates::RefundedMulti` state. If the reund tx fails, it will
344                // retry using per-note refund again.
345                state: MintInputStates::RefundedBundle(MintInputStateRefundedBundle {
346                    refund_txid: out_point_range.txid(),
347                    spendable_notes: spendable_notes.clone(),
348                }),
349            })]
350        }),
351    };
352
353    global_context
354        .claim_inputs(dbtx, ClientInputBundle::new(inputs, vec![sm]))
355        .await
356        .expect("Cannot claim input, additional funding needed")
357        .txid()
358}