Skip to main content

breez_sdk_liquid/
chain_swap.rs

1use std::str::FromStr;
2use std::time::Duration;
3use std::{collections::HashSet, sync::Arc};
4
5use anyhow::{anyhow, bail, Context, Result};
6use boltz_client::{
7    boltz::{self},
8    swaps::boltz::{ChainSwapStates, CreateChainResponse, TransactionInfo},
9    ElementsLockTime, Secp256k1, Serialize, ToHex,
10};
11use elements::{hex::FromHex, Script, Transaction};
12use futures_util::TryFutureExt;
13use log::{debug, error, info, warn};
14use lwk_wollet::hashes::hex::DisplayHex;
15use tokio::sync::{broadcast, Mutex};
16use tokio_with_wasm::alias as tokio;
17
18use crate::{
19    chain::{bitcoin::BitcoinChainService, liquid::LiquidChainService},
20    elements, ensure_sdk,
21    error::{PaymentError, SdkError, SdkResult},
22    model::{
23        BlockListener, BtcHistory, ChainSwap, ChainSwapUpdate, Config, Direction, LBtcHistory,
24        PaymentState::{self, *},
25        PaymentTxData, PaymentType, Swap, SwapScriptV2, Transaction as SdkTransaction,
26        LIQUID_FEE_RATE_MSAT_PER_VBYTE,
27    },
28    persist::Persister,
29    swapper::Swapper,
30    utils,
31    wallet::{handle_stale_cache_broadcast_error, OnchainWallet},
32};
33use crate::{
34    error::is_txn_already_spent_error, model::DEFAULT_ONCHAIN_FEE_RATE_LEEWAY_SAT,
35    persist::model::PaymentTxBalance,
36};
37
38// Estimates based on https://github.com/BoltzExchange/boltz-backend/blob/ee4c77be1fcb9bb2b45703c542ad67f7efbf218d/lib/rates/FeeProvider.ts#L68
39pub const ESTIMATED_BTC_CLAIM_TX_VSIZE: u64 = 111;
40
41pub(crate) struct ChainSwapHandler {
42    config: Config,
43    onchain_wallet: Arc<dyn OnchainWallet>,
44    persister: std::sync::Arc<Persister>,
45    swapper: Arc<dyn Swapper>,
46    liquid_chain_service: Arc<dyn LiquidChainService>,
47    bitcoin_chain_service: Arc<dyn BitcoinChainService>,
48    subscription_notifier: broadcast::Sender<String>,
49    claiming_swaps: Arc<Mutex<HashSet<String>>>,
50}
51
52#[sdk_macros::async_trait]
53impl BlockListener for ChainSwapHandler {
54    async fn on_bitcoin_block(&self, height: u32) {
55        if let Err(e) = self.claim_outgoing(height).await {
56            error!("Error claiming outgoing: {e:?}");
57        }
58    }
59
60    async fn on_liquid_block(&self, height: u32) {
61        if let Err(e) = self.refund_outgoing(height).await {
62            warn!("Error refunding outgoing: {e:?}");
63        }
64        if let Err(e) = self.claim_incoming(height).await {
65            error!("Error claiming incoming: {e:?}");
66        }
67    }
68}
69
70impl ChainSwapHandler {
71    pub(crate) fn new(
72        config: Config,
73        onchain_wallet: Arc<dyn OnchainWallet>,
74        persister: std::sync::Arc<Persister>,
75        swapper: Arc<dyn Swapper>,
76        liquid_chain_service: Arc<dyn LiquidChainService>,
77        bitcoin_chain_service: Arc<dyn BitcoinChainService>,
78    ) -> Result<Self> {
79        let (subscription_notifier, _) = broadcast::channel::<String>(30);
80        Ok(Self {
81            config,
82            onchain_wallet,
83            persister,
84            swapper,
85            liquid_chain_service,
86            bitcoin_chain_service,
87            subscription_notifier,
88            claiming_swaps: Arc::new(Mutex::new(HashSet::new())),
89        })
90    }
91
92    pub(crate) fn subscribe_payment_updates(&self) -> broadcast::Receiver<String> {
93        self.subscription_notifier.subscribe()
94    }
95
96    /// Handles status updates from Boltz for Chain swaps
97    pub(crate) async fn on_new_status(&self, update: &boltz::SwapStatus) -> Result<()> {
98        let id = &update.id;
99        let swap = self.fetch_chain_swap_by_id(id)?;
100
101        match swap.direction {
102            Direction::Incoming => self.on_new_incoming_status(&swap, update).await,
103            Direction::Outgoing => self.on_new_outgoing_status(&swap, update).await,
104        }
105    }
106
107    async fn claim_incoming(&self, height: u32) -> Result<()> {
108        let chain_swaps: Vec<ChainSwap> = self
109            .persister
110            .list_chain_swaps()?
111            .into_iter()
112            .filter(|s| {
113                s.direction == Direction::Incoming && s.state == Pending && s.claim_tx_id.is_none()
114            })
115            .collect();
116        info!(
117            "Rescanning {} incoming Chain Swap(s) server lockup txs at height {}",
118            chain_swaps.len(),
119            height
120        );
121        for swap in chain_swaps {
122            if let Err(e) = self.claim_confirmed_server_lockup(&swap).await {
123                error!(
124                    "Error rescanning server lockup of incoming Chain Swap {}: {e:?}",
125                    swap.id,
126                );
127            }
128        }
129        Ok(())
130    }
131
132    async fn claim_outgoing(&self, height: u32) -> Result<()> {
133        let chain_swaps: Vec<ChainSwap> = self
134            .persister
135            .list_chain_swaps()?
136            .into_iter()
137            .filter(|s| {
138                s.direction == Direction::Outgoing && s.state == Pending && s.claim_tx_id.is_none()
139            })
140            .collect();
141        info!(
142            "Rescanning {} outgoing Chain Swap(s) server lockup txs at height {}",
143            chain_swaps.len(),
144            height
145        );
146        for swap in chain_swaps {
147            if let Err(e) = self.claim_confirmed_server_lockup(&swap).await {
148                error!(
149                    "Error rescanning server lockup of outgoing Chain Swap {}: {e:?}",
150                    swap.id
151                );
152            }
153        }
154        Ok(())
155    }
156
157    async fn fetch_script_history(&self, swap_script: &SwapScriptV2) -> Result<Vec<(String, i32)>> {
158        let history = match swap_script {
159            SwapScriptV2::Liquid(_) => self
160                .fetch_liquid_script_history(swap_script)
161                .await?
162                .into_iter()
163                .map(|h| (h.txid.to_hex(), h.height))
164                .collect(),
165            SwapScriptV2::Bitcoin(_) => self
166                .fetch_bitcoin_script_history(swap_script)
167                .await?
168                .into_iter()
169                .map(|h| (h.txid.to_hex(), h.height))
170                .collect(),
171        };
172        Ok(history)
173    }
174
175    async fn claim_confirmed_server_lockup(&self, swap: &ChainSwap) -> Result<()> {
176        let Some(tx_id) = swap.server_lockup_tx_id.clone() else {
177            // Skip the rescan if there is no server_lockup_tx_id yet
178            return Ok(());
179        };
180        let swap_id = &swap.id;
181        let swap_script = swap.get_claim_swap_script()?;
182        let script_history = self.fetch_script_history(&swap_script).await?;
183        let (_tx_history, tx_height) =
184            script_history
185                .iter()
186                .find(|h| h.0.eq(&tx_id))
187                .ok_or(anyhow!(
188                    "Server lockup tx for Chain Swap {swap_id} was not found, txid={tx_id}"
189                ))?;
190        if *tx_height > 0 {
191            info!("Chain Swap {swap_id} server lockup tx is confirmed");
192            self.claim(swap_id)
193                .await
194                .map_err(|e| anyhow!("Could not claim Chain Swap {swap_id}: {e:?}"))?;
195        }
196        Ok(())
197    }
198
199    async fn on_new_incoming_status(
200        &self,
201        swap: &ChainSwap,
202        update: &boltz::SwapStatus,
203    ) -> Result<()> {
204        let id = update.id.clone();
205        let status = &update.status;
206        let swap_state = ChainSwapStates::from_str(status)
207            .map_err(|_| anyhow!("Invalid ChainSwapState for Chain Swap {id}: {status}"))?;
208
209        info!("Handling incoming Chain Swap transition to {status:?} for swap {id}");
210        // See https://docs.boltz.exchange/v/api/lifecycle#chain-swaps
211        match swap_state {
212            // Boltz announced the user lockup tx is in the mempool or has been confirmed.
213            ChainSwapStates::TransactionMempool | ChainSwapStates::TransactionConfirmed => {
214                if let Some(zero_conf_rejected) = update.zero_conf_rejected {
215                    info!("Is zero conf rejected for Chain Swap {id}: {zero_conf_rejected}");
216                    self.persister
217                        .update_chain_swap_accept_zero_conf(&id, !zero_conf_rejected)?;
218                }
219                if let Some(transaction) = update.transaction.clone() {
220                    let actual_payer_amount =
221                        self.fetch_incoming_swap_actual_payer_amount(swap).await?;
222                    self.persister
223                        .update_actual_payer_amount(&swap.id, actual_payer_amount)?;
224
225                    self.update_swap_info(&ChainSwapUpdate {
226                        swap_id: id,
227                        to_state: Pending,
228                        user_lockup_tx_id: Some(transaction.id),
229                        ..Default::default()
230                    })?;
231                }
232                Ok(())
233            }
234
235            // Boltz announced the server lockup tx is in the mempool.
236            // Verify the transaction and claim if zero-conf
237            ChainSwapStates::TransactionServerMempool => {
238                match swap.claim_tx_id.clone() {
239                    None => {
240                        let Some(transaction) = update.transaction.clone() else {
241                            return Err(anyhow!("Unexpected payload from Boltz status stream"));
242                        };
243
244                        if let Err(e) = self.verify_user_lockup_tx(swap).await {
245                            warn!("User lockup transaction for incoming Chain Swap {} could not be verified. err: {}", swap.id, e);
246                            return Err(anyhow!("Could not verify user lockup transaction: {e}",));
247                        }
248
249                        if let Err(e) = self
250                            .verify_server_lockup_tx(swap, &transaction, false)
251                            .await
252                        {
253                            warn!("Server lockup mempool transaction for incoming Chain Swap {} could not be verified. txid: {}, err: {}",
254                                swap.id,
255                                transaction.id,
256                                e);
257                            return Err(anyhow!(
258                                "Could not verify server lockup transaction {}: {e}",
259                                transaction.id
260                            ));
261                        }
262
263                        info!("Server lockup mempool transaction was verified for incoming Chain Swap {}", swap.id);
264                        self.update_swap_info(&ChainSwapUpdate {
265                            swap_id: id.clone(),
266                            to_state: Pending,
267                            server_lockup_tx_id: Some(transaction.id),
268                            ..Default::default()
269                        })?;
270
271                        if swap.accept_zero_conf {
272                            maybe_delay_before_claim(swap.metadata.is_local).await;
273                            self.claim(&id).await.map_err(|e| {
274                                error!("Could not cooperate Chain Swap {id} claim: {e}");
275                                anyhow!("Could not post claim details. Err: {e:?}")
276                            })?;
277                        }
278                    }
279                    Some(claim_tx_id) => {
280                        warn!("Claim tx for Chain Swap {id} was already broadcast: txid {claim_tx_id}")
281                    }
282                };
283                Ok(())
284            }
285
286            // Boltz announced the server lockup tx has been confirmed.
287            // Verify the transaction and claim
288            ChainSwapStates::TransactionServerConfirmed => {
289                match swap.claim_tx_id.clone() {
290                    None => {
291                        let Some(transaction) = update.transaction.clone() else {
292                            return Err(anyhow!("Unexpected payload from Boltz status stream"));
293                        };
294
295                        if let Err(e) = self.verify_user_lockup_tx(swap).await {
296                            warn!("User lockup transaction for incoming Chain Swap {} could not be verified. err: {}", swap.id, e);
297                            return Err(anyhow!("Could not verify user lockup transaction: {e}",));
298                        }
299
300                        let verify_res =
301                            self.verify_server_lockup_tx(swap, &transaction, true).await;
302
303                        // Set the server_lockup_tx_id if it is verified or not.
304                        // If it is not yet confirmed, then it will be claimed after confirmation
305                        // in rescan_incoming_chain_swap_server_lockup_tx()
306                        self.update_swap_info(&ChainSwapUpdate {
307                            swap_id: id.clone(),
308                            to_state: Pending,
309                            server_lockup_tx_id: Some(transaction.id.clone()),
310                            ..Default::default()
311                        })?;
312
313                        match verify_res {
314                            Ok(_) => {
315                                info!("Server lockup transaction was verified for incoming Chain Swap {}", swap.id);
316
317                                maybe_delay_before_claim(swap.metadata.is_local).await;
318                                self.claim(&id).await.map_err(|e| {
319                                    error!("Could not cooperate Chain Swap {id} claim: {e}");
320                                    anyhow!("Could not post claim details. Err: {e:?}")
321                                })?;
322                            }
323                            Err(e) => {
324                                warn!("Server lockup transaction for incoming Chain Swap {} could not be verified. txid: {}, err: {}", swap.id, transaction.id, e);
325                                return Err(anyhow!(
326                                    "Could not verify server lockup transaction {}: {e}",
327                                    transaction.id
328                                ));
329                            }
330                        }
331                    }
332                    Some(claim_tx_id) => {
333                        warn!("Claim tx for Chain Swap {id} was already broadcast: txid {claim_tx_id}")
334                    }
335                };
336                Ok(())
337            }
338
339            // If swap state is unrecoverable, either:
340            // 1. The transaction failed
341            // 2. Lockup failed (too little/too much funds were sent; incl. amountless receive swaps)
342            // 3. The claim lockup was refunded
343            // 4. The swap has expired (>24h)
344            // We initiate a cooperative refund, and then fallback to a regular one
345            ChainSwapStates::TransactionFailed
346            | ChainSwapStates::TransactionLockupFailed
347            | ChainSwapStates::TransactionRefunded
348            | ChainSwapStates::SwapExpired => {
349                // Zero-amount Receive Chain Swaps also get to TransactionLockupFailed when user locks up funds
350                let is_zero_amount = swap.payer_amount_sat == 0;
351                if matches!(swap_state, ChainSwapStates::TransactionLockupFailed) && is_zero_amount
352                {
353                    // Assumption: after a successful quote acceptance for a swap id, swapper does
354                    // not provide renegotiation via subsequent lockupFailed updates. Therefore,
355                    // ignore post-accept duplicates to avoid regressing accepted amount state.
356                    if swap.is_waiting_fee_acceptance() {
357                        if let Err(e) = self.handle_amountless_update(swap).await {
358                            // In case of error, we log the error but don't mark as refundable,
359                            // letting the recovery logic handle the state.
360                            error!("Failed to accept the quote for swap {}: {e:?}", swap.id);
361                        }
362                    } else {
363                        debug!(
364                            "Ignoring repeated TransactionLockupFailed for already-accepted zero-amount swap {}",
365                            swap.id
366                        );
367                    }
368                    return Ok(());
369                }
370
371                match swap.refund_tx_id.clone() {
372                    None => {
373                        warn!("Chain Swap {id} is in an unrecoverable state: {swap_state:?}");
374                        if self
375                            .user_lockup_tx_exists(swap)
376                            .await
377                            .context("Failed to check if user lockup tx exists")?
378                        {
379                            info!("Chain Swap {id} user lockup tx was broadcast. Setting the swap to refundable.");
380                            self.update_swap_info(&ChainSwapUpdate {
381                                swap_id: id,
382                                to_state: Refundable,
383                                ..Default::default()
384                            })?;
385                        } else {
386                            info!("Chain Swap {id} user lockup tx was never broadcast. Resolving payment as failed.");
387                            self.update_swap_info(&ChainSwapUpdate {
388                                swap_id: id,
389                                to_state: Failed,
390                                ..Default::default()
391                            })?;
392                        }
393                    }
394                    Some(refund_tx_id) => warn!(
395                        "Refund for Chain Swap {id} was already broadcast: txid {refund_tx_id}"
396                    ),
397                };
398                Ok(())
399            }
400
401            _ => {
402                debug!("Unhandled state for Chain Swap {id}: {swap_state:?}");
403                Ok(())
404            }
405        }
406    }
407
408    async fn handle_amountless_update(&self, swap: &ChainSwap) -> Result<(), PaymentError> {
409        let id = swap.id.clone();
410
411        // Since we optimistically persist the accepted receiver amount, if accepting a quote with
412        //  the swapper fails, we might still think it's accepted, so now we should get rid of the
413        //  old invalid accepted amount.
414        if swap.accepted_receiver_amount_sat.is_some() {
415            info!("Handling amountless update for swap {id} with existing accepted receiver amount. Erasing the accepted amount now...");
416            self.persister.update_accepted_receiver_amount(&id, None)?;
417        }
418
419        let quote = self
420            .swapper
421            .get_zero_amount_chain_swap_quote(&id)
422            .await
423            .map(|quote| quote.to_sat())?;
424        info!("Got quote of {quote} sat for swap {}", id);
425
426        match self.validate_amountless_swap(swap, quote).await? {
427            ValidateAmountlessSwapResult::ReadyForAccepting {
428                user_lockup_amount_sat,
429                receiver_amount_sat,
430            } => {
431                debug!("Zero-amount swap validated. Auto-accepting...");
432                self.persister
433                    .update_actual_payer_amount(&id, user_lockup_amount_sat)?;
434                self.persister
435                    .update_accepted_receiver_amount(&id, Some(receiver_amount_sat))?;
436                self.swapper
437                    .accept_zero_amount_chain_swap_quote(&id, quote)
438                    .inspect_err(|e| {
439                        error!("Failed to accept zero-amount swap {id} quote: {e} - trying to erase the accepted receiver amount...");
440                        let _ = self.persister.update_accepted_receiver_amount(&id, None);
441                    })
442                    .await?;
443                self.persister.set_chain_swap_auto_accepted_fees(&id)
444            }
445            ValidateAmountlessSwapResult::RequiresUserAction {
446                user_lockup_amount_sat,
447            } => {
448                debug!("Zero-amount swap validated. Fees are too high for automatic accepting. Moving to WaitingFeeAcceptance");
449                self.persister
450                    .update_actual_payer_amount(&id, user_lockup_amount_sat)?;
451                self.update_swap_info(&ChainSwapUpdate {
452                    swap_id: id,
453                    to_state: WaitingFeeAcceptance,
454                    ..Default::default()
455                })
456            }
457        }
458    }
459
460    async fn validate_amountless_swap(
461        &self,
462        swap: &ChainSwap,
463        quote_server_lockup_amount_sat: u64,
464    ) -> Result<ValidateAmountlessSwapResult, PaymentError> {
465        debug!("Validating {swap:?}");
466
467        ensure_sdk!(
468            matches!(swap.direction, Direction::Incoming),
469            PaymentError::generic(format!(
470                "Only an incoming chain swap can be a zero-amount swap. Swap ID: {}",
471                swap.id
472            ))
473        );
474
475        let script_pubkey = swap.get_receive_lockup_swap_script_pubkey(self.config.network)?;
476        let script_balance = self
477            .bitcoin_chain_service
478            .script_get_balance_with_retry(script_pubkey.as_script(), 10)
479            .await?;
480        debug!("Found lockup balance {script_balance:?}");
481        let user_lockup_amount_sat = match script_balance.confirmed > 0 {
482            true => script_balance.confirmed,
483            false => match script_balance.unconfirmed > 0 {
484                true => script_balance.unconfirmed.unsigned_abs(),
485                false => 0,
486            },
487        };
488        ensure_sdk!(
489            user_lockup_amount_sat > 0,
490            PaymentError::generic("Lockup address has no confirmed or unconfirmed balance")
491        );
492
493        let pair = swap.get_boltz_pair()?;
494
495        // Original server lockup quote estimate
496        let server_fees_estimate_sat = pair.fees.server();
497        let service_fees_sat = pair.fees.boltz(user_lockup_amount_sat);
498        let server_lockup_amount_estimate_sat =
499            user_lockup_amount_sat - server_fees_estimate_sat - service_fees_sat;
500
501        // Min auto accept server lockup quote
502        let server_fees_leeway_sat = self
503            .config
504            .onchain_fee_rate_leeway_sat
505            .unwrap_or(DEFAULT_ONCHAIN_FEE_RATE_LEEWAY_SAT);
506        let min_auto_accept_server_lockup_amount_sat =
507            server_lockup_amount_estimate_sat.saturating_sub(server_fees_leeway_sat);
508
509        debug!(
510            "user_lockup_amount_sat = {user_lockup_amount_sat}, \
511            service_fees_sat = {service_fees_sat}, \
512            server_fees_estimate_sat = {server_fees_estimate_sat}, \
513            server_fees_leeway_sat = {server_fees_leeway_sat}, \
514            min_auto_accept_server_lockup_amount_sat = {min_auto_accept_server_lockup_amount_sat}, \
515            quote_server_lockup_amount_sat = {quote_server_lockup_amount_sat}",
516        );
517
518        if min_auto_accept_server_lockup_amount_sat > quote_server_lockup_amount_sat {
519            Ok(ValidateAmountlessSwapResult::RequiresUserAction {
520                user_lockup_amount_sat,
521            })
522        } else {
523            let receiver_amount_sat = quote_server_lockup_amount_sat - swap.claim_fees_sat;
524            Ok(ValidateAmountlessSwapResult::ReadyForAccepting {
525                user_lockup_amount_sat,
526                receiver_amount_sat,
527            })
528        }
529    }
530
531    async fn on_new_outgoing_status(
532        &self,
533        swap: &ChainSwap,
534        update: &boltz::SwapStatus,
535    ) -> Result<()> {
536        let id = update.id.clone();
537        let status = &update.status;
538        let swap_state = ChainSwapStates::from_str(status)
539            .map_err(|_| anyhow!("Invalid ChainSwapState for Chain Swap {id}: {status}"))?;
540
541        info!("Handling outgoing Chain Swap transition to {status:?} for swap {id}");
542        // See https://docs.boltz.exchange/v/api/lifecycle#chain-swaps
543        match swap_state {
544            // The swap is created
545            ChainSwapStates::Created => {
546                match (swap.state, swap.user_lockup_tx_id.clone()) {
547                    // The swap timed out before receiving this status
548                    (TimedOut, _) => warn!("Chain Swap {id} timed out, do not broadcast a lockup tx"),
549
550                    // Create the user lockup tx
551                    (_, None) => {
552                        let create_response = swap.get_boltz_create_response()?;
553                        let user_lockup_tx = self.lockup_funds(&id, &create_response).await?;
554                        let lockup_tx_id = user_lockup_tx.txid().to_string();
555                        let lockup_tx_fees_sat: u64 = user_lockup_tx.all_fees().values().sum();
556
557                        // We insert a pseudo-lockup-tx in case LWK fails to pick up the new mempool tx for a while
558                        // This makes the tx known to the SDK (get_info, list_payments) instantly
559                        self.persister.insert_or_update_payment(PaymentTxData {
560                            tx_id: lockup_tx_id.clone(),
561                            timestamp: Some(utils::now()),
562                            fees_sat: lockup_tx_fees_sat,
563                            is_confirmed: false,
564                            unblinding_data: None,
565                        },
566                        &[PaymentTxBalance {
567                            asset_id: self.config.lbtc_asset_id().to_string(),
568                            amount: create_response.lockup_details.amount,
569                            payment_type: PaymentType::Send,
570                        }],
571                        None, false)?;
572
573                        self.update_swap_info(&ChainSwapUpdate {
574                            swap_id: id,
575                            to_state: Pending,
576                            user_lockup_tx_id: Some(lockup_tx_id),
577                            ..Default::default()
578                        })?;
579                    },
580
581                    // Lockup tx already exists
582                    (_, Some(lockup_tx_id)) => warn!("User lockup tx for Chain Swap {id} was already broadcast: txid {lockup_tx_id}"),
583                };
584                Ok(())
585            }
586
587            // Boltz announced the user lockup tx is in the mempool or has been confirmed.
588            ChainSwapStates::TransactionMempool | ChainSwapStates::TransactionConfirmed => {
589                if let Some(zero_conf_rejected) = update.zero_conf_rejected {
590                    info!("Is zero conf rejected for Chain Swap {id}: {zero_conf_rejected}");
591                    self.persister
592                        .update_chain_swap_accept_zero_conf(&id, !zero_conf_rejected)?;
593                }
594                if let Some(transaction) = update.transaction.clone() {
595                    self.update_swap_info(&ChainSwapUpdate {
596                        swap_id: id,
597                        to_state: Pending,
598                        user_lockup_tx_id: Some(transaction.id),
599                        ..Default::default()
600                    })?;
601                }
602                Ok(())
603            }
604
605            // Boltz announced the server lockup tx is in the mempool.
606            // Verify the transaction and claim if zero-conf
607            ChainSwapStates::TransactionServerMempool => {
608                match swap.claim_tx_id.clone() {
609                    None => {
610                        let Some(transaction) = update.transaction.clone() else {
611                            return Err(anyhow!("Unexpected payload from Boltz status stream"));
612                        };
613
614                        if let Err(e) = self.verify_user_lockup_tx(swap).await {
615                            warn!("User lockup transaction for outgoing Chain Swap {} could not be verified. err: {}", swap.id, e);
616                            return Err(anyhow!("Could not verify user lockup transaction: {e}",));
617                        }
618
619                        if let Err(e) = self
620                            .verify_server_lockup_tx(swap, &transaction, false)
621                            .await
622                        {
623                            warn!("Server lockup mempool transaction for outgoing Chain Swap {} could not be verified. txid: {}, err: {}",
624                                swap.id,
625                                transaction.id,
626                                e);
627                            return Err(anyhow!(
628                                "Could not verify server lockup transaction {}: {e}",
629                                transaction.id
630                            ));
631                        }
632
633                        info!("Server lockup mempool transaction was verified for outgoing Chain Swap {}", swap.id);
634                        self.update_swap_info(&ChainSwapUpdate {
635                            swap_id: id.clone(),
636                            to_state: Pending,
637                            server_lockup_tx_id: Some(transaction.id),
638                            ..Default::default()
639                        })?;
640
641                        if swap.accept_zero_conf {
642                            maybe_delay_before_claim(swap.metadata.is_local).await;
643                            self.claim(&id).await.map_err(|e| {
644                                error!("Could not cooperate Chain Swap {id} claim: {e}");
645                                anyhow!("Could not post claim details. Err: {e:?}")
646                            })?;
647                        }
648                    }
649                    Some(claim_tx_id) => {
650                        warn!("Claim tx for Chain Swap {id} was already broadcast: txid {claim_tx_id}")
651                    }
652                };
653                Ok(())
654            }
655
656            // Boltz announced the server lockup tx has been confirmed.
657            // Verify the transaction and claim
658            ChainSwapStates::TransactionServerConfirmed => {
659                match swap.claim_tx_id.clone() {
660                    None => {
661                        let Some(transaction) = update.transaction.clone() else {
662                            return Err(anyhow!("Unexpected payload from Boltz status stream"));
663                        };
664
665                        if let Err(e) = self.verify_user_lockup_tx(swap).await {
666                            warn!("User lockup transaction for outgoing Chain Swap {} could not be verified. err: {}", swap.id, e);
667                            return Err(anyhow!("Could not verify user lockup transaction: {e}",));
668                        }
669
670                        if let Err(e) = self.verify_server_lockup_tx(swap, &transaction, true).await
671                        {
672                            warn!("Server lockup transaction for outgoing Chain Swap {} could not be verified. txid: {}, err: {}",
673                                swap.id,
674                                transaction.id,
675                                e);
676                            return Err(anyhow!(
677                                "Could not verify server lockup transaction {}: {e}",
678                                transaction.id
679                            ));
680                        }
681
682                        info!(
683                            "Server lockup transaction was verified for outgoing Chain Swap {}",
684                            swap.id
685                        );
686                        self.update_swap_info(&ChainSwapUpdate {
687                            swap_id: id.clone(),
688                            to_state: Pending,
689                            server_lockup_tx_id: Some(transaction.id),
690                            ..Default::default()
691                        })?;
692
693                        maybe_delay_before_claim(swap.metadata.is_local).await;
694                        self.claim(&id).await.map_err(|e| {
695                            error!("Could not cooperate Chain Swap {id} claim: {e}");
696                            anyhow!("Could not post claim details. Err: {e:?}")
697                        })?;
698                    }
699                    Some(claim_tx_id) => {
700                        warn!("Claim tx for Chain Swap {id} was already broadcast: txid {claim_tx_id}")
701                    }
702                };
703                Ok(())
704            }
705
706            // If swap state is unrecoverable, either:
707            // 1. The transaction failed
708            // 2. Lockup failed (too little funds were sent)
709            // 3. The claim lockup was refunded
710            // 4. The swap has expired (>24h)
711            // We initiate a cooperative refund, and then fallback to a regular one
712            ChainSwapStates::TransactionFailed
713            | ChainSwapStates::TransactionLockupFailed
714            | ChainSwapStates::TransactionRefunded
715            | ChainSwapStates::SwapExpired => {
716                match &swap.refund_tx_id {
717                    None => {
718                        warn!("Chain Swap {id} is in an unrecoverable state: {swap_state:?}");
719                        match swap.user_lockup_tx_id {
720                            Some(_) => {
721                                warn!("Chain Swap {id} user lockup tx has been broadcast.");
722                                let refund_tx_id = match self.refund_outgoing_swap(swap, true).await
723                                {
724                                    Ok(refund_tx_id) => Some(refund_tx_id),
725                                    Err(e) => {
726                                        warn!(
727                                            "Could not refund Send swap {id} cooperatively: {e:?}"
728                                        );
729                                        None
730                                    }
731                                };
732                                // Set the payment state to `RefundPending`. This ensures that the
733                                // background thread will pick it up and try to refund it
734                                // periodically
735                                self.update_swap_info(&ChainSwapUpdate {
736                                    swap_id: id,
737                                    to_state: RefundPending,
738                                    refund_tx_id,
739                                    ..Default::default()
740                                })?;
741                            }
742                            None => {
743                                warn!("Chain Swap {id} user lockup tx was never broadcast. Resolving payment as failed.");
744                                self.update_swap_info(&ChainSwapUpdate {
745                                    swap_id: id,
746                                    to_state: Failed,
747                                    ..Default::default()
748                                })?;
749                            }
750                        }
751                    }
752                    Some(refund_tx_id) => warn!(
753                        "Refund tx for Chain Swap {id} was already broadcast: txid {refund_tx_id}"
754                    ),
755                };
756                Ok(())
757            }
758
759            _ => {
760                debug!("Unhandled state for Chain Swap {id}: {swap_state:?}");
761                Ok(())
762            }
763        }
764    }
765
766    async fn lockup_funds(
767        &self,
768        swap_id: &str,
769        create_response: &CreateChainResponse,
770    ) -> Result<Transaction, PaymentError> {
771        let lockup_details = create_response.lockup_details.clone();
772
773        debug!(
774            "Initiated Chain Swap: send {} sats to liquid address {}",
775            lockup_details.amount, lockup_details.lockup_address
776        );
777
778        let lockup_tx = self
779            .onchain_wallet
780            .build_tx_or_drain_tx(
781                Some(LIQUID_FEE_RATE_MSAT_PER_VBYTE),
782                &lockup_details.lockup_address,
783                &self.config.lbtc_asset_id().to_string(),
784                lockup_details.amount,
785            )
786            .await?;
787
788        let lockup_tx_id = match self.liquid_chain_service.broadcast(&lockup_tx).await {
789            Ok(tx_id) => tx_id.to_string(),
790            Err(err) => {
791                return Err(handle_stale_cache_broadcast_error(&*self.onchain_wallet, err).await)
792            }
793        };
794
795        self.onchain_wallet.apply_broadcast_tx(&lockup_tx).await;
796
797        debug!(
798          "Successfully broadcast lockup transaction for Chain Swap {swap_id}. Lockup tx id: {lockup_tx_id}"
799        );
800        Ok(lockup_tx)
801    }
802
803    fn fetch_chain_swap_by_id(&self, swap_id: &str) -> Result<ChainSwap, PaymentError> {
804        self.persister
805            .fetch_chain_swap_by_id(swap_id)
806            .map_err(|e| {
807                error!("Failed to fetch chain swap by id: {e:?}");
808                PaymentError::PersistError
809            })?
810            .ok_or(PaymentError::Generic {
811                err: format!("Chain Swap not found {swap_id}"),
812            })
813    }
814
815    // Updates the swap without state transition validation
816    pub(crate) fn update_swap(&self, updated_swap: ChainSwap) -> Result<(), PaymentError> {
817        let swap = self.fetch_chain_swap_by_id(&updated_swap.id)?;
818        if updated_swap != swap {
819            info!(
820                "Updating Chain swap {} to {:?} (user_lockup_tx_id = {:?}, server_lockup_tx_id = {:?}, claim_tx_id = {:?}, refund_tx_id = {:?})",
821                updated_swap.id,
822                updated_swap.state,
823                updated_swap.user_lockup_tx_id,
824                updated_swap.server_lockup_tx_id,
825                updated_swap.claim_tx_id,
826                updated_swap.refund_tx_id
827            );
828            self.persister.insert_or_update_chain_swap(&updated_swap)?;
829            let _ = self.subscription_notifier.send(updated_swap.id);
830        }
831        Ok(())
832    }
833
834    // Updates the swap state with validation
835    pub(crate) fn update_swap_info(
836        &self,
837        swap_update: &ChainSwapUpdate,
838    ) -> Result<(), PaymentError> {
839        info!("Updating Chain swap {swap_update:?}");
840        let swap = self.fetch_chain_swap_by_id(&swap_update.swap_id)?;
841        Self::validate_state_transition(swap.state, swap_update.to_state)?;
842        self.persister.try_handle_chain_swap_update(swap_update)?;
843        let updated_swap = self.fetch_chain_swap_by_id(&swap_update.swap_id)?;
844        if updated_swap != swap {
845            let _ = self.subscription_notifier.send(updated_swap.id);
846        }
847        Ok(())
848    }
849
850    async fn claim(&self, swap_id: &str) -> Result<(), PaymentError> {
851        {
852            let mut claiming_guard = self.claiming_swaps.lock().await;
853            if claiming_guard.contains(swap_id) {
854                debug!("Claim for swap {swap_id} already in progress, skipping.");
855                return Ok(());
856            }
857            claiming_guard.insert(swap_id.to_string());
858        }
859
860        let result = self.claim_inner(swap_id).await;
861
862        {
863            let mut claiming_guard = self.claiming_swaps.lock().await;
864            claiming_guard.remove(swap_id);
865        }
866
867        result
868    }
869
870    async fn claim_inner(&self, swap_id: &str) -> Result<(), PaymentError> {
871        let swap = self.fetch_chain_swap_by_id(swap_id)?;
872        ensure_sdk!(swap.claim_tx_id.is_none(), PaymentError::AlreadyClaimed);
873
874        // Make sure the claim timeout block height has not been reached (with 10 blocks margin for incoming, 2 blocks margin for outgoing)
875        // Skip this check if user lockup has been spent (server claimed), as we must claim regardless of timeout
876        if !swap.user_lockup_spent {
877            match swap.direction {
878                Direction::Incoming => {
879                    let liquid_tip = self.liquid_chain_service.tip().await?;
880                    if liquid_tip > swap.claim_timeout_block_height - 10 {
881                        return Err(PaymentError::Generic {
882                            err: format!("Preventing claim for incoming chain swap {swap_id} as timeout block height {} has been/will soon be reached (liquid tip: {liquid_tip})", swap.claim_timeout_block_height),
883                        });
884                    }
885                }
886                Direction::Outgoing => {
887                    let bitcoin_tip = self.bitcoin_chain_service.tip().await?;
888                    if bitcoin_tip > swap.claim_timeout_block_height - 2 {
889                        return Err(PaymentError::Generic {
890                            err: format!("Preventing claim for outgoing chain swap {swap_id} as timeout block height {} has been/will soon be reached (bitcoin tip: {bitcoin_tip})", swap.claim_timeout_block_height),
891                        });
892                    }
893                }
894            }
895        }
896
897        debug!("Initiating claim for Chain Swap {swap_id}");
898        // Derive a new Liquid address if one is not already set for an incoming swap,
899        // or use the set Bitcoin address for an outgoing swap
900        let claim_address = match (swap.direction, swap.claim_address.clone()) {
901            (Direction::Incoming, None) => {
902                Some(self.onchain_wallet.next_unused_address().await?.to_string())
903            }
904            _ => swap.claim_address.clone(),
905        };
906        let claim_tx = self
907            .swapper
908            .create_claim_tx(Swap::Chain(swap.clone()), claim_address.clone(), true)
909            .await?;
910
911        // Set the swap claim_tx_id before broadcasting.
912        // If another claim_tx_id has been set in the meantime, don't broadcast the claim tx
913        let tx_id = claim_tx.txid();
914        match self
915            .persister
916            .set_chain_swap_claim(swap_id, claim_address, &tx_id)
917        {
918            Ok(_) => {
919                let broadcast_res = match claim_tx {
920                    // We attempt broadcasting via chain service, then fallback to Boltz
921                    SdkTransaction::Liquid(tx) => {
922                        match self.liquid_chain_service.broadcast(&tx).await {
923                            Ok(tx_id) => Ok(tx_id.to_hex()),
924                            Err(e) if is_txn_already_spent_error(&e) => {
925                                Err(PaymentError::AlreadyClaimed)
926                            }
927                            Err(err) => {
928                                debug!(
929                                        "Could not broadcast claim tx via chain service for Chain swap {swap_id}: {err:?}"
930                                    );
931                                let claim_tx_hex = tx.serialize().to_lower_hex_string();
932                                self.swapper
933                                    .broadcast_tx(self.config.network.into(), &claim_tx_hex)
934                                    .await
935                            }
936                        }
937                    }
938                    SdkTransaction::Bitcoin(tx) => self
939                        .bitcoin_chain_service
940                        .broadcast(&tx)
941                        .await
942                        .map(|tx_id| tx_id.to_hex())
943                        .map_err(|err| PaymentError::Generic {
944                            err: err.to_string(),
945                        }),
946                };
947
948                match broadcast_res {
949                    Ok(claim_tx_id) => {
950                        let payment_id = match swap.direction {
951                            Direction::Incoming => {
952                                // We insert a pseudo-claim-tx in case LWK fails to pick up the new mempool tx for a while
953                                // This makes the tx known to the SDK (get_info, list_payments) instantly
954                                self.persister.insert_or_update_payment(
955                                    PaymentTxData {
956                                        tx_id: claim_tx_id.clone(),
957                                        timestamp: Some(utils::now()),
958                                        fees_sat: 0,
959                                        is_confirmed: false,
960                                        unblinding_data: None,
961                                    },
962                                    &[PaymentTxBalance {
963                                        asset_id: self.config.lbtc_asset_id().to_string(),
964                                        amount: swap
965                                            .accepted_receiver_amount_sat
966                                            .unwrap_or(swap.receiver_amount_sat),
967                                        payment_type: PaymentType::Receive,
968                                    }],
969                                    None,
970                                    false,
971                                )?;
972                                Some(claim_tx_id.clone())
973                            }
974                            Direction::Outgoing => swap.user_lockup_tx_id,
975                        };
976
977                        info!("Successfully broadcast claim tx {claim_tx_id} for Chain Swap {swap_id}");
978                        // The claim_tx_id is already set by set_chain_swap_claim. Manually trigger notifying
979                        // subscribers as update_swap_info will not recognise a change to the swap
980                        payment_id.and_then(|payment_id| {
981                            self.subscription_notifier.send(payment_id).ok()
982                        });
983                        Ok(())
984                    }
985                    Err(err) => {
986                        // Multiple attempts to broadcast have failed. Unset the swap claim_tx_id
987                        debug!(
988                            "Could not broadcast claim tx via swapper for Chain swap {swap_id}: {err:?}"
989                        );
990                        self.persister
991                            .unset_chain_swap_claim_tx_id(swap_id, &tx_id)?;
992                        Err(err)
993                    }
994                }
995            }
996            Err(err) => {
997                debug!(
998                    "Failed to set claim_tx_id after creating tx for Chain swap {swap_id}: txid {tx_id}"
999                );
1000                Err(err)
1001            }
1002        }
1003    }
1004
1005    pub(crate) async fn prepare_refund(
1006        &self,
1007        lockup_address: &str,
1008        refund_address: &str,
1009        fee_rate_sat_per_vb: u32,
1010    ) -> SdkResult<(u32, u64, Option<String>)> {
1011        let swap = self
1012            .persister
1013            .fetch_chain_swap_by_lockup_address(lockup_address)?
1014            .ok_or(SdkError::generic(format!(
1015                "Chain Swap with lockup address {lockup_address} not found"
1016            )))?;
1017
1018        let refund_tx_id = swap.refund_tx_id.clone();
1019        if let Some(refund_tx_id) = &refund_tx_id {
1020            warn!(
1021                "A refund tx for Chain Swap {} was already broadcast: txid {refund_tx_id}",
1022                swap.id
1023            );
1024        }
1025
1026        let (refund_tx_size, refund_tx_fees_sat) = self
1027            .swapper
1028            .estimate_refund_broadcast(
1029                Swap::Chain(swap),
1030                refund_address,
1031                Some(fee_rate_sat_per_vb as f64),
1032                true,
1033            )
1034            .await?;
1035
1036        Ok((refund_tx_size, refund_tx_fees_sat, refund_tx_id))
1037    }
1038
1039    pub(crate) async fn refund_incoming_swap(
1040        &self,
1041        lockup_address: &str,
1042        refund_address: &str,
1043        broadcast_fee_rate_sat_per_vb: u32,
1044        is_cooperative: bool,
1045    ) -> Result<String, PaymentError> {
1046        let swap = self
1047            .persister
1048            .fetch_chain_swap_by_lockup_address(lockup_address)?
1049            .ok_or(PaymentError::Generic {
1050                err: format!("Swap for lockup address {lockup_address} not found"),
1051            })?;
1052        let id = &swap.id;
1053
1054        ensure_sdk!(
1055            swap.state.is_refundable(),
1056            PaymentError::Generic {
1057                err: format!("Chain Swap {id} was not in refundable state")
1058            }
1059        );
1060
1061        info!("Initiating refund for incoming Chain Swap {id}, is_cooperative: {is_cooperative}");
1062
1063        let SwapScriptV2::Bitcoin(swap_script) = swap.get_lockup_swap_script()? else {
1064            return Err(PaymentError::Generic {
1065                err: "Unexpected swap script type found".to_string(),
1066            });
1067        };
1068
1069        let script_pk = swap_script
1070            .to_address(self.config.network.as_bitcoin_chain())
1071            .map_err(|e| anyhow!("Could not retrieve address from swap script: {e:?}"))?
1072            .script_pubkey();
1073        let utxos = self
1074            .bitcoin_chain_service
1075            .get_script_utxos(&script_pk)
1076            .await?;
1077
1078        let SdkTransaction::Bitcoin(refund_tx) = self
1079            .swapper
1080            .create_refund_tx(
1081                Swap::Chain(swap.clone()),
1082                refund_address,
1083                utxos,
1084                Some(broadcast_fee_rate_sat_per_vb as f64),
1085                is_cooperative,
1086            )
1087            .await?
1088        else {
1089            return Err(PaymentError::Generic {
1090                err: format!("Unexpected refund tx type returned for incoming Chain swap {id}",),
1091            });
1092        };
1093        let refund_tx_id = self
1094            .bitcoin_chain_service
1095            .broadcast(&refund_tx)
1096            .await?
1097            .to_string();
1098
1099        info!("Successfully broadcast refund for incoming Chain Swap {id}, is_cooperative: {is_cooperative}");
1100
1101        // After refund tx is broadcasted, set the payment state to `RefundPending`. This ensures:
1102        // - the swap is not shown in `list-refundables` anymore
1103        // - the background thread will move it to Failed once the refund tx confirms
1104        self.update_swap_info(&ChainSwapUpdate {
1105            swap_id: swap.id,
1106            to_state: RefundPending,
1107            refund_tx_id: Some(refund_tx_id.clone()),
1108            ..Default::default()
1109        })?;
1110
1111        Ok(refund_tx_id)
1112    }
1113
1114    pub(crate) async fn refund_outgoing_swap(
1115        &self,
1116        swap: &ChainSwap,
1117        is_cooperative: bool,
1118    ) -> Result<String, PaymentError> {
1119        ensure_sdk!(
1120            swap.refund_tx_id.is_none(),
1121            PaymentError::Generic {
1122                err: format!(
1123                    "A refund tx for outgoing Chain Swap {} was already broadcast",
1124                    swap.id
1125                )
1126            }
1127        );
1128
1129        info!(
1130            "Initiating refund for outgoing Chain Swap {}, is_cooperative: {is_cooperative}",
1131            swap.id
1132        );
1133
1134        let SwapScriptV2::Liquid(swap_script) = swap.get_lockup_swap_script()? else {
1135            return Err(PaymentError::Generic {
1136                err: "Unexpected swap script type found".to_string(),
1137            });
1138        };
1139
1140        let script_pk = swap_script
1141            .to_address(self.config.network.into())
1142            .map_err(|e| anyhow!("Could not retrieve address from swap script: {e:?}"))?
1143            .to_unconfidential()
1144            .script_pubkey();
1145        let utxos = self
1146            .liquid_chain_service
1147            .get_script_utxos(&script_pk)
1148            .await?;
1149
1150        let refund_address = match swap.refund_address {
1151            Some(ref refund_address) => refund_address.clone(),
1152            None => {
1153                // If no refund address is set, we get an unused one
1154                let address = self.onchain_wallet.next_unused_address().await?.to_string();
1155                self.persister
1156                    .set_chain_swap_refund_address(&swap.id, &address)?;
1157                address
1158            }
1159        };
1160
1161        let SdkTransaction::Liquid(refund_tx) = self
1162            .swapper
1163            .create_refund_tx(
1164                Swap::Chain(swap.clone()),
1165                &refund_address,
1166                utxos,
1167                None,
1168                is_cooperative,
1169            )
1170            .await?
1171        else {
1172            return Err(PaymentError::Generic {
1173                err: format!(
1174                    "Unexpected refund tx type returned for outgoing Chain swap {}",
1175                    swap.id
1176                ),
1177            });
1178        };
1179        let refund_tx_id = self
1180            .liquid_chain_service
1181            .broadcast(&refund_tx)
1182            .await?
1183            .to_string();
1184
1185        info!(
1186            "Successfully broadcast refund for outgoing Chain Swap {}, is_cooperative: {is_cooperative}",
1187            swap.id
1188        );
1189
1190        Ok(refund_tx_id)
1191    }
1192
1193    async fn refund_outgoing(&self, height: u32) -> Result<(), PaymentError> {
1194        // Get all pending outgoing chain swaps with no refund tx
1195        let pending_swaps: Vec<ChainSwap> = self
1196            .persister
1197            .list_pending_chain_swaps()?
1198            .into_iter()
1199            .filter(|s| s.direction == Direction::Outgoing && s.refund_tx_id.is_none())
1200            .collect();
1201        for swap in pending_swaps {
1202            let swap_script = swap.get_lockup_swap_script()?.as_liquid_script()?;
1203            let locktime_from_height = ElementsLockTime::from_height(height)
1204                .map_err(|e| PaymentError::Generic { err: e.to_string() })?;
1205            info!("Checking Chain Swap {} expiration: locktime_from_height = {locktime_from_height:?},  swap_script.locktime = {:?}", swap.id, swap_script.locktime);
1206            let has_swap_expired =
1207                utils::is_locktime_expired(locktime_from_height, swap_script.locktime);
1208            if has_swap_expired || swap.state == RefundPending {
1209                let refund_tx_id_res = match swap.state {
1210                    Pending => self.refund_outgoing_swap(&swap, false).await,
1211                    RefundPending => match has_swap_expired {
1212                        true => {
1213                            self.refund_outgoing_swap(&swap, true)
1214                                .or_else(|e| {
1215                                    warn!("Failed to initiate cooperative refund, switching to non-cooperative: {e:?}");
1216                                    self.refund_outgoing_swap(&swap, false)
1217                                })
1218                                .await
1219                        }
1220                        false => self.refund_outgoing_swap(&swap, true).await,
1221                    },
1222                    _ => {
1223                        continue;
1224                    }
1225                };
1226
1227                if let Ok(refund_tx_id) = refund_tx_id_res {
1228                    let update_swap_info_res = self.update_swap_info(&ChainSwapUpdate {
1229                        swap_id: swap.id.clone(),
1230                        to_state: RefundPending,
1231                        refund_tx_id: Some(refund_tx_id),
1232                        ..Default::default()
1233                    });
1234                    if let Err(err) = update_swap_info_res {
1235                        warn!(
1236                            "Could not update outgoing Chain swap {} information: {err:?}",
1237                            swap.id
1238                        );
1239                    };
1240                }
1241            }
1242        }
1243        Ok(())
1244    }
1245
1246    fn validate_state_transition(
1247        from_state: PaymentState,
1248        to_state: PaymentState,
1249    ) -> Result<(), PaymentError> {
1250        match (from_state, to_state) {
1251            (_, Created) => Err(PaymentError::Generic {
1252                err: "Cannot transition to Created state".to_string(),
1253            }),
1254
1255            (Created | Pending | WaitingFeeAcceptance, Pending) => Ok(()),
1256            (_, Pending) => Err(PaymentError::Generic {
1257                err: format!("Cannot transition from {from_state:?} to Pending state"),
1258            }),
1259
1260            (Created | Pending | WaitingFeeAcceptance, WaitingFeeAcceptance) => Ok(()),
1261            (_, WaitingFeeAcceptance) => Err(PaymentError::Generic {
1262                err: format!("Cannot transition from {from_state:?} to WaitingFeeAcceptance state"),
1263            }),
1264
1265            (Created | Pending | WaitingFeeAcceptance | RefundPending, Complete) => Ok(()),
1266            (_, Complete) => Err(PaymentError::Generic {
1267                err: format!("Cannot transition from {from_state:?} to Complete state"),
1268            }),
1269
1270            (Created, TimedOut) => Ok(()),
1271            (_, TimedOut) => Err(PaymentError::Generic {
1272                err: format!("Cannot transition from {from_state:?} to TimedOut state"),
1273            }),
1274
1275            (
1276                Created | Pending | WaitingFeeAcceptance | RefundPending | Failed | Complete,
1277                Refundable,
1278            ) => Ok(()),
1279            (_, Refundable) => Err(PaymentError::Generic {
1280                err: format!("Cannot transition from {from_state:?} to Refundable state"),
1281            }),
1282
1283            (Pending | WaitingFeeAcceptance | Refundable | RefundPending, RefundPending) => Ok(()),
1284            (_, RefundPending) => Err(PaymentError::Generic {
1285                err: format!("Cannot transition from {from_state:?} to RefundPending state"),
1286            }),
1287
1288            (Complete, Failed) => Err(PaymentError::Generic {
1289                err: format!("Cannot transition from {from_state:?} to Failed state"),
1290            }),
1291
1292            (_, Failed) => Ok(()),
1293        }
1294    }
1295
1296    async fn fetch_incoming_swap_actual_payer_amount(&self, chain_swap: &ChainSwap) -> Result<u64> {
1297        let swap_script = chain_swap.get_lockup_swap_script()?;
1298        let script_pubkey = swap_script
1299            .as_bitcoin_script()?
1300            .to_address(self.config.network.as_bitcoin_chain())
1301            .map_err(|e| anyhow!("Failed to get swap script address {e:?}"))?
1302            .script_pubkey();
1303
1304        let history = self.fetch_bitcoin_script_history(&swap_script).await?;
1305
1306        // User lockup tx is by definition the first
1307        let first_tx_id = history
1308            .first()
1309            .ok_or(anyhow!(
1310                "No history found for user lockup script for swap {}",
1311                chain_swap.id
1312            ))?
1313            .txid
1314            .to_raw_hash()
1315            .into();
1316
1317        // Get full transaction
1318        let txs = self
1319            .bitcoin_chain_service
1320            .get_transactions_with_retry(&[first_tx_id], 3)
1321            .await?;
1322        let user_lockup_tx = txs.first().ok_or(anyhow!(
1323            "No transactions found for user lockup script for swap {}",
1324            chain_swap.id
1325        ))?;
1326
1327        // Find output paying to our script and get amount
1328        user_lockup_tx
1329            .output
1330            .iter()
1331            .find(|out| out.script_pubkey == script_pubkey)
1332            .map(|out| out.value.to_sat())
1333            .ok_or(anyhow!("No output found paying to user lockup script"))
1334    }
1335
1336    async fn verify_server_lockup_tx(
1337        &self,
1338        chain_swap: &ChainSwap,
1339        swap_update_tx: &TransactionInfo,
1340        verify_confirmation: bool,
1341    ) -> Result<()> {
1342        match chain_swap.direction {
1343            Direction::Incoming => {
1344                self.verify_incoming_server_lockup_tx(
1345                    chain_swap,
1346                    swap_update_tx,
1347                    verify_confirmation,
1348                )
1349                .await
1350            }
1351            Direction::Outgoing => {
1352                self.verify_outgoing_server_lockup_tx(
1353                    chain_swap,
1354                    swap_update_tx,
1355                    verify_confirmation,
1356                )
1357                .await
1358            }
1359        }
1360    }
1361
1362    async fn verify_incoming_server_lockup_tx(
1363        &self,
1364        chain_swap: &ChainSwap,
1365        swap_update_tx: &TransactionInfo,
1366        verify_confirmation: bool,
1367    ) -> Result<()> {
1368        let swap_script = chain_swap.get_claim_swap_script()?;
1369        let claim_details = chain_swap.get_boltz_create_response()?.claim_details;
1370        // Verify transaction
1371        let liquid_swap_script = swap_script.as_liquid_script()?;
1372        let address = liquid_swap_script
1373            .to_address(self.config.network.into())
1374            .map_err(|e| anyhow!("Failed to get swap script address {e:?}"))?;
1375        let tx_hex = swap_update_tx
1376            .hex
1377            .as_ref()
1378            .ok_or(anyhow!("Transaction info without hex"))?;
1379        let tx = self
1380            .liquid_chain_service
1381            .verify_tx(&address, &swap_update_tx.id, tx_hex, verify_confirmation)
1382            .await?;
1383        // Verify RBF
1384        let rbf_explicit = tx.input.iter().any(|tx_in| tx_in.sequence.is_rbf());
1385        if !verify_confirmation && rbf_explicit {
1386            bail!("Transaction signals RBF");
1387        }
1388        // Verify amount
1389        let secp = Secp256k1::new();
1390        let to_address_output = tx
1391            .output
1392            .iter()
1393            .filter(|tx_out| tx_out.script_pubkey == address.script_pubkey());
1394        let mut value = 0;
1395        for tx_out in to_address_output {
1396            value += tx_out
1397                .unblind(&secp, liquid_swap_script.blinding_key.secret_key())?
1398                .value;
1399        }
1400        match chain_swap.accepted_receiver_amount_sat {
1401            None => {
1402                if value < claim_details.amount {
1403                    bail!(
1404                        "Transaction value {value} sats is less than {} sats",
1405                        claim_details.amount
1406                    );
1407                }
1408            }
1409            Some(accepted_receiver_amount_sat) => {
1410                let expected_server_lockup_amount_sat =
1411                    accepted_receiver_amount_sat + chain_swap.claim_fees_sat;
1412                if value < expected_server_lockup_amount_sat {
1413                    bail!(
1414                        "Transaction value {value} sats is less than accepted {} sats",
1415                        expected_server_lockup_amount_sat
1416                    );
1417                }
1418            }
1419        }
1420
1421        Ok(())
1422    }
1423
1424    async fn verify_outgoing_server_lockup_tx(
1425        &self,
1426        chain_swap: &ChainSwap,
1427        swap_update_tx: &TransactionInfo,
1428        verify_confirmation: bool,
1429    ) -> Result<()> {
1430        let swap_script = chain_swap.get_claim_swap_script()?;
1431        let claim_details = chain_swap.get_boltz_create_response()?.claim_details;
1432        // Verify transaction
1433        let address = swap_script
1434            .as_bitcoin_script()?
1435            .to_address(self.config.network.as_bitcoin_chain())
1436            .map_err(|e| anyhow!("Failed to get swap script address {e:?}"))?;
1437        let tx_hex = swap_update_tx
1438            .hex
1439            .as_ref()
1440            .ok_or(anyhow!("Transaction info without hex"))?;
1441        let tx = self
1442            .bitcoin_chain_service
1443            .verify_tx(&address, &swap_update_tx.id, tx_hex, verify_confirmation)
1444            .await?;
1445        // Verify RBF
1446        let rbf_explicit = tx.input.iter().any(|input| input.sequence.is_rbf());
1447        if !verify_confirmation && rbf_explicit {
1448            return Err(anyhow!("Transaction signals RBF"));
1449        }
1450        // Verify amount
1451        let value: u64 = tx
1452            .output
1453            .iter()
1454            .filter(|tx_out| tx_out.script_pubkey == address.script_pubkey())
1455            .map(|tx_out| tx_out.value.to_sat())
1456            .sum();
1457        if value < claim_details.amount {
1458            return Err(anyhow!(
1459                "Transaction value {value} sats is less than {} sats",
1460                claim_details.amount
1461            ));
1462        }
1463        Ok(())
1464    }
1465
1466    async fn user_lockup_tx_exists(&self, chain_swap: &ChainSwap) -> Result<bool> {
1467        let lockup_script = chain_swap.get_lockup_swap_script()?;
1468        let script_history = self.fetch_script_history(&lockup_script).await?;
1469
1470        match chain_swap.user_lockup_tx_id.clone() {
1471            Some(user_lockup_tx_id) => {
1472                if !script_history.iter().any(|h| h.0 == user_lockup_tx_id) {
1473                    return Ok(false);
1474                }
1475            }
1476            None => {
1477                let (txid, _tx_height) = match script_history.into_iter().nth(0) {
1478                    Some(h) => h,
1479                    None => {
1480                        return Ok(false);
1481                    }
1482                };
1483                self.update_swap_info(&ChainSwapUpdate {
1484                    swap_id: chain_swap.id.clone(),
1485                    to_state: Pending,
1486                    user_lockup_tx_id: Some(txid.clone()),
1487                    ..Default::default()
1488                })?;
1489            }
1490        }
1491
1492        Ok(true)
1493    }
1494
1495    async fn verify_user_lockup_tx(&self, chain_swap: &ChainSwap) -> Result<()> {
1496        if !self.user_lockup_tx_exists(chain_swap).await? {
1497            bail!("User lockup tx not found in script history");
1498        }
1499
1500        // Verify amount for incoming chain swaps
1501        if chain_swap.direction == Direction::Incoming {
1502            let actual_payer_amount_sat = match chain_swap.actual_payer_amount_sat {
1503                Some(amount) => amount,
1504                None => {
1505                    let actual_payer_amount_sat = self
1506                        .fetch_incoming_swap_actual_payer_amount(chain_swap)
1507                        .await?;
1508                    self.persister
1509                        .update_actual_payer_amount(&chain_swap.id, actual_payer_amount_sat)?;
1510                    actual_payer_amount_sat
1511                }
1512            };
1513            // For non-amountless swaps, make sure user locked up the agreed amount
1514            if chain_swap.payer_amount_sat > 0
1515                && chain_swap.payer_amount_sat != actual_payer_amount_sat
1516            {
1517                bail!("Invalid user lockup tx - user lockup amount ({actual_payer_amount_sat} sat) differs from agreed ({} sat)", chain_swap.payer_amount_sat);
1518            }
1519        }
1520
1521        Ok(())
1522    }
1523
1524    async fn fetch_bitcoin_script_history(
1525        &self,
1526        swap_script: &SwapScriptV2,
1527    ) -> Result<Vec<BtcHistory>> {
1528        let address = swap_script
1529            .as_bitcoin_script()?
1530            .to_address(self.config.network.as_bitcoin_chain())
1531            .map_err(|e| anyhow!("Failed to get swap script address {e:?}"))?;
1532        let script_pubkey = address.script_pubkey();
1533        let script = script_pubkey.as_script();
1534        self.bitcoin_chain_service
1535            .get_script_history_with_retry(script, 10)
1536            .await
1537    }
1538
1539    async fn fetch_liquid_script_history(
1540        &self,
1541        swap_script: &SwapScriptV2,
1542    ) -> Result<Vec<LBtcHistory>> {
1543        let address = swap_script
1544            .as_liquid_script()?
1545            .to_address(self.config.network.into())
1546            .map_err(|e| anyhow!("Failed to get swap script address {e:?}"))?
1547            .to_unconfidential();
1548        let script = Script::from_hex(hex::encode(address.script_pubkey().as_bytes()).as_str())
1549            .map_err(|e| anyhow!("Failed to get script from address {e:?}"))?;
1550        self.liquid_chain_service
1551            .get_script_history_with_retry(&script, 10)
1552            .await
1553    }
1554}
1555
1556enum ValidateAmountlessSwapResult {
1557    ReadyForAccepting {
1558        user_lockup_amount_sat: u64,
1559        receiver_amount_sat: u64,
1560    },
1561    RequiresUserAction {
1562        user_lockup_amount_sat: u64,
1563    },
1564}
1565
1566async fn maybe_delay_before_claim(is_swap_local: bool) {
1567    // Chain swap claims cannot be created concurrently with other instances.
1568    // This is a preventive measure to reduce the likelihood of failures.
1569    // In any case, the claim implementation gracefully handles such failures
1570    // using a retry mechanism
1571    if !is_swap_local {
1572        info!("Waiting 5 seconds before claim to reduce likelihood of concurrent claims");
1573        tokio::time::sleep(Duration::from_secs(5)).await;
1574    }
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579    use anyhow::Result;
1580    use std::collections::{HashMap, HashSet};
1581
1582    use crate::{
1583        model::{
1584            ChainSwapUpdate, Direction,
1585            PaymentState::{self, *},
1586        },
1587        test_utils::{
1588            chain_swap::{new_chain_swap, new_chain_swap_handler},
1589            persist::create_persister,
1590        },
1591    };
1592
1593    #[cfg(feature = "browser-tests")]
1594    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
1595
1596    #[sdk_macros::async_test_all]
1597    async fn test_chain_swap_state_transitions() -> Result<()> {
1598        create_persister!(persister);
1599
1600        let chain_swap_handler = new_chain_swap_handler(persister.clone())?;
1601
1602        // Test valid combinations of states
1603        let all_states = HashSet::from([
1604            Created,
1605            Pending,
1606            WaitingFeeAcceptance,
1607            Complete,
1608            TimedOut,
1609            Failed,
1610        ]);
1611        let valid_combinations = HashMap::from([
1612            (
1613                Created,
1614                HashSet::from([
1615                    Pending,
1616                    WaitingFeeAcceptance,
1617                    Complete,
1618                    TimedOut,
1619                    Refundable,
1620                    Failed,
1621                ]),
1622            ),
1623            (
1624                Pending,
1625                HashSet::from([
1626                    Pending,
1627                    WaitingFeeAcceptance,
1628                    Complete,
1629                    Refundable,
1630                    RefundPending,
1631                    Failed,
1632                ]),
1633            ),
1634            (
1635                WaitingFeeAcceptance,
1636                HashSet::from([
1637                    Pending,
1638                    WaitingFeeAcceptance,
1639                    Complete,
1640                    Refundable,
1641                    RefundPending,
1642                    Failed,
1643                ]),
1644            ),
1645            (TimedOut, HashSet::from([Failed])),
1646            (Complete, HashSet::from([Refundable])),
1647            (Refundable, HashSet::from([RefundPending, Failed])),
1648            (
1649                RefundPending,
1650                HashSet::from([Refundable, Complete, Failed, RefundPending]),
1651            ),
1652            (Failed, HashSet::from([Failed, Refundable])),
1653        ]);
1654
1655        for (first_state, allowed_states) in valid_combinations.iter() {
1656            for allowed_state in allowed_states {
1657                let chain_swap = new_chain_swap(
1658                    Direction::Incoming,
1659                    Some(*first_state),
1660                    false,
1661                    None,
1662                    false,
1663                    false,
1664                    None,
1665                );
1666                persister.insert_or_update_chain_swap(&chain_swap)?;
1667
1668                assert!(chain_swap_handler
1669                    .update_swap_info(&ChainSwapUpdate {
1670                        swap_id: chain_swap.id,
1671                        to_state: *allowed_state,
1672                        ..Default::default()
1673                    })
1674                    .is_ok());
1675            }
1676        }
1677
1678        // Test invalid combinations of states
1679        let invalid_combinations: HashMap<PaymentState, HashSet<PaymentState>> = valid_combinations
1680            .iter()
1681            .map(|(first_state, allowed_states)| {
1682                (
1683                    *first_state,
1684                    all_states.difference(allowed_states).cloned().collect(),
1685                )
1686            })
1687            .collect();
1688
1689        for (first_state, disallowed_states) in invalid_combinations.iter() {
1690            for disallowed_state in disallowed_states {
1691                let chain_swap = new_chain_swap(
1692                    Direction::Incoming,
1693                    Some(*first_state),
1694                    false,
1695                    None,
1696                    false,
1697                    false,
1698                    None,
1699                );
1700                persister.insert_or_update_chain_swap(&chain_swap)?;
1701
1702                assert!(chain_swap_handler
1703                    .update_swap_info(&ChainSwapUpdate {
1704                        swap_id: chain_swap.id,
1705                        to_state: *disallowed_state,
1706                        ..Default::default()
1707                    })
1708                    .is_err());
1709            }
1710        }
1711
1712        Ok(())
1713    }
1714}