Skip to main content

breez_sdk_liquid/swapper/boltz/
mod.rs

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