1use std::cmp::max;
2use std::collections::BTreeMap;
3use std::fmt;
4
5use anyhow::Context;
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_client_module::transaction::TransactionBuilder;
12use fedimint_core::core::OperationId;
13use fedimint_core::db::{DatabaseTransaction, IDatabaseTransactionOpsCoreTyped as _};
14use fedimint_core::encoding::{Decodable, Encodable};
15use fedimint_core::module::CommonModuleInit;
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_RECOVERY, LOG_CLIENT_RECOVERY_MINT};
21use fedimint_mint_common::{MintCommonInit, MintInput, MintOutput, Nonce};
22use itertools::Itertools;
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, RecoveryFinalizedKey, RecoveryStateKey, ReusedNoteIndices,
32};
33use crate::event::RecoveryReissuanceStarted;
34use crate::output::{
35 MintOutputCommon, MintOutputStateMachine, MintOutputStatesCreated, NoteIssuanceRequest,
36};
37use crate::{
38 MintClientInit, MintClientModule, MintClientStateMachines, MintOperationMeta,
39 MintOperationMetaVariant, NoteIndex, ReissueExternalNotesError, SpendableNote,
40 create_bundle_for_inputs,
41};
42
43const MAX_REISSUE_NOTES_PER_TX: usize = 100;
44
45#[derive(Clone, Debug)]
46pub struct MintRecovery {
47 state: MintRecoveryStateV2,
48 secret: DerivableSecret,
49 client_ctx: ClientContext<MintClientModule>,
50}
51
52#[apply(async_trait_maybe_send!)]
53impl RecoveryFromHistory for MintRecovery {
54 type Init = MintClientInit;
55
56 async fn new(
57 _init: &Self::Init,
58 args: &ClientModuleRecoverArgs<Self::Init>,
59 snapshot: Option<&EcashBackup>,
60 ) -> anyhow::Result<(Self, u64)> {
61 let snapshot_v0 = match snapshot {
62 Some(EcashBackup::V0(snapshot_v0)) => Some(snapshot_v0),
63 Some(EcashBackup::Default { variant, .. }) => {
64 warn!(%variant, "Unsupported backup variant. Ignoring mint backup.");
65 None
66 }
67 None => None,
68 };
69
70 let config = args.cfg();
71
72 let secret = args.module_root_secret().clone();
73 let (snapshot, starting_session) = if let Some(snapshot) = snapshot_v0 {
74 (snapshot.clone(), snapshot.session_count)
75 } else {
76 (EcashBackupV0::new_empty(), 0)
77 };
78
79 Ok((
80 MintRecovery {
81 state: MintRecoveryStateV2::from_backup(
82 snapshot,
83 100,
84 config.tbs_pks.clone(),
85 config.peer_tbs_pks.clone(),
86 &secret,
87 ),
88 secret,
89 client_ctx: args.context(),
90 },
91 starting_session,
92 ))
93 }
94
95 async fn load_dbtx(
96 _init: &Self::Init,
97 dbtx: &mut DatabaseTransaction<'_>,
98 args: &ClientModuleRecoverArgs<Self::Init>,
99 ) -> anyhow::Result<Option<(Self, RecoveryFromHistoryCommon)>> {
100 dbtx.ensure_isolated()
101 .expect("Must be in prefixed database");
102 Ok(dbtx
103 .get_value(&RecoveryStateKey)
104 .await
105 .and_then(|(state, common)| {
106 if let MintRecoveryState::V2(state) = state {
107 Some((state, common))
108 } else {
109 warn!(target: LOG_CLIENT_RECOVERY, "Found unknown version recovery state. Ignoring");
110 None
111 }
112 })
113 .map(|(state, common)| {
114 (
115 MintRecovery {
116 state,
117 secret: args.module_root_secret().clone(),
118 client_ctx: args.context(),
119 },
120 common,
121 )
122 }))
123 }
124
125 async fn store_dbtx(
126 &self,
127 dbtx: &mut DatabaseTransaction<'_>,
128 common: &RecoveryFromHistoryCommon,
129 ) {
130 dbtx.ensure_isolated()
131 .expect("Must be in prefixed database");
132 dbtx.insert_entry(
133 &RecoveryStateKey,
134 &(MintRecoveryState::V2(self.state.clone()), common.clone()),
135 )
136 .await;
137 }
138
139 async fn delete_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) {
140 dbtx.remove_entry(&RecoveryStateKey).await;
141 }
142
143 async fn load_finalized(dbtx: &mut DatabaseTransaction<'_>) -> Option<bool> {
144 dbtx.get_value(&RecoveryFinalizedKey).await
145 }
146
147 async fn store_finalized(dbtx: &mut DatabaseTransaction<'_>, state: bool) {
148 dbtx.insert_entry(&RecoveryFinalizedKey, &state).await;
149 }
150
151 async fn handle_input(
152 &mut self,
153 _client_ctx: &ClientContext<MintClientModule>,
154 _idx: usize,
155 input: &MintInput,
156 _session_idx: u64,
157 ) -> anyhow::Result<()> {
158 self.state.handle_input(input);
159 Ok(())
160 }
161
162 async fn handle_output(
163 &mut self,
164 _client_ctx: &ClientContext<MintClientModule>,
165 out_point: OutPoint,
166 output: &MintOutput,
167 _session_idx: u64,
168 ) -> anyhow::Result<()> {
169 self.state.handle_output(out_point, output, &self.secret);
170 Ok(())
171 }
172
173 #[allow(clippy::too_many_lines)]
175 async fn finalize_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
176 let finalized = self.state.clone().finalize();
177
178 let restored_amount = finalized
179 .unconfirmed_notes
180 .iter()
181 .map(|entry| entry.1)
182 .sum::<Amount>()
183 + finalized.spendable_notes.total_amount();
184
185 info!(
186 amount = %restored_amount,
187 burned_total = %finalized.burned_total,
188 "Finalizing mint recovery"
189 );
190
191 dbtx.insert_new_entry(&ReusedNoteIndices, &finalized.reused_note_indices)
192 .await;
193 debug!(
194 target: LOG_CLIENT_RECOVERY_MINT,
195 len = finalized.spendable_notes.count_items(),
196 "Reissuing spendable notes"
197 );
198
199 let operation_id = OperationId::new_random();
200 let mut out_point_ranges = Vec::new();
201 let reissue_amount = finalized.spendable_notes.total_amount();
202
203 for notes in finalized
206 .spendable_notes
207 .into_iter_items()
208 .chunks(MAX_REISSUE_NOTES_PER_TX)
209 .into_iter()
210 .map(Iterator::collect::<TieredMulti<_>>)
211 .collect::<Vec<_>>()
212 {
213 let amount = notes.total_amount();
214 debug!(
215 target: LOG_CLIENT_RECOVERY_MINT,
216 len = notes.count_items(),
217 %amount,
218 ?operation_id,
219 "Reissuing chunk of spendable notes"
220 );
221
222 let mint_inputs = self.client_ctx.self_ref().create_input_from_notes(notes)?;
223
224 let tx = TransactionBuilder::new().with_inputs(
225 self.client_ctx
226 .make_dyn(create_bundle_for_inputs(mint_inputs, operation_id)),
227 );
228
229 let out_range = self
230 .client_ctx
231 .finalize_and_submit_transaction_dbtx(dbtx, operation_id, tx)
232 .await
233 .context(ReissueExternalNotesError::AlreadyReissued)?;
234 out_point_ranges.push(out_range);
235 }
236
237 self.client_ctx
238 .log_event(
239 dbtx,
240 RecoveryReissuanceStarted {
241 amount: reissue_amount,
242 operation_id,
243 },
244 )
245 .await;
246
247 self.client_ctx
248 .add_operation_log_entry_dbtx(
249 dbtx,
250 operation_id,
251 MintCommonInit::KIND.as_str(),
252 MintOperationMeta {
253 variant: MintOperationMetaVariant::Recovery { out_point_ranges },
254 amount: reissue_amount,
255 extra_meta: serde_json::Value::Null,
256 },
257 )
258 .await;
259
260 for (amount, note_idx) in finalized.next_note_idx.iter() {
261 debug!(
262 target: LOG_CLIENT_RECOVERY_MINT,
263 %amount,
264 %note_idx,
265 "Restoring NextECashNodeIndex"
266 );
267 dbtx.insert_entry(&NextECashNoteIndexKey(amount), ¬e_idx.as_u64())
268 .await;
269 }
270
271 debug!(
272 target: LOG_CLIENT_RECOVERY_MINT,
273 len = finalized.unconfirmed_notes.len(),
274 "Restoring unconfirmed notes state machines"
275 );
276
277 for (out_point, amount, issuance_request) in finalized.unconfirmed_notes {
278 self.client_ctx
279 .add_state_machines_dbtx(
280 dbtx,
281 self.client_ctx
282 .map_dyn(vec![MintClientStateMachines::Output(
283 MintOutputStateMachine {
284 common: MintOutputCommon {
285 operation_id: OperationId::new_random(),
286 out_point_range: OutPointRange::new_single(
287 out_point.txid,
288 out_point.out_idx,
289 )
290 .expect("Can't overflow"),
291 },
292 state: crate::output::MintOutputStates::Created(
293 MintOutputStatesCreated {
294 amount,
295 issuance_request,
296 },
297 ),
298 },
299 )])
300 .collect(),
301 )
302 .await?;
303 }
304
305 debug!(
306 target: LOG_CLIENT_RECOVERY_MINT,
307 "Mint module recovery finalized"
308 );
309
310 Ok(())
311 }
312}
313
314#[derive(Debug, Clone)]
315pub struct EcashRecoveryFinalState {
316 pub spendable_notes: TieredMulti<SpendableNote>,
317 pub unconfirmed_notes: Vec<(OutPoint, Amount, NoteIssuanceRequest)>,
319 pub next_note_idx: Tiered<NoteIndex>,
321 pub burned_total: Amount,
323 pub reused_note_indices: Vec<(Amount, NoteIndex)>,
325}
326
327#[derive(
329 Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Decodable, Encodable, Serialize, Deserialize,
330)]
331struct CompressedBlindedMessage(#[serde(with = "serde_big_array::BigArray")] [u8; 48]);
332
333impl From<BlindedMessage> for CompressedBlindedMessage {
334 fn from(value: BlindedMessage) -> Self {
335 Self(value.0.to_compressed())
336 }
337}
338
339impl From<CompressedBlindedMessage> for BlindedMessage {
340 fn from(value: CompressedBlindedMessage) -> Self {
341 BlindedMessage(
342 std::convert::Into::<Option<G1Affine>>::into(G1Affine::from_compressed(&value.0))
343 .expect("We never produce invalid compressed blinded messages"),
344 )
345 }
346}
347
348#[allow(clippy::large_enum_variant)]
349#[derive(Debug, Clone, Decodable, Encodable)]
350pub enum MintRecoveryState {
351 #[encodable(index = 2)]
352 V2(MintRecoveryStateV2),
353 #[encodable_default]
355 Default { variant: u64, bytes: Vec<u8> },
356}
357
358#[derive(Clone, Eq, PartialEq, Decodable, Encodable, Serialize, Deserialize)]
366pub struct MintRecoveryStateV2 {
367 spendable_notes: BTreeMap<Nonce, (Amount, SpendableNote)>,
368 pending_outputs: BTreeMap<Nonce, (OutPoint, Amount, NoteIssuanceRequest)>,
370 pending_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
377 used_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
380 reused_note_indices: Vec<(Amount, NoteIndex)>,
382 burned_total: Amount,
384 next_pending_note_idx: Tiered<NoteIndex>,
387 last_used_nonce_idx: Tiered<NoteIndex>,
393 threshold: u64,
395 pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
399 tbs_pks: Tiered<AggregatePublicKey>,
401 gap_limit: u64,
404}
405
406impl fmt::Debug for MintRecoveryStateV2 {
407 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
408 f.write_fmt(format_args!(
409 "MintRestoreInProgressState(pending_outputs: {}, pending_nonces: {}, used_nonces: {}, burned_total: {})",
410 self.pending_outputs.len(),
411 self.pending_nonces.len(),
412 self.used_nonces.len(),
413 self.burned_total,
414 ))
415 }
416}
417
418impl MintRecoveryStateV2 {
419 pub fn from_backup(
420 backup: EcashBackupV0,
421 gap_limit: u64,
422 tbs_pks: Tiered<AggregatePublicKey>,
423 pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
424 secret: &DerivableSecret,
425 ) -> Self {
426 let amount_tiers: Vec<_> = tbs_pks.tiers().copied().collect();
427 let mut s = Self {
428 spendable_notes: backup
429 .spendable_notes
430 .into_iter_items()
431 .map(|(amount, note)| (note.nonce(), (amount, note)))
432 .collect(),
433 pending_outputs: backup
434 .pending_notes
435 .into_iter()
436 .map(|(outpoint, amount, issuance_request)| {
437 (
438 issuance_request.nonce(),
439 (outpoint, amount, issuance_request),
440 )
441 })
442 .collect(),
443 reused_note_indices: Vec::new(),
444 pending_nonces: BTreeMap::default(),
445 used_nonces: BTreeMap::default(),
446 burned_total: Amount::ZERO,
447 next_pending_note_idx: backup.next_note_idx.clone(),
448 last_used_nonce_idx: backup
449 .next_note_idx
450 .into_iter()
451 .filter_map(|(a, idx)| idx.prev().map(|idx| (a, idx)))
452 .collect(),
453 threshold: pub_key_shares.to_num_peers().threshold() as u64,
454 gap_limit,
455 tbs_pks,
456 pub_key_shares,
457 };
458
459 for amount in amount_tiers {
460 s.fill_initial_pending_nonces(amount, secret);
461 }
462
463 s
464 }
465
466 fn fill_initial_pending_nonces(&mut self, amount: Amount, secret: &DerivableSecret) {
468 debug!(%amount, count=self.gap_limit, "Generating initial set of nonces for amount tier");
469 for _ in 0..self.gap_limit {
470 self.add_next_pending_nonce_in_pending_pool(amount, secret);
471 }
472 }
473
474 fn add_next_pending_nonce_in_pending_pool(&mut self, amount: Amount, secret: &DerivableSecret) {
476 let note_idx_ref = self.next_pending_note_idx.get_mut_or_default(amount);
477
478 let (note_issuance_request, blind_nonce) = NoteIssuanceRequest::new(
479 fedimint_core::secp256k1::SECP256K1,
480 &MintClientModule::new_note_secret_static(secret, amount, *note_idx_ref),
481 );
482 assert!(
483 self.pending_nonces
484 .insert(
485 blind_nonce.0.into(),
486 (note_issuance_request, *note_idx_ref, amount)
487 )
488 .is_none()
489 );
490
491 note_idx_ref.advance();
492 }
493
494 pub fn handle_input(&mut self, input: &MintInput) {
495 match input {
496 MintInput::V0(input) => {
497 self.pending_outputs.remove(&input.note.nonce);
499 self.spendable_notes.remove(&input.note.nonce);
500 }
501 MintInput::Default { variant, .. } => {
502 trace!("Ignoring future mint input variant {variant}");
503 }
504 }
505 }
506
507 pub fn handle_output(
508 &mut self,
509 out_point: OutPoint,
510 output: &MintOutput,
511 secret: &DerivableSecret,
512 ) {
513 let output = match output {
514 MintOutput::V0(output) => output,
515 MintOutput::Default { variant, .. } => {
516 trace!("Ignoring future mint output variant {variant}");
517 return;
518 }
519 };
520
521 if let Some((_issuance_request, note_idx, amount)) =
522 self.used_nonces.get(&output.blind_nonce.0.into())
523 {
524 self.burned_total += *amount;
525 self.reused_note_indices.push((*amount, *note_idx));
526 warn!(
527 target: LOG_CLIENT_RECOVERY_MINT,
528 %note_idx,
529 %amount,
530 burned_total = %self.burned_total,
531 "Detected reused nonce during recovery. This means client probably burned funds in the past."
532 );
533 }
534 if let Some((issuance_request, note_idx, pending_amount)) =
549 self.pending_nonces.remove(&output.blind_nonce.0.into())
550 {
551 self.observe_nonce_idx_being_used(pending_amount, note_idx, secret);
555
556 if pending_amount == output.amount {
557 self.used_nonces.insert(
558 output.blind_nonce.0.into(),
559 (issuance_request, note_idx, pending_amount),
560 );
561
562 self.pending_outputs.insert(
563 issuance_request.nonce(),
564 (out_point, output.amount, issuance_request),
565 );
566 } else {
567 self.pending_nonces.insert(
569 output.blind_nonce.0.into(),
570 (issuance_request, note_idx, pending_amount),
571 );
572 warn!(
573 target: LOG_CLIENT_RECOVERY_MINT,
574 output = ?out_point,
575 blind_nonce = ?output.blind_nonce.0,
576 expected_amount = %pending_amount,
577 found_amount = %output.amount,
578 "Transaction output contains blind nonce that looks like ours but is of the wrong amount. Ignoring."
579 );
580 }
581 }
582 }
583
584 fn observe_nonce_idx_being_used(
591 &mut self,
592 amount: Amount,
593 note_idx: NoteIndex,
594 secret: &DerivableSecret,
595 ) {
596 self.last_used_nonce_idx.insert(
597 amount,
598 max(
599 self.last_used_nonce_idx
600 .get(amount)
601 .copied()
602 .unwrap_or_default(),
603 note_idx,
604 ),
605 );
606
607 while self.next_pending_note_idx.get_mut_or_default(amount).0
608 < self.gap_limit
609 + self
610 .last_used_nonce_idx
611 .get(amount)
612 .expect("must be there already")
613 .0
614 {
615 self.add_next_pending_nonce_in_pending_pool(amount, secret);
616 }
617 }
618
619 pub fn finalize(self) -> EcashRecoveryFinalState {
620 EcashRecoveryFinalState {
621 spendable_notes: self.spendable_notes.into_values().collect(),
622 unconfirmed_notes: self.pending_outputs.into_values().collect(),
623 next_note_idx: self
625 .last_used_nonce_idx
626 .iter()
627 .map(|(amount, value)| (amount, value.next()))
628 .collect(),
629 reused_note_indices: self.reused_note_indices,
630 burned_total: self.burned_total,
631 }
632 }
633}