Skip to main content

fedimint_wallet_client/
backup.rs

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