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    async fn get_claim_partial_sig(
151        &self,
152        swap: &ChainSwap,
153    ) -> Result<(MusigPartialSignature, MusigPubNonce), PaymentError> {
154        let refund_keypair = swap.get_refund_keypair()?;
155
156        // Create a temporary refund tx to an address from the swap lockup chain
157        // We need it to calculate the musig partial sig for the claim tx from the other chain
158        let lockup_address = &swap.lockup_address;
159
160        let claim_tx_details = self
161            .get_boltz_client()
162            .await?
163            .inner
164            .get_chain_claim_tx_details(&swap.id)
165            .await?;
166        match swap.direction {
167            Direction::Incoming => {
168                let refund_tx_wrapper = self
169                    .new_btc_refund_wrapper(&Swap::Chain(swap.clone()), lockup_address)
170                    .await?;
171
172                refund_tx_wrapper.partial_sign(
173                    &refund_keypair,
174                    &claim_tx_details.pub_nonce,
175                    &claim_tx_details.transaction_hash,
176                )
177            }
178            Direction::Outgoing => {
179                let refund_tx_wrapper = self
180                    .new_lbtc_refund_wrapper(&Swap::Chain(swap.clone()), lockup_address)
181                    .await?;
182
183                refund_tx_wrapper.partial_sign(
184                    &refund_keypair,
185                    &claim_tx_details.pub_nonce,
186                    &claim_tx_details.transaction_hash,
187                )
188            }
189        }
190        .map_err(Into::into)
191    }
192
193    async fn get_cooperative_details(
194        &self,
195        swap_id: String,
196        pub_nonce: Option<MusigPubNonce>,
197        partial_sig: Option<MusigPartialSignature>,
198    ) -> Result<Option<Cooperative>> {
199        Ok(Some(Cooperative {
200            boltz_api: &self.get_boltz_client().await?.inner,
201            swap_id,
202            pub_nonce,
203            partial_sig,
204        }))
205    }
206
207    async fn create_claim_tx_impl(
208        &self,
209        swap: &Swap,
210        claim_address: Option<String>,
211    ) -> Result<Transaction, PaymentError> {
212        let tx = match &swap {
213            Swap::Chain(swap) => {
214                let Some(claim_address) = claim_address else {
215                    return Err(PaymentError::Generic {
216                        err: format!(
217                            "No claim address was supplied when claiming for Chain swap {}",
218                            swap.id
219                        ),
220                    });
221                };
222                match swap.direction {
223                    Direction::Incoming => Transaction::Liquid(
224                        self.new_incoming_chain_claim_tx(swap, claim_address)
225                            .await?,
226                    ),
227                    Direction::Outgoing => Transaction::Bitcoin(
228                        self.new_outgoing_chain_claim_tx(swap, claim_address)
229                            .await?,
230                    ),
231                }
232            }
233            Swap::Receive(swap) => {
234                let Some(claim_address) = claim_address else {
235                    return Err(PaymentError::Generic {
236                        err: format!(
237                            "No claim address was supplied when claiming for Receive swap {}",
238                            swap.id
239                        ),
240                    });
241                };
242                Transaction::Liquid(self.new_receive_claim_tx(swap, claim_address).await?)
243            }
244            Swap::Send(swap) => {
245                return Err(PaymentError::Generic {
246                    err: format!(
247                        "Failed to create claim tx for Send swap {}: invalid swap type",
248                        swap.id
249                    ),
250                });
251            }
252        };
253
254        Ok(tx)
255    }
256}
257
258#[sdk_macros::async_trait]
259impl<P: ProxyUrlFetcher> Swapper for BoltzSwapper<P> {
260    /// Create a new chain swap
261    async fn create_chain_swap(
262        &self,
263        req: CreateChainRequest,
264    ) -> Result<CreateChainResponse, PaymentError> {
265        let client = self.get_boltz_client().await?;
266        let modified_req = CreateChainRequest {
267            referral_id: client.referral_id.clone(),
268            ..req.clone()
269        };
270        Ok(client.inner.post_chain_req(modified_req).await?)
271    }
272
273    /// Create a new send swap
274    async fn create_send_swap(
275        &self,
276        req: CreateSubmarineRequest,
277    ) -> Result<CreateSubmarineResponse, PaymentError> {
278        let client = self.get_boltz_client().await?;
279        let modified_req = CreateSubmarineRequest {
280            referral_id: client.referral_id.clone(),
281            ..req.clone()
282        };
283        Ok(client.inner.post_swap_req(&modified_req).await?)
284    }
285
286    async fn get_chain_pair(
287        &self,
288        direction: Direction,
289    ) -> Result<Option<ChainPair>, PaymentError> {
290        let pairs = self
291            .get_boltz_client()
292            .await?
293            .inner
294            .get_chain_pairs()
295            .await?;
296        let pair = match direction {
297            Direction::Incoming => pairs.get_btc_to_lbtc_pair(),
298            Direction::Outgoing => pairs.get_lbtc_to_btc_pair(),
299        };
300        Ok(pair)
301    }
302
303    async fn get_chain_pairs(
304        &self,
305    ) -> Result<(Option<ChainPair>, Option<ChainPair>), PaymentError> {
306        let pairs = self
307            .get_boltz_client()
308            .await?
309            .inner
310            .get_chain_pairs()
311            .await?;
312        let pair_outgoing = pairs.get_lbtc_to_btc_pair();
313        let pair_incoming = pairs.get_btc_to_lbtc_pair();
314        Ok((pair_outgoing, pair_incoming))
315    }
316
317    async fn get_zero_amount_chain_swap_quote(&self, swap_id: &str) -> Result<Amount, SdkError> {
318        self.get_boltz_client()
319            .await?
320            .inner
321            .get_quote(swap_id)
322            .await
323            .map(|r| Amount::from_sat(r.amount))
324            .map_err(Into::into)
325    }
326
327    async fn accept_zero_amount_chain_swap_quote(
328        &self,
329        swap_id: &str,
330        server_lockup_sat: u64,
331    ) -> Result<(), PaymentError> {
332        self.get_boltz_client()
333            .await?
334            .inner
335            .accept_quote(swap_id, server_lockup_sat)
336            .await
337            .map_err(Into::into)
338    }
339
340    /// Get a submarine pair information
341    async fn get_submarine_pairs(&self) -> Result<Option<SubmarinePair>, PaymentError> {
342        Ok(self
343            .get_boltz_client()
344            .await?
345            .inner
346            .get_submarine_pairs()
347            .await?
348            .get_lbtc_to_btc_pair())
349    }
350
351    /// Get a submarine swap's preimage
352    async fn get_submarine_preimage(&self, swap_id: &str) -> Result<String, PaymentError> {
353        Ok(self
354            .get_boltz_client()
355            .await?
356            .inner
357            .get_submarine_preimage(swap_id)
358            .await?
359            .preimage)
360    }
361
362    /// Get claim tx details which includes the preimage as a proof of payment.
363    /// It is used to validate the preimage before claiming which is the reason why we need to separate
364    /// the claim into two steps.
365    async fn get_send_claim_tx_details(
366        &self,
367        swap: &SendSwap,
368    ) -> Result<SubmarineClaimTxResponse, PaymentError> {
369        let claim_tx_response = self
370            .get_boltz_client()
371            .await?
372            .inner
373            .get_submarine_claim_tx_details(&swap.id)
374            .await?;
375        info!("Received claim tx details: {:?}", &claim_tx_response);
376
377        self.validate_send_swap_preimage(&swap.id, &swap.invoice, &claim_tx_response.preimage)?;
378        Ok(claim_tx_response)
379    }
380
381    /// Claim send swap cooperatively. Here the remote swapper is the one that claims.
382    /// We are helping to use key spend path for cheaper fees.
383    async fn claim_send_swap_cooperative(
384        &self,
385        swap: &SendSwap,
386        claim_tx_response: SubmarineClaimTxResponse,
387        refund_address: &str,
388    ) -> Result<(), PaymentError> {
389        let swap_id = &swap.id;
390        let keypair = swap.get_refund_keypair()?;
391        let refund_tx_wrapper = self
392            .new_lbtc_refund_wrapper(&Swap::Send(swap.clone()), refund_address)
393            .await?;
394
395        let (partial_sig, pub_nonce) = refund_tx_wrapper.partial_sign(
396            &keypair,
397            &claim_tx_response.pub_nonce,
398            &claim_tx_response.transaction_hash,
399        )?;
400
401        self.get_boltz_client()
402            .await?
403            .inner
404            .post_submarine_claim_tx_details(&swap_id.to_string(), pub_nonce, partial_sig)
405            .await?;
406        info!("Successfully cooperatively claimed Send Swap {swap_id}");
407        Ok(())
408    }
409
410    // Create a new receive swap
411    async fn create_receive_swap(
412        &self,
413        req: CreateReverseRequest,
414    ) -> Result<CreateReverseResponse, PaymentError> {
415        let client = self.get_boltz_client().await?;
416        let modified_req = CreateReverseRequest {
417            referral_id: client.referral_id.clone(),
418            ..req.clone()
419        };
420        Ok(client.inner.post_reverse_req(modified_req).await?)
421    }
422
423    // Get a reverse pair information
424    async fn get_reverse_swap_pairs(&self) -> Result<Option<ReversePair>, PaymentError> {
425        Ok(self
426            .get_boltz_client()
427            .await?
428            .inner
429            .get_reverse_pairs()
430            .await?
431            .get_btc_to_lbtc_pair())
432    }
433
434    /// Create a claim transaction for a receive or chain swap
435    async fn create_claim_tx(
436        &self,
437        swap: Swap,
438        claim_address: Option<String>,
439    ) -> Result<Transaction, PaymentError> {
440        let mut attempts = 0;
441        let mut current_delay_secs = MIN_RETRY_DELAY_SECS;
442        loop {
443            match self
444                .create_claim_tx_impl(&swap, claim_address.clone())
445                .await
446            {
447                Ok(tx) => return Ok(tx),
448                Err(e) if is_concurrent_claim_error(&e) => {
449                    attempts += 1;
450                    if attempts >= MAX_RETRY_ATTEMPTS {
451                        return Err(e);
452                    }
453
454                    // Exponential backoff with jitter
455                    let jitter = rand::thread_rng().gen_range(0..=current_delay_secs);
456                    let delay_with_jitter_secs = current_delay_secs + jitter;
457
458                    warn!(
459                        "Failed to create claim tx (likely due to concurrent instance attempting \
460                        to claim), attempt {attempts}/{MAX_RETRY_ATTEMPTS}. Retrying in \
461                        {delay_with_jitter_secs}s. Error: {e:?}"
462                    );
463                    sleep(Duration::from_secs(delay_with_jitter_secs)).await;
464
465                    current_delay_secs = (current_delay_secs * 2).min(MAX_RETRY_DELAY_SECS);
466                }
467                Err(e) => return Err(e),
468            }
469        }
470    }
471
472    /// Estimate the refund broadcast transaction size and fees in sats for a send or chain swap
473    async fn estimate_refund_broadcast(
474        &self,
475        swap: Swap,
476        refund_address: &str,
477        fee_rate_sat_per_vb: Option<f64>,
478        is_cooperative: bool,
479    ) -> Result<(u32, u64), SdkError> {
480        let refund_address = &refund_address.to_string();
481        let refund_keypair = match &swap {
482            Swap::Chain(swap) => swap.get_refund_keypair()?,
483            Swap::Send(swap) => swap.get_refund_keypair()?,
484            Swap::Receive(swap) => {
485                return Err(SdkError::generic(format!(
486                    "Cannot create refund tx for Receive swap {}: invalid swap type",
487                    swap.id
488                )));
489            }
490        };
491
492        let refund_tx_size = match self.new_lbtc_refund_wrapper(&swap, refund_address).await {
493            Ok(refund_tx_wrapper) => {
494                refund_tx_wrapper.size(&refund_keypair, is_cooperative, true)?
495            }
496            Err(_) => {
497                let refund_tx_wrapper = self.new_btc_refund_wrapper(&swap, refund_address).await?;
498                refund_tx_wrapper.size(&refund_keypair, is_cooperative)?
499            }
500        } as u32;
501
502        let fee_rate_sat_per_vb = fee_rate_sat_per_vb.unwrap_or(LIQUID_FEE_RATE_SAT_PER_VBYTE);
503        let refund_tx_fees_sat = (refund_tx_size as f64 * fee_rate_sat_per_vb).ceil() as u64;
504
505        Ok((refund_tx_size, refund_tx_fees_sat))
506    }
507
508    /// Create a refund transaction for a send or chain swap
509    async fn create_refund_tx(
510        &self,
511        swap: Swap,
512        refund_address: &str,
513        utxos: Vec<Utxo>,
514        broadcast_fee_rate_sat_per_vb: Option<f64>,
515        is_cooperative: bool,
516    ) -> Result<Transaction, PaymentError> {
517        let swap_id = swap.id();
518        let refund_address = &refund_address.to_string();
519
520        let tx = match &swap {
521            Swap::Chain(chain_swap) => match chain_swap.direction {
522                Direction::Incoming => {
523                    let Some(broadcast_fee_rate_sat_per_vb) = broadcast_fee_rate_sat_per_vb else {
524                        return Err(PaymentError::generic(format!("No broadcast fee rate provided when refunding incoming Chain Swap {swap_id}")));
525                    };
526
527                    Transaction::Bitcoin(
528                        self.new_btc_refund_tx(
529                            chain_swap,
530                            refund_address,
531                            utxos,
532                            broadcast_fee_rate_sat_per_vb,
533                            is_cooperative,
534                        )
535                        .await?,
536                    )
537                }
538                Direction::Outgoing => Transaction::Liquid(
539                    self.new_lbtc_refund_tx(&swap, refund_address, utxos, is_cooperative)
540                        .await?,
541                ),
542            },
543            Swap::Send(_) => Transaction::Liquid(
544                self.new_lbtc_refund_tx(&swap, refund_address, utxos, is_cooperative)
545                    .await?,
546            ),
547            Swap::Receive(_) => {
548                return Err(PaymentError::Generic {
549                    err: format!(
550                        "Failed to create refund tx for Receive swap {swap_id}: invalid swap type",
551                    ),
552                });
553            }
554        };
555
556        Ok(tx)
557    }
558
559    async fn broadcast_tx(&self, chain: Chain, tx_hex: &str) -> Result<String, PaymentError> {
560        let response = self
561            .get_boltz_client()
562            .await?
563            .inner
564            .broadcast_tx(chain, &tx_hex.into())
565            .await?;
566        let err = format!("Unexpected response from Boltz server: {response}");
567        let tx_id = response
568            .as_object()
569            .ok_or(PaymentError::Generic { err: err.clone() })?
570            .get("id")
571            .ok_or(PaymentError::Generic { err: err.clone() })?
572            .as_str()
573            .ok_or(PaymentError::Generic { err })?
574            .to_string();
575        Ok(tx_id)
576    }
577
578    async fn check_for_mrh(&self, invoice: &str) -> Result<Option<(String, Amount)>, PaymentError> {
579        boltz_client::swaps::magic_routing::check_for_mrh(
580            &self.get_boltz_client().await?.inner,
581            invoice,
582            self.config.network.into(),
583        )
584        .await
585        .map_err(Into::into)
586    }
587
588    async fn get_bolt12_info(
589        &self,
590        req: GetBolt12FetchRequest,
591    ) -> Result<GetBolt12FetchResponse, PaymentError> {
592        let invoice_res = self
593            .get_boltz_client()
594            .await?
595            .inner
596            .get_bolt12_invoice(req)
597            .await?;
598        info!("Received BOLT12 invoice response: {invoice_res:?}");
599        Ok(invoice_res)
600    }
601
602    async fn create_bolt12_offer(&self, req: CreateBolt12OfferRequest) -> Result<(), SdkError> {
603        self.get_boltz_client()
604            .await?
605            .inner
606            .post_bolt12_offer(req)
607            .await?;
608        Ok(())
609    }
610
611    async fn update_bolt12_offer(&self, req: UpdateBolt12OfferRequest) -> Result<(), SdkError> {
612        self.get_boltz_client()
613            .await?
614            .inner
615            .patch_bolt12_offer(req)
616            .await?;
617        Ok(())
618    }
619
620    async fn delete_bolt12_offer(&self, offer: &str, signature: &str) -> Result<(), SdkError> {
621        self.get_boltz_client()
622            .await?
623            .inner
624            .delete_bolt12_offer(offer, signature)
625            .await?;
626        Ok(())
627    }
628
629    async fn get_bolt12_params(&self) -> Result<GetBolt12ParamsResponse, PaymentError> {
630        let res = self
631            .get_boltz_client()
632            .await?
633            .inner
634            .get_bolt12_params()
635            .await?;
636        Ok(res)
637    }
638
639    async fn get_nodes(&self) -> Result<GetNodesResponse, PaymentError> {
640        let res = self.get_boltz_client().await?.inner.get_nodes().await?;
641        Ok(res)
642    }
643}
644
645fn is_concurrent_claim_error(e: &PaymentError) -> bool {
646    let e_string = e.to_string();
647    e_string.contains("invalid partial signature")
648        || e_string.contains("session already initialized")
649}