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