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