1use assert_matches::assert_matches;
2use fedimint_client_module::DynGlobalClientContext;
3use fedimint_client_module::module::OutPointRange;
4use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
5use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
6use fedimint_core::core::OperationId;
7use fedimint_core::encoding::{Decodable, Encodable};
8use fedimint_core::module::Amounts;
9use fedimint_core::util::FmtCompact;
10use fedimint_core::{Amount, TransactionId};
11use fedimint_logging::LOG_CLIENT_MODULE_MINT;
12use fedimint_mint_common::MintInput;
13use tracing::{debug, warn};
14
15use crate::{MintClientContext, SpendableNote};
16
17#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
31pub enum MintInputStates {
32 #[deprecated(note = "Use CreateMulti instead")]
33 Created(MintInputStateCreated),
34 Refund(MintInputStateRefund),
35 Success(MintInputStateSuccess),
36 Error(MintInputStateError),
37 RefundSuccess(MintInputStateRefundSuccess),
38 CreatedBundle(MintInputStateCreatedBundle),
43 RefundedBundle(MintInputStateRefundedBundle),
46 RefundedPerNote(MintInputStateRefundedPerNote),
48}
49
50#[derive(Debug, Clone, Eq, Hash, PartialEq, Decodable, Encodable)]
51pub struct MintInputCommonV0 {
52 pub(crate) operation_id: OperationId,
53 pub(crate) txid: TransactionId,
54 pub(crate) input_idx: u64,
55}
56
57#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Decodable, Encodable)]
58pub struct MintInputCommon {
59 pub(crate) operation_id: OperationId,
60 pub(crate) out_point_range: OutPointRange,
61}
62
63#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
64pub struct MintInputStateMachineV0 {
65 pub(crate) common: MintInputCommonV0,
66 pub(crate) state: MintInputStates,
67}
68
69#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
70pub struct MintInputStateMachine {
71 pub(crate) common: MintInputCommon,
72 pub(crate) state: MintInputStates,
73}
74
75impl State for MintInputStateMachine {
76 type ModuleContext = MintClientContext;
77
78 #[allow(deprecated)]
79 fn transitions(
80 &self,
81 _context: &Self::ModuleContext,
82 global_context: &DynGlobalClientContext,
83 ) -> Vec<StateTransition<Self>> {
84 match &self.state {
85 MintInputStates::Created(_) => {
86 MintInputStateCreated::transitions(self.common, global_context)
87 }
88 MintInputStates::CreatedBundle(_) => {
89 MintInputStateCreatedBundle::transitions(self.common, global_context)
90 }
91 MintInputStates::RefundedBundle(state) => {
92 MintInputStateRefundedBundle::transitions(state, global_context)
93 }
94 MintInputStates::Refund(refund) => refund.transitions(global_context),
95 MintInputStates::Success(_)
96 | MintInputStates::Error(_)
97 | MintInputStates::RefundSuccess(_)
98 | MintInputStates::RefundedPerNote(_) => {
101 vec![]
102 }
103 }
104 }
105
106 fn operation_id(&self) -> OperationId {
107 self.common.operation_id
108 }
109
110 fn fmt_visualization(&self, f: &mut dyn std::fmt::Write, indent: &str) -> std::fmt::Result {
111 let txid = self.common.out_point_range.txid();
112 let start = self.common.out_point_range.start_idx();
113 let count = self.common.out_point_range.count();
114 match &self.state {
115 #[allow(deprecated)]
116 MintInputStates::Created(c) => {
117 write!(
118 f,
119 "{indent}MintInputStateMachine\n{indent} state: Created tx={}:[{start},{end}) amount={}",
120 txid.fmt_short(),
121 c.amount,
122 end = start + count as u64,
123 )
124 }
125 MintInputStates::CreatedBundle(c) => {
126 let total: Amount = c.notes.iter().map(|(a, _)| *a).sum();
127 write!(
128 f,
129 "{indent}MintInputStateMachine\n{indent} state: CreatedBundle tx={}:[{start},{end}) notes={}, total={total}",
130 txid.fmt_short(),
131 c.notes.len(),
132 end = start + count as u64,
133 )
134 }
135 MintInputStates::Success(_) => {
136 write!(f, "{indent}MintInputStateMachine\n{indent} state: Success",)
137 }
138 MintInputStates::Refund(r) => {
139 write!(
140 f,
141 "{indent}MintInputStateMachine\n{indent} state: Refund refund_txid={}",
142 r.refund_txid.fmt_short(),
143 )
144 }
145 MintInputStates::RefundedBundle(r) => {
146 let total: Amount = r.spendable_notes.iter().map(|(a, _)| *a).sum();
147 write!(
148 f,
149 "{indent}MintInputStateMachine\n{indent} state: RefundedBundle refund_txid={} notes={}, total={total}",
150 r.refund_txid.fmt_short(),
151 r.spendable_notes.len(),
152 )
153 }
154 MintInputStates::RefundedPerNote(r) => {
155 write!(
156 f,
157 "{indent}MintInputStateMachine\n{indent} state: RefundedPerNote txids={}",
158 r.refund_txids.len(),
159 )
160 }
161 MintInputStates::RefundSuccess(r) => {
162 write!(
163 f,
164 "{indent}MintInputStateMachine\n{indent} state: RefundSuccess refund_txid={}",
165 r.refund_txid.fmt_short(),
166 )
167 }
168 MintInputStates::Error(e) => {
169 write!(
170 f,
171 "{indent}MintInputStateMachine\n{indent} state: Error error={}",
172 e.error,
173 )
174 }
175 }
176 }
177}
178
179#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
180pub struct MintInputStateCreated {
181 pub(crate) amount: Amount,
182 pub(crate) spendable_note: SpendableNote,
183}
184
185impl MintInputStateCreated {
186 fn transitions(
187 common: MintInputCommon,
188 global_context: &DynGlobalClientContext,
189 ) -> Vec<StateTransition<MintInputStateMachine>> {
190 let global_context = global_context.clone();
191 vec![StateTransition::new(
192 Self::await_success(common, global_context.clone()),
193 move |dbtx, result, old_state| {
194 Box::pin(Self::transition_success(
195 result,
196 old_state,
197 dbtx,
198 global_context.clone(),
199 ))
200 },
201 )]
202 }
203
204 async fn await_success(
205 common: MintInputCommon,
206 global_context: DynGlobalClientContext,
207 ) -> Result<(), String> {
208 global_context
209 .await_tx_accepted(common.out_point_range.txid())
210 .await
211 }
212
213 #[allow(deprecated)]
214 async fn transition_success(
215 result: Result<(), String>,
216 old_state: MintInputStateMachine,
217 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
218 global_context: DynGlobalClientContext,
219 ) -> MintInputStateMachine {
220 assert_matches!(old_state.state, MintInputStates::Created(_));
221
222 match result {
223 Ok(()) => {
224 MintInputStateMachine {
226 common: old_state.common,
227 state: MintInputStates::Success(MintInputStateSuccess {}),
228 }
229 }
230 Err(err) => {
231 debug!(target: LOG_CLIENT_MODULE_MINT, err = %err.as_str(), "Refunding mint transaction input due to transaction error");
233 Self::refund(dbtx, old_state, global_context).await
234 }
235 }
236 }
237
238 #[allow(deprecated)]
239 async fn refund(
240 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
241 old_state: MintInputStateMachine,
242 global_context: DynGlobalClientContext,
243 ) -> MintInputStateMachine {
244 let (amount, spendable_note) = match old_state.state {
245 MintInputStates::Created(created) => (created.amount, created.spendable_note),
246 _ => panic!("Invalid state transition"),
247 };
248
249 let refund_input = ClientInput::<MintInput> {
250 input: MintInput::new_v0(amount, spendable_note.note()),
251 keys: vec![spendable_note.spend_key],
252 amounts: Amounts::new_bitcoin(amount),
253 };
254
255 let change_range = global_context
256 .claim_inputs(
257 dbtx,
258 ClientInputBundle::new_no_sm(vec![refund_input]),
261 )
262 .await
263 .expect("Cannot claim input, additional funding needed");
264
265 MintInputStateMachine {
266 common: old_state.common,
267 state: MintInputStates::Refund(MintInputStateRefund {
268 refund_txid: change_range.txid(),
269 }),
270 }
271 }
272}
273
274#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
275pub struct MintInputStateCreatedBundle {
276 pub(crate) notes: Vec<(Amount, SpendableNote)>,
277}
278
279impl MintInputStateCreatedBundle {
280 fn transitions(
281 common: MintInputCommon,
282 global_context: &DynGlobalClientContext,
283 ) -> Vec<StateTransition<MintInputStateMachine>> {
284 let global_context = global_context.clone();
285 vec![StateTransition::new(
286 Self::await_success(common, global_context.clone()),
287 move |dbtx, result, old_state| {
288 Box::pin(Self::transition_success(
289 result,
290 old_state,
291 dbtx,
292 global_context.clone(),
293 ))
294 },
295 )]
296 }
297
298 async fn await_success(
299 common: MintInputCommon,
300 global_context: DynGlobalClientContext,
301 ) -> Result<(), String> {
302 global_context
303 .await_tx_accepted(common.out_point_range.txid())
304 .await
305 }
306
307 async fn transition_success(
308 result: Result<(), String>,
309 old_state: MintInputStateMachine,
310 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
311 global_context: DynGlobalClientContext,
312 ) -> MintInputStateMachine {
313 assert_matches!(old_state.state, MintInputStates::CreatedBundle(_));
314
315 match result {
316 Ok(()) => {
317 MintInputStateMachine {
319 common: old_state.common,
320 state: MintInputStates::Success(MintInputStateSuccess {}),
321 }
322 }
323 Err(err) => {
324 debug!(target: LOG_CLIENT_MODULE_MINT, err = %err.as_str(), "Refunding mint transaction input due to transaction error");
326 Self::refund(dbtx, old_state, global_context).await
327 }
328 }
329 }
330
331 async fn refund(
332 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
333 old_state: MintInputStateMachine,
334 global_context: DynGlobalClientContext,
335 ) -> MintInputStateMachine {
336 let spendable_notes = match old_state.state {
337 MintInputStates::CreatedBundle(created) => created.notes,
338 _ => panic!("Invalid state transition"),
339 };
340
341 let mut inputs = Vec::new();
342
343 for (amount, spendable_note) in spendable_notes.clone() {
344 inputs.push(ClientInput::<MintInput> {
345 input: MintInput::new_v0(amount, spendable_note.note()),
346 keys: vec![spendable_note.spend_key],
347 amounts: Amounts::new_bitcoin(amount),
348 });
349 }
350
351 let change_range = global_context
352 .claim_inputs(
353 dbtx,
354 ClientInputBundle::new_no_sm(inputs),
356 )
357 .await
358 .expect("Cannot claim input, additional funding needed");
359
360 let refund_txid = change_range.txid();
361 MintInputStateMachine {
362 common: old_state.common,
363 state: if spendable_notes.len() == 1 {
364 MintInputStates::RefundedPerNote(MintInputStateRefundedPerNote {
366 refund_txids: vec![refund_txid],
367 })
368 } else {
369 MintInputStates::RefundedBundle(MintInputStateRefundedBundle {
370 refund_txid,
371 spendable_notes,
372 })
373 },
374 }
375 }
376}
377
378#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
379pub struct MintInputStateRefundedBundle {
380 pub(crate) refund_txid: TransactionId,
381 pub(crate) spendable_notes: Vec<(Amount, SpendableNote)>,
382}
383
384impl MintInputStateRefundedBundle {
385 fn transitions(
386 state: &MintInputStateRefundedBundle,
387 global_context: &DynGlobalClientContext,
388 ) -> Vec<StateTransition<MintInputStateMachine>> {
389 let global_context = global_context.clone();
390 vec![StateTransition::new(
391 Self::await_success(state.refund_txid, global_context.clone()),
392 move |dbtx, result, old_state| {
393 Box::pin(Self::transition_success(
394 result,
395 old_state,
396 dbtx,
397 global_context.clone(),
398 ))
399 },
400 )]
401 }
402
403 async fn await_success(
404 txid: TransactionId,
405 global_context: DynGlobalClientContext,
406 ) -> Result<(), String> {
407 global_context.await_tx_accepted(txid).await
408 }
409
410 async fn transition_success(
411 result: Result<(), String>,
412 old_state: MintInputStateMachine,
413 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
414 global_context: DynGlobalClientContext,
415 ) -> MintInputStateMachine {
416 assert_matches!(old_state.state, MintInputStates::RefundedBundle(_));
417
418 match result {
419 Ok(()) => {
420 MintInputStateMachine {
422 common: old_state.common,
423 state: MintInputStates::Success(MintInputStateSuccess {}),
424 }
425 }
426 Err(err) => {
427 debug!(target: LOG_CLIENT_MODULE_MINT, err = %err.as_str(), "Refunding mint transaction input due to transaction error on multi-note refund");
429 Self::refund(dbtx, old_state, global_context).await
430 }
431 }
432 }
433
434 async fn refund(
435 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
436 old_state: MintInputStateMachine,
437 global_context: DynGlobalClientContext,
438 ) -> MintInputStateMachine {
439 let spendable_notes = match old_state.state {
440 MintInputStates::RefundedBundle(created) => created.spendable_notes,
441 _ => panic!("Invalid state transition"),
442 };
443
444 let mut refund_txids = vec![];
445 for (amount, spendable_note) in spendable_notes {
446 let refund_input = ClientInput::<MintInput> {
447 input: MintInput::new_v0(amount, spendable_note.note()),
448 keys: vec![spendable_note.spend_key],
449 amounts: Amounts::new_bitcoin(amount),
450 };
451 match global_context
452 .claim_inputs(
453 dbtx,
454 ClientInputBundle::new_no_sm(vec![refund_input.clone()]),
457 )
458 .await
459 {
460 Ok(change_range) => {
461 refund_txids.push(change_range.txid());
462 }
463 Err(err) => {
464 warn!(
465 target: LOG_CLIENT_MODULE_MINT,
466 err = %err.fmt_compact(),
467 refund_input_amounts = ?refund_input.amounts,
468 input = %refund_input.input,
469 "Failed to remint a single note"
470 );
471 }
472 }
473 }
474
475 MintInputStateMachine {
476 common: old_state.common,
477 state: MintInputStates::RefundedPerNote(MintInputStateRefundedPerNote { refund_txids }),
478 }
479 }
480}
481
482#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
483pub struct MintInputStateRefund {
484 pub refund_txid: TransactionId,
485}
486
487impl MintInputStateRefund {
488 fn transitions(
489 &self,
490 global_context: &DynGlobalClientContext,
491 ) -> Vec<StateTransition<MintInputStateMachine>> {
492 vec![StateTransition::new(
493 Self::await_refund_success(global_context.clone(), self.refund_txid),
494 |_dbtx, result, old_state| {
495 Box::pin(async { Self::transition_refund_success(result, old_state) })
496 },
497 )]
498 }
499
500 async fn await_refund_success(
501 global_context: DynGlobalClientContext,
502 refund_txid: TransactionId,
503 ) -> Result<(), String> {
504 global_context.await_tx_accepted(refund_txid).await
505 }
506
507 fn transition_refund_success(
508 result: Result<(), String>,
509 old_state: MintInputStateMachine,
510 ) -> MintInputStateMachine {
511 let refund_txid = match old_state.state {
512 MintInputStates::Refund(refund) => refund.refund_txid,
513 _ => panic!("Invalid state transition"),
514 };
515
516 match result {
517 Ok(()) => {
518 MintInputStateMachine {
520 common: old_state.common,
521 state: MintInputStates::RefundSuccess(MintInputStateRefundSuccess {
522 refund_txid,
523 }),
524 }
525 }
526 Err(err) => {
527 warn!(target: LOG_CLIENT_MODULE_MINT, err = %err.as_str(), %refund_txid, "Refund transaction rejected. Notes probably lost.");
530 MintInputStateMachine {
531 common: old_state.common,
532 state: MintInputStates::Error(MintInputStateError {
533 error: format!("Refund transaction {refund_txid} was rejected"),
534 }),
535 }
536 }
537 }
538 }
539}
540
541#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
542pub struct MintInputStateRefundedPerNote {
543 pub refund_txids: Vec<TransactionId>,
544}
545
546#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
547pub struct MintInputStateSuccess {}
548
549#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
550pub struct MintInputStateError {
551 error: String,
552}
553
554#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
555pub struct MintInputStateRefundSuccess {
556 refund_txid: TransactionId,
557}