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::event::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(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
168 let finalized = self.state.clone().finalize();
169
170 let restored_amount = finalized
171 .unconfirmed_notes
172 .iter()
173 .map(|entry| entry.1)
174 .sum::<Amount>()
175 + finalized.spendable_notes.total_amount();
176
177 info!(
178 amount = %restored_amount,
179 burned_total = %finalized.burned_total,
180 "Finalizing mint recovery"
181 );
182
183 dbtx.insert_new_entry(&ReusedNoteIndices, &finalized.reused_note_indices)
184 .await;
185 debug!(
186 target: LOG_CLIENT_RECOVERY_MINT,
187 len = finalized.spendable_notes.count_items(),
188 "Restoring spendable notes"
189 );
190 for (amount, note) in finalized.spendable_notes.into_iter_items() {
191 let key = NoteKey {
192 amount,
193 nonce: note.nonce(),
194 };
195 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Restoring note");
196 self.client_ctx
197 .log_event(
198 dbtx,
199 NoteCreated {
200 nonce: note.nonce(),
201 },
202 )
203 .await;
204 dbtx.insert_new_entry(&key, ¬e.to_undecoded()).await;
205 }
206
207 for (amount, note_idx) in finalized.next_note_idx.iter() {
208 debug!(
209 target: LOG_CLIENT_RECOVERY_MINT,
210 %amount,
211 %note_idx,
212 "Restoring NextECashNodeIndex"
213 );
214 dbtx.insert_entry(&NextECashNoteIndexKey(amount), ¬e_idx.as_u64())
215 .await;
216 }
217
218 debug!(
219 target: LOG_CLIENT_RECOVERY_MINT,
220 len = finalized.unconfirmed_notes.len(),
221 "Restoring unconfirmed notes state machines"
222 );
223
224 for (out_point, amount, issuance_request) in finalized.unconfirmed_notes {
225 self.client_ctx
226 .add_state_machines_dbtx(
227 dbtx,
228 self.client_ctx
229 .map_dyn(vec![MintClientStateMachines::Output(
230 MintOutputStateMachine {
231 common: MintOutputCommon {
232 operation_id: OperationId::new_random(),
233 out_point_range: OutPointRange::new_single(
234 out_point.txid,
235 out_point.out_idx,
236 )
237 .expect("Can't overflow"),
238 },
239 state: crate::output::MintOutputStates::Created(
240 MintOutputStatesCreated {
241 amount,
242 issuance_request,
243 },
244 ),
245 },
246 )])
247 .collect(),
248 )
249 .await?;
250 }
251
252 debug!(
253 target: LOG_CLIENT_RECOVERY_MINT,
254 "Mint module recovery finalized"
255 );
256
257 Ok(())
258 }
259}
260
261#[derive(Debug, Clone)]
262pub struct EcashRecoveryFinalState {
263 pub spendable_notes: TieredMulti<SpendableNote>,
264 pub unconfirmed_notes: Vec<(OutPoint, Amount, NoteIssuanceRequest)>,
266 pub next_note_idx: Tiered<NoteIndex>,
268 pub burned_total: Amount,
270 pub reused_note_indices: Vec<(Amount, NoteIndex)>,
272}
273
274#[derive(
276 Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Decodable, Encodable, Serialize, Deserialize,
277)]
278struct CompressedBlindedMessage(#[serde(with = "serde_big_array::BigArray")] [u8; 48]);
279
280impl From<BlindedMessage> for CompressedBlindedMessage {
281 fn from(value: BlindedMessage) -> Self {
282 Self(value.0.to_compressed())
283 }
284}
285
286impl From<CompressedBlindedMessage> for BlindedMessage {
287 fn from(value: CompressedBlindedMessage) -> Self {
288 BlindedMessage(
289 std::convert::Into::<Option<G1Affine>>::into(G1Affine::from_compressed(&value.0))
290 .expect("We never produce invalid compressed blinded messages"),
291 )
292 }
293}
294
295#[allow(clippy::large_enum_variant)]
296#[derive(Debug, Clone, Decodable, Encodable)]
297pub enum MintRecoveryState {
298 #[encodable(index = 2)]
299 V2(MintRecoveryStateV2),
300 #[encodable_default]
302 Default { variant: u64, bytes: Vec<u8> },
303}
304
305#[derive(Clone, Eq, PartialEq, Decodable, Encodable, Serialize, Deserialize)]
313pub struct MintRecoveryStateV2 {
314 spendable_notes: BTreeMap<Nonce, (Amount, SpendableNote)>,
315 pending_outputs: BTreeMap<Nonce, (OutPoint, Amount, NoteIssuanceRequest)>,
317 pending_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
324 used_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
327 reused_note_indices: Vec<(Amount, NoteIndex)>,
329 burned_total: Amount,
331 next_pending_note_idx: Tiered<NoteIndex>,
334 last_used_nonce_idx: Tiered<NoteIndex>,
340 threshold: u64,
342 pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
346 tbs_pks: Tiered<AggregatePublicKey>,
348 gap_limit: u64,
351}
352
353impl fmt::Debug for MintRecoveryStateV2 {
354 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355 f.write_fmt(format_args!(
356 "MintRestoreInProgressState(pending_outputs: {}, pending_nonces: {}, used_nonces: {}, burned_total: {})",
357 self.pending_outputs.len(),
358 self.pending_nonces.len(),
359 self.used_nonces.len(),
360 self.burned_total,
361 ))
362 }
363}
364
365impl MintRecoveryStateV2 {
366 pub fn from_backup(
367 backup: EcashBackupV0,
368 gap_limit: u64,
369 tbs_pks: Tiered<AggregatePublicKey>,
370 pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
371 secret: &DerivableSecret,
372 ) -> Self {
373 let amount_tiers: Vec<_> = tbs_pks.tiers().copied().collect();
374 let mut s = Self {
375 spendable_notes: backup
376 .spendable_notes
377 .into_iter_items()
378 .map(|(amount, note)| (note.nonce(), (amount, note)))
379 .collect(),
380 pending_outputs: backup
381 .pending_notes
382 .into_iter()
383 .map(|(outpoint, amount, issuance_request)| {
384 (
385 issuance_request.nonce(),
386 (outpoint, amount, issuance_request),
387 )
388 })
389 .collect(),
390 reused_note_indices: Vec::new(),
391 pending_nonces: BTreeMap::default(),
392 used_nonces: BTreeMap::default(),
393 burned_total: Amount::ZERO,
394 next_pending_note_idx: backup.next_note_idx.clone(),
395 last_used_nonce_idx: backup
396 .next_note_idx
397 .into_iter()
398 .filter_map(|(a, idx)| idx.prev().map(|idx| (a, idx)))
399 .collect(),
400 threshold: pub_key_shares.to_num_peers().threshold() as u64,
401 gap_limit,
402 tbs_pks,
403 pub_key_shares,
404 };
405
406 for amount in amount_tiers {
407 s.fill_initial_pending_nonces(amount, secret);
408 }
409
410 s
411 }
412
413 fn fill_initial_pending_nonces(&mut self, amount: Amount, secret: &DerivableSecret) {
415 for _ in 0..self.gap_limit {
416 self.add_next_pending_nonce_in_pending_pool(amount, secret);
417 }
418 }
419
420 fn add_next_pending_nonce_in_pending_pool(&mut self, amount: Amount, secret: &DerivableSecret) {
422 let note_idx_ref = self.next_pending_note_idx.get_mut_or_default(amount);
423
424 let (note_issuance_request, blind_nonce) = NoteIssuanceRequest::new(
425 fedimint_core::secp256k1::SECP256K1,
426 &MintClientModule::new_note_secret_static(secret, amount, *note_idx_ref),
427 );
428 assert!(
429 self.pending_nonces
430 .insert(
431 blind_nonce.0.into(),
432 (note_issuance_request, *note_idx_ref, amount)
433 )
434 .is_none()
435 );
436
437 note_idx_ref.advance();
438 }
439
440 pub fn handle_input(&mut self, input: &MintInput) {
441 match input {
442 MintInput::V0(input) => {
443 self.pending_outputs.remove(&input.note.nonce);
445 self.spendable_notes.remove(&input.note.nonce);
446 }
447 MintInput::Default { variant, .. } => {
448 trace!("Ignoring future mint input variant {variant}");
449 }
450 }
451 }
452
453 pub fn handle_output(
454 &mut self,
455 out_point: OutPoint,
456 output: &MintOutput,
457 secret: &DerivableSecret,
458 ) {
459 let output = match output {
460 MintOutput::V0(output) => output,
461 MintOutput::Default { variant, .. } => {
462 trace!("Ignoring future mint output variant {variant}");
463 return;
464 }
465 };
466
467 if let Some((_issuance_request, note_idx, amount)) =
468 self.used_nonces.get(&output.blind_nonce.0.into())
469 {
470 self.burned_total += *amount;
471 self.reused_note_indices.push((*amount, *note_idx));
472 warn!(
473 target: LOG_CLIENT_RECOVERY_MINT,
474 %note_idx,
475 %amount,
476 burned_total = %self.burned_total,
477 "Detected reused nonce during recovery. This means client probably burned funds in the past."
478 );
479 }
480 if let Some((issuance_request, note_idx, pending_amount)) =
495 self.pending_nonces.remove(&output.blind_nonce.0.into())
496 {
497 self.observe_nonce_idx_being_used(pending_amount, note_idx, secret);
501
502 if pending_amount == output.amount {
503 self.used_nonces.insert(
504 output.blind_nonce.0.into(),
505 (issuance_request, note_idx, pending_amount),
506 );
507
508 self.pending_outputs.insert(
509 issuance_request.nonce(),
510 (out_point, output.amount, issuance_request),
511 );
512 } else {
513 self.pending_nonces.insert(
515 output.blind_nonce.0.into(),
516 (issuance_request, note_idx, pending_amount),
517 );
518 warn!(
519 target: LOG_CLIENT_RECOVERY_MINT,
520 output = ?out_point,
521 blind_nonce = ?output.blind_nonce.0,
522 expected_amount = %pending_amount,
523 found_amount = %output.amount,
524 "Transaction output contains blind nonce that looks like ours but is of the wrong amount. Ignoring."
525 );
526 }
527 }
528 }
529
530 fn observe_nonce_idx_being_used(
537 &mut self,
538 amount: Amount,
539 note_idx: NoteIndex,
540 secret: &DerivableSecret,
541 ) {
542 self.last_used_nonce_idx.insert(
543 amount,
544 max(
545 self.last_used_nonce_idx
546 .get(amount)
547 .copied()
548 .unwrap_or_default(),
549 note_idx,
550 ),
551 );
552
553 while self.next_pending_note_idx.get_mut_or_default(amount).0
554 < self.gap_limit
555 + self
556 .last_used_nonce_idx
557 .get(amount)
558 .expect("must be there already")
559 .0
560 {
561 self.add_next_pending_nonce_in_pending_pool(amount, secret);
562 }
563 }
564
565 pub fn finalize(self) -> EcashRecoveryFinalState {
566 EcashRecoveryFinalState {
567 spendable_notes: self.spendable_notes.into_values().collect(),
568 unconfirmed_notes: self.pending_outputs.into_values().collect(),
569 next_note_idx: self
571 .last_used_nonce_idx
572 .iter()
573 .map(|(amount, value)| (amount, value.next()))
574 .collect(),
575 reused_note_indices: self.reused_note_indices,
576 burned_total: self.burned_total,
577 }
578 }
579}
580
581const GAP_LIMIT: u64 = 100;
582
583#[derive(Clone, Debug, Encodable, Decodable)]
585pub struct RecoveryStateV2 {
586 pub next_index: u64,
588 pub total_items: u64,
590 pending_outputs: BTreeMap<hash160::Hash, (Amount, NoteIssuanceRequest)>,
592 pending_nonces: BTreeMap<(Amount, hash160::Hash), (NoteIssuanceRequest, u64)>,
594 next_pending_note_idx: BTreeMap<Amount, u64>,
597 last_used_nonce_idx: BTreeMap<Amount, u64>,
600}
601
602impl RecoveryStateV2 {
603 pub fn new(total_items: u64, amount_tiers: Vec<Amount>, secret: &DerivableSecret) -> Self {
604 let mut state = Self {
605 next_index: 0,
606 total_items,
607 pending_outputs: BTreeMap::default(),
608 pending_nonces: BTreeMap::default(),
609 next_pending_note_idx: BTreeMap::default(),
610 last_used_nonce_idx: BTreeMap::default(),
611 };
612
613 for amount in amount_tiers {
614 state.add_pending_nonces(amount, GAP_LIMIT, secret);
615 }
616
617 state
618 }
619
620 fn add_pending_nonces(&mut self, amount: Amount, count: u64, secret: &DerivableSecret) {
621 let next_idx = self
622 .next_pending_note_idx
623 .get(&amount)
624 .copied()
625 .unwrap_or_default();
626
627 self.next_pending_note_idx.insert(amount, next_idx + count);
628
629 for i in next_idx..(next_idx + count) {
630 let secret = MintClientModule::new_note_secret_static(secret, amount, NoteIndex(i));
631
632 let (request, blind_nonce) = NoteIssuanceRequest::new(SECP256K1, &secret);
633
634 let hash = blind_nonce.consensus_hash::<hash160::Hash>();
635
636 self.pending_nonces.insert((amount, hash), (request, i));
637 }
638 }
639
640 pub fn handle_output(
641 &mut self,
642 amount: Amount,
643 blind_nonce_hash: hash160::Hash,
644 secret: &DerivableSecret,
645 ) {
646 if let Some((request, idx)) = self.pending_nonces.remove(&(amount, blind_nonce_hash)) {
647 self.observe_nonce_idx_being_used(amount, idx, secret);
648
649 let hash = request.nonce().consensus_hash::<hash160::Hash>();
650
651 self.pending_outputs.insert(hash, (amount, request));
652 }
653 }
654
655 pub fn handle_input(&mut self, nonce_hash: hash160::Hash) {
656 self.pending_outputs.remove(&nonce_hash);
657 }
658
659 fn observe_nonce_idx_being_used(&mut self, amount: Amount, idx: u64, secret: &DerivableSecret) {
660 let last_used_nonce_idx = self
661 .last_used_nonce_idx
662 .get(&amount)
663 .copied()
664 .unwrap_or(idx);
665
666 self.last_used_nonce_idx
667 .insert(amount, max(last_used_nonce_idx, idx));
668
669 let next_pending_note_idx = self
670 .next_pending_note_idx
671 .get(&amount)
672 .copied()
673 .unwrap_or_default();
674
675 let missing = last_used_nonce_idx
676 .add(GAP_LIMIT)
677 .saturating_sub(next_pending_note_idx);
678
679 if missing > 0 {
680 self.add_pending_nonces(amount, missing, secret);
681 }
682 }
683
684 pub fn finalize(self) -> RecoveryStateV2Finalized {
685 RecoveryStateV2Finalized {
686 pending_notes: self.pending_outputs.into_values().collect(),
687 next_note_idx: self
688 .last_used_nonce_idx
689 .into_iter()
690 .map(|(amount, idx)| (amount, NoteIndex(idx + 1)))
691 .collect(),
692 }
693 }
694}
695
696pub struct RecoveryStateV2Finalized {
697 pub pending_notes: Vec<(Amount, NoteIssuanceRequest)>,
699 pub next_note_idx: BTreeMap<Amount, NoteIndex>,
701}