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