fedimint_mint_client/
repair_wallet.rs

1use std::collections::BTreeMap;
2
3use fedimint_api_client::api::DynModuleApi;
4use fedimint_core::db::IDatabaseTransactionOpsCoreTyped;
5use fedimint_core::util::backoff_util::aggressive_backoff;
6use fedimint_core::util::retry;
7use fedimint_core::{Amount, TieredCounts};
8use futures::{StreamExt, TryStreamExt, stream};
9
10use crate::api::MintFederationApi;
11use crate::client_db::{
12    NextECashNoteIndexKey, NextECashNoteIndexKeyPrefix, NoteKey, NoteKeyPrefix,
13};
14use crate::output::NoteIssuanceRequest;
15use crate::{MintClientModule, NoteIndex};
16
17const CHECK_PARALLELISM: usize = 16;
18
19#[derive(Debug, Clone, Default)]
20pub struct RepairSummary {
21    /// Number of e-cash notes that were found to be spent and removed from the
22    /// wallet per denomination
23    pub spent_notes: TieredCounts,
24    /// Denomination of which e-cash nonces were found to be used already and
25    /// were skipped
26    ///
27    /// Note: if this is non-empty the correct approach is doing a full
28    /// from-scratch recovery, otherwise we might not be aware of unspent notes
29    /// issued to us.
30    pub used_indices: TieredCounts,
31}
32
33impl MintClientModule {
34    /// Attempts to fix inconsistent wallet states. **Breaks privacy guarantees
35    /// and is destructive!**
36    ///
37    /// Invalid states that are fixable this way:
38    ///   * Already-spent e-cash being in the wallet
39    ///   * E-cash nonces that would be used to issue new notes already being
40    ///     used
41    ///
42    /// When invalid notes are found, they are removed from the wallet. Make
43    /// sure that the user has a backup of their seed before running this
44    /// function.
45    pub async fn try_repair_wallet(&self, gap_limit: u64) -> anyhow::Result<RepairSummary> {
46        let mut summary = RepairSummary::default();
47
48        let module_api = self.client_ctx.module_api();
49        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
50
51        // First check if any of our notes are already spent and remove them
52        let spent_notes: Vec<NoteKey> = dbtx
53            .find_by_prefix_sorted_descending(&NoteKeyPrefix)
54            .await
55            .map(|(key, _)| {
56                let module_api_inner = module_api.clone();
57                async move {
58                    let spent = retry("fetch e-cash spentness", aggressive_backoff(), || async {
59                        Ok(module_api_inner.check_note_spent(key.nonce).await?)
60                    })
61                    .await?;
62                    anyhow::Ok(if spent { Some(key) } else { None })
63                }
64            })
65            .buffer_unordered(CHECK_PARALLELISM)
66            .try_filter_map(|result| async move { Ok(result) })
67            .try_collect()
68            .await?;
69
70        for note_key in spent_notes {
71            summary.spent_notes.inc(note_key.amount, 1);
72            dbtx.remove_entry(&note_key).await;
73        }
74
75        let next_indices: BTreeMap<_, _> = {
76            let mut db_next_indexes = dbtx
77                .find_by_prefix_sorted_descending(&NextECashNoteIndexKeyPrefix)
78                .await
79                .map(|(key, idx)| (key.0, idx))
80                .collect::<BTreeMap<_, _>>()
81                .await;
82
83            self.cfg
84                .tbs_pks
85                .tiers()
86                .map(|&denomination| {
87                    (
88                        denomination,
89                        db_next_indexes.remove(&denomination).unwrap_or_default(),
90                    )
91                })
92                .collect()
93        };
94
95        // Next check if any of the indices for issuing new notes are already used
96        let used_nonces = stream::iter(next_indices.into_iter())
97            .map(|(amount, original_next_index)| {
98                let module_api_inner = module_api.clone();
99                async move {
100                    let mut next_index = original_next_index;
101                    let maybe_advanced_index = loop {
102                        let maybe_nonce_gap = self
103                            .gap_till_next_nonce_used(
104                                &module_api_inner,
105                                amount,
106                                next_index,
107                                gap_limit,
108                            )
109                            .await?;
110
111                        if let Some(gap) = maybe_nonce_gap {
112                            // If the nonce was already used, try again with the next index
113                            next_index += gap + 1;
114                        } else if original_next_index == next_index {
115                            // If the initial nonce wasn't used we are good, nothing to be done
116                            break None;
117                        } else {
118                            // If the initial nonce was used but we found an unused one by now,
119                            // report the used index
120                            break Some((amount, next_index));
121                        }
122                    };
123
124                    Result::<_, anyhow::Error>::Ok(maybe_advanced_index)
125                }
126            })
127            .buffer_unordered(CHECK_PARALLELISM)
128            .try_filter_map(|advanced_index| async move { Ok(advanced_index) })
129            .try_collect::<Vec<_>>()
130            .await?;
131
132        for (amount, next_index) in used_nonces {
133            let old_index = dbtx
134                .insert_entry(&NextECashNoteIndexKey(amount), &next_index)
135                .await
136                .unwrap_or_default();
137            summary
138                .used_indices
139                .inc(amount, (next_index - old_index) as usize);
140        }
141
142        dbtx.commit_tx().await;
143        Ok(summary)
144    }
145
146    /// Checks up to `gap_limit` nonces starting from `base_index` for having
147    /// being used already.
148    ///
149    /// If the nonce at `base_index` is used, returns `Some(0)`, if it's unused
150    /// returns `None`. If there's an unused nonce and then a used one returns
151    /// `Some(1)`.
152    async fn gap_till_next_nonce_used(
153        &self,
154        module_api: &DynModuleApi,
155        amount: Amount,
156        base_index: u64,
157        gap_limit: u64,
158    ) -> anyhow::Result<Option<u64>> {
159        for gap in 0..gap_limit {
160            let idx = base_index + gap;
161            let note_secret = Self::new_note_secret_static(&self.secret, amount, NoteIndex(idx));
162            let (_, blind_nonce) = NoteIssuanceRequest::new(&self.secp, &note_secret);
163            let nonce_used = retry(
164                "checking if blind nonce was already used",
165                aggressive_backoff(),
166                || async { Ok(module_api.check_blind_nonce_used(blind_nonce).await?) },
167            )
168            .await?;
169            if nonce_used {
170                return Ok(Some(gap));
171            }
172        }
173        Ok(None)
174    }
175}