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