1use std::cmp::max;
2use std::collections::BTreeMap;
3use std::fmt;
4use std::ops::Add;
5
6use fedimint_client_module::module::init::ClientModuleRecoverArgs;
7use fedimint_client_module::module::init::recovery::{
8 RecoveryFromHistory, RecoveryFromHistoryCommon,
9};
10use fedimint_client_module::module::{ClientContext, OutPointRange};
11use fedimint_core::bitcoin::hashes::hash160;
12use fedimint_core::core::OperationId;
13use fedimint_core::db::{DatabaseTransaction, IDatabaseTransactionOpsCoreTyped as _};
14use fedimint_core::encoding::{Decodable, Encodable};
15use fedimint_core::secp256k1::SECP256K1;
16use fedimint_core::{
17 Amount, NumPeersExt, OutPoint, PeerId, Tiered, TieredMulti, apply, async_trait_maybe_send,
18};
19use fedimint_derive_secret::DerivableSecret;
20use fedimint_logging::{LOG_CLIENT_MODULE_MINT, LOG_CLIENT_RECOVERY, LOG_CLIENT_RECOVERY_MINT};
21use fedimint_mint_common::{MintInput, MintOutput, Nonce};
22use serde::{Deserialize, Serialize};
23use tbs::{AggregatePublicKey, BlindedMessage, PublicKeyShare};
24use threshold_crypto::G1Affine;
25use tracing::{debug, info, trace, warn};
26
27use super::EcashBackup;
28use crate::backup::EcashBackupV0;
29use crate::client_db::{
30 NextECashNoteIndexKey, NoteKey, RecoveryFinalizedKey, RecoveryStateKey, ReusedNoteIndices,
31};
32use crate::events::NoteCreated;
33use crate::output::{
34 MintOutputCommon, MintOutputStateMachine, MintOutputStatesCreated, NoteIssuanceRequest,
35};
36use crate::{MintClientInit, MintClientModule, MintClientStateMachines, NoteIndex, SpendableNote};
37
38#[derive(Clone, Debug)]
39pub struct MintRecovery {
40 state: MintRecoveryStateV2,
41 secret: DerivableSecret,
42 client_ctx: ClientContext<MintClientModule>,
43}
44
45#[apply(async_trait_maybe_send!)]
46impl RecoveryFromHistory for MintRecovery {
47 type Init = MintClientInit;
48
49 async fn new(
50 _init: &Self::Init,
51 args: &ClientModuleRecoverArgs<Self::Init>,
52 snapshot: Option<&EcashBackup>,
53 ) -> anyhow::Result<(Self, u64)> {
54 let snapshot_v0 = match snapshot {
55 Some(EcashBackup::V0(snapshot_v0)) => Some(snapshot_v0),
56 Some(EcashBackup::Default { variant, .. }) => {
57 warn!(%variant, "Unsupported backup variant. Ignoring mint backup.");
58 None
59 }
60 None => None,
61 };
62
63 let config = args.cfg();
64
65 let secret = args.module_root_secret().clone();
66 let (snapshot, starting_session) = if let Some(snapshot) = snapshot_v0 {
67 (snapshot.clone(), snapshot.session_count)
68 } else {
69 (EcashBackupV0::new_empty(), 0)
70 };
71
72 Ok((
73 MintRecovery {
74 state: MintRecoveryStateV2::from_backup(
75 snapshot,
76 100,
77 config.tbs_pks.clone(),
78 config.peer_tbs_pks.clone(),
79 &secret,
80 ),
81 secret,
82 client_ctx: args.context(),
83 },
84 starting_session,
85 ))
86 }
87
88 async fn load_dbtx(
89 _init: &Self::Init,
90 dbtx: &mut DatabaseTransaction<'_>,
91 args: &ClientModuleRecoverArgs<Self::Init>,
92 ) -> anyhow::Result<Option<(Self, RecoveryFromHistoryCommon)>> {
93 dbtx.ensure_isolated()
94 .expect("Must be in prefixed database");
95 Ok(dbtx
96 .get_value(&RecoveryStateKey)
97 .await
98 .and_then(|(state, common)| {
99 if let MintRecoveryState::V2(state) = state {
100 Some((state, common))
101 } else {
102 warn!(target: LOG_CLIENT_RECOVERY, "Found unknown version recovery state. Ignoring");
103 None
104 }
105 })
106 .map(|(state, common)| {
107 (
108 MintRecovery {
109 state,
110 secret: args.module_root_secret().clone(),
111 client_ctx: args.context(),
112 },
113 common,
114 )
115 }))
116 }
117
118 async fn store_dbtx(
119 &self,
120 dbtx: &mut DatabaseTransaction<'_>,
121 common: &RecoveryFromHistoryCommon,
122 ) {
123 dbtx.ensure_isolated()
124 .expect("Must be in prefixed database");
125 dbtx.insert_entry(
126 &RecoveryStateKey,
127 &(MintRecoveryState::V2(self.state.clone()), common.clone()),
128 )
129 .await;
130 }
131
132 async fn delete_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) {
133 dbtx.remove_entry(&RecoveryStateKey).await;
134 }
135
136 async fn load_finalized(dbtx: &mut DatabaseTransaction<'_>) -> Option<bool> {
137 dbtx.get_value(&RecoveryFinalizedKey).await
138 }
139
140 async fn store_finalized(dbtx: &mut DatabaseTransaction<'_>, state: bool) {
141 dbtx.insert_entry(&RecoveryFinalizedKey, &state).await;
142 }
143
144 async fn handle_input(
145 &mut self,
146 _client_ctx: &ClientContext<MintClientModule>,
147 _idx: usize,
148 input: &MintInput,
149 _session_idx: u64,
150 ) -> anyhow::Result<()> {
151 self.state.handle_input(input);
152 Ok(())
153 }
154
155 async fn handle_output(
156 &mut self,
157 _client_ctx: &ClientContext<MintClientModule>,
158 out_point: OutPoint,
159 output: &MintOutput,
160 _session_idx: u64,
161 ) -> anyhow::Result<()> {
162 self.state.handle_output(out_point, output, &self.secret);
163 Ok(())
164 }
165
166 async fn finalize_dbtx(
168 &self,
169 dbtx: &mut DatabaseTransaction<'_>,
170 ) -> anyhow::Result<Option<Amount>> {
171 let finalized = self.state.clone().finalize();
172
173 let restored_amount = finalized
174 .unconfirmed_notes
175 .iter()
176 .map(|entry| entry.1)
177 .sum::<Amount>()
178 + finalized.spendable_notes.total_amount();
179
180 info!(
181 amount = %restored_amount,
182 burned_total = %finalized.burned_total,
183 "Finalizing mint recovery"
184 );
185
186 dbtx.insert_new_entry(&ReusedNoteIndices, &finalized.reused_note_indices)
187 .await;
188 debug!(
189 target: LOG_CLIENT_RECOVERY_MINT,
190 len = finalized.spendable_notes.count_items(),
191 "Restoring spendable notes"
192 );
193 for (amount, note) in finalized.spendable_notes.into_iter_items() {
194 let key = NoteKey {
195 amount,
196 nonce: note.nonce(),
197 };
198 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Restoring note");
199 self.client_ctx
200 .log_event(
201 dbtx,
202 NoteCreated {
203 nonce: note.nonce(),
204 },
205 )
206 .await;
207 dbtx.insert_new_entry(&key, ¬e.to_undecoded()).await;
208 }
209
210 for (amount, note_idx) in finalized.next_note_idx.iter() {
211 debug!(
212 target: LOG_CLIENT_RECOVERY_MINT,
213 %amount,
214 %note_idx,
215 "Restoring NextECashNodeIndex"
216 );
217 dbtx.insert_entry(&NextECashNoteIndexKey(amount), ¬e_idx.as_u64())
218 .await;
219 }
220
221 debug!(
222 target: LOG_CLIENT_RECOVERY_MINT,
223 len = finalized.unconfirmed_notes.len(),
224 "Restoring unconfirmed notes state machines"
225 );
226
227 for (out_point, amount, issuance_request) in finalized.unconfirmed_notes {
228 self.client_ctx
229 .add_state_machines_dbtx(
230 dbtx,
231 self.client_ctx
232 .map_dyn(vec![MintClientStateMachines::Output(
233 MintOutputStateMachine {
234 common: MintOutputCommon {
235 operation_id: OperationId::new_random(),
236 out_point_range: OutPointRange::new_single(
237 out_point.txid,
238 out_point.out_idx,
239 )
240 .expect("Can't overflow"),
241 },
242 state: crate::output::MintOutputStates::Created(
243 MintOutputStatesCreated {
244 amount,
245 issuance_request,
246 },
247 ),
248 },
249 )])
250 .collect(),
251 )
252 .await?;
253 }
254
255 debug!(
256 target: LOG_CLIENT_RECOVERY_MINT,
257 "Mint module recovery finalized"
258 );
259
260 Ok(Some(restored_amount))
261 }
262}
263
264#[derive(Debug, Clone)]
265pub struct EcashRecoveryFinalState {
266 pub spendable_notes: TieredMulti<SpendableNote>,
267 pub unconfirmed_notes: Vec<(OutPoint, Amount, NoteIssuanceRequest)>,
269 pub next_note_idx: Tiered<NoteIndex>,
271 pub burned_total: Amount,
273 pub reused_note_indices: Vec<(Amount, NoteIndex)>,
275}
276
277#[derive(
279 Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Decodable, Encodable, Serialize, Deserialize,
280)]
281struct CompressedBlindedMessage(#[serde(with = "serde_big_array::BigArray")] [u8; 48]);
282
283impl From<BlindedMessage> for CompressedBlindedMessage {
284 fn from(value: BlindedMessage) -> Self {
285 Self(value.0.to_compressed())
286 }
287}
288
289impl From<CompressedBlindedMessage> for BlindedMessage {
290 fn from(value: CompressedBlindedMessage) -> Self {
291 BlindedMessage(
292 std::convert::Into::<Option<G1Affine>>::into(G1Affine::from_compressed(&value.0))
293 .expect("We never produce invalid compressed blinded messages"),
294 )
295 }
296}
297
298#[allow(clippy::large_enum_variant)]
299#[derive(Debug, Clone, Decodable, Encodable)]
300pub enum MintRecoveryState {
301 #[encodable(index = 2)]
302 V2(MintRecoveryStateV2),
303 #[encodable_default]
305 Default { variant: u64, bytes: Vec<u8> },
306}
307
308#[derive(Clone, Eq, PartialEq, Decodable, Encodable, Serialize, Deserialize)]
316pub struct MintRecoveryStateV2 {
317 spendable_notes: BTreeMap<Nonce, (Amount, SpendableNote)>,
318 pending_outputs: BTreeMap<Nonce, (OutPoint, Amount, NoteIssuanceRequest)>,
320 pending_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
327 used_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
330 reused_note_indices: Vec<(Amount, NoteIndex)>,
332 burned_total: Amount,
334 next_pending_note_idx: Tiered<NoteIndex>,
337 last_used_nonce_idx: Tiered<NoteIndex>,
343 threshold: u64,
345 pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
349 tbs_pks: Tiered<AggregatePublicKey>,
351 gap_limit: u64,
354}
355
356impl fmt::Debug for MintRecoveryStateV2 {
357 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358 f.write_fmt(format_args!(
359 "MintRestoreInProgressState(pending_outputs: {}, pending_nonces: {}, used_nonces: {}, burned_total: {})",
360 self.pending_outputs.len(),
361 self.pending_nonces.len(),
362 self.used_nonces.len(),
363 self.burned_total,
364 ))
365 }
366}
367
368impl MintRecoveryStateV2 {
369 pub fn from_backup(
370 backup: EcashBackupV0,
371 gap_limit: u64,
372 tbs_pks: Tiered<AggregatePublicKey>,
373 pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
374 secret: &DerivableSecret,
375 ) -> Self {
376 let amount_tiers: Vec<_> = tbs_pks.tiers().copied().collect();
377 let mut s = Self {
378 spendable_notes: backup
379 .spendable_notes
380 .into_iter_items()
381 .map(|(amount, note)| (note.nonce(), (amount, note)))
382 .collect(),
383 pending_outputs: backup
384 .pending_notes
385 .into_iter()
386 .map(|(outpoint, amount, issuance_request)| {
387 (
388 issuance_request.nonce(),
389 (outpoint, amount, issuance_request),
390 )
391 })
392 .collect(),
393 reused_note_indices: Vec::new(),
394 pending_nonces: BTreeMap::default(),
395 used_nonces: BTreeMap::default(),
396 burned_total: Amount::ZERO,
397 next_pending_note_idx: backup.next_note_idx.clone(),
398 last_used_nonce_idx: backup
399 .next_note_idx
400 .into_iter()
401 .filter_map(|(a, idx)| idx.prev().map(|idx| (a, idx)))
402 .collect(),
403 threshold: pub_key_shares.to_num_peers().threshold() as u64,
404 gap_limit,
405 tbs_pks,
406 pub_key_shares,
407 };
408
409 for amount in amount_tiers {
410 s.fill_initial_pending_nonces(amount, secret);
411 }
412
413 s
414 }
415
416 fn fill_initial_pending_nonces(&mut self, amount: Amount, secret: &DerivableSecret) {
418 for _ in 0..self.gap_limit {
419 self.add_next_pending_nonce_in_pending_pool(amount, secret);
420 }
421 }
422
423 fn add_next_pending_nonce_in_pending_pool(&mut self, amount: Amount, secret: &DerivableSecret) {
425 let note_idx_ref = self.next_pending_note_idx.get_mut_or_default(amount);
426
427 let (note_issuance_request, blind_nonce) = NoteIssuanceRequest::new(
428 fedimint_core::secp256k1::SECP256K1,
429 &MintClientModule::new_note_secret_static(secret, amount, *note_idx_ref),
430 );
431 assert!(
432 self.pending_nonces
433 .insert(
434 blind_nonce.0.into(),
435 (note_issuance_request, *note_idx_ref, amount)
436 )
437 .is_none()
438 );
439
440 note_idx_ref.advance();
441 }
442
443 pub fn handle_input(&mut self, input: &MintInput) {
444 match input {
445 MintInput::V0(input) => {
446 self.pending_outputs.remove(&input.note.nonce);
448 self.spendable_notes.remove(&input.note.nonce);
449 }
450 MintInput::Default { variant, .. } => {
451 trace!("Ignoring future mint input variant {variant}");
452 }
453 }
454 }
455
456 pub fn handle_output(
457 &mut self,
458 out_point: OutPoint,
459 output: &MintOutput,
460 secret: &DerivableSecret,
461 ) {
462 let output = match output {
463 MintOutput::V0(output) => output,
464 MintOutput::Default { variant, .. } => {
465 trace!("Ignoring future mint output variant {variant}");
466 return;
467 }
468 };
469
470 if let Some((_issuance_request, note_idx, amount)) =
471 self.used_nonces.get(&output.blind_nonce.0.into())
472 {
473 self.burned_total += *amount;
474 self.reused_note_indices.push((*amount, *note_idx));
475 warn!(
476 target: LOG_CLIENT_RECOVERY_MINT,
477 %note_idx,
478 %amount,
479 burned_total = %self.burned_total,
480 "Detected reused nonce during recovery. This means client probably burned funds in the past."
481 );
482 }
483 if let Some((issuance_request, note_idx, pending_amount)) =
498 self.pending_nonces.remove(&output.blind_nonce.0.into())
499 {
500 self.observe_nonce_idx_being_used(pending_amount, note_idx, secret);
504
505 if pending_amount == output.amount {
506 self.used_nonces.insert(
507 output.blind_nonce.0.into(),
508 (issuance_request, note_idx, pending_amount),
509 );
510
511 self.pending_outputs.insert(
512 issuance_request.nonce(),
513 (out_point, output.amount, issuance_request),
514 );
515 } else {
516 self.pending_nonces.insert(
518 output.blind_nonce.0.into(),
519 (issuance_request, note_idx, pending_amount),
520 );
521 warn!(
522 target: LOG_CLIENT_RECOVERY_MINT,
523 output = ?out_point,
524 blind_nonce = ?output.blind_nonce.0,
525 expected_amount = %pending_amount,
526 found_amount = %output.amount,
527 "Transaction output contains blind nonce that looks like ours but is of the wrong amount. Ignoring."
528 );
529 }
530 }
531 }
532
533 fn observe_nonce_idx_being_used(
540 &mut self,
541 amount: Amount,
542 note_idx: NoteIndex,
543 secret: &DerivableSecret,
544 ) {
545 self.last_used_nonce_idx.insert(
546 amount,
547 max(
548 self.last_used_nonce_idx
549 .get(amount)
550 .copied()
551 .unwrap_or_default(),
552 note_idx,
553 ),
554 );
555
556 while self.next_pending_note_idx.get_mut_or_default(amount).0
557 < self.gap_limit
558 + self
559 .last_used_nonce_idx
560 .get(amount)
561 .expect("must be there already")
562 .0
563 {
564 self.add_next_pending_nonce_in_pending_pool(amount, secret);
565 }
566 }
567
568 pub fn finalize(self) -> EcashRecoveryFinalState {
569 EcashRecoveryFinalState {
570 spendable_notes: self.spendable_notes.into_values().collect(),
571 unconfirmed_notes: self.pending_outputs.into_values().collect(),
572 next_note_idx: self
574 .last_used_nonce_idx
575 .iter()
576 .map(|(amount, value)| (amount, value.next()))
577 .collect(),
578 reused_note_indices: self.reused_note_indices,
579 burned_total: self.burned_total,
580 }
581 }
582}
583
584const GAP_LIMIT: u64 = 100;
585
586#[derive(Clone, Debug, Encodable, Decodable)]
588pub struct RecoveryStateV2 {
589 pub next_index: u64,
591 pub total_items: u64,
593 pending_outputs: BTreeMap<hash160::Hash, (Amount, NoteIssuanceRequest)>,
595 pending_nonces: BTreeMap<(Amount, hash160::Hash), (NoteIssuanceRequest, u64)>,
597 next_pending_note_idx: BTreeMap<Amount, u64>,
600 last_used_nonce_idx: BTreeMap<Amount, u64>,
603}
604
605impl RecoveryStateV2 {
606 pub fn new(total_items: u64, amount_tiers: Vec<Amount>, secret: &DerivableSecret) -> Self {
607 let mut state = Self {
608 next_index: 0,
609 total_items,
610 pending_outputs: BTreeMap::default(),
611 pending_nonces: BTreeMap::default(),
612 next_pending_note_idx: BTreeMap::default(),
613 last_used_nonce_idx: BTreeMap::default(),
614 };
615
616 for amount in amount_tiers {
617 state.add_pending_nonces(amount, GAP_LIMIT, secret);
618 }
619
620 state
621 }
622
623 fn add_pending_nonces(&mut self, amount: Amount, count: u64, secret: &DerivableSecret) {
624 let next_idx = self
625 .next_pending_note_idx
626 .get(&amount)
627 .copied()
628 .unwrap_or_default();
629
630 self.next_pending_note_idx.insert(amount, next_idx + count);
631
632 for i in next_idx..(next_idx + count) {
633 let secret = MintClientModule::new_note_secret_static(secret, amount, NoteIndex(i));
634
635 let (request, blind_nonce) = NoteIssuanceRequest::new(SECP256K1, &secret);
636
637 let hash = blind_nonce.consensus_hash::<hash160::Hash>();
638
639 self.pending_nonces.insert((amount, hash), (request, i));
640 }
641 }
642
643 pub fn handle_output(
644 &mut self,
645 amount: Amount,
646 blind_nonce_hash: hash160::Hash,
647 secret: &DerivableSecret,
648 ) {
649 if let Some((request, idx)) = self.pending_nonces.remove(&(amount, blind_nonce_hash)) {
650 self.observe_nonce_idx_being_used(amount, idx, secret);
651
652 let hash = request.nonce().consensus_hash::<hash160::Hash>();
653
654 self.pending_outputs.insert(hash, (amount, request));
655 }
656 }
657
658 pub fn handle_input(&mut self, nonce_hash: hash160::Hash) {
659 self.pending_outputs.remove(&nonce_hash);
660 }
661
662 fn observe_nonce_idx_being_used(&mut self, amount: Amount, idx: u64, secret: &DerivableSecret) {
663 let last_used_nonce_idx = self
664 .last_used_nonce_idx
665 .get(&amount)
666 .copied()
667 .unwrap_or(idx);
668
669 self.last_used_nonce_idx
670 .insert(amount, max(last_used_nonce_idx, idx));
671
672 let next_pending_note_idx = self
673 .next_pending_note_idx
674 .get(&amount)
675 .copied()
676 .unwrap_or_default();
677
678 let missing = last_used_nonce_idx
679 .add(GAP_LIMIT)
680 .saturating_sub(next_pending_note_idx);
681
682 if missing > 0 {
683 self.add_pending_nonces(amount, missing, secret);
684 }
685 }
686
687 pub fn finalize(self) -> RecoveryStateV2Finalized {
688 RecoveryStateV2Finalized {
689 pending_notes: self.pending_outputs.into_values().collect(),
690 next_note_idx: self
691 .last_used_nonce_idx
692 .into_iter()
693 .map(|(amount, idx)| (amount, NoteIndex(idx + 1)))
694 .collect(),
695 }
696 }
697}
698
699pub struct RecoveryStateV2Finalized {
700 pub pending_notes: Vec<(Amount, NoteIssuanceRequest)>,
702 pub next_note_idx: BTreeMap<Amount, NoteIndex>,
704}