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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}