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_bitcoind};
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 rpc_config = init
141            .0
142            .clone()
143            .unwrap_or(WalletClientModule::get_rpc_config(args.cfg()));
144
145        let btc_rpc = create_bitcoind(&rpc_config)?;
146
147        let data = WalletClientModuleData {
148            cfg: args.cfg().clone(),
149            module_root_secret: args.module_root_secret().clone(),
150        };
151
152        #[allow(clippy::single_match_else)]
153        let (
154            next_unused_idx_from_backup,
155            start_session_idx,
156            already_claimed_tweak_idxes_from_backup,
157        ) = match snapshot.as_ref() {
158            Some(WalletModuleBackup::V0(backup)) => {
159                debug!(target: LOG_CLIENT_MODULE_WALLET, ?backup, "Restoring starting from an existing backup (v0)");
160
161                (
162                    backup.next_tweak_idx,
163                    backup.session_count.saturating_sub(1),
164                    None,
165                )
166            }
167            Some(WalletModuleBackup::V1(backup)) => {
168                debug!(target: LOG_CLIENT_MODULE_WALLET, ?backup, "Restoring starting from an existing backup (v1)");
169
170                (
171                    backup.next_tweak_idx,
172                    backup.session_count.saturating_sub(1),
173                    Some(backup.already_claimed_tweak_idxes.clone()),
174                )
175            }
176            _ => {
177                debug!(target: LOG_CLIENT_MODULE_WALLET, "Restoring without an existing backup");
178                (TweakIdx(0), 0, None)
179            }
180        };
181
182        // fetch consensus height first
183        let session_count = args
184            .context()
185            .global_api()
186            .session_count()
187            .await?
188            // In case something is off, at least don't panic due to start not being before end
189            .max(start_session_idx);
190
191        debug!(target: LOG_CLIENT_MODULE_WALLET, next_unused_tweak_idx = ?next_unused_idx_from_backup, "Scanning federation history for used peg-in addresses");
192
193        Ok((
194            WalletRecovery {
195                state: WalletRecoveryStateV1 {
196                    snapshot: snapshot.cloned(),
197                    new_start_idx: None,
198                    tweak_idxes_with_pegins: None,
199                    next_unused_idx_from_backup,
200                    already_claimed_tweak_idxes_from_backup,
201                    tracker: ConsensusPegInTweakIdxesUsedTracker::new(
202                        next_unused_idx_from_backup,
203                        start_session_idx,
204                        session_count,
205                        &data,
206                    ),
207                },
208                data,
209                btc_rpc,
210            },
211            start_session_idx,
212        ))
213    }
214
215    async fn load_dbtx(
216        init: &WalletClientInit,
217        dbtx: &mut DatabaseTransaction<'_>,
218        args: &ClientModuleRecoverArgs<Self::Init>,
219    ) -> anyhow::Result<Option<(Self, RecoveryFromHistoryCommon)>> {
220        trace!(target: LOG_CLIENT_MODULE_WALLET, "Loading recovery state");
221        let rpc_config = init
222            .0
223            .clone()
224            .unwrap_or(WalletClientModule::get_rpc_config(args.cfg()));
225
226        let btc_rpc = create_bitcoind(&rpc_config)?;
227
228        let data = WalletClientModuleData {
229            cfg: args.cfg().clone(),
230            module_root_secret: args.module_root_secret().clone(),
231        };
232        Ok(dbtx.get_value(&RecoveryStateKey)
233            .await
234            .and_then(|(state, common)| {
235                if let WalletRecoveryState::V1(state) = state {
236                    Some((state, common))
237                } else {
238                    warn!(target: LOG_CLIENT_RECOVERY, "Found unknown version recovery state. Ignoring");
239                    None
240                }
241            })
242            .map(|(state, common)| {
243                (
244                    WalletRecovery {
245                        state,
246                        data,
247                        btc_rpc,
248                    },
249                    common,
250                )
251            }))
252    }
253
254    async fn store_dbtx(
255        &self,
256        dbtx: &mut DatabaseTransaction<'_>,
257        common: &RecoveryFromHistoryCommon,
258    ) {
259        trace!(target: LOG_CLIENT_MODULE_WALLET, "Storing recovery state");
260        dbtx.insert_entry(
261            &RecoveryStateKey,
262            &(WalletRecoveryState::V1(self.state.clone()), common.clone()),
263        )
264        .await;
265    }
266
267    async fn delete_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) {
268        dbtx.remove_entry(&RecoveryStateKey).await;
269    }
270
271    async fn load_finalized(dbtx: &mut DatabaseTransaction<'_>) -> Option<bool> {
272        dbtx.get_value(&RecoveryFinalizedKey).await
273    }
274
275    async fn store_finalized(dbtx: &mut DatabaseTransaction<'_>, state: bool) {
276        dbtx.insert_entry(&RecoveryFinalizedKey, &state).await;
277    }
278
279    async fn handle_input(
280        &mut self,
281        _client_ctx: &ClientContext<WalletClientModule>,
282        _idx: usize,
283        input: &WalletInput,
284        session_idx: u64,
285    ) -> anyhow::Result<()> {
286        let script_pubkey = match input {
287            WalletInput::V0(WalletInputV0(input)) => &input.tx_output().script_pubkey,
288            WalletInput::V1(input) => &input.tx_out.script_pubkey,
289            WalletInput::Default {
290                variant: _,
291                bytes: _,
292            } => {
293                return Ok(());
294            }
295        };
296
297        self.state
298            .tracker
299            .handle_script(&self.data, script_pubkey, session_idx);
300
301        Ok(())
302    }
303
304    async fn pre_finalize(&mut self) -> anyhow::Result<()> {
305        let data = &self.data;
306        let btc_rpc = &self.btc_rpc;
307        // Due to lifetime in async context issue, this one is cloned and wrapped in a
308        // mutex
309        let tracker = &Arc::new(Mutex::new(self.state.tracker.clone()));
310
311        debug!(target: LOG_CLIENT_MODULE_WALLET,
312            next_unused_tweak_idx = ?self.state.next_unused_idx_from_backup,
313            "Scanning blockchain for used peg-in addresses");
314        let RecoverScanOutcome { last_used_idx: _, new_start_idx, tweak_idxes_with_pegins}
315            = recover_scan_idxes_for_activity(
316                if self.state.already_claimed_tweak_idxes_from_backup.is_some() {
317                    // If the backup contains list of already claimed tweak_idexes, we can just scan
318                    // the blockchain addresses starting from tweakidx `0`, without loosing too much privacy,
319                    // as we will skip all the idxes that had peg-ins already
320                    TweakIdx(0)
321                } else {
322                    // If backup didn't have it, we just start from the last derived address from backup (or 0 otherwise).
323                    self.state.next_unused_idx_from_backup
324                },
325                &self.state.tracker.used_tweak_idxes()
326                    .union(&self.state.already_claimed_tweak_idxes_from_backup.clone().unwrap_or_default())
327                    .copied().collect(),
328                |cur_tweak_idx: TweakIdx|
329                async move {
330
331                    let (script, address, _tweak_key, _operation_id) =
332                    data.derive_peg_in_script(cur_tweak_idx);
333
334                    // Randomly query for the decoy before or after our own address
335                    let use_decoy_before_real_query : bool = rand::random();
336                    let decoy = tracker.lock().expect("locking failed").pop_decoy();
337
338                    let use_decoy = || async {
339                        if let Some(decoy) = decoy.as_ref() {
340                            btc_rpc.watch_script_history(decoy).await?;
341                            let _ = btc_rpc.get_script_history(decoy).await?;
342                        }
343                        Ok::<_, anyhow::Error>(())
344                    };
345
346                    if use_decoy_before_real_query {
347                        use_decoy().await?;
348                    }
349                    btc_rpc.watch_script_history(&script).await?;
350                    let history = btc_rpc.get_script_history(&script).await?;
351
352                    if !use_decoy_before_real_query {
353                        use_decoy().await?;
354                    }
355
356                    debug!(target: LOG_CLIENT_MODULE_WALLET, %cur_tweak_idx, %address, history_len=history.len(), "Checked address");
357
358                    Ok(history)
359                }).await?;
360
361        self.state.new_start_idx = Some(new_start_idx);
362        self.state.tweak_idxes_with_pegins = Some(tweak_idxes_with_pegins);
363
364        Ok(())
365    }
366
367    async fn finalize_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>) -> anyhow::Result<()> {
368        let now = fedimint_core::time::now();
369
370        let mut tweak_idx = TweakIdx(0);
371
372        let new_start_idx = self
373            .state
374            .new_start_idx
375            .expect("Must have new_star_idx already set by previous steps");
376
377        let tweak_idxes_with_pegins = self
378            .state
379            .tweak_idxes_with_pegins
380            .clone()
381            .expect("Must be set by previous steps");
382
383        debug!(target: LOG_CLIENT_MODULE_WALLET, ?new_start_idx, "Finalizing recovery");
384
385        while tweak_idx < new_start_idx {
386            let (_script, _address, _tweak_key, operation_id) =
387                self.data.derive_peg_in_script(tweak_idx);
388            dbtx.insert_new_entry(
389                &PegInTweakIndexKey(tweak_idx),
390                &PegInTweakIndexData {
391                    creation_time: now,
392                    next_check_time: if tweak_idxes_with_pegins.contains(&tweak_idx) {
393                        // The addresses that were already used before, or didn't seem to
394                        // contain anything don't need automatic
395                        // peg-in attempt, and can be re-attempted
396                        // manually if needed.
397                        Some(now)
398                    } else {
399                        None
400                    },
401                    last_check_time: None,
402                    operation_id,
403                    claimed: vec![],
404                },
405            )
406            .await;
407            tweak_idx = tweak_idx.next();
408        }
409
410        dbtx.insert_new_entry(&NextPegInTweakIndexKey, &new_start_idx)
411            .await;
412        Ok(())
413    }
414}
415
416/// We will check this many addresses after last actually used
417/// one before we give up
418pub(crate) const ONCHAIN_RECOVER_MAX_GAP: u64 = 10;
419
420/// When scanning the history of the Federation, there's no need to be
421/// so cautious about the privacy (as it's perfectly private), so might
422/// as well increase the gap limit.
423pub(crate) const FEDERATION_RECOVER_MAX_GAP: u64 = 50;
424
425/// New client will start deriving new addresses from last used one
426/// plus that many indexes. This should be less than
427/// `MAX_GAP`, but more than 0: We want to make sure we detect
428/// deposits that might have been made after multiple successive recoveries,
429/// but we want also to avoid accidental address re-use.
430pub(crate) const RECOVER_NUM_IDX_ADD_TO_LAST_USED: u64 = 8;
431
432#[derive(Clone, PartialEq, Eq, Debug)]
433pub(crate) struct RecoverScanOutcome {
434    pub(crate) last_used_idx: Option<TweakIdx>,
435    pub(crate) new_start_idx: TweakIdx,
436    pub(crate) tweak_idxes_with_pegins: BTreeSet<TweakIdx>,
437}
438
439/// A part of `WalletClientInit::recover` extracted out to be easy to
440/// test, as a side-effect free.
441pub(crate) async fn recover_scan_idxes_for_activity<F, FF, T>(
442    scan_from_idx: TweakIdx,
443    used_tweak_idxes: &BTreeSet<TweakIdx>,
444    check_addr_history: F,
445) -> anyhow::Result<RecoverScanOutcome>
446where
447    F: Fn(TweakIdx) -> FF,
448    FF: Future<Output = anyhow::Result<Vec<T>>>,
449{
450    let tweak_indexes_to_scan = (scan_from_idx.0..).map(TweakIdx).filter(|tweak_idx| {
451        let already_used = used_tweak_idxes.contains(tweak_idx);
452
453        if already_used {
454            debug!(target: LOG_CLIENT_MODULE_WALLET,
455                %tweak_idx,
456                "Skipping checking history of an address, as it was previously used"
457            );
458        }
459
460        !already_used
461    });
462
463    // Last tweak index which had on-chain activity, used to implement a gap limit,
464    // i.e. scanning a certain number of addresses past the last one that had
465    // activity.
466    let mut last_used_idx = used_tweak_idxes.last().copied();
467    // When we didn't find any used idx yet, assume that last one before
468    // `scan_from_idx` was used.
469    let fallback_last_used_idx = scan_from_idx.prev().unwrap_or_default();
470    let mut tweak_idxes_with_pegins = BTreeSet::new();
471
472    for cur_tweak_idx in tweak_indexes_to_scan {
473        if ONCHAIN_RECOVER_MAX_GAP
474            <= cur_tweak_idx.saturating_sub(last_used_idx.unwrap_or(fallback_last_used_idx))
475        {
476            break;
477        }
478
479        let history = retry(
480            "Check address history",
481            backoff_util::background_backoff(),
482            || async { check_addr_history(cur_tweak_idx).await },
483        )
484        .await?;
485
486        if !history.is_empty() {
487            tweak_idxes_with_pegins.insert(cur_tweak_idx);
488            last_used_idx = Some(cur_tweak_idx);
489        }
490    }
491
492    let new_start_idx = last_used_idx
493        .unwrap_or(fallback_last_used_idx)
494        .advance(RECOVER_NUM_IDX_ADD_TO_LAST_USED);
495
496    Ok(RecoverScanOutcome {
497        last_used_idx,
498        new_start_idx,
499        tweak_idxes_with_pegins,
500    })
501}