1use std::{
2 collections::{HashMap, HashSet},
3 sync::Arc,
4};
5
6use bitcoin::{
7 Address, Amount, FeeRate, OutPoint, ScriptBuf, Transaction, WPubkeyHash,
8 address::NetworkUnchecked,
9 consensus::encode::{deserialize_hex, serialize_hex},
10 hashes::Hash,
11};
12use spark_wallet::{
13 ChainQuery, ChainResult, EXITING_STATUSES, Fee, SilentPaymentAddress, SparkWalletError,
14 TreeNode, TreeNodeStatus, WATCHTOWER_EXITED_STATUSES, WatchtowerExitLookup,
15 refund_sweep_input_fee, watchtower_exit_recovery_payout,
16};
17use tracing::{debug, error, info};
18
19use crate::{
20 ChainTransaction, CheckRecoverFundsRequest, CheckRecoverFundsResponse,
21 CooperativeRecoveryError, CooperativeRecoveryFailure, ExitLeafSelection, ExitTransactionStatus,
22 LeafRecovery, Network, PrepareRecoverFundsRequest, PrepareRecoverFundsResponse,
23 RecoverFundsLeaf, RecoverFundsRequest, RecoverFundsResponse, RecoveryMethod,
24 RecoveryRedoReason, RecoveryTransaction, RecoveryTxKind, RecoveryVerdict, SdkEvent,
25 SkippedLeaf, SkippedLeafReason, Storage, UpdateLeafRecovery, error::SdkError,
26 persist::ObjectCacheRepository, signer::CpfpSigner, utils::time::now_secs,
27};
28
29use super::{
30 BreezSdk,
31 chain_queries::{ChainQueries, StoredCheck, StoredInBlock, result_of, without_result},
32 unilateral_exit::{
33 UnilateralBuild, UnilateralQuote, check_recovery_transactions, has_stored_sweep, node_ids,
34 store_exited_leaf_checks,
35 },
36 watchtower_exit::{
37 MissingExit, OutputSpend, WatchtowerExit, build_recovery, output_spend,
38 outputs_from_storage,
39 },
40};
41
42#[macros::instance_span]
43#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
44#[allow(clippy::needless_pass_by_value)]
45impl BreezSdk {
46 pub async fn prepare_recover_funds(
50 &self,
51 request: PrepareRecoverFundsRequest,
52 ) -> Result<PrepareRecoverFundsResponse, SdkError> {
53 let fee_rate = request.fee_rate_sat_per_vbyte;
54 let destination = parse_destination(&request.destination, self.config.network)?;
55 self.spark_wallet.refresh_before_exit().await;
56 let mut queries = ChainQueries::new(self.chain_service.clone());
57 let stored = self
58 .checked_leaf_recoveries(&request.selection, &mut queries)
59 .await?;
60 let selection = self
61 .split_selection(request.selection, &stored, &mut queries)
62 .await?;
63
64 let mut leaves = Vec::new();
65 let mut skipped = selection.skipped;
66 let mut cooperative_fee_sats = 0u64;
67 for exit in selection.cooperative {
68 let Some(recovery) = build_recovery(&exit.output, &destination, fee_rate) else {
69 skipped.push(SkippedLeaf {
70 leaf_id: exit.leaf_id,
71 value_sats: exit.value_sats,
72 reason: SkippedLeafReason::FeeExceedsValue,
73 });
74 continue;
75 };
76 cooperative_fee_sats = cooperative_fee_sats
77 .saturating_add(exit.exit_fee_sats())
78 .saturating_add(recovery.fee_sat);
79 leaves.push(RecoverFundsLeaf {
80 leaf_id: exit.leaf_id,
81 value_sats: exit.value_sats,
82 method: RecoveryMethod::Cooperative,
83 });
84 }
85 let cooperative_value_sats = total_value(&leaves);
86 let unilateral = match selection.unilateral {
87 Some(unilateral) => {
88 self.quote_unilateral_exit(
89 &destination,
90 fee_rate,
91 request.funding_kind.as_ref(),
92 request.funding_strategy.unwrap_or_default(),
93 unilateral,
94 &selection.not_unilateral,
95 &stored,
96 &mut queries,
97 )
98 .await?
99 }
100 None => UnilateralQuote::default(),
101 };
102 leaves.extend(unilateral.leaves);
103 skipped.extend(unilateral.skipped);
104
105 let response = PrepareRecoverFundsResponse {
106 leaves,
107 skipped,
108 recoverable_value_sats: cooperative_value_sats
109 .saturating_add(unilateral.recoverable_value_sats),
110 total_fee_sats: cooperative_fee_sats.saturating_add(unilateral.total_fee_sats),
111 cooperative_fee_sats,
112 cpfp_fee_sats: unilateral.cpfp_fee_sats,
113 fanout_fee_sats: unilateral.fanout_fee_sats,
114 refund_fee_sats: unilateral.refund_fee_sats,
115 sweep_fee_sats: unilateral.sweep_fee_sats,
116 funding: unilateral.funding,
117 fee_rate_sat_per_vbyte: fee_rate,
118 destination: request.destination,
119 exit_chain_state: unilateral.exit_chain_state,
120 };
121 debug!(
122 leaves = response.leaves.len(),
123 recoverable_value_sats = response.recoverable_value_sats,
124 total_fee_sats = response.total_fee_sats,
125 "prepare_recover_funds: quote ready"
126 );
127 self.take_out_finished_leaves(&stored).await;
128 Ok(response)
129 }
130
131 pub async fn recover_funds(
136 &self,
137 request: RecoverFundsRequest,
138 signer: Option<Arc<dyn CpfpSigner>>,
139 ) -> Result<RecoverFundsResponse, SdkError> {
140 let RecoverFundsRequest {
141 prepared,
142 funding_inputs,
143 } = request;
144 let fee_rate = prepared.fee_rate_sat_per_vbyte;
145 let destination = parse_destination(&prepared.destination, self.config.network)?;
146 let (cooperative, unilateral): (Vec<&RecoverFundsLeaf>, Vec<&RecoverFundsLeaf>) = prepared
147 .leaves
148 .iter()
149 .partition(|leaf| leaf.method == RecoveryMethod::Cooperative);
150
151 let mut queries = ChainQueries::new(self.chain_service.clone());
154 let stored = self.leaf_recoveries().await?;
155 let (exits, failed) = self
156 .quoted_watchtower_exits(&cooperative, &stored, &mut queries)
157 .await?;
158
159 let unilateral = if unilateral.is_empty() {
160 UnilateralBuild::default()
161 } else {
162 let leaf_ids: Vec<String> =
163 unilateral.iter().map(|leaf| leaf.leaf_id.clone()).collect();
164 self.build_unilateral_exit(
165 &destination,
166 node_ids(&leaf_ids)?,
167 fee_rate,
168 funding_inputs.clone(),
169 prepared
170 .funding
171 .as_ref()
172 .map_or(0, |funding| funding.single_utxo_sats),
173 prepared
174 .funding
175 .as_ref()
176 .map(|funding| funding.strategy)
177 .unwrap_or_default(),
178 &prepared.exit_chain_state,
179 signer.as_deref(),
180 &mut queries,
181 )
182 .await?
183 };
184
185 let cooperative = self
186 .cosign_cooperative_recoveries(exits, &destination, fee_rate, &mut queries)
187 .await;
188 let mut leaves = cooperative.leaves;
189 let recoverable_value_sats =
190 total_value(&leaves).saturating_add(unilateral.recoverable_value_sats);
191 leaves.extend(unilateral.leaves);
192 let mut transactions = cooperative.transactions;
193 transactions.extend(unilateral.transactions);
194 let response = RecoverFundsResponse {
195 recoverable_value_sats,
196 total_fee_sats: cooperative
197 .fee_sats
198 .saturating_add(unilateral.total_fee_sats),
199 cooperative_fee_sats: cooperative.fee_sats,
200 cpfp_fee_sats: unilateral.cpfp_fee_sats,
201 fanout_fee_sats: unilateral.fanout_fee_sats,
202 refund_fee_sats: unilateral.refund_fee_sats,
203 sweep_fee_sats: unilateral.sweep_fee_sats,
204 leaves,
205 failed: failed.into_iter().chain(cooperative.failed).collect(),
206 transactions,
207 funding_inputs,
208 fee_rate_sat_per_vbyte: fee_rate,
209 destination: prepared.destination,
210 };
211 debug!(
212 transactions = response.transactions.len(),
213 failed = response.failed.len(),
214 total_fee_sats = response.total_fee_sats,
215 "recover_funds: complete"
216 );
217 self.take_out_finished_leaves(&stored).await;
218 Ok(response)
219 }
220
221 pub async fn check_recover_funds(
225 &self,
226 request: CheckRecoverFundsRequest,
227 ) -> Result<CheckRecoverFundsResponse, SdkError> {
228 let mut recovery = request.recovery;
229 let (mut cooperative, mut unilateral): (Vec<_>, Vec<_>) =
230 std::mem::take(&mut recovery.transactions)
231 .into_iter()
232 .partition(|tx| tx.kind == RecoveryTxKind::Cooperative);
233 let mut queries = ChainQueries::new(self.chain_service.clone());
234 let recovered = check_cooperative_recoveries(&mut queries, &mut cooperative).await?;
235 let diverged = check_recovery_transactions(&mut queries, &mut unilateral).await?;
236 recovery.transactions = cooperative.into_iter().chain(unilateral).collect();
237 self.record_finished(&recovery.transactions, recovered)
238 .await;
239
240 let all_confirmed = recovery
241 .transactions
242 .iter()
243 .all(|tx| matches!(tx.status, ExitTransactionStatus::Confirmed { .. }));
244 let verdict = if diverged {
245 RecoveryVerdict::Redo {
246 reason: RecoveryRedoReason::OnChainStateDiverged,
247 }
248 } else if all_confirmed && !recovery.transactions.is_empty() {
249 RecoveryVerdict::Done
250 } else {
251 RecoveryVerdict::Valid
252 };
253 debug!(?verdict, "check_recover_funds: read back");
254 Ok(CheckRecoverFundsResponse { recovery, verdict })
255 }
256}
257
258#[derive(Default)]
259struct CooperativeRecoveries {
260 leaves: Vec<RecoverFundsLeaf>,
261 transactions: Vec<RecoveryTransaction>,
262 failed: Vec<CooperativeRecoveryFailure>,
263 fee_sats: u64,
264}
265
266struct RecoverySelection {
267 cooperative: Vec<WatchtowerExit>,
268 skipped: Vec<SkippedLeaf>,
270 unilateral: Option<spark_wallet::ExitLeafSelection>,
271 not_unilateral: HashSet<String>,
273}
274
275#[derive(Default)]
277struct NewLeaves {
278 to_recover: Vec<UpdateLeafRecovery>,
281 recovered: Vec<TreeNode>,
285 exited: Vec<TreeNode>,
289}
290
291impl BreezSdk {
292 pub(crate) async fn recoverable_funds_sats(&self) -> Result<u64, SdkError> {
293 Ok(ObjectCacheRepository::new(self.storage.clone())
294 .fetch_recoverable_funds()
295 .await?
296 .unwrap_or_default())
297 }
298
299 async fn checked_leaf_recoveries(
305 &self,
306 selection: &ExitLeafSelection,
307 queries: &mut ChainQueries,
308 ) -> Result<HashMap<String, LeafRecovery>, SdkError> {
309 let stored = self.leaf_recoveries().await?;
310 let named = named_leaf_ids(selection);
311 let finished = finished_transactions(&stored, named.as_ref());
312 let checks = queries.check_stored_in_block(&finished).await;
313 let (stored, moved) = after_finished_checks(stored, &checks);
314 for update in moved {
315 self.store_leaf_recovery(update).await;
316 }
317 Ok(stored)
318 }
319
320 pub(super) async fn leaf_recoveries(&self) -> Result<HashMap<String, LeafRecovery>, SdkError> {
321 leaf_recoveries(self.storage.as_ref()).await
322 }
323
324 pub(super) async fn store_leaf_recovery(&self, update: UpdateLeafRecovery) -> bool {
325 store_update(self.storage.as_ref(), update).await
326 }
327
328 pub(super) async fn sync_recoverable_funds(&self) {
337 let leaves = match self.leaves_that_left_the_balance().await {
338 Ok(leaves) => leaves,
339 Err(e) => {
340 error!("Failed to list the leaves to recover: {e}");
341 return;
342 }
343 };
344 let mut stored = if leaves.is_empty() {
345 HashMap::new()
346 } else {
347 match self.leaf_recoveries().await {
348 Ok(stored) => stored,
349 Err(e) => {
350 error!("Failed to read the stored leaf recoveries: {e}");
351 return;
352 }
353 }
354 };
355 let new = new_leaves(&leaves, &stored);
356 let mut new_leaf_ids: HashSet<String> = HashSet::new();
357 for update in new.to_recover {
358 let leaf_id = update.leaf_id.clone();
359 if self.store_leaf_recovery(update).await {
360 new_leaf_ids.insert(leaf_id);
361 }
362 }
363 let mut queries = ChainQueries::for_sync(self.chain_service.clone());
364 new_leaf_ids.extend(
365 self.check_recovered_leaves(&new.recovered, &stored, &mut queries)
366 .await,
367 );
368 new_leaf_ids.extend(
369 store_exited_leaf_checks(
370 &new.exited,
371 self.config.network.into(),
372 &mut queries,
373 self.storage.as_ref(),
374 )
375 .await,
376 );
377 if !leaves.is_empty() {
380 stored = match self.leaf_recoveries().await {
381 Ok(stored) => stored,
382 Err(e) => {
383 error!("Failed to read the stored leaf recoveries: {e}");
384 return;
385 }
386 };
387 }
388 let (funds, found) = recoverable_funds(&leaves, &stored, &new_leaf_ids);
389
390 let repository = ObjectCacheRepository::new(self.storage.clone());
391 let stored_funds = match repository.fetch_recoverable_funds().await {
392 Ok(stored_funds) => stored_funds.unwrap_or_default(),
393 Err(e) => {
394 error!("Failed to read the recoverable funds: {e}");
395 0
396 }
397 };
398 if funds != stored_funds
399 && let Err(e) = repository.save_recoverable_funds(funds).await
400 {
401 error!("Failed to store the recoverable funds: {e}");
402 }
403 if found > 0 {
404 info!("Found {found} leaves with funds to recover");
405 self.event_emitter
406 .emit(&SdkEvent::RecoverableFunds {
407 recoverable_funds_sats: funds,
408 })
409 .await;
410 }
411 }
412
413 async fn record_finished(
416 &self,
417 transactions: &[RecoveryTransaction],
418 recovered: Vec<(String, ChainTransaction)>,
419 ) {
420 let swept = swept_leaves(transactions);
421 if recovered.is_empty() && swept.is_empty() {
422 return;
423 }
424 let stored = match self.leaf_recoveries().await {
425 Ok(stored) => stored,
426 Err(e) => {
427 error!("Failed to read the stored leaf recoveries: {e}");
428 return;
429 }
430 };
431 store_finished(self.storage.as_ref(), &stored, recovered, swept).await;
432 self.take_out_finished_leaves(&stored).await;
433 }
434
435 async fn take_out_finished_leaves(&self, before: &HashMap<String, LeafRecovery>) {
438 let leaves = match self.leaves_that_left_the_balance().await {
439 Ok(leaves) => leaves,
440 Err(e) => {
441 error!("Failed to list the leaves to recover: {e}");
442 return;
443 }
444 };
445 take_out_finished_leaves(&self.storage, &leaves, before).await;
446 }
447
448 async fn leaves_that_left_the_balance(&self) -> Result<Vec<TreeNode>, SparkWalletError> {
449 let statuses = [EXITING_STATUSES.as_slice(), &WATCHTOWER_EXITED_STATUSES].concat();
450 self.spark_wallet.list_leaves_with_status(&statuses).await
451 }
452
453 async fn cosign_cooperative_recoveries(
454 &self,
455 exits: Vec<WatchtowerExit>,
456 destination: &Address,
457 fee_rate: u64,
458 queries: &mut ChainQueries,
459 ) -> CooperativeRecoveries {
460 let mut recoveries = CooperativeRecoveries::default();
461 let mut unreachable: Option<String> = None;
462 for exit in exits {
463 match self
464 .cooperative_recovery(
465 &exit,
466 destination,
467 fee_rate,
468 unreachable.as_deref(),
469 queries,
470 )
471 .await
472 {
473 Ok(Some(recovery)) => {
474 let tx = recovery.tx;
475 recoveries.fee_sats = recoveries
476 .fee_sats
477 .saturating_add(exit.exit_fee_sats())
478 .saturating_add(recovery.fee_sats);
479 recoveries.leaves.push(RecoverFundsLeaf {
480 leaf_id: exit.leaf_id.clone(),
481 value_sats: exit.value_sats,
482 method: RecoveryMethod::Cooperative,
483 });
484 recoveries.transactions.push(RecoveryTransaction {
485 kind: RecoveryTxKind::Cooperative,
486 node_id: Some(exit.leaf_id),
487 txid: tx.compute_txid().to_string(),
488 tx_hex: serialize_hex(&tx),
489 cpfp_tx_hex: None,
490 csv_timelock_blocks: None,
491 depends_on: Vec::new(),
492 status: if recovery.verified {
493 ExitTransactionStatus::Ready
494 } else {
495 ExitTransactionStatus::Unverified
496 },
497 });
498 }
499 Ok(None) => debug!(
500 leaf_id = exit.leaf_id,
501 "recover_funds: a recovery of the leaf is in a block"
502 ),
503 Err(error) => {
504 info!(
505 "Leaf {} was not recovered cooperatively: {error}",
506 exit.leaf_id
507 );
508 if let CooperativeRecoveryError::OperatorsUnavailable { message } = &error {
509 unreachable.get_or_insert_with(|| message.clone());
510 }
511 recoveries.failed.push(CooperativeRecoveryFailure {
512 leaf_id: exit.leaf_id,
513 output_txid: exit.output.outpoint.txid.to_string(),
514 output_vout: exit.output.outpoint.vout,
515 error,
516 });
517 }
518 }
519 }
520 recoveries
521 }
522
523 async fn watchtower_exit_candidates(&self) -> Result<Vec<TreeNode>, SdkError> {
524 let statuses = [
525 WATCHTOWER_EXITED_STATUSES.as_slice(),
526 &[TreeNodeStatus::OnChain],
529 ]
530 .concat();
531 Ok(self.spark_wallet.list_leaves_with_status(&statuses).await?)
532 }
533
534 async fn quoted_watchtower_exits(
539 &self,
540 leaves: &[&RecoverFundsLeaf],
541 stored: &HashMap<String, LeafRecovery>,
542 queries: &mut ChainQueries,
543 ) -> Result<(Vec<WatchtowerExit>, Vec<CooperativeRecoveryFailure>), SdkError> {
544 if leaves.is_empty() {
545 return Ok((Vec::new(), Vec::new()));
546 }
547 let listed: Vec<TreeNode> = self
548 .watchtower_exit_candidates()
549 .await?
550 .into_iter()
551 .filter(|leaf| {
552 let leaf_id = leaf.id.to_string();
553 leaves.iter().any(|quoted| quoted.leaf_id == leaf_id)
554 })
555 .collect();
556 let from_storage: HashSet<String> = listed
558 .iter()
559 .map(|leaf| leaf.id.to_string())
560 .filter(|leaf_id| {
561 stored
562 .get(leaf_id)
563 .is_some_and(|stored| stored.watchtower_exit_output.is_some())
564 })
565 .collect();
566 let mut found = self
567 .lookup_watchtower_exits(&listed, stored, &from_storage, queries)
568 .await?;
569 let recovered: HashSet<String> = listed
570 .iter()
571 .filter(|leaf| leaf.status == TreeNodeStatus::WatchtowerExitRecovered)
572 .map(|leaf| leaf.id.to_string())
573 .collect();
574 let mut exits = Vec::with_capacity(leaves.len());
575 let mut failed = Vec::new();
576 for leaf in leaves {
577 if let Some(exit) = found.exits.remove(&leaf.leaf_id) {
578 exits.push(exit);
579 continue;
580 }
581 let unresolved = unresolved_exit(
582 leaf,
583 found.missing.get(&leaf.leaf_id),
584 stored.get(&leaf.leaf_id),
585 recovered.contains(&leaf.leaf_id),
586 )?;
587 match unresolved {
588 Unresolved::Exit(exit) => exits.push(exit),
589 Unresolved::Failed(failure) => failed.push(failure),
590 }
591 }
592 Ok((exits, failed))
593 }
594
595 async fn split_selection(
596 &self,
597 selection: ExitLeafSelection,
598 stored: &HashMap<String, LeafRecovery>,
599 queries: &mut ChainQueries,
600 ) -> Result<RecoverySelection, SdkError> {
601 let named = named_leaf_ids(&selection);
602 if named.as_ref().is_some_and(HashSet::is_empty) {
603 return Err(SdkError::InvalidInput("No leaves to recover".to_string()));
604 }
605 let candidates = self
606 .watchtower_exit_candidates()
607 .await?
608 .into_iter()
609 .filter(|leaf| {
610 named
611 .as_ref()
612 .is_none_or(|named| named.contains(&leaf.id.to_string()))
613 })
614 .collect();
615 let (finished, selected) = finished_and_open(candidates, stored);
616 let from_storage =
617 outputs_from_storage(&selected, stored, queries, self.storage.as_ref()).await;
618 let mut found = self
619 .lookup_watchtower_exits(&selected, stored, &from_storage, queries)
620 .await?;
621
622 let mut not_unilateral: HashSet<String> = selected
623 .iter()
624 .chain(&finished)
625 .filter(|leaf| leaf.status.is_watchtower_exited())
626 .map(|leaf| leaf.id.to_string())
627 .collect();
628 not_unilateral.extend(found.exits.keys().cloned());
629 not_unilateral.extend(
632 stored
633 .values()
634 .filter(|leaf| is_finished(leaf))
635 .map(|leaf| leaf.leaf_id.clone()),
636 );
637 let mut skipped: Vec<SkippedLeaf> = selected
638 .iter()
639 .filter_map(|leaf| {
640 let leaf_id = leaf.id.to_string();
641 let lookup = found.missing.get(&leaf_id).map(|missing| &missing.lookup);
642 skipped_cooperative_leaf(leaf, stored.get(&leaf_id), lookup)
643 })
644 .collect();
645 if found.operators_unavailable {
646 for leaf in &mut skipped {
647 leaf.reason = SkippedLeafReason::OperatorsUnavailable;
648 }
649 }
650 let cooperative = selected
651 .iter()
652 .filter_map(|leaf| found.exits.remove(&leaf.id.to_string()))
653 .filter(|exit| !exit.recovered)
654 .collect();
655
656 let unilateral = match selection {
657 ExitLeafSelection::Specific { leaf_ids } => {
658 let leaf_ids = named_for_unilateral(leaf_ids, ¬_unilateral, stored);
659 if leaf_ids.is_empty() {
660 None
661 } else {
662 Some(spark_wallet::ExitLeafSelection::Specific(node_ids(
663 &leaf_ids,
664 )?))
665 }
666 }
667 ExitLeafSelection::All => Some(spark_wallet::ExitLeafSelection::Auto),
668 ExitLeafSelection::RecoverableOnly => Some(spark_wallet::ExitLeafSelection::Exiting),
669 };
670 Ok(RecoverySelection {
671 cooperative,
672 skipped,
673 unilateral,
674 not_unilateral,
675 })
676 }
677}
678
679enum Unresolved {
682 Exit(WatchtowerExit),
683 Failed(CooperativeRecoveryFailure),
684}
685
686fn unresolved_exit(
692 leaf: &RecoverFundsLeaf,
693 missing: Option<&MissingExit>,
694 stored: Option<&LeafRecovery>,
695 recovered: bool,
696) -> Result<Unresolved, SdkError> {
697 let no_output = || {
698 SdkError::InvalidInput(format!(
699 "Leaf {} has no funds this wallet can recover cooperatively",
700 leaf.leaf_id
701 ))
702 };
703 let Some(missing) = missing else {
704 let exit = stored
705 .map(|stored| WatchtowerExit::from_stored(leaf.value_sats, stored))
706 .transpose()?
707 .flatten();
708 return exit.map(Unresolved::Exit).ok_or_else(no_output);
709 };
710 let assumed = missing.assumed.clone().ok_or_else(no_output)?;
711 let message = match &missing.lookup {
712 WatchtowerExitLookup::NotFound => "The chain shows the leaf's funds in no output",
713 WatchtowerExitLookup::Unrecoverable => {
714 "The transaction that took the leaf's funds on-chain pays none of them to the \
715 leaf's key"
716 }
717 WatchtowerExitLookup::Unilateral => "The leaf's own refunds recover its funds",
718 WatchtowerExitLookup::Pending
719 | WatchtowerExitLookup::Found { .. }
720 | WatchtowerExitLookup::Unconfirmed(_) => {
721 return Ok(Unresolved::Exit(
722 WatchtowerExit::new(
723 leaf.leaf_id.clone(),
724 leaf.value_sats,
725 assumed,
726 stored,
727 &OutputSpend::Unknown,
728 recovered,
729 )
730 .assumed(),
731 ));
732 }
733 };
734 Ok(Unresolved::Failed(CooperativeRecoveryFailure {
735 leaf_id: leaf.leaf_id.clone(),
736 output_txid: assumed.outpoint.txid.to_string(),
737 output_vout: assumed.outpoint.vout,
738 error: CooperativeRecoveryError::Generic {
739 message: message.to_string(),
740 },
741 }))
742}
743
744fn named_leaf_ids(selection: &ExitLeafSelection) -> Option<HashSet<String>> {
746 match selection {
747 ExitLeafSelection::Specific { leaf_ids } => Some(leaf_ids.iter().cloned().collect()),
748 ExitLeafSelection::All | ExitLeafSelection::RecoverableOnly => None,
749 }
750}
751
752fn named_for_unilateral(
756 leaf_ids: Vec<String>,
757 not_unilateral: &HashSet<String>,
758 stored: &HashMap<String, LeafRecovery>,
759) -> Vec<String> {
760 leaf_ids
761 .into_iter()
762 .filter(|leaf_id| !not_unilateral.contains(leaf_id) || has_stored_sweep(stored, leaf_id))
763 .collect()
764}
765
766fn finished_transactions(
770 stored: &HashMap<String, LeafRecovery>,
771 named: Option<&HashSet<String>>,
772) -> Vec<StoredInBlock> {
773 stored
774 .values()
775 .filter(|leaf| named.is_none_or(|named| named.contains(&leaf.leaf_id)))
776 .flat_map(|leaf| [&leaf.watchtower_exit_spend, &leaf.unilateral_exit_sweep])
777 .flatten()
778 .map(|transaction| StoredInBlock {
779 txid: transaction.txid.clone(),
780 block_height: transaction.block_height,
781 check_always: named.is_some(),
782 })
783 .collect()
784}
785
786fn after_check(
790 transaction: Option<ChainTransaction>,
791 checks: &HashMap<String, StoredCheck>,
792) -> (Option<ChainTransaction>, bool) {
793 let Some(transaction) = transaction else {
794 return (None, false);
795 };
796 match checks.get(&transaction.txid) {
797 Some(StoredCheck::NotInBlock) => (None, false),
798 Some(StoredCheck::InBlock {
799 block_height: Some(block_height),
800 }) if *block_height != transaction.block_height => (
801 Some(ChainTransaction {
802 block_height: *block_height,
803 ..transaction
804 }),
805 true,
806 ),
807 _ => (Some(transaction), false),
808 }
809}
810
811fn after_finished_checks(
815 stored: HashMap<String, LeafRecovery>,
816 checks: &HashMap<String, StoredCheck>,
817) -> (HashMap<String, LeafRecovery>, Vec<UpdateLeafRecovery>) {
818 let mut moved = Vec::new();
819 let stored = stored
820 .into_iter()
821 .map(|(leaf_id, mut leaf)| {
822 let (spend, spend_moved) = after_check(leaf.watchtower_exit_spend.take(), checks);
823 let (sweep, sweep_moved) = after_check(leaf.unilateral_exit_sweep.take(), checks);
824 if spend_moved || sweep_moved {
825 moved.push(UpdateLeafRecovery {
826 leaf_id: leaf_id.clone(),
827 watchtower_exit_spend: spend.clone().filter(|_| spend_moved),
828 unilateral_exit_sweep: sweep.clone().filter(|_| sweep_moved),
829 ..Default::default()
830 });
831 }
832 leaf.watchtower_exit_spend = spend;
833 leaf.unilateral_exit_sweep = sweep;
834 (leaf_id, leaf)
835 })
836 .collect();
837 (stored, moved)
838}
839
840async fn leaf_recoveries(storage: &dyn Storage) -> Result<HashMap<String, LeafRecovery>, SdkError> {
841 Ok(storage
842 .list_leaf_recoveries()
843 .await?
844 .into_iter()
845 .map(|leaf| (leaf.leaf_id.clone(), leaf))
846 .collect())
847}
848
849async fn store_finished(
852 storage: &dyn Storage,
853 stored: &HashMap<String, LeafRecovery>,
854 recovered: Vec<(String, ChainTransaction)>,
855 swept: Vec<(String, ChainTransaction)>,
856) {
857 let now = now_secs();
858 let recovered = recovered
860 .into_iter()
861 .filter(|(leaf_id, spend)| {
862 stored
863 .get(leaf_id)
864 .is_some_and(|leaf| leaf.watchtower_exit_spend.as_ref() != Some(spend))
865 })
866 .map(|(leaf_id, spend)| UpdateLeafRecovery {
867 leaf_id,
868 chain_checked_at: Some(now),
869 watchtower_exit_spend: Some(spend),
870 ..Default::default()
871 });
872 let swept = swept
873 .into_iter()
874 .filter(|(leaf_id, sweep)| {
875 stored
876 .get(leaf_id)
877 .is_none_or(|leaf| leaf.unilateral_exit_sweep.as_ref() != Some(sweep))
878 })
879 .map(|(leaf_id, sweep)| UpdateLeafRecovery {
880 leaf_id,
881 chain_checked_at: Some(now),
882 unilateral_exit_sweep: Some(sweep),
883 ..Default::default()
884 });
885 for update in recovered.chain(swept).collect::<Vec<_>>() {
886 store_update(storage, update).await;
887 }
888}
889
890pub(super) async fn store_update(storage: &dyn Storage, update: UpdateLeafRecovery) -> bool {
894 let leaf_id = update.leaf_id.clone();
895 match storage.update_leaf_recovery(update).await {
896 Ok(()) => true,
897 Err(e) => {
898 error!("Failed to store the recovery of leaf {leaf_id}: {e}");
899 false
900 }
901 }
902}
903
904pub(super) async fn store_checks(
906 storage: &dyn Storage,
907 checks: Vec<UpdateLeafRecovery>,
908) -> Vec<String> {
909 let mut complete = Vec::new();
910 for check in checks {
911 let leaf_id = check.leaf_id.clone();
912 let is_complete = check.chain_checked_at.is_some();
913 if store_update(storage, check).await && is_complete {
914 complete.push(leaf_id);
915 }
916 }
917 complete
918}
919
920fn is_finished(stored: &LeafRecovery) -> bool {
923 stored.watchtower_exit_spend.is_some() || stored.unilateral_exit_sweep.is_some()
924}
925
926fn finished_and_open(
929 leaves: Vec<TreeNode>,
930 stored: &HashMap<String, LeafRecovery>,
931) -> (Vec<TreeNode>, Vec<TreeNode>) {
932 leaves
933 .into_iter()
934 .partition(|leaf| stored.get(&leaf.id.to_string()).is_some_and(is_finished))
935}
936
937fn check_incomplete(stored: &LeafRecovery) -> bool {
940 stored.chain_checked_at.is_none() && stored.watchtower_exit_output.is_some()
941}
942
943fn new_leaves(leaves: &[TreeNode], stored: &HashMap<String, LeafRecovery>) -> NewLeaves {
944 let mut new = NewLeaves::default();
945 for leaf in leaves {
946 let leaf_id = leaf.id.to_string();
947 if let Some(stored) = stored.get(&leaf_id) {
948 if check_incomplete(stored) {
949 new.recovered.push(leaf.clone());
950 }
951 continue;
952 }
953 match leaf.status {
954 _ if !can_be_sent_out(leaf) => new.to_recover.push(UpdateLeafRecovery {
956 leaf_id,
957 ..Default::default()
958 }),
959 TreeNodeStatus::WatchtowerExitRecovered => new.recovered.push(leaf.clone()),
960 TreeNodeStatus::Exited => new.exited.push(leaf.clone()),
961 _ => new.to_recover.push(UpdateLeafRecovery {
962 leaf_id,
963 ..Default::default()
964 }),
965 }
966 }
967 new
968}
969
970fn can_be_sent_out(leaf: &TreeNode) -> bool {
974 let value = Amount::from_sat(leaf.value);
975 let fee_rate = FeeRate::BROADCAST_MIN;
976 match leaf.status {
977 TreeNodeStatus::WatchtowerExitRecovered => {
980 let output = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros());
981 let fee = Fee::Rate {
982 sat_per_vbyte: fee_rate.to_sat_per_vb_ceil(),
983 };
984 watchtower_exit_recovery_payout(value, &output, fee).is_some()
985 }
986 TreeNodeStatus::Exited => refund_sweep_input_fee(fee_rate).is_some_and(|fee| value > fee),
989 _ => true,
990 }
991}
992
993fn recoverable_funds(
997 leaves: &[TreeNode],
998 stored: &HashMap<String, LeafRecovery>,
999 new_leaf_ids: &HashSet<String>,
1000) -> (u64, usize) {
1001 let mut funds: u64 = 0;
1002 let mut found: usize = 0;
1003 for leaf in leaves {
1004 let leaf_id = leaf.id.to_string();
1005 if stored.get(&leaf_id).is_some_and(is_finished) {
1006 continue;
1007 }
1008 funds = funds.saturating_add(leaf.value);
1009 if new_leaf_ids.contains(&leaf_id) {
1010 found = found.saturating_add(1);
1011 }
1012 }
1013 (funds, found)
1014}
1015
1016async fn take_out_finished_leaves(
1019 storage: &Arc<dyn Storage>,
1020 leaves: &[TreeNode],
1021 before: &HashMap<String, LeafRecovery>,
1022) {
1023 let stored = match leaf_recoveries(storage.as_ref()).await {
1024 Ok(stored) => stored,
1025 Err(e) => {
1026 error!("Failed to read the stored leaf recoveries: {e}");
1027 return;
1028 }
1029 };
1030 let finished = |recoveries: &HashMap<String, LeafRecovery>, leaf: &TreeNode| {
1031 recoveries
1032 .get(&leaf.id.to_string())
1033 .is_some_and(is_finished)
1034 };
1035 let taken_out = leaves
1036 .iter()
1037 .filter(|leaf| finished(&stored, leaf) && !finished(before, leaf))
1038 .map(|leaf| leaf.value)
1039 .fold(0, u64::saturating_add);
1040 if taken_out == 0 {
1041 return;
1042 }
1043 let (funds, _) = recoverable_funds(leaves, &stored, &HashSet::new());
1044 let repository = ObjectCacheRepository::new(storage.clone());
1045 let stored_funds = match repository.fetch_recoverable_funds().await {
1046 Ok(stored_funds) => stored_funds.unwrap_or_default(),
1047 Err(e) => {
1048 error!("Failed to read the recoverable funds: {e}");
1049 return;
1050 }
1051 };
1052 let lowered = funds.clamp(stored_funds.saturating_sub(taken_out), stored_funds);
1056 if lowered != stored_funds
1057 && let Err(e) = repository.save_recoverable_funds(lowered).await
1058 {
1059 error!("Failed to store the recoverable funds: {e}");
1060 }
1061}
1062
1063fn swept_leaves(transactions: &[RecoveryTransaction]) -> Vec<(String, ChainTransaction)> {
1066 let mut swept = Vec::new();
1067 for sweep in transactions {
1068 let ExitTransactionStatus::Confirmed {
1069 block_height: Some(block_height),
1070 } = sweep.status
1071 else {
1072 continue;
1073 };
1074 if sweep.kind != RecoveryTxKind::Sweep {
1075 continue;
1076 }
1077 let leaf_ids = transactions
1078 .iter()
1079 .filter(|tx| tx.kind == RecoveryTxKind::Refund && sweep.depends_on.contains(&tx.txid))
1080 .filter_map(|tx| tx.node_id.clone());
1081 for leaf_id in leaf_ids {
1082 swept.push((
1083 leaf_id,
1084 ChainTransaction {
1085 txid: sweep.txid.clone(),
1086 block_height,
1087 },
1088 ));
1089 }
1090 }
1091 swept
1092}
1093
1094fn skipped_cooperative_leaf(
1099 leaf: &TreeNode,
1100 stored: Option<&LeafRecovery>,
1101 lookup: Option<&WatchtowerExitLookup>,
1102) -> Option<SkippedLeaf> {
1103 let lookup = lookup?;
1104 if !leaf.status.is_watchtower_exited()
1105 || stored.is_some_and(|stored| stored.watchtower_exit_spend.is_some())
1106 {
1107 return None;
1108 }
1109 let reason = match lookup {
1110 WatchtowerExitLookup::Unrecoverable => SkippedLeafReason::NotRecoverable {
1111 message: "The transaction that took the leaf's funds on-chain pays none of them to \
1112 the leaf's key"
1113 .to_string(),
1114 },
1115 WatchtowerExitLookup::Pending => SkippedLeafReason::Unverified,
1116 _ => SkippedLeafReason::FundsNotFound,
1117 };
1118 Some(SkippedLeaf {
1119 leaf_id: leaf.id.to_string(),
1120 value_sats: leaf.value,
1121 reason,
1122 })
1123}
1124
1125fn total_value(leaves: &[RecoverFundsLeaf]) -> u64 {
1126 leaves
1127 .iter()
1128 .map(|leaf| leaf.value_sats)
1129 .fold(0, u64::saturating_add)
1130}
1131
1132fn recovered_output(tx: &RecoveryTransaction) -> Result<OutPoint, SdkError> {
1134 let decoded: Transaction = deserialize_hex(&tx.tx_hex)
1135 .map_err(|e| SdkError::InvalidInput(format!("Invalid transaction: {e}")))?;
1136 decoded
1137 .input
1138 .first()
1139 .map(|input| input.previous_output)
1140 .ok_or_else(|| {
1141 SdkError::InvalidInput(format!("Cooperative recovery {} spends nothing", tx.txid))
1142 })
1143}
1144
1145async fn check_cooperative_recoveries(
1148 queries: &mut ChainQueries,
1149 transactions: &mut [RecoveryTransaction],
1150) -> Result<Vec<(String, ChainTransaction)>, SdkError> {
1151 let outputs = transactions
1152 .iter()
1153 .map(recovered_output)
1154 .collect::<Result<Vec<OutPoint>, SdkError>>()?;
1155 let unverified_outputs: Vec<ChainQuery> = transactions
1159 .iter()
1160 .zip(&outputs)
1161 .filter(|(tx, _)| tx.status == ExitTransactionStatus::Unverified)
1162 .map(|(_, output)| ChainQuery::TxConfirmed(output.txid))
1163 .collect();
1164 queries
1165 .resolve(|observed| {
1166 let outspends = outputs.iter().copied().map(ChainQuery::Outspend);
1167 let wanted = outspends
1168 .chain(unverified_outputs.iter().cloned())
1169 .collect();
1170 ((), without_result(wanted, observed))
1171 })
1172 .await;
1173 let mut recovered: Vec<(String, ChainTransaction)> = Vec::new();
1174 for (tx, output) in transactions.iter_mut().zip(&outputs) {
1175 let spend = output_spend(queries.observed(), *output);
1176 let output_in_block = tx.status != ExitTransactionStatus::Unverified
1177 || matches!(
1178 result_of(queries.observed(), &ChainQuery::TxConfirmed(output.txid)),
1179 Some(ChainResult::Confirmed {
1180 confirmed: true,
1181 ..
1182 })
1183 );
1184 tx.status = cooperative_recovery_status(&spend, output_in_block, tx.status);
1185 if let (Some(leaf_id), Some(spend)) = (&tx.node_id, spend.in_block()) {
1186 recovered.push((leaf_id.clone(), spend));
1187 }
1188 }
1189 Ok(recovered)
1190}
1191
1192fn cooperative_recovery_status(
1197 spend: &OutputSpend,
1198 output_in_block: bool,
1199 status: ExitTransactionStatus,
1200) -> ExitTransactionStatus {
1201 match spend {
1202 OutputSpend::InBlock { block_height, .. } => ExitTransactionStatus::Confirmed {
1203 block_height: *block_height,
1204 },
1205 OutputSpend::Unspent | OutputSpend::InMempool(_) if output_in_block => {
1206 ExitTransactionStatus::Ready
1207 }
1208 OutputSpend::Unspent | OutputSpend::InMempool(_) | OutputSpend::Unknown => status,
1209 }
1210}
1211
1212fn parse_destination(destination: &str, network: Network) -> Result<Address, SdkError> {
1213 destination
1214 .parse::<Address<NetworkUnchecked>>()
1215 .map_err(|e| {
1216 if destination.parse::<SilentPaymentAddress>().is_ok() {
1217 SdkError::InvalidInput(
1218 "Silent payment addresses are not supported for fund recovery".to_string(),
1219 )
1220 } else {
1221 SdkError::InvalidInput(format!("Invalid destination address: {e}"))
1222 }
1223 })?
1224 .require_network(network.into())
1225 .map_err(|e| SdkError::InvalidInput(format!("Address network mismatch: {e}")))
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230 use bitcoin::{Txid, hashes::Hash};
1231 use spark_wallet::tree_store_tests::create_test_node_with_parent;
1232
1233 use crate::{StoredWatchtowerExitOutput, chain::stub::tx_paying};
1234
1235 use super::*;
1236
1237 #[test]
1238 fn a_silent_payment_address_is_no_recovery_destination() {
1239 let result = parse_destination(
1240 "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv",
1241 Network::Mainnet,
1242 );
1243 assert!(matches!(
1244 result,
1245 Err(SdkError::InvalidInput(message))
1246 if message == "Silent payment addresses are not supported for fund recovery"
1247 ));
1248 }
1249
1250 const LEAF_ID: &str = "00000000-0000-0000-0000-00000000000a";
1251 const OTHER_LEAF_ID: &str = "00000000-0000-0000-0000-00000000000b";
1252
1253 fn stored_leaf(leaf_id: &str) -> LeafRecovery {
1254 LeafRecovery {
1255 leaf_id: leaf_id.to_string(),
1256 chain_checked_at: None,
1257 watchtower_exit_output: None,
1258 watchtower_exit_recoveries: Vec::new(),
1259 watchtower_exit_spend: None,
1260 unilateral_exit_sweep: None,
1261 }
1262 }
1263
1264 fn in_block(txid: &str) -> ChainTransaction {
1265 ChainTransaction {
1266 txid: txid.to_string(),
1267 block_height: 100,
1268 }
1269 }
1270
1271 fn by_leaf_id(exits: Vec<LeafRecovery>) -> HashMap<String, LeafRecovery> {
1272 exits
1273 .into_iter()
1274 .map(|leaf| (leaf.leaf_id.clone(), leaf))
1275 .collect()
1276 }
1277
1278 #[test]
1279 fn a_watchtower_exited_leaf_without_an_output_is_skipped() {
1280 let leaf = create_test_node_with_parent(LEAF_ID, None, TreeNodeStatus::WatchtowerExited);
1281 let skipped = |stored: Option<&LeafRecovery>, lookup: Option<&WatchtowerExitLookup>| {
1282 skipped_cooperative_leaf(&leaf, stored, lookup).map(|skipped| skipped.reason)
1283 };
1284
1285 assert_eq!(
1286 skipped(None, Some(&WatchtowerExitLookup::NotFound)),
1287 Some(SkippedLeafReason::FundsNotFound)
1288 );
1289 assert_eq!(
1290 skipped(
1291 Some(&stored_leaf(LEAF_ID)),
1292 Some(&WatchtowerExitLookup::NotFound)
1293 ),
1294 Some(SkippedLeafReason::FundsNotFound)
1295 );
1296 assert_eq!(
1297 skipped(None, Some(&WatchtowerExitLookup::Pending)),
1298 Some(SkippedLeafReason::Unverified)
1299 );
1300 assert!(matches!(
1301 skipped(None, Some(&WatchtowerExitLookup::Unrecoverable)),
1302 Some(SkippedLeafReason::NotRecoverable { .. })
1303 ));
1304 assert_eq!(skipped(None, None), None, "an output was found");
1305 let recovered = LeafRecovery {
1306 watchtower_exit_spend: Some(in_block("recovery")),
1307 ..stored_leaf(LEAF_ID)
1308 };
1309 assert_eq!(
1310 skipped(Some(&recovered), Some(&WatchtowerExitLookup::NotFound)),
1311 None,
1312 "a finished recovery is not listed"
1313 );
1314
1315 let on_chain = create_test_node_with_parent(LEAF_ID, None, TreeNodeStatus::OnChain);
1316 assert_eq!(
1317 skipped_cooperative_leaf(&on_chain, None, Some(&WatchtowerExitLookup::NotFound)),
1318 None,
1319 "its unilateral exit is quoted"
1320 );
1321 }
1322
1323 #[test]
1324 fn a_new_leaf_is_stored_unless_its_status_leaves_its_funds_open() {
1325 let id = |n: u8| format!("00000000-0000-0000-0000-0000000000{n:02}");
1326 let leaves: Vec<TreeNode> = [
1327 TreeNodeStatus::WatchtowerExited,
1328 TreeNodeStatus::OnChain,
1329 TreeNodeStatus::ParentExited,
1330 TreeNodeStatus::WatchtowerExitRecovered,
1331 TreeNodeStatus::Exited,
1332 TreeNodeStatus::WatchtowerExitRecovered,
1333 ]
1334 .into_iter()
1335 .zip(1u8..)
1336 .map(|(status, n)| create_test_node_with_parent(&id(n), None, status))
1337 .collect();
1338 let stored = by_leaf_id(vec![stored_leaf(&id(6))]);
1340
1341 let new = new_leaves(&leaves, &stored);
1342
1343 let ids = |leaves: &[TreeNode]| -> Vec<String> {
1344 leaves.iter().map(|leaf| leaf.id.to_string()).collect()
1345 };
1346 assert_eq!(
1347 new.to_recover
1348 .iter()
1349 .map(|update| update.leaf_id.clone())
1350 .collect::<Vec<_>>(),
1351 vec![id(1), id(2), id(3)]
1352 );
1353 assert!(
1354 new.to_recover
1355 .iter()
1356 .all(|update| update.chain_checked_at.is_none())
1357 );
1358 assert_eq!(ids(&new.recovered), vec![id(4)]);
1359 assert_eq!(ids(&new.exited), vec![id(5)]);
1360 }
1361
1362 #[test]
1363 fn a_leaf_too_small_to_send_out_is_stored_without_a_chain_check() {
1364 let leaf = |id: &str, status, value| {
1365 let mut leaf = create_test_node_with_parent(id, None, status);
1366 leaf.value = value;
1367 leaf
1368 };
1369 let recovered = |value| leaf(LEAF_ID, TreeNodeStatus::WatchtowerExitRecovered, value);
1370 let exited = |value| leaf(OTHER_LEAF_ID, TreeNodeStatus::Exited, value);
1371
1372 assert!(can_be_sent_out(&recovered(393)));
1375 assert!(!can_be_sent_out(&recovered(392)));
1376 assert!(can_be_sent_out(&exited(59)));
1378 assert!(!can_be_sent_out(&exited(58)));
1379 assert!(can_be_sent_out(&leaf(
1381 LEAF_ID,
1382 TreeNodeStatus::WatchtowerExited,
1383 1
1384 )));
1385
1386 let new = new_leaves(&[recovered(392), exited(58)], &HashMap::new());
1387 assert!(new.recovered.is_empty() && new.exited.is_empty());
1388 assert_eq!(
1389 new.to_recover
1390 .iter()
1391 .map(|update| update.leaf_id.as_str())
1392 .collect::<Vec<_>>(),
1393 vec![LEAF_ID, OTHER_LEAF_ID]
1394 );
1395 let new = new_leaves(&[recovered(393), exited(59)], &HashMap::new());
1396 assert!(new.to_recover.is_empty());
1397 assert_eq!((new.recovered.len(), new.exited.len()), (1, 1));
1398 }
1399
1400 #[test]
1401 fn the_funds_to_recover_are_those_of_leaves_not_taken_out() {
1402 let leaf = |leaf_id: &str| {
1403 create_test_node_with_parent(leaf_id, None, TreeNodeStatus::WatchtowerExited)
1404 };
1405 let leaves = [leaf(LEAF_ID), leaf(OTHER_LEAF_ID)];
1406 let value = leaves[0].value;
1407 let new_leaf_ids = HashSet::from([LEAF_ID.to_string(), OTHER_LEAF_ID.to_string()]);
1408 let funds = |other: Option<LeafRecovery>, new_leaf_ids: &HashSet<String>| {
1409 let stored = by_leaf_id(std::iter::once(stored_leaf(LEAF_ID)).chain(other).collect());
1410 recoverable_funds(&leaves, &stored, new_leaf_ids)
1411 };
1412
1413 assert_eq!(
1414 funds(Some(stored_leaf(OTHER_LEAF_ID)), &new_leaf_ids),
1415 (value.saturating_mul(2), 2)
1416 );
1417 assert_eq!(
1418 funds(Some(stored_leaf(OTHER_LEAF_ID)), &HashSet::new()),
1419 (value.saturating_mul(2), 0),
1420 "a leaf stored before is not found again"
1421 );
1422 assert_eq!(
1423 funds(None, &HashSet::from([LEAF_ID.to_string()])),
1424 (value.saturating_mul(2), 1),
1425 "a leaf without a stored recovery is counted"
1426 );
1427 let recovered = LeafRecovery {
1428 watchtower_exit_spend: Some(in_block("recovery")),
1429 ..stored_leaf(OTHER_LEAF_ID)
1430 };
1431 assert_eq!(funds(Some(recovered), &new_leaf_ids), (value, 1));
1432 let swept = LeafRecovery {
1433 unilateral_exit_sweep: Some(in_block("sweep")),
1434 ..stored_leaf(OTHER_LEAF_ID)
1435 };
1436 assert_eq!(funds(Some(swept), &new_leaf_ids), (value, 1));
1437 }
1438
1439 fn stored_output() -> StoredWatchtowerExitOutput {
1440 StoredWatchtowerExitOutput {
1441 txid: Txid::from_byte_array([3u8; 32]).to_string(),
1442 vout: 0,
1443 amount_sats: 9_500,
1444 script_pubkey: "5120".to_string(),
1445 block_height: 100,
1446 }
1447 }
1448
1449 #[test]
1450 fn a_leaf_with_a_stored_output_and_no_check_time_is_counted_and_checked_again() {
1451 let leaf =
1452 create_test_node_with_parent(LEAF_ID, None, TreeNodeStatus::WatchtowerExitRecovered);
1453 let leaves = std::slice::from_ref(&leaf);
1454 let incomplete = LeafRecovery {
1455 watchtower_exit_output: Some(stored_output()),
1456 ..stored_leaf(LEAF_ID)
1457 };
1458 let complete = LeafRecovery {
1459 chain_checked_at: Some(1_000),
1460 ..incomplete.clone()
1461 };
1462
1463 let stored = by_leaf_id(vec![incomplete]);
1464 let new = new_leaves(leaves, &stored);
1465 assert_eq!(new.recovered.len(), 1);
1466 assert!(new.to_recover.is_empty() && new.exited.is_empty());
1467 assert_eq!(
1468 recoverable_funds(leaves, &stored, &HashSet::new()).0,
1469 leaf.value
1470 );
1471
1472 let stored = by_leaf_id(vec![complete]);
1473 let new = new_leaves(leaves, &stored);
1474 assert!(new.recovered.is_empty());
1475 assert_eq!(
1476 recoverable_funds(leaves, &stored, &HashSet::new()).0,
1477 leaf.value
1478 );
1479 }
1480
1481 #[test]
1482 fn a_leaf_without_a_looked_up_exit_is_built_on_an_assumed_output_or_fails() {
1483 let leaf = RecoverFundsLeaf {
1484 leaf_id: LEAF_ID.to_string(),
1485 value_sats: 10_000,
1486 method: RecoveryMethod::Cooperative,
1487 };
1488 let assumed = spark_wallet::WatchtowerExitOutput {
1489 leaf_id: create_test_node_with_parent(LEAF_ID, None, TreeNodeStatus::WatchtowerExited)
1490 .id,
1491 outpoint: OutPoint {
1492 txid: Txid::from_byte_array([7u8; 32]),
1493 vout: 1,
1494 },
1495 tx_out: bitcoin::TxOut {
1496 value: Amount::from_sat(9_500),
1497 script_pubkey: ScriptBuf::new(),
1498 },
1499 };
1500 let missing = |lookup: WatchtowerExitLookup, has_assumed: bool| MissingExit {
1501 lookup,
1502 assumed: has_assumed.then(|| assumed.clone()),
1503 };
1504
1505 let pending = missing(WatchtowerExitLookup::Pending, true);
1507 let Unresolved::Exit(exit) = unresolved_exit(&leaf, Some(&pending), None, false).unwrap()
1508 else {
1509 panic!("expected an exit");
1510 };
1511 assert_eq!(exit.output, assumed);
1512
1513 for lookup in [
1516 WatchtowerExitLookup::NotFound,
1517 WatchtowerExitLookup::Unrecoverable,
1518 WatchtowerExitLookup::Unilateral,
1519 ] {
1520 let elsewhere = missing(lookup, true);
1521 let Unresolved::Failed(failure) =
1522 unresolved_exit(&leaf, Some(&elsewhere), None, false).unwrap()
1523 else {
1524 panic!("expected a failure");
1525 };
1526 assert_eq!(failure.output_txid, assumed.outpoint.txid.to_string());
1527 assert_eq!(failure.output_vout, 1);
1528 assert!(matches!(
1529 failure.error,
1530 CooperativeRecoveryError::Generic { .. }
1531 ));
1532 }
1533
1534 let no_output = missing(WatchtowerExitLookup::Pending, false);
1536 assert!(unresolved_exit(&leaf, Some(&no_output), None, false).is_err());
1537 assert!(unresolved_exit(&leaf, None, None, false).is_err());
1538
1539 let stored = LeafRecovery {
1541 watchtower_exit_output: Some(stored_output()),
1542 ..stored_leaf(LEAF_ID)
1543 };
1544 assert!(matches!(
1545 unresolved_exit(&leaf, None, Some(&stored), false).unwrap(),
1546 Unresolved::Exit(_)
1547 ));
1548 }
1549
1550 #[test]
1551 fn a_finished_leaf_is_checked_always_when_named_and_else_while_not_settled() {
1552 let stored = by_leaf_id(vec![
1553 LeafRecovery {
1554 watchtower_exit_spend: Some(in_block("recovery")),
1555 ..stored_leaf(LEAF_ID)
1556 },
1557 LeafRecovery {
1558 unilateral_exit_sweep: Some(in_block("sweep")),
1559 ..stored_leaf(OTHER_LEAF_ID)
1560 },
1561 ]);
1562 let txids = |transactions: &[StoredInBlock]| -> HashSet<(String, bool)> {
1563 transactions
1564 .iter()
1565 .map(|tx| (tx.txid.clone(), tx.check_always))
1566 .collect()
1567 };
1568
1569 assert_eq!(
1570 txids(&finished_transactions(&stored, None)),
1571 HashSet::from([
1572 ("recovery".to_string(), false),
1573 ("sweep".to_string(), false)
1574 ])
1575 );
1576 let named = HashSet::from([OTHER_LEAF_ID.to_string()]);
1577 assert_eq!(
1578 txids(&finished_transactions(&stored, Some(&named))),
1579 HashSet::from([("sweep".to_string(), true)])
1580 );
1581 }
1582
1583 #[test]
1584 fn a_named_leaf_with_a_stored_sweep_reaches_the_unilateral_prepare() {
1585 const THIRD_LEAF_ID: &str = "00000000-0000-0000-0000-00000000000c";
1586 let stored = by_leaf_id(vec![
1587 LeafRecovery {
1588 watchtower_exit_spend: Some(in_block("recovery")),
1589 ..stored_leaf(LEAF_ID)
1590 },
1591 LeafRecovery {
1592 unilateral_exit_sweep: Some(in_block("sweep")),
1593 ..stored_leaf(OTHER_LEAF_ID)
1594 },
1595 ]);
1596 let not_unilateral = HashSet::from([LEAF_ID.to_string(), OTHER_LEAF_ID.to_string()]);
1597 let named = vec![
1598 LEAF_ID.to_string(),
1599 OTHER_LEAF_ID.to_string(),
1600 THIRD_LEAF_ID.to_string(),
1601 ];
1602
1603 assert_eq!(
1604 named_for_unilateral(named, ¬_unilateral, &stored),
1605 vec![OTHER_LEAF_ID.to_string(), THIRD_LEAF_ID.to_string()]
1606 );
1607 }
1608
1609 #[test]
1610 fn a_leaf_is_not_finished_while_the_chain_shows_its_recovery_in_no_block() {
1611 let stored = || {
1612 by_leaf_id(vec![
1613 LeafRecovery {
1614 watchtower_exit_spend: Some(in_block("recovery")),
1615 ..stored_leaf(LEAF_ID)
1616 },
1617 LeafRecovery {
1618 unilateral_exit_sweep: Some(in_block("sweep")),
1619 ..stored_leaf(OTHER_LEAF_ID)
1620 },
1621 ])
1622 };
1623
1624 let (unchanged, moved) = after_finished_checks(stored(), &HashMap::new());
1626 assert_eq!(unchanged, stored());
1627 assert!(moved.is_empty());
1628
1629 let checks = HashMap::from([
1630 ("recovery".to_string(), StoredCheck::NotInBlock),
1631 (
1632 "sweep".to_string(),
1633 StoredCheck::InBlock {
1634 block_height: Some(104),
1635 },
1636 ),
1637 ]);
1638 let (checked, moved) = after_finished_checks(stored(), &checks);
1639 assert!(!is_finished(&checked[LEAF_ID]));
1640 let sweep = ChainTransaction {
1641 txid: "sweep".to_string(),
1642 block_height: 104,
1643 };
1644 assert_eq!(
1645 checked[OTHER_LEAF_ID].unilateral_exit_sweep,
1646 Some(sweep.clone())
1647 );
1648 assert_eq!(
1649 moved,
1650 vec![UpdateLeafRecovery {
1651 leaf_id: OTHER_LEAF_ID.to_string(),
1652 unilateral_exit_sweep: Some(sweep),
1653 ..Default::default()
1654 }]
1655 );
1656 }
1657
1658 #[test]
1659 fn a_quote_looks_up_no_leaf_whose_recovery_or_sweep_is_stored() {
1660 let id = |n: u8| format!("00000000-0000-0000-0000-0000000000{n:02}");
1661 let leaves: Vec<TreeNode> = (1u8..=4)
1662 .map(|n| {
1663 create_test_node_with_parent(&id(n), None, TreeNodeStatus::WatchtowerExitRecovered)
1664 })
1665 .collect();
1666 let stored = by_leaf_id(vec![
1667 LeafRecovery {
1668 watchtower_exit_spend: Some(in_block("recovery")),
1669 ..stored_leaf(&id(1))
1670 },
1671 LeafRecovery {
1672 unilateral_exit_sweep: Some(in_block("sweep")),
1673 ..stored_leaf(&id(2))
1674 },
1675 LeafRecovery {
1676 watchtower_exit_output: Some(stored_output()),
1677 ..stored_leaf(&id(3))
1678 },
1679 ]);
1680 let ids = |leaves: &[TreeNode]| -> Vec<String> {
1681 leaves.iter().map(|leaf| leaf.id.to_string()).collect()
1682 };
1683
1684 let (finished, open) = finished_and_open(leaves, &stored);
1685
1686 assert_eq!(ids(&finished), vec![id(1), id(2)]);
1687 assert_eq!(ids(&open), vec![id(3), id(4)]);
1688 }
1689
1690 fn unilateral(
1691 kind: RecoveryTxKind,
1692 txid: &str,
1693 node_id: Option<&str>,
1694 depends_on: &[&str],
1695 status: ExitTransactionStatus,
1696 ) -> RecoveryTransaction {
1697 RecoveryTransaction {
1698 kind,
1699 node_id: node_id.map(ToString::to_string),
1700 txid: txid.to_string(),
1701 tx_hex: String::new(),
1702 cpfp_tx_hex: None,
1703 csv_timelock_blocks: None,
1704 depends_on: depends_on.iter().map(ToString::to_string).collect(),
1705 status,
1706 }
1707 }
1708
1709 #[test]
1710 fn a_sweep_in_a_block_swept_the_leaves_of_the_refunds_it_spends() {
1711 let confirmed = ExitTransactionStatus::Confirmed {
1712 block_height: Some(100),
1713 };
1714 let refunds = [
1715 unilateral(
1716 RecoveryTxKind::Refund,
1717 "refund_a",
1718 Some(LEAF_ID),
1719 &[],
1720 confirmed,
1721 ),
1722 unilateral(
1723 RecoveryTxKind::Refund,
1724 "refund_b",
1725 Some(OTHER_LEAF_ID),
1726 &[],
1727 confirmed,
1728 ),
1729 ];
1730 let with_sweep = |sweep: RecoveryTransaction| -> Vec<RecoveryTransaction> {
1731 refunds.iter().cloned().chain([sweep]).collect()
1732 };
1733 let sweep =
1734 |status| unilateral(RecoveryTxKind::Sweep, "sweep", None, &["refund_a"], status);
1735
1736 assert_eq!(
1737 swept_leaves(&with_sweep(sweep(confirmed))),
1738 vec![(LEAF_ID.to_string(), in_block("sweep"))]
1739 );
1740 for status in [
1742 ExitTransactionStatus::Ready,
1743 ExitTransactionStatus::Confirmed { block_height: None },
1744 ] {
1745 assert!(swept_leaves(&with_sweep(sweep(status))).is_empty());
1746 }
1747 assert!(swept_leaves(&refunds).is_empty());
1748 }
1749
1750 fn watchtower_output() -> OutPoint {
1751 OutPoint {
1752 txid: Txid::from_byte_array([7; 32]),
1753 vout: 1,
1754 }
1755 }
1756
1757 #[test]
1758 fn a_cooperative_recovery_spends_the_output_of_its_first_input() {
1759 let tx = tx_paying(watchtower_output(), 9_500);
1760 let recovery = |tx_hex: String| RecoveryTransaction {
1761 kind: RecoveryTxKind::Cooperative,
1762 node_id: Some(LEAF_ID.to_string()),
1763 txid: tx.compute_txid().to_string(),
1764 tx_hex,
1765 cpfp_tx_hex: None,
1766 csv_timelock_blocks: None,
1767 depends_on: Vec::new(),
1768 status: ExitTransactionStatus::Ready,
1769 };
1770
1771 assert_eq!(
1772 recovered_output(&recovery(serialize_hex(&tx))).unwrap(),
1773 watchtower_output()
1774 );
1775 assert!(recovered_output(&recovery("00".to_string())).is_err());
1776 }
1777
1778 #[test]
1779 fn a_cooperative_recovery_is_confirmed_once_any_recovery_of_its_output_is() {
1780 let confirmed = ExitTransactionStatus::Confirmed {
1781 block_height: Some(120),
1782 };
1783 let in_block = OutputSpend::InBlock {
1784 txid: Txid::from_byte_array([8; 32]),
1785 block_height: Some(120),
1786 };
1787
1788 assert_eq!(
1789 cooperative_recovery_status(&in_block, true, ExitTransactionStatus::Ready),
1790 confirmed
1791 );
1792 let not_in_block = [
1794 OutputSpend::Unspent,
1795 OutputSpend::InMempool(Txid::from_byte_array([8; 32])),
1796 ];
1797 for spend in ¬_in_block {
1798 assert_eq!(
1799 cooperative_recovery_status(spend, true, confirmed),
1800 ExitTransactionStatus::Ready
1801 );
1802 }
1803 assert_eq!(
1805 cooperative_recovery_status(&OutputSpend::Unknown, true, confirmed),
1806 confirmed
1807 );
1808 let unverified = ExitTransactionStatus::Unverified;
1810 for spend in ¬_in_block {
1811 assert_eq!(
1812 cooperative_recovery_status(spend, false, unverified),
1813 unverified
1814 );
1815 }
1816 assert_eq!(
1817 cooperative_recovery_status(&in_block, false, unverified),
1818 confirmed
1819 );
1820 }
1821
1822 #[cfg(not(target_family = "wasm"))]
1823 mod stored_funds {
1824 use std::sync::atomic::Ordering;
1825
1826 use crate::{
1827 chain::stub::ChainStub, persist::sqlite::SqliteStorage,
1828 sdk::watchtower_exit::store_watchtower_exit_lookups,
1829 };
1830
1831 use super::*;
1832
1833 fn temp_storage() -> Arc<dyn Storage> {
1834 let mut dir = std::env::temp_dir();
1835 dir.push(format!("breez-recoverable-funds-{}", uuid::Uuid::new_v4()));
1836 Arc::new(SqliteStorage::new(&dir).expect("create sqlite storage"))
1837 }
1838
1839 fn leaf(leaf_id: &str, status: TreeNodeStatus) -> TreeNode {
1840 create_test_node_with_parent(leaf_id, None, status)
1841 }
1842
1843 async fn store_leaves_to_recover(storage: &Arc<dyn Storage>, leaves: &[TreeNode]) {
1846 for leaf in leaves {
1847 let stored = UpdateLeafRecovery {
1848 leaf_id: leaf.id.to_string(),
1849 ..Default::default()
1850 };
1851 storage.update_leaf_recovery(stored).await.unwrap();
1852 }
1853 let (funds, _) = recoverable_funds(leaves, &HashMap::new(), &HashSet::new());
1854 ObjectCacheRepository::new(storage.clone())
1855 .save_recoverable_funds(funds)
1856 .await
1857 .unwrap();
1858 }
1859
1860 async fn stored(storage: &Arc<dyn Storage>) -> HashMap<String, LeafRecovery> {
1861 leaf_recoveries(storage.as_ref()).await.unwrap()
1862 }
1863
1864 async fn stored_funds(storage: &Arc<dyn Storage>) -> u64 {
1865 ObjectCacheRepository::new(storage.clone())
1866 .fetch_recoverable_funds()
1867 .await
1868 .unwrap()
1869 .unwrap_or_default()
1870 }
1871
1872 async fn sweep_and_take_out(
1875 storage: &Arc<dyn Storage>,
1876 leaf: &TreeNode,
1877 leaves: &[TreeNode],
1878 ) {
1879 let before = stored(storage).await;
1880 let swept = vec![(leaf.id.to_string(), in_block("sweep"))];
1881 store_finished(storage.as_ref(), &before, Vec::new(), swept).await;
1882 take_out_finished_leaves(storage, leaves, &before).await;
1883 }
1884
1885 fn kept(
1886 kind: RecoveryTxKind,
1887 tx: &Transaction,
1888 node_id: Option<&str>,
1889 depends_on: &[&Transaction],
1890 ) -> RecoveryTransaction {
1891 RecoveryTransaction {
1892 kind,
1893 node_id: node_id.map(ToString::to_string),
1894 txid: tx.compute_txid().to_string(),
1895 tx_hex: serialize_hex(tx),
1896 cpfp_tx_hex: None,
1897 csv_timelock_blocks: None,
1898 depends_on: depends_on
1899 .iter()
1900 .map(|tx| tx.compute_txid().to_string())
1901 .collect(),
1902 status: ExitTransactionStatus::Ready,
1903 }
1904 }
1905
1906 fn chain_with(spends: &[(OutPoint, &Transaction, u32)]) -> Arc<ChainStub> {
1909 let outspends = spends
1910 .iter()
1911 .map(|(outpoint, spender, block_height)| {
1912 let spender = spender.compute_txid().to_string();
1913 (
1914 (outpoint.txid.to_string(), outpoint.vout),
1915 ChainStub::spent(&spender, true, Some(*block_height)),
1916 )
1917 })
1918 .collect();
1919 Arc::new(ChainStub {
1920 outspends,
1921 ..Default::default()
1922 })
1923 }
1924
1925 #[tokio::test]
1926 async fn check_takes_a_recovered_leaf_out_of_the_funds_to_recover() {
1927 let recovered = leaf(LEAF_ID, TreeNodeStatus::WatchtowerExitRecovered);
1928 let open = leaf(OTHER_LEAF_ID, TreeNodeStatus::WatchtowerExited);
1929 let leaves = [recovered, open.clone()];
1930 let storage = temp_storage();
1931 store_leaves_to_recover(&storage, &leaves).await;
1932 let before = stored(&storage).await;
1933 let recovery = tx_paying(watchtower_output(), 9_500);
1934 let mut transactions = vec![kept(
1935 RecoveryTxKind::Cooperative,
1936 &recovery,
1937 Some(LEAF_ID),
1938 &[],
1939 )];
1940 let chain = chain_with(&[(watchtower_output(), &recovery, 120)]);
1941 let mut queries = ChainQueries::new(chain.clone());
1942
1943 let spends = check_cooperative_recoveries(&mut queries, &mut transactions)
1944 .await
1945 .unwrap();
1946 store_finished(storage.as_ref(), &before, spends, Vec::new()).await;
1947 take_out_finished_leaves(&storage, &leaves, &before).await;
1948
1949 assert_eq!(stored_funds(&storage).await, open.value);
1950 assert_eq!(
1951 transactions[0].status,
1952 ExitTransactionStatus::Confirmed {
1953 block_height: Some(120)
1954 }
1955 );
1956 assert_eq!(chain.requests.load(Ordering::SeqCst), 1);
1957 }
1958
1959 #[tokio::test]
1960 async fn check_takes_a_swept_leaf_out_of_the_funds_to_recover() {
1961 let exited = leaf(LEAF_ID, TreeNodeStatus::Exited);
1962 let open = leaf(OTHER_LEAF_ID, TreeNodeStatus::OnChain);
1963 let leaves = [exited, open.clone()];
1964 let storage = temp_storage();
1965 store_leaves_to_recover(&storage, &leaves).await;
1966 let before = stored(&storage).await;
1967 let node_output = OutPoint {
1968 txid: Txid::from_byte_array([5; 32]),
1969 vout: 0,
1970 };
1971 let refund = tx_paying(node_output, 900);
1972 let refund_output = OutPoint {
1973 txid: refund.compute_txid(),
1974 vout: 0,
1975 };
1976 let sweep = tx_paying(refund_output, 800);
1977 let mut transactions = vec![
1978 kept(RecoveryTxKind::Refund, &refund, Some(LEAF_ID), &[]),
1979 kept(RecoveryTxKind::Sweep, &sweep, None, &[&refund]),
1980 ];
1981 let chain = chain_with(&[(node_output, &refund, 100), (refund_output, &sweep, 101)]);
1982 let mut queries = ChainQueries::new(chain.clone());
1983
1984 let diverged = check_recovery_transactions(&mut queries, &mut transactions)
1985 .await
1986 .unwrap();
1987 let swept = swept_leaves(&transactions);
1988 store_finished(storage.as_ref(), &before, Vec::new(), swept).await;
1989 take_out_finished_leaves(&storage, &leaves, &before).await;
1990
1991 assert!(!diverged);
1992 assert_eq!(stored_funds(&storage).await, open.value);
1993 assert_eq!(chain.requests.load(Ordering::SeqCst), 2);
1994 }
1995
1996 #[tokio::test]
1997 async fn prepare_takes_a_recovered_leaf_out_of_the_funds_to_recover() {
1998 let recovered = leaf(LEAF_ID, TreeNodeStatus::WatchtowerExitRecovered);
1999 let open = leaf(OTHER_LEAF_ID, TreeNodeStatus::WatchtowerExited);
2000 let leaves = [recovered.clone(), open.clone()];
2001 let storage = temp_storage();
2002 store_leaves_to_recover(&storage, &leaves).await;
2003 let output = stored_output();
2004 let outpoint = OutPoint {
2005 txid: output.txid.parse().unwrap(),
2006 vout: output.vout,
2007 };
2008 let found_output = UpdateLeafRecovery {
2009 leaf_id: LEAF_ID.to_string(),
2010 chain_checked_at: Some(900),
2011 watchtower_exit_output: Some(output),
2012 ..Default::default()
2013 };
2014 storage.update_leaf_recovery(found_output).await.unwrap();
2015 let before = stored(&storage).await;
2016 let recovery = tx_paying(outpoint, 9_000);
2017 let chain = chain_with(&[(outpoint, &recovery, 101)]);
2018 let mut queries = ChainQueries::new(chain.clone());
2019
2020 let found = store_watchtower_exit_lookups(
2021 std::slice::from_ref(&recovered),
2022 &HashMap::new(),
2023 true,
2024 &before,
2025 &HashSet::from([LEAF_ID.to_string()]),
2026 &mut queries,
2027 storage.as_ref(),
2028 )
2029 .await
2030 .unwrap();
2031 take_out_finished_leaves(&storage, &leaves, &before).await;
2032
2033 assert!(found.exits[LEAF_ID].recovered);
2034 assert_eq!(stored_funds(&storage).await, open.value);
2035 assert_eq!(chain.requests.load(Ordering::SeqCst), 1);
2036 }
2037
2038 #[tokio::test]
2039 async fn the_sdk_adds_no_leaf_to_the_funds_to_recover_outside_a_sync() {
2040 let exited = leaf(LEAF_ID, TreeNodeStatus::Exited);
2041 let mut new = leaf(OTHER_LEAF_ID, TreeNodeStatus::OnChain);
2042 new.value = exited.value.saturating_mul(3);
2043 let storage = temp_storage();
2044 store_leaves_to_recover(&storage, std::slice::from_ref(&exited)).await;
2045
2046 sweep_and_take_out(&storage, &exited, &[exited.clone(), new]).await;
2047
2048 assert_eq!(stored_funds(&storage).await, exited.value);
2049 }
2050
2051 #[tokio::test]
2052 async fn a_leaf_the_wallet_no_longer_lists_stays_in_the_funds_to_recover() {
2053 let exited = leaf(LEAF_ID, TreeNodeStatus::Exited);
2054 let unlisted = leaf(OTHER_LEAF_ID, TreeNodeStatus::OnChain);
2055 let storage = temp_storage();
2056 store_leaves_to_recover(&storage, &[exited.clone(), unlisted.clone()]).await;
2057
2058 sweep_and_take_out(&storage, &exited, std::slice::from_ref(&exited)).await;
2059
2060 assert_eq!(stored_funds(&storage).await, unlisted.value);
2061 }
2062
2063 #[tokio::test]
2064 async fn the_funds_to_recover_stay_when_a_swept_leaf_was_not_among_them() {
2065 let exited = leaf(LEAF_ID, TreeNodeStatus::Exited);
2066 let open = leaf(OTHER_LEAF_ID, TreeNodeStatus::OnChain);
2067 let storage = temp_storage();
2068 store_leaves_to_recover(&storage, std::slice::from_ref(&open)).await;
2069
2070 sweep_and_take_out(&storage, &exited, &[exited.clone(), open.clone()]).await;
2071
2072 assert_eq!(stored_funds(&storage).await, open.value);
2073 }
2074
2075 #[tokio::test]
2076 async fn the_funds_to_recover_stay_for_a_leaf_swept_before_the_call() {
2077 let exited = leaf(LEAF_ID, TreeNodeStatus::Exited);
2078 let unlisted = leaf(OTHER_LEAF_ID, TreeNodeStatus::OnChain);
2079 let storage = temp_storage();
2080 store_leaves_to_recover(&storage, std::slice::from_ref(&unlisted)).await;
2081 let swept = vec![(LEAF_ID.to_string(), in_block("sweep"))];
2082 store_finished(storage.as_ref(), &HashMap::new(), Vec::new(), swept).await;
2083 let before = stored(&storage).await;
2084
2085 take_out_finished_leaves(&storage, std::slice::from_ref(&exited), &before).await;
2086
2087 assert_eq!(stored_funds(&storage).await, unlisted.value);
2088 }
2089
2090 #[tokio::test]
2091 async fn the_funds_to_recover_stay_while_the_wallet_lists_no_leaf() {
2092 let exited = leaf(LEAF_ID, TreeNodeStatus::Exited);
2093 let storage = temp_storage();
2094 store_leaves_to_recover(&storage, std::slice::from_ref(&exited)).await;
2095
2096 sweep_and_take_out(&storage, &exited, &[]).await;
2097
2098 assert_eq!(stored_funds(&storage).await, exited.value);
2099 }
2100 }
2101}