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 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#[derive(Clone, Debug)]
117pub struct RecoveryStateV2 {
118 pub pending_pubkey_scripts: BTreeMap<bitcoin::ScriptBuf, TweakIdx>,
120 pub next_pending_tweak_idx: TweakIdx,
122 pub used_tweak_idxes: BTreeSet<TweakIdx>,
124 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#[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 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 let session_count = args
274 .context()
275 .global_api()
276 .session_count()
277 .await
278 .map_err(ClientModuleError::other)?
279 .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 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 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 TweakIdx(0)
431 } else {
432 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 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 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 Ok(None)
529 }
530}
531
532pub(crate) const ONCHAIN_RECOVER_MAX_GAP: u64 = 10;
535
536pub(crate) const FEDERATION_RECOVER_MAX_GAP: u64 = 50;
540
541pub(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
555pub(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 let mut last_used_idx = used_tweak_idxes.last().copied();
583 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}