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 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#[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 let session_count = args
184 .context()
185 .global_api()
186 .session_count()
187 .await?
188 .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 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 TweakIdx(0)
321 } else {
322 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 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 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
416pub(crate) const ONCHAIN_RECOVER_MAX_GAP: u64 = 10;
419
420pub(crate) const FEDERATION_RECOVER_MAX_GAP: u64 = 50;
424
425pub(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
439pub(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 let mut last_used_idx = used_tweak_idxes.last().copied();
467 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}