fedimint_wallet_client/
backup.rs

1mod recovery_history_tracker;
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::{Arc, Mutex};
5
6use fedimint_bitcoind::{BitcoindTracked, DynBitcoindRpc, IBitcoindRpc, 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/// Recovery state for slice-based recovery (V2)
113#[derive(Clone, Debug)]
114pub struct RecoveryStateV2 {
115    /// Scripts we're looking for → `TweakIdx`
116    pub pending_pubkey_scripts: BTreeMap<bitcoin::ScriptBuf, TweakIdx>,
117    /// Next `TweakIdx` to generate
118    pub next_pending_tweak_idx: TweakIdx,
119    /// `TweakIdx`es that were found in history
120    pub used_tweak_idxes: BTreeSet<TweakIdx>,
121    /// Claimed outpoints per `TweakIdx`
122    pub claimed_outpoints: BTreeMap<TweakIdx, Vec<bitcoin::OutPoint>>,
123}
124
125impl RecoveryStateV2 {
126    pub fn new() -> Self {
127        Self {
128            pending_pubkey_scripts: BTreeMap::new(),
129            next_pending_tweak_idx: TweakIdx(0),
130            used_tweak_idxes: BTreeSet::new(),
131            claimed_outpoints: BTreeMap::new(),
132        }
133    }
134
135    pub fn generate_next_pending_script(&mut self, data: &WalletClientModuleData) {
136        let script = data.derive_peg_in_script(self.next_pending_tweak_idx).0;
137
138        self.pending_pubkey_scripts
139            .insert(script, self.next_pending_tweak_idx);
140
141        self.next_pending_tweak_idx = self.next_pending_tweak_idx.next();
142    }
143
144    pub fn refill_pending_pool_up_to(
145        &mut self,
146        data: &WalletClientModuleData,
147        tweak_idx: TweakIdx,
148    ) {
149        while self.next_pending_tweak_idx < tweak_idx {
150            self.generate_next_pending_script(data);
151        }
152    }
153
154    pub fn handle_item(
155        &mut self,
156        outpoint: bitcoin::OutPoint,
157        script: &bitcoin::ScriptBuf,
158        data: &WalletClientModuleData,
159    ) {
160        if let Some(tweak_idx) = self.pending_pubkey_scripts.get(script).copied() {
161            self.used_tweak_idxes.insert(tweak_idx);
162            self.claimed_outpoints
163                .entry(tweak_idx)
164                .or_default()
165                .push(outpoint);
166
167            self.refill_pending_pool_up_to(data, tweak_idx.advance(FEDERATION_RECOVER_MAX_GAP));
168        }
169    }
170
171    pub fn new_start_idx(&self) -> TweakIdx {
172        self.used_tweak_idxes
173            .last()
174            .copied()
175            .unwrap_or(TweakIdx(0))
176            .advance(RECOVER_NUM_IDX_ADD_TO_LAST_USED)
177    }
178}
179
180/// Wallet client module recovery implementation
181///
182/// First, history of Federation is scanned for expected peg-in addresses being
183/// used to find any peg-ins in a perfectly private way.
184///
185/// Then from that point (`TweakIdx`) Bitcoin node is queried for any peg-ins
186/// that might have happened on chain, but not were claimed yet, up to a certain
187/// gap limit.
188///
189/// Eventually last known used `TweakIdx `is moved a bit forward, and that's the
190/// new point a client will use for new peg-ins.
191#[derive(Clone, Debug)]
192pub struct WalletRecovery {
193    state: WalletRecoveryStateV1,
194    data: WalletClientModuleData,
195    btc_rpc: DynBitcoindRpc,
196}
197
198#[apply(async_trait_maybe_send!)]
199impl RecoveryFromHistory for WalletRecovery {
200    type Init = WalletClientInit;
201
202    async fn new(
203        init: &WalletClientInit,
204        args: &ClientModuleRecoverArgs<Self::Init>,
205        snapshot: Option<&WalletModuleBackup>,
206    ) -> anyhow::Result<(Self, u64)> {
207        trace!(target: LOG_CLIENT_MODULE_WALLET, "Starting new recovery");
208
209        let rpc_config = WalletClientModule::get_rpc_config(args.cfg());
210
211        // Priority:
212        // 1. user-provided bitcoind RPC from ClientBuilder::with_bitcoind_rpc
213        // 2. user-provided no-chain-id factory from
214        //    ClientBuilder::with_bitcoind_rpc_no_chain_id
215        // 3. WalletClientInit constructor
216        // 4. create from config (esplora)
217        let btc_rpc = if let Some(user_rpc) = args.user_bitcoind_rpc() {
218            user_rpc.clone()
219        } else if let Some(factory) = args.user_bitcoind_rpc_no_chain_id() {
220            if let Some(rpc) = factory(rpc_config.url.clone()).await {
221                rpc
222            } else {
223                init.0
224                    .clone()
225                    .unwrap_or(create_esplora_rpc(&rpc_config.url)?)
226            }
227        } else {
228            init.0
229                .clone()
230                .unwrap_or(create_esplora_rpc(&rpc_config.url)?)
231        };
232        let btc_rpc = BitcoindTracked::new(btc_rpc, "wallet-recovery").into_dyn();
233
234        let data = WalletClientModuleData {
235            cfg: args.cfg().clone(),
236            module_root_secret: args.module_root_secret().clone(),
237        };
238
239        #[allow(clippy::single_match_else)]
240        let (
241            next_unused_idx_from_backup,
242            start_session_idx,
243            already_claimed_tweak_idxes_from_backup,
244        ) = match snapshot.as_ref() {
245            Some(WalletModuleBackup::V0(backup)) => {
246                debug!(target: LOG_CLIENT_MODULE_WALLET, ?backup, "Restoring starting from an existing backup (v0)");
247
248                (
249                    backup.next_tweak_idx,
250                    backup.session_count.saturating_sub(1),
251                    None,
252                )
253            }
254            Some(WalletModuleBackup::V1(backup)) => {
255                debug!(target: LOG_CLIENT_MODULE_WALLET, ?backup, "Restoring starting from an existing backup (v1)");
256
257                (
258                    backup.next_tweak_idx,
259                    backup.session_count.saturating_sub(1),
260                    Some(backup.already_claimed_tweak_idxes.clone()),
261                )
262            }
263            _ => {
264                debug!(target: LOG_CLIENT_MODULE_WALLET, "Restoring without an existing backup");
265                (TweakIdx(0), 0, None)
266            }
267        };
268
269        // fetch consensus height first
270        let session_count = args
271            .context()
272            .global_api()
273            .session_count()
274            .await?
275            // In case something is off, at least don't panic due to start not being before end
276            .max(start_session_idx);
277
278        debug!(target: LOG_CLIENT_MODULE_WALLET, next_unused_tweak_idx = ?next_unused_idx_from_backup, "Scanning federation history for used peg-in addresses");
279
280        Ok((
281            WalletRecovery {
282                state: WalletRecoveryStateV1 {
283                    snapshot: snapshot.cloned(),
284                    new_start_idx: None,
285                    tweak_idxes_with_pegins: None,
286                    next_unused_idx_from_backup,
287                    already_claimed_tweak_idxes_from_backup,
288                    tracker: ConsensusPegInTweakIdxesUsedTracker::new(
289                        next_unused_idx_from_backup,
290                        start_session_idx,
291                        session_count,
292                        &data,
293                    ),
294                },
295                data,
296                btc_rpc,
297            },
298            start_session_idx,
299        ))
300    }
301
302    async fn load_dbtx(
303        init: &WalletClientInit,
304        dbtx: &mut DatabaseTransaction<'_>,
305        args: &ClientModuleRecoverArgs<Self::Init>,
306    ) -> anyhow::Result<Option<(Self, RecoveryFromHistoryCommon)>> {
307        trace!(target: LOG_CLIENT_MODULE_WALLET, "Loading recovery state");
308
309        let rpc_config = WalletClientModule::get_rpc_config(args.cfg());
310
311        // Priority:
312        // 1. user-provided bitcoind RPC from ClientBuilder::with_bitcoind_rpc
313        // 2. user-provided no-chain-id factory from
314        //    ClientBuilder::with_bitcoind_rpc_no_chain_id
315        // 3. WalletClientInit constructor
316        // 4. create from config (esplora)
317        let btc_rpc = if let Some(user_rpc) = args.user_bitcoind_rpc() {
318            user_rpc.clone()
319        } else if let Some(factory) = args.user_bitcoind_rpc_no_chain_id() {
320            if let Some(rpc) = factory(rpc_config.url.clone()).await {
321                rpc
322            } else {
323                init.0
324                    .clone()
325                    .unwrap_or(create_esplora_rpc(&rpc_config.url)?)
326            }
327        } else {
328            init.0
329                .clone()
330                .unwrap_or(create_esplora_rpc(&rpc_config.url)?)
331        };
332        let btc_rpc = BitcoindTracked::new(btc_rpc, "wallet-recovery").into_dyn();
333
334        let data = WalletClientModuleData {
335            cfg: args.cfg().clone(),
336            module_root_secret: args.module_root_secret().clone(),
337        };
338        Ok(dbtx.get_value(&RecoveryStateKey)
339            .await
340            .and_then(|(state, common)| {
341                if let WalletRecoveryState::V1(state) = state {
342                    Some((state, common))
343                } else {
344                    warn!(target: LOG_CLIENT_RECOVERY, "Found unknown version recovery state. Ignoring");
345                    None
346                }
347            })
348            .map(|(state, common)| {
349                (
350                    WalletRecovery {
351                        state,
352                        data,
353                        btc_rpc,
354                    },
355                    common,
356                )
357            }))
358    }
359
360    async fn store_dbtx(
361        &self,
362        dbtx: &mut DatabaseTransaction<'_>,
363        common: &RecoveryFromHistoryCommon,
364    ) {
365        trace!(target: LOG_CLIENT_MODULE_WALLET, "Storing recovery state");
366        dbtx.insert_entry(
367            &RecoveryStateKey,
368            &(WalletRecoveryState::V1(self.state.clone()), common.clone()),
369        )
370        .await;
371    }
372
373    async fn delete_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) {
374        dbtx.remove_entry(&RecoveryStateKey).await;
375    }
376
377    async fn load_finalized(dbtx: &mut DatabaseTransaction<'_>) -> Option<bool> {
378        dbtx.get_value(&RecoveryFinalizedKey).await
379    }
380
381    async fn store_finalized(dbtx: &mut DatabaseTransaction<'_>, state: bool) {
382        dbtx.insert_entry(&RecoveryFinalizedKey, &state).await;
383    }
384
385    async fn handle_input(
386        &mut self,
387        _client_ctx: &ClientContext<WalletClientModule>,
388        _idx: usize,
389        input: &WalletInput,
390        session_idx: u64,
391    ) -> anyhow::Result<()> {
392        let script_pubkey = match input {
393            WalletInput::V0(WalletInputV0(input)) => &input.tx_output().script_pubkey,
394            WalletInput::V1(input) => &input.tx_out.script_pubkey,
395            WalletInput::Default {
396                variant: _,
397                bytes: _,
398            } => {
399                return Ok(());
400            }
401        };
402
403        self.state
404            .tracker
405            .handle_script(&self.data, script_pubkey, session_idx);
406
407        Ok(())
408    }
409
410    async fn pre_finalize(&mut self) -> anyhow::Result<()> {
411        let data = &self.data;
412        let btc_rpc = &self.btc_rpc;
413        // Due to lifetime in async context issue, this one is cloned and wrapped in a
414        // mutex
415        let tracker = &Arc::new(Mutex::new(self.state.tracker.clone()));
416
417        debug!(target: LOG_CLIENT_MODULE_WALLET,
418            next_unused_tweak_idx = ?self.state.next_unused_idx_from_backup,
419            "Scanning blockchain for used peg-in addresses");
420        let RecoverScanOutcome { last_used_idx: _, new_start_idx, tweak_idxes_with_pegins}
421            = recover_scan_idxes_for_activity(
422                if self.state.already_claimed_tweak_idxes_from_backup.is_some() {
423                    // If the backup contains list of already claimed tweak_indices, we can just scan
424                    // the blockchain addresses starting from tweakidx `0`, without losing too much privacy,
425                    // as we will skip all the idxes that had peg-ins already
426                    TweakIdx(0)
427                } else {
428                    // If backup didn't have it, we just start from the last derived address from backup (or 0 otherwise).
429                    self.state.next_unused_idx_from_backup
430                },
431                &self.state.tracker.used_tweak_idxes()
432                    .union(&self.state.already_claimed_tweak_idxes_from_backup.clone().unwrap_or_default())
433                    .copied().collect(),
434                |cur_tweak_idx: TweakIdx|
435                async move {
436
437                    let (script, address, _tweak_key, _operation_id) =
438                    data.derive_peg_in_script(cur_tweak_idx);
439
440                    // Randomly query for the decoy before or after our own address
441                    let use_decoy_before_real_query : bool = rand::random();
442                    let decoy = tracker.lock().expect("locking failed").pop_decoy();
443
444                    let use_decoy = || async {
445                        if let Some(decoy) = decoy.as_ref() {
446                            btc_rpc.watch_script_history(decoy).await?;
447                            let _ = btc_rpc.get_script_history(decoy).await?;
448                        }
449                        Ok::<_, anyhow::Error>(())
450                    };
451
452                    if use_decoy_before_real_query {
453                        use_decoy().await?;
454                    }
455                    btc_rpc.watch_script_history(&script).await?;
456                    let history = btc_rpc.get_script_history(&script).await?;
457
458                    if !use_decoy_before_real_query {
459                        use_decoy().await?;
460                    }
461
462                    debug!(target: LOG_CLIENT_MODULE_WALLET, %cur_tweak_idx, %address, history_len=history.len(), "Checked address");
463
464                    Ok(history)
465                }).await?;
466
467        self.state.new_start_idx = Some(new_start_idx);
468        self.state.tweak_idxes_with_pegins = Some(tweak_idxes_with_pegins);
469
470        Ok(())
471    }
472
473    async fn finalize_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
474        let now = fedimint_core::time::now();
475
476        let mut tweak_idx = TweakIdx(0);
477
478        let new_start_idx = self
479            .state
480            .new_start_idx
481            .expect("Must have new_star_idx already set by previous steps");
482
483        let tweak_idxes_with_pegins = self
484            .state
485            .tweak_idxes_with_pegins
486            .clone()
487            .expect("Must be set by previous steps");
488
489        debug!(target: LOG_CLIENT_MODULE_WALLET, ?new_start_idx, "Finalizing recovery");
490
491        while tweak_idx < new_start_idx {
492            let (_script, _address, _tweak_key, operation_id) =
493                self.data.derive_peg_in_script(tweak_idx);
494            dbtx.insert_new_entry(
495                &PegInTweakIndexKey(tweak_idx),
496                &PegInTweakIndexData {
497                    creation_time: now,
498                    next_check_time: if tweak_idxes_with_pegins.contains(&tweak_idx) {
499                        // The addresses that were already used before, or didn't seem to
500                        // contain anything don't need automatic
501                        // peg-in attempt, and can be re-attempted
502                        // manually if needed.
503                        Some(now)
504                    } else {
505                        None
506                    },
507                    last_check_time: None,
508                    operation_id,
509                    claimed: vec![],
510                },
511            )
512            .await;
513            tweak_idx = tweak_idx.next();
514        }
515
516        dbtx.insert_new_entry(&NextPegInTweakIndexKey, &new_start_idx)
517            .await;
518        Ok(())
519    }
520}
521
522/// We will check this many addresses after last actually used
523/// one before we give up
524pub(crate) const ONCHAIN_RECOVER_MAX_GAP: u64 = 10;
525
526/// When scanning the history of the Federation, there's no need to be
527/// so cautious about the privacy (as it's perfectly private), so might
528/// as well increase the gap limit.
529pub(crate) const FEDERATION_RECOVER_MAX_GAP: u64 = 50;
530
531/// New client will start deriving new addresses from last used one
532/// plus that many indexes. This should be less than
533/// `MAX_GAP`, but more than 0: We want to make sure we detect
534/// deposits that might have been made after multiple successive recoveries,
535/// but we want also to avoid accidental address re-use.
536pub(crate) const RECOVER_NUM_IDX_ADD_TO_LAST_USED: u64 = 8;
537
538#[derive(Clone, PartialEq, Eq, Debug)]
539pub(crate) struct RecoverScanOutcome {
540    pub(crate) last_used_idx: Option<TweakIdx>,
541    pub(crate) new_start_idx: TweakIdx,
542    pub(crate) tweak_idxes_with_pegins: BTreeSet<TweakIdx>,
543}
544
545/// A part of `WalletClientInit::recover` extracted out to be easy to
546/// test, as a side-effect free.
547pub(crate) async fn recover_scan_idxes_for_activity<F, FF, T>(
548    scan_from_idx: TweakIdx,
549    used_tweak_idxes: &BTreeSet<TweakIdx>,
550    check_addr_history: F,
551) -> anyhow::Result<RecoverScanOutcome>
552where
553    F: Fn(TweakIdx) -> FF,
554    FF: Future<Output = anyhow::Result<Vec<T>>>,
555{
556    let tweak_indexes_to_scan = (scan_from_idx.0..).map(TweakIdx).filter(|tweak_idx| {
557        let already_used = used_tweak_idxes.contains(tweak_idx);
558
559        if already_used {
560            debug!(target: LOG_CLIENT_MODULE_WALLET,
561                %tweak_idx,
562                "Skipping checking history of an address, as it was previously used"
563            );
564        }
565
566        !already_used
567    });
568
569    // Last tweak index which had on-chain activity, used to implement a gap limit,
570    // i.e. scanning a certain number of addresses past the last one that had
571    // activity.
572    let mut last_used_idx = used_tweak_idxes.last().copied();
573    // When we didn't find any used idx yet, assume that last one before
574    // `scan_from_idx` was used.
575    let fallback_last_used_idx = scan_from_idx.prev().unwrap_or_default();
576    let mut tweak_idxes_with_pegins = BTreeSet::new();
577
578    for cur_tweak_idx in tweak_indexes_to_scan {
579        if ONCHAIN_RECOVER_MAX_GAP
580            <= cur_tweak_idx.saturating_sub(last_used_idx.unwrap_or(fallback_last_used_idx))
581        {
582            break;
583        }
584
585        let history = retry(
586            "Check address history",
587            backoff_util::background_backoff(),
588            || async { check_addr_history(cur_tweak_idx).await },
589        )
590        .await?;
591
592        if !history.is_empty() {
593            tweak_idxes_with_pegins.insert(cur_tweak_idx);
594            last_used_idx = Some(cur_tweak_idx);
595        }
596    }
597
598    let new_start_idx = last_used_idx
599        .unwrap_or(fallback_last_used_idx)
600        .advance(RECOVER_NUM_IDX_ADD_TO_LAST_USED);
601
602    Ok(RecoverScanOutcome {
603        last_used_idx,
604        new_start_idx,
605        tweak_idxes_with_pegins,
606    })
607}