mokshamint/lightning/
strike.rs

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        // invoiceId is the last 16 bytes of the description hash
62        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        // strike doesn't return the payment_hash so we have to read the invoice into a Bolt11 and extract it
82        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        // strike doesn't return the payment_hash so we have to read the invoice into a Bolt11 and extract it
98        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, // FIXME return fees for strike
121        })
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
232// strike has a 2 step process for getting a lightning invoice
233// 1. create an "invoice" which on their platform means a currency agnostic payment request
234// 2. generate a "quote" for the invoice which is a specific quoted conversion rate and a lightning invoice
235impl StrikeClient {
236    // this is not a lightning invoice, it's the strike internal representation of an invoice
237    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(&params)?)
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    // this is how you get the actual lightning invoice
263    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(&params)?)
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(&params)?,
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}