fedimint_mint_client/backup/
recovery.rs

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    /// Handle session outcome, adjusting the current state
174    #[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        // We reissue notes in chunks to avoid hitting consensus size limits and in the
204        // future minimize transaction fees.
205        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), &note_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    /// Unsigned notes
318    pub unconfirmed_notes: Vec<(OutPoint, Amount, NoteIssuanceRequest)>,
319    /// Note index to derive next note in a given amount tier
320    pub next_note_idx: Tiered<NoteIndex>,
321    /// Total burned amount
322    pub burned_total: Amount,
323    /// Note indices that were reused.
324    pub reused_note_indices: Vec<(Amount, NoteIndex)>,
325}
326
327/// Newtype over [`BlindedMessage`] to enable `Ord`
328#[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    // index 0 has incompatible db encoding, index 1 was skipped to match with V2
354    #[encodable_default]
355    Default { variant: u64, bytes: Vec<u8> },
356}
357
358/// The state machine used for fast-forwarding backup from point when it was
359/// taken to the present time by following epoch history items from the time the
360/// snapshot was taken.
361///
362/// The caller is responsible for creating it, and then feeding it in order all
363/// valid consensus items from the epoch history between time taken (or even
364/// somewhat before it) and present time.
365#[derive(Clone, Eq, PartialEq, Decodable, Encodable, Serialize, Deserialize)]
366pub struct MintRecoveryStateV2 {
367    spendable_notes: BTreeMap<Nonce, (Amount, SpendableNote)>,
368    /// Nonces that we track that are currently spendable.
369    pending_outputs: BTreeMap<Nonce, (OutPoint, Amount, NoteIssuanceRequest)>,
370    /// Next nonces that we expect might soon get used.
371    /// Once we see them, we move the tracking to `pending_outputs`
372    ///
373    /// Note: since looking up nonces is going to be the most common operation
374    /// the pool is kept shared (so only one lookup is enough), and
375    /// replenishment is done each time a note is consumed.
376    pending_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
377    /// Nonces that we have already used. Used for detecting double-used nonces
378    /// (accidentally burning funds).
379    used_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
380    /// Note indices that were reused.
381    reused_note_indices: Vec<(Amount, NoteIndex)>,
382    /// Total amount probably burned due to re-using nonces
383    burned_total: Amount,
384    /// Tail of `pending`. `pending_notes` is filled by generating note with
385    /// this index and incrementing it.
386    next_pending_note_idx: Tiered<NoteIndex>,
387    /// `LastECashNoteIndex` but tracked in flight. Basically max index of any
388    /// note that got a partial sig from the federation (initialled from the
389    /// backup value). TODO: One could imagine a case where the note was
390    /// issued but not get any partial sigs yet. Very unlikely in real life
391    /// scenario, but worth considering.
392    last_used_nonce_idx: Tiered<NoteIndex>,
393    /// Threshold
394    threshold: u64,
395    /// Public key shares for each peer
396    ///
397    /// Used to validate contributed consensus items
398    pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
399    /// Aggregate public key for each amount tier
400    tbs_pks: Tiered<AggregatePublicKey>,
401    /// The number of nonces we look-ahead when looking for mints (per each
402    /// amount).
403    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    /// Fill each tier pool to the gap limit
467    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    /// Add next nonce from `amount` tier to the `next_pending_note_idx`
475    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                // We attempt to delete any nonce we see as spent, simple
498                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        // There is nothing preventing other users from creating valid
535        // transactions mining notes to our own blind nonce, possibly
536        // even racing with us. Including amount in blind nonce
537        // derivation helps us avoid accidentally using a nonce mined
538        // for as smaller amount, but it doesn't eliminate completely
539        // the possibility that we might use a note mined in a different
540        // transaction, that our original one.
541        // While it is harmless to us, as such duplicated blind nonces are
542        // effective as good the as the original ones (same amount), it
543        // breaks the assumption that all our blind nonces in an our
544        // output need to be in the pending pool. It forces us to be
545        // greedy no matter what and take what we can, and just report
546        // anything suspicious.
547
548        if let Some((issuance_request, note_idx, pending_amount)) =
549            self.pending_nonces.remove(&output.blind_nonce.0.into())
550        {
551            // the moment we see our blind nonce in the epoch history, correctly or
552            // incorrectly used, we know that we must have used
553            // already
554            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                // put it back, incorrect amount
568                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    /// React to a valid pending nonce being tracked being used in the epoch
585    /// history
586    ///
587    /// (Possibly) increment the `self.last_mined_nonce_idx`, then replenish the
588    /// pending pool to always maintain at least `gap_limit` of pending
589    /// nonces in each amount tier.
590    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 is the last one detected as used + 1
624            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}