breez_sdk_liquid/swapper/boltz/
mod.rs

1use std::{sync::OnceLock, time::Duration};
2
3use super::{ProxyUrlFetcher, Swapper};
4use crate::bitcoin::secp256k1::rand;
5use crate::model::BREEZ_SWAP_PROXY_URL;
6use crate::{
7    error::{PaymentError, SdkError},
8    model::LIQUID_FEE_RATE_SAT_PER_VBYTE,
9    prelude::{ChainSwap, Config, Direction, LiquidNetwork, SendSwap, Swap, Transaction, Utxo},
10};
11use anyhow::{anyhow, bail, Result};
12use boltz_client::reqwest::header::HeaderMap;
13use boltz_client::{
14    boltz::{
15        self, BoltzApiClientV2, ChainPair, Cooperative, CreateBolt12OfferRequest,
16        CreateChainRequest, CreateChainResponse, CreateReverseRequest, CreateReverseResponse,
17        CreateSubmarineRequest, CreateSubmarineResponse, GetBolt12FetchRequest,
18        GetBolt12FetchResponse, GetBolt12ParamsResponse, GetNodesResponse, ReversePair,
19        SubmarineClaimTxResponse, SubmarinePair, UpdateBolt12OfferRequest, WsRequest,
20    },
21    elements::secp256k1_zkp::{MusigPartialSignature, MusigPubNonce},
22    network::Chain,
23    Amount,
24};
25use client::{BitcoinClient, LiquidClient};
26use log::{info, warn};
27use proxy::split_boltz_url;
28use rand::Rng;
29use sdk_common::utils::Arc;
30use tokio::sync::broadcast;
31use tokio::time::sleep;
32use tokio_with_wasm::alias as tokio;
33
34pub(crate) mod bitcoin;
35mod client;
36pub(crate) mod liquid;
37pub(crate) mod proxy;
38pub mod status_stream;
39
40const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
41const MAX_RETRY_ATTEMPTS: u8 = 10;
42const MIN_RETRY_DELAY_SECS: u64 = 1;
43const MAX_RETRY_DELAY_SECS: u64 = 10;
44
45pub(crate) struct BoltzClient {
46    referral_id: Option<String>,
47    inner: BoltzApiClientV2,
48    ws_auth_api_key: Option<String>,
49}
50
51pub struct BoltzSwapper<P: ProxyUrlFetcher> {
52    config: Config,
53    boltz_client: OnceLock<BoltzClient>,
54    liquid_client: OnceLock<LiquidClient>,
55    bitcoin_client: OnceLock<BitcoinClient>,
56    proxy_url: Arc<P>,
57    request_notifier: broadcast::Sender<WsRequest>,
58    update_notifier: broadcast::Sender<boltz::SwapStatus>,
59    invoice_request_notifier: broadcast::Sender<boltz::InvoiceRequest>,
60}
61
62impl<P: ProxyUrlFetcher> BoltzSwapper<P> {
63    pub fn new(config: Config, proxy_url: Arc<P>) -> Result<Self, SdkError> {
64        let (request_notifier, _) = broadcast::channel::<WsRequest>(30);
65        let (update_notifier, _) = broadcast::channel::<boltz::SwapStatus>(30);
66        let (invoice_request_notifier, _) = broadcast::channel::<boltz::InvoiceRequest>(30);
67
68        Ok(Self {
69            proxy_url,
70            config: config.clone(),
71            boltz_client: OnceLock::new(),
72            liquid_client: OnceLock::new(),
73            bitcoin_client: OnceLock::new(),
74            request_notifier,
75            update_notifier,
76            invoice_request_notifier,
77        })
78    }
79
80    async fn get_boltz_client(&self) -> Result<&BoltzClient> {
81        if let Some(client) = self.boltz_client.get() {
82            return Ok(client);
83        }
84
85        let (boltz_api_base_url, referral_id) = match &self.config.network {
86            LiquidNetwork::Testnet | LiquidNetwork::Regtest => (None, None),
87            LiquidNetwork::Mainnet => match self.proxy_url.fetch().await {
88                Ok(Some(boltz_swapper_urls)) => {
89                    if self.config.breez_api_key.is_some() {
90                        split_boltz_url(&boltz_swapper_urls.proxy_url)
91                    } else {
92                        split_boltz_url(&boltz_swapper_urls.boltz_url)
93                    }
94                }
95                _ => (None, None),
96            },
97        };
98
99        let boltz_url = boltz_api_base_url.unwrap_or(self.config.default_boltz_url().to_string());
100
101        let mut ws_auth_api_key = None;
102        let mut headers = HeaderMap::new();
103        if boltz_url == BREEZ_SWAP_PROXY_URL {
104            match &self.config.breez_api_key {
105                Some(api_key) => {
106                    ws_auth_api_key = Some(api_key.clone());
107                    headers.insert("authorization", format!("Bearer {api_key}").parse()?);
108                }
109                None => {
110                    bail!("Cannot start Boltz client: Breez API key is not set")
111                }
112            }
113        }
114
115        let inner = BoltzApiClientV2::with_client(
116            boltz_url,
117            boltz_client::reqwest::Client::builder()
118                .default_headers(headers)
119                .build()?,
120            Some(CONNECTION_TIMEOUT),
121        );
122        let client = self.boltz_client.get_or_init(|| BoltzClient {
123            inner,
124            referral_id,
125            ws_auth_api_key,
126        });
127        Ok(client)
128    }
129
130    fn get_liquid_client(&self) -> Result<&LiquidClient> {
131        if let Some(client) = self.liquid_client.get() {
132            return Ok(client);
133        }
134        let liquid_client = LiquidClient::new(&self.config)
135            .map_err(|err| anyhow!("Could not create Boltz Liquid client: {err:?}"))?;
136        let liquid_client = self.liquid_client.get_or_init(|| liquid_client);
137        Ok(liquid_client)
138    }
139
140    fn get_bitcoin_client(&self) -> Result<&BitcoinClient> {
141        if let Some(client) = self.bitcoin_client.get() {
142            return Ok(client);
143        }
144        let bitcoin_client = BitcoinClient::new(&self.config)
145            .map_err(|err| anyhow!("Could not create Boltz Bitcoin client: {err:?}"))?;
146        let bitcoin_client = self.bitcoin_client.get_or_init(|| bitcoin_client);
147        Ok(bitcoin_client)
148    }
149
150    /// Get the partial signature and pub nonce for a chain swap.
151    /// If we fail to get the claim tx details, we may have already sent it, so we return None.
152    async fn get_claim_partial_sig(
153        &self,
154        swap: &ChainSwap,
155    ) -> Result<Option<(MusigPartialSignature, MusigPubNonce)>, PaymentError> {
156        let refund_keypair = swap.get_refund_keypair()?;
157
158        // Create a temporary refund tx to an address from the swap lockup chain
159        // We need it to calculate the musig partial sig for the claim tx from the other chain
160        let lockup_address = &swap.lockup_address;
161
162        let claim_tx_details = match self
163            .get_boltz_client()
164            .await?
165            .inner
166            .get_chain_claim_tx_details(&swap.id)
167            .await
168        {
169            Ok(claim_tx_details) => claim_tx_details,
170            Err(e) => {
171                warn!("Failed to get chain claim tx details: {e:?} - continuing without signature as we may have already sent it");
172                return Ok(None);
173            }
174        };
175
176        let signature = match swap.direction {
177            Direction::Incoming => {
178                let refund_tx_wrapper = self
179                    .new_btc_refund_wrapper(&Swap::Chain(swap.clone()), lockup_address)
180                    .await?;
181
182                refund_tx_wrapper.partial_sign(
183                    &refund_keypair,
184                    &claim_tx_details.pub_nonce,
185                    &claim_tx_details.transaction_hash,
186                )?
187            }
188            Direction::Outgoing => {
189                let refund_tx_wrapper = self
190                    .new_lbtc_refund_wrapper(&Swap::Chain(swap.clone()), lockup_address)
191                    .await?;
192
193                refund_tx_wrapper.partial_sign(
194                    &refund_keypair,
195                    &claim_tx_details.pub_nonce,
196                    &claim_tx_details.transaction_hash,
197                )?
198            }
199        };
200
201        Ok(Some(signature))
202    }
203
204    async fn get_cooperative_details(
205        &self,
206        swap_id: String,
207        signature: Option<(MusigPartialSignature, MusigPubNonce)>,
208    ) -> Result<Option<Cooperative<'_>>> {
209        Ok(Some(Cooperative {
210            boltz_api: &self.get_boltz_client().await?.inner,
211            swap_id,
212            signature,
213        }))
214    }
215
216    async fn create_claim_tx_impl(
217        &self,
218        swap: &Swap,
219        claim_address: Option<String>,
220    ) -> Result<Transaction, PaymentError> {
221        let tx = match &swap {
222            Swap::Chain(swap) => {
223                let Some(claim_address) = claim_address else {
224                    return Err(PaymentError::Generic {
225                        err: format!(
226                            "No claim address was supplied when claiming for Chain swap {}",
227                            swap.id
228                        ),
229                    });
230                };
231                match swap.direction {
232                    Direction::Incoming => Transaction::Liquid(
233                        self.new_incoming_chain_claim_tx(swap, claim_address)
234                            .await?,
235                    ),
236                    Direction::Outgoing => Transaction::Bitcoin(
237                        self.new_outgoing_chain_claim_tx(swap, claim_address)
238                            .await?,
239                    ),
240                }
241            }
242            Swap::Receive(swap) => {
243                let Some(claim_address) = claim_address else {
244                    return Err(PaymentError::Generic {
245                        err: format!(
246                            "No claim address was supplied when claiming for Receive swap {}",
247                            swap.id
248                        ),
249                    });
250                };
251                Transaction::Liquid(self.new_receive_claim_tx(swap, claim_address).await?)
252            }
253            Swap::Send(swap) => {
254                return Err(PaymentError::Generic {
255                    err: format!(
256                        "Failed to create claim tx for Send swap {}: invalid swap type",
257                        swap.id
258                    ),
259                });
260            }
261        };
262
263        Ok(tx)
264    }
265}
266
267#[sdk_macros::async_trait]
268impl<P: ProxyUrlFetcher> Swapper for BoltzSwapper<P> {
269    /// Create a new chain swap
270    async fn create_chain_swap(
271        &self,
272        req: CreateChainRequest,
273    ) -> Result<CreateChainResponse, PaymentError> {
274        let client = self.get_boltz_client().await?;
275        let modified_req = CreateChainRequest {
276            referral_id: client.referral_id.clone(),
277            ..req.clone()
278        };
279        Ok(client.inner.post_chain_req(modified_req).await?)
280    }
281
282    /// Create a new send swap
283    async fn create_send_swap(
284        &self,
285        req: CreateSubmarineRequest,
286    ) -> Result<CreateSubmarineResponse, PaymentError> {
287        let client = self.get_boltz_client().await?;
288        let modified_req = CreateSubmarineRequest {
289            referral_id: client.referral_id.clone(),
290            ..req.clone()
291        };
292        Ok(client.inner.post_swap_req(&modified_req).await?)
293    }
294
295    async fn get_chain_pair(
296        &self,
297        direction: Direction,
298    ) -> Result<Option<ChainPair>, PaymentError> {
299        let pairs = self
300            .get_boltz_client()
301            .await?
302            .inner
303            .get_chain_pairs()
304            .await?;
305        let pair = match direction {
306            Direction::Incoming => pairs.get_btc_to_lbtc_pair(),
307            Direction::Outgoing => pairs.get_lbtc_to_btc_pair(),
308        };
309        Ok(pair)
310    }
311
312    async fn get_chain_pairs(
313        &self,
314    ) -> Result<(Option<ChainPair>, Option<ChainPair>), PaymentError> {
315        let pairs = self
316            .get_boltz_client()
317            .await?
318            .inner
319            .get_chain_pairs()
320            .await?;
321        let pair_outgoing = pairs.get_lbtc_to_btc_pair();
322        let pair_incoming = pairs.get_btc_to_lbtc_pair();
323        Ok((pair_outgoing, pair_incoming))
324    }
325
326    async fn get_zero_amount_chain_swap_quote(&self, swap_id: &str) -> Result<Amount, SdkError> {
327        self.get_boltz_client()
328            .await?
329            .inner
330            .get_quote(swap_id)
331            .await
332            .map(|r| Amount::from_sat(r.amount))
333            .map_err(Into::into)
334    }
335
336    async fn accept_zero_amount_chain_swap_quote(
337        &self,
338        swap_id: &str,
339        server_lockup_sat: u64,
340    ) -> Result<(), PaymentError> {
341        self.get_boltz_client()
342            .await?
343            .inner
344            .accept_quote(swap_id, server_lockup_sat)
345            .await
346            .map_err(Into::into)
347    }
348
349    /// Get a submarine pair information
350    async fn get_submarine_pairs(&self) -> Result<Option<SubmarinePair>, PaymentError> {
351        Ok(self
352            .get_boltz_client()
353            .await?
354            .inner
355            .get_submarine_pairs()
356            .await?
357            .get_lbtc_to_btc_pair())
358    }
359
360    /// Get a submarine swap's preimage
361    async fn get_submarine_preimage(&self, swap_id: &str) -> Result<String, PaymentError> {
362        Ok(self
363            .get_boltz_client()
364            .await?
365            .inner
366            .get_submarine_preimage(swap_id)
367            .await?
368            .preimage)
369    }
370
371    /// Get claim tx details which includes the preimage as a proof of payment.
372    /// It is used to validate the preimage before claiming which is the reason why we need to separate
373    /// the claim into two steps.
374    async fn get_send_claim_tx_details(
375        &self,
376        swap: &SendSwap,
377    ) -> Result<SubmarineClaimTxResponse, PaymentError> {
378        let claim_tx_response = self
379            .get_boltz_client()
380            .await?
381            .inner
382            .get_submarine_claim_tx_details(&swap.id)
383            .await?;
384        info!("Received claim tx details: {:?}", &claim_tx_response);
385
386        self.validate_send_swap_preimage(&swap.id, &swap.invoice, &claim_tx_response.preimage)?;
387        Ok(claim_tx_response)
388    }
389
390    /// Claim send swap cooperatively. Here the remote swapper is the one that claims.
391    /// We are helping to use key spend path for cheaper fees.
392    async fn claim_send_swap_cooperative(
393        &self,
394        swap: &SendSwap,
395        claim_tx_response: SubmarineClaimTxResponse,
396        refund_address: &str,
397    ) -> Result<(), PaymentError> {
398        let swap_id = &swap.id;
399        let keypair = swap.get_refund_keypair()?;
400        let refund_tx_wrapper = self
401            .new_lbtc_refund_wrapper(&Swap::Send(swap.clone()), refund_address)
402            .await?;
403
404        let (partial_sig, pub_nonce) = refund_tx_wrapper.partial_sign(
405            &keypair,
406            &claim_tx_response.pub_nonce,
407            &claim_tx_response.transaction_hash,
408        )?;
409
410        self.get_boltz_client()
411            .await?
412            .inner
413            .post_submarine_claim_tx_details(&swap_id.to_string(), pub_nonce, partial_sig)
414            .await?;
415        info!("Successfully cooperatively claimed Send Swap {swap_id}");
416        Ok(())
417    }
418
419    // Create a new receive swap
420    async fn create_receive_swap(
421        &self,
422        req: CreateReverseRequest,
423    ) -> Result<CreateReverseResponse, PaymentError> {
424        let client = self.get_boltz_client().await?;
425        let modified_req = CreateReverseRequest {
426            referral_id: client.referral_id.clone(),
427            ..req.clone()
428        };
429        Ok(client.inner.post_reverse_req(modified_req).await?)
430    }
431
432    // Get a reverse pair information
433    async fn get_reverse_swap_pairs(&self) -> Result<Option<ReversePair>, PaymentError> {
434        Ok(self
435            .get_boltz_client()
436            .await?
437            .inner
438            .get_reverse_pairs()
439            .await?
440            .get_btc_to_lbtc_pair())
441    }
442
443    /// Create a claim transaction for a receive or chain swap
444    async fn create_claim_tx(
445        &self,
446        swap: Swap,
447        claim_address: Option<String>,
448    ) -> Result<Transaction, PaymentError> {
449        let mut attempts = 0;
450        let mut current_delay_secs = MIN_RETRY_DELAY_SECS;
451        loop {
452            match self
453                .create_claim_tx_impl(&swap, claim_address.clone())
454                .await
455            {
456                Ok(tx) => return Ok(tx),
457                Err(e) if is_concurrent_claim_error(&e) => {
458                    attempts += 1;
459                    if attempts >= MAX_RETRY_ATTEMPTS {
460                        return Err(e);
461                    }
462
463                    // Exponential backoff with jitter
464                    let jitter = rand::thread_rng().gen_range(0..=current_delay_secs);
465                    let delay_with_jitter_secs = current_delay_secs + jitter;
466
467                    warn!(
468                        "Failed to create claim tx (likely due to concurrent instance attempting \
469                        to claim), attempt {attempts}/{MAX_RETRY_ATTEMPTS}. Retrying in \
470                        {delay_with_jitter_secs}s. Error: {e:?}"
471                    );
472                    sleep(Duration::from_secs(delay_with_jitter_secs)).await;
473
474                    current_delay_secs = (current_delay_secs * 2).min(MAX_RETRY_DELAY_SECS);
475                }
476                Err(e) => return Err(e),
477            }
478        }
479    }
480
481    /// Estimate the refund broadcast transaction size and fees in sats for a send or chain swap
482    async fn estimate_refund_broadcast(
483        &self,
484        swap: Swap,
485        refund_address: &str,
486        fee_rate_sat_per_vb: Option<f64>,
487        is_cooperative: bool,
488    ) -> Result<(u32, u64), SdkError> {
489        let refund_address = &refund_address.to_string();
490        let refund_keypair = match &swap {
491            Swap::Chain(swap) => swap.get_refund_keypair()?,
492            Swap::Send(swap) => swap.get_refund_keypair()?,
493            Swap::Receive(swap) => {
494                return Err(SdkError::generic(format!(
495                    "Cannot create refund tx for Receive swap {}: invalid swap type",
496                    swap.id
497                )));
498            }
499        };
500
501        let refund_tx_size = match self.new_lbtc_refund_wrapper(&swap, refund_address).await {
502            Ok(refund_tx_wrapper) => {
503                refund_tx_wrapper.size(&refund_keypair, is_cooperative, true)?
504            }
505            Err(_) => {
506                let refund_tx_wrapper = self.new_btc_refund_wrapper(&swap, refund_address).await?;
507                refund_tx_wrapper.size(&refund_keypair, is_cooperative)?
508            }
509        } as u32;
510
511        let fee_rate_sat_per_vb = fee_rate_sat_per_vb.unwrap_or(LIQUID_FEE_RATE_SAT_PER_VBYTE);
512        let refund_tx_fees_sat = (refund_tx_size as f64 * fee_rate_sat_per_vb).ceil() as u64;
513
514        Ok((refund_tx_size, refund_tx_fees_sat))
515    }
516
517    /// Create a refund transaction for a send or chain swap
518    async fn create_refund_tx(
519        &self,
520        swap: Swap,
521        refund_address: &str,
522        utxos: Vec<Utxo>,
523        broadcast_fee_rate_sat_per_vb: Option<f64>,
524        is_cooperative: bool,
525    ) -> Result<Transaction, PaymentError> {
526        let swap_id = swap.id();
527        let refund_address = &refund_address.to_string();
528
529        let tx = match &swap {
530            Swap::Chain(chain_swap) => match chain_swap.direction {
531                Direction::Incoming => {
532                    let Some(broadcast_fee_rate_sat_per_vb) = broadcast_fee_rate_sat_per_vb else {
533                        return Err(PaymentError::generic(format!("No broadcast fee rate provided when refunding incoming Chain Swap {swap_id}")));
534                    };
535
536                    Transaction::Bitcoin(
537                        self.new_btc_refund_tx(
538                            chain_swap,
539                            refund_address,
540                            utxos,
541                            broadcast_fee_rate_sat_per_vb,
542                            is_cooperative,
543                        )
544                        .await?,
545                    )
546                }
547                Direction::Outgoing => Transaction::Liquid(
548                    self.new_lbtc_refund_tx(&swap, refund_address, utxos, is_cooperative)
549                        .await?,
550                ),
551            },
552            Swap::Send(_) => Transaction::Liquid(
553                self.new_lbtc_refund_tx(&swap, refund_address, utxos, is_cooperative)
554                    .await?,
555            ),
556            Swap::Receive(_) => {
557                return Err(PaymentError::Generic {
558                    err: format!(
559                        "Failed to create refund tx for Receive swap {swap_id}: invalid swap type",
560                    ),
561                });
562            }
563        };
564
565        Ok(tx)
566    }
567
568    async fn broadcast_tx(&self, chain: Chain, tx_hex: &str) -> Result<String, PaymentError> {
569        let response = self
570            .get_boltz_client()
571            .await?
572            .inner
573            .broadcast_tx(chain, &tx_hex.into())
574            .await?;
575        let err = format!("Unexpected response from Boltz server: {response}");
576        let tx_id = response
577            .as_object()
578            .ok_or(PaymentError::Generic { err: err.clone() })?
579            .get("id")
580            .ok_or(PaymentError::Generic { err: err.clone() })?
581            .as_str()
582            .ok_or(PaymentError::Generic { err })?
583            .to_string();
584        Ok(tx_id)
585    }
586
587    async fn check_for_mrh(&self, invoice: &str) -> Result<Option<(String, Amount)>, PaymentError> {
588        boltz_client::swaps::magic_routing::check_for_mrh(
589            &self.get_boltz_client().await?.inner,
590            invoice,
591            self.config.network.into(),
592        )
593        .await
594        .map_err(Into::into)
595    }
596
597    async fn get_bolt12_info(
598        &self,
599        req: GetBolt12FetchRequest,
600    ) -> Result<GetBolt12FetchResponse, PaymentError> {
601        let invoice_res = self
602            .get_boltz_client()
603            .await?
604            .inner
605            .get_bolt12_invoice(req)
606            .await?;
607        info!("Received BOLT12 invoice response: {invoice_res:?}");
608        Ok(invoice_res)
609    }
610
611    async fn create_bolt12_offer(&self, req: CreateBolt12OfferRequest) -> Result<(), SdkError> {
612        self.get_boltz_client()
613            .await?
614            .inner
615            .post_bolt12_offer(req)
616            .await?;
617        Ok(())
618    }
619
620    async fn update_bolt12_offer(&self, req: UpdateBolt12OfferRequest) -> Result<(), SdkError> {
621        self.get_boltz_client()
622            .await?
623            .inner
624            .patch_bolt12_offer(req)
625            .await?;
626        Ok(())
627    }
628
629    async fn delete_bolt12_offer(&self, offer: &str, signature: &str) -> Result<(), SdkError> {
630        self.get_boltz_client()
631            .await?
632            .inner
633            .delete_bolt12_offer(offer, signature)
634            .await?;
635        Ok(())
636    }
637
638    async fn get_bolt12_params(&self) -> Result<GetBolt12ParamsResponse, PaymentError> {
639        let res = self
640            .get_boltz_client()
641            .await?
642            .inner
643            .get_bolt12_params()
644            .await?;
645        Ok(res)
646    }
647
648    async fn get_nodes(&self) -> Result<GetNodesResponse, PaymentError> {
649        let res = self.get_boltz_client().await?.inner.get_nodes().await?;
650        Ok(res)
651    }
652}
653
654fn is_concurrent_claim_error(e: &PaymentError) -> bool {
655    let e_string = e.to_string();
656    e_string.contains("invalid partial signature")
657        || e_string.contains("session already initialized")
658}