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
137        Ok(())
138    }
139
140    /// Reconstruct Chain Send Swap tx IDs from the onchain data
141    ///
142    /// The implementation tolerates a `tx_map` that is older than the history in the sense that
143    /// no incorrect data is recovered. Transactions that are missing from `tx_map` are simply not recovered.
144    fn recover_onchain_data(
145        tx_map: &TxMap,
146        swap_id: &str,
147        history: &SendChainSwapHistory,
148        claim_script: &BtcScript,
149    ) -> Result<RecoveredOnchainDataChainSend> {
150        // If a history tx is one of our outgoing txs, it's a lockup tx
151        let lbtc_user_lockup_tx_id = history
152            .lbtc_lockup_script_history
153            .iter()
154            .find(|&tx| tx_map.outgoing_tx_map.contains_key::<Txid>(&tx.txid))
155            .cloned();
156
157        if lbtc_user_lockup_tx_id.is_none() {
158            error!("No lockup tx found when recovering data for Chain Send Swap {swap_id}");
159        }
160
161        // If a history tx is one of our incoming txs, it's a refund tx
162        let lbtc_refund_tx_id = history
163            .lbtc_lockup_script_history
164            .iter()
165            .find(|&tx| tx_map.incoming_tx_map.contains_key::<Txid>(&tx.txid))
166            .cloned();
167
168        let (btc_server_lockup_tx_id, btc_claim_tx_id) = match history
169            .btc_claim_script_history
170            .len()
171        {
172            // Only lockup tx available
173            1 => (Some(history.btc_claim_script_history[0].clone()), None),
174
175            2 => {
176                let first_tx = history.btc_claim_script_txs[0].clone();
177                let first_tx_id = history.btc_claim_script_history[0].clone();
178                let second_tx_id = history.btc_claim_script_history[1].clone();
179
180                // We check the full tx, to determine if this is the BTC lockup tx
181                let is_first_tx_lockup_tx = first_tx
182                    .output
183                    .iter()
184                    .any(|out| matches!(&out.script_pubkey, x if x == claim_script));
185
186                match is_first_tx_lockup_tx {
187                    true => (Some(first_tx_id), Some(second_tx_id)),
188                    false => (Some(second_tx_id), Some(first_tx_id)),
189                }
190            }
191            n => {
192                warn!("BTC script history with length {n} found while recovering data for Chain Send Swap {swap_id}");
193                (None, None)
194            }
195        };
196
197        Ok(RecoveredOnchainDataChainSend {
198            lbtc_user_lockup_tx_id,
199            lbtc_refund_tx_id,
200            btc_server_lockup_tx_id,
201            btc_claim_tx_id,
202        })
203    }
204}
205
206pub(crate) struct RecoveredOnchainDataChainSend {
207    /// LBTC tx initiated by the SDK (the "user" as per Boltz), sending funds to the swap funding address.
208    pub(crate) lbtc_user_lockup_tx_id: Option<LBtcHistory>,
209    /// LBTC tx initiated by the SDK to itself, in case the initial funds have to be refunded.
210    pub(crate) lbtc_refund_tx_id: Option<LBtcHistory>,
211    /// BTC tx locking up funds by the swapper
212    pub(crate) btc_server_lockup_tx_id: Option<BtcHistory>,
213    /// BTC tx that claims to the final BTC destination address. The final step in a successful swap.
214    pub(crate) btc_claim_tx_id: Option<BtcHistory>,
215}
216
217// TODO: We have to be careful around overwriting the RefundPending state, as this swap monitored
218// after the expiration of the swap and if new funds are detected on the lockup script they are refunded.
219// Perhaps we should check in the recovery the lockup balance and set accordingly.
220impl RecoveredOnchainDataChainSend {
221    pub(crate) fn derive_partial_state(&self, is_expired: bool) -> Option<PaymentState> {
222        match &self.lbtc_user_lockup_tx_id {
223            Some(_) => match (&self.btc_claim_tx_id, &self.lbtc_refund_tx_id) {
224                (Some(btc_claim_tx_id), None) => match btc_claim_tx_id.confirmed() {
225                    true => Some(PaymentState::Complete),
226                    false => Some(PaymentState::Pending),
227                },
228                (None, Some(lbtc_refund_tx_id)) => match lbtc_refund_tx_id.confirmed() {
229                    true => Some(PaymentState::Failed),
230                    false => Some(PaymentState::RefundPending),
231                },
232                (Some(btc_claim_tx_id), Some(lbtc_refund_tx_id)) => {
233                    match btc_claim_tx_id.confirmed() {
234                        true => match lbtc_refund_tx_id.confirmed() {
235                            true => Some(PaymentState::Complete),
236                            false => Some(PaymentState::RefundPending),
237                        },
238                        false => Some(PaymentState::Pending),
239                    }
240                }
241                (None, None) => match is_expired {
242                    true => Some(PaymentState::RefundPending),
243                    false => Some(PaymentState::Pending),
244                },
245            },
246            None => match is_expired {
247                true => Some(PaymentState::Failed),
248                // We have no onchain data to support deriving the state as the swap could
249                // potentially be Created or TimedOut. In this case we return None.
250                false => None,
251            },
252        }
253    }
254}
255
256#[derive(Clone)]
257pub(crate) struct SendChainSwapHistory {
258    pub(crate) lbtc_lockup_script_history: Vec<LBtcHistory>,
259    pub(crate) btc_claim_script_history: Vec<BtcHistory>,
260    pub(crate) btc_claim_script_txs: Vec<bitcoin::Transaction>,
261}