breez_sdk_liquid/
receive_swap.rs

1use std::collections::HashSet;
2use std::str::FromStr;
3use std::sync::Arc;
4
5use anyhow::{anyhow, bail, Context, Result};
6use boltz_client::swaps::boltz::RevSwapStates;
7use boltz_client::{boltz, Serialize, ToHex};
8use log::{debug, error, info, warn};
9use lwk_wollet::elements::secp256k1_zkp::Secp256k1;
10use lwk_wollet::elements::{Transaction, Txid};
11use lwk_wollet::hashes::hex::DisplayHex;
12use lwk_wollet::secp256k1::SecretKey;
13use tokio::sync::{broadcast, Mutex};
14
15use crate::chain::liquid::LiquidChainService;
16use crate::error::is_txn_mempool_conflict_error;
17use crate::model::{BlockListener, PaymentState::*};
18use crate::model::{Config, PaymentTxData, PaymentType, ReceiveSwap};
19use crate::persist::model::{PaymentTxBalance, PaymentTxDetails};
20use crate::prelude::Swap;
21use crate::{ensure_sdk, utils};
22use crate::{
23    error::PaymentError, model::PaymentState, persist::Persister, swapper::Swapper,
24    wallet::OnchainWallet,
25};
26
27/// The maximum acceptable amount in satoshi when claiming using zero-conf
28pub const DEFAULT_ZERO_CONF_MAX_SAT: u64 = 1_000_000;
29// The grace period for which we accept the `invoice.settled` event from Boltz as the real invoice
30// settlment time. If the event is received after this period, the settlement time will be
31// backfilled to the claim tx confirmation timestamp
32const SETTLED_AT_GRACE_PERIOD: u32 = 120;
33
34pub(crate) struct ReceiveSwapHandler {
35    config: Config,
36    onchain_wallet: Arc<dyn OnchainWallet>,
37    persister: std::sync::Arc<Persister>,
38    swapper: Arc<dyn Swapper>,
39    subscription_notifier: broadcast::Sender<String>,
40    liquid_chain_service: Arc<dyn LiquidChainService>,
41    claiming_swaps: Arc<Mutex<HashSet<String>>>,
42}
43
44#[sdk_macros::async_trait]
45impl BlockListener for ReceiveSwapHandler {
46    async fn on_bitcoin_block(&self, _height: u32) {}
47
48    async fn on_liquid_block(&self, height: u32) {
49        if let Err(e) = self.claim_confirmed_lockups(height).await {
50            error!("Error claiming confirmed lockups: {e:?}");
51        }
52    }
53}
54
55impl ReceiveSwapHandler {
56    pub(crate) fn new(
57        config: Config,
58        onchain_wallet: Arc<dyn OnchainWallet>,
59        persister: std::sync::Arc<Persister>,
60        swapper: Arc<dyn Swapper>,
61        liquid_chain_service: Arc<dyn LiquidChainService>,
62    ) -> Self {
63        let (subscription_notifier, _) = broadcast::channel::<String>(30);
64        Self {
65            config,
66            onchain_wallet,
67            persister,
68            swapper,
69            subscription_notifier,
70            liquid_chain_service,
71            claiming_swaps: Arc::new(Mutex::new(HashSet::new())),
72        }
73    }
74
75    pub(crate) fn subscribe_payment_updates(&self) -> broadcast::Receiver<String> {
76        self.subscription_notifier.subscribe()
77    }
78
79    /// Handles status updates from Boltz for Receive swaps
80    pub(crate) async fn on_new_status(&self, update: &boltz::SwapStatus) -> Result<()> {
81        let id = &update.id;
82        let status = &update.status;
83        let swap_state = RevSwapStates::from_str(status)
84            .map_err(|_| anyhow!("Invalid RevSwapState for Receive Swap {id}: {status}"))?;
85        let receive_swap = self.fetch_receive_swap_by_id(id)?;
86
87        info!("Handling Receive Swap transition to {swap_state:?} for swap {id}");
88
89        match swap_state {
90            RevSwapStates::SwapExpired
91            | RevSwapStates::InvoiceExpired
92            | RevSwapStates::TransactionFailed
93            | RevSwapStates::TransactionRefunded => {
94                match receive_swap.mrh_tx_id {
95                    Some(mrh_tx_id) => {
96                        warn!("Swap {id} is expired but MRH payment was received: txid {mrh_tx_id}")
97                    }
98                    None => {
99                        error!("Swap {id} entered into an unrecoverable state: {swap_state:?}");
100                        self.update_swap_info(id, Failed, None, None, None, None)?;
101                    }
102                }
103                Ok(())
104            }
105            // The lockup tx is in the mempool and we accept 0-conf => try to claim
106            // Execute 0-conf preconditions check
107            RevSwapStates::TransactionMempool => {
108                let Some(transaction) = update.transaction.clone() else {
109                    return Err(anyhow!("Unexpected payload from Boltz status stream"));
110                };
111
112                if let Some(claim_tx_id) = receive_swap.claim_tx_id {
113                    return Err(anyhow!(
114                        "Claim tx for Receive Swap {id} was already broadcast: txid {claim_tx_id}"
115                    ));
116                }
117
118                // Do not continue or claim the swap if it was already paid via MRH
119                if let Some(mrh_tx_id) = receive_swap.mrh_tx_id {
120                    return Err(anyhow!(
121                        "MRH tx for Receive Swap {id} was already broadcast, ignoring swap: txid {mrh_tx_id}"
122                    ));
123                }
124
125                // Looking for lockup script history to verify lockup was broadcasted
126                let tx_hex = transaction.hex.ok_or(anyhow!(
127                    "Missing lockup transaction hex in swap status update"
128                ))?;
129                let lockup_tx = utils::deserialize_tx_hex(&tx_hex)
130                    .context("Failed to deserialize tx hex in swap status update")?;
131                debug!(
132                    "Broadcasting lockup tx received in swap status update for receive swap {id}"
133                );
134                if let Err(e) = self.liquid_chain_service.broadcast(&lockup_tx).await {
135                    warn!(
136                        "Failed to broadcast lockup tx in swap status update: {e:?} - maybe the \
137                    tx depends on inputs that haven't been seen yet, falling back to waiting for \
138                    it to appear in the mempool"
139                    );
140                    if let Err(e) = self
141                        .verify_lockup_tx_status(&receive_swap, &transaction.id, &tx_hex, false)
142                        .await
143                    {
144                        return Err(anyhow!(
145                            "Swapper mempool reported lockup could not be verified. txid: {}, err: {}",
146                            transaction.id,
147                            e
148                        ));
149                    }
150                }
151
152                if let Err(e) = self
153                    .verify_lockup_tx_amount(&receive_swap, &lockup_tx)
154                    .await
155                {
156                    // The lockup amount in the tx is underpaid compared to the expected amount
157                    self.update_swap_info(id, Failed, None, None, None, None)?;
158                    return Err(anyhow!(
159                        "Swapper underpaid lockup amount. txid: {}, err: {}",
160                        transaction.id,
161                        e
162                    ));
163                }
164                info!("Swapper lockup was verified");
165
166                let lockup_tx_id = &transaction.id;
167                self.update_swap_info(id, Pending, None, Some(lockup_tx_id), None, None)?;
168
169                // If the amount is greater than the zero-conf limit
170                let max_amount_sat = self.config.zero_conf_max_amount_sat();
171                let receiver_amount_sat = receive_swap.receiver_amount_sat;
172                if receiver_amount_sat > max_amount_sat {
173                    warn!("[Receive Swap {id}] Amount is too high to claim with zero-conf ({receiver_amount_sat} sat > {max_amount_sat} sat). Waiting for confirmation...");
174                    return Ok(());
175                }
176
177                debug!("[Receive Swap {id}] Amount is within valid range for zero-conf ({receiver_amount_sat} < {max_amount_sat} sat)");
178
179                // If the transaction has RBF, see https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki
180                // TODO: Check for inherent RBF by ensuring all tx ancestors are confirmed
181                let rbf_explicit = lockup_tx.input.iter().any(|input| input.sequence.is_rbf());
182                // let rbf_inherent = lockup_tx_history.height < 0;
183
184                if rbf_explicit {
185                    warn!("[Receive Swap {id}] Lockup transaction signals RBF. Waiting for confirmation...");
186                    return Ok(());
187                }
188                debug!("[Receive Swap {id}] Lockup tx does not signal RBF. Proceeding...");
189
190                if let Err(err) = self.claim(id).await {
191                    match err {
192                        PaymentError::AlreadyClaimed => {
193                            warn!("Funds already claimed for Receive Swap {id}")
194                        }
195                        _ => error!("Claim for Receive Swap {id} failed: {err}"),
196                    }
197                }
198
199                Ok(())
200            }
201            RevSwapStates::TransactionConfirmed => {
202                let Some(transaction) = update.transaction.clone() else {
203                    return Err(anyhow!("Unexpected payload from Boltz status stream"));
204                };
205
206                // Do not continue or claim the swap if it was already paid via MRH
207                if let Some(mrh_tx_id) = receive_swap.mrh_tx_id {
208                    return Err(anyhow!(
209                        "MRH tx for Receive Swap {id} was already broadcast, ignoring swap: txid {mrh_tx_id}"
210                    ));
211                }
212
213                // Looking for lockup script history to verify lockup was broadcasted and confirmed
214                let tx_hex = transaction.hex.ok_or(anyhow!(
215                    "Missing lockup transaction hex in swap status update"
216                ))?;
217                let lockup_tx = match self
218                    .verify_lockup_tx_status(&receive_swap, &transaction.id, &tx_hex, true)
219                    .await
220                {
221                    Ok(lockup_tx) => lockup_tx,
222                    Err(e) => {
223                        return Err(anyhow!(
224                            "Swapper reported lockup could not be verified. txid: {}, err: {}",
225                            transaction.id,
226                            e
227                        ));
228                    }
229                };
230
231                if let Err(e) = self
232                    .verify_lockup_tx_amount(&receive_swap, &lockup_tx)
233                    .await
234                {
235                    // The lockup amount in the tx is underpaid compared to the expected amount
236                    self.update_swap_info(id, Failed, None, None, None, None)?;
237                    return Err(anyhow!(
238                        "Swapper underpaid lockup amount. txid: {}, err: {}",
239                        transaction.id,
240                        e
241                    ));
242                }
243                info!("Swapper lockup was verified, moving to claim");
244
245                match receive_swap.claim_tx_id {
246                    Some(claim_tx_id) => {
247                        warn!("Claim tx for Receive Swap {id} was already broadcast: txid {claim_tx_id}")
248                    }
249                    None => {
250                        self.update_swap_info(&receive_swap.id, Pending, None, None, None, None)?;
251
252                        if let Err(err) = self.claim(id).await {
253                            match err {
254                                PaymentError::AlreadyClaimed => {
255                                    warn!("Funds already claimed for Receive Swap {id}")
256                                }
257                                _ => error!("Claim for Receive Swap {id} failed: {err}"),
258                            }
259                        }
260                    }
261                }
262                Ok(())
263            }
264
265            RevSwapStates::InvoiceSettled => {
266                info!(
267                    "Received `InvoiceSettled` state from Boltz, saving invoice settlement time."
268                );
269                let Some(claim_tx_id) = receive_swap.claim_tx_id else {
270                    bail!("Could not save invoice settlement time: no claim tx id found.");
271                };
272                if utils::now().saturating_sub(receive_swap.created_at) > SETTLED_AT_GRACE_PERIOD {
273                    info!("Received `InvoiceSettled` event after grace period for Receive swap {id}: backfilling to claim tx timestamp.");
274                    return Ok(());
275                }
276                self.persister
277                    .insert_or_update_payment_details(PaymentTxDetails {
278                        tx_id: claim_tx_id,
279                        destination: receive_swap.invoice,
280                        settled_at: Some(utils::now()),
281                        ..Default::default()
282                    })
283                    .map_err(|err| anyhow!("Could not persist invoice settlement time for Receive Swap {id}: {err}"))?;
284                Ok(())
285            }
286
287            _ => {
288                debug!("Unhandled state for Receive Swap {id}: {swap_state:?}");
289                Ok(())
290            }
291        }
292    }
293
294    fn fetch_receive_swap_by_id(&self, swap_id: &str) -> Result<ReceiveSwap, PaymentError> {
295        self.persister
296            .fetch_receive_swap_by_id(swap_id)
297            .map_err(|e| {
298                error!("Failed to fetch receive swap by id: {e:?}");
299                PaymentError::PersistError
300            })?
301            .ok_or(PaymentError::Generic {
302                err: format!("Receive Swap not found {swap_id}"),
303            })
304    }
305
306    // Updates the swap without state transition validation
307    pub(crate) fn update_swap(&self, updated_swap: ReceiveSwap) -> Result<(), PaymentError> {
308        let swap_id = &updated_swap.id;
309        let swap = self.fetch_receive_swap_by_id(swap_id)?;
310        if updated_swap != swap {
311            info!(
312                "Updating Receive swap {swap_id} to {:?} (claim_tx_id = {:?}, lockup_tx_id = {:?}, mrh_tx_id = {:?})",
313                updated_swap.state, updated_swap.claim_tx_id, updated_swap.lockup_tx_id, updated_swap.mrh_tx_id
314            );
315            self.persister
316                .insert_or_update_receive_swap(&updated_swap)?;
317            let _ = self.subscription_notifier.send(swap_id.clone());
318        }
319
320        // If the swap is Complete and `settled_at` was never written (e.g. we missed
321        // `InvoiceSettled` event), backfill it now using the confirmed claim tx timestamp
322        // as the best available approximation.
323        if updated_swap.state == Complete {
324            utils::update_invoice_settled_at(
325                &self.persister,
326                swap_id,
327                updated_swap.claim_tx_id.as_ref(),
328                updated_swap.invoice.clone(),
329            );
330        }
331        Ok(())
332    }
333
334    // Updates the swap state with validation
335    pub(crate) fn update_swap_info(
336        &self,
337        swap_id: &str,
338        to_state: PaymentState,
339        claim_tx_id: Option<&str>,
340        lockup_tx_id: Option<&str>,
341        mrh_tx_id: Option<&str>,
342        mrh_amount_sat: Option<u64>,
343    ) -> Result<(), PaymentError> {
344        info!(
345            "Transitioning Receive swap {swap_id} to {to_state:?} (claim_tx_id = {claim_tx_id:?}, lockup_tx_id = {lockup_tx_id:?}, mrh_tx_id = {mrh_tx_id:?})"
346        );
347        let swap = self.fetch_receive_swap_by_id(swap_id)?;
348        Self::validate_state_transition(swap.state, to_state)?;
349        self.persister.try_handle_receive_swap_update(
350            swap_id,
351            to_state,
352            claim_tx_id,
353            lockup_tx_id,
354            mrh_tx_id,
355            mrh_amount_sat,
356        )?;
357        let updated_swap = self.fetch_receive_swap_by_id(swap_id)?;
358
359        if mrh_tx_id.is_some() {
360            self.persister.delete_reserved_address(&swap.mrh_address)?;
361        }
362
363        if updated_swap != swap {
364            let _ = self.subscription_notifier.send(updated_swap.id);
365        }
366        Ok(())
367    }
368
369    async fn claim(&self, swap_id: &str) -> Result<(), PaymentError> {
370        {
371            let mut claiming_guard = self.claiming_swaps.lock().await;
372            if claiming_guard.contains(swap_id) {
373                debug!("Claim for swap {swap_id} already in progress, skipping.");
374                return Ok(());
375            }
376            claiming_guard.insert(swap_id.to_string());
377        }
378
379        let result = self.claim_inner(swap_id).await;
380
381        {
382            let mut claiming_guard = self.claiming_swaps.lock().await;
383            claiming_guard.remove(swap_id);
384        }
385
386        result
387    }
388
389    async fn claim_inner(&self, swap_id: &str) -> Result<(), PaymentError> {
390        let swap = self.fetch_receive_swap_by_id(swap_id)?;
391        ensure_sdk!(swap.claim_tx_id.is_none(), PaymentError::AlreadyClaimed);
392
393        // Use non-cooperative claim if within 10 blocks of timeout
394        let liquid_tip = self.liquid_chain_service.tip().await?;
395        let is_cooperative = liquid_tip <= swap.timeout_block_height.saturating_sub(10);
396        if !is_cooperative {
397            info!(
398                "Using non-cooperative claim for Receive Swap {swap_id} as timeout block height {} is near or past (liquid tip: {liquid_tip})",
399                swap.timeout_block_height
400            );
401        }
402
403        info!("Initiating claim for Receive Swap {swap_id}");
404        let claim_address = match swap.claim_address {
405            Some(ref claim_address) => claim_address.clone(),
406            None => {
407                // If no claim address is set, we get an unused one
408                let address = self.onchain_wallet.next_unused_address().await?.to_string();
409                self.persister
410                    .set_receive_swap_claim_address(&swap.id, &address)?;
411                address
412            }
413        };
414
415        let crate::prelude::Transaction::Liquid(claim_tx) = self
416            .swapper
417            .create_claim_tx(
418                Swap::Receive(swap.clone()),
419                Some(claim_address.clone()),
420                is_cooperative,
421            )
422            .await?
423        else {
424            return Err(PaymentError::Generic {
425                err: format!("Constructed invalid transaction for Receive swap {swap_id}"),
426            });
427        };
428
429        // Set the swap claim_tx_id before broadcasting.
430        // If another claim_tx_id has been set in the meantime, don't broadcast the claim tx
431        let tx_id = claim_tx.txid().to_hex();
432        match self.persister.set_receive_swap_claim_tx_id(swap_id, &tx_id) {
433            Ok(_) => {
434                // We attempt broadcasting via chain service, then fallback to Boltz
435                let broadcast_res = match self.liquid_chain_service.broadcast(&claim_tx).await {
436                    Ok(tx_id) => Ok(tx_id.to_hex()),
437                    Err(e) if is_txn_mempool_conflict_error(&e) => {
438                        Err(PaymentError::AlreadyClaimed)
439                    }
440                    Err(err) => {
441                        debug!(
442                            "Could not broadcast claim tx via chain service for Receive swap {swap_id}: {err:?}"
443                        );
444                        let claim_tx_hex = claim_tx.serialize().to_lower_hex_string();
445                        self.swapper
446                            .broadcast_tx(self.config.network.into(), &claim_tx_hex)
447                            .await
448                    }
449                };
450                match broadcast_res {
451                    Ok(claim_tx_id) => {
452                        // We insert a pseudo-claim-tx in case LWK fails to pick up the new mempool tx for a while
453                        // This makes the tx known to the SDK (get_info, list_payments) instantly
454                        self.persister.insert_or_update_payment(
455                            PaymentTxData {
456                                tx_id: claim_tx_id.clone(),
457                                timestamp: Some(utils::now()),
458                                fees_sat: 0,
459                                is_confirmed: false,
460                                unblinding_data: None,
461                            },
462                            &[PaymentTxBalance {
463                                amount: swap.receiver_amount_sat,
464                                payment_type: PaymentType::Receive,
465                                asset_id: self.config.lbtc_asset_id(),
466                            }],
467                            None,
468                            false,
469                        )?;
470
471                        info!("Successfully broadcast claim tx {claim_tx_id} for Receive Swap {swap_id}");
472                        // The claim_tx_id is already set by set_receive_swap_claim_tx_id. Manually trigger notifying
473                        // subscribers as update_swap_info will not recognise a change to the swap
474                        _ = self.subscription_notifier.send(claim_tx_id);
475                        Ok(())
476                    }
477                    Err(err) => {
478                        // Multiple attempts to broadcast have failed. Unset the swap claim_tx_id
479                        debug!(
480                            "Could not broadcast claim tx via swapper for Receive swap {swap_id}: {err:?}"
481                        );
482                        self.persister
483                            .unset_receive_swap_claim_tx_id(swap_id, &tx_id)?;
484                        Err(err)
485                    }
486                }
487            }
488            Err(err) => {
489                debug!(
490                    "Failed to set claim_tx_id after creating tx for Receive swap {swap_id}: txid {tx_id}"
491                );
492                Err(err)
493            }
494        }
495    }
496
497    async fn claim_confirmed_lockups(&self, height: u32) -> Result<()> {
498        let receive_swaps: Vec<ReceiveSwap> = self
499            .persister
500            .list_ongoing_receive_swaps()?
501            .into_iter()
502            .filter(|s| s.lockup_tx_id.is_some() && s.claim_tx_id.is_none())
503            .collect();
504        info!(
505            "Rescanning {} Receive Swap(s) lockup txs at height {}",
506            receive_swaps.len(),
507            height
508        );
509        for swap in receive_swaps {
510            if let Err(e) = self.claim_confirmed_lockup(&swap).await {
511                error!("Error rescanning Receive Swap {}: {e:?}", swap.id,);
512            }
513        }
514        Ok(())
515    }
516
517    async fn claim_confirmed_lockup(&self, receive_swap: &ReceiveSwap) -> Result<()> {
518        let Some(tx_id) = receive_swap.lockup_tx_id.clone() else {
519            // Skip the rescan if there is no lockup_tx_id yet
520            return Ok(());
521        };
522        let swap_id = &receive_swap.id;
523        let tx_hex = self
524            .liquid_chain_service
525            .get_transaction_hex(&Txid::from_str(&tx_id)?)
526            .await?
527            .ok_or(anyhow!("Lockup tx not found for Receive swap {swap_id}"))?
528            .serialize()
529            .to_lower_hex_string();
530        let lockup_tx = self
531            .verify_lockup_tx_status(receive_swap, &tx_id, &tx_hex, true)
532            .await?;
533        if let Err(e) = self.verify_lockup_tx_amount(receive_swap, &lockup_tx).await {
534            self.update_swap_info(swap_id, Failed, None, None, None, None)?;
535            return Err(e);
536        }
537        info!("Receive Swap {swap_id} lockup tx is confirmed");
538        self.claim(swap_id)
539            .await
540            .map_err(|e| anyhow!("Could not claim Receive Swap {swap_id}: {e:?}"))
541    }
542
543    fn validate_state_transition(
544        from_state: PaymentState,
545        to_state: PaymentState,
546    ) -> Result<(), PaymentError> {
547        match (from_state, to_state) {
548            (_, Created) => Err(PaymentError::Generic {
549                err: "Cannot transition to Created state".to_string(),
550            }),
551
552            (Created | Pending, Pending) => Ok(()),
553            (_, Pending) => Err(PaymentError::Generic {
554                err: format!("Cannot transition from {from_state:?} to Pending state"),
555            }),
556
557            (Created | Pending, Complete) => Ok(()),
558            (_, Complete) => Err(PaymentError::Generic {
559                err: format!("Cannot transition from {from_state:?} to Complete state"),
560            }),
561
562            (Created | TimedOut, TimedOut) => Ok(()),
563            (_, TimedOut) => Err(PaymentError::Generic {
564                err: format!("Cannot transition from {from_state:?} to TimedOut state"),
565            }),
566
567            (_, Refundable) => Err(PaymentError::Generic {
568                err: format!("Cannot transition from {from_state:?} to Refundable state"),
569            }),
570
571            (_, RefundPending) => Err(PaymentError::Generic {
572                err: format!("Cannot transition from {from_state:?} to RefundPending state"),
573            }),
574
575            (Complete, Failed) => Err(PaymentError::Generic {
576                err: format!("Cannot transition from {from_state:?} to Failed state"),
577            }),
578            (_, Failed) => Ok(()),
579
580            (_, WaitingFeeAcceptance) => Err(PaymentError::Generic {
581                err: format!("Cannot transition from {from_state:?} to WaitingFeeAcceptance state"),
582            }),
583        }
584    }
585
586    async fn verify_lockup_tx_status(
587        &self,
588        receive_swap: &ReceiveSwap,
589        tx_id: &str,
590        tx_hex: &str,
591        verify_confirmation: bool,
592    ) -> Result<Transaction> {
593        // Looking for lockup script history to verify lockup was broadcasted
594        let script = receive_swap.get_swap_script()?;
595        let address = script
596            .to_address(self.config.network.into())
597            .map_err(|e| anyhow!("Failed to get swap script address {e:?}"))?;
598        self.liquid_chain_service
599            .verify_tx(&address, tx_id, tx_hex, verify_confirmation)
600            .await
601    }
602
603    async fn verify_lockup_tx_amount(
604        &self,
605        receive_swap: &ReceiveSwap,
606        lockup_tx: &Transaction,
607    ) -> Result<()> {
608        let secp = Secp256k1::new();
609        let script = receive_swap.get_swap_script()?;
610        let address = script
611            .to_address(self.config.network.into())
612            .map_err(|e| anyhow!("Failed to get swap script address {e:?}"))?;
613        let blinding_key = receive_swap
614            .get_boltz_create_response()?
615            .blinding_key
616            .ok_or(anyhow!("Missing blinding key"))?;
617        let tx_out = lockup_tx
618            .output
619            .iter()
620            .find(|tx_out| tx_out.script_pubkey == address.script_pubkey())
621            .ok_or(anyhow!("Failed to get tx output"))?;
622        let lockup_amount_sat = tx_out
623            .unblind(&secp, SecretKey::from_str(&blinding_key)?)
624            .map(|o| o.value)?;
625        let expected_lockup_amount_sat =
626            receive_swap.receiver_amount_sat + receive_swap.claim_fees_sat;
627        if lockup_amount_sat < expected_lockup_amount_sat {
628            bail!(
629                "Failed to verify lockup amount for Receive Swap {}: {} sat vs {} sat",
630                receive_swap.id,
631                expected_lockup_amount_sat,
632                lockup_amount_sat
633            );
634        }
635        Ok(())
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use std::collections::{HashMap, HashSet};
642
643    use anyhow::Result;
644
645    use crate::{
646        model::PaymentState::{self, *},
647        test_utils::{
648            persist::{create_persister, new_receive_swap},
649            receive_swap::new_receive_swap_handler,
650        },
651    };
652
653    #[cfg(feature = "browser-tests")]
654    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
655
656    #[sdk_macros::async_test_all]
657    async fn test_receive_swap_state_transitions() -> Result<()> {
658        create_persister!(persister);
659
660        let receive_swap_state_handler = new_receive_swap_handler(persister.clone())?;
661
662        // Test valid combinations of states
663        let valid_combinations = HashMap::from([
664            (
665                Created,
666                HashSet::from([Pending, Complete, TimedOut, Failed]),
667            ),
668            (Pending, HashSet::from([Pending, Complete, Failed])),
669            (TimedOut, HashSet::from([TimedOut, Failed])),
670            (Complete, HashSet::from([])),
671            (Refundable, HashSet::from([Failed])),
672            (RefundPending, HashSet::from([Failed])),
673            (Failed, HashSet::from([Failed])),
674        ]);
675
676        for (first_state, allowed_states) in valid_combinations.iter() {
677            for allowed_state in allowed_states {
678                let receive_swap = new_receive_swap(Some(*first_state), None);
679                persister.insert_or_update_receive_swap(&receive_swap)?;
680
681                assert!(receive_swap_state_handler
682                    .update_swap_info(&receive_swap.id, *allowed_state, None, None, None, None)
683                    .is_ok());
684            }
685        }
686
687        // Test invalid combinations of states
688        let all_states = HashSet::from([Created, Pending, Complete, TimedOut, Failed]);
689        let invalid_combinations: HashMap<PaymentState, HashSet<PaymentState>> = valid_combinations
690            .iter()
691            .map(|(first_state, allowed_states)| {
692                (
693                    *first_state,
694                    all_states.difference(allowed_states).cloned().collect(),
695                )
696            })
697            .collect();
698
699        for (first_state, disallowed_states) in invalid_combinations.iter() {
700            for disallowed_state in disallowed_states {
701                let receive_swap = new_receive_swap(Some(*first_state), None);
702                persister.insert_or_update_receive_swap(&receive_swap)?;
703
704                assert!(receive_swap_state_handler
705                    .update_swap_info(&receive_swap.id, *disallowed_state, None, None, None, None)
706                    .is_err());
707            }
708        }
709
710        Ok(())
711    }
712}