From 1f6be6d304410575cefde196cf6677d9e9d63d2c Mon Sep 17 00:00:00 2001 From: "kody.low" Date: Sun, 17 Sep 2023 16:55:28 -0700 Subject: [PATCH] feat: add alby client for lightning adds alby client for lightning backend --- .env.example | 2 + moksha-mint/src/alby.rs | 144 +++++++++++++++++++++++++++++ moksha-mint/src/bin/moksha-mint.rs | 10 +- moksha-mint/src/error.rs | 10 +- moksha-mint/src/lib.rs | 7 +- moksha-mint/src/lightning.rs | 105 ++++++++++++++++++--- moksha-mint/src/lnbits.rs | 23 +---- moksha-mint/src/mint.rs | 5 +- moksha-mint/src/model.rs | 21 +++++ 9 files changed, 284 insertions(+), 43 deletions(-) create mode 100644 moksha-mint/src/alby.rs diff --git a/.env.example b/.env.example index 3dc53683..62907fa6 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,8 @@ MINT_LIGHTNING_BACKEND=Lnbits LNBITS_URL=https://legend.lnbits.com LNBITS_ADMIN_KEY=YOUR_ADMIN_KEY +ALBY_API_KEY=YOUR_API_KEY + # absolute path to the lnd macaroon file LND_MACAROON_PATH="/.../admin.macaroon" # absolute path to the tls certificate diff --git a/moksha-mint/src/alby.rs b/moksha-mint/src/alby.rs new file mode 100644 index 00000000..86918adb --- /dev/null +++ b/moksha-mint/src/alby.rs @@ -0,0 +1,144 @@ +use hyper::{header::CONTENT_TYPE, http::HeaderValue}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::model::{CreateInvoiceParams, CreateInvoiceResult, PayInvoiceResult}; + +#[derive(Debug, thiserror::Error)] +pub enum AlbyError { + #[error("reqwest error: {0}")] + ReqwestError(#[from] reqwest::Error), + + #[error("url error: {0}")] + UrlError(#[from] url::ParseError), + + #[error("serde error: {0}")] + SerdeError(#[from] serde_json::Error), + + #[error("Not found")] + NotFound, + + #[error("Unauthorized")] + Unauthorized, +} + +#[derive(Clone)] +pub struct AlbyClient { + api_key: String, + alby_url: Url, + reqwest_client: reqwest::Client, +} + +impl AlbyClient { + pub fn new(api_key: &str) -> Result { + let alby_url = Url::parse("https://api.getalby.com")?; + + let reqwest_client = reqwest::Client::builder().build()?; + + Ok(AlbyClient { + api_key: api_key.to_owned(), + alby_url, + reqwest_client, + }) + } +} + +impl AlbyClient { + pub async fn make_get(&self, endpoint: &str) -> Result { + let url = self.alby_url.join(endpoint)?; + let response = self + .reqwest_client + .get(url) + .bearer_auth(self.api_key.clone()) + .send() + .await?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Err(AlbyError::NotFound); + } + + Ok(response.text().await?) + } + + pub async fn make_post(&self, endpoint: &str, body: &str) -> Result { + let url = self.alby_url.join(endpoint)?; + let response = self + .reqwest_client + .post(url) + .bearer_auth(self.api_key.clone()) + .header( + CONTENT_TYPE, + HeaderValue::from_str("application/json").expect("Invalid header value"), + ) + .body(body.to_string()) + .send() + .await?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Err(AlbyError::NotFound); + } + + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(AlbyError::Unauthorized); + } + + Ok(response.text().await?) + } +} + +impl AlbyClient { + pub async fn create_invoice( + &self, + params: &CreateInvoiceParams, + ) -> Result { + let params = serde_json::json!({ + "amount": params.amount, + "description": params.memo, + }); + + let body = self + .make_post("invoices", &serde_json::to_string(¶ms)?) + .await?; + + let response: serde_json::Value = serde_json::from_str(&body)?; + let payment_request = response["payment_request"] + .as_str() + .expect("payment_request is empty") + .to_owned(); + let payment_hash = response["payment_hash"] + .as_str() + .expect("payment_hash is empty") + .to_owned(); + + Ok(CreateInvoiceResult { + payment_hash: payment_hash.as_bytes().to_vec(), + payment_request, + }) + } + + pub async fn pay_invoice(&self, bolt11: &str) -> Result { + let body = self + .make_post( + "payments/bolt11", + &serde_json::to_string(&serde_json::json!({"invoice": bolt11 }))?, + ) + .await?; + + let response: serde_json::Value = serde_json::from_str(&body)?; + + Ok(PayInvoiceResult { + payment_hash: response["payment_hash"] + .as_str() + .expect("payment_hash is empty") + .to_owned(), + }) + } + + pub async fn is_invoice_paid(&self, payment_hash: &str) -> Result { + let body = self.make_get(&format!("invoices/{payment_hash}")).await?; + + Ok(serde_json::from_str::(&body)?["settled"] + .as_bool() + .unwrap_or(false)) + } +} diff --git a/moksha-mint/src/bin/moksha-mint.rs b/moksha-mint/src/bin/moksha-mint.rs index 1ba8e051..2c5c2436 100644 --- a/moksha-mint/src/bin/moksha-mint.rs +++ b/moksha-mint/src/bin/moksha-mint.rs @@ -1,6 +1,8 @@ use mokshamint::{ info::MintInfoSettings, - lightning::{LightningType, LnbitsLightningSettings, LndLightningSettings}, + lightning::{ + AlbyLightningSettings, LightningType, LnbitsLightningSettings, LndLightningSettings, + }, MintBuilder, }; use std::{env, fmt, net::SocketAddr, path::PathBuf}; @@ -44,6 +46,12 @@ pub async fn main() -> anyhow::Result<()> { .expect("Please provide lnd info"); LightningType::Lnd(lnd_settings) } + "Alby" => { + let alby_settings = envy::prefixed("ALBY_") + .from_env::() + .expect("Please provide alby info"); + LightningType::Alby(alby_settings) + } _ => panic!( "env MINT_LIGHTNING_BACKEND not found or invalid values. Valid values are Lnbits and Lnd" ), diff --git a/moksha-mint/src/error.rs b/moksha-mint/src/error.rs index 009b2ce3..739d897f 100644 --- a/moksha-mint/src/error.rs +++ b/moksha-mint/src/error.rs @@ -11,7 +11,7 @@ use thiserror::Error; use tonic_lnd::ConnectError; use tracing::{event, Level}; -use crate::lnbits::LNBitsError; +use crate::{alby::AlbyError, lnbits::LNBitsError}; #[derive(Error, Debug)] pub enum MokshaMintError { @@ -24,6 +24,9 @@ pub enum MokshaMintError { #[error("Failed to pay invoice {0} - Error {1}")] PayInvoice(String, LNBitsError), + #[error("Failed to pay invoice {0} - Error {1}")] + PayInvoiceAlby(String, AlbyError), + #[error("DB Error {0}")] Db(#[from] rocksdb::Error), @@ -58,7 +61,10 @@ pub enum MokshaMintError { InvalidAmount, #[error("Lightning Error {0}")] - Lightning(#[from] LNBitsError), + LightningLNBits(#[from] LNBitsError), + + #[error("Lightning Error {0}")] + LightningAlby(#[from] AlbyError), } impl IntoResponse for MokshaMintError { diff --git a/moksha-mint/src/lib.rs b/moksha-mint/src/lib.rs index 10500cea..d04c380a 100644 --- a/moksha-mint/src/lib.rs +++ b/moksha-mint/src/lib.rs @@ -13,7 +13,7 @@ use error::MokshaMintError; use hyper::http::{HeaderName, HeaderValue}; use hyper::Method; use info::{MintInfoResponse, MintInfoSettings, Parameter}; -use lightning::{Lightning, LightningType, LnbitsLightning}; +use lightning::{AlbyLightning, Lightning, LightningType, LnbitsLightning}; use mint::{LightningFeeConfig, Mint}; use model::{GetMintQuery, PostMintQuery}; use moksha_core::model::{ @@ -33,6 +33,7 @@ use tracing::{event, info, Level}; use tracing_subscriber::prelude::__tracing_subscriber_SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; +mod alby; mod database; mod error; pub mod info; @@ -88,7 +89,9 @@ impl MintBuilder { lnbits_settings.admin_key.expect("LNBITS_ADMIN_KEY not set"), lnbits_settings.url.expect("LNBITS_URL not set"), )), - + Some(LightningType::Alby(alby_settings)) => Arc::new(AlbyLightning::new( + alby_settings.api_key.expect("ALBY_API_KEY not set"), + )), Some(LightningType::Lnd(lnd_settings)) => Arc::new( lightning::LndLightning::new( lnd_settings.grpc_host.expect("LND_GRPC_HOST not set"), diff --git a/moksha-mint/src/lightning.rs b/moksha-mint/src/lightning.rs index 1dccc949..1fbeb021 100644 --- a/moksha-mint/src/lightning.rs +++ b/moksha-mint/src/lightning.rs @@ -8,8 +8,10 @@ use url::Url; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::{ + alby::AlbyClient, error::MokshaMintError, - lnbits::{CreateInvoiceParams, CreateInvoiceResult, LNBitsClient, PayInvoiceResult}, + lnbits::LNBitsClient, + model::{CreateInvoiceParams, CreateInvoiceResult, PayInvoiceResult}, }; use lightning_invoice::Bolt11Invoice as LNInvoice; @@ -21,6 +23,7 @@ use std::{path::PathBuf, str::FromStr, sync::Arc}; #[derive(Debug, Clone)] pub enum LightningType { Lnbits(LnbitsLightningSettings), + Alby(AlbyLightningSettings), Lnd(LndLightningSettings), } @@ -28,11 +31,38 @@ impl fmt::Display for LightningType { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { LightningType::Lnbits(settings) => write!(f, "Lnbits: {}", settings), + LightningType::Alby(settings) => write!(f, "Alby: {}", settings), LightningType::Lnd(settings) => write!(f, "Lnd: {}", settings), } } } +#[cfg_attr(test, automock)] +#[allow(implied_bounds_entailment)] +#[async_trait] +pub trait Lightning: Send + Sync { + async fn is_invoice_paid(&self, invoice: String) -> Result; + async fn create_invoice(&self, amount: u64) -> Result; + async fn pay_invoice( + &self, + payment_request: String, + ) -> Result; + + async fn decode_invoice(&self, payment_request: String) -> Result { + LNInvoice::from_str(&payment_request) + .map_err(|err| MokshaMintError::DecodeInvoice(payment_request, err)) + } +} + +impl LnbitsLightning { + pub fn new(admin_key: String, url: String) -> Self { + Self { + client: LNBitsClient::new(&admin_key, &url, None) + .expect("Can not create Lnbits client"), + } + } +} + #[derive(Clone)] pub struct LnbitsLightning { pub client: LNBitsClient, @@ -64,33 +94,77 @@ impl LnbitsLightningSettings { } } -#[cfg_attr(test, automock)] +#[allow(implied_bounds_entailment)] #[async_trait] -pub trait Lightning: Send + Sync { - async fn is_invoice_paid(&self, invoice: String) -> Result; - async fn create_invoice(&self, amount: u64) -> Result; +impl Lightning for LnbitsLightning { + async fn is_invoice_paid(&self, invoice: String) -> Result { + let decoded_invoice = self.decode_invoice(invoice).await?; + Ok(self + .client + .is_invoice_paid(&decoded_invoice.payment_hash().to_string()) + .await?) + } + + async fn create_invoice(&self, amount: u64) -> Result { + Ok(self + .client + .create_invoice(&CreateInvoiceParams { + amount, + unit: "sat".to_string(), + memo: None, + expiry: Some(10000), + webhook: None, + internal: None, + }) + .await?) + } + async fn pay_invoice( &self, payment_request: String, - ) -> Result; + ) -> Result { + self.client + .pay_invoice(&payment_request) + .await + .map_err(|err| MokshaMintError::PayInvoice(payment_request, err)) + } +} - async fn decode_invoice(&self, payment_request: String) -> Result { - LNInvoice::from_str(&payment_request) - .map_err(|err| MokshaMintError::DecodeInvoice(payment_request, err)) +#[derive(Clone)] +pub struct AlbyLightning { + pub client: AlbyClient, +} + +#[derive(Deserialize, Serialize, Debug, Clone, Default)] +pub struct AlbyLightningSettings { + pub api_key: Option, +} + +impl fmt::Display for AlbyLightningSettings { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "api_key: {}", self.api_key.as_ref().unwrap(),) } } -impl LnbitsLightning { - pub fn new(admin_key: String, url: String) -> Self { +impl AlbyLightningSettings { + pub fn new(api_key: &str) -> Self { Self { - client: LNBitsClient::new(&admin_key, &url, None) - .expect("Can not create Lnbits client"), + api_key: Some(api_key.to_owned()), + } + } +} + +impl AlbyLightning { + pub fn new(api_key: String) -> Self { + Self { + client: AlbyClient::new(&api_key).expect("Can not create Alby client"), } } } +#[allow(implied_bounds_entailment)] #[async_trait] -impl Lightning for LnbitsLightning { +impl Lightning for AlbyLightning { async fn is_invoice_paid(&self, invoice: String) -> Result { let decoded_invoice = self.decode_invoice(invoice).await?; Ok(self @@ -120,7 +194,7 @@ impl Lightning for LnbitsLightning { self.client .pay_invoice(&payment_request) .await - .map_err(|err| MokshaMintError::PayInvoice(payment_request, err)) + .map_err(|err| MokshaMintError::PayInvoiceAlby(payment_request, err)) } } @@ -195,6 +269,7 @@ impl LndLightning { } } +#[allow(implied_bounds_entailment)] #[async_trait] impl Lightning for LndLightning { async fn is_invoice_paid(&self, payment_request: String) -> Result { diff --git a/moksha-mint/src/lnbits.rs b/moksha-mint/src/lnbits.rs index 6ea27b7c..9f323d83 100644 --- a/moksha-mint/src/lnbits.rs +++ b/moksha-mint/src/lnbits.rs @@ -2,6 +2,8 @@ use hyper::{header::CONTENT_TYPE, http::HeaderValue}; use serde::{Deserialize, Serialize}; use url::Url; +use crate::model::{CreateInvoiceParams, CreateInvoiceResult, PayInvoiceResult}; + #[derive(Debug, thiserror::Error)] pub enum LNBitsError { #[error("reqwest error: {0}")] @@ -95,27 +97,6 @@ impl LNBitsClient { } } -#[derive(Debug, Serialize, Deserialize)] -pub struct CreateInvoiceResult { - pub payment_hash: Vec, - pub payment_request: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct PayInvoiceResult { - pub payment_hash: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct CreateInvoiceParams { - pub amount: u64, - pub unit: String, - pub memo: Option, - pub expiry: Option, - pub webhook: Option, - pub internal: Option, -} - impl LNBitsClient { pub async fn create_invoice( &self, diff --git a/moksha-mint/src/mint.rs b/moksha-mint/src/mint.rs index 98750bb6..0b9fc779 100644 --- a/moksha-mint/src/mint.rs +++ b/moksha-mint/src/mint.rs @@ -244,8 +244,8 @@ impl Mint { #[cfg(test)] mod tests { use crate::lightning::{LightningType, MockLightning}; - use crate::lnbits::PayInvoiceResult; - use crate::model::Invoice; + use crate::lnbits::LNBitsError; + use crate::model::{Invoice, PayInvoiceResult}; use crate::{database::MockDatabase, error::MokshaMintError, Mint}; use moksha_core::dhke; use moksha_core::model::{BlindedMessage, TokenV3, TotalAmount}; @@ -411,6 +411,7 @@ mod tests { Ok(PayInvoiceResult { payment_hash: "hash".to_string(), }) + .map_err(|_err: LNBitsError| MokshaMintError::InvoiceNotFound("".to_string())) }); let mint = Mint::new( diff --git a/moksha-mint/src/model.rs b/moksha-mint/src/model.rs index 0d580344..38939638 100644 --- a/moksha-mint/src/model.rs +++ b/moksha-mint/src/model.rs @@ -24,3 +24,24 @@ impl Invoice { } } } + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateInvoiceResult { + pub payment_hash: Vec, + pub payment_request: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PayInvoiceResult { + pub payment_hash: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateInvoiceParams { + pub amount: u64, + pub unit: String, + pub memo: Option, + pub expiry: Option, + pub webhook: Option, + pub internal: Option, +}