breez_sdk_liquid/recover/handlers/
handle_send_swap.rs

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