breez_sdk_liquid/recover/handlers/
handle_chain_send_swap.rs

1use anyhow::Result;
2use log::{debug, error, warn};
3use lwk_wollet::elements::Txid;
4
5use crate::prelude::*;
6use crate::recover::model::*;
7
8/// Handler for updating chain send swaps with recovered data
9pub(crate) struct ChainSendSwapHandler;
10
11impl ChainSendSwapHandler {
12    /// Check if chain send swap recovery should be skipped
13    pub fn should_skip_recovery(
14        chain_swap: &ChainSwap,
15        recovered_data: &RecoveredOnchainDataChainSend,
16        is_within_grace_period: bool,
17    ) -> bool {
18        let swap_id = &chain_swap.id;
19
20        let lockup_is_cleared = chain_swap.user_lockup_tx_id.is_some()
21            && recovered_data.lbtc_user_lockup_tx_id.is_none();
22        let refund_is_cleared =
23            chain_swap.refund_tx_id.is_some() && recovered_data.lbtc_refund_tx_id.is_none();
24        let claim_is_cleared =
25            chain_swap.claim_tx_id.is_some() && recovered_data.btc_claim_tx_id.is_none();
26
27        if is_within_grace_period && (lockup_is_cleared || refund_is_cleared || claim_is_cleared) {
28            warn!(
29                "Local outgoing chain swap {swap_id} was updated recently - skipping recovery \
30                as it would clear a tx that may have been broadcasted by us. Lockup clear: \
31                {lockup_is_cleared} - Refund clear: {refund_is_cleared}"
32            );
33            return true;
34        }
35
36        false
37    }
38
39    /// Recover and update a chain send swap with data from the chain
40    pub async fn recover_swap(
41        chain_swap: &mut ChainSwap,
42        context: &ChainSwapRecoveryContext,
43        is_within_grace_period: bool,
44    ) -> Result<()> {
45        let swap_id = &chain_swap.id.clone();
46        debug!("[Recover Chain Send] Recovering data for swap {swap_id}");
47
48        // Extract claim script from swap
49        let claim_script = chain_swap
50            .get_claim_swap_script()
51            .ok()
52            .and_then(|script| script.as_bitcoin_script().ok())
53            .and_then(|script| script.funding_addrs.map(|addr| addr.script_pubkey()))
54            .ok_or_else(|| {
55                anyhow::anyhow!("BTC claim script not found for Onchain Send Swap {swap_id}")
56            })?;
57
58        let lockup_script = chain_swap
59            .get_lockup_swap_script()
60            .ok()
61            .and_then(|script| script.as_liquid_script().ok())
62            .and_then(|script| script.funding_addrs.map(|addr| addr.script_pubkey()))
63            .ok_or_else(|| {
64                anyhow::anyhow!("LBTC lockup script not found for Onchain Send Swap {swap_id}")
65            })?;
66
67        let history = &SendChainSwapHistory {
68            lbtc_lockup_script_history: context
69                .lbtc_script_to_history_map
70                .get(&lockup_script)
71                .cloned()
72                .unwrap_or_default(),
73            btc_claim_script_history: context
74                .btc_script_to_history_map
75                .get(&claim_script)
76                .cloned()
77                .unwrap_or_default(),
78            btc_claim_script_txs: context
79                .btc_script_to_txs_map
80                .get(&claim_script)
81                .cloned()
82                .unwrap_or_default(),
83        };
84
85        // First obtain transaction IDs from the history
86        let recovered_data =
87            Self::recover_onchain_data(&context.tx_map, swap_id, history, &claim_script)?;
88
89        // Update the swap with recovered data
90        Self::update_swap(
91            chain_swap,
92            &recovered_data,
93            context.liquid_tip_height,
94            context.bitcoin_tip_height,
95            is_within_grace_period,
96        )
97    }
98
99    /// Update a chain send swap with recovered data
100    pub fn update_swap(
101        chain_swap: &mut ChainSwap,
102        recovered_data: &RecoveredOnchainDataChainSend,
103        current_liquid_block_height: u32,
104        current_bitcoin_block_height: u32,
105        is_within_grace_period: bool,
106    ) -> Result<()> {
107        // Skip updating if within grace period and would clear transactions
108        if Self::should_skip_recovery(chain_swap, recovered_data, is_within_grace_period) {
109            return Ok(());
110        }
111
112        // Update state based on chain tip
113        let is_expired = current_liquid_block_height >= chain_swap.timeout_block_height
114            || current_bitcoin_block_height >= chain_swap.claim_timeout_block_height;
115        if let Some(new_state) = recovered_data.derive_partial_state(is_expired) {
116            chain_swap.state = new_state;
117        }
118
119        // Update transaction IDs
120        chain_swap.user_lockup_tx_id = recovered_data
121            .lbtc_user_lockup_tx_id
122            .clone()
123            .map(|h| h.txid.to_string());
124        chain_swap.refund_tx_id = recovered_data
125            .lbtc_refund_tx_id
126            .clone()
127            .map(|h| h.txid.to_string());
128        chain_swap.server_lockup_tx_id = recovered_data
129            .btc_server_lockup_tx_id
130            .clone()
131            .map(|h| h.txid.to_string());
132        chain_swap.claim_tx_id = recovered_data
133            .btc_claim_tx_id
134            .clone()
135            .map(|h| h.txid.to_string());
136        chain_swap.user_lockup_spent = recovered_data.user_lockup_spent;
137
138        Ok(())
139    }
140
141    /// Reconstruct Chain Send Swap tx IDs from the onchain data
142    ///
143    /// The implementation tolerates a `tx_map` that is older than the history in the sense that
144    /// no incorrect data is recovered. Transactions that are missing from `tx_map` are simply not recovered.
145    fn recover_onchain_data(
146        tx_map: &TxMap,
147        swap_id: &str,
148        history: &SendChainSwapHistory,
149        claim_script: &BtcScript,
150    ) -> Result<RecoveredOnchainDataChainSend> {
151        // If a history tx is one of our outgoing txs, it's a lockup tx
152        let lbtc_user_lockup_tx_id = history
153            .lbtc_lockup_script_history
154            .iter()
155            .find(|&tx| tx_map.outgoing_tx_map.contains_key::<Txid>(&tx.txid))
156            .cloned();
157
158        if lbtc_user_lockup_tx_id.is_none() {
159            error!("No lockup tx found when recovering data for Chain Send Swap {swap_id}");
160        }
161
162        // If a history tx is one of our incoming txs, it's a refund tx
163        let lbtc_refund_tx_id = history
164            .lbtc_lockup_script_history
165            .iter()
166            .find(|&tx| tx_map.incoming_tx_map.contains_key::<Txid>(&tx.txid))
167            .cloned();
168
169        let (btc_server_lockup_tx_id, btc_claim_tx_id) = match history
170            .btc_claim_script_history
171            .len()
172        {
173            // Only lockup tx available
174            1 => (Some(history.btc_claim_script_history[0].clone()), None),
175
176            2 => {
177                let first_tx = history.btc_claim_script_txs[0].clone();
178                let first_tx_id = history.btc_claim_script_history[0].clone();
179                let second_tx_id = history.btc_claim_script_history[1].clone();
180
181                // We check the full tx, to determine if this is the BTC lockup tx
182                let is_first_tx_lockup_tx = first_tx
183                    .output
184                    .iter()
185                    .any(|out| matches!(&out.script_pubkey, x if x == claim_script));
186
187                match is_first_tx_lockup_tx {
188                    true => (Some(first_tx_id), Some(second_tx_id)),
189                    false => (Some(second_tx_id), Some(first_tx_id)),
190                }
191            }
192            n => {
193                warn!("BTC script history with length {n} found while recovering data for Chain Send Swap {swap_id}");
194                (None, None)
195            }
196        };
197
198        // User lockup is spent if there are 2+ txs on the lockup script (lockup + spend)
199        let user_lockup_spent = history.lbtc_lockup_script_history.len() >= 2;
200
201        Ok(RecoveredOnchainDataChainSend {
202            lbtc_user_lockup_tx_id,
203            lbtc_refund_tx_id,
204            btc_server_lockup_tx_id,
205            btc_claim_tx_id,
206            user_lockup_spent,
207        })
208    }
209}
210
211pub(crate) struct RecoveredOnchainDataChainSend {
212    /// LBTC tx initiated by the SDK (the "user" as per Boltz), sending funds to the swap funding address.
213    pub(crate) lbtc_user_lockup_tx_id: Option<LBtcHistory>,
214    /// LBTC tx initiated by the SDK to itself, in case the initial funds have to be refunded.
215    pub(crate) lbtc_refund_tx_id: Option<LBtcHistory>,
216    /// BTC tx locking up funds by the swapper
217    pub(crate) btc_server_lockup_tx_id: Option<BtcHistory>,
218    /// BTC tx that claims to the final BTC destination address. The final step in a successful swap.
219    pub(crate) btc_claim_tx_id: Option<BtcHistory>,
220    /// Whether the user's LBTC lockup has been spent (by server claim or user refund)
221    pub(crate) user_lockup_spent: bool,
222}
223
224// TODO: We have to be careful around overwriting the RefundPending state, as this swap monitored
225// after the expiration of the swap and if new funds are detected on the lockup script they are refunded.
226// Perhaps we should check in the recovery the lockup balance and set accordingly.
227impl RecoveredOnchainDataChainSend {
228    pub(crate) fn derive_partial_state(&self, is_expired: bool) -> Option<PaymentState> {
229        match &self.lbtc_user_lockup_tx_id {
230            Some(_) => match (&self.btc_claim_tx_id, &self.lbtc_refund_tx_id) {
231                (Some(btc_claim_tx_id), None) => match btc_claim_tx_id.confirmed() {
232                    true => Some(PaymentState::Complete),
233                    false => Some(PaymentState::Pending),
234                },
235                (None, Some(lbtc_refund_tx_id)) => match lbtc_refund_tx_id.confirmed() {
236                    true => Some(PaymentState::Failed),
237                    false => Some(PaymentState::RefundPending),
238                },
239                (Some(btc_claim_tx_id), Some(lbtc_refund_tx_id)) => {
240                    match btc_claim_tx_id.confirmed() {
241                        true => match lbtc_refund_tx_id.confirmed() {
242                            true => Some(PaymentState::Complete),
243                            false => Some(PaymentState::RefundPending),
244                        },
245                        false => Some(PaymentState::Pending),
246                    }
247                }
248                (None, None) => match is_expired {
249                    true => Some(PaymentState::RefundPending),
250                    false => Some(PaymentState::Pending),
251                },
252            },
253            None => match is_expired {
254                true => Some(PaymentState::Failed),
255                // We have no onchain data to support deriving the state as the swap could
256                // potentially be Created or TimedOut. In this case we return None.
257                false => None,
258            },
259        }
260    }
261}
262
263#[derive(Clone)]
264pub(crate) struct SendChainSwapHistory {
265    pub(crate) lbtc_lockup_script_history: Vec<LBtcHistory>,
266    pub(crate) btc_claim_script_history: Vec<BtcHistory>,
267    pub(crate) btc_claim_script_txs: Vec<bitcoin::Transaction>,
268}