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