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#[derive(Debug, Clone, Decodable, Encodable)]
293pub enum MintRecoveryState {
294    #[encodable(index = 2)]
295    V2(MintRecoveryStateV2),
296    // index 0 has incompatible db encoding, index 1 was skipped to match with V2
297    #[encodable_default]
298    Default { variant: u64, bytes: Vec<u8> },
299}
300
301/// The state machine used for fast-forwarding backup from point when it was
302/// taken to the present time by following epoch history items from the time the
303/// snapshot was taken.
304///
305/// The caller is responsible for creating it, and then feeding it in order all
306/// valid consensus items from the epoch history between time taken (or even
307/// somewhat before it) and present time.
308#[derive(Clone, Eq, PartialEq, Decodable, Encodable, Serialize, Deserialize)]
309pub struct MintRecoveryStateV2 {
310    spendable_notes: BTreeMap<Nonce, (Amount, SpendableNote)>,
311    /// Nonces that we track that are currently spendable.
312    pending_outputs: BTreeMap<Nonce, (OutPoint, Amount, NoteIssuanceRequest)>,
313    /// Next nonces that we expect might soon get used.
314    /// Once we see them, we move the tracking to `pending_outputs`
315    ///
316    /// Note: since looking up nonces is going to be the most common operation
317    /// the pool is kept shared (so only one lookup is enough), and
318    /// replenishment is done each time a note is consumed.
319    pending_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
320    /// Nonces that we have already used. Used for detecting double-used nonces
321    /// (accidentally burning funds).
322    used_nonces: BTreeMap<CompressedBlindedMessage, (NoteIssuanceRequest, NoteIndex, Amount)>,
323    /// Note indices that were reused.
324    reused_note_indices: Vec<(Amount, NoteIndex)>,
325    /// Total amount probably burned due to re-using nonces
326    burned_total: Amount,
327    /// Tail of `pending`. `pending_notes` is filled by generating note with
328    /// this index and incrementing it.
329    next_pending_note_idx: Tiered<NoteIndex>,
330    /// `LastECashNoteIndex` but tracked in flight. Basically max index of any
331    /// note that got a partial sig from the federation (initialled from the
332    /// backup value). TODO: One could imagine a case where the note was
333    /// issued but not get any partial sigs yet. Very unlikely in real life
334    /// scenario, but worth considering.
335    last_used_nonce_idx: Tiered<NoteIndex>,
336    /// Threshold
337    threshold: u64,
338    /// Public key shares for each peer
339    ///
340    /// Used to validate contributed consensus items
341    pub_key_shares: BTreeMap<PeerId, Tiered<PublicKeyShare>>,
342    /// Aggregate public key for each amount tier
343    tbs_pks: Tiered<AggregatePublicKey>,
344    /// The number of nonces we look-ahead when looking for mints (per each
345    /// amount).
346    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    /// Fill each tier pool to the gap limit
410    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    /// Add next nonce from `amount` tier to the `next_pending_note_idx`
418    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                // We attempt to delete any nonce we see as spent, simple
441                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        // There is nothing preventing other users from creating valid
478        // transactions mining notes to our own blind nonce, possibly
479        // even racing with us. Including amount in blind nonce
480        // derivation helps us avoid accidentally using a nonce mined
481        // for as smaller amount, but it doesn't eliminate completely
482        // the possibility that we might use a note mined in a different
483        // transaction, that our original one.
484        // While it is harmless to us, as such duplicated blind nonces are
485        // effective as good the as the original ones (same amount), it
486        // breaks the assumption that all our blind nonces in an our
487        // output need to be in the pending pool. It forces us to be
488        // greedy no matter what and take what we can, and just report
489        // anything suspicious.
490
491        if let Some((issuance_request, note_idx, pending_amount)) =
492            self.pending_nonces.remove(&output.blind_nonce.0.into())
493        {
494            // the moment we see our blind nonce in the epoch history, correctly or
495            // incorrectly used, we know that we must have used
496            // already
497            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                // put it back, incorrect amount
511                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    /// React to a valid pending nonce being tracked being used in the epoch
528    /// history
529    ///
530    /// (Possibly) increment the `self.last_mined_nonce_idx`, then replenish the
531    /// pending pool to always maintain at least `gap_limit` of pending
532    /// nonces in each amount tier.
533    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 is the last one detected as used + 1
567            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}