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