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