breez_sdk_liquid/
chain_swap.rs

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