1use std::collections::HashMap;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use serde_json::to_string_pretty;
6
7use const_format::concatcp;
8use sdk_common::prelude::*;
9use serde_json::json;
10
11use crate::bitcoin::Txid;
12use crate::models::ReverseSwapPairInfo;
13use crate::swap_out::reverseswap::CreateReverseSwapResponse;
14use crate::{ReverseSwapServiceAPI, RouteHint, RouteHintHop};
15
16use super::error::{ReverseSwapError, ReverseSwapResult};
17
18const BOLTZ_API_URL: &str = "https://api.boltz.exchange/";
19const GET_PAIRS_ENDPOINT: &str = concatcp!(BOLTZ_API_URL, "getpairs");
20const GET_SWAP_STATUS_ENDPOINT: &str = concatcp!(BOLTZ_API_URL, "swapstatus");
21const GET_ROUTE_HINTS_ENDPOINT: &str = concatcp!(BOLTZ_API_URL, "routinghints");
22pub(crate) const CREATE_REVERSE_SWAP_ENDPOINT: &str = concatcp!(BOLTZ_API_URL, "createswap");
23
24#[derive(Debug, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26struct BoltzRouteHintHop {
27 node_id: String,
28 chan_id: String,
29 fee_base_msat: u32,
30 fee_proportional_millionths: u32,
31 cltv_expiry_delta: u64,
32}
33
34#[derive(Debug, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36struct BoltzRoute {
37 hop_hints_list: Vec<BoltzRouteHintHop>,
38}
39
40impl From<BoltzRoute> for RouteHint {
41 fn from(value: BoltzRoute) -> Self {
42 RouteHint {
43 hops: value
44 .hop_hints_list
45 .into_iter()
46 .map(|hop| hop.into())
47 .collect(),
48 }
49 }
50}
51
52#[derive(Debug, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub(crate) struct BoltzRouteHints {
55 routing_hints: Vec<BoltzRoute>,
56}
57
58impl From<BoltzRouteHintHop> for RouteHintHop {
59 fn from(value: BoltzRouteHintHop) -> Self {
60 RouteHintHop {
61 src_node_id: value.node_id,
62 short_channel_id: "0x0x0".to_string(),
63 fees_base_msat: value.fee_base_msat,
64 fees_proportional_millionths: value.fee_proportional_millionths,
65 cltv_expiry_delta: value.cltv_expiry_delta,
66 htlc_minimum_msat: None,
67 htlc_maximum_msat: None,
68 }
69 }
70}
71
72impl From<BoltzRouteHints> for Vec<RouteHint> {
73 fn from(value: BoltzRouteHints) -> Self {
74 value
75 .routing_hints
76 .into_iter()
77 .map(|hop| hop.into())
78 .collect()
79 }
80}
81
82#[derive(Debug, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84struct Post {
85 id: Option<i32>,
86 title: String,
87 body: String,
88 user_id: i32,
89}
90
91#[derive(Debug, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93struct MaximalZeroConf {
94 base_asset: u64,
95 quote_asset: u64,
96}
97
98#[derive(Debug, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100struct Limits {
101 maximal: u64,
102 minimal: u64,
103 maximal_zero_conf: MaximalZeroConf,
104}
105
106#[derive(Debug, Serialize, Deserialize)]
107struct ReverseFeesAsset {
108 lockup: u64,
109 claim: u64,
110}
111
112#[derive(Debug, Serialize, Deserialize)]
113struct FeesAsset {
114 normal: u64,
115 reverse: ReverseFeesAsset,
116}
117
118#[derive(Debug, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120struct MinerFees {
121 base_asset: FeesAsset,
122 quote_asset: FeesAsset,
123}
124
125#[derive(Debug, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase")]
127struct Fees {
128 percentage: f64,
129 miner_fees: MinerFees,
130}
131
132#[derive(Debug, Serialize, Deserialize)]
133struct Pair {
134 rate: f64,
135 hash: String,
136 limits: Limits,
137 fees: Fees,
138}
139
140#[derive(Debug, Serialize, Deserialize)]
141struct Pairs {
142 warnings: Vec<String>,
143 info: Vec<String>,
144 pairs: HashMap<String, Pair>,
145}
146
147#[derive(Clone, Serialize, Deserialize, Debug)]
148#[serde(untagged)]
149pub(crate) enum BoltzApiCreateReverseSwapResponse {
150 BoltzApiSuccess(CreateReverseSwapResponse),
152
153 BoltzApiError { error: String },
155}
156
157#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
159pub struct LockTxData {
160 id: Txid,
161 hex: String,
162 eta: Option<u32>,
163}
164
165#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
181#[serde(tag = "status")]
182pub enum BoltzApiReverseSwapStatus {
183 #[serde(rename = "swap.created")]
186 SwapCreated,
187
188 #[serde(rename = "swap.expired")]
190 SwapExpired,
191
192 #[serde(rename = "transaction.mempool")]
194 LockTxMempool { transaction: LockTxData },
195
196 #[serde(rename = "transaction.confirmed")]
198 LockTxConfirmed { transaction: LockTxData },
199
200 #[serde(rename = "transaction.failed")]
203 LockTxFailed,
204
205 #[serde(rename = "transaction.refunded")]
208 #[serde(rename_all = "camelCase")]
209 LockTxRefunded { failure_reason: String },
210
211 #[serde(rename = "invoice.settled")]
213 InvoiceSettled,
214
215 #[serde(rename = "invoice.expired")]
216 InvoiceExpired,
217}
218
219pub struct BoltzApi {
220 rest_client: Arc<dyn RestClient>,
221}
222
223impl BoltzApi {
224 pub fn new(rest_client: Arc<dyn RestClient>) -> Self {
225 BoltzApi { rest_client }
226 }
227
228 pub async fn reverse_swap_pair_info(&self) -> ReverseSwapResult<ReverseSwapPairInfo> {
229 let (response, _) =
230 get_and_check_success(self.rest_client.as_ref(), GET_PAIRS_ENDPOINT).await?;
231 let pairs: Pairs = parse_json(&response)?;
232 match pairs.pairs.get("BTC/BTC") {
233 None => Err(ReverseSwapError::generic("BTC pair not found")),
234 Some(btc_pair) => {
235 debug!(
236 "Boltz API pair: {}",
237 serde_json::to_string_pretty(&btc_pair)?
238 );
239 let hash = String::from(&btc_pair.hash);
240 Ok(ReverseSwapPairInfo {
241 fees_hash: hash,
242 min: btc_pair.limits.minimal,
243 max: btc_pair.limits.maximal,
244 fees_percentage: btc_pair.fees.percentage,
245 fees_lockup: btc_pair.fees.miner_fees.base_asset.reverse.lockup,
246 fees_claim: btc_pair.fees.miner_fees.base_asset.reverse.claim,
247 total_fees: None,
248 })
249 }
250 }
251 }
252}
253
254#[tonic::async_trait]
255impl ReverseSwapServiceAPI for BoltzApi {
256 async fn fetch_reverse_swap_fees(&self) -> ReverseSwapResult<ReverseSwapPairInfo> {
257 self.reverse_swap_pair_info().await
258 }
259
260 async fn create_reverse_swap_on_remote(
270 &self,
271 amount_sat: u64,
272 preimage_hash_hex: String,
273 claim_pubkey: String,
274 pair_hash: String,
275 routing_node: String,
276 ) -> ReverseSwapResult<BoltzApiCreateReverseSwapResponse> {
277 let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
278 let body = build_boltz_reverse_swap_args(
279 amount_sat,
280 preimage_hash_hex,
281 pair_hash.clone(),
282 claim_pubkey.clone(),
283 routing_node,
284 );
285 self.rest_client
286 .post(CREATE_REVERSE_SWAP_ENDPOINT, Some(headers), Some(body)).await.map_err(|e| {
287 ReverseSwapError::ServiceConnectivity(format!(
288 "(Boltz {CREATE_REVERSE_SWAP_ENDPOINT}) Failed to request creation of reverse swap: {e}"
289 ))
290 })
291 .and_then(|(response, _)| {
292 trace!("Boltz API create raw response {}", to_string_pretty(&response)?);
293 serde_json::from_str::<BoltzApiCreateReverseSwapResponse>(&response).map_err(|e| {
294 ReverseSwapError::ServiceConnectivity(format!(
295 "(Boltz {CREATE_REVERSE_SWAP_ENDPOINT}) Failed to parse create swap response: {e}"
296 ))
297 })
298 })
299 }
300
301 async fn get_boltz_status(&self, id: String) -> ReverseSwapResult<BoltzApiReverseSwapStatus> {
311 let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
312 let body = json!({ "id": id }).to_string();
313 self.rest_client
314 .post(GET_SWAP_STATUS_ENDPOINT, Some(headers), Some(body)).await.map_err(|e| {
315 ReverseSwapError::ServiceConnectivity(format!(
316 "(Boltz {GET_SWAP_STATUS_ENDPOINT}) Failed to request swap status: {e}"
317 ))
318 })
319 .and_then(|(response, _)| {
320 trace!("Boltz API status raw response {}", to_string_pretty(&response)?);
321 serde_json::from_str::<BoltzApiReverseSwapStatus>(&response).map_err(|e| {
322 ReverseSwapError::ServiceConnectivity(format!(
323 "(Boltz {GET_SWAP_STATUS_ENDPOINT}) Failed to parse get status response: {e}"
324 ))
325 })
326 })
327 }
328
329 async fn get_route_hints(&self, routing_node_id: String) -> ReverseSwapResult<Vec<RouteHint>> {
330 let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
331 let body = json!({ "routingNode": routing_node_id, "symbol": "BTC" }).to_string();
332 self.rest_client
333 .post(GET_ROUTE_HINTS_ENDPOINT, Some(headers), Some(body)).await
334 .map_err(|e| {
335 ReverseSwapError::ServiceConnectivity(format!(
336 "(Boltz {GET_ROUTE_HINTS_ENDPOINT}) Failed to get routing hints: {e}"
337 ))
338 })
339 .and_then(|(response, _)| {
340 trace!(
341 "Boltz API routinghints raw response {}",
342 to_string_pretty(&response)?
343 );
344 serde_json::from_str::<BoltzRouteHints>(&response)
345 .map_err(|e| {
346 ReverseSwapError::ServiceConnectivity(format!(
347 "(Boltz {GET_ROUTE_HINTS_ENDPOINT}) Failed to parse get route hints response: {e}"
348 ))
349 })
350 })
351 .map(Into::into)
352 }
353}
354
355fn build_boltz_reverse_swap_args(
356 amount_sat: u64,
357 preimage_hash_hex: String,
358 pair_hash: String,
359 claim_pubkey: String,
360 routing_node: String,
361) -> String {
362 json!({
363 "type": "reversesubmarine",
364 "pairId": "BTC/BTC",
365 "orderSide": "buy",
366 "invoiceAmount": amount_sat,
367 "preimageHash": preimage_hash_hex,
368 "pairHash": pair_hash,
369 "claimPublicKey": claim_pubkey,
370 "routingNode": routing_node
371 })
372 .to_string()
373}
374
375#[cfg(test)]
376mod tests {
377 use std::str::FromStr;
378
379 use crate::bitcoin::Txid;
380 use crate::swap_out::boltzswap::{BoltzApiReverseSwapStatus, LockTxData};
381
382 #[test]
383 fn test_boltz_status_deserialize() {
384 assert!(matches!(
385 serde_json::from_str(
386 r#"
387 {
388 "status": "swap.created"
389 }"#
390 ),
391 Ok(BoltzApiReverseSwapStatus::SwapCreated)
392 ));
393
394 let id = Txid::from_str("71aa5902960e453491c4531f26d3602ae31af220dbb1d86d0ec4fa6056ab77b7")
395 .unwrap();
396 let hex: String = "0100000000010177c9bf7b1a206d1e4ceb48d1d9efd8de4d66e1e4bf1b3db85cb73f6c6782e0c30000000000ffffffff02cfae000000000000220020befd7d08cf438d51f20879d1d9ef50e53abcd769ccb11a61adcf4207224c19926c8f2c010000000022512053f1fd711325372f39603d6f2be048a39333c9bddd57de3c03a30687d759694801405c5ab7ddbbffaffc255477bedacbad2db2061efa7fea7659430e35107bb8e8fad535b1dfd8816d52a3a336e277e137f328d23383bdb275839af5fe554ea3247b00000000".into();
397 assert!(matches!(
398 serde_json::from_str(
399 r#"
400 {
401 "status":"transaction.mempool",
402 "transaction":
403 {
404 "id":"71aa5902960e453491c4531f26d3602ae31af220dbb1d86d0ec4fa6056ab77b7",
405 "hex":"0100000000010177c9bf7b1a206d1e4ceb48d1d9efd8de4d66e1e4bf1b3db85cb73f6c6782e0c30000000000ffffffff02cfae000000000000220020befd7d08cf438d51f20879d1d9ef50e53abcd769ccb11a61adcf4207224c19926c8f2c010000000022512053f1fd711325372f39603d6f2be048a39333c9bddd57de3c03a30687d759694801405c5ab7ddbbffaffc255477bedacbad2db2061efa7fea7659430e35107bb8e8fad535b1dfd8816d52a3a336e277e137f328d23383bdb275839af5fe554ea3247b00000000",
406 "eta":2
407 }
408 }"#
409 ),
410 Ok(BoltzApiReverseSwapStatus::LockTxMempool {
411 transaction: LockTxData {
412 id: id_temp,
413 hex: hex_temp,
414 eta: Some(2)
415 }
416 })
417 if id_temp == id && hex_temp == hex
418 ));
419
420 assert!(matches!(
421 serde_json::from_str(
422 r#"
423 {
424 "status":"transaction.confirmed",
425 "transaction":
426 {
427 "id":"71aa5902960e453491c4531f26d3602ae31af220dbb1d86d0ec4fa6056ab77b7",
428 "hex":"0100000000010177c9bf7b1a206d1e4ceb48d1d9efd8de4d66e1e4bf1b3db85cb73f6c6782e0c30000000000ffffffff02cfae000000000000220020befd7d08cf438d51f20879d1d9ef50e53abcd769ccb11a61adcf4207224c19926c8f2c010000000022512053f1fd711325372f39603d6f2be048a39333c9bddd57de3c03a30687d759694801405c5ab7ddbbffaffc255477bedacbad2db2061efa7fea7659430e35107bb8e8fad535b1dfd8816d52a3a336e277e137f328d23383bdb275839af5fe554ea3247b00000000"
429 }
430 }"#
431 ),
432 Ok(BoltzApiReverseSwapStatus::LockTxConfirmed {
433 transaction: LockTxData { id: id_temp, hex: hex_temp, eta: None }
434 })
435 if id_temp == id && hex_temp == hex
436 ));
437
438 let failure_reason : String = "refunded onchain coins: 71aa5902960e453491c4531f26d3602ae31af220dbb1d86d0ec4fa6056ab77b7".into();
439 assert!(matches!(
440 serde_json::from_str(
441 r#"
442 {
443 "status":"transaction.refunded",
444 "failureReason":"refunded onchain coins: 71aa5902960e453491c4531f26d3602ae31af220dbb1d86d0ec4fa6056ab77b7"
445 }"#
446 ),
447 Ok(BoltzApiReverseSwapStatus::LockTxRefunded { failure_reason: fr }) if fr == failure_reason
448 ));
449 }
450}