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