diff --git a/.env.example b/.env.example index 08e7c797..94857b47 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,12 @@ LND_TLS_CERT_PATH="/../tls.cert" LND_GRPC_HOST="https://localhost:10004" +STABLESATS_AUTH_BEARER=YOUR_AUTH_BEARER +STABLESATS_GALOY_URL=YOUR_GALOY_URL +STABLESATS_USD_WALLET_ID=YOUR_USD_WALLET_ID + + + ### environment variables for the fedimint-cli CLI_FEDIMINT_CONNECTION="fed...." \ No newline at end of file diff --git a/moksha-core/src/model.rs b/moksha-core/src/model.rs index 4d3d11c6..f8d03eda 100644 --- a/moksha-core/src/model.rs +++ b/moksha-core/src/model.rs @@ -438,6 +438,11 @@ pub struct CashuErrorResponse { pub error: String, } +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct InvoiceQuoteResult { + pub amount_in_cent: u64, +} + #[cfg(test)] mod tests { use crate::{ diff --git a/moksha-mint/src/bin/moksha-mint.rs b/moksha-mint/src/bin/moksha-mint.rs index 3e694290..b71e21cc 100644 --- a/moksha-mint/src/bin/moksha-mint.rs +++ b/moksha-mint/src/bin/moksha-mint.rs @@ -1,8 +1,8 @@ use mokshamint::{ info::MintInfoSettings, lightning::{ - AlbyLightningSettings, LightningType, LnbitsLightningSettings, LndLightningSettings, - StrikeLightningSettings, + stablesats::StablesatsSettings, AlbyLightningSettings, LightningType, + LnbitsLightningSettings, LndLightningSettings, StrikeLightningSettings, }, MintBuilder, }; @@ -58,6 +58,12 @@ pub async fn main() -> anyhow::Result<()> { .from_env::() .expect("Please provide strike info"); LightningType::Strike(strike_settings) + }, + "Stablesats" => { + let settings = envy::prefixed("STABLESATS_") + .from_env::() + .expect("Please provide stablesats info"); + LightningType::Stablesats(settings) } _ => panic!( "env MINT_LIGHTNING_BACKEND not found or invalid values. Valid values are Lnbits, Lnd, Alby, and Strike" diff --git a/moksha-mint/src/error.rs b/moksha-mint/src/error.rs index ad4c71e9..12aa812a 100644 --- a/moksha-mint/src/error.rs +++ b/moksha-mint/src/error.rs @@ -24,6 +24,9 @@ pub enum MokshaMintError { #[error("Failed to pay invoice {0} - Error {1}")] PayInvoice(String, LightningError), + #[error("Failed to pay invoice {0} - Error {1}")] + PayInvoiceStablesats(String, String), // FIXME + #[error("DB Error {0}")] Db(#[from] rocksdb::Error), @@ -59,6 +62,9 @@ pub enum MokshaMintError { #[error("Lightning Error {0}")] Lightning(#[from] LightningError), + + #[error("Deserialize Error {0}")] + DeserializeResponse(String), } impl IntoResponse for MokshaMintError { diff --git a/moksha-mint/src/lib.rs b/moksha-mint/src/lib.rs index ceb52aad..328b2ce8 100644 --- a/moksha-mint/src/lib.rs +++ b/moksha-mint/src/lib.rs @@ -4,7 +4,7 @@ use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; -use axum::extract::{Query, State}; +use axum::extract::{Path, Query, State}; use axum::routing::{get_service, post}; use axum::Router; use axum::{routing::get, Json}; @@ -13,12 +13,14 @@ use error::MokshaMintError; use hyper::http::{HeaderName, HeaderValue}; use hyper::Method; use info::{MintInfoResponse, MintInfoSettings, Parameter}; +use lightning::stablesats::StablesatsLightning; use lightning::{AlbyLightning, Lightning, LightningType, LnbitsLightning, StrikeLightning}; use mint::{LightningFeeConfig, Mint}; use model::{GetMintQuery, PostMintQuery}; use moksha_core::model::{ - CheckFeesRequest, CheckFeesResponse, Keysets, PaymentRequest, PostMeltRequest, - PostMeltResponse, PostMintRequest, PostMintResponse, PostSplitRequest, PostSplitResponse, + CheckFeesRequest, CheckFeesResponse, InvoiceQuoteResult, Keysets, PaymentRequest, + PostMeltRequest, PostMeltResponse, PostMintRequest, PostMintResponse, PostSplitRequest, + PostSplitResponse, }; use secp256k1::PublicKey; @@ -93,6 +95,21 @@ impl MintBuilder { Some(LightningType::Strike(strike_settings)) => Arc::new(StrikeLightning::new( strike_settings.api_key.expect("STRIKE_API_KEY not set"), )), + Some(LightningType::Stablesats(settings)) => Arc::new(StablesatsLightning::new( + settings + .auth_bearer + .expect("STABLESATS_AUTH_BEARER not set") + .as_str(), + settings + .galoy_url + .expect("STABLESATS_GALOY_URL not set") + .as_str(), + settings + .usd_wallet_id + .expect("STABLESATS_USD_WALLET_ID not set") + .as_str(), + )), + Some(LightningType::Lnd(lnd_settings)) => Arc::new( lightning::LndLightning::new( lnd_settings.grpc_host.expect("LND_GRPC_HOST not set"), @@ -171,6 +188,7 @@ fn app(mint: Mint, serve_wallet_path: Option, prefix: Option) - .route("/keysets", get(get_keysets)) .route("/mint", get(get_mint).post(post_mint)) .route("/checkfees", post(post_check_fees)) + .route("/melt/:invoice", get(get_melt)) .route("/melt", post(post_melt)) .route("/split", post(post_split)) .route("/info", get(get_info)); @@ -242,6 +260,16 @@ async fn post_check_fees( })) } +async fn get_melt( + Path(invoice): Path, + State(mint): State, +) -> Result, MokshaMintError> { + let quote = mint.lightning.get_quote(invoice.to_owned()).await?; + Ok(Json(InvoiceQuoteResult { + amount_in_cent: quote.amount_in_cent, + })) +} + async fn get_info(State(mint): State) -> Result, MokshaMintError> { let mint_info = MintInfoResponse { name: mint.mint_info.name, diff --git a/moksha-mint/src/lightning/mod.rs b/moksha-mint/src/lightning/mod.rs index 4f64816c..c596a76b 100644 --- a/moksha-mint/src/lightning/mod.rs +++ b/moksha-mint/src/lightning/mod.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use moksha_core::model::InvoiceQuoteResult; use std::fmt::{self, Formatter}; use tokio::sync::{MappedMutexGuard, Mutex, MutexGuard}; use tonic_lnd::Client; @@ -17,13 +18,17 @@ use lightning_invoice::{Bolt11Invoice as LNInvoice, SignedRawBolt11Invoice}; mod alby; pub mod error; mod lnbits; +pub mod stablesats; mod strike; #[cfg(test)] use mockall::automock; use std::{path::PathBuf, str::FromStr, sync::Arc}; -use self::{alby::AlbyClient, error::LightningError, lnbits::LNBitsClient, strike::StrikeClient}; +use self::{ + alby::AlbyClient, error::LightningError, lnbits::LNBitsClient, stablesats::StablesatsSettings, + strike::StrikeClient, +}; #[derive(Debug, Clone)] pub enum LightningType { @@ -31,6 +36,7 @@ pub enum LightningType { Alby(AlbyLightningSettings), Strike(StrikeLightningSettings), Lnd(LndLightningSettings), + Stablesats(StablesatsSettings), } impl fmt::Display for LightningType { @@ -40,6 +46,7 @@ impl fmt::Display for LightningType { LightningType::Alby(settings) => write!(f, "Alby: {}", settings), LightningType::Strike(settings) => write!(f, "Strike: {}", settings), LightningType::Lnd(settings) => write!(f, "Lnd: {}", settings), + LightningType::Stablesats(settings) => write!(f, "Stablesats: {}", settings), } } } @@ -58,6 +65,10 @@ pub trait Lightning: Send + Sync { LNInvoice::from_str(&payment_request) .map_err(|err| MokshaMintError::DecodeInvoice(payment_request, err)) } + + async fn get_quote(&self, _invoice: String) -> Result { + Ok(Default::default()) + } } #[derive(Deserialize, Serialize, Debug, Clone, Default)] diff --git a/moksha-mint/src/lightning/stablesats.rs b/moksha-mint/src/lightning/stablesats.rs new file mode 100644 index 00000000..a2f3d97a --- /dev/null +++ b/moksha-mint/src/lightning/stablesats.rs @@ -0,0 +1,286 @@ +use std::fmt::{self, Formatter}; + +use async_trait::async_trait; +use axum::http::HeaderValue; + +use hyper::header::CONTENT_TYPE; +use lightning_invoice::Bolt11Invoice; +use moksha_core::model::InvoiceQuoteResult; +use serde_derive::{Deserialize, Serialize}; + +use tracing::info; +use url::Url; + +use crate::{ + error::MokshaMintError, + model::{CreateInvoiceResult, PayInvoiceResult}, +}; + +use super::{error::LightningError, Lightning}; + +#[derive(Deserialize, Serialize, Debug, Clone, Default)] +pub struct StablesatsSettings { + pub auth_bearer: Option, + pub galoy_url: Option, // FIXME use Url type instead + pub usd_wallet_id: Option, +} + +impl StablesatsSettings { + pub fn new(auth_bearer: &str, galoy_url: &str, usd_wallet_id: &str) -> StablesatsSettings { + StablesatsSettings { + auth_bearer: Some(auth_bearer.to_owned()), + galoy_url: Some(galoy_url.to_owned()), + usd_wallet_id: Some(usd_wallet_id.to_owned()), + } + } +} + +impl fmt::Display for StablesatsSettings { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!( + f, + "auth_bearer: {}, galoy_url: {}", + self.auth_bearer.as_ref().unwrap(), + self.galoy_url.as_ref().unwrap(), + ) + } +} + +#[derive(Clone, Debug)] +pub struct StablesatsLightning { + auth_bearer: String, + galoy_url: Url, + usd_wallet_id: String, + reqwest_client: reqwest::Client, +} + +impl StablesatsLightning { + pub fn new(auth_bearer: &str, galoy_url: &str, usd_wallet_id: &str) -> StablesatsLightning { + let galoy_url = Url::parse(galoy_url).expect("invalid galoy url"); + + let reqwest_client = reqwest::Client::builder() + .build() + .expect("invalid reqwest client"); + + StablesatsLightning { + auth_bearer: auth_bearer.to_owned(), + galoy_url, + reqwest_client, + usd_wallet_id: usd_wallet_id.to_owned(), + } + } + + pub async fn make_gqlpost(&self, body: &str) -> Result { + let response = self + .reqwest_client + .post(self.galoy_url.clone()) + .bearer_auth(self.auth_bearer.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(LightningError::NotFound); + } + + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(LightningError::Unauthorized); + } + + Ok(response.text().await?) + } + + async fn get_btc_price(&self) -> Result { + let query = r#"{"query":"query btcPrice {btcPrice { base currencyUnit formattedAmount offset}}","variables":{}}"#; + + let response = self + .make_gqlpost(query) + .await + .map_err(|err| MokshaMintError::PayInvoice("payment_request".to_string(), err))?; // FIXME + + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + let formatted_amount = response["data"]["btcPrice"]["formattedAmount"] + .as_str() + .unwrap() + .to_owned(); + + let btc_price = formatted_amount.parse::().unwrap(); // FIXME + Ok(btc_price / 100.0) + } +} + +#[async_trait] +impl Lightning for StablesatsLightning { + async fn is_invoice_paid(&self, invoice: String) -> Result { + let input = LnInvoicePaymentStatusInput { + payment_request: invoice, + }; + let query = format!( + r#"{{"query":"query LnInvoicePaymentStatus($input: LnInvoicePaymentStatusInput!) {{ lnInvoicePaymentStatus(input: $input) {{ status errors {{ message path code }} }} }}","variables":{{"input":{}}}}}"#, + serde_json::to_string(&input).map_err(MokshaMintError::Serialization)? + ); + + let response = self + .make_gqlpost(&query) + .await + .map_err(|err| MokshaMintError::PayInvoice("payment_request".to_string(), err))?; + + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + let status = response["data"]["lnInvoicePaymentStatus"]["status"] + .as_str() + .unwrap() + .to_owned(); + + Ok(status == "PAID") + } + + async fn get_quote(&self, pr: String) -> Result { + let inv: Bolt11Invoice = self.decode_invoice(pr.clone()).await.unwrap(); + + let invoice_amount_sat = inv.amount_milli_satoshis().unwrap() / 1_000; + let btc_price_usd = self.get_btc_price().await?; + let price_in_usd_cents = (btc_price_usd * invoice_amount_sat as f64) * 100.0; + + Ok(InvoiceQuoteResult { + amount_in_cent: price_in_usd_cents as u64, + }) + } + + async fn create_invoice( + &self, + amount_in_usd_cent: u64, + ) -> Result { + let input = LnUsdInvoiceCreateInput { + amount: amount_in_usd_cent, + wallet_id: self.usd_wallet_id.clone(), + }; + let query = format!( + r#"{{"query":"mutation lnUsdInvoiceCreate($input: LnUsdInvoiceCreateInput!) {{ lnUsdInvoiceCreate(input: $input) {{ invoice {{ paymentRequest paymentHash satoshis }} }} }}","variables":{{"input":{}}}}}"#, + serde_json::to_string(&input).map_err(MokshaMintError::Serialization)? + ); + + let response = self + .make_gqlpost(&query) + .await + .map_err(|err| MokshaMintError::PayInvoice("payment_request".to_string(), err))?; // FIXME + + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + let payment_request = response["data"]["lnUsdInvoiceCreate"]["invoice"]["paymentRequest"] + .as_str() + .unwrap() + .to_owned(); + + let payment_hash = response["data"]["lnUsdInvoiceCreate"]["invoice"]["paymentHash"] + .as_str() + .unwrap(); + + let sats = response["data"]["lnUsdInvoiceCreate"]["invoice"]["satoshis"] + .as_u64() + .unwrap(); + + Ok(CreateInvoiceResult { + payment_hash: payment_hash.as_bytes().to_vec(), + payment_request, + }) + } + + async fn pay_invoice( + &self, + payment_request: String, + ) -> Result { + let invoice = self.decode_invoice(payment_request.clone()).await?; + let payment_hash = invoice.payment_hash().to_vec(); + + let input = LnInvoicePaymentSendInput { + payment_request: payment_request.clone(), + wallet_id: self.usd_wallet_id.clone(), + }; + let query = format!( + r#"{{"query":"mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) {{ lnInvoicePaymentSend(input: $input) {{ status errors {{ message path code }} }} }}","variables":{{"input":{}}}}}"#, + serde_json::to_string(&input).map_err(MokshaMintError::Serialization)? + ); + + let response = self + .make_gqlpost(&query) + .await + .map_err(|err| MokshaMintError::PayInvoice(payment_request.clone(), err))?; + + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + let status = response["data"]["lnInvoicePaymentSend"]["status"] + .as_str() + .unwrap(); + + if status == "SUCCESS" { + Ok(PayInvoiceResult { + payment_hash: hex::encode(payment_hash), + }) + } else { + Err(MokshaMintError::PayInvoiceStablesats( + payment_request, + "Error paying invoice".to_owned(), + )) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LnInvoicePaymentSendInput { + payment_request: String, + wallet_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LnInvoicePaymentStatusInput { + payment_request: String, +} + +// # create invoice +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LnUsdInvoiceCreateInput { + amount: u64, + wallet_id: String, +} + +#[cfg(test)] +mod tests { + + use super::StablesatsLightning; + use crate::lightning::Lightning; + + #[tokio::test] + #[ignore] + async fn test_pay_invoice() -> anyhow::Result<()> { + let ln = + StablesatsLightning::new("auth bearer", "https://api.blink.sv/graphql", "wallet id"); + let result = ln.pay_invoice("lnbc180...".to_string()).await; + println!("{:?}", result); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_create_invoice() -> anyhow::Result<()> { + let ln = + StablesatsLightning::new("auth bearer", "https://api.blink.sv/graphql", "wallet id"); + let result = ln.create_invoice(50).await?; + println!("{:?}", result); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn test_is_invoice_paid() -> anyhow::Result<()> { + let ln = + StablesatsLightning::new("auth bearer", "https://api.blink.sv/graphql", "wallet id"); + let result = ln.is_invoice_paid("lnbc30...".to_owned()).await?; + println!("{:?}", result); + Ok(()) + } +} diff --git a/moksha-wallet/src/client/mod.rs b/moksha-wallet/src/client/mod.rs index 17a0cdf4..c9f6d345 100644 --- a/moksha-wallet/src/client/mod.rs +++ b/moksha-wallet/src/client/mod.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use async_trait::async_trait; use moksha_core::model::{ - BlindedMessage, CheckFeesResponse, Keysets, PaymentRequest, PostMeltResponse, PostMintResponse, - PostSplitResponse, Proofs, + BlindedMessage, CheckFeesResponse, InvoiceQuoteResult, Keysets, PaymentRequest, + PostMeltResponse, PostMintResponse, PostSplitResponse, Proofs, }; use secp256k1::PublicKey; use url::Url; @@ -38,6 +38,12 @@ pub trait Client { outputs: Vec, ) -> Result; + async fn get_melt_tokens( + &self, + mint_url: &Url, + pr: String, + ) -> Result; + async fn post_checkfees( &self, mint_url: &Url, diff --git a/moksha-wallet/src/client/reqwest.rs b/moksha-wallet/src/client/reqwest.rs index a55e373f..c69903e6 100644 --- a/moksha-wallet/src/client/reqwest.rs +++ b/moksha-wallet/src/client/reqwest.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use async_trait::async_trait; use moksha_core::model::{ - BlindedMessage, CashuErrorResponse, CheckFeesRequest, CheckFeesResponse, Keysets, - PaymentRequest, PostMeltRequest, PostMeltResponse, PostMintRequest, PostMintResponse, + BlindedMessage, CashuErrorResponse, CheckFeesRequest, CheckFeesResponse, InvoiceQuoteResult, + Keysets, PaymentRequest, PostMeltRequest, PostMeltResponse, PostMintRequest, PostMintResponse, PostSplitRequest, PostSplitResponse, Proofs, }; use reqwest::{ @@ -100,6 +100,16 @@ impl Client for HttpClient { extract_response_data::(resp).await } + async fn get_melt_tokens( + &self, + mint_url: &Url, + pr: String, + ) -> Result { + let url = mint_url.join(&format!("melt/{}", pr))?; + let resp = self.request_client.get(url).send().await?; + extract_response_data::(resp).await + } + async fn get_mint_keys( &self, mint_url: &Url, diff --git a/moksha-wallet/src/wallet.rs b/moksha-wallet/src/wallet.rs index f8185b46..31d3a032 100644 --- a/moksha-wallet/src/wallet.rs +++ b/moksha-wallet/src/wallet.rs @@ -172,11 +172,22 @@ impl Wallet { .post_checkfees(&self.mint_url, invoice.clone()) .await?; - let ln_amount = Self::get_invoice_amount(&invoice)? + fees.fee; + // FIXME check if quote is available + // FIXME get currency from mint + // let ln_amount = Self::get_invoice_amount(&invoice)? + fees.fee; + + // if ln_amount > all_proofs.total_amount() { + // return Err(MokshaWalletError::NotEnoughTokens); + // } + + let quote_result = self + .client + .get_melt_tokens(&self.mint_url, invoice.clone()) + .await?; + + let ln_amount = quote_result.amount_in_cent; + println!("quote_result: {:?}", quote_result); - if ln_amount > all_proofs.total_amount() { - return Err(MokshaWalletError::NotEnoughTokens); - } let selected_proofs = all_proofs.proofs_for_amount(ln_amount)?; let total_proofs = { @@ -453,8 +464,8 @@ mod tests { use async_trait::async_trait; use moksha_core::fixture::{read_fixture, read_fixture_as}; use moksha_core::model::{ - BlindedMessage, CheckFeesResponse, Keysets, MintKeyset, PaymentRequest, PostMeltResponse, - PostMintResponse, PostSplitResponse, Proofs, Token, TokenV3, + BlindedMessage, CheckFeesResponse, InvoiceQuoteResult, Keysets, MintKeyset, PaymentRequest, + PostMeltResponse, PostMintResponse, PostSplitResponse, Proofs, Token, TokenV3, }; use secp256k1::PublicKey; use std::collections::HashMap; @@ -569,6 +580,14 @@ mod tests { Ok(self.split_response.clone()) } + async fn get_melt_tokens( + &self, + _mint_url: &Url, + _pr: String, + ) -> Result { + unimplemented!() + } + async fn post_mint_payment_request( &self, _mint_url: &Url, diff --git a/moksha-wallet/tests/tests.rs b/moksha-wallet/tests/tests.rs index e38ec9f0..75e1c2e8 100644 --- a/moksha-wallet/tests/tests.rs +++ b/moksha-wallet/tests/tests.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use async_trait::async_trait; use moksha_core::fixture::{read_fixture, read_fixture_as}; use moksha_core::model::{ - BlindedMessage, CheckFeesResponse, Keysets, MintKeyset, PaymentRequest, PostMeltResponse, - PostMintResponse, PostSplitResponse, Proofs, TokenV3, + BlindedMessage, CheckFeesResponse, InvoiceQuoteResult, Keysets, MintKeyset, PaymentRequest, + PostMeltResponse, PostMintResponse, PostSplitResponse, Proofs, TokenV3, }; use moksha_wallet::localstore::sqlite::SqliteLocalStore; use moksha_wallet::localstore::LocalStore; @@ -51,6 +51,14 @@ impl Client for MockClient { Ok(self.split_response.clone()) } + async fn get_melt_tokens( + &self, + _mint_url: &Url, + _pr: String, + ) -> Result { + unimplemented!() + } + async fn post_mint_payment_request( &self, _mint_url: &Url,