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 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)]
114pub struct RecoveryStateV2 {
115 pub pending_pubkey_scripts: BTreeMap<bitcoin::ScriptBuf, TweakIdx>,
117 pub next_pending_tweak_idx: TweakIdx,
119 pub used_tweak_idxes: BTreeSet<TweakIdx>,
121 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#[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 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 let session_count = args
271 .context()
272 .global_api()
273 .session_count()
274 .await?
275 .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 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 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 TweakIdx(0)
427 } else {
428 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 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 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
522pub(crate) const ONCHAIN_RECOVER_MAX_GAP: u64 = 10;
525
526pub(crate) const FEDERATION_RECOVER_MAX_GAP: u64 = 50;
530
531pub(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
545pub(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 let mut last_used_idx = used_tweak_idxes.last().copied();
573 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}