fedimint_wallet_client/
backup.rs

1mod recovery_history_tracker;
2
3use std::collections::BTreeSet;
4use std::sync::{Arc, Mutex};
5
6use fedimint_bitcoind::{DynBitcoindRpc, create_esplora_rpc};
7use fedimint_client_module::module::ClientContext;
8use fedimint_client_module::module::init::ClientModuleRecoverArgs;
9use fedimint_client_module::module::init::recovery::{
10    RecoveryFromHistory, RecoveryFromHistoryCommon,
11};
12use fedimint_client_module::module::recovery::{DynModuleBackup, ModuleBackup};
13use fedimint_core::core::{IntoDynInstance, ModuleInstanceId, ModuleKind};
14use fedimint_core::db::{DatabaseTransaction, IDatabaseTransactionOpsCoreTyped as _};
15use fedimint_core::encoding::{Decodable, Encodable};
16use fedimint_core::util::{backoff_util, retry};
17use fedimint_core::{apply, async_trait_maybe_send};
18use fedimint_logging::{LOG_CLIENT_MODULE_WALLET, LOG_CLIENT_RECOVERY};
19use fedimint_wallet_common::{KIND, WalletInput, WalletInputV0};
20use futures::Future;
21use tracing::{debug, trace, warn};
22
23use self::recovery_history_tracker::ConsensusPegInTweakIdxesUsedTracker;
24use crate::client_db::{
25    NextPegInTweakIndexKey, PegInTweakIndexData, PegInTweakIndexKey, RecoveryFinalizedKey,
26    RecoveryStateKey, TweakIdx,
27};
28use crate::{WalletClientInit, WalletClientModule, WalletClientModuleData};
29
30#[derive(Clone, PartialEq, Eq, Debug, Encodable, Decodable)]
31pub enum WalletModuleBackup {
32    V0(WalletModuleBackupV0),
33    V1(WalletModuleBackupV1),
34    #[encodable_default]
35    Default {
36        variant: u64,
37        bytes: Vec<u8>,
38    },
39}
40
41impl IntoDynInstance for WalletModuleBackup {
42    type DynType = DynModuleBackup;
43
44    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
45        DynModuleBackup::from_typed(instance_id, self)
46    }
47}
48
49impl ModuleBackup for WalletModuleBackup {
50    const KIND: Option<ModuleKind> = Some(KIND);
51}
52
53impl WalletModuleBackup {
54    pub fn new_v1(
55        session_count: u64,
56        next_tweak_idx: TweakIdx,
57        already_claimed_tweak_idxes: BTreeSet<TweakIdx>,
58    ) -> WalletModuleBackup {
59        WalletModuleBackup::V1(WalletModuleBackupV1 {
60            session_count,
61            next_tweak_idx,
62            already_claimed_tweak_idxes,
63        })
64    }
65}
66
67#[derive(Clone, PartialEq, Eq, Debug, Encodable, Decodable)]
68pub struct WalletModuleBackupV0 {
69    pub session_count: u64,
70    pub next_tweak_idx: TweakIdx,
71}
72
73#[derive(Clone, PartialEq, Eq, Debug, Encodable, Decodable)]
74pub struct WalletModuleBackupV1 {
75    pub session_count: u64,
76    pub next_tweak_idx: TweakIdx,
77    pub already_claimed_tweak_idxes: BTreeSet<TweakIdx>,
78}
79
80#[derive(Debug, Clone, Decodable, Encodable)]
81pub struct WalletRecoveryStateV0 {
82    snapshot: Option<WalletModuleBackup>,
83    next_unused_idx_from_backup: TweakIdx,
84    new_start_idx: Option<TweakIdx>,
85    tweak_idxes_with_pegins: Option<BTreeSet<TweakIdx>>,
86    tracker: ConsensusPegInTweakIdxesUsedTracker,
87}
88
89#[derive(Debug, Clone, Decodable, Encodable)]
90pub struct WalletRecoveryStateV1 {
91    snapshot: Option<WalletModuleBackup>,
92    next_unused_idx_from_backup: TweakIdx,
93    // If `Some` - backup contained information about which tweak idxes were already claimed (the
94    // set can still be empty). If `None` - backup version did not contain that information.
95    already_claimed_tweak_idxes_from_backup: Option<BTreeSet<TweakIdx>>,
96    new_start_idx: Option<TweakIdx>,
97    tweak_idxes_with_pegins: Option<BTreeSet<TweakIdx>>,
98    tracker: ConsensusPegInTweakIdxesUsedTracker,
99}
100
101#[derive(Debug, Clone, Decodable, Encodable)]
102pub enum WalletRecoveryState {
103    V0(WalletRecoveryStateV0),
104    V1(WalletRecoveryStateV1),
105    #[encodable_default]
106    Default {
107        variant: u64,
108        bytes: Vec<u8>,
109    },
110}
111
112/// Wallet client module recovery implementation
113///
114/// First, history of Federation is scanned for expected peg-in addresses being
115/// used to find any peg-ins in a perfectly private way.
116///
117/// Then from that point (`TweakIdx`) Bitcoin node is queried for any peg-ins
118/// that might have happened on chain, but not were claimed yet, up to a certain
119/// gap limit.
120///
121/// Eventually last known used `TweakIdx `is moved a bit forward, and that's the
122/// new point a client will use for new peg-ins.
123#[derive(Clone, Debug)]
124pub struct WalletRecovery {
125    state: WalletRecoveryStateV1,
126    data: WalletClientModuleData,
127    btc_rpc: DynBitcoindRpc,
128}
129
130#[apply(async_trait_maybe_send!)]
131impl RecoveryFromHistory for WalletRecovery {
132    type Init = WalletClientInit;
133
134    async fn new(
135        init: &WalletClientInit,
136        args: &ClientModuleRecoverArgs<Self::Init>,
137        snapshot: Option<&WalletModuleBackup>,
138    ) -> anyhow::Result<(Self, u64)> {
139        trace!(target: LOG_CLIENT_MODULE_WALLET, "Starting new recovery");
140        let btc_rpc = init.0.clone().unwrap_or(create_esplora_rpc(
141            &WalletClientModule::get_rpc_config(args.cfg()).url,
142        )?);
143
144        let data = WalletClientModuleData {
145            cfg: args.cfg().clone(),
146            module_root_secret: args.module_root_secret().clone(),
147        };
148
149        #[allow(clippy::single_match_else)]
150        let (
151            next_unused_idx_from_backup,
152            start_session_idx,
153            already_claimed_tweak_idxes_from_backup,
154        ) = match snapshot.as_ref() {
155            Some(WalletModuleBackup::V0(backup)) => {
156                debug!(target: LOG_CLIENT_MODULE_WALLET, ?backup, "Restoring starting from an existing backup (v0)");
157
158                (
159                    backup.next_tweak_idx,
160                    backup.session_count.saturating_sub(1),
161                    None,
162                )
163            }
164            Some(WalletModuleBackup::V1(backup)) => {
165                debug!(target: LOG_CLIENT_MODULE_WALLET, ?backup, "Restoring starting from an existing backup (v1)");
166
167                (
168                    backup.next_tweak_idx,
169                    backup.session_count.saturating_sub(1),
170                    Some(backup.already_claimed_tweak_idxes.clone()),
171                )
172            }
173            _ => {
174                debug!(target: LOG_CLIENT_MODULE_WALLET, "Restoring without an existing backup");
175                (TweakIdx(0), 0, None)
176            }
177        };
178
179        // fetch consensus height first
180        let session_count = args
181            .context()
182            .global_api()
183            .session_count()
184            .await?
185            // In case something is off, at least don't panic due to start not being before end
186            .max(start_session_idx);
187
188        debug!(target: LOG_CLIENT_MODULE_WALLET, next_unused_tweak_idx = ?next_unused_idx_from_backup, "Scanning federation history for used peg-in addresses");
189
190        Ok((
191            WalletRecovery {
192                state: WalletRecoveryStateV1 {
193                    snapshot: snapshot.cloned(),
194                    new_start_idx: None,
195                    tweak_idxes_with_pegins: None,
196                    next_unused_idx_from_backup,
197                    already_claimed_tweak_idxes_from_backup,
198                    tracker: ConsensusPegInTweakIdxesUsedTracker::new(
199                        next_unused_idx_from_backup,
200                        start_session_idx,
201                        session_count,
202                        &data,
203                    ),
204                },
205                data,
206                btc_rpc,
207            },
208            start_session_idx,
209        ))
210    }
211
212    async fn load_dbtx(
213        init: &WalletClientInit,
214        dbtx: &mut DatabaseTransaction<'_>,
215        args: &ClientModuleRecoverArgs<Self::Init>,
216    ) -> anyhow::Result<Option<(Self, RecoveryFromHistoryCommon)>> {
217        trace!(target: LOG_CLIENT_MODULE_WALLET, "Loading recovery state");
218        let btc_rpc = init.0.clone().unwrap_or(create_esplora_rpc(
219            &WalletClientModule::get_rpc_config(args.cfg()).url,
220        )?);
221
222        let data = WalletClientModuleData {
223            cfg: args.cfg().clone(),
224            module_root_secret: args.module_root_secret().clone(),
225        };
226        Ok(dbtx.get_value(&RecoveryStateKey)
227            .await
228            .and_then(|(state, common)| {
229                if let WalletRecoveryState::V1(state) = state {
230                    Some((state, common))
231                } else {
232                    warn!(target: LOG_CLIENT_RECOVERY, "Found unknown version recovery state. Ignoring");
233                    None
234                }
235            })
236            .map(|(state, common)| {
237                (
238                    WalletRecovery {
239                        state,
240                        data,
241                        btc_rpc,
242                    },
243                    common,
244                )
245            }))
246    }
247
248    async fn store_dbtx(
249        &self,
250        dbtx: &mut DatabaseTransaction<'_>,
251        common: &RecoveryFromHistoryCommon,
252    ) {
253        trace!(target: LOG_CLIENT_MODULE_WALLET, "Storing recovery state");
254        dbtx.insert_entry(
255            &RecoveryStateKey,
256            &(WalletRecoveryState::V1(self.state.clone()), common.clone()),
257        )
258        .await;
259    }
260
261    async fn delete_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) {
262        dbtx.remove_entry(&RecoveryStateKey).await;
263    }
264
265    async fn load_finalized(dbtx: &mut DatabaseTransaction<'_>) -> Option<bool> {
266        dbtx.get_value(&RecoveryFinalizedKey).await
267    }
268
269    async fn store_finalized(dbtx: &mut DatabaseTransaction<'_>, state: bool) {
270        dbtx.insert_entry(&RecoveryFinalizedKey, &state).await;
271    }
272
273    async fn handle_input(
274        &mut self,
275        _client_ctx: &ClientContext<WalletClientModule>,
276        _idx: usize,
277        input: &WalletInput,
278        session_idx: u64,
279    ) -> anyhow::Result<()> {
280        let script_pubkey = match input {
281            WalletInput::V0(WalletInputV0(input)) => &input.tx_output().script_pubkey,
282            WalletInput::V1(input) => &input.tx_out.script_pubkey,
283            WalletInput::Default {
284                variant: _,
285                bytes: _,
286            } => {
287                return Ok(());
288            }
289        };
290
291        self.state
292            .tracker
293            .handle_script(&self.data, script_pubkey, session_idx);
294
295        Ok(())
296    }
297
298    async fn pre_finalize(&mut self) -> anyhow::Result<()> {
299        let data = &self.data;
300        let btc_rpc = &self.btc_rpc;
301        // Due to lifetime in async context issue, this one is cloned and wrapped in a
302        // mutex
303        let tracker = &Arc::new(Mutex::new(self.state.tracker.clone()));
304
305        debug!(target: LOG_CLIENT_MODULE_WALLET,
306            next_unused_tweak_idx = ?self.state.next_unused_idx_from_backup,
307            "Scanning blockchain for used peg-in addresses");
308        let RecoverScanOutcome { last_used_idx: _, new_start_idx, tweak_idxes_with_pegins}
309            = recover_scan_idxes_for_activity(
310                if self.state.already_claimed_tweak_idxes_from_backup.is_some() {
311                    // If the backup contains list of already claimed tweak_idexes, we can just scan
312                    // the blockchain addresses starting from tweakidx `0`, without loosing too much privacy,
313                    // as we will skip all the idxes that had peg-ins already
314                    TweakIdx(0)
315                } else {
316                    // If backup didn't have it, we just start from the last derived address from backup (or 0 otherwise).
317                    self.state.next_unused_idx_from_backup
318                },
319                &self.state.tracker.used_tweak_idxes()
320                    .union(&self.state.already_claimed_tweak_idxes_from_backup.clone().unwrap_or_default())
321                    .copied().collect(),
322                |cur_tweak_idx: TweakIdx|
323                async move {
324
325                    let (script, address, _tweak_key, _operation_id) =
326                    data.derive_peg_in_script(cur_tweak_idx);
327
328                    // Randomly query for the decoy before or after our own address
329                    let use_decoy_before_real_query : bool = rand::random();
330                    let decoy = tracker.lock().expect("locking failed").pop_decoy();
331
332                    let use_decoy = || async {
333                        if let Some(decoy) = decoy.as_ref() {
334                            let _ = btc_rpc.get_script_history(decoy).await?;
335                        }
336                        Ok::<_, anyhow::Error>(())
337                    };
338
339                    if use_decoy_before_real_query {
340                        use_decoy().await?;
341                    }
342                    let history = btc_rpc.get_script_history(&script).await?;
343
344                    if !use_decoy_before_real_query {
345                        use_decoy().await?;
346                    }
347
348                    debug!(target: LOG_CLIENT_MODULE_WALLET, %cur_tweak_idx, %address, history_len=history.len(), "Checked address");
349
350                    Ok(history)
351                }).await?;
352
353        self.state.new_start_idx = Some(new_start_idx);
354        self.state.tweak_idxes_with_pegins = Some(tweak_idxes_with_pegins);
355
356        Ok(())
357    }
358
359    async fn finalize_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
360        let now = fedimint_core::time::now();
361
362        let mut tweak_idx = TweakIdx(0);
363
364        let new_start_idx = self
365            .state
366            .new_start_idx
367            .expect("Must have new_star_idx already set by previous steps");
368
369        let tweak_idxes_with_pegins = self
370            .state
371            .tweak_idxes_with_pegins
372            .clone()
373            .expect("Must be set by previous steps");
374
375        debug!(target: LOG_CLIENT_MODULE_WALLET, ?new_start_idx, "Finalizing recovery");
376
377        while tweak_idx < new_start_idx {
378            let (_script, _address, _tweak_key, operation_id) =
379                self.data.derive_peg_in_script(tweak_idx);
380            dbtx.insert_new_entry(
381                &PegInTweakIndexKey(tweak_idx),
382                &PegInTweakIndexData {
383                    creation_time: now,
384                    next_check_time: if tweak_idxes_with_pegins.contains(&tweak_idx) {
385                        // The addresses that were already used before, or didn't seem to
386                        // contain anything don't need automatic
387                        // peg-in attempt, and can be re-attempted
388                        // manually if needed.
389                        Some(now)
390                    } else {
391                        None
392                    },
393                    last_check_time: None,
394                    operation_id,
395                    claimed: vec![],
396                },
397            )
398            .await;
399            tweak_idx = tweak_idx.next();
400        }
401
402        dbtx.insert_new_entry(&NextPegInTweakIndexKey, &new_start_idx)
403            .await;
404        Ok(())
405    }
406}
407
408/// We will check this many addresses after last actually used
409/// one before we give up
410pub(crate) const ONCHAIN_RECOVER_MAX_GAP: u64 = 10;
411
412/// When scanning the history of the Federation, there's no need to be
413/// so cautious about the privacy (as it's perfectly private), so might
414/// as well increase the gap limit.
415pub(crate) const FEDERATION_RECOVER_MAX_GAP: u64 = 50;
416
417/// New client will start deriving new addresses from last used one
418/// plus that many indexes. This should be less than
419/// `MAX_GAP`, but more than 0: We want to make sure we detect
420/// deposits that might have been made after multiple successive recoveries,
421/// but we want also to avoid accidental address re-use.
422pub(crate) const RECOVER_NUM_IDX_ADD_TO_LAST_USED: u64 = 8;
423
424#[derive(Clone, PartialEq, Eq, Debug)]
425pub(crate) struct RecoverScanOutcome {
426    pub(crate) last_used_idx: Option<TweakIdx>,
427    pub(crate) new_start_idx: TweakIdx,
428    pub(crate) tweak_idxes_with_pegins: BTreeSet<TweakIdx>,
429}
430
431/// A part of `WalletClientInit::recover` extracted out to be easy to
432/// test, as a side-effect free.
433pub(crate) async fn recover_scan_idxes_for_activity<F, FF, T>(
434    scan_from_idx: TweakIdx,
435    used_tweak_idxes: &BTreeSet<TweakIdx>,
436    check_addr_history: F,
437) -> anyhow::Result<RecoverScanOutcome>
438where
439    F: Fn(TweakIdx) -> FF,
440    FF: Future<Output = anyhow::Result<Vec<T>>>,
441{
442    let tweak_indexes_to_scan = (scan_from_idx.0..).map(TweakIdx).filter(|tweak_idx| {
443        let already_used = used_tweak_idxes.contains(tweak_idx);
444
445        if already_used {
446            debug!(target: LOG_CLIENT_MODULE_WALLET,
447                %tweak_idx,
448                "Skipping checking history of an address, as it was previously used"
449            );
450        }
451
452        !already_used
453    });
454
455    // Last tweak index which had on-chain activity, used to implement a gap limit,
456    // i.e. scanning a certain number of addresses past the last one that had
457    // activity.
458    let mut last_used_idx = used_tweak_idxes.last().copied();
459    // When we didn't find any used idx yet, assume that last one before
460    // `scan_from_idx` was used.
461    let fallback_last_used_idx = scan_from_idx.prev().unwrap_or_default();
462    let mut tweak_idxes_with_pegins = BTreeSet::new();
463
464    for cur_tweak_idx in tweak_indexes_to_scan {
465        if ONCHAIN_RECOVER_MAX_GAP
466            <= cur_tweak_idx.saturating_sub(last_used_idx.unwrap_or(fallback_last_used_idx))
467        {
468            break;
469        }
470
471        let history = retry(
472            "Check address history",
473            backoff_util::background_backoff(),
474            || async { check_addr_history(cur_tweak_idx).await },
475        )
476        .await?;
477
478        if !history.is_empty() {
479            tweak_idxes_with_pegins.insert(cur_tweak_idx);
480            last_used_idx = Some(cur_tweak_idx);
481        }
482    }
483
484    let new_start_idx = last_used_idx
485        .unwrap_or(fallback_last_used_idx)
486        .advance(RECOVER_NUM_IDX_ADD_TO_LAST_USED);
487
488    Ok(RecoverScanOutcome {
489        last_used_idx,
490        new_start_idx,
491        tweak_idxes_with_pegins,
492    })
493}