breez_sdk_liquid/recover/handlers/
handle_send_swap.rs

1use std::sync::Arc;
2
3use anyhow::Result;
4use boltz_client::ToHex;
5use log::{debug, error, warn};
6use lwk_wollet::elements::Txid;
7
8use crate::prelude::*;
9use crate::recover::model::*;
10use crate::swapper::Swapper;
11use crate::utils;
12
13/// Handler for updating send swaps with recovered data
14pub(crate) struct SendSwapHandler;
15
16impl SendSwapHandler {
17    /// Check if send swap recovery should be skipped
18    pub fn should_skip_recovery(
19        send_swap: &SendSwap,
20        recovered_data: &RecoveredOnchainDataSend,
21        is_within_grace_period: bool,
22    ) -> bool {
23        let swap_id = &send_swap.id;
24        let lockup_is_cleared =
25            send_swap.lockup_tx_id.is_some() && recovered_data.lockup_tx_id.is_none();
26        let refund_is_cleared =
27            send_swap.refund_tx_id.is_some() && recovered_data.refund_tx_id.is_none();
28
29        if is_within_grace_period && (lockup_is_cleared || refund_is_cleared) {
30            warn!(
31                "Local send swap {swap_id} was updated recently - skipping recovery \
32                as it would clear a tx that may have been broadcasted by us. Lockup clear: \
33                {lockup_is_cleared} - Refund clear: {refund_is_cleared}"
34            );
35            return true;
36        }
37
38        false
39    }
40
41    /// Recover and update a send swap with data from the chain
42    pub async fn recover_swap(
43        send_swap: &mut SendSwap,
44        context: &ReceiveOrSendSwapRecoveryContext,
45        is_within_grace_period: bool,
46    ) -> Result<()> {
47        let swap_id = send_swap.id.clone();
48        debug!("[Recover Send] Recovering data for swap {swap_id}");
49        let swap_script = send_swap.get_swap_script()?;
50        let lockup_script = swap_script
51            .funding_addrs
52            .ok_or(anyhow::anyhow!("no funding address found"))?
53            .script_pubkey();
54
55        let empty_history = Vec::<LBtcHistory>::new();
56        let history = context
57            .lbtc_script_to_history_map
58            .get(&lockup_script)
59            .unwrap_or(&empty_history);
60
61        // First obtain transaction IDs from the history
62        let mut recovered_data = Self::recover_onchain_data(&context.tx_map, &swap_id, history)?;
63
64        // Recover preimage if needed
65        if recovered_data.lockup_tx_id.is_some() && send_swap.preimage.is_none() {
66            // We can attempt to recover the preimage cooperatively after we know the
67            // lockup tx was broadcast. If we cannot recover it cooperatively,
68            // we can try to recover it from the claim tx.
69            match Self::recover_preimage(
70                context,
71                recovered_data.claim_tx_id.clone(),
72                &swap_id,
73                context.swapper.clone(),
74            )
75            .await
76            {
77                Ok(Some(preimage)) => {
78                    recovered_data.preimage = Some(preimage);
79                }
80                Ok(None) => {
81                    warn!("No preimage found for Send Swap {swap_id}");
82                    recovered_data.claim_tx_id = None;
83                }
84                Err(e) => {
85                    error!("Failed to recover preimage for swap {swap_id}: {e}");
86                    recovered_data.claim_tx_id = None
87                }
88            }
89        }
90
91        // Update the swap with recovered data
92        Self::update_swap(
93            send_swap,
94            &swap_id,
95            &recovered_data,
96            context.liquid_tip_height,
97            is_within_grace_period,
98        )
99    }
100
101    /// Update a send swap with recovered data
102    pub fn update_swap(
103        send_swap: &mut SendSwap,
104        swap_id: &str,
105        recovered_data: &RecoveredOnchainDataSend,
106        current_block_height: u32,
107        is_within_grace_period: bool,
108    ) -> Result<()> {
109        // Skip updating if within grace period and would clear transactions
110        if Self::should_skip_recovery(send_swap, recovered_data, is_within_grace_period) {
111            return Ok(());
112        }
113
114        // Update transaction IDs
115        send_swap.lockup_tx_id = recovered_data
116            .lockup_tx_id
117            .clone()
118            .map(|h| h.txid.to_string());
119        send_swap.refund_tx_id = recovered_data
120            .refund_tx_id
121            .clone()
122            .map(|h| h.txid.to_string());
123
124        // Update preimage if valid
125        if let Some(preimage) = &recovered_data.preimage {
126            match utils::verify_payment_hash(preimage, &send_swap.invoice) {
127                Ok(_) => send_swap.preimage = Some(preimage.clone()),
128                Err(e) => {
129                    error!("Failed to verify recovered preimage for swap {swap_id}: {e}");
130                }
131            }
132        }
133
134        // Update state based on recovered data
135        let timeout_block_height = send_swap.timeout_block_height as u32;
136        let is_expired = current_block_height >= timeout_block_height;
137        if let Some(new_state) =
138            recovered_data.derive_partial_state(send_swap.preimage.clone(), is_expired)
139        {
140            send_swap.state = new_state;
141        }
142
143        Ok(())
144    }
145
146    /// Reconstruct Send Swap tx IDs from the onchain data
147    ///
148    /// The implementation tolerates a `tx_map` that is older than the history in the sense that
149    /// no incorrect data is recovered. Transactions that are missing from `tx_map` are simply not recovered.
150    fn recover_onchain_data(
151        tx_map: &TxMap,
152        swap_id: &str,
153        wallet_history: &[LBtcHistory],
154    ) -> Result<RecoveredOnchainDataSend> {
155        // If a history tx is one of our outgoing txs, it's a lockup tx
156        let lockup_tx_id = wallet_history
157            .iter()
158            .find(|&tx| tx_map.outgoing_tx_map.contains_key::<Txid>(&tx.txid))
159            .cloned();
160
161        let claim_tx_id = if lockup_tx_id.is_some() {
162            // A history tx that is neither a known incoming or outgoing tx is a claim tx.
163            //
164            // Only find the claim_tx from the history if we find a lockup_tx. Not doing so will select
165            // the first tx as the claim, whereas we should check that the claim is not the lockup.
166            wallet_history
167                .iter()
168                .filter(|&tx| !tx_map.incoming_tx_map.contains_key::<Txid>(&tx.txid))
169                .find(|&tx| !tx_map.outgoing_tx_map.contains_key::<Txid>(&tx.txid))
170                .cloned()
171        } else {
172            error!("No lockup tx found when recovering data for Send Swap {swap_id}");
173            None
174        };
175
176        // If a history tx is one of our incoming txs, it's a refund tx
177        let refund_tx_id = wallet_history
178            .iter()
179            .find(|&tx| tx_map.incoming_tx_map.contains_key::<Txid>(&tx.txid))
180            .cloned();
181
182        Ok(RecoveredOnchainDataSend {
183            lockup_tx_id,
184            claim_tx_id,
185            refund_tx_id,
186            preimage: None,
187        })
188    }
189
190    /// Tries to recover the preimage for a send swap
191    async fn recover_preimage(
192        context: &ReceiveOrSendSwapRecoveryContext,
193        claim_tx_id: Option<LBtcHistory>,
194        swap_id: &str,
195        swapper: Arc<dyn Swapper>,
196    ) -> Result<Option<String>> {
197        // Try cooperative first
198        if let Ok(preimage) = swapper.get_submarine_preimage(swap_id).await {
199            log::debug!("Fetched Send Swap {swap_id} preimage cooperatively: {preimage}");
200            return Ok(Some(preimage));
201        }
202        warn!("Could not recover Send swap {swap_id} preimage cooperatively");
203        match claim_tx_id {
204            // If we have a claim tx id, we can try to recover the preimage from the tx
205            Some(claim_tx_id) => {
206                let claim_txs = context
207                    .liquid_chain_service
208                    .get_transactions(&[claim_tx_id.txid])
209                    .await?;
210                match claim_txs.is_empty() {
211                    false => Self::extract_preimage_from_claim_tx(&claim_txs[0], swap_id).map(Some),
212                    true => {
213                        warn!("Could not recover Send swap {swap_id} preimage non cooperatively");
214                        Ok(None)
215                    }
216                }
217            }
218            None => Ok(None),
219        }
220    }
221
222    /// Extracts the preimage from a claim tx
223    pub fn extract_preimage_from_claim_tx(
224        claim_tx: &lwk_wollet::elements::Transaction,
225        swap_id: &str,
226    ) -> Result<String> {
227        use lwk_wollet::bitcoin::Witness;
228        use lwk_wollet::hashes::{sha256, Hash as _};
229
230        let input = claim_tx
231            .input
232            .first()
233            .ok_or_else(|| anyhow::anyhow!("Found no input for claim tx"))?;
234
235        let script_witness_bytes = input.clone().witness.script_witness;
236        log::debug!("Found Send Swap {swap_id} claim tx witness: {script_witness_bytes:?}");
237        let script_witness = Witness::from(script_witness_bytes);
238
239        let preimage_bytes = script_witness
240            .nth(1)
241            .ok_or_else(|| anyhow::anyhow!("Claim tx witness has no preimage"))?;
242        let preimage = sha256::Hash::from_slice(preimage_bytes)
243            .map_err(|e| anyhow::anyhow!("Claim tx witness has invalid preimage: {e}"))?;
244        let preimage_hex = preimage.to_hex();
245        log::debug!("Found Send Swap {swap_id} claim tx preimage: {preimage_hex}");
246
247        Ok(preimage_hex)
248    }
249}
250
251pub(crate) struct RecoveredOnchainDataSend {
252    pub(crate) lockup_tx_id: Option<LBtcHistory>,
253    pub(crate) claim_tx_id: Option<LBtcHistory>,
254    pub(crate) refund_tx_id: Option<LBtcHistory>,
255    pub(crate) preimage: Option<String>,
256}
257
258impl RecoveredOnchainDataSend {
259    pub(crate) fn derive_partial_state(
260        &self,
261        preimage: Option<String>,
262        is_expired: bool,
263    ) -> Option<PaymentState> {
264        match &self.lockup_tx_id {
265            Some(_) => match preimage {
266                Some(_) => Some(PaymentState::Complete),
267                None => match &self.refund_tx_id {
268                    Some(refund_tx_id) => match refund_tx_id.confirmed() {
269                        true => Some(PaymentState::Failed),
270                        false => Some(PaymentState::RefundPending),
271                    },
272                    None => match is_expired {
273                        true => Some(PaymentState::RefundPending),
274                        false => Some(PaymentState::Pending),
275                    },
276                },
277            },
278            None => match is_expired {
279                true => Some(PaymentState::Failed),
280                // We have no onchain data to support deriving the state as the swap could
281                // potentially be Created or TimedOut. In this case we return None.
282                false => None,
283            },
284        }
285    }
286}