1use std::fmt::{self, Formatter};
2
3use async_trait::async_trait;
4use clap::Parser;
5use hyper::{header::CONTENT_TYPE, http::HeaderValue};
6use lightning_invoice::SignedRawBolt11Invoice;
7use serde::{Deserialize, Serialize};
8
9use url::Url;
10
11use super::{error::LightningError, Lightning};
12use crate::{
13 error::MokshaMintError,
14 model::{CreateInvoiceParams, CreateInvoiceResult, PayInvoiceResult},
15};
16use lightning_invoice::Bolt11Invoice as LNInvoice;
17
18#[derive(Deserialize, Serialize, Debug, Clone, Default, Parser)]
19pub struct StrikeLightningSettings {
20 #[clap(long, env = "MINT_STRIKE_API_KEY")]
21 pub api_key: Option<String>,
22}
23
24impl fmt::Display for StrikeLightningSettings {
25 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
26 write!(f, "api_key: {}", self.api_key.as_ref().unwrap(),)
27 }
28}
29
30impl StrikeLightningSettings {
31 pub fn new(api_key: &str) -> Self {
32 Self {
33 api_key: Some(api_key.to_owned()),
34 }
35 }
36}
37
38#[derive(Clone)]
39pub struct StrikeLightning {
40 pub client: StrikeClient,
41}
42
43impl StrikeLightning {
44 pub fn new(api_key: String) -> Self {
45 Self {
46 client: StrikeClient::new(&api_key).expect("Can not create Strike client"),
47 }
48 }
49}
50
51#[async_trait]
52impl Lightning for StrikeLightning {
53 async fn is_invoice_paid(&self, invoice: String) -> Result<bool, MokshaMintError> {
54 let decoded_invoice = self.decode_invoice(invoice).await?;
55 let description_hash = decoded_invoice
56 .into_signed_raw()
57 .description_hash()
58 .unwrap()
59 .0;
60
61 let invoice_id = format_as_uuid_string(&description_hash[16..]);
63
64 Ok(self.client.is_invoice_paid(&invoice_id).await?)
65 }
66
67 async fn create_invoice(&self, amount: u64) -> Result<CreateInvoiceResult, MokshaMintError> {
68 let strike_invoice_id = self
69 .client
70 .create_strike_invoice(&CreateInvoiceParams {
71 amount,
72 unit: "sat".to_string(),
73 memo: None,
74 expiry: Some(10000),
75 webhook: None,
76 internal: None,
77 })
78 .await?;
79
80 let payment_request = self.client.create_strike_quote(&strike_invoice_id).await?;
81 let invoice =
83 LNInvoice::from_signed(payment_request.parse::<SignedRawBolt11Invoice>().unwrap())
84 .unwrap();
85 let payment_hash = invoice.payment_hash().to_vec();
86
87 Ok(CreateInvoiceResult {
88 payment_hash,
89 payment_request,
90 })
91 }
92
93 async fn pay_invoice(
94 &self,
95 payment_request: String,
96 ) -> Result<PayInvoiceResult, MokshaMintError> {
97 let invoice = self.decode_invoice(payment_request.clone()).await?;
99 let payment_hash = invoice.payment_hash().to_vec();
100
101 let payment_quote_id = self
102 .client
103 .create_ln_payment_quote(&invoice.into_signed_raw().to_string())
104 .await?;
105
106 let payment_result = self
107 .client
108 .execute_ln_payment_quote(&payment_quote_id)
109 .await?;
110
111 if !payment_result {
112 return Err(MokshaMintError::PayInvoice(
113 payment_request,
114 LightningError::PaymentFailed,
115 ));
116 }
117
118 Ok(PayInvoiceResult {
119 payment_hash: hex::encode(payment_hash),
120 total_fees: 0, })
122 }
123}
124
125fn format_as_uuid_string(bytes: &[u8]) -> String {
126 let byte_str = hex::encode(bytes);
127 format!(
128 "{}-{}-{}-{}-{}",
129 &byte_str[..8],
130 &byte_str[8..12],
131 &byte_str[12..16],
132 &byte_str[16..20],
133 &byte_str[20..]
134 )
135}
136
137#[derive(Clone)]
138pub struct StrikeClient {
139 api_key: String,
140 strike_url: Url,
141 reqwest_client: reqwest::Client,
142}
143
144impl StrikeClient {
145 pub fn new(api_key: &str) -> Result<Self, LightningError> {
146 let strike_url = Url::parse("https://api.strike.me")?;
147
148 let reqwest_client = reqwest::Client::builder().build()?;
149
150 Ok(Self {
151 api_key: api_key.to_owned(),
152 strike_url,
153 reqwest_client,
154 })
155 }
156}
157
158impl StrikeClient {
159 pub async fn make_get(&self, endpoint: &str) -> Result<String, LightningError> {
160 let url = self.strike_url.join(endpoint)?;
161 let response = self
162 .reqwest_client
163 .get(url)
164 .bearer_auth(self.api_key.clone())
165 .send()
166 .await?;
167
168 if response.status() == reqwest::StatusCode::NOT_FOUND {
169 return Err(LightningError::NotFound);
170 }
171
172 Ok(response.text().await?)
173 }
174
175 pub async fn make_post(&self, endpoint: &str, body: &str) -> Result<String, LightningError> {
176 let url = self.strike_url.join(endpoint)?;
177 let response = self
178 .reqwest_client
179 .post(url)
180 .bearer_auth(self.api_key.clone())
181 .header(
182 CONTENT_TYPE,
183 HeaderValue::from_str("application/json").expect("Invalid header value"),
184 )
185 .body(body.to_string())
186 .send()
187 .await?;
188
189 if response.status() == reqwest::StatusCode::NOT_FOUND {
190 return Err(LightningError::NotFound);
191 }
192
193 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
194 return Err(LightningError::Unauthorized);
195 }
196
197 Ok(response.text().await?)
198 }
199
200 pub async fn make_patch(&self, endpoint: &str, body: &str) -> Result<String, LightningError> {
201 let url = self.strike_url.join(endpoint)?;
202 let response = self
203 .reqwest_client
204 .patch(url)
205 .bearer_auth(self.api_key.clone())
206 .header(
207 CONTENT_TYPE,
208 HeaderValue::from_str("application/json").expect("Invalid header value"),
209 )
210 .body(body.to_string())
211 .send()
212 .await?;
213
214 if response.status() == reqwest::StatusCode::NOT_FOUND {
215 return Err(LightningError::NotFound);
216 }
217
218 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
219 return Err(LightningError::Unauthorized);
220 }
221
222 Ok(response.text().await?)
223 }
224}
225
226#[derive(Debug, Serialize, Deserialize)]
227pub struct QuoteRequest {
228 #[serde(rename = "descriptionHash")]
229 pub description_hash: String,
230}
231
232impl StrikeClient {
236 pub async fn create_strike_invoice(
238 &self,
239 params: &CreateInvoiceParams,
240 ) -> Result<String, LightningError> {
241 let btc = (params.amount as f64) / 100_000_000.0;
242 let params = serde_json::json!({
243 "amount": {
244 "amount": btc,
245 "currency": "BTC"
246 },
247 "description": params.memo,
248 });
249 let body = self
250 .make_post("v1/invoices", &serde_json::to_string(¶ms)?)
251 .await?;
252
253 let response: serde_json::Value = serde_json::from_str(&body)?;
254 let invoice_id = response["invoiceId"]
255 .as_str()
256 .expect("invoiceId is empty")
257 .to_owned();
258
259 Ok(invoice_id)
260 }
261
262 pub async fn create_strike_quote(&self, invoice_id: &str) -> Result<String, LightningError> {
264 let endpoint = format!("v1/invoices/{}/quote", invoice_id);
265 let description_hash = format!(
266 "{:0>64}",
267 hex::encode(hex::decode(invoice_id.replace('-', "").as_bytes()).unwrap())
268 );
269 let params = QuoteRequest { description_hash };
270 let body = self
271 .make_post(&endpoint, &serde_json::to_string(¶ms)?)
272 .await?;
273 let response: serde_json::Value = serde_json::from_str(&body)?;
274 let payment_request = response["lnInvoice"]
275 .as_str()
276 .expect("lnInvoice is empty")
277 .to_owned();
278
279 Ok(payment_request)
280 }
281
282 pub async fn create_ln_payment_quote(&self, bolt11: &str) -> Result<String, LightningError> {
283 let params = serde_json::json!({
284 "lnInvoice": bolt11,
285 "sourceCurrency": "BTC",
286 });
287 let body = self
288 .make_post(
289 "v1/payment-quotes/lightning",
290 &serde_json::to_string(¶ms)?,
291 )
292 .await?;
293 let response: serde_json::Value = serde_json::from_str(&body)?;
294 let payment_quote_id = response["paymentQuoteId"]
295 .as_str()
296 .expect("paymentQuoteId is empty")
297 .to_owned();
298
299 Ok(payment_quote_id)
300 }
301
302 pub async fn execute_ln_payment_quote(&self, quote_id: &str) -> Result<bool, LightningError> {
303 let endpoint = format!("v1/payment-quotes/{}/execute", quote_id);
304 let body = self
305 .make_patch(&endpoint, &serde_json::to_string(&serde_json::json!({}))?)
306 .await?;
307 let response: serde_json::Value = serde_json::from_str(&body)?;
308
309 Ok(response["state"].as_str().unwrap_or("") == "COMPLETED")
310 }
311
312 pub async fn is_invoice_paid(&self, invoice_id: &str) -> Result<bool, LightningError> {
313 let body = self.make_get(&format!("v1/invoices/{invoice_id}")).await?;
314 let response = serde_json::from_str::<serde_json::Value>(&body)?;
315
316 Ok(response["state"].as_str().unwrap_or("") == "PAID")
317 }
318}