1use std::cmp::max;
2use std::collections::BTreeMap;
3use std::fmt;
4
5use fedimint_client_module::module::init::ClientModuleRecoverArgs;
6use fedimint_client_module::module::init::recovery::{
7 RecoveryFromHistory, RecoveryFromHistoryCommon,
8};
9use fedimint_client_module::module::{ClientContext, OutPointRange};
10use fedimint_core::core::OperationId;
11use fedimint_core::db::{DatabaseTransaction, IDatabaseTransactionOpsCoreTyped as _};
12use fedimint_core::encoding::{Decodable, Encodable};
13use fedimint_core::{
14 Amount, NumPeersExt, OutPoint, PeerId, Tiered, TieredMulti, apply, async_trait_maybe_send,
15};
16use fedimint_derive_secret::DerivableSecret;
17use fedimint_logging::{LOG_CLIENT_MODULE_MINT, LOG_CLIENT_RECOVERY, LOG_CLIENT_RECOVERY_MINT};
18use fedimint_mint_common::{MintInput, MintOutput, Nonce};
19use serde::{Deserialize, Serialize};
20use tbs::{AggregatePublicKey, BlindedMessage, PublicKeyShare};
21use threshold_crypto::G1Affine;
22use tracing::{debug, info, trace, warn};
23
24use super::EcashBackup;
25use crate::backup::EcashBackupV0;
26use crate::client_db::{
27 NextECashNoteIndexKey, NoteKey, RecoveryFinalizedKey, RecoveryStateKey, ReusedNoteIndices,
28};
29use crate::event::NoteCreated;
30use crate::output::{
31 MintOutputCommon, MintOutputStateMachine, MintOutputStatesCreated, NoteIssuanceRequest,
32};
33use crate::{MintClientInit, MintClientModule, MintClientStateMachines, NoteIndex, SpendableNote};
34
35#[derive(Clone, Debug)]
36pub struct MintRecovery {
37 state: MintRecoveryStateV2,
38 secret: DerivableSecret,
39 client_ctx: ClientContext<MintClientModule>,
40}
41
42#[apply(async_trait_maybe_send!)]
43impl RecoveryFromHistory for MintRecovery {
44 type Init = MintClientInit;
45
46 async fn new(
47 _init: &Self::Init,
48 args: &ClientModuleRecoverArgs<Self::Init>,
49 snapshot: Option<&EcashBackup>,
50 ) -> anyhow::Result<(Self, u64)> {
51 let snapshot_v0 = match snapshot {
52 Some(EcashBackup::V0(snapshot_v0)) => Some(snapshot_v0),
53 Some(EcashBackup::Default { variant, .. }) => {
54 warn!(%variant, "Unsupported backup variant. Ignoring mint backup.");
55 None
56 }
57 None => None,
58 };
59
60 let config = args.cfg();
61
62 let secret = args.module_root_secret().clone();
63 let (snapshot, starting_session) = if let Some(snapshot) = snapshot_v0 {
64 (snapshot.clone(), snapshot.session_count)
65 } else {
66 (EcashBackupV0::new_empty(), 0)
67 };
68
69 Ok((
70 MintRecovery {
71 state: MintRecoveryStateV2::from_backup(
72 snapshot,
73 100,
74 config.tbs_pks.clone(),
75 config.peer_tbs_pks.clone(),
76 &secret,
77 ),
78 secret,
79 client_ctx: args.context(),
80 },
81 starting_session,
82 ))
83 }
84
85 async fn load_dbtx(
86 _init: &Self::Init,
87 dbtx: &mut DatabaseTransaction<'_>,
88 args: &ClientModuleRecoverArgs<Self::Init>,
89 ) -> anyhow::Result<Option<(Self, RecoveryFromHistoryCommon)>> {
90 dbtx.ensure_isolated()
91 .expect("Must be in prefixed database");
92 Ok(dbtx
93 .get_value(&RecoveryStateKey)
94 .await
95 .and_then(|(state, common)| {
96 if let MintRecoveryState::V2(state) = state {
97 Some((state, common))
98 } else {
99 warn!(target: LOG_CLIENT_RECOVERY, "Found unknown version recovery state. Ignoring");
100 None
101 }
102 })
103 .map(|(state, common)| {
104 (
105 MintRecovery {
106 state,
107 secret: args.module_root_secret().clone(),
108 client_ctx: args.context(),
109 },
110 common,
111 )
112 }))
113 }
114
115 async fn store_dbtx(
116 &self,
117 dbtx: &mut DatabaseTransaction<'_>,
118 common: &RecoveryFromHistoryCommon,
119 ) {
120 dbtx.ensure_isolated()
121 .expect("Must be in prefixed database");
122 dbtx.insert_entry(
123 &RecoveryStateKey,
124 &(MintRecoveryState::V2(self.state.clone()), common.clone()),
125 )
126 .await;
127 }
128
129 async fn delete_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) {
130 dbtx.remove_entry(&RecoveryStateKey).await;
131 }
132
133 async fn load_finalized(dbtx: &mut DatabaseTransaction<'_>) -> Option<bool> {
134 dbtx.get_value(&RecoveryFinalizedKey).await
135 }
136
137 async fn store_finalized(dbtx: &mut DatabaseTransaction<'_>, state: bool) {
138 dbtx.insert_entry(&RecoveryFinalizedKey, &state).await;
139 }
140
141 async fn handle_input(
142 &mut self,
143 _client_ctx: &ClientContext<MintClientModule>,
144 _idx: usize,
145 input: &MintInput,
146 _session_idx: u64,
147 ) -> anyhow::Result<()> {
148 self.state.handle_input(input);
149 Ok(())
150 }
151
152 async fn handle_output(
153 &mut self,
154 _client_ctx: &ClientContext<MintClientModule>,
155 out_point: OutPoint,
156 output: &MintOutput,
157 _session_idx: u64,
158 ) -> anyhow::Result<()> {
159 self.state.handle_output(out_point, output, &self.secret);
160 Ok(())
161 }
162
163 async fn finalize_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
165 let finalized = self.state.clone().finalize();
166
167 let restored_amount = finalized
168 .unconfirmed_notes
169 .iter()
170 .map(|entry| entry.1)
171 .sum::<Amount>()
172 + finalized.spendable_notes.total_amount();
173
174 info!(
175 amount = %restored_amount,
176 burned_total = %finalized.burned_total,
177 "Finalizing mint recovery"
178 );
179
180 dbtx.insert_new_entry(&ReusedNoteIndices, &finalized.reused_note_indices)
181 .await;
182 debug!(
183 target: LOG_CLIENT_RECOVERY_MINT,
184 len = finalized.spendable_notes.count_items(),
185 "Restoring spendable notes"
186 );
187 for (amount, note) in finalized.spendable_notes.into_iter_items() {
188 let key = NoteKey {
189 amount,
190 nonce: note.nonce(),
191 };
192 debug!(target: LOG_CLIENT_MODULE_MINT, %amount, %note, "Restoring note");
193 self.client_ctx
194 .log_event(
195 dbtx,
196 NoteCreated {
197 nonce: note.nonce(),
198 },
199 )
200 .await;
201 dbtx.insert_new_entry(&key, ¬e.to_undecoded()).await;
202 }
203
204 for (amount, note_idx) in finalized.next_note_idx.iter() {
205 debug!(
206 target: LOG_CLIENT_RECOVERY_MINT,
207 %amount,
208 %note_idx,
209 "Restoring NextECashNodeIndex"
210 );
211 dbtx.insert_entry(&NextECashNoteIndexKey(amount), ¬e_idx.as_u64())
212 .await;
213 }
214
215 debug!(
216 target: LOG_CLIENT_RECOVERY_MINT,
217 len = finalized.unconfirmed_notes.len(),
218 "Restoring unconfirmed notes state machines"
219 );
220
221 for (out_point, amount, issuance_request) in finalized.unconfirmed_notes {
222 self.client_ctx
223 .add_state_machines_dbtx(
224 dbtx,
225 self.client_ctx
226 .map_dyn(vec![MintClientStateMachines::Output(
227 MintOutputStateMachine {
228 common: MintOutputCommon {
229 operation_id: OperationId::new_random(),
230 out_point_range: OutPointRange::new_single(
231 out_point.txid,
232 out_point.out_idx,
233 )
234 .expect("Can't overflow"),
235 },
236 state: crate::output::MintOutputStates::Created(
237 MintOutputStatesCreated {
238 amount,
239 issuance_request,
240 },
241 ),
242 },
243 )])
244 .collect(),
245 )
246 .await?;
247 }
248
249 debug!(
250 target: LOG_CLIENT_RECOVERY_MINT,
251 "Mint module recovery finalized"
252 );
253
254 Ok(())
255 }
256}
257
258#[derive(Debug, Clone)]
259pub struct EcashRecoveryFinalState {
260 pub spendable_notes: TieredMulti<SpendableNote>,
261 pub unconfirmed_notes: Vec<(OutPoint, Amount, NoteIssuanceRequest)>,
263 pub next_note_idx: Tiered<NoteIndex>,
265 pub burned_total: Amount,
267 pub reused_note_indices: Vec<(Amount, NoteIndex)>,
269}
270
271#[derive(
273 Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Decodable, Encodable, Serialize, Deserialize,
274)]
275struct CompressedBlindedMessage(#[serde(with = "serde_big_array::BigArray")] [u8; 48]);
276
277impl From<BlindedMessage> for CompressedBlindedMessage {
278 fn from(value: BlindedMessage) -> Self {
279 Self(value.0.to_compressed())
280 }
281}
282
283impl From<CompressedBlindedMessage> for BlindedMessage {
284 fn from(value: CompressedBlindedMessage) -> Self {
285 BlindedMessage(
286 std::convert::Into::<Option<G1Affine>>::into(G1Affine::from_compressed(&value.0))
287 .expect("We never produce invalid compressed blinded messages"),
288 )
289 }
290}
291
292#[derive(Debug, Clone, Decodable, Encodable)]
293pub enum MintRecoveryState {
294 #[encodable(index = 2)]
295 V2(MintRecoveryStateV2),
296 #[encodable_default]
298 Default { variant: u64, bytes: Vec<u8> },
299}
300
301#[derive(Clone, Eq, PartialEq, Decodable, Encodable, Serialize, Deserialize)]
309pub struct MintRecoveryStateV2 {
310 spendable_notes: BTreeMap<Nonce, (Amount, SpendableNote)>,
311 pending_outputs: BTreeMap<Nonce, (OutPoint, Amount, NoteIssuanceRequest)>,
313 pending_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
320 used_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
323 reused_note_indices: Vec<(Amount, NoteIndex)>,
325 burned_total: Amount,
327 next_pending_note_idx: Tiered<NoteIndex>,
330 last_used_nonce_idx: Tiered<NoteIndex>,
336 threshold: u64,
338 pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
342 tbs_pks: Tiered<AggregatePublicKey>,
344 gap_limit: u64,
347}
348
349impl fmt::Debug for MintRecoveryStateV2 {
350 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351 f.write_fmt(format_args!(
352 "MintRestoreInProgressState(pending_outputs: {}, pending_nonces: {}, used_nonces: {}, burned_total: {})",
353 self.pending_outputs.len(),
354 self.pending_nonces.len(),
355 self.used_nonces.len(),
356 self.burned_total,
357 ))
358 }
359}
360
361impl MintRecoveryStateV2 {
362 pub fn from_backup(
363 backup: EcashBackupV0,
364 gap_limit: u64,
365 tbs_pks: Tiered<AggregatePublicKey>,
366 pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
367 secret: &DerivableSecret,
368 ) -> Self {
369 let amount_tiers: Vec<_> = tbs_pks.tiers().copied().collect();
370 let mut s = Self {
371 spendable_notes: backup
372 .spendable_notes
373 .into_iter_items()
374 .map(|(amount, note)| (note.nonce(), (amount, note)))
375 .collect(),
376 pending_outputs: backup
377 .pending_notes
378 .into_iter()
379 .map(|(outpoint, amount, issuance_request)| {
380 (
381 issuance_request.nonce(),
382 (outpoint, amount, issuance_request),
383 )
384 })
385 .collect(),
386 reused_note_indices: Vec::new(),
387 pending_nonces: BTreeMap::default(),
388 used_nonces: BTreeMap::default(),
389 burned_total: Amount::ZERO,
390 next_pending_note_idx: backup.next_note_idx.clone(),
391 last_used_nonce_idx: backup
392 .next_note_idx
393 .into_iter()
394 .filter_map(|(a, idx)| idx.prev().map(|idx| (a, idx)))
395 .collect(),
396 threshold: pub_key_shares.to_num_peers().threshold() as u64,
397 gap_limit,
398 tbs_pks,
399 pub_key_shares,
400 };
401
402 for amount in amount_tiers {
403 s.fill_initial_pending_nonces(amount, secret);
404 }
405
406 s
407 }
408
409 fn fill_initial_pending_nonces(&mut self, amount: Amount, secret: &DerivableSecret) {
411 debug!(%amount, count=self.gap_limit, "Generating initial set of nonces for amount tier");
412 for _ in 0..self.gap_limit {
413 self.add_next_pending_nonce_in_pending_pool(amount, secret);
414 }
415 }
416
417 fn add_next_pending_nonce_in_pending_pool(&mut self, amount: Amount, secret: &DerivableSecret) {
419 let note_idx_ref = self.next_pending_note_idx.get_mut_or_default(amount);
420
421 let (note_issuance_request, blind_nonce) = NoteIssuanceRequest::new(
422 fedimint_core::secp256k1::SECP256K1,
423 &MintClientModule::new_note_secret_static(secret, amount, *note_idx_ref),
424 );
425 assert!(
426 self.pending_nonces
427 .insert(
428 blind_nonce.0.into(),
429 (note_issuance_request, *note_idx_ref, amount)
430 )
431 .is_none()
432 );
433
434 note_idx_ref.advance();
435 }
436
437 pub fn handle_input(&mut self, input: &MintInput) {
438 match input {
439 MintInput::V0(input) => {
440 self.pending_outputs.remove(&input.note.nonce);
442 self.spendable_notes.remove(&input.note.nonce);
443 }
444 MintInput::Default { variant, .. } => {
445 trace!("Ignoring future mint input variant {variant}");
446 }
447 }
448 }
449
450 pub fn handle_output(
451 &mut self,
452 out_point: OutPoint,
453 output: &MintOutput,
454 secret: &DerivableSecret,
455 ) {
456 let output = match output {
457 MintOutput::V0(output) => output,
458 MintOutput::Default { variant, .. } => {
459 trace!("Ignoring future mint output variant {variant}");
460 return;
461 }
462 };
463
464 if let Some((_issuance_request, note_idx, amount)) =
465 self.used_nonces.get(&output.blind_nonce.0.into())
466 {
467 self.burned_total += *amount;
468 self.reused_note_indices.push((*amount, *note_idx));
469 warn!(
470 target: LOG_CLIENT_RECOVERY_MINT,
471 %note_idx,
472 %amount,
473 burned_total = %self.burned_total,
474 "Detected reused nonce during recovery. This means client probably burned funds in the past."
475 );
476 }
477 if let Some((issuance_request, note_idx, pending_amount)) =
492 self.pending_nonces.remove(&output.blind_nonce.0.into())
493 {
494 self.observe_nonce_idx_being_used(pending_amount, note_idx, secret);
498
499 if pending_amount == output.amount {
500 self.used_nonces.insert(
501 output.blind_nonce.0.into(),
502 (issuance_request, note_idx, pending_amount),
503 );
504
505 self.pending_outputs.insert(
506 issuance_request.nonce(),
507 (out_point, output.amount, issuance_request),
508 );
509 } else {
510 self.pending_nonces.insert(
512 output.blind_nonce.0.into(),
513 (issuance_request, note_idx, pending_amount),
514 );
515 warn!(
516 target: LOG_CLIENT_RECOVERY_MINT,
517 output = ?out_point,
518 blind_nonce = ?output.blind_nonce.0,
519 expected_amount = %pending_amount,
520 found_amount = %output.amount,
521 "Transaction output contains blind nonce that looks like ours but is of the wrong amount. Ignoring."
522 );
523 }
524 }
525 }
526
527 fn observe_nonce_idx_being_used(
534 &mut self,
535 amount: Amount,
536 note_idx: NoteIndex,
537 secret: &DerivableSecret,
538 ) {
539 self.last_used_nonce_idx.insert(
540 amount,
541 max(
542 self.last_used_nonce_idx
543 .get(amount)
544 .copied()
545 .unwrap_or_default(),
546 note_idx,
547 ),
548 );
549
550 while self.next_pending_note_idx.get_mut_or_default(amount).0
551 < self.gap_limit
552 + self
553 .last_used_nonce_idx
554 .get(amount)
555 .expect("must be there already")
556 .0
557 {
558 self.add_next_pending_nonce_in_pending_pool(amount, secret);
559 }
560 }
561
562 pub fn finalize(self) -> EcashRecoveryFinalState {
563 EcashRecoveryFinalState {
564 spendable_notes: self.spendable_notes.into_values().collect(),
565 unconfirmed_notes: self.pending_outputs.into_values().collect(),
566 next_note_idx: self
568 .last_used_nonce_idx
569 .iter()
570 .map(|(amount, value)| (amount, value.next()))
571 .collect(),
572 reused_note_indices: self.reused_note_indices,
573 burned_total: self.burned_total,
574 }
575 }
576}