Skip to main content

breez_sdk_spark/sdk/
recover_funds.rs

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    /// Prepares a recovery of the selected leaves: how each is recovered, the
47    /// exact fees, and how much to fund. It records a recovery or sweep it finds
48    /// in a block, and takes those funds out of the total to recover.
49    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    /// Builds and signs the recovery a `prepare_recover_funds` quote describes:
132    /// the operators co-sign each cooperative recovery, and `signer` signs the
133    /// spends of `funding_inputs` for the unilateral exit. Returns every
134    /// transaction in broadcast order, without broadcasting.
135    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        // Looked up first, so an unknown leaf fails the call before anything is
152        // signed.
153        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    /// Reads a recovery you kept back against the chain: which of its
222    /// transactions are now in a block, and whether it can still be finished as
223    /// it stands.
224    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    /// Leaves only a cooperative recovery reaches, and that have none to prepare.
269    skipped: Vec<SkippedLeaf>,
270    unilateral: Option<spark_wallet::ExitLeafSelection>,
271    /// Leaves that no unilateral exit recovers.
272    not_unilateral: HashSet<String>,
273}
274
275/// The leaves a sync has work for, by what the SDK does with each.
276#[derive(Default)]
277struct NewLeaves {
278    /// Leaves without a stored recovery. The SDK stores these as they are,
279    /// without a chain check.
280    to_recover: Vec<UpdateLeafRecovery>,
281    /// Leaves whose check is not complete. The SDK checks these with the chain
282    /// service: the operators report a recovered leaf the same whether or not
283    /// its recovery is in a block.
284    recovered: Vec<TreeNode>,
285    /// Leaves without a stored recovery. The SDK checks these with the chain
286    /// service: the operators report an exited leaf the same whether or not a
287    /// sweep of its refund is in a block.
288    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    /// The stored recoveries as prepare reads them. The SDK first checks that
300    /// the recovery or sweep of a finished leaf is still in a block: always for
301    /// a leaf `selection` names, and else while the transaction is not settled.
302    /// One the chain service no longer shows in a block is left out, so its
303    /// leaf is not finished for this call. Storage keeps it.
304    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    /// Stores the recovery of each leaf that has none stored, then the total of the
329    /// funds to recover: the value of every leaf whose recovery or sweep the SDK
330    /// has not seen in a block. The funds of a recovered or exited leaf may have
331    /// left already, so the SDK checks such leaves with the chain service, in
332    /// batches, and stores what each batch established: a later sync continues
333    /// where a failed request stopped this one. Emits `RecoverableFunds` for a
334    /// leaf it stored without a check, or whose check it completed with funds
335    /// still to recover.
336    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        // The SDK stored what the checks above found, and it can have stored a
378        // recovery or sweep in one of the recovery calls meanwhile.
379        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    /// Stores the spends in `recovered` and the sweeps in a block among
414    /// `transactions`, and takes their leaves out of the recoverable funds.
415    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    /// Takes each leaf whose recovery or sweep the SDK stored after it read
436    /// `before` out of the stored recoverable funds.
437    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            // Its direct tx can confirm at a fee its refunds do not spend, and a
527            // leaf renewed at a zero timelock has no refund spending it at all.
528            &[TreeNodeStatus::OnChain],
529        ]
530        .concat();
531        Ok(self.spark_wallet.list_leaves_with_status(&statuses).await?)
532    }
533
534    /// The watchtower exit of each cooperative leaf of a prepare response, and
535    /// the leaves the SDK builds no recovery for. Fails when the SDK has no
536    /// output at all for a leaf: the prepare response then does not match the
537    /// wallet's data.
538    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        // The recovery takes every stored output as it is: prepare checked it.
557        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        // A finished leaf has nothing left to exit, also when the SDK cannot
630        // read its exit from the chain.
631        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, &not_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
679/// What `recover_funds` makes of a cooperative leaf without an exit from the
680/// lookup.
681enum Unresolved {
682    Exit(WatchtowerExit),
683    Failed(CooperativeRecoveryFailure),
684}
685
686/// The exit of `leaf` when the lookup gave none, or why the leaf failed. The
687/// SDK builds on the stored output of a leaf the wallet no longer lists, and
688/// on the output it assumes when the lookup has no result. A lookup that shows
689/// the leaf's funds elsewhere fails the leaf. Errors when the SDK has no output
690/// for the leaf at all.
691fn 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
744/// The leaves `selection` names. `None` for a selection by status.
745fn 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
752/// The named leaves the unilateral prepare gets. One with a stored sweep is
753/// among them although nothing is left to exit: the unilateral prepare reports
754/// its refund as swept.
755fn 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
766/// The recovery and the sweep in a block of each finished leaf a call covers:
767/// the leaves in `named`, or every stored leaf for a selection by status. The
768/// SDK checks one of a named leaf however deep it is.
769fn 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
786/// `transaction` after its check: unset when the chain service no longer shows
787/// it in a block, and at its new height when it shows it in another block.
788/// Also whether the height changed.
789fn 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
811/// `stored` after the `checks` of its recoveries and sweeps, and what to store:
812/// each transaction the chain service shows in another block than the stored
813/// one.
814fn 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
849/// Stores each spend in `recovered` and each sweep in `swept` that is not in
850/// `stored` yet.
851async 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    // The SDK stores a spend only for a leaf that has a stored recovery.
859    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
890/// Returns whether storing `update` succeeded. The SDK only logs a failure: it
891/// can get what the update holds again, from the chain service or the
892/// operators.
893pub(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
904/// Stores `checks` and returns the leaves among them whose check is complete.
905pub(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
920/// Whether storage holds the recovery or the sweep of the leaf's funds in a
921/// block.
922fn is_finished(stored: &LeafRecovery) -> bool {
923    stored.watchtower_exit_spend.is_some() || stored.unilateral_exit_sweep.is_some()
924}
925
926/// Splits `leaves` into the finished ones and the ones prepare still looks up.
927/// A lookup of a finished leaf could only find again what storage holds.
928fn 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
937/// Whether the SDK found the leaf's output and has yet to learn whether that
938/// output is spent.
939fn 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            // The SDK sends no chain request for funds too small to send out.
955            _ 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
970/// Whether the funds of a recovered or exited `leaf` can be sent out at the
971/// lowest fee rate nodes relay. The leaf's value is the most its on-chain
972/// output holds.
973fn 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        // A recovery is a transaction of its own. Of the outputs wallets
978        // receive on, P2WPKH is the smallest and has the lowest dust limit.
979        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        // One sweep has the refunds of several leaves as inputs, so a refund
987        // only has to hold more than the fee of its own input.
988        TreeNodeStatus::Exited => refund_sweep_input_fee(fee_rate).is_some_and(|fee| value > fee),
989        _ => true,
990    }
991}
992
993/// The value of the leaves without a stored watchtower exit spend or sweep, and
994/// how many of them are among `new_leaf_ids`. A leaf whose check is not
995/// complete has neither, so it is counted.
996fn 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
1016/// Takes each of `leaves` whose recovery or sweep the SDK stored after it read
1017/// `before` out of the stored recoverable funds.
1018async 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    // The SDK adds a leaf to the total during a sync, and takes one out only
1053    // for a stored recovery or sweep. So here the total does not rise, and it
1054    // drops by no more than the leaves just taken out.
1055    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
1063/// The sweep of each leaf that has one in a block among `transactions`. A
1064/// sweep lists the refunds it depends on, and each refund has its leaf's id.
1065fn 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
1094/// The reason `prepare_recover_funds` gives for leaving out a watchtower-exited
1095/// `leaf` with no output to recover, from the `lookup` of that output. `None`
1096/// for a leaf with a recovery in a block, and for a leaf that is not
1097/// watchtower-exited.
1098fn 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
1132/// The output a cooperative recovery has as input.
1133fn 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
1145/// Updates the statuses of the cooperative recoveries `transactions` from the
1146/// chain. Returns the spend in a block of each recovered output, by leaf id.
1147async 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    // The chain service reports an output of a transaction it does not know
1156    // as unspent, so for an unverified recovery the SDK also checks that the
1157    // output's transaction is in a block.
1158    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
1192/// The status of a cooperative recovery, from the `spend` of its input:
1193/// confirmed once any recovery of that output is in a block, this one or a
1194/// replacement, and ready while the output is in a block and no recovery of it
1195/// is. Without those results from the chain service it stays `status`.
1196fn 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        // The sixth leaf has a stored recovery.
1339        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        // A recovery to a P2WPKH output is 99 vbytes, and the dust limit of
1373        // that output is 294 sats.
1374        assert!(can_be_sent_out(&recovered(393)));
1375        assert!(!can_be_sent_out(&recovered(392)));
1376        // A taproot key-path input is 57.5 vbytes.
1377        assert!(can_be_sent_out(&exited(59)));
1378        assert!(!can_be_sent_out(&exited(58)));
1379        // A leaf in another status gets no chain check to begin with.
1380        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        // The lookup has no result: the SDK builds on the output it assumes.
1506        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        // The lookup shows the leaf's funds elsewhere: the leaf fails, and the
1514        // entry names the output the SDK assumed.
1515        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        // Without any output the prepare response does not match the wallet.
1535        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        // A leaf the wallet no longer lists still has its stored output.
1540        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, &not_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        // Without a result the SDK goes by storage.
1625        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        // Not in a block, or in one the chain service did not name.
1741        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        // Not in a block, whatever the status was before.
1793        let not_in_block = [
1794            OutputSpend::Unspent,
1795            OutputSpend::InMempool(Txid::from_byte_array([8; 32])),
1796        ];
1797        for spend in &not_in_block {
1798            assert_eq!(
1799                cooperative_recovery_status(spend, true, confirmed),
1800                ExitTransactionStatus::Ready
1801            );
1802        }
1803        // Without a result the status stays as it was.
1804        assert_eq!(
1805            cooperative_recovery_status(&OutputSpend::Unknown, true, confirmed),
1806            confirmed
1807        );
1808        // An unverified recovery is ready only once its output is in a block.
1809        let unverified = ExitTransactionStatus::Unverified;
1810        for spend in &not_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        /// Stores a recovery for each of `leaves` and their value as the funds
1844        /// to recover, as the SDK does in a sync.
1845        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        /// Stores a sweep in a block for `leaf` and takes the leaf out of the
1873        /// stored funds of a wallet that lists `leaves`.
1874        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        /// A chain service that shows each of `spends` in a block: the
1907        /// outpoint, its spender, and the height of the spender's block.
1908        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}