fedimint_mint_client/backup/
recovery.rs

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    /// Handle session outcome, adjusting the current state
164    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, &note.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), &note_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    /// Unsigned notes
262    pub unconfirmed_notes: Vec<(OutPoint, Amount, NoteIssuanceRequest)>,
263    /// Note index to derive next note in a given amount tier
264    pub next_note_idx: Tiered<NoteIndex>,
265    /// Total burned amount
266    pub burned_total: Amount,
267    /// Note indices that were reused.
268    pub reused_note_indices: Vec<(Amount, NoteIndex)>,
269}
270
271/// Newtype over [`BlindedMessage`] to enable `Ord`
272#[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#[allow(clippy::large_enum_variant)]
293#[derive(Debug, Clone, Decodable, Encodable)]
294pub enum MintRecoveryState {
295    #[encodable(index = 2)]
296    V2(MintRecoveryStateV2),
297    // index 0 has incompatible db encoding, index 1 was skipped to match with V2
298    #[encodable_default]
299    Default { variant: u64, bytes: Vec<u8> },
300}
301
302/// The state machine used for fast-forwarding backup from point when it was
303/// taken to the present time by following epoch history items from the time the
304/// snapshot was taken.
305///
306/// The caller is responsible for creating it, and then feeding it in order all
307/// valid consensus items from the epoch history between time taken (or even
308/// somewhat before it) and present time.
309#[derive(Clone, Eq, PartialEq, Decodable, Encodable, Serialize, Deserialize)]
310pub struct MintRecoveryStateV2 {
311    spendable_notes: BTreeMap<Nonce, (Amount, SpendableNote)>,
312    /// Nonces that we track that are currently spendable.
313    pending_outputs: BTreeMap<Nonce, (OutPoint, Amount, NoteIssuanceRequest)>,
314    /// Next nonces that we expect might soon get used.
315    /// Once we see them, we move the tracking to `pending_outputs`
316    ///
317    /// Note: since looking up nonces is going to be the most common operation
318    /// the pool is kept shared (so only one lookup is enough), and
319    /// replenishment is done each time a note is consumed.
320    pending_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
321    /// Nonces that we have already used. Used for detecting double-used nonces
322    /// (accidentally burning funds).
323    used_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
324    /// Note indices that were reused.
325    reused_note_indices: Vec<(Amount, NoteIndex)>,
326    /// Total amount probably burned due to re-using nonces
327    burned_total: Amount,
328    /// Tail of `pending`. `pending_notes` is filled by generating note with
329    /// this index and incrementing it.
330    next_pending_note_idx: Tiered<NoteIndex>,
331    /// `LastECashNoteIndex` but tracked in flight. Basically max index of any
332    /// note that got a partial sig from the federation (initialled from the
333    /// backup value). TODO: One could imagine a case where the note was
334    /// issued but not get any partial sigs yet. Very unlikely in real life
335    /// scenario, but worth considering.
336    last_used_nonce_idx: Tiered<NoteIndex>,
337    /// Threshold
338    threshold: u64,
339    /// Public key shares for each peer
340    ///
341    /// Used to validate contributed consensus items
342    pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
343    /// Aggregate public key for each amount tier
344    tbs_pks: Tiered<AggregatePublicKey>,
345    /// The number of nonces we look-ahead when looking for mints (per each
346    /// amount).
347    gap_limit: u64,
348}
349
350impl fmt::Debug for MintRecoveryStateV2 {
351    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
352        f.write_fmt(format_args!(
353            "MintRestoreInProgressState(pending_outputs: {}, pending_nonces: {}, used_nonces: {}, burned_total: {})",
354            self.pending_outputs.len(),
355            self.pending_nonces.len(),
356            self.used_nonces.len(),
357            self.burned_total,
358        ))
359    }
360}
361
362impl MintRecoveryStateV2 {
363    pub fn from_backup(
364        backup: EcashBackupV0,
365        gap_limit: u64,
366        tbs_pks: Tiered<AggregatePublicKey>,
367        pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
368        secret: &DerivableSecret,
369    ) -> Self {
370        let amount_tiers: Vec<_> = tbs_pks.tiers().copied().collect();
371        let mut s = Self {
372            spendable_notes: backup
373                .spendable_notes
374                .into_iter_items()
375                .map(|(amount, note)| (note.nonce(), (amount, note)))
376                .collect(),
377            pending_outputs: backup
378                .pending_notes
379                .into_iter()
380                .map(|(outpoint, amount, issuance_request)| {
381                    (
382                        issuance_request.nonce(),
383                        (outpoint, amount, issuance_request),
384                    )
385                })
386                .collect(),
387            reused_note_indices: Vec::new(),
388            pending_nonces: BTreeMap::default(),
389            used_nonces: BTreeMap::default(),
390            burned_total: Amount::ZERO,
391            next_pending_note_idx: backup.next_note_idx.clone(),
392            last_used_nonce_idx: backup
393                .next_note_idx
394                .into_iter()
395                .filter_map(|(a, idx)| idx.prev().map(|idx| (a, idx)))
396                .collect(),
397            threshold: pub_key_shares.to_num_peers().threshold() as u64,
398            gap_limit,
399            tbs_pks,
400            pub_key_shares,
401        };
402
403        for amount in amount_tiers {
404            s.fill_initial_pending_nonces(amount, secret);
405        }
406
407        s
408    }
409
410    /// Fill each tier pool to the gap limit
411    fn fill_initial_pending_nonces(&mut self, amount: Amount, secret: &DerivableSecret) {
412        debug!(%amount, count=self.gap_limit, "Generating initial set of nonces for amount tier");
413        for _ in 0..self.gap_limit {
414            self.add_next_pending_nonce_in_pending_pool(amount, secret);
415        }
416    }
417
418    /// Add next nonce from `amount` tier to the `next_pending_note_idx`
419    fn add_next_pending_nonce_in_pending_pool(&mut self, amount: Amount, secret: &DerivableSecret) {
420        let note_idx_ref = self.next_pending_note_idx.get_mut_or_default(amount);
421
422        let (note_issuance_request, blind_nonce) = NoteIssuanceRequest::new(
423            fedimint_core::secp256k1::SECP256K1,
424            &MintClientModule::new_note_secret_static(secret, amount, *note_idx_ref),
425        );
426        assert!(
427            self.pending_nonces
428                .insert(
429                    blind_nonce.0.into(),
430                    (note_issuance_request, *note_idx_ref, amount)
431                )
432                .is_none()
433        );
434
435        note_idx_ref.advance();
436    }
437
438    pub fn handle_input(&mut self, input: &MintInput) {
439        match input {
440            MintInput::V0(input) => {
441                // We attempt to delete any nonce we see as spent, simple
442                self.pending_outputs.remove(&input.note.nonce);
443                self.spendable_notes.remove(&input.note.nonce);
444            }
445            MintInput::Default { variant, .. } => {
446                trace!("Ignoring future mint input variant {variant}");
447            }
448        }
449    }
450
451    pub fn handle_output(
452        &mut self,
453        out_point: OutPoint,
454        output: &MintOutput,
455        secret: &DerivableSecret,
456    ) {
457        let output = match output {
458            MintOutput::V0(output) => output,
459            MintOutput::Default { variant, .. } => {
460                trace!("Ignoring future mint output variant {variant}");
461                return;
462            }
463        };
464
465        if let Some((_issuance_request, note_idx, amount)) =
466            self.used_nonces.get(&output.blind_nonce.0.into())
467        {
468            self.burned_total += *amount;
469            self.reused_note_indices.push((*amount, *note_idx));
470            warn!(
471                target: LOG_CLIENT_RECOVERY_MINT,
472                %note_idx,
473                %amount,
474                burned_total = %self.burned_total,
475                "Detected reused nonce during recovery. This means client probably burned funds in the past."
476            );
477        }
478        // There is nothing preventing other users from creating valid
479        // transactions mining notes to our own blind nonce, possibly
480        // even racing with us. Including amount in blind nonce
481        // derivation helps us avoid accidentally using a nonce mined
482        // for as smaller amount, but it doesn't eliminate completely
483        // the possibility that we might use a note mined in a different
484        // transaction, that our original one.
485        // While it is harmless to us, as such duplicated blind nonces are
486        // effective as good the as the original ones (same amount), it
487        // breaks the assumption that all our blind nonces in an our
488        // output need to be in the pending pool. It forces us to be
489        // greedy no matter what and take what we can, and just report
490        // anything suspicious.
491
492        if let Some((issuance_request, note_idx, pending_amount)) =
493            self.pending_nonces.remove(&output.blind_nonce.0.into())
494        {
495            // the moment we see our blind nonce in the epoch history, correctly or
496            // incorrectly used, we know that we must have used
497            // already
498            self.observe_nonce_idx_being_used(pending_amount, note_idx, secret);
499
500            if pending_amount == output.amount {
501                self.used_nonces.insert(
502                    output.blind_nonce.0.into(),
503                    (issuance_request, note_idx, pending_amount),
504                );
505
506                self.pending_outputs.insert(
507                    issuance_request.nonce(),
508                    (out_point, output.amount, issuance_request),
509                );
510            } else {
511                // put it back, incorrect amount
512                self.pending_nonces.insert(
513                    output.blind_nonce.0.into(),
514                    (issuance_request, note_idx, pending_amount),
515                );
516                warn!(
517                    target: LOG_CLIENT_RECOVERY_MINT,
518                    output = ?out_point,
519                    blind_nonce = ?output.blind_nonce.0,
520                    expected_amount = %pending_amount,
521                    found_amount = %output.amount,
522                    "Transaction output contains blind nonce that looks like ours but is of the wrong amount. Ignoring."
523                );
524            }
525        }
526    }
527
528    /// React to a valid pending nonce being tracked being used in the epoch
529    /// history
530    ///
531    /// (Possibly) increment the `self.last_mined_nonce_idx`, then replenish the
532    /// pending pool to always maintain at least `gap_limit` of pending
533    /// nonces in each amount tier.
534    fn observe_nonce_idx_being_used(
535        &mut self,
536        amount: Amount,
537        note_idx: NoteIndex,
538        secret: &DerivableSecret,
539    ) {
540        self.last_used_nonce_idx.insert(
541            amount,
542            max(
543                self.last_used_nonce_idx
544                    .get(amount)
545                    .copied()
546                    .unwrap_or_default(),
547                note_idx,
548            ),
549        );
550
551        while self.next_pending_note_idx.get_mut_or_default(amount).0
552            < self.gap_limit
553                + self
554                    .last_used_nonce_idx
555                    .get(amount)
556                    .expect("must be there already")
557                    .0
558        {
559            self.add_next_pending_nonce_in_pending_pool(amount, secret);
560        }
561    }
562
563    pub fn finalize(self) -> EcashRecoveryFinalState {
564        EcashRecoveryFinalState {
565            spendable_notes: self.spendable_notes.into_values().collect(),
566            unconfirmed_notes: self.pending_outputs.into_values().collect(),
567            // next note idx is the last one detected as used + 1
568            next_note_idx: self
569                .last_used_nonce_idx
570                .iter()
571                .map(|(amount, value)| (amount, value.next()))
572                .collect(),
573            reused_note_indices: self.reused_note_indices,
574            burned_total: self.burned_total,
575        }
576    }
577}