diff --git a/crates/cdk-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index 9083163b0..e16752184 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -132,7 +132,12 @@ pub struct MintState { pub struct ApiDocV1; /// Create mint [`Router`] with required endpoints for cashu mint -pub async fn create_mint_router(mint: Arc, cache_ttl: u64, cache_tti: u64) -> Result { +pub async fn create_mint_router( + mint: Arc, + cache_ttl: u64, + cache_tti: u64, + include_bolt12: bool, +) -> Result { let state = MintState { mint, cache: Cache::builder() @@ -142,7 +147,7 @@ pub async fn create_mint_router(mint: Arc, cache_ttl: u64, cache_tti: u64) .build(), }; - let v1_router = Router::new() + let mut v1_router = Router::new() .route("/keys", get(get_keys)) .route("/keysets", get(get_keysets)) .route("/keys/:keyset_id", get(get_keyset_pubkeys)) @@ -163,7 +168,32 @@ pub async fn create_mint_router(mint: Arc, cache_ttl: u64, cache_tti: u64) .route("/info", get(get_mint_info)) .route("/restore", post(post_restore)); + // Conditionally create and merge bolt12_router + if include_bolt12 { + let bolt12_router = create_bolt12_router(state.clone()); + //v1_router = bolt12_router.merge(v1_router); + v1_router = v1_router.merge(bolt12_router); + } + + // Nest the combined router under "/v1" let mint_router = Router::new().nest("/v1", v1_router).with_state(state); Ok(mint_router) } + +fn create_bolt12_router(state: MintState) -> Router { + Router::new() + .route("/melt/quote/bolt12", post(get_melt_bolt12_quote)) + .route( + "/melt/quote/bolt12/:quote_id", + get(get_check_melt_bolt11_quote), + ) + .route("/melt/bolt12", post(post_melt_bolt12)) + .route("/mint/quote/bolt12", post(get_mint_bolt12_quote)) + .route( + "/mint/quote/bolt12/:quote_id", + get(get_check_mint_bolt11_quote), + ) + .route("/mint/bolt12", post(post_mint_bolt12)) + .with_state(state) +} diff --git a/crates/cdk-axum/src/router_handlers.rs b/crates/cdk-axum/src/router_handlers.rs index c4ba781ab..546f37838 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -3,11 +3,12 @@ use axum::extract::{Json, Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use cdk::error::ErrorResponse; +use cdk::nuts::nut19::{MintQuoteBolt12Request, MintQuoteBolt12Response}; use cdk::nuts::{ CheckStateRequest, CheckStateResponse, Id, KeysResponse, KeysetResponse, MeltBolt11Request, - MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, MintBolt11Response, - MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, RestoreRequest, RestoreResponse, - SwapRequest, SwapResponse, + MeltBolt12Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltQuoteBolt12Request, + MintBolt11Request, MintBolt11Response, MintInfo, MintQuoteBolt11Request, + MintQuoteBolt11Response, RestoreRequest, RestoreResponse, SwapRequest, SwapResponse, }; use cdk::util::unix_time; use cdk::Error; @@ -143,6 +144,20 @@ pub async fn get_mint_bolt11_quote( Ok(Json(quote)) } +/// Get mint bolt12 quote +pub async fn get_mint_bolt12_quote( + State(state): State, + Json(payload): Json, +) -> Result, Response> { + let quote = state + .mint + .get_mint_bolt12_quote(payload) + .await + .map_err(into_response)?; + + Ok(Json(quote)) +} + #[cfg_attr(feature = "swagger", utoipa::path( get, context_path = "/v1", @@ -155,8 +170,6 @@ pub async fn get_mint_bolt11_quote( (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") ) ))] -/// Get mint quote by ID -/// /// Get mint quote state. pub async fn get_check_mint_bolt11_quote( State(state): State, @@ -205,6 +218,23 @@ pub async fn post_mint_bolt11( Ok(Json(res)) } +/// Request a quote for melting tokens +pub async fn post_mint_bolt12( + State(state): State, + Json(payload): Json, +) -> Result, Response> { + let res = state + .mint + .process_mint_request(payload) + .await + .map_err(|err| { + tracing::error!("Could not process mint: {}", err); + into_response(err) + })?; + + Ok(Json(res)) +} + #[cfg_attr(feature = "swagger", utoipa::path( post, context_path = "/v1", @@ -215,7 +245,6 @@ pub async fn post_mint_bolt11( (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") ) ))] -/// Request a quote for melting tokens pub async fn get_melt_bolt11_quote( State(state): State, Json(payload): Json, @@ -277,12 +306,30 @@ pub async fn post_melt_bolt11( State(state): State, Json(payload): Json, ) -> Result, Response> { - let res = state + let res = state.mint.melt(&payload).await.map_err(into_response)?; + + Ok(Json(res)) +} + +pub async fn get_melt_bolt12_quote( + State(state): State, + Json(payload): Json, +) -> Result, Response> { + let quote = state .mint - .melt_bolt11(&payload) + .get_melt_bolt12_quote(&payload) .await .map_err(into_response)?; + Ok(Json(quote)) +} + +pub async fn post_melt_bolt12( + State(state): State, + Json(payload): Json, +) -> Result, Response> { + let res = state.mint.melt(&payload).await.map_err(into_response)?; + Ok(Json(res)) } diff --git a/crates/cdk-cli/src/main.rs b/crates/cdk-cli/src/main.rs index 7ff70cda0..7d1a286e2 100644 --- a/crates/cdk-cli/src/main.rs +++ b/crates/cdk-cli/src/main.rs @@ -64,6 +64,8 @@ enum Commands { MintInfo(sub_commands::mint_info::MintInfoSubcommand), /// Mint proofs via bolt11 Mint(sub_commands::mint::MintSubCommand), + /// Remint + ReMint(sub_commands::remint_bolt12::ReMintSubCommand), /// Burn Spent tokens Burn(sub_commands::burn::BurnSubCommand), /// Restore proofs from seed @@ -83,7 +85,7 @@ enum Commands { #[tokio::main] async fn main() -> Result<()> { let args: Cli = Cli::parse(); - let default_filter = args.log_level; + let default_filter = "warn"; let sqlx_filter = "sqlx=warn"; @@ -219,5 +221,14 @@ async fn main() -> Result<()> { Commands::CreateRequest(sub_command_args) => { sub_commands::create_request::create_request(&multi_mint_wallet, sub_command_args).await } + Commands::ReMint(sub_command_args) => { + sub_commands::remint_bolt12::remint( + &multi_mint_wallet, + &mnemonic.to_seed_normalized(""), + localstore, + sub_command_args, + ) + .await + } } } diff --git a/crates/cdk-cli/src/sub_commands/melt.rs b/crates/cdk-cli/src/sub_commands/melt.rs index 1467ebc72..4ed5e09a6 100644 --- a/crates/cdk-cli/src/sub_commands/melt.rs +++ b/crates/cdk-cli/src/sub_commands/melt.rs @@ -3,9 +3,10 @@ use std::io::Write; use std::str::FromStr; use anyhow::{bail, Result}; -use cdk::nuts::CurrencyUnit; +use cdk::amount::Amount; +use cdk::nuts::{CurrencyUnit, PaymentMethod}; use cdk::wallet::multi_mint_wallet::{MultiMintWallet, WalletKey}; -use cdk::Bolt11Invoice; +// use cdk::Bolt11Invoice; use clap::Args; use crate::sub_commands::balance::mint_balances; @@ -15,13 +16,19 @@ pub struct MeltSubCommand { /// Currency unit e.g. sat #[arg(default_value = "sat")] unit: String, + /// Payment method + #[arg(short, long, default_value = "bolt11")] + method: String, + /// Amount + #[arg(short, long)] + amount: Option, } pub async fn pay( multi_mint_wallet: &MultiMintWallet, sub_command_args: &MeltSubCommand, ) -> Result<()> { - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; + let unit = CurrencyUnit::from_str(&sub_command_args.unit).unwrap(); let mints_amounts = mint_balances(multi_mint_wallet, &unit).await?; println!("Enter mint number to melt from"); @@ -44,22 +51,36 @@ pub async fn pay( .await .expect("Known wallet"); - println!("Enter bolt11 invoice request"); + let method = PaymentMethod::from_str(&sub_command_args.method)?; + match method { + PaymentMethod::Bolt11 => { + println!("Enter bolt11 invoice request"); + } + PaymentMethod::Bolt12 => { + println!("Enter bolt12 invoice request"); + } + _ => panic!("Unknown payment method"), + } let mut user_input = String::new(); let stdin = io::stdin(); io::stdout().flush().unwrap(); stdin.read_line(&mut user_input)?; - let bolt11 = Bolt11Invoice::from_str(user_input.trim())?; - - if bolt11 - .amount_milli_satoshis() - .unwrap() - .gt(&(>::into(mints_amounts[mint_number].1) * 1000_u64)) - { - bail!("Not enough funds"); - } - let quote = wallet.melt_quote(bolt11.to_string(), None).await?; + + let quote = match method { + PaymentMethod::Bolt11 => { + wallet + .melt_quote(user_input.trim().to_string(), None) + .await? + } + PaymentMethod::Bolt12 => { + let amount = sub_command_args.amount.map(Amount::from); + wallet + .melt_bolt12_quote(user_input.trim().to_string(), amount) + .await? + } + _ => panic!("Unsupported payment methof"), + }; println!("{:?}", quote); diff --git a/crates/cdk-cli/src/sub_commands/mint.rs b/crates/cdk-cli/src/sub_commands/mint.rs index 46ce6a27c..4f250f8b6 100644 --- a/crates/cdk-cli/src/sub_commands/mint.rs +++ b/crates/cdk-cli/src/sub_commands/mint.rs @@ -6,7 +6,7 @@ use anyhow::Result; use cdk::amount::SplitTarget; use cdk::cdk_database::{Error, WalletDatabase}; use cdk::mint_url::MintUrl; -use cdk::nuts::{CurrencyUnit, MintQuoteState}; +use cdk::nuts::{CurrencyUnit, MintQuoteState, PaymentMethod}; use cdk::wallet::multi_mint_wallet::WalletKey; use cdk::wallet::{MultiMintWallet, Wallet}; use cdk::Amount; @@ -21,8 +21,11 @@ pub struct MintSubCommand { /// Amount amount: u64, /// Currency unit e.g. sat - #[arg(default_value = "sat")] + #[arg(short, long, default_value = "sat")] unit: String, + /// Payment method + #[arg(long, default_value = "bolt11")] + method: String, /// Quote description #[serde(skip_serializing_if = "Option::is_none")] description: Option, @@ -51,9 +54,22 @@ pub async fn mint( } }; - let quote = wallet - .mint_quote(Amount::from(sub_command_args.amount), description) - .await?; + let method = PaymentMethod::from_str(&sub_command_args.method)?; + + let quote = match method { + PaymentMethod::Bolt11 => { + println!("Bolt11"); + wallet + .mint_quote(Amount::from(sub_command_args.amount), description) + .await? + } + PaymentMethod::Bolt12 => { + wallet + .mint_bolt12_quote(Amount::from(sub_command_args.amount), description) + .await? + } + _ => panic!("Unsupported unit"), + }; println!("Quote: {:#?}", quote); diff --git a/crates/cdk-cli/src/sub_commands/mod.rs b/crates/cdk-cli/src/sub_commands/mod.rs index 8256d0aea..61bce63ce 100644 --- a/crates/cdk-cli/src/sub_commands/mod.rs +++ b/crates/cdk-cli/src/sub_commands/mod.rs @@ -11,6 +11,7 @@ pub mod mint_info; pub mod pay_request; pub mod pending_mints; pub mod receive; +pub mod remint_bolt12; pub mod restore; pub mod send; pub mod update_mint_url; diff --git a/crates/cdk-cli/src/sub_commands/remint_bolt12.rs b/crates/cdk-cli/src/sub_commands/remint_bolt12.rs new file mode 100644 index 000000000..4d63c9f49 --- /dev/null +++ b/crates/cdk-cli/src/sub_commands/remint_bolt12.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use anyhow::Result; +use cdk::amount::SplitTarget; +use cdk::cdk_database::{Error, WalletDatabase}; +use cdk::mint_url::MintUrl; +use cdk::nuts::CurrencyUnit; +use cdk::wallet::multi_mint_wallet::WalletKey; +use cdk::wallet::{MultiMintWallet, Wallet}; +use clap::Args; +use serde::{Deserialize, Serialize}; + +#[derive(Args, Serialize, Deserialize)] +pub struct ReMintSubCommand { + /// Mint url + mint_url: MintUrl, + #[arg(long)] + quote_id: String, +} + +pub async fn remint( + multi_mint_wallet: &MultiMintWallet, + seed: &[u8], + localstore: Arc + Sync + Send>, + sub_command_args: &ReMintSubCommand, +) -> Result<()> { + let mint_url = sub_command_args.mint_url.clone(); + let quote_id = sub_command_args.quote_id.clone(); + + let wallet = match multi_mint_wallet + .get_wallet(&WalletKey::new(mint_url.clone(), CurrencyUnit::Sat)) + .await + { + Some(wallet) => wallet.clone(), + None => { + let wallet = Wallet::new( + &mint_url.to_string(), + CurrencyUnit::Sat, + localstore, + seed, + None, + )?; + + multi_mint_wallet.add_wallet(wallet.clone()).await; + wallet + } + }; + + let receive_amount = wallet.mint("e_id, SplitTarget::default(), None).await?; + + println!("Received {receive_amount} from mint {mint_url}"); + + Ok(()) +} diff --git a/crates/cdk-cln/Cargo.toml b/crates/cdk-cln/Cargo.toml index a5c505ede..374275ed5 100644 --- a/crates/cdk-cln/Cargo.toml +++ b/crates/cdk-cln/Cargo.toml @@ -14,6 +14,7 @@ async-trait = "0.1" bitcoin = { version = "0.32.2", default-features = false } cdk = { path = "../cdk", version = "0.4.0", default-features = false, features = ["mint"] } cln-rpc = "0.2.0" +lightning = { version = "0.0.125", default-features = false, features = ["std"]} futures = { version = "0.3.28", default-features = false } tokio = { version = "1", default-features = false } tokio-util = { version = "0.7.11", default-features = false } diff --git a/crates/cdk-cln/src/error.rs b/crates/cdk-cln/src/error.rs index e97832fc4..fc76e0964 100644 --- a/crates/cdk-cln/src/error.rs +++ b/crates/cdk-cln/src/error.rs @@ -17,6 +17,9 @@ pub enum Error { /// Invalid payment hash #[error("Invalid hash")] InvalidHash, + /// Wrong payment type + #[error("Wrong payment type")] + WrongPaymentType, /// Cln Error #[error(transparent)] Cln(#[from] cln_rpc::Error), diff --git a/crates/cdk-cln/src/lib.rs b/crates/cdk-cln/src/lib.rs index 1e8f609c4..f9287990b 100644 --- a/crates/cdk-cln/src/lib.rs +++ b/crates/cdk-cln/src/lib.rs @@ -11,19 +11,21 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; -use cdk::amount::{to_unit, Amount}; +use cdk::amount::{amount_for_offer, to_unit, Amount}; use cdk::cdk_lightning::{ - self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, + self, Bolt12PaymentQuoteResponse, CreateInvoiceResponse, CreateOfferResponse, MintLightning, + PayInvoiceResponse, PaymentQuoteResponse, Settings, }; +use cdk::mint::types::PaymentRequest; use cdk::mint::FeeReserve; use cdk::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, + CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteBolt12Request, MeltQuoteState, MintQuoteState, }; use cdk::util::{hex, unix_time}; use cdk::{mint, Bolt11Invoice}; use cln_rpc::model::requests::{ - InvoiceRequest, ListinvoicesRequest, ListpaysRequest, PayRequest, WaitanyinvoiceRequest, + FetchinvoiceRequest, InvoiceRequest, ListinvoicesRequest, ListpaysRequest, OfferRequest, + PayRequest, WaitanyinvoiceRequest, }; use cln_rpc::model::responses::{ ListinvoicesInvoices, ListinvoicesInvoicesStatus, ListpaysPaysStatus, PayStatus, @@ -33,6 +35,8 @@ use cln_rpc::model::Request; use cln_rpc::primitives::{Amount as CLN_Amount, AmountOrAny}; use error::Error; use futures::{Stream, StreamExt}; +use lightning::offers::invoice::Bolt12Invoice; +use lightning::offers::offer::Offer; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -45,8 +49,8 @@ pub struct Cln { rpc_socket: PathBuf, cln_client: Arc>, fee_reserve: FeeReserve, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, + bolt12_mint: bool, + bolt12_melt: bool, wait_invoice_cancel_token: CancellationToken, wait_invoice_is_active: Arc, } @@ -56,8 +60,8 @@ impl Cln { pub async fn new( rpc_socket: PathBuf, fee_reserve: FeeReserve, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, + bolt12_mint: bool, + bolt12_melt: bool, ) -> Result { let cln_client = cln_rpc::ClnRpc::new(&rpc_socket).await?; @@ -65,8 +69,8 @@ impl Cln { rpc_socket, cln_client: Arc::new(Mutex::new(cln_client)), fee_reserve, - mint_settings, - melt_settings, + bolt12_mint, + bolt12_melt, wait_invoice_cancel_token: CancellationToken::new(), wait_invoice_is_active: Arc::new(AtomicBool::new(false)), }) @@ -81,8 +85,8 @@ impl MintLightning for Cln { Settings { mpp: true, unit: CurrencyUnit::Msat, - mint_settings: self.mint_settings, - melt_settings: self.melt_settings, + bolt12_mint: self.bolt12_mint, + bolt12_melt: self.bolt12_melt, invoice_description: true, } } @@ -101,7 +105,7 @@ impl MintLightning for Cln { // Clippy thinks select is not stable but it compiles fine on MSRV (1.63.0) async fn wait_any_invoice( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { let last_pay_index = self.get_last_pay_index().await?; let cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; @@ -158,6 +162,11 @@ impl MintLightning for Cln { let payment_hash = wait_any_response.payment_hash.to_string(); + + // TODO: Handle unit conversion + let amount_msats = wait_any_response.amount_received_msat.expect("status is paid there should be an amount"); + let amount_sats = amount_msats.msat() / 1000; + let request_look_up = match wait_any_response.bolt12 { // If it is a bolt12 payment we need to get the offer_id as this is what we use as the request look up. // Since this is not returned in the wait any response, @@ -188,7 +197,7 @@ impl MintLightning for Cln { None => payment_hash, }; - return Some((request_look_up, (cln_client, last_pay_idx, cancel_token, is_active))); + break Some(((request_look_up, amount_sats.into()), (cln_client, last_pay_idx, cancel_token, is_active))); } Err(e) => { tracing::warn!("Error fetching invoice: {e}"); @@ -245,7 +254,11 @@ impl MintLightning for Cln { partial_amount: Option, max_fee: Option, ) -> Result { - let bolt11 = Bolt11Invoice::from_str(&melt_quote.request)?; + let bolt11 = &match melt_quote.request { + PaymentRequest::Bolt11 { bolt11 } => bolt11, + PaymentRequest::Bolt12 { .. } => return Err(Error::WrongPaymentType.into()), + }; + let pay_state = self .check_outgoing_payment(&bolt11.payment_hash().to_string()) .await?; @@ -265,7 +278,7 @@ impl MintLightning for Cln { let mut cln_client = self.cln_client.lock().await; let cln_response = cln_client .call(Request::Pay(PayRequest { - bolt11: melt_quote.request.to_string(), + bolt11: bolt11.to_string(), amount_msat: None, label: None, riskfactor: None, @@ -384,41 +397,19 @@ impl MintLightning for Cln { ) -> Result { let mut cln_client = self.cln_client.lock().await; - let cln_response = cln_client - .call(Request::ListInvoices(ListinvoicesRequest { - payment_hash: Some(payment_hash.to_string()), - label: None, - invstring: None, - offer_id: None, - index: None, - limit: None, - start: None, - })) - .await - .map_err(Error::from)?; - - let status = match cln_response { - cln_rpc::Response::ListInvoices(invoice_response) => { - match invoice_response.invoices.first() { - Some(invoice_response) => { - cln_invoice_status_to_mint_state(invoice_response.status) - } - None => { - tracing::info!( - "Check invoice called on unknown look up id: {}", - payment_hash - ); - return Err(Error::WrongClnResponse.into()); - } - } + match fetch_invoice_by_payment_hash(&mut cln_client, payment_hash).await? { + Some(invoice) => { + let status = cln_invoice_status_to_mint_state(invoice.status); + Ok(status) } - _ => { - tracing::warn!("CLN returned wrong response kind"); - return Err(Error::WrongClnResponse.into()); + None => { + tracing::info!( + "Check invoice called on unknown payment hash: {}", + payment_hash + ); + Err(Error::UnknownInvoice.into()) } - }; - - Ok(status) + } } async fn check_outgoing_payment( @@ -465,6 +456,197 @@ impl MintLightning for Cln { } } } + + async fn get_bolt12_payment_quote( + &self, + melt_quote_request: &MeltQuoteBolt12Request, + ) -> Result { + let offer = + Offer::from_str(&melt_quote_request.request).map_err(|_| Error::UnknownInvoice)?; + + let amount = match melt_quote_request.amount { + Some(amount) => amount, + None => amount_for_offer(&offer, &CurrencyUnit::Msat)?, + }; + + let mut cln_client = self.cln_client.lock().await; + let cln_response = cln_client + .call(Request::FetchInvoice(FetchinvoiceRequest { + amount_msat: Some(CLN_Amount::from_msat(amount.into())), + offer: melt_quote_request.request.clone(), + payer_note: None, + quantity: None, + recurrence_counter: None, + recurrence_label: None, + recurrence_start: None, + timeout: None, + })) + .await; + + let amount = to_unit(amount, &CurrencyUnit::Msat, &melt_quote_request.unit)?; + + match cln_response { + Ok(cln_rpc::Response::FetchInvoice(invoice_response)) => { + let bolt12_invoice = + Bolt12Invoice::try_from(hex::decode(&invoice_response.invoice).unwrap()) + .unwrap(); + + Ok(Bolt12PaymentQuoteResponse { + request_lookup_id: bolt12_invoice.payment_hash().to_string(), + amount, + fee: Amount::ZERO, + state: MeltQuoteState::Unpaid, + invoice: Some(invoice_response.invoice), + }) + } + c => { + tracing::debug!("{:?}", c); + tracing::error!("Error attempting to pay invoice for offer",); + Err(Error::WrongClnResponse.into()) + } + } + } + + async fn pay_bolt12_offer( + &self, + melt_quote: mint::MeltQuote, + _amount: Option, + max_fee: Option, + ) -> Result { + let bolt12 = &match melt_quote.request { + PaymentRequest::Bolt12 { offer: _, invoice } => invoice.ok_or(Error::UnknownInvoice)?, + PaymentRequest::Bolt11 { .. } => return Err(Error::WrongPaymentType.into()), + }; + + let pay_state = self + .check_outgoing_payment(&melt_quote.request_lookup_id) + .await?; + + match pay_state.status { + MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => (), + MeltQuoteState::Paid => { + tracing::debug!("Melt attempted on invoice already paid"); + return Err(Self::Err::InvoiceAlreadyPaid); + } + MeltQuoteState::Pending => { + tracing::debug!("Melt attempted on invoice already pending"); + return Err(Self::Err::InvoicePaymentPending); + } + } + + let mut cln_client = self.cln_client.lock().await; + let cln_response = cln_client + .call(Request::Pay(PayRequest { + bolt11: bolt12.to_string(), + amount_msat: None, + label: None, + riskfactor: None, + maxfeepercent: None, + retry_for: None, + maxdelay: None, + exemptfee: None, + localinvreqid: None, + exclude: None, + maxfee: max_fee + .map(|a| { + let msat = to_unit(a, &melt_quote.unit, &CurrencyUnit::Msat)?; + Ok::(CLN_Amount::from_msat( + msat.into(), + )) + }) + .transpose()?, + description: None, + partial_msat: None, + })) + .await; + + let response = match cln_response { + Ok(cln_rpc::Response::Pay(pay_response)) => { + let status = match pay_response.status { + PayStatus::COMPLETE => MeltQuoteState::Paid, + PayStatus::PENDING => MeltQuoteState::Pending, + PayStatus::FAILED => MeltQuoteState::Failed, + }; + PayInvoiceResponse { + payment_preimage: Some(hex::encode(pay_response.payment_preimage.to_vec())), + payment_lookup_id: pay_response.payment_hash.to_string(), + status, + total_spent: to_unit( + pay_response.amount_sent_msat.msat(), + &CurrencyUnit::Msat, + &melt_quote.unit, + )?, + unit: melt_quote.unit, + } + } + _ => { + tracing::error!("Error attempting to pay invoice: {}", bolt12); + return Err(Error::WrongClnResponse.into()); + } + }; + + Ok(response) + } + + /// Create bolt12 offer + async fn create_bolt12_offer( + &self, + amount: Option, + unit: &CurrencyUnit, + description: String, + unix_expiry: u64, + single_use: bool, + ) -> Result { + let time_now = unix_time(); + assert!(unix_expiry > time_now); + let mut cln_client = self.cln_client.lock().await; + + let label = Uuid::new_v4().to_string(); + + let amount = match amount { + Some(amount) => { + let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?; + + amount.to_string() + } + None => "any".to_string(), + }; + + let cln_response = cln_client + .call(cln_rpc::Request::Offer(OfferRequest { + absolute_expiry: Some(unix_expiry), + description: Some(description), + label: Some(label), + issuer: None, + quantity_max: None, + recurrence: None, + recurrence_base: None, + recurrence_limit: None, + recurrence_paywindow: None, + recurrence_start_any_period: None, + single_use: Some(single_use), + amount, + })) + .await + .map_err(Error::from)?; + + match cln_response { + cln_rpc::Response::Offer(offer_res) => { + let offer = Offer::from_str(&offer_res.bolt12).unwrap(); + let expiry = offer.absolute_expiry().map(|t| t.as_secs()); + + Ok(CreateOfferResponse { + request_lookup_id: offer_res.offer_id.to_string(), + request: offer, + expiry, + }) + } + _ => { + tracing::warn!("CLN returned wrong response kind"); + Err(Error::WrongClnResponse.into()) + } + } + } } impl Cln { diff --git a/crates/cdk-fake-wallet/src/error.rs b/crates/cdk-fake-wallet/src/error.rs index 036d1cab4..f943e7f5c 100644 --- a/crates/cdk-fake-wallet/src/error.rs +++ b/crates/cdk-fake-wallet/src/error.rs @@ -14,6 +14,9 @@ pub enum Error { /// Unknown invoice #[error("No channel receiver")] NoReceiver, + /// Wrong invoice type + #[error("Wrong invoice type")] + WrongRequestType, } impl From for cdk::cdk_lightning::Error { diff --git a/crates/cdk-fake-wallet/src/lib.rs b/crates/cdk-fake-wallet/src/lib.rs index e0fba0727..34af1c8d7 100644 --- a/crates/cdk-fake-wallet/src/lib.rs +++ b/crates/cdk-fake-wallet/src/lib.rs @@ -7,7 +7,6 @@ use std::collections::{HashMap, HashSet}; use std::pin::Pin; -use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -16,13 +15,14 @@ use bitcoin::hashes::{sha256, Hash}; use bitcoin::secp256k1::{Secp256k1, SecretKey}; use cdk::amount::{to_unit, Amount}; use cdk::cdk_lightning::{ - self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, + self, Bolt12PaymentQuoteResponse, CreateInvoiceResponse, CreateOfferResponse, MintLightning, + PayInvoiceResponse, PaymentQuoteResponse, Settings, }; use cdk::mint; +use cdk::mint::types::PaymentRequest; use cdk::mint::FeeReserve; use cdk::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, + CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteBolt12Request, MeltQuoteState, MintQuoteState, }; use cdk::util::unix_time; use error::Error; @@ -44,8 +44,6 @@ pub struct FakeWallet { fee_reserve: FeeReserve, sender: tokio::sync::mpsc::Sender, receiver: Arc>>>, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, payment_states: Arc>>, failed_payment_check: Arc>>, payment_delay: u64, @@ -57,8 +55,6 @@ impl FakeWallet { /// Creat new [`FakeWallet`] pub fn new( fee_reserve: FeeReserve, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, payment_states: HashMap, fail_payment_check: HashSet, payment_delay: u64, @@ -69,8 +65,6 @@ impl FakeWallet { fee_reserve, sender, receiver: Arc::new(Mutex::new(Some(receiver))), - mint_settings, - melt_settings, payment_states: Arc::new(Mutex::new(payment_states)), failed_payment_check: Arc::new(Mutex::new(fail_payment_check)), payment_delay, @@ -112,8 +106,8 @@ impl MintLightning for FakeWallet { Settings { mpp: true, unit: CurrencyUnit::Msat, - mint_settings: self.mint_settings, - melt_settings: self.melt_settings, + bolt12_mint: false, + bolt12_melt: false, invoice_description: true, } } @@ -128,10 +122,11 @@ impl MintLightning for FakeWallet { async fn wait_any_invoice( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { let receiver = self.receiver.lock().await.take().ok_or(Error::NoReceiver)?; let receiver_stream = ReceiverStream::new(receiver); - Ok(Box::pin(receiver_stream.map(|label| label))) + self.wait_invoice_is_active.store(true, Ordering::SeqCst); + Ok(Box::pin(receiver_stream.map(|label| (label, Amount::ZERO)))) } async fn get_payment_quote( @@ -173,7 +168,10 @@ impl MintLightning for FakeWallet { _partial_msats: Option, _max_fee_msats: Option, ) -> Result { - let bolt11 = Bolt11Invoice::from_str(&melt_quote.request)?; + let bolt11 = &match melt_quote.request { + PaymentRequest::Bolt11 { bolt11 } => bolt11, + PaymentRequest::Bolt12 { .. } => return Err(Error::WrongRequestType.into()), + }; let payment_hash = bolt11.payment_hash().to_string(); @@ -286,6 +284,35 @@ impl MintLightning for FakeWallet { unit: self.get_settings().unit, }) } + + async fn get_bolt12_payment_quote( + &self, + _melt_quote_request: &MeltQuoteBolt12Request, + ) -> Result { + todo!() + } + + /// Pay a bolt12 offer + async fn pay_bolt12_offer( + &self, + _melt_quote: mint::MeltQuote, + _amount: Option, + _max_fee_amount: Option, + ) -> Result { + todo!() + } + + /// Create bolt12 offer + async fn create_bolt12_offer( + &self, + _amount: Option, + _unit: &CurrencyUnit, + _description: String, + _unix_expiry: u64, + _single_use: bool, + ) -> Result { + todo!() + } } /// Create fake invoice diff --git a/crates/cdk-integration-tests/src/init_fake_wallet.rs b/crates/cdk-integration-tests/src/init_fake_wallet.rs index 3eeaa37f9..b4c76c11f 100644 --- a/crates/cdk-integration-tests/src/init_fake_wallet.rs +++ b/crates/cdk-integration-tests/src/init_fake_wallet.rs @@ -9,7 +9,7 @@ use cdk::{ cdk_database::{self, MintDatabase}, cdk_lightning::MintLightning, mint::FeeReserve, - nuts::{CurrencyUnit, MeltMethodSettings, MintMethodSettings}, + nuts::CurrencyUnit, types::LnKey, }; use cdk_fake_wallet::FakeWallet; @@ -46,14 +46,7 @@ where percent_fee_reserve: 1.0, }; - let fake_wallet = FakeWallet::new( - fee_reserve, - MintMethodSettings::default(), - MeltMethodSettings::default(), - HashMap::default(), - HashSet::default(), - 0, - ); + let fake_wallet = FakeWallet::new(fee_reserve, HashMap::default(), HashSet::default(), 0); ln_backends.insert( LnKey::new(CurrencyUnit::Sat, cdk::nuts::PaymentMethod::Bolt11), @@ -65,9 +58,10 @@ where let cache_tti = 3600; let mint_arc = Arc::new(mint); - let v1_service = cdk_axum::create_mint_router(Arc::clone(&mint_arc), cache_ttl, cache_tti) - .await - .unwrap(); + let v1_service = + cdk_axum::create_mint_router(Arc::clone(&mint_arc), cache_ttl, cache_tti, false) + .await + .unwrap(); let mint_service = Router::new() .merge(v1_service) diff --git a/crates/cdk-integration-tests/src/init_regtest.rs b/crates/cdk-integration-tests/src/init_regtest.rs index 769a33500..78074b26e 100644 --- a/crates/cdk-integration-tests/src/init_regtest.rs +++ b/crates/cdk-integration-tests/src/init_regtest.rs @@ -7,7 +7,7 @@ use cdk::{ cdk_database::{self, MintDatabase}, cdk_lightning::MintLightning, mint::{FeeReserve, Mint}, - nuts::{CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings}, + nuts::{CurrencyUnit, MintInfo}, types::{LnKey, QuoteTTL}, }; use cdk_cln::Cln as CdkCln; @@ -131,13 +131,7 @@ pub async fn create_cln_backend(cln_client: &ClnClient) -> Result { percent_fee_reserve: 1.0, }; - Ok(CdkCln::new( - rpc_path, - fee_reserve, - MintMethodSettings::default(), - MeltMethodSettings::default(), - ) - .await?) + Ok(CdkCln::new(rpc_path, fee_reserve, true, true).await?) } pub async fn create_mint( @@ -222,6 +216,7 @@ where Arc::clone(&mint_arc), cache_time_to_live, cache_time_to_idle, + false, ) .await .unwrap(); diff --git a/crates/cdk-integration-tests/src/lib.rs b/crates/cdk-integration-tests/src/lib.rs index eacc3b3a5..7a727d7b3 100644 --- a/crates/cdk-integration-tests/src/lib.rs +++ b/crates/cdk-integration-tests/src/lib.rs @@ -11,8 +11,8 @@ use cdk::cdk_lightning::MintLightning; use cdk::dhke::construct_proofs; use cdk::mint::FeeReserve; use cdk::nuts::{ - CurrencyUnit, Id, KeySet, MeltMethodSettings, MintInfo, MintMethodSettings, MintQuoteState, - Nuts, PaymentMethod, PreMintSecrets, Proofs, State, + CurrencyUnit, Id, KeySet, MintInfo, MintQuoteState, Nuts, PaymentMethod, PreMintSecrets, + Proofs, State, }; use cdk::types::{LnKey, QuoteTTL}; use cdk::wallet::client::HttpClient; @@ -39,9 +39,7 @@ pub fn create_backends_fake_wallet( let ln_key = LnKey::new(CurrencyUnit::Sat, PaymentMethod::Bolt11); let wallet = Arc::new(FakeWallet::new( - fee_reserve.clone(), - MintMethodSettings::default(), - MeltMethodSettings::default(), + fee_reserve, HashMap::default(), HashSet::default(), 0, @@ -88,11 +86,11 @@ pub async fn start_mint( let cache_time_to_idle = 3600; let mint_arc = Arc::new(mint); - let v1_service = cdk_axum::create_mint_router( Arc::clone(&mint_arc), cache_time_to_live, cache_time_to_idle, + false, ) .await?; diff --git a/crates/cdk-integration-tests/tests/fake_wallet.rs b/crates/cdk-integration-tests/tests/fake_wallet.rs index e120a4a2d..b1c651132 100644 --- a/crates/cdk-integration-tests/tests/fake_wallet.rs +++ b/crates/cdk-integration-tests/tests/fake_wallet.rs @@ -367,15 +367,13 @@ async fn test_fake_melt_change_in_quote() -> Result<()> { let check = wallet.melt_quote_status(&melt_quote.id).await?; - assert_eq!( - melt_response - .change - .unwrap() - .sort_by(|a, b| a.amount.cmp(&b.amount)), - check - .change - .unwrap() - .sort_by(|a, b| a.amount.cmp(&b.amount)) - ); + let mut melt_response_change = melt_response.change.unwrap(); + let mut check_change = check.change.unwrap(); + + melt_response_change.sort_by(|a, b| a.amount.cmp(&b.amount)); + + check_change.sort_by(|a, b| a.amount.cmp(&b.amount)); + + assert_eq!(melt_response_change, check_change); Ok(()) } diff --git a/crates/cdk-integration-tests/tests/mint.rs b/crates/cdk-integration-tests/tests/mint.rs index c86e2dd31..b1ff2642e 100644 --- a/crates/cdk-integration-tests/tests/mint.rs +++ b/crates/cdk-integration-tests/tests/mint.rs @@ -69,14 +69,18 @@ async fn mint_proofs( mint.mint_url.clone(), "".to_string(), CurrencyUnit::Sat, - amount, + Some(amount), unix_time() + 36000, request_lookup.to_string(), + Amount::ZERO, + Amount::ZERO, + None, ); mint.localstore.add_mint_quote(quote.clone()).await?; - mint.pay_mint_quote_for_request_id(&request_lookup).await?; + mint.pay_mint_quote_for_request_id(&request_lookup, amount) + .await?; let keyset_id = Id::from(&keys); let premint = PreMintSecrets::random(keyset_id, amount, split_target)?; diff --git a/crates/cdk-lnbits/src/error.rs b/crates/cdk-lnbits/src/error.rs index c968376d4..e5d1bcd48 100644 --- a/crates/cdk-lnbits/src/error.rs +++ b/crates/cdk-lnbits/src/error.rs @@ -11,6 +11,12 @@ pub enum Error { /// Unknown invoice #[error("Unknown invoice")] UnknownInvoice, + /// Wrong invoice type + #[error("Wrong invoice type")] + WrongRequestType, + /// Unsupported method + #[error("Unsupported method")] + UnsupportedMethod, /// Anyhow error #[error(transparent)] Anyhow(#[from] anyhow::Error), diff --git a/crates/cdk-lnbits/src/lib.rs b/crates/cdk-lnbits/src/lib.rs index 64e7bef7e..2248d3d3b 100644 --- a/crates/cdk-lnbits/src/lib.rs +++ b/crates/cdk-lnbits/src/lib.rs @@ -12,12 +12,13 @@ use async_trait::async_trait; use axum::Router; use cdk::amount::{to_unit, Amount, MSAT_IN_SAT}; use cdk::cdk_lightning::{ - self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, + self, Bolt12PaymentQuoteResponse, CreateInvoiceResponse, CreateOfferResponse, MintLightning, + PayInvoiceResponse, PaymentQuoteResponse, Settings, }; +use cdk::mint::types::PaymentRequest; use cdk::mint::FeeReserve; use cdk::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, + CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteBolt12Request, MeltQuoteState, MintQuoteState, }; use cdk::util::unix_time; use cdk::{mint, Bolt11Invoice}; @@ -35,8 +36,6 @@ pub mod error; #[derive(Clone)] pub struct LNbits { lnbits_api: LNBitsClient, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, fee_reserve: FeeReserve, receiver: Arc>>>, webhook_url: String, @@ -51,8 +50,6 @@ impl LNbits { admin_api_key: String, invoice_api_key: String, api_url: String, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, fee_reserve: FeeReserve, receiver: Arc>>>, webhook_url: String, @@ -61,8 +58,6 @@ impl LNbits { Ok(Self { lnbits_api, - mint_settings, - melt_settings, receiver, fee_reserve, webhook_url, @@ -80,8 +75,8 @@ impl MintLightning for LNbits { Settings { mpp: false, unit: CurrencyUnit::Sat, - mint_settings: self.mint_settings, - melt_settings: self.melt_settings, + bolt12_mint: false, + bolt12_melt: false, invoice_description: true, } } @@ -97,7 +92,7 @@ impl MintLightning for LNbits { #[allow(clippy::incompatible_msrv)] async fn wait_any_invoice( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { let receiver = self .receiver .lock() @@ -134,7 +129,7 @@ impl MintLightning for LNbits { match check { Ok(state) => { if state { - Some((msg, (receiver, lnbits_api, cancel_token, is_active))) + Some(((msg, Amount::ZERO), (receiver, lnbits_api, cancel_token, is_active))) } else { None } @@ -198,9 +193,14 @@ impl MintLightning for LNbits { _partial_msats: Option, _max_fee_msats: Option, ) -> Result { + let bolt11 = &match melt_quote.request { + PaymentRequest::Bolt11 { bolt11 } => bolt11, + PaymentRequest::Bolt12 { .. } => return Err(Error::WrongRequestType.into()), + }; + let pay_response = self .lnbits_api - .pay_invoice(&melt_quote.request) + .pay_invoice(&bolt11.to_string()) .await .map_err(|err| { tracing::error!("Could not pay invoice"); @@ -329,6 +329,35 @@ impl MintLightning for LNbits { Ok(pay_response) } + + async fn get_bolt12_payment_quote( + &self, + _melt_quote_request: &MeltQuoteBolt12Request, + ) -> Result { + Err(Error::UnsupportedMethod.into()) + } + + /// Pay a bolt12 offer + async fn pay_bolt12_offer( + &self, + _melt_quote: mint::MeltQuote, + _amount: Option, + _max_fee_amount: Option, + ) -> Result { + Err(Error::UnsupportedMethod.into()) + } + + /// Create bolt12 offer + async fn create_bolt12_offer( + &self, + _amount: Option, + _unit: &CurrencyUnit, + _description: String, + _unix_expiry: u64, + _single_use: bool, + ) -> Result { + Err(Error::UnsupportedMethod.into()) + } } fn lnbits_to_melt_status(status: &str, pending: bool) -> MeltQuoteState { diff --git a/crates/cdk-lnd/src/error.rs b/crates/cdk-lnd/src/error.rs index 3b6f427b2..8d7d7fda6 100644 --- a/crates/cdk-lnd/src/error.rs +++ b/crates/cdk-lnd/src/error.rs @@ -20,6 +20,9 @@ pub enum Error { /// Payment failed #[error("LND payment failed")] PaymentFailed, + /// Wrong invoice type + #[error("Wrong invoice type")] + WrongRequestType, /// Unknown payment status #[error("LND unknown payment status")] UnknownPaymentStatus, diff --git a/crates/cdk-lnd/src/lib.rs b/crates/cdk-lnd/src/lib.rs index c5ecbeb8f..5fdf7c9ac 100644 --- a/crates/cdk-lnd/src/lib.rs +++ b/crates/cdk-lnd/src/lib.rs @@ -15,12 +15,13 @@ use anyhow::anyhow; use async_trait::async_trait; use cdk::amount::{to_unit, Amount, MSAT_IN_SAT}; use cdk::cdk_lightning::{ - self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, + self, Bolt12PaymentQuoteResponse, CreateInvoiceResponse, CreateOfferResponse, MintLightning, + PayInvoiceResponse, PaymentQuoteResponse, Settings, }; +use cdk::mint::types::PaymentRequest; use cdk::mint::FeeReserve; use cdk::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, + CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteBolt12Request, MeltQuoteState, MintQuoteState, }; use cdk::util::{hex, unix_time}; use cdk::{mint, Bolt11Invoice}; @@ -43,8 +44,6 @@ pub struct Lnd { macaroon_file: PathBuf, client: Arc>, fee_reserve: FeeReserve, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, wait_invoice_cancel_token: CancellationToken, wait_invoice_is_active: Arc, } @@ -56,8 +55,6 @@ impl Lnd { cert_file: PathBuf, macaroon_file: PathBuf, fee_reserve: FeeReserve, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, ) -> Result { let client = fedimint_tonic_lnd::connect(address.to_string(), &cert_file, &macaroon_file) .await @@ -72,8 +69,6 @@ impl Lnd { macaroon_file, client: Arc::new(Mutex::new(client)), fee_reserve, - mint_settings, - melt_settings, wait_invoice_cancel_token: CancellationToken::new(), wait_invoice_is_active: Arc::new(AtomicBool::new(false)), }) @@ -88,8 +83,8 @@ impl MintLightning for Lnd { Settings { mpp: true, unit: CurrencyUnit::Msat, - mint_settings: self.mint_settings, - melt_settings: self.melt_settings, + bolt12_mint: false, + bolt12_melt: false, invoice_description: true, } } @@ -104,7 +99,7 @@ impl MintLightning for Lnd { async fn wait_any_invoice( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { let mut client = fedimint_tonic_lnd::connect(self.address.clone(), &self.cert_file, &self.macaroon_file) .await @@ -146,7 +141,7 @@ impl MintLightning for Lnd { match msg { Ok(Some(msg)) => { if msg.state == 1 { - Some((hex::encode(msg.r_hash), (stream, cancel_token, is_active))) + Some(((hex::encode(msg.r_hash), Amount::ZERO), (stream, cancel_token, is_active))) } else { None } @@ -210,10 +205,13 @@ impl MintLightning for Lnd { partial_amount: Option, max_fee: Option, ) -> Result { - let payment_request = melt_quote.request; + let bolt11 = &match melt_quote.request { + PaymentRequest::Bolt11 { bolt11 } => bolt11, + PaymentRequest::Bolt12 { .. } => return Err(Error::WrongRequestType.into()), + }; let pay_req = fedimint_tonic_lnd::lnrpc::SendRequest { - payment_request, + payment_request: bolt11.to_string(), fee_limit: max_fee.map(|f| { let limit = Limit::Fixed(u64::from(f) as i64); @@ -393,4 +391,33 @@ impl MintLightning for Lnd { // If the stream is exhausted without a final status Err(Error::UnknownPaymentStatus.into()) } + + async fn get_bolt12_payment_quote( + &self, + _melt_quote_request: &MeltQuoteBolt12Request, + ) -> Result { + todo!() + } + + /// Pay a bolt12 offer + async fn pay_bolt12_offer( + &self, + _melt_quote: mint::MeltQuote, + _amount: Option, + _max_fee_amount: Option, + ) -> Result { + todo!() + } + + /// Create bolt12 offer + async fn create_bolt12_offer( + &self, + _amount: Option, + _unit: &CurrencyUnit, + _description: String, + _unix_expiry: u64, + _single_use: bool, + ) -> Result { + todo!() + } } diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index 34640215e..0cc41fa41 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -9,6 +9,15 @@ repository = "https://github.com/cashubtc/cdk.git" rust-version = "1.63.0" # MSRV description = "CDK mint binary" +[lib] +name = "cdk_mintd" +path = "src/lib.rs" + +[[bin]] +name = "cdk-mintd" +path = "src/main.rs" + + [dependencies] anyhow = "1" axum = "0.6.20" diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 5e94f69bc..a4a1ac072 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -35,12 +35,27 @@ pub enum LnBackend { Lnd, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Ln { pub ln_backend: LnBackend, pub invoice_description: Option, - pub fee_percent: f32, - pub reserve_fee_min: Amount, + pub min_mint: Amount, + pub max_mint: Amount, + pub min_melt: Amount, + pub max_melt: Amount, +} + +impl Default for Ln { + fn default() -> Self { + Ln { + ln_backend: LnBackend::default(), + invoice_description: None, + min_mint: 1.into(), + max_mint: 500_000.into(), + min_melt: 1.into(), + max_melt: 500_000.into(), + } + } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -54,11 +69,16 @@ pub struct LNbits { pub admin_api_key: String, pub invoice_api_key: String, pub lnbits_api: String, + pub fee_percent: f32, + pub reserve_fee_min: Amount, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Cln { pub rpc_path: PathBuf, + pub bolt12: bool, + pub fee_percent: f32, + pub reserve_fee_min: Amount, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -66,23 +86,32 @@ pub struct Lnd { pub address: String, pub cert_file: PathBuf, pub macaroon_file: PathBuf, + pub fee_percent: f32, + pub reserve_fee_min: Amount, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Phoenixd { pub api_password: String, pub api_url: String, + pub bolt12: bool, + pub fee_percent: f32, + pub reserve_fee_min: Amount, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FakeWallet { pub supported_units: Vec, + pub fee_percent: f32, + pub reserve_fee_min: Amount, } impl Default for FakeWallet { fn default() -> Self { Self { supported_units: vec![CurrencyUnit::Sat], + fee_percent: 0.02, + reserve_fee_min: 2.into(), } } } diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs new file mode 100644 index 000000000..92ab8f49b --- /dev/null +++ b/crates/cdk-mintd/src/lib.rs @@ -0,0 +1,23 @@ +//! Cdk mintd lib + +use std::path::PathBuf; + +pub mod cli; +pub mod config; +pub mod mint; +pub mod setup; + +fn expand_path(path: &str) -> Option { + if path.starts_with('~') { + if let Some(home_dir) = home::home_dir().as_mut() { + let remainder = &path[2..]; + home_dir.push(remainder); + let expanded_path = home_dir; + Some(expanded_path.clone()) + } else { + None + } + } else { + Some(PathBuf::from(path)) + } +} diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index 4adc2809d..1f668c35e 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -3,7 +3,7 @@ #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; @@ -14,33 +14,22 @@ use bip39::Mnemonic; use cdk::cdk_database::{self, MintDatabase}; use cdk::cdk_lightning; use cdk::cdk_lightning::MintLightning; -use cdk::mint::{FeeReserve, MeltQuote, Mint}; -use cdk::mint_url::MintUrl; -use cdk::nuts::{ - nut04, nut05, ContactInfo, CurrencyUnit, MeltMethodSettings, MeltQuoteState, MintInfo, - MintMethodSettings, MintVersion, MppMethodSettings, Nuts, PaymentMethod, -}; -use cdk::types::{LnKey, QuoteTTL}; -use cdk_cln::Cln; -use cdk_fake_wallet::FakeWallet; -use cdk_lnbits::LNbits; -use cdk_lnd::Lnd; -use cdk_phoenixd::Phoenixd; +use cdk::mint::{MeltQuote, Mint}; +use cdk::nuts::{ContactInfo, CurrencyUnit, MeltQuoteState, MintVersion, PaymentMethod}; +use cdk::types::LnKey; +use cdk_mintd::mint::{MintBuilder, MintMeltLimits}; +use cdk_mintd::setup::LnBackendSetup; use cdk_redb::MintRedbDatabase; use cdk_sqlite::MintSqliteDatabase; -use cdk_strike::Strike; use clap::Parser; -use cli::CLIArgs; -use config::{DatabaseEngine, LnBackend}; -use tokio::sync::{Mutex, Notify}; +use tokio::sync::Notify; use tower_http::cors::CorsLayer; use tracing_subscriber::EnvFilter; -use url::Url; #[cfg(feature = "swagger")] use utoipa::OpenApi; -mod cli; -mod config; +use cdk_mintd::cli::CLIArgs; +use cdk_mintd::config::{self, DatabaseEngine, LnBackend}; const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); const DEFAULT_QUOTE_TTL_SECS: u64 = 1800; @@ -74,6 +63,8 @@ async fn main() -> anyhow::Result<()> { None => work_dir.join("config.toml"), }; + let mut mint_builder = MintBuilder::new(); + let settings = config::Settings::new(&Some(config_file_arg)); let localstore: Arc + Send + Sync> = @@ -92,6 +83,8 @@ async fn main() -> anyhow::Result<()> { } }; + mint_builder = mint_builder.with_localstore(localstore); + let mut contact_info: Option> = None; if let Some(nostr_contact) = &settings.mint_info.contact_nostr_public_key { @@ -123,323 +116,185 @@ async fn main() -> anyhow::Result<()> { CARGO_PKG_VERSION.unwrap_or("Unknown").to_string(), ); - let relative_ln_fee = settings.ln.fee_percent; - - let absolute_ln_fee_reserve = settings.ln.reserve_fee_min; - - let fee_reserve = FeeReserve { - min_fee_reserve: absolute_ln_fee_reserve, - percent_fee_reserve: relative_ln_fee, - }; - let mut ln_backends: HashMap< LnKey, Arc + Send + Sync>, > = HashMap::new(); + let mut ln_routers = vec![]; - let mut supported_units = HashMap::new(); - let input_fee_ppk = settings.info.input_fee_ppk.unwrap_or(0); - - let mint_url: MintUrl = settings.info.url.parse()?; + let mint_melt_limits = MintMeltLimits { + mint_min: settings.ln.min_mint, + mint_max: settings.ln.max_mint, + melt_min: settings.ln.min_melt, + melt_max: settings.ln.max_melt, + }; - let ln_routers: Vec = match settings.ln.ln_backend { + match settings.ln.ln_backend { LnBackend::Cln => { - let cln_socket = expand_path( - settings - .cln - .expect("Config checked at load that cln is some") - .rpc_path - .to_str() - .ok_or(anyhow!("cln socket not defined"))?, - ) - .ok_or(anyhow!("cln socket not defined"))?; - let cln = Arc::new( - Cln::new( - cln_socket, - fee_reserve, - MintMethodSettings::default(), - MeltMethodSettings::default(), - ) - .await?, + let cln_settings = settings + .cln + .clone() + .expect("Config checked at load that cln is some"); + + let cln = cln_settings + .setup(&mut ln_routers, &settings, CurrencyUnit::Msat) + .await?; + let cln = Arc::new(cln); + let ln_key = LnKey { + unit: CurrencyUnit::Sat, + method: PaymentMethod::Bolt11, + }; + ln_backends.insert(ln_key, cln.clone()); + + mint_builder = mint_builder.add_ln_backend( + CurrencyUnit::Sat, + PaymentMethod::Bolt11, + mint_melt_limits, + cln.clone(), ); - ln_backends.insert(LnKey::new(CurrencyUnit::Sat, PaymentMethod::Bolt11), cln); - supported_units.insert(CurrencyUnit::Sat, (input_fee_ppk, 64)); - vec![] + let ln_key = LnKey { + unit: CurrencyUnit::Sat, + method: PaymentMethod::Bolt12, + }; + ln_backends.insert(ln_key, cln.clone()); + + mint_builder = mint_builder.add_ln_backend( + CurrencyUnit::Sat, + PaymentMethod::Bolt12, + mint_melt_limits, + cln, + ) } LnBackend::Strike => { - let strike_settings = settings.strike.expect("Checked on config load"); - let api_key = strike_settings.api_key; + let strike_settings = settings.clone().strike.expect("Checked on config load"); - let units = strike_settings + for unit in strike_settings + .clone() .supported_units - .unwrap_or(vec![CurrencyUnit::Sat]); - - let mut routers = vec![]; - - for unit in units { - // Channel used for strike web hook - let (sender, receiver) = tokio::sync::mpsc::channel(8); - let webhook_endpoint = format!("/webhook/{}/invoice", unit); - - let webhook_url = mint_url.join(&webhook_endpoint)?; - - let strike = Strike::new( - api_key.clone(), - MintMethodSettings::default(), - MeltMethodSettings::default(), - unit, - Arc::new(Mutex::new(Some(receiver))), - webhook_url.to_string(), - ) - .await?; - - let router = strike - .create_invoice_webhook(&webhook_endpoint, sender) + .unwrap_or(vec![CurrencyUnit::Sat]) + { + let strike = strike_settings + .setup(&mut ln_routers, &settings, unit) .await?; - routers.push(router); - - let ln_key = LnKey::new(unit, PaymentMethod::Bolt11); - - ln_backends.insert(ln_key, Arc::new(strike)); - supported_units.insert(unit, (input_fee_ppk, 64)); + mint_builder = mint_builder.add_ln_backend( + unit, + PaymentMethod::Bolt11, + mint_melt_limits, + Arc::new(strike), + ); } - - routers } LnBackend::LNbits => { - let lnbits_settings = settings.lnbits.expect("Checked on config load"); - let admin_api_key = lnbits_settings.admin_api_key; - let invoice_api_key = lnbits_settings.invoice_api_key; - - // Channel used for lnbits web hook - let (sender, receiver) = tokio::sync::mpsc::channel(8); - let webhook_endpoint = "/webhook/lnbits/sat/invoice"; - - let webhook_url = mint_url.join(webhook_endpoint)?; - - let lnbits = LNbits::new( - admin_api_key, - invoice_api_key, - lnbits_settings.lnbits_api, - MintMethodSettings::default(), - MeltMethodSettings::default(), - fee_reserve, - Arc::new(Mutex::new(Some(receiver))), - webhook_url.to_string(), - ) - .await?; - - let router = lnbits - .create_invoice_webhook_router(webhook_endpoint, sender) + let lnbits_settings = settings.clone().lnbits.expect("Checked on config load"); + let lnbits = lnbits_settings + .setup(&mut ln_routers, &settings, CurrencyUnit::Sat) .await?; - let unit = CurrencyUnit::Sat; - - let ln_key = LnKey::new(unit, PaymentMethod::Bolt11); - - ln_backends.insert(ln_key, Arc::new(lnbits)); - - supported_units.insert(unit, (input_fee_ppk, 64)); - vec![router] + mint_builder = mint_builder.add_ln_backend( + CurrencyUnit::Sat, + PaymentMethod::Bolt11, + mint_melt_limits, + Arc::new(lnbits), + ); } LnBackend::Phoenixd => { - let api_password = settings - .clone() - .phoenixd - .expect("Checked at config load") - .api_password; - - let api_url = settings - .clone() - .phoenixd - .expect("Checked at config load") - .api_url; - - if fee_reserve.percent_fee_reserve < 0.04 { - bail!("Fee reserve is too low needs to be at least 0.02"); - } - - let webhook_endpoint = "/webhook/phoenixd"; - - let mint_url = Url::parse(&settings.info.url)?; - - let webhook_url = mint_url.join(webhook_endpoint)?.to_string(); - - let (sender, receiver) = tokio::sync::mpsc::channel(8); - - let phoenixd = Phoenixd::new( - api_password.to_string(), - api_url.to_string(), - MintMethodSettings::default(), - MeltMethodSettings::default(), - fee_reserve, - Arc::new(Mutex::new(Some(receiver))), - webhook_url, - )?; - - let router = phoenixd - .create_invoice_webhook(webhook_endpoint, sender) + let phd_settings = settings.clone().phoenixd.expect("Checked at config load"); + let phd = phd_settings + .setup(&mut ln_routers, &settings, CurrencyUnit::Sat) .await?; - supported_units.insert(CurrencyUnit::Sat, (input_fee_ppk, 64)); - ln_backends.insert( - LnKey { - unit: CurrencyUnit::Sat, - method: PaymentMethod::Bolt11, - }, - Arc::new(phoenixd), + mint_builder = mint_builder.add_ln_backend( + CurrencyUnit::Sat, + PaymentMethod::Bolt11, + mint_melt_limits, + Arc::new(phd), ); - - vec![router] } LnBackend::Lnd => { - let lnd_settings = settings.lnd.expect("Checked at config load"); - - let address = lnd_settings.address; - let cert_file = lnd_settings.cert_file; - let macaroon_file = lnd_settings.macaroon_file; - - let lnd = Lnd::new( - address, - cert_file, - macaroon_file, - fee_reserve, - MintMethodSettings::default(), - MeltMethodSettings::default(), - ) - .await?; + let lnd_settings = settings.clone().lnd.expect("Checked at config load"); + let lnd = lnd_settings + .setup(&mut ln_routers, &settings, CurrencyUnit::Msat) + .await?; - supported_units.insert(CurrencyUnit::Sat, (input_fee_ppk, 64)); - ln_backends.insert( - LnKey { - unit: CurrencyUnit::Sat, - method: PaymentMethod::Bolt11, - }, + mint_builder = mint_builder.add_ln_backend( + CurrencyUnit::Sat, + PaymentMethod::Bolt11, + mint_melt_limits, Arc::new(lnd), ); - - vec![] } LnBackend::FakeWallet => { - let units = settings.fake_wallet.unwrap_or_default().supported_units; + let fake_wallet = settings.clone().fake_wallet.expect("Fake wallet defined"); - for unit in units { - let ln_key = LnKey::new(unit, PaymentMethod::Bolt11); + for unit in fake_wallet.clone().supported_units { + let fake = fake_wallet + .setup(&mut ln_routers, &settings, CurrencyUnit::Sat) + .await?; - let wallet = Arc::new(FakeWallet::new( - fee_reserve.clone(), - MintMethodSettings::default(), - MeltMethodSettings::default(), - HashMap::default(), - HashSet::default(), - 0, - )); + let fake = Arc::new(fake); - ln_backends.insert(ln_key, wallet); + mint_builder = mint_builder.add_ln_backend( + unit, + PaymentMethod::Bolt11, + mint_melt_limits, + fake.clone(), + ); - supported_units.insert(unit, (input_fee_ppk, 64)); + mint_builder = mint_builder.add_ln_backend( + unit, + PaymentMethod::Bolt12, + mint_melt_limits, + fake.clone(), + ); } - - vec![] } }; - let (nut04_settings, nut05_settings, mpp_settings): ( - nut04::Settings, - nut05::Settings, - Vec, - ) = ln_backends.iter().fold( - ( - nut04::Settings::new(vec![], false), - nut05::Settings::new(vec![], false), - Vec::new(), - ), - |(mut nut_04, mut nut_05, mut mpp), (key, ln)| { - let settings = ln.get_settings(); - - let m = MppMethodSettings { - method: key.method, - unit: key.unit, - mpp: settings.mpp, - }; - - let n4 = MintMethodSettings { - method: key.method, - unit: key.unit, - min_amount: settings.mint_settings.min_amount, - max_amount: settings.mint_settings.max_amount, - description: settings.invoice_description, - }; - - let n5 = MeltMethodSettings { - method: key.method, - unit: key.unit, - min_amount: settings.melt_settings.min_amount, - max_amount: settings.melt_settings.max_amount, - }; - - nut_04.methods.push(n4); - nut_05.methods.push(n5); - mpp.push(m); - - (nut_04, nut_05, mpp) - }, - ); + let support_bolt12_mint = ln_backends.iter().any(|(_k, ln)| { + let settings = ln.get_settings(); + settings.bolt12_mint + }); - let nuts = Nuts::new() - .nut04(nut04_settings) - .nut05(nut05_settings) - .nut07(true) - .nut08(true) - .nut09(true) - .nut10(true) - .nut11(true) - .nut12(true) - .nut14(true) - .nut15(mpp_settings); - - let mut mint_info = MintInfo::new() - .name(settings.mint_info.name) - .version(mint_version) - .description(settings.mint_info.description) - .nuts(nuts); + let support_bolt12_melt = ln_backends.iter().any(|(_k, ln)| { + let settings = ln.get_settings(); + settings.bolt12_melt + }); if let Some(long_description) = &settings.mint_info.description_long { - mint_info = mint_info.long_description(long_description); + mint_builder = mint_builder.with_long_description(long_description.to_string()); } if let Some(contact_info) = contact_info { - mint_info = mint_info.contact_info(contact_info); + for info in contact_info { + mint_builder = mint_builder.add_contact_info(info); + } } if let Some(pubkey) = settings.mint_info.pubkey { - mint_info = mint_info.pubkey(pubkey); + mint_builder = mint_builder.with_pubkey(pubkey); } if let Some(icon_url) = &settings.mint_info.icon_url { - mint_info = mint_info.icon_url(icon_url); + mint_builder = mint_builder.with_icon_url(icon_url.to_string()); } if let Some(motd) = settings.mint_info.motd { - mint_info = mint_info.motd(motd); + mint_builder = mint_builder.with_motd(motd); } let mnemonic = Mnemonic::from_str(&settings.info.mnemonic)?; - let quote_ttl = QuoteTTL::new(10000, 10000); + mint_builder = mint_builder + .with_name(settings.mint_info.name) + .with_mint_url(settings.info.url) + .with_version(mint_version) + .with_description(settings.mint_info.description) + .with_quote_ttl(10000, 10000) + .with_seed(mnemonic.to_seed_normalized("").to_vec()); - let mint = Mint::new( - &settings.info.url, - &mnemonic.to_seed_normalized(""), - mint_info, - quote_ttl, - localstore, - ln_backends.clone(), - supported_units, - ) - .await?; + let mint = mint_builder.build().await?; let mint = Arc::new(mint); @@ -471,7 +326,11 @@ async fn main() -> anyhow::Result<()> { .seconds_to_extend_cache_by .unwrap_or(DEFAULT_CACHE_TTI_SECS); - let v1_service = cdk_axum::create_mint_router(Arc::clone(&mint), cache_ttl, cache_tti).await?; + let include_bolt12 = support_bolt12_mint || support_bolt12_melt; + + let v1_service = + cdk_axum::create_mint_router(Arc::clone(&mint), cache_ttl, cache_tti, include_bolt12) + .await?; let mut mint_service = Router::new() .merge(v1_service) @@ -529,14 +388,14 @@ async fn check_pending_mint_quotes( ln: Arc + Send + Sync>, ) -> Result<()> { let mut pending_quotes = mint.get_pending_mint_quotes().await?; - tracing::trace!("There are {} pending mint quotes.", pending_quotes.len()); + tracing::info!("There are {} pending mint quotes.", pending_quotes.len()); let mut unpaid_quotes = mint.get_unpaid_mint_quotes().await?; - tracing::trace!("There are {} unpaid mint quotes.", unpaid_quotes.len()); + tracing::info!("There are {} unpaid mint quotes.", unpaid_quotes.len()); unpaid_quotes.append(&mut pending_quotes); for quote in unpaid_quotes { - tracing::trace!("Checking status of mint quote: {}", quote.id); + tracing::debug!("Checking status of mint quote: {}", quote.id); let lookup_id = quote.request_lookup_id; match ln.check_incoming_invoice_status(&lookup_id).await { Ok(state) => { @@ -567,8 +426,10 @@ async fn check_pending_melt_quotes( .into_iter() .filter(|q| q.state == MeltQuoteState::Pending || q.state == MeltQuoteState::Unknown) .collect(); + tracing::info!("There are {} pending melt quotes.", pending_quotes.len()); for pending_quote in pending_quotes { + tracing::debug!("Checking status for melt quote {}.", pending_quote.id); let melt_request_ln_key = mint.localstore.get_melt_request(&pending_quote.id).await?; let (melt_request, ln_key) = match melt_request_ln_key { @@ -599,11 +460,7 @@ async fn check_pending_melt_quotes( match pay_invoice_response.status { MeltQuoteState::Paid => { if let Err(err) = mint - .process_melt_request( - &melt_request, - pay_invoice_response.payment_preimage, - pay_invoice_response.total_spent, - ) + .process_melt_request(&melt_request, pay_invoice_response.total_spent) .await { tracing::error!( @@ -645,21 +502,6 @@ async fn check_pending_melt_quotes( Ok(()) } -fn expand_path(path: &str) -> Option { - if path.starts_with('~') { - if let Some(home_dir) = home::home_dir().as_mut() { - let remainder = &path[2..]; - home_dir.push(remainder); - let expanded_path = home_dir; - Some(expanded_path.clone()) - } else { - None - } - } else { - Some(PathBuf::from(path)) - } -} - fn work_dir() -> Result { let home_dir = home::home_dir().ok_or(anyhow!("Unknown home dir"))?; diff --git a/crates/cdk-mintd/src/mint.rs b/crates/cdk-mintd/src/mint.rs new file mode 100644 index 000000000..d4eb1ad05 --- /dev/null +++ b/crates/cdk-mintd/src/mint.rs @@ -0,0 +1,234 @@ +use core::panic; +use std::{collections::HashMap, sync::Arc}; + +use anyhow::anyhow; +use cdk::{ + amount::Amount, + cdk_database::{self, MintDatabase}, + cdk_lightning::{self, MintLightning}, + mint::Mint, + nuts::{ + ContactInfo, CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings, MintVersion, + MppMethodSettings, PaymentMethod, + }, + types::{LnKey, QuoteTTL}, +}; + +/// Cashu Mint +#[derive(Default)] +pub struct MintBuilder { + /// Mint Url + mint_url: Option, + /// Mint Info + mint_info: MintInfo, + /// Mint Storage backend + localstore: Option + Send + Sync>>, + /// Ln backends for mint + ln: Option + Send + Sync>>>, + seed: Option>, + quote_ttl: Option, + supported_units: HashMap, +} + +impl MintBuilder { + pub fn new() -> MintBuilder { + MintBuilder::default() + } + + /// Set localstore + pub fn with_localstore( + mut self, + localstore: Arc + Send + Sync>, + ) -> MintBuilder { + self.localstore = Some(localstore); + self + } + + // Set mint url + pub fn with_mint_url(mut self, mint_url: String) -> Self { + self.mint_url = Some(mint_url); + self + } + + /// Set seed + pub fn with_seed(mut self, seed: Vec) -> Self { + self.seed = Some(seed); + self + } + + /// Set name + pub fn with_name(mut self, name: String) -> Self { + self.mint_info.name = Some(name); + self + } + + /// Set icon url + pub fn with_icon_url(mut self, icon_url: String) -> Self { + self.mint_info.icon_url = Some(icon_url); + self + } + + /// Set icon url + pub fn with_motd(mut self, motd: String) -> Self { + self.mint_info.motd = Some(motd); + self + } + + /// Set description + pub fn with_description(mut self, description: String) -> Self { + self.mint_info.description = Some(description); + self + } + + /// Set long description + pub fn with_long_description(mut self, description: String) -> Self { + self.mint_info.description_long = Some(description); + self + } + + /// Set version + pub fn with_version(mut self, version: MintVersion) -> Self { + self.mint_info.version = Some(version); + self + } + + /// Set contact info + pub fn add_contact_info(mut self, contact_info: ContactInfo) -> Self { + let mut contacts = self.mint_info.contact.clone().unwrap_or_default(); + contacts.push(contact_info); + self.mint_info.contact = Some(contacts); + self + } + + /// Add ln backend + pub fn add_ln_backend( + mut self, + unit: CurrencyUnit, + method: PaymentMethod, + limits: MintMeltLimits, + ln_backend: Arc + Send + Sync>, + ) -> Self { + let ln_key = LnKey { unit, method }; + + let mut ln = self.ln.unwrap_or_default(); + + let settings = ln_backend.get_settings(); + + if settings.mpp { + let mpp_settings = MppMethodSettings { + method, + unit, + mpp: true, + }; + let mut mpp = self.mint_info.nuts.nut15.clone().unwrap_or_default(); + + mpp.methods.push(mpp_settings); + + self.mint_info.nuts.nut15 = Some(mpp); + } + + match method { + PaymentMethod::Bolt11 => { + let mint_method_settings = MintMethodSettings { + method, + unit, + min_amount: Some(limits.mint_min), + max_amount: Some(limits.mint_max), + description: settings.invoice_description, + }; + + self.mint_info.nuts.nut04.methods.push(mint_method_settings); + self.mint_info.nuts.nut04.disabled = false; + + let melt_method_settings = MeltMethodSettings { + method, + unit, + min_amount: Some(limits.melt_min), + max_amount: Some(limits.melt_max), + }; + self.mint_info.nuts.nut05.methods.push(melt_method_settings); + self.mint_info.nuts.nut05.disabled = false; + } + PaymentMethod::Bolt12 => { + let mint_method_settings = MintMethodSettings { + method, + unit, + min_amount: Some(limits.mint_min), + max_amount: Some(limits.mint_max), + description: settings.invoice_description, + }; + + let mut nut18_settings = self.mint_info.nuts.nut18.unwrap_or_default(); + + nut18_settings.methods.push(mint_method_settings); + nut18_settings.disabled = false; + + self.mint_info.nuts.nut18 = Some(nut18_settings); + + let melt_method_settings = MeltMethodSettings { + method, + unit, + min_amount: Some(limits.melt_min), + max_amount: Some(limits.melt_max), + }; + + let mut nut19_settings = self.mint_info.nuts.nut19.unwrap_or_default(); + nut19_settings.methods.push(melt_method_settings); + nut19_settings.disabled = false; + + self.mint_info.nuts.nut19 = Some(nut19_settings); + } + _ => panic!("Unsupported unit"), + } + + ln.insert(ln_key, ln_backend); + + let mut supported_units = self.supported_units.clone(); + + supported_units.insert(ln_key.unit, (0, 32)); + self.supported_units = supported_units; + + self.ln = Some(ln); + + self + } + + /// Set quote ttl + pub fn with_quote_ttl(mut self, mint_ttl: u64, melt_ttl: u64) -> Self { + let quote_ttl = QuoteTTL { mint_ttl, melt_ttl }; + + self.quote_ttl = Some(quote_ttl); + + self + } + + /// Set pubkey + pub fn with_pubkey(mut self, pubkey: cdk::nuts::PublicKey) -> Self { + self.mint_info.pubkey = Some(pubkey); + + self + } + + pub async fn build(&self) -> anyhow::Result { + Ok(Mint::new( + self.mint_url.as_ref().ok_or(anyhow!("Mint url not set"))?, + self.seed.as_ref().ok_or(anyhow!("Mint seed not set"))?, + self.mint_info.clone(), + self.quote_ttl.ok_or(anyhow!("Quote ttl not set"))?, + self.localstore + .clone() + .ok_or(anyhow!("Localstore not set"))?, + self.ln.clone().ok_or(anyhow!("Ln backends not set"))?, + self.supported_units.clone(), + ) + .await?) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct MintMeltLimits { + pub mint_min: Amount, + pub mint_max: Amount, + pub melt_min: Amount, + pub melt_max: Amount, +} diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs new file mode 100644 index 000000000..a88e1ded3 --- /dev/null +++ b/crates/cdk-mintd/src/setup.rs @@ -0,0 +1,229 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use anyhow::{anyhow, bail}; +use axum::{async_trait, Router}; + +use cdk::{cdk_lightning::MintLightning, mint::FeeReserve, mint_url::MintUrl, nuts::CurrencyUnit}; +use tokio::sync::Mutex; +use url::Url; + +use crate::{ + config::{self, Settings}, + expand_path, +}; + +#[async_trait] +pub trait LnBackendSetup { + async fn setup( + &self, + routers: &mut Vec, + settings: &Settings, + unit: CurrencyUnit, + ) -> anyhow::Result; +} + +#[async_trait] +impl LnBackendSetup for config::Cln { + async fn setup( + &self, + _routers: &mut Vec, + _settings: &Settings, + _unit: CurrencyUnit, + ) -> anyhow::Result { + let cln_socket = expand_path( + self.rpc_path + .to_str() + .ok_or(anyhow!("cln socket not defined"))?, + ) + .ok_or(anyhow!("cln socket not defined"))?; + + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + let cln = cdk_cln::Cln::new(cln_socket, fee_reserve, true, true).await?; + + Ok(cln) + } +} + +#[async_trait] +impl LnBackendSetup for config::Strike { + async fn setup( + &self, + routers: &mut Vec, + settings: &Settings, + unit: CurrencyUnit, + ) -> anyhow::Result { + let api_key = &self.api_key; + + // Channel used for strike web hook + let (sender, receiver) = tokio::sync::mpsc::channel(8); + let webhook_endpoint = format!("/webhook/{}/invoice", unit); + + let mint_url: MintUrl = settings.info.url.parse()?; + let webhook_url = mint_url.join(&webhook_endpoint)?; + + let strike = cdk_strike::Strike::new( + api_key.clone(), + unit, + Arc::new(Mutex::new(Some(receiver))), + webhook_url.to_string(), + ) + .await?; + + let router = strike + .create_invoice_webhook(&webhook_endpoint, sender) + .await?; + routers.push(router); + + Ok(strike) + } +} + +#[async_trait] +impl LnBackendSetup for config::LNbits { + async fn setup( + &self, + routers: &mut Vec, + settings: &Settings, + _unit: CurrencyUnit, + ) -> anyhow::Result { + let admin_api_key = &self.admin_api_key; + let invoice_api_key = &self.invoice_api_key; + + // Channel used for lnbits web hook + let (sender, receiver) = tokio::sync::mpsc::channel(8); + let webhook_endpoint = "/webhook/lnbits/sat/invoice"; + + let mint_url: MintUrl = settings.info.url.parse()?; + let webhook_url = mint_url.join(webhook_endpoint)?; + + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + let lnbits = cdk_lnbits::LNbits::new( + admin_api_key.clone(), + invoice_api_key.clone(), + self.lnbits_api.clone(), + fee_reserve, + Arc::new(Mutex::new(Some(receiver))), + webhook_url.to_string(), + ) + .await?; + + let router = lnbits + .create_invoice_webhook_router(webhook_endpoint, sender) + .await?; + + routers.push(router); + + Ok(lnbits) + } +} + +#[async_trait] +impl LnBackendSetup for config::Phoenixd { + async fn setup( + &self, + routers: &mut Vec, + settings: &Settings, + _unit: CurrencyUnit, + ) -> anyhow::Result { + let api_password = &self.api_password; + + let api_url = &self.api_url; + + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + if fee_reserve.percent_fee_reserve < 0.04 { + bail!("Fee reserve is too low needs to be at least 0.02"); + } + + let webhook_endpoint = "/webhook/phoenixd"; + + let mint_url = Url::parse(&settings.info.url)?; + + let webhook_url = mint_url.join(webhook_endpoint)?.to_string(); + + let (sender, receiver) = tokio::sync::mpsc::channel(8); + + let phoenixd = cdk_phoenixd::Phoenixd::new( + api_password.to_string(), + api_url.to_string(), + fee_reserve, + Arc::new(Mutex::new(Some(receiver))), + webhook_url, + )?; + + let router = phoenixd + .create_invoice_webhook(webhook_endpoint, sender) + .await?; + + routers.push(router); + + Ok(phoenixd) + } +} + +#[async_trait] +impl LnBackendSetup for config::Lnd { + async fn setup( + &self, + _routers: &mut Vec, + _settings: &Settings, + _unit: CurrencyUnit, + ) -> anyhow::Result { + let address = &self.address; + let cert_file = &self.cert_file; + let macaroon_file = &self.macaroon_file; + + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + let lnd = cdk_lnd::Lnd::new( + address.to_string(), + cert_file.clone(), + macaroon_file.clone(), + fee_reserve, + ) + .await?; + + Ok(lnd) + } +} + +#[async_trait] +impl LnBackendSetup for config::FakeWallet { + async fn setup( + &self, + _router: &mut Vec, + _settings: &Settings, + _unit: CurrencyUnit, + ) -> anyhow::Result { + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + let fake_wallet = cdk_fake_wallet::FakeWallet::new( + fee_reserve, + HashMap::default(), + HashSet::default(), + 0, + ); + + Ok(fake_wallet) + } +} diff --git a/crates/cdk-phoenixd/Cargo.toml b/crates/cdk-phoenixd/Cargo.toml index cfeb98b81..a8eecade6 100644 --- a/crates/cdk-phoenixd/Cargo.toml +++ b/crates/cdk-phoenixd/Cargo.toml @@ -20,6 +20,7 @@ tokio = { version = "1", default-features = false } tokio-util = { version = "0.7.11", default-features = false } tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] } thiserror = "1" +lightning = { version = "0.0.125", default-features = false, features = ["std"]} # phoenixd-rs = "0.3.0" -phoenixd-rs = { git = "https://github.com/thesimplekid/phoenixd-rs", rev = "22a44f0"} +phoenixd-rs = { git = "https://github.com/thesimplekid/phoenixd-rs", rev = "91dc766f"} uuid = { version = "1", features = ["v4"] } diff --git a/crates/cdk-phoenixd/src/error.rs b/crates/cdk-phoenixd/src/error.rs index 85e56c4eb..955343940 100644 --- a/crates/cdk-phoenixd/src/error.rs +++ b/crates/cdk-phoenixd/src/error.rs @@ -17,6 +17,12 @@ pub enum Error { /// phd error #[error(transparent)] Phd(#[from] phoenixd_rs::Error), + /// Wrong invoice type + #[error("Wrong invoice type")] + WrongRequestType, + /// Unsupported method + #[error("Unsupported method")] + UnsupportedMethod, /// Anyhow error #[error(transparent)] Anyhow(#[from] anyhow::Error), diff --git a/crates/cdk-phoenixd/src/lib.rs b/crates/cdk-phoenixd/src/lib.rs index 4aecfac6b..e5a8a9052 100644 --- a/crates/cdk-phoenixd/src/lib.rs +++ b/crates/cdk-phoenixd/src/lib.rs @@ -4,24 +4,28 @@ #![warn(rustdoc::bare_urls)] use std::pin::Pin; +use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use anyhow::anyhow; use async_trait::async_trait; use axum::Router; -use cdk::amount::{to_unit, Amount, MSAT_IN_SAT}; +use cdk::amount::{amount_for_offer, to_unit, Amount}; use cdk::cdk_lightning::{ - self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, + self, Bolt12PaymentQuoteResponse, CreateInvoiceResponse, CreateOfferResponse, MintLightning, + PayInvoiceResponse, PaymentQuoteResponse, Settings, }; +use cdk::mint::types::PaymentRequest; use cdk::mint::FeeReserve; use cdk::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, + CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteBolt12Request, MeltQuoteState, MintQuoteState, }; +use cdk::util::hex; use cdk::{mint, Bolt11Invoice}; use error::Error; use futures::{Stream, StreamExt}; +use lightning::offers::offer::Offer; use phoenixd_rs::webhooks::WebhookResponse; use phoenixd_rs::{InvoiceRequest, Phoenixd as PhoenixdApi}; use tokio::sync::Mutex; @@ -32,8 +36,6 @@ pub mod error; /// Phoenixd #[derive(Clone)] pub struct Phoenixd { - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, phoenixd_api: PhoenixdApi, fee_reserve: FeeReserve, receiver: Arc>>>, @@ -47,16 +49,12 @@ impl Phoenixd { pub fn new( api_password: String, api_url: String, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, fee_reserve: FeeReserve, receiver: Arc>>>, webhook_url: String, ) -> Result { let phoenixd = PhoenixdApi::new(&api_password, &api_url)?; Ok(Self { - mint_settings, - melt_settings, phoenixd_api: phoenixd, fee_reserve, receiver, @@ -86,8 +84,8 @@ impl MintLightning for Phoenixd { Settings { mpp: false, unit: CurrencyUnit::Sat, - mint_settings: self.mint_settings, - melt_settings: self.melt_settings, + bolt12_mint: false, + bolt12_melt: true, invoice_description: true, } } @@ -103,7 +101,7 @@ impl MintLightning for Phoenixd { #[allow(clippy::incompatible_msrv)] async fn wait_any_invoice( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { let receiver = self .receiver .lock() @@ -138,7 +136,7 @@ impl MintLightning for Phoenixd { Ok(state) => { if state.is_paid { // Yield the payment hash and continue the stream - Some((msg.payment_hash, (receiver, phoenixd_api, cancel_token, is_active))) + Some(((msg.payment_hash, Amount::ZERO), (receiver, phoenixd_api, cancel_token, is_active))) } else { // Invoice not paid yet, continue waiting // We need to continue the stream, so we return the same state @@ -210,9 +208,14 @@ impl MintLightning for Phoenixd { partial_amount: Option, _max_fee_msats: Option, ) -> Result { + let bolt11 = &match melt_quote.request { + PaymentRequest::Bolt11 { bolt11 } => bolt11, + PaymentRequest::Bolt12 { .. } => return Err(Error::WrongRequestType.into()), + }; + let pay_response = self .phoenixd_api - .pay_bolt11_invoice(&melt_quote.request, partial_amount.map(|a| a.into())) + .pay_bolt11_invoice(&bolt11.to_string(), partial_amount.map(|a| a.into())) .await?; // The pay invoice response does not give the needed fee info so we have to check. @@ -220,12 +223,10 @@ impl MintLightning for Phoenixd { .check_outgoing_payment(&pay_response.payment_id) .await?; - let bolt11: Bolt11Invoice = melt_quote.request.parse()?; - Ok(PayInvoiceResponse { payment_lookup_id: bolt11.payment_hash().to_string(), payment_preimage: Some(pay_response.payment_preimage), - status: MeltQuoteState::Paid, + status: check_outgoing_response.status, total_spent: check_outgoing_response.total_spent, unit: CurrencyUnit::Sat, }) @@ -279,6 +280,19 @@ impl MintLightning for Phoenixd { &self, payment_id: &str, ) -> Result { + // We can only check the status of the payment if we have the payment id not if we only have a payment hash. + // In phd this is a uuid, that we get after getting a response from the pay invoice + if let Err(_err) = uuid::Uuid::from_str(payment_id) { + tracing::warn!("Could not check status of payment, no payment id"); + return Ok(PayInvoiceResponse { + payment_lookup_id: payment_id.to_string(), + payment_preimage: None, + status: MeltQuoteState::Unknown, + total_spent: Amount::ZERO, + unit: CurrencyUnit::Sat, + }); + } + let res = self.phoenixd_api.get_outgoing_invoice(payment_id).await; let state = match res { @@ -288,13 +302,11 @@ impl MintLightning for Phoenixd { false => MeltQuoteState::Unpaid, }; - let total_spent = res.sent + (res.fees + 999) / MSAT_IN_SAT; - PayInvoiceResponse { payment_lookup_id: res.payment_hash, payment_preimage: Some(res.preimage), status, - total_spent: total_spent.into(), + total_spent: res.sent.into(), unit: CurrencyUnit::Sat, } } @@ -314,4 +326,97 @@ impl MintLightning for Phoenixd { Ok(state) } + + async fn get_bolt12_payment_quote( + &self, + melt_quote_request: &MeltQuoteBolt12Request, + ) -> Result { + if CurrencyUnit::Sat != melt_quote_request.unit { + return Err(Error::UnsupportedUnit.into()); + } + + let offer = Offer::from_str(&melt_quote_request.request) + .map_err(|_| Error::Anyhow(anyhow!("Invalid offer")))?; + + let amount = match melt_quote_request.amount { + Some(amount) => amount, + None => amount_for_offer(&offer, &CurrencyUnit::Sat)?, + }; + + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + + let mut fee = match relative_fee_reserve > absolute_fee_reserve { + true => relative_fee_reserve, + false => absolute_fee_reserve, + }; + + // Fee in phoenixd is always 0.04 + 4 sat + fee += 4; + + Ok(Bolt12PaymentQuoteResponse { + request_lookup_id: hex::encode(offer.id().0), + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + invoice: None, + }) + } + + async fn pay_bolt12_offer( + &self, + melt_quote: mint::MeltQuote, + amount: Option, + _max_fee_amount: Option, + ) -> Result { + let offer = &match melt_quote.request { + PaymentRequest::Bolt12 { offer, invoice: _ } => offer, + PaymentRequest::Bolt11 { .. } => return Err(Error::WrongRequestType.into()), + }; + + let amount = match amount { + Some(amount) => amount, + None => amount_for_offer(offer, &CurrencyUnit::Sat)?, + }; + + let pay_response = self + .phoenixd_api + .pay_bolt12_offer(offer.to_string(), amount.into(), None) + .await?; + + // The pay invoice response does not give the needed fee info so we have to check. + let check_outgoing_response = self + .check_outgoing_payment(&pay_response.payment_id) + .await?; + + tracing::debug!( + "Phd offer {} with amount {} with fee {} total spent {}", + check_outgoing_response.status, + amount, + check_outgoing_response.total_spent - amount, + check_outgoing_response.total_spent + ); + + Ok(PayInvoiceResponse { + payment_lookup_id: pay_response.payment_id, + payment_preimage: Some(pay_response.payment_preimage), + status: check_outgoing_response.status, + total_spent: check_outgoing_response.total_spent, + unit: CurrencyUnit::Sat, + }) + } + + /// Create bolt12 offer + async fn create_bolt12_offer( + &self, + _amount: Option, + _unit: &CurrencyUnit, + _description: String, + _unix_expiry: u64, + _single_use: bool, + ) -> Result { + Err(Error::UnsupportedMethod.into()) + } } diff --git a/crates/cdk-redb/Cargo.toml b/crates/cdk-redb/Cargo.toml index d7ec6b509..a1a5908b7 100644 --- a/crates/cdk-redb/Cargo.toml +++ b/crates/cdk-redb/Cargo.toml @@ -17,6 +17,7 @@ wallet = ["cdk/wallet"] [dependencies] async-trait = "0.1" +anyhow = "1" cdk = { path = "../cdk", version = "0.4.0", default-features = false } redb = "2.1.0" thiserror = "1" diff --git a/crates/cdk-redb/src/error.rs b/crates/cdk-redb/src/error.rs index 556adb78f..d2a08283d 100644 --- a/crates/cdk-redb/src/error.rs +++ b/crates/cdk-redb/src/error.rs @@ -58,6 +58,9 @@ pub enum Error { /// Unknown Database Version #[error("Unknown database version")] UnknownDatabaseVersion, + /// Anyhow error + #[error(transparent)] + Anyhow(#[from] anyhow::Error), } impl From for cdk::cdk_database::Error { diff --git a/crates/cdk-redb/src/mint/migrations.rs b/crates/cdk-redb/src/mint/migrations.rs index 90feaeffb..0f8101406 100644 --- a/crates/cdk-redb/src/mint/migrations.rs +++ b/crates/cdk-redb/src/mint/migrations.rs @@ -3,15 +3,16 @@ use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; +use cdk::mint::types::PaymentRequest; use cdk::mint::MintQuote; use cdk::mint_url::MintUrl; -use cdk::nuts::{CurrencyUnit, MintQuoteState, Proof, State}; +use cdk::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState, Proof, State}; use cdk::Amount; use lightning_invoice::Bolt11Invoice; use redb::{Database, MultimapTableDefinition, ReadableTable, TableDefinition}; use serde::{Deserialize, Serialize}; -use super::{Error, PROOFS_STATE_TABLE, PROOFS_TABLE, QUOTE_SIGNATURES_TABLE}; +use super::{Error, MELT_QUOTES_TABLE, PROOFS_STATE_TABLE, PROOFS_TABLE, QUOTE_SIGNATURES_TABLE}; const MINT_QUOTES_TABLE: TableDefinition<&str, &str> = TableDefinition::new("mint_quotes"); const PENDING_PROOFS_TABLE: TableDefinition<[u8; 33], &str> = @@ -29,6 +30,7 @@ pub fn migrate_02_to_03(db: Arc) -> Result { migrate_mint_proofs_02_to_03(db)?; Ok(3) } + pub fn migrate_03_to_04(db: Arc) -> Result { let write_txn = db.begin_write()?; let _ = write_txn.open_multimap_table(QUOTE_PROOFS_TABLE)?; @@ -36,6 +38,118 @@ pub fn migrate_03_to_04(db: Arc) -> Result { Ok(4) } +/// Melt Quote Info +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct V04MeltQuote { + /// Quote id + pub id: String, + /// Quote unit + pub unit: CurrencyUnit, + /// Quote amount + pub amount: Amount, + /// Quote Payment request e.g. bolt11 + pub request: String, + /// Quote fee reserve + pub fee_reserve: Amount, + /// Quote state + pub state: MeltQuoteState, + /// Expiration time of quote + pub expiry: u64, + /// Payment preimage + pub payment_preimage: Option, + /// Value used by ln backend to look up state of request + pub request_lookup_id: String, +} + +/// Melt Quote Info +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct V05MeltQuote { + /// Quote id + pub id: String, + /// Quote unit + pub unit: CurrencyUnit, + /// Quote amount + pub amount: Amount, + /// Quote Payment request e.g. bolt11 + pub request: PaymentRequest, + /// Quote fee reserve + pub fee_reserve: Amount, + /// Quote state + pub state: MeltQuoteState, + /// Expiration time of quote + pub expiry: u64, + /// Payment preimage + pub payment_preimage: Option, + /// Value used by ln backend to look up state of request + pub request_lookup_id: String, +} + +impl TryFrom for V05MeltQuote { + type Error = anyhow::Error; + fn try_from(melt_quote: V04MeltQuote) -> anyhow::Result { + let V04MeltQuote { + id, + unit, + amount, + request, + fee_reserve, + state, + expiry, + payment_preimage, + request_lookup_id, + } = melt_quote; + + let bolt11 = Bolt11Invoice::from_str(&request)?; + + let payment_request = PaymentRequest::Bolt11 { bolt11 }; + + Ok(V05MeltQuote { + id, + unit, + amount, + request: payment_request, + fee_reserve, + state, + expiry, + payment_preimage, + request_lookup_id, + }) + } +} + +pub fn migrate_04_to_05(db: Arc) -> anyhow::Result { + let quotes: Vec<_>; + { + let read_txn = db.begin_write()?; + let table = read_txn.open_table(MELT_QUOTES_TABLE)?; + + quotes = table + .iter()? + .flatten() + .map(|(k, v)| (k.value().to_string(), v.value().to_string())) + .collect(); + } + + let write_txn = db.begin_write()?; + { + let mut table = write_txn.open_table(MELT_QUOTES_TABLE)?; + + for (quote_id, quote) in quotes { + let melt_quote: V04MeltQuote = serde_json::from_str("e)?; + + let v05_melt_quote: V05MeltQuote = melt_quote.try_into()?; + + table.insert( + quote_id.as_str(), + serde_json::to_string(&v05_melt_quote)?.as_str(), + )?; + } + } + write_txn.commit()?; + + Ok(5) +} + /// Mint Quote Info #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] struct V1MintQuote { @@ -53,12 +167,16 @@ impl From for MintQuote { MintQuote { id: quote.id, mint_url: quote.mint_url, - amount: quote.amount, + amount: Some(quote.amount), unit: quote.unit, request: quote.request.clone(), state: quote.state, expiry: quote.expiry, request_lookup_id: Bolt11Invoice::from_str("e.request).unwrap().to_string(), + // TODO: Create real migrations + amount_paid: Amount::ZERO, + amount_issued: Amount::ZERO, + single_use: None, } } } diff --git a/crates/cdk-redb/src/mint/mod.rs b/crates/cdk-redb/src/mint/mod.rs index 0ca822e9f..291215314 100644 --- a/crates/cdk-redb/src/mint/mod.rs +++ b/crates/cdk-redb/src/mint/mod.rs @@ -22,7 +22,7 @@ use redb::{Database, MultimapTableDefinition, ReadableTable, TableDefinition}; use super::error::Error; use crate::migrations::migrate_00_to_01; -use crate::mint::migrations::{migrate_02_to_03, migrate_03_to_04}; +use crate::mint::migrations::{migrate_02_to_03, migrate_03_to_04, migrate_04_to_05}; mod migrations; @@ -43,7 +43,7 @@ const QUOTE_SIGNATURES_TABLE: MultimapTableDefinition<&str, [u8; 33]> = const MELT_REQUESTS: TableDefinition<&str, (&str, &str)> = TableDefinition::new("melt_requests"); -const DATABASE_VERSION: u32 = 4; +const DATABASE_VERSION: u32 = 5; /// Mint Redbdatabase #[derive(Debug, Clone)] @@ -93,6 +93,10 @@ impl MintRedbDatabase { current_file_version = migrate_03_to_04(Arc::clone(&db))?; } + if current_file_version == 4 { + current_file_version = migrate_04_to_05(Arc::clone(&db))?; + } + if current_file_version != DATABASE_VERSION { tracing::warn!( "Database upgrade did not complete at {} current is {}", diff --git a/crates/cdk-sqlite/src/mint/error.rs b/crates/cdk-sqlite/src/mint/error.rs index 5f2be0907..10dc7a5bb 100644 --- a/crates/cdk-sqlite/src/mint/error.rs +++ b/crates/cdk-sqlite/src/mint/error.rs @@ -41,6 +41,9 @@ pub enum Error { /// Invalid Database Path #[error("Invalid database path")] InvalidDbPath, + /// Invalid bolt11 + #[error("Invalid bolt11")] + InvalidBolt11, /// Serde Error #[error(transparent)] Serde(#[from] serde_json::Error), diff --git a/crates/cdk-sqlite/src/mint/migrations/20241002093700_unknown_status_for_quote.sql b/crates/cdk-sqlite/src/mint/migrations/20241002093700_unknown_status_for_quote.sql new file mode 100644 index 000000000..1f9242361 --- /dev/null +++ b/crates/cdk-sqlite/src/mint/migrations/20241002093700_unknown_status_for_quote.sql @@ -0,0 +1,23 @@ +-- Create a new table with the updated CHECK constraint +CREATE TABLE melt_quote_new ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + amount INTEGER NOT NULL, + request TEXT NOT NULL, + fee_reserve INTEGER NOT NULL, + expiry INTEGER NOT NULL, + state TEXT CHECK ( state IN ('UNPAID', 'PENDING', 'PAID', 'UNKNOWN') ) NOT NULL DEFAULT 'UNPAID', + payment_preimage TEXT, + request_lookup_id TEXT +); + +-- Copy the data from the old table to the new table +INSERT INTO melt_quote_new (id, unit, amount, request, fee_reserve, expiry, state, payment_preimage, request_lookup_id) +SELECT id, unit, amount, request, fee_reserve, expiry, state, payment_preimage, request_lookup_id +FROM melt_quote; + +-- Drop the old table +DROP TABLE melt_quote; + +-- Rename the new table to the original table name +ALTER TABLE melt_quote_new RENAME TO melt_quote; diff --git a/crates/cdk-sqlite/src/mint/mod.rs b/crates/cdk-sqlite/src/mint/mod.rs index c72e5220a..121d43dde 100644 --- a/crates/cdk-sqlite/src/mint/mod.rs +++ b/crates/cdk-sqlite/src/mint/mod.rs @@ -8,6 +8,7 @@ use std::time::Duration; use async_trait::async_trait; use bitcoin::bip32::DerivationPath; use cdk::cdk_database::{self, MintDatabase}; +use cdk::mint::types::PaymentRequest; use cdk::mint::{MintKeySetInfo, MintQuote}; use cdk::mint_url::MintUrl; use cdk::nuts::nut00::ProofsMethods; @@ -211,7 +212,8 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?); ) .bind(quote.id.to_string()) .bind(quote.mint_url.to_string()) - .bind(u64::from(quote.amount) as i64) + // REVIEW: Should this be 0 + .bind(u64::from(quote.amount.unwrap_or(Amount::ZERO)) as i64) .bind(quote.unit.to_string()) .bind(quote.request) .bind(quote.state.to_string()) @@ -472,7 +474,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); .bind(quote.id.to_string()) .bind(quote.unit.to_string()) .bind(u64::from(quote.amount) as i64) - .bind(quote.request) + .bind(serde_json::to_string("e.request)?) .bind(u64::from(quote.fee_reserve) as i64) .bind(quote.state.to_string()) .bind(quote.expiry as i64) @@ -1289,12 +1291,16 @@ fn sqlite_row_to_mint_quote(row: SqliteRow) -> Result { Ok(MintQuote { id: row_id, mint_url: MintUrl::from_str(&row_mint_url)?, - amount: Amount::from(row_amount as u64), + amount: Some(Amount::from(row_amount as u64)), unit: CurrencyUnit::from_str(&row_unit).map_err(Error::from)?, request: row_request, state: MintQuoteState::from_str(&row_state).map_err(Error::from)?, expiry: row_expiry as u64, request_lookup_id, + // TODO: Get these values + amount_paid: Amount::ZERO, + amount_issued: Amount::ZERO, + single_use: None, }) } @@ -1312,11 +1318,20 @@ fn sqlite_row_to_melt_quote(row: SqliteRow) -> Result { let request_lookup_id = row_request_lookup.unwrap_or(row_request.clone()); + let request: PaymentRequest = match serde_json::from_str(&row_request) { + Ok(request) => request, + Err(_) => { + let bolt11 = Bolt11Invoice::from_str(&row_request).map_err(|_| Error::InvalidBolt11)?; + + PaymentRequest::Bolt11 { bolt11 } + } + }; + Ok(mint::MeltQuote { id: row_id, amount: Amount::from(row_amount as u64), unit: CurrencyUnit::from_str(&row_unit).map_err(Error::from)?, - request: row_request, + request, fee_reserve: Amount::from(row_fee_reserve as u64), state: QuoteState::from_str(&row_state)?, expiry: row_expiry as u64, diff --git a/crates/cdk-sqlite/src/wallet/migrations/20241001184621_melt_quote_payment_method.sql b/crates/cdk-sqlite/src/wallet/migrations/20241001184621_melt_quote_payment_method.sql new file mode 100644 index 000000000..b0b6a5b3d --- /dev/null +++ b/crates/cdk-sqlite/src/wallet/migrations/20241001184621_melt_quote_payment_method.sql @@ -0,0 +1 @@ +ALTER TABLE melt_quote ADD payment_method TEXT; diff --git a/crates/cdk-sqlite/src/wallet/mod.rs b/crates/cdk-sqlite/src/wallet/mod.rs index f45c6bb44..bb740eca3 100644 --- a/crates/cdk-sqlite/src/wallet/mod.rs +++ b/crates/cdk-sqlite/src/wallet/mod.rs @@ -9,8 +9,8 @@ use cdk::amount::Amount; use cdk::cdk_database::{self, WalletDatabase}; use cdk::mint_url::MintUrl; use cdk::nuts::{ - CurrencyUnit, Id, KeySetInfo, Keys, MeltQuoteState, MintInfo, MintQuoteState, Proof, PublicKey, - SpendingConditions, State, + CurrencyUnit, Id, KeySetInfo, Keys, MeltQuoteState, MintInfo, MintQuoteState, PaymentMethod, + Proof, PublicKey, SpendingConditions, State, }; use cdk::secret::Secret; use cdk::types::ProofInfo; @@ -419,8 +419,8 @@ WHERE id=? sqlx::query( r#" INSERT OR REPLACE INTO melt_quote -(id, unit, amount, request, fee_reserve, state, expiry) -VALUES (?, ?, ?, ?, ?, ?, ?); +(id, unit, amount, request, fee_reserve, state, expiry, payment_method) +VALUES (?, ?, ?, ?, ?, ?, ?, ?); "#, ) .bind(quote.id.to_string()) @@ -430,6 +430,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?); .bind(u64::from(quote.fee_reserve) as i64) .bind(quote.state.to_string()) .bind(quote.expiry as i64) + .bind(quote.payment_method.to_string()) .execute(&self.pool) .await .map_err(Error::from)?; @@ -838,6 +839,9 @@ fn sqlite_row_to_melt_quote(row: &SqliteRow) -> Result let row_state: String = row.try_get("state").map_err(Error::from)?; let row_expiry: i64 = row.try_get("expiry").map_err(Error::from)?; let row_preimage: Option = row.try_get("payment_preimage").map_err(Error::from)?; + let row_payment_method: Option = row.try_get("payment_method").map_err(Error::from)?; + + let payment_method = row_payment_method.and_then(|p| PaymentMethod::from_str(&p).ok()); let state = MeltQuoteState::from_str(&row_state)?; Ok(wallet::MeltQuote { @@ -845,6 +849,7 @@ fn sqlite_row_to_melt_quote(row: &SqliteRow) -> Result amount: Amount::from(row_amount as u64), unit: CurrencyUnit::from_str(&row_unit).map_err(Error::from)?, request: row_request, + payment_method: payment_method.unwrap_or_default(), fee_reserve: Amount::from(row_fee_reserve as u64), state, expiry: row_expiry as u64, diff --git a/crates/cdk-strike/src/error.rs b/crates/cdk-strike/src/error.rs index b9915d8d8..a7316e95d 100644 --- a/crates/cdk-strike/src/error.rs +++ b/crates/cdk-strike/src/error.rs @@ -14,6 +14,9 @@ pub enum Error { /// Strikers error #[error(transparent)] StrikeRs(#[from] strike_rs::Error), + /// Unsupported method + #[error("Unsupported method")] + UnsupportedMethod, /// Anyhow error #[error(transparent)] Anyhow(#[from] anyhow::Error), diff --git a/crates/cdk-strike/src/lib.rs b/crates/cdk-strike/src/lib.rs index 76eb0f256..7a0251f78 100644 --- a/crates/cdk-strike/src/lib.rs +++ b/crates/cdk-strike/src/lib.rs @@ -12,11 +12,11 @@ use async_trait::async_trait; use axum::Router; use cdk::amount::Amount; use cdk::cdk_lightning::{ - self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, + self, Bolt12PaymentQuoteResponse, CreateInvoiceResponse, CreateOfferResponse, MintLightning, + PayInvoiceResponse, PaymentQuoteResponse, Settings, }; use cdk::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, + CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteBolt12Request, MeltQuoteState, MintQuoteState, }; use cdk::util::unix_time; use cdk::{mint, Bolt11Invoice}; @@ -37,8 +37,6 @@ pub mod error; #[derive(Clone)] pub struct Strike { strike_api: StrikeApi, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, unit: CurrencyUnit, receiver: Arc>>>, webhook_url: String, @@ -50,8 +48,6 @@ impl Strike { /// Create new [`Strike`] wallet pub async fn new( api_key: String, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, unit: CurrencyUnit, receiver: Arc>>>, webhook_url: String, @@ -59,8 +55,6 @@ impl Strike { let strike = StrikeApi::new(&api_key, None)?; Ok(Self { strike_api: strike, - mint_settings, - melt_settings, receiver, unit, webhook_url, @@ -78,8 +72,8 @@ impl MintLightning for Strike { Settings { mpp: false, unit: self.unit, - mint_settings: self.mint_settings, - melt_settings: self.melt_settings, + bolt12_mint: false, + bolt12_melt: false, invoice_description: true, } } @@ -95,7 +89,7 @@ impl MintLightning for Strike { #[allow(clippy::incompatible_msrv)] async fn wait_any_invoice( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { self.strike_api .subscribe_to_invoice_webhook(self.webhook_url.clone()) .await?; @@ -135,7 +129,7 @@ impl MintLightning for Strike { match check { Ok(state) => { if state.state == InvoiceState::Paid { - Some((msg, (receiver, strike_api, cancel_token, is_active))) + Some(((msg, Amount::ZERO), (receiver, strike_api, cancel_token, is_active))) } else { None } @@ -307,6 +301,34 @@ impl MintLightning for Strike { Ok(pay_invoice_response) } + + async fn get_bolt12_payment_quote( + &self, + _melt_quote_request: &MeltQuoteBolt12Request, + ) -> Result { + Err(Error::UnsupportedMethod.into()) + } + + async fn pay_bolt12_offer( + &self, + _melt_quote: mint::MeltQuote, + _amount: Option, + _max_fee_amount: Option, + ) -> Result { + Err(Error::UnsupportedMethod.into()) + } + + /// Create bolt12 offer + async fn create_bolt12_offer( + &self, + _amount: Option, + _unit: &CurrencyUnit, + _description: String, + _unix_expiry: u64, + _single_use: bool, + ) -> Result { + Err(Error::UnsupportedMethod.into()) + } } impl Strike { diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index b72e50d23..2da522fd1 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -25,6 +25,7 @@ bitcoin = { version= "0.32.2", features = ["base64", "serde", "rand", "rand-std" ciborium = { version = "0.2.2", default-features = false, features = ["std"] } cbor-diag = "0.1.12" lightning-invoice = { version = "0.32.0", features = ["serde", "std"] } +lightning = { version = "0.0.125", default-features = false, features = ["std"]} once_cell = "1.19" regex = "1" reqwest = { version = "0.12", default-features = false, features = [ diff --git a/crates/cdk/src/amount.rs b/crates/cdk/src/amount.rs index d2c599d04..087d01784 100644 --- a/crates/cdk/src/amount.rs +++ b/crates/cdk/src/amount.rs @@ -7,6 +7,8 @@ use std::fmt; use std::str::FromStr; use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use lightning::offers::offer::Offer; use thiserror::Error; use crate::nuts::CurrencyUnit; @@ -23,6 +25,12 @@ pub enum Error { /// Cannot convert units #[error("Cannot convert units")] CannotConvertUnits, + /// Amount undefinded + #[error("Amount undefined")] + AmountUndefinded, + /// Utf8 parse error + #[error(transparent)] + Utf8ParseError(#[from] std::string::FromUtf8Error), } /// Amount can be any unit @@ -300,6 +308,27 @@ where } } +/// Convert offer to amount in unit +pub fn amount_for_offer(offer: &Offer, unit: &CurrencyUnit) -> Result { + let offer_amount = offer.amount().ok_or(Error::AmountUndefinded)?; + + let (amount, currency) = match offer_amount { + lightning::offers::offer::Amount::Bitcoin { amount_msats } => { + (amount_msats, CurrencyUnit::Msat) + } + lightning::offers::offer::Amount::Currency { + iso4217_code, + amount, + } => ( + amount, + CurrencyUnit::from_str(&String::from_utf8(iso4217_code.to_vec())?) + .map_err(|_| Error::CannotConvertUnits)?, + ), + }; + + to_unit(amount, ¤cy, unit).map_err(|_err| Error::CannotConvertUnits) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cdk/src/cdk_lightning/mod.rs b/crates/cdk/src/cdk_lightning/mod.rs index eb94fb4b2..1c0d58de9 100644 --- a/crates/cdk/src/cdk_lightning/mod.rs +++ b/crates/cdk/src/cdk_lightning/mod.rs @@ -4,14 +4,13 @@ use std::pin::Pin; use async_trait::async_trait; use futures::Stream; +use lightning::offers::offer::Offer; use lightning_invoice::{Bolt11Invoice, ParseOrSemanticError}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, -}; +use crate::nuts::nut20::MeltQuoteBolt12Request; +use crate::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use crate::{mint, Amount}; /// CDK Lightning Error @@ -23,12 +22,18 @@ pub enum Error { /// Invoice pay pending #[error("Invoice pay is pending")] InvoicePaymentPending, + /// Invoice amount unknown + #[error("Invoice amount unknown")] + InvoiceAmountUnknown, /// Unsupported unit #[error("Unsupported unit")] UnsupportedUnit, /// Payment state is unknown #[error("Payment state is unknown")] UnknownPaymentState, + /// Utf8 parse error + #[error(transparent)] + Utf8ParseError(#[from] std::string::FromUtf8Error), /// Lightning Error #[error(transparent)] Lightning(Box), @@ -83,7 +88,7 @@ pub trait MintLightning { /// Returns a stream of request_lookup_id once invoices are paid async fn wait_any_invoice( &self, - ) -> Result + Send>>, Self::Err>; + ) -> Result + Send>>, Self::Err>; /// Is wait invoice active fn is_wait_invoice_active(&self) -> bool; @@ -102,10 +107,34 @@ pub trait MintLightning { &self, request_lookup_id: &str, ) -> Result; + + /// Bolt12 Payment quote + async fn get_bolt12_payment_quote( + &self, + melt_quote_request: &MeltQuoteBolt12Request, + ) -> Result; + + /// Pay a bolt12 offer + async fn pay_bolt12_offer( + &self, + melt_quote: mint::MeltQuote, + amount: Option, + max_fee_amount: Option, + ) -> Result; + + /// Create bolt12 offer + async fn create_bolt12_offer( + &self, + amount: Option, + unit: &CurrencyUnit, + description: String, + unix_expiry: u64, + single_use: bool, + ) -> Result; } /// Create invoice response -#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct CreateInvoiceResponse { /// Id that is used to look up the invoice from the ln backend pub request_lookup_id: String, @@ -115,6 +144,17 @@ pub struct CreateInvoiceResponse { pub expiry: Option, } +/// Create offer response +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct CreateOfferResponse { + /// Id that is used to look up the invoice from the ln backend + pub request_lookup_id: String, + /// Bolt11 payment request + pub request: Offer, + /// Unix Expiry of Invoice + pub expiry: Option, +} + /// Pay invoice response #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct PayInvoiceResponse { @@ -143,15 +183,30 @@ pub struct PaymentQuoteResponse { pub state: MeltQuoteState, } -/// Ln backend settings +/// Payment quote response #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct Bolt12PaymentQuoteResponse { + /// Request look up id + pub request_lookup_id: String, + /// Amount + pub amount: Amount, + /// Fee required for melt + pub fee: Amount, + /// Status + pub state: MeltQuoteState, + /// Bolt12 invoice + pub invoice: Option, +} + +/// Ln backend settings +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Settings { /// MPP supported pub mpp: bool, - /// Min amount to mint - pub mint_settings: MintMethodSettings, - /// Max amount to mint - pub melt_settings: MeltMethodSettings, + /// Supports bolt12 mint + pub bolt12_mint: bool, + /// Supports bolt12 melt + pub bolt12_melt: bool, /// Base unit of backend pub unit: CurrencyUnit, /// Invoice Description supported diff --git a/crates/cdk/src/error.rs b/crates/cdk/src/error.rs index 1695a58c7..61c2aaadf 100644 --- a/crates/cdk/src/error.rs +++ b/crates/cdk/src/error.rs @@ -193,6 +193,9 @@ pub enum Error { /// From hex error #[error(transparent)] ReqwestError(#[from] reqwest::Error), + /// Bolt12 parse error + #[error("BOLT12 Parse error")] + Bolt12Parse, // Crate error conversions /// Cashu Url Error @@ -237,12 +240,24 @@ pub enum Error { /// NUT18 Error #[error(transparent)] NUT18(#[from] crate::nuts::nut18::Error), + /// NUT19 Error + #[error(transparent)] + NUT19(#[from] crate::nuts::nut19::Error), + /// NUT18 Error + #[error(transparent)] + NUT20(#[from] crate::nuts::nut20::Error), /// Database Error #[cfg(any(feature = "wallet", feature = "mint"))] #[error(transparent)] Database(#[from] crate::cdk_database::Error), } +impl From for Error { + fn from(_err: lightning::offers::parse::Bolt12ParseError) -> Error { + Error::Bolt12Parse + } +} + /// CDK Error Response /// /// See NUT definition in [00](https://github.com/cashubtc/nuts/blob/main/00.md) diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs index 53268b4d2..d94263a90 100644 --- a/crates/cdk/src/mint/melt.rs +++ b/crates/cdk/src/mint/melt.rs @@ -1,10 +1,12 @@ use std::collections::HashSet; use std::str::FromStr; +use std::sync::Arc; use anyhow::bail; -use lightning_invoice::Bolt11Invoice; +use lightning::offers::offer::Offer; use tracing::instrument; +use crate::amount::amount_for_offer; use crate::cdk_lightning; use crate::cdk_lightning::MintLightning; use crate::cdk_lightning::PayInvoiceResponse; @@ -16,9 +18,12 @@ use crate::{ Amount, Error, }; +use super::nut05::MeltRequestTrait; +use super::BlindSignature; +use super::MeltQuoteBolt12Request; use super::{ - CurrencyUnit, MeltBolt11Request, MeltQuote, MeltQuoteBolt11Request, MeltQuoteBolt11Response, - Mint, PaymentMethod, PublicKey, State, + CurrencyUnit, MeltQuote, MeltQuoteBolt11Request, MeltQuoteBolt11Response, Mint, PaymentMethod, + PaymentRequest, PublicKey, State, }; impl Mint { @@ -95,8 +100,84 @@ impl Mint { Error::UnitUnsupported })?; + let request = PaymentRequest::Bolt11 { + bolt11: request.clone(), + }; + let quote = MeltQuote::new( - request.to_string(), + request, + *unit, + payment_quote.amount, + payment_quote.fee, + unix_time() + self.quote_ttl.melt_ttl, + payment_quote.request_lookup_id.clone(), + ); + + tracing::debug!( + "New melt quote {} for {} {} with request id {}", + quote.id, + amount, + unit, + payment_quote.request_lookup_id + ); + + self.localstore.add_melt_quote(quote.clone()).await?; + + Ok(quote.into()) + } + + /// Get melt bolt12 quote + #[instrument(skip_all)] + pub async fn get_melt_bolt12_quote( + &self, + melt_request: &MeltQuoteBolt12Request, + ) -> Result { + let MeltQuoteBolt12Request { + request, + unit, + amount, + } = melt_request; + + let offer = Offer::from_str(request).unwrap(); + + let amount = match amount { + Some(amount) => *amount, + None => amount_for_offer(&offer, unit).map_err(|_| Error::UnsupportedUnit)?, + }; + + self.check_melt_request_acceptable(amount, *unit, PaymentMethod::Bolt12)?; + + let ln = self + .ln + .get(&LnKey::new(*unit, PaymentMethod::Bolt12)) + .ok_or_else(|| { + tracing::info!("Could not get ln backend for {}, bolt11 ", unit); + + Error::UnitUnsupported + })?; + + let payment_quote = ln + .get_bolt12_payment_quote(melt_request) + .await + .map_err(|err| { + tracing::error!( + "Could not get payment quote for mint quote, {} bolt11, {}", + unit, + err + ); + + Error::UnitUnsupported + })?; + + let offer = Offer::from_str(request)?; + + let payment_request = PaymentRequest::Bolt12 { + offer: Box::new(offer), + invoice: payment_quote.invoice, + }; + + let quote = MeltQuote::new( + payment_request, *unit, payment_quote.amount, payment_quote.fee, @@ -169,70 +250,81 @@ impl Mint { /// Check melt has expected fees #[instrument(skip_all)] - pub async fn check_melt_expected_ln_fees( + pub async fn check_melt_expected_ln_fees( &self, melt_quote: &MeltQuote, - melt_request: &MeltBolt11Request, - ) -> Result, Error> { - let invoice = Bolt11Invoice::from_str(&melt_quote.request)?; - - let quote_msats = to_unit(melt_quote.amount, &melt_quote.unit, &CurrencyUnit::Msat) - .expect("Quote unit is checked above that it can convert to msat"); - - let invoice_amount_msats: Amount = invoice - .amount_milli_satoshis() - .ok_or(Error::InvoiceAmountUndefined)? - .into(); - - let partial_amount = match invoice_amount_msats > quote_msats { - true => { - let partial_msats = invoice_amount_msats - quote_msats; - - Some( - to_unit(partial_msats, &CurrencyUnit::Msat, &melt_quote.unit) + melt_request: &R, + ) -> Result, Error> + where + R: MeltRequestTrait, + { + let quote_amount = melt_quote.amount; + + let request_amount = match &melt_quote.request { + PaymentRequest::Bolt11 { bolt11 } => match bolt11.amount_milli_satoshis() { + Some(amount) => Some( + to_unit(amount, &CurrencyUnit::Msat, &melt_quote.unit) .map_err(|_| Error::UnitUnsupported)?, - ) - } - false => None, + ), + None => None, + }, + PaymentRequest::Bolt12 { offer, invoice: _ } => match offer.amount() { + Some(amount) => { + let (amount, currency) = match amount { + lightning::offers::offer::Amount::Bitcoin { amount_msats } => { + (amount_msats, CurrencyUnit::Msat) + } + lightning::offers::offer::Amount::Currency { + iso4217_code, + amount, + } => ( + amount, + CurrencyUnit::from_str(&String::from_utf8(iso4217_code.to_vec())?)?, + ), + }; + + Some( + to_unit(amount, ¤cy, &melt_quote.unit) + .map_err(|_err| Error::UnsupportedUnit)?, + ) + } + None => None, + }, }; - let amount_to_pay = match partial_amount { - Some(amount_to_pay) => amount_to_pay, - None => to_unit(invoice_amount_msats, &CurrencyUnit::Msat, &melt_quote.unit) - .map_err(|_| Error::UnitUnsupported)?, - }; + let amount_to_pay = request_amount.unwrap_or(quote_amount); - let inputs_amount_quote_unit = melt_request.proofs_amount().map_err(|_| { - tracing::error!("Proof inputs in melt quote overflowed"); - Error::AmountOverflow - })?; + let inputs_amount = melt_request + .inputs_amount() + .map_err(|_| Error::AmountOverflow)?; - if amount_to_pay + melt_quote.fee_reserve > inputs_amount_quote_unit { + if amount_to_pay + melt_quote.fee_reserve > inputs_amount { tracing::debug!( "Not enough inputs provided: {} msats needed {} msats", - inputs_amount_quote_unit, + inputs_amount, amount_to_pay ); return Err(Error::TransactionUnbalanced( - inputs_amount_quote_unit.into(), + inputs_amount.into(), amount_to_pay.into(), melt_quote.fee_reserve.into(), )); } - Ok(partial_amount) + Ok(Some(amount_to_pay)) } /// Verify melt request is valid #[instrument(skip_all)] - pub async fn verify_melt_request( - &self, - melt_request: &MeltBolt11Request, - ) -> Result { + pub async fn verify_melt_request(&self, melt_request: &R) -> Result + where + R: MeltRequestTrait, + { + let quote_id = melt_request.get_quote_id(); let state = self .localstore - .update_melt_quote_state(&melt_request.quote, MeltQuoteState::Pending) + .update_melt_quote_state(quote_id, MeltQuoteState::Pending) .await?; match state { @@ -242,34 +334,33 @@ impl Mint { MeltQuoteState::Unknown => Err(Error::UnknownPaymentState), }?; - let ys = melt_request.inputs.ys()?; + let inputs = melt_request.get_inputs(); + + let ys = inputs.ys()?; // Ensure proofs are unique and not being double spent - if melt_request.inputs.len() != ys.iter().collect::>().len() { + if inputs.len() != ys.iter().collect::>().len() { return Err(Error::DuplicateProofs); } self.localstore - .add_proofs( - melt_request.inputs.clone(), - Some(melt_request.quote.clone()), - ) + .add_proofs(inputs.clone(), Some(quote_id.to_string())) .await?; self.check_ys_spendable(&ys, State::Pending).await?; - for proof in &melt_request.inputs { + for proof in inputs.iter() { self.verify_proof(proof).await?; } let quote = self .localstore - .get_melt_quote(&melt_request.quote) + .get_melt_quote(quote_id) .await? .ok_or(Error::UnknownQuote)?; - let proofs_total = melt_request.proofs_amount()?; + let proofs_total = Amount::try_sum(inputs.iter().map(|p| p.amount))?; - let fee = self.get_proofs_fee(&melt_request.inputs).await?; + let fee = self.get_proofs_fee(inputs).await?; let required_total = quote.amount + quote.fee_reserve + fee; @@ -289,8 +380,7 @@ impl Mint { )); } - let input_keyset_ids: HashSet = - melt_request.inputs.iter().map(|p| p.keyset_id).collect(); + let input_keyset_ids: HashSet = inputs.iter().map(|p| p.keyset_id).collect(); let mut keyset_units = HashSet::with_capacity(input_keyset_ids.capacity()); @@ -303,13 +393,15 @@ impl Mint { keyset_units.insert(keyset.unit); } - let EnforceSigFlag { sig_flag, .. } = enforce_sig_flag(melt_request.inputs.clone()); + let EnforceSigFlag { sig_flag, .. } = enforce_sig_flag(inputs.clone()); if sig_flag.eq(&SigFlag::SigAll) { return Err(Error::SigAllUsedInMelt); } - if let Some(outputs) = &melt_request.outputs { + let outputs = melt_request.get_outputs(); + + if let Some(outputs) = outputs { let output_keysets_ids: HashSet = outputs.iter().map(|b| b.keyset_id).collect(); for id in output_keysets_ids { let keyset = self @@ -338,7 +430,7 @@ impl Mint { return Err(Error::MultipleUnits); } - tracing::debug!("Verified melt quote: {}", melt_request.quote); + tracing::debug!("Verified melt quote: {}", quote_id); Ok(quote) } @@ -347,48 +439,47 @@ impl Mint { /// made The [`Proofs`] should be returned to an unspent state and the /// quote should be unpaid #[instrument(skip_all)] - pub async fn process_unpaid_melt(&self, melt_request: &MeltBolt11Request) -> Result<(), Error> { - let input_ys = melt_request.inputs.ys()?; - + pub async fn process_unpaid_melt(&self, melt_request: &R) -> Result<(), Error> + where + R: MeltRequestTrait, + { + let inputs = melt_request.get_inputs(); + let input_ys = inputs.ys()?; self.localstore .update_proofs_states(&input_ys, State::Unspent) .await?; self.localstore - .update_melt_quote_state(&melt_request.quote, MeltQuoteState::Unpaid) + .update_melt_quote_state(melt_request.get_quote_id(), MeltQuoteState::Unpaid) .await?; Ok(()) } - /// Melt Bolt11 - #[instrument(skip_all)] - pub async fn melt_bolt11( - &self, - melt_request: &MeltBolt11Request, - ) -> Result { - use std::sync::Arc; - async fn check_payment_state( - ln: Arc + Send + Sync>, - melt_quote: &MeltQuote, - ) -> anyhow::Result { - match ln - .check_outgoing_payment(&melt_quote.request_lookup_id) - .await - { - Ok(response) => Ok(response), - Err(check_err) => { - // If we cannot check the status of the payment we keep the proofs stuck as pending. - tracing::error!( - "Could not check the status of payment for {},. Proofs stuck as pending", - melt_quote.id - ); - tracing::error!("Checking payment error: {}", check_err); - bail!("Could not check payment status") - } + async fn check_payment_state( + ln: Arc + Send + Sync>, + request_lookup_id: &str, + ) -> anyhow::Result { + match ln.check_outgoing_payment(request_lookup_id).await { + Ok(response) => Ok(response), + Err(check_err) => { + // If we cannot check the status of the payment we keep the proofs stuck as pending. + tracing::error!( + "Could not check the status of payment for {},. Proofs stuck as pending", + request_lookup_id + ); + tracing::error!("Checking payment error: {}", check_err); + bail!("Could not check payment status") } } + } + /// Melt Bolt11 + #[instrument(skip_all)] + pub async fn melt(&self, melt_request: &R) -> Result + where + R: MeltRequestTrait, + { let quote = match self.verify_melt_request(melt_request).await { Ok(quote) => quote, Err(err) => { @@ -397,7 +488,7 @@ impl Mint { if let Err(err) = self.process_unpaid_melt(melt_request).await { tracing::error!( "Could not reset melt quote {} state: {}", - melt_request.quote, + melt_request.get_quote_id(), err ); } @@ -405,15 +496,19 @@ impl Mint { } }; + let inputs_amount = melt_request + .inputs_amount() + .map_err(|_err| Error::AmountOverflow)?; + let settled_internally_amount = - match self.handle_internal_melt_mint("e, melt_request).await { + match self.handle_internal_melt_mint("e, inputs_amount).await { Ok(amount) => amount, Err(err) => { tracing::error!("Attempting to settle internally failed"); if let Err(err) = self.process_unpaid_melt(melt_request).await { tracing::error!( "Could not reset melt quote {} state: {}", - melt_request.quote, + melt_request.get_quote_id(), err ); } @@ -447,6 +542,7 @@ impl Mint { } _ => None, }; + let ln = match self.ln.get(&LnKey::new(quote.unit, PaymentMethod::Bolt11)) { Some(ln) => ln, None => { @@ -459,17 +555,26 @@ impl Mint { } }; - let pre = match ln - .pay_invoice(quote.clone(), partial_amount, Some(quote.fee_reserve)) - .await - { + let attempt_to_pay = match melt_request.get_payment_method() { + PaymentMethod::Bolt11 => { + ln.pay_invoice(quote.clone(), partial_amount, Some(quote.fee_reserve)) + .await + } + PaymentMethod::Bolt12 => { + ln.pay_bolt12_offer(quote.clone(), partial_amount, Some(quote.fee_reserve)) + .await + } + }; + + let pre = match attempt_to_pay { Ok(pay) if pay.status == MeltQuoteState::Unknown || pay.status == MeltQuoteState::Failed => { - let check_response = check_payment_state(Arc::clone(ln), "e) - .await - .map_err(|_| Error::Internal)?; + let check_response = + Self::check_payment_state(Arc::clone(ln), "e.request_lookup_id) + .await + .map_err(|_| Error::Internal)?; if check_response.status == MeltQuoteState::Paid { tracing::warn!("Pay invoice returned {} but check returned {}. Proofs stuck as pending", pay.status.to_string(), check_response.status.to_string()); @@ -493,9 +598,10 @@ impl Mint { tracing::error!("Error returned attempting to pay: {} {}", quote.id, err); - let check_response = check_payment_state(Arc::clone(ln), "e) - .await - .map_err(|_| Error::Internal)?; + let check_response = + Self::check_payment_state(Arc::clone(ln), "e.request_lookup_id) + .await + .map_err(|_| Error::Internal)?; // If there error is something else we want to check the status of the payment ensure it is not pending or has been made. if check_response.status == MeltQuoteState::Paid { tracing::warn!("Pay invoice returned an error but check returned {}. Proofs stuck as pending", check_response.status.to_string()); @@ -511,7 +617,7 @@ impl Mint { MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => { tracing::info!( "Lightning payment for quote {} failed.", - melt_request.quote + melt_request.get_quote_id() ); if let Err(err) = self.process_unpaid_melt(melt_request).await { tracing::error!("Could not reset melt quote state: {}", err); @@ -521,7 +627,7 @@ impl Mint { MeltQuoteState::Pending => { tracing::warn!( "LN payment pending, proofs are stuck as pending for quote: {}", - melt_request.quote + melt_request.get_quote_id() ); return Err(Error::PendingQuote); } @@ -542,7 +648,7 @@ impl Mint { payment_lookup_id ); - let mut melt_quote = quote; + let mut melt_quote = quote.clone(); melt_quote.request_lookup_id = payment_lookup_id; if let Err(err) = self.localstore.add_melt_quote(melt_quote).await { @@ -556,51 +662,84 @@ impl Mint { // If we made it here the payment has been made. // We process the melt burning the inputs and returning change - let res = self - .process_melt_request(melt_request, preimage, amount_spent_quote_unit) + let change = self + .process_melt_request(melt_request, amount_spent_quote_unit) .await .map_err(|err| { tracing::error!("Could not process melt request: {}", err); err })?; - Ok(res) + let change_amount: u64 = change + .as_ref() + .unwrap_or(&vec![]) + .iter() + .map(|b| u64::from(b.amount)) + .sum(); + + tracing::debug!( + "Quote {} paid, quote amount: {}, total paid: {}, change amount: {}", + quote.id, + quote.amount, + amount_spent_quote_unit, + change_amount + ); + + Ok(MeltQuoteBolt11Response { + paid: Some(true), + payment_preimage: preimage, + change, + quote: quote.id, + amount: quote.amount, + fee_reserve: quote.fee_reserve, + state: MeltQuoteState::Paid, + expiry: quote.expiry, + }) } /// Process melt request marking [`Proofs`] as spent /// The melt request must be verifyed using [`Self::verify_melt_request`] /// before calling [`Self::process_melt_request`] #[instrument(skip_all)] - pub async fn process_melt_request( + pub async fn process_melt_request( &self, - melt_request: &MeltBolt11Request, - payment_preimage: Option, + melt_request: &R, total_spent: Amount, - ) -> Result { - tracing::debug!("Processing melt quote: {}", melt_request.quote); + ) -> Result>, Error> + where + R: MeltRequestTrait, + { + let quote_id = melt_request.get_quote_id(); + tracing::debug!("Processing melt quote: {}", quote_id); let quote = self .localstore - .get_melt_quote(&melt_request.quote) + .get_melt_quote(quote_id) .await? .ok_or(Error::UnknownQuote)?; - let input_ys = melt_request.inputs.ys()?; + let inputs = melt_request.get_inputs(); + + let input_ys = inputs.ys()?; self.localstore .update_proofs_states(&input_ys, State::Spent) .await?; self.localstore - .update_melt_quote_state(&melt_request.quote, MeltQuoteState::Paid) + .update_melt_quote_state(quote_id, MeltQuoteState::Paid) .await?; let mut change = None; + let inputs_amount = Amount::try_sum(inputs.iter().map(|p| p.amount))?; + + let outputs = melt_request.get_outputs(); + // Check if there is change to return - if melt_request.proofs_amount()? > total_spent { + if inputs_amount > total_spent { // Check if wallet provided change outputs - if let Some(outputs) = melt_request.outputs.clone() { + if let Some(outputs) = outputs { let blinded_messages: Vec = outputs.iter().map(|b| b.blinded_secret).collect(); @@ -618,7 +757,7 @@ impl Mint { return Err(Error::BlindedMessageAlreadySigned); } - let change_target = melt_request.proofs_amount()? - total_spent; + let change_target = inputs_amount - total_spent; let mut amounts = change_target.split(); let mut change_sigs = Vec::with_capacity(amounts.len()); @@ -635,7 +774,7 @@ impl Mint { amounts.sort_by(|a, b| b.cmp(a)); } - let mut outputs = outputs; + let mut outputs = outputs.clone(); for (amount, blinded_message) in amounts.iter().zip(&mut outputs) { blinded_message.amount = *amount; @@ -659,15 +798,6 @@ impl Mint { } } - Ok(MeltQuoteBolt11Response { - amount: quote.amount, - paid: Some(true), - payment_preimage, - change, - quote: quote.id, - fee_reserve: quote.fee_reserve, - state: MeltQuoteState::Paid, - expiry: quote.expiry, - }) + Ok(change) } } diff --git a/crates/cdk/src/mint/mint_18.rs b/crates/cdk/src/mint/mint_18.rs new file mode 100644 index 000000000..ddda8d8ae --- /dev/null +++ b/crates/cdk/src/mint/mint_18.rs @@ -0,0 +1,95 @@ +use tracing::instrument; + +use crate::{types::LnKey, util::unix_time, Amount, Error}; + +use super::{ + nut19::{MintQuoteBolt12Request, MintQuoteBolt12Response}, + Mint, MintQuote, PaymentMethod, +}; + +impl Mint { + /// Create new mint bolt11 quote + #[instrument(skip_all)] + pub async fn get_mint_bolt12_quote( + &self, + mint_quote_request: MintQuoteBolt12Request, + ) -> Result { + let MintQuoteBolt12Request { + amount, + unit, + description, + single_use, + expiry, + } = mint_quote_request; + + let nut18 = &self + .mint_info + .nuts + .nut18 + .as_ref() + .ok_or(Error::UnsupportedUnit)?; + + if nut18.disabled { + return Err(Error::MintingDisabled); + } + + let ln = self + .ln + .get(&LnKey::new(unit, PaymentMethod::Bolt12)) + .ok_or_else(|| { + tracing::info!("Bolt11 mint request for unsupported unit"); + + Error::UnitUnsupported + })?; + + let quote_expiry = match expiry { + Some(expiry) => expiry, + None => unix_time() + self.quote_ttl.mint_ttl, + }; + + if description.is_some() && !ln.get_settings().invoice_description { + tracing::error!("Backend does not support invoice description"); + return Err(Error::InvoiceDescriptionUnsupported); + } + + let single_use = single_use.unwrap_or(true); + + let create_invoice_response = ln + .create_bolt12_offer( + amount, + &unit, + description.unwrap_or("".to_string()), + quote_expiry, + single_use, + ) + .await + .map_err(|err| { + tracing::error!("Could not create invoice: {}", err); + Error::InvalidPaymentRequest + })?; + + let quote = MintQuote::new( + self.mint_url.clone(), + create_invoice_response.request.to_string(), + unit, + amount, + create_invoice_response.expiry.unwrap_or(0), + create_invoice_response.request_lookup_id.clone(), + Amount::ZERO, + Amount::ZERO, + Some(single_use), + ); + + tracing::debug!( + "New bolt12 mint quote {} for {} {} with request id {}", + quote.id, + amount.unwrap_or_default(), + unit, + create_invoice_response.request_lookup_id, + ); + + self.localstore.add_mint_quote(quote.clone()).await?; + + Ok(quote.into()) + } +} diff --git a/crates/cdk/src/mint/mint_nut04.rs b/crates/cdk/src/mint/mint_nut04.rs index 33e0c3240..178c2901a 100644 --- a/crates/cdk/src/mint/mint_nut04.rs +++ b/crates/cdk/src/mint/mint_nut04.rs @@ -12,7 +12,7 @@ impl Mint { fn check_mint_request_acceptable( &self, amount: Amount, - unit: CurrencyUnit, + unit: &CurrencyUnit, ) -> Result<(), Error> { let nut04 = &self.mint_info.nuts.nut04; @@ -20,7 +20,7 @@ impl Mint { return Err(Error::MintingDisabled); } - match nut04.get_settings(&unit, &PaymentMethod::Bolt11) { + match nut04.get_settings(unit, &PaymentMethod::Bolt11) { Some(settings) => { if settings .max_amount @@ -64,7 +64,7 @@ impl Mint { description, } = mint_quote_request; - self.check_mint_request_acceptable(amount, unit)?; + self.check_mint_request_acceptable(amount, &unit)?; let ln = self .ln @@ -99,13 +99,16 @@ impl Mint { self.mint_url.clone(), create_invoice_response.request.to_string(), unit, - amount, + Some(amount), create_invoice_response.expiry.unwrap_or(0), create_invoice_response.request_lookup_id.clone(), + Amount::ZERO, + Amount::ZERO, + None, ); tracing::debug!( - "New mint quote {} for {} {} with request id {}", + "New bolt11 mint quote {} for {} {} with request id {}", quote.id, amount, unit, @@ -142,6 +145,8 @@ impl Mint { paid: Some(paid), state, expiry: Some(quote.expiry), + amount_paid: quote.amount_paid, + amount_issued: quote.amount_issued, }) } @@ -194,6 +199,7 @@ impl Mint { pub async fn pay_mint_quote_for_request_id( &self, request_lookup_id: &str, + amount: Amount, ) -> Result<(), Error> { if let Ok(Some(mint_quote)) = self .localstore @@ -208,6 +214,36 @@ impl Mint { self.localstore .update_mint_quote_state(&mint_quote.id, MintQuoteState::Paid) .await?; + let quote = self + .localstore + .get_mint_quote(&mint_quote.id) + .await? + .unwrap(); + + let amount_paid = quote.amount_paid + amount; + + let quote = MintQuote { + id: quote.id, + mint_url: quote.mint_url, + amount: quote.amount, + unit: quote.unit, + request: quote.request, + state: MintQuoteState::Paid, + expiry: quote.expiry, + request_lookup_id: quote.request_lookup_id, + amount_paid, + amount_issued: quote.amount_issued, + single_use: None, + }; + + tracing::debug!( + "Quote: {}, Amount paid: {}, amount issued: {}", + quote.id, + amount_paid, + quote.amount_issued + ); + + self.localstore.add_mint_quote(quote).await?; } Ok(()) } @@ -234,6 +270,11 @@ impl Mint { .localstore .update_mint_quote_state(&mint_request.quote, MintQuoteState::Pending) .await?; + let quote = self + .localstore + .get_mint_quote(&mint_request.quote) + .await? + .unwrap(); match state { MintQuoteState::Unpaid => { @@ -243,11 +284,21 @@ impl Mint { return Err(Error::PendingQuote); } MintQuoteState::Issued => { - return Err(Error::IssuedQuote); + if quote.amount_issued >= quote.amount_paid { + return Err(Error::IssuedQuote); + } } MintQuoteState::Paid => (), } + let amount_can_issue = quote.amount_paid - quote.amount_issued; + + let messages_amount = mint_request.total_amount().unwrap(); + + if amount_can_issue < messages_amount { + return Err(Error::IssuedQuote); + } + let blinded_messages: Vec = mint_request .outputs .iter() @@ -299,6 +350,22 @@ impl Mint { .update_mint_quote_state(&mint_request.quote, MintQuoteState::Issued) .await?; + let mint_quote = MintQuote { + id: quote.id, + mint_url: quote.mint_url, + amount: quote.amount, + unit: quote.unit, + request: quote.request, + state: MintQuoteState::Issued, + expiry: quote.expiry, + amount_paid: quote.amount_paid, + amount_issued: quote.amount_issued + messages_amount, + request_lookup_id: quote.request_lookup_id, + single_use: None, + }; + + self.localstore.add_mint_quote(mint_quote).await?; + Ok(nut04::MintBolt11Response { signatures: blind_signatures, }) diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index 0699cf7f7..30a6b02cf 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -12,6 +12,7 @@ use tokio::sync::{Notify, RwLock}; use tokio::task::JoinSet; use tracing::instrument; +use self::types::PaymentRequest; use crate::cdk_database::{self, MintDatabase}; use crate::cdk_lightning::{self, MintLightning}; use crate::dhke::{sign_message, verify_message}; @@ -27,6 +28,7 @@ mod check_spendable; mod info; mod keysets; mod melt; +mod mint_18; mod mint_nut04; mod swap; pub mod types; @@ -208,7 +210,7 @@ impl Mint { match result { Ok(mut stream) => { while let Some(request_lookup_id) = stream.next().await { - if let Err(err) = mint.pay_mint_quote_for_request_id(&request_lookup_id).await { + if let Err(err) = mint.pay_mint_quote_for_request_id(&request_lookup_id.0, request_lookup_id.1).await { tracing::warn!("{:?}", err); } } @@ -363,13 +365,14 @@ impl Mint { pub async fn handle_internal_melt_mint( &self, melt_quote: &MeltQuote, - melt_request: &MeltBolt11Request, + inputs_amount: Amount, ) -> Result, Error> { - let mint_quote = match self - .localstore - .get_mint_quote_by_request(&melt_quote.request) - .await - { + let request = match &melt_quote.request { + PaymentRequest::Bolt11 { bolt11 } => bolt11.to_string(), + PaymentRequest::Bolt12 { offer, invoice: _ } => offer.to_string(), + }; + + let mint_quote = match self.localstore.get_mint_quote_by_request(&request).await { Ok(Some(mint_quote)) => mint_quote, // Not an internal melt -> mint Ok(None) => return Ok(None), @@ -384,18 +387,13 @@ impl Mint { return Err(Error::RequestAlreadyPaid); } - let inputs_amount_quote_unit = melt_request.proofs_amount().map_err(|_| { - tracing::error!("Proof inputs in melt quote overflowed"); - Error::AmountOverflow - })?; - let mut mint_quote = mint_quote; - if mint_quote.amount > inputs_amount_quote_unit { + if mint_quote.amount.unwrap_or_default() > inputs_amount { tracing::debug!( "Not enough inuts provided: {} needed {}", - inputs_amount_quote_unit, - mint_quote.amount + inputs_amount, + mint_quote.amount.unwrap_or_default() ); return Err(Error::InsufficientFunds); } @@ -489,7 +487,7 @@ impl Mint { } /// Mint Fee Reserve -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct FeeReserve { /// Absolute expected min fee pub min_fee_reserve: Amount, diff --git a/crates/cdk/src/mint/types.rs b/crates/cdk/src/mint/types.rs index 44047fd9b..a2f3413a1 100644 --- a/crates/cdk/src/mint/types.rs +++ b/crates/cdk/src/mint/types.rs @@ -1,5 +1,7 @@ //! Mint Types +use lightning::offers::offer::Offer; +use lightning_invoice::Bolt11Invoice; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -16,7 +18,7 @@ pub struct MintQuote { /// Mint Url pub mint_url: MintUrl, /// Amount of quote - pub amount: Amount, + pub amount: Option, /// Unit of quote pub unit: CurrencyUnit, /// Quote payment request e.g. bolt11 @@ -27,6 +29,12 @@ pub struct MintQuote { pub expiry: u64, /// Value used by ln backend to look up state of request pub request_lookup_id: String, + /// Amount paid + pub amount_paid: Amount, + /// Amount issued + pub amount_issued: Amount, + /// Single use + pub single_use: Option, } impl MintQuote { @@ -35,9 +43,12 @@ impl MintQuote { mint_url: MintUrl, request: String, unit: CurrencyUnit, - amount: Amount, + amount: Option, expiry: u64, request_lookup_id: String, + amount_paid: Amount, + amount_issued: Amount, + single_use: Option, ) -> Self { let id = Uuid::new_v4(); @@ -50,6 +61,9 @@ impl MintQuote { state: MintQuoteState::Unpaid, expiry, request_lookup_id, + amount_paid, + amount_issued, + single_use, } } } @@ -64,7 +78,7 @@ pub struct MeltQuote { /// Quote amount pub amount: Amount, /// Quote Payment request e.g. bolt11 - pub request: String, + pub request: PaymentRequest, /// Quote fee reserve pub fee_reserve: Amount, /// Quote state @@ -80,7 +94,7 @@ pub struct MeltQuote { impl MeltQuote { /// Create new [`MeltQuote`] pub fn new( - request: String, + request: PaymentRequest, unit: CurrencyUnit, amount: Amount, fee_reserve: Amount, @@ -102,3 +116,45 @@ impl MeltQuote { } } } + +/// Payment request +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum PaymentRequest { + /// Bolt11 Payment + Bolt11 { + /// Bolt11 invoice + bolt11: Bolt11Invoice, + }, + /// Bolt12 Payment + Bolt12 { + /// Offer + #[serde(with = "offer_serde")] + offer: Box, + /// Invoice + invoice: Option, + }, +} + +mod offer_serde { + use super::Offer; + use serde::{self, Deserialize, Deserializer, Serializer}; + use std::str::FromStr; + + pub fn serialize(offer: &Offer, serializer: S) -> Result + where + S: Serializer, + { + let s = offer.to_string(); + serializer.serialize_str(&s) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Box::new(Offer::from_str(&s).map_err(|_| { + serde::de::Error::custom("Invalid Bolt12 Offer") + })?)) + } +} diff --git a/crates/cdk/src/nuts/mod.rs b/crates/cdk/src/nuts/mod.rs index 07518bff1..9efa6acbd 100644 --- a/crates/cdk/src/nuts/mod.rs +++ b/crates/cdk/src/nuts/mod.rs @@ -19,6 +19,8 @@ pub mod nut13; pub mod nut14; pub mod nut15; pub mod nut18; +pub mod nut19; +pub mod nut20; pub use nut00::{ BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, PreMint, PreMintSecrets, Proof, @@ -48,3 +50,4 @@ pub use nut12::{BlindSignatureDleq, ProofDleq}; pub use nut14::HTLCWitness; pub use nut15::{Mpp, MppMethodSettings, Settings as NUT15Settings}; pub use nut18::{PaymentRequest, PaymentRequestPayload, Transport}; +pub use nut20::{MeltBolt12Request, MeltQuoteBolt12Request}; diff --git a/crates/cdk/src/nuts/nut00/mod.rs b/crates/cdk/src/nuts/nut00/mod.rs index d6c3dffdb..0ec8b6b48 100644 --- a/crates/cdk/src/nuts/nut00/mod.rs +++ b/crates/cdk/src/nuts/nut00/mod.rs @@ -391,7 +391,8 @@ impl CurrencyUnit { impl FromStr for CurrencyUnit { type Err = Error; fn from_str(value: &str) -> Result { - match value { + let value = value.to_lowercase(); + match value.as_str() { "sat" => Ok(Self::Sat), "msat" => Ok(Self::Msat), "usd" => Ok(Self::Usd), @@ -444,13 +445,17 @@ pub enum PaymentMethod { /// Bolt11 payment type #[default] Bolt11, + /// Bolt12 offer + Bolt12, } impl FromStr for PaymentMethod { type Err = Error; fn from_str(value: &str) -> Result { - match value { + let value = value.to_lowercase(); + match value.as_str() { "bolt11" => Ok(Self::Bolt11), + "bolt12" => Ok(Self::Bolt12), _ => Err(Error::UnsupportedPaymentMethod), } } @@ -460,6 +465,7 @@ impl fmt::Display for PaymentMethod { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { PaymentMethod::Bolt11 => write!(f, "bolt11"), + PaymentMethod::Bolt12 => write!(f, "bolt12"), } } } diff --git a/crates/cdk/src/nuts/nut04.rs b/crates/cdk/src/nuts/nut04.rs index 3067628f6..33e8deb16 100644 --- a/crates/cdk/src/nuts/nut04.rs +++ b/crates/cdk/src/nuts/nut04.rs @@ -95,6 +95,10 @@ pub struct MintQuoteBolt11Response { pub state: MintQuoteState, /// Unix timestamp until the quote is valid pub expiry: Option, + /// Amount that has been paid + pub amount_paid: Amount, + /// Amount that has been issued + pub amount_issued: Amount, } // A custom deserializer is needed until all mints @@ -152,12 +156,24 @@ impl<'de> Deserialize<'de> for MintQuoteBolt11Response { .ok_or(serde::de::Error::missing_field("expiry"))? .as_u64(); + let amount_paid = value + .get("amount_paid") + .ok_or(serde::de::Error::missing_field("expiry"))? + .as_u64(); + + let amount_issued = value + .get("amount_issued") + .ok_or(serde::de::Error::missing_field("expiry"))? + .as_u64(); + Ok(Self { quote, request, paid: Some(paid), state, expiry, + amount_paid: amount_paid.unwrap_or_default().into(), + amount_issued: amount_issued.unwrap_or_default().into(), }) } } @@ -172,6 +188,8 @@ impl From for MintQuoteBolt11Response { paid: Some(paid), state: mint_quote.state, expiry: Some(mint_quote.expiry), + amount_paid: mint_quote.amount_paid, + amount_issued: mint_quote.amount_issued, } } } diff --git a/crates/cdk/src/nuts/nut05.rs b/crates/cdk/src/nuts/nut05.rs index e7ac3153a..2c6d292b7 100644 --- a/crates/cdk/src/nuts/nut05.rs +++ b/crates/cdk/src/nuts/nut05.rs @@ -226,12 +226,59 @@ pub struct MeltBolt11Request { pub outputs: Option>, } -impl MeltBolt11Request { +/// MeltRequest trait +pub trait MeltRequestTrait { + /// Error for MeltRequest trait + type Err: Into; + // async fn verify(&self, service: &MyService) -> Result; + /// Get id for [`MeltRequest`] + fn get_quote_id(&self) -> &str; + /// Get inputs for [`MeltRequest`] + fn get_inputs(&self) -> &Proofs; + /// Get outputs for [`MeltRequest`] + fn get_outputs(&self) -> &Option>; /// Total [`Amount`] of [`Proofs`] - pub fn proofs_amount(&self) -> Result { + fn inputs_amount(&self) -> Result; + /// Total [`Amount`] of outputs + fn outputs_amount(&self) -> Result; + /// [`PaymentMethod`] of request + fn get_payment_method(&self) -> PaymentMethod; +} + +impl MeltRequestTrait for MeltBolt11Request { + type Err = Error; + + fn get_quote_id(&self) -> &str { + &self.quote + } + + fn get_inputs(&self) -> &Proofs { + &self.inputs + } + + fn get_outputs(&self) -> &Option> { + &self.outputs + } + + fn inputs_amount(&self) -> Result { Amount::try_sum(self.inputs.iter().map(|proof| proof.amount)) .map_err(|_| Error::AmountOverflow) } + + fn outputs_amount(&self) -> Result { + Amount::try_sum( + self.outputs + .as_ref() + .unwrap_or(&vec![]) + .iter() + .map(|proof| proof.amount), + ) + .map_err(|_| Error::AmountOverflow) + } + + fn get_payment_method(&self) -> PaymentMethod { + PaymentMethod::Bolt11 + } } /// Melt Method Settings diff --git a/crates/cdk/src/nuts/nut06.rs b/crates/cdk/src/nuts/nut06.rs index 358cc4ff4..f7869c17b 100644 --- a/crates/cdk/src/nuts/nut06.rs +++ b/crates/cdk/src/nuts/nut06.rs @@ -229,9 +229,17 @@ pub struct Nuts { #[serde(rename = "14")] pub nut14: SupportedSettings, /// NUT15 Settings - #[serde(default)] #[serde(rename = "15")] - pub nut15: nut15::Settings, + #[serde(skip_serializing_if = "Option::is_none")] + pub nut15: Option, + /// NUT04 Settings + #[serde(rename = "18")] + #[serde(skip_serializing_if = "Option::is_none")] + pub nut18: Option, + /// NUT05 Settings + #[serde(rename = "19")] + #[serde(skip_serializing_if = "Option::is_none")] + pub nut19: Option, } impl Nuts { @@ -315,21 +323,43 @@ impl Nuts { /// Nut15 settings pub fn nut15(self, mpp_settings: Vec) -> Self { Self { - nut15: nut15::Settings { + nut15: Some(nut15::Settings { methods: mpp_settings, - }, + }), + ..self + } + } + + /// Nut18 settings + pub fn nut18(self, nut04_settings: nut04::Settings) -> Self { + Self { + nut18: Some(nut04_settings), + ..self + } + } + + /// Nut19 settings + pub fn nut19(self, nut05_settings: nut05::Settings) -> Self { + Self { + nut19: Some(nut05_settings), ..self } } } /// Check state Settings -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub struct SupportedSettings { supported: bool, } +impl Default for SupportedSettings { + fn default() -> Self { + Self { supported: true } + } +} + /// Contact Info #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] diff --git a/crates/cdk/src/nuts/nut19.rs b/crates/cdk/src/nuts/nut19.rs new file mode 100644 index 000000000..8fdafb32d --- /dev/null +++ b/crates/cdk/src/nuts/nut19.rs @@ -0,0 +1,66 @@ +//! NUT-17: Mint Tokens via Bolt11 +//! +//! + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::nut00::CurrencyUnit; +use crate::Amount; + +/// NUT04 Error +#[derive(Debug, Error)] +pub enum Error { + /// Unknown Quote State + #[error("Unknown Quote State")] + UnknownState, + /// Amount overflow + #[error("Amount overflow")] + AmountOverflow, +} + +/// Mint quote request [NUT-17] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MintQuoteBolt12Request { + /// Amount + pub amount: Option, + /// Unit wallet would like to pay with + pub unit: CurrencyUnit, + /// Memo to create the invoice with + pub description: Option, + /// Single use + pub single_use: Option, + /// Expiry + pub expiry: Option, +} + +/// Mint quote response [NUT-04] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MintQuoteBolt12Response { + /// Quote Id + pub quote: String, + /// Payment request to fulfil + pub request: String, + /// Single use + pub single_use: Option, + /// Unix timestamp until the quote is valid + pub expiry: Option, + /// Amount that has been paid + pub amount_paid: Amount, + /// Amount that has been issued + pub amount_issued: Amount, +} + +#[cfg(feature = "mint")] +impl From for MintQuoteBolt12Response { + fn from(mint_quote: crate::mint::MintQuote) -> MintQuoteBolt12Response { + MintQuoteBolt12Response { + quote: mint_quote.id, + request: mint_quote.request, + expiry: Some(mint_quote.expiry), + amount_paid: mint_quote.amount_paid, + amount_issued: mint_quote.amount_issued, + single_use: mint_quote.single_use, + } + } +} diff --git a/crates/cdk/src/nuts/nut20.rs b/crates/cdk/src/nuts/nut20.rs new file mode 100644 index 000000000..dbb498084 --- /dev/null +++ b/crates/cdk/src/nuts/nut20.rs @@ -0,0 +1,77 @@ +//! Bolt12 +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::Amount; + +use super::{nut05::MeltRequestTrait, BlindedMessage, CurrencyUnit, PaymentMethod, Proofs}; + +/// NUT18 Error +#[derive(Debug, Error)] +pub enum Error { + /// Unknown Quote State + #[error("Unknown quote state")] + UnknownState, + /// Amount overflow + #[error("Amount Overflow")] + AmountOverflow, +} + +/// Melt quote request [NUT-18] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct MeltQuoteBolt12Request { + /// Bolt12 invoice to be paid + pub request: String, + /// Unit wallet would like to pay with + pub unit: CurrencyUnit, + /// Payment Options + pub amount: Option, +} + +/// Melt Bolt12 Request [NUT-18] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MeltBolt12Request { + /// Quote ID + pub quote: String, + /// Proofs + pub inputs: Proofs, + /// Blinded Message that can be used to return change [NUT-08] + /// Amount field of BlindedMessages `SHOULD` be set to zero + pub outputs: Option>, +} + +impl MeltRequestTrait for MeltBolt12Request { + type Err = Error; + + fn get_quote_id(&self) -> &str { + &self.quote + } + + fn get_inputs(&self) -> &Proofs { + &self.inputs + } + + fn get_outputs(&self) -> &Option> { + &self.outputs + } + + fn inputs_amount(&self) -> Result { + Amount::try_sum(self.inputs.iter().map(|proof| proof.amount)) + .map_err(|_| Error::AmountOverflow) + } + + fn outputs_amount(&self) -> Result { + Amount::try_sum( + self.outputs + .as_ref() + .unwrap_or(&vec![]) + .iter() + .map(|proof| proof.amount), + ) + .map_err(|_| Error::AmountOverflow) + } + + fn get_payment_method(&self) -> PaymentMethod { + PaymentMethod::Bolt12 + } +} diff --git a/crates/cdk/src/wallet/client.rs b/crates/cdk/src/wallet/client.rs index d5b413ce4..cdd9726c5 100644 --- a/crates/cdk/src/wallet/client.rs +++ b/crates/cdk/src/wallet/client.rs @@ -11,10 +11,10 @@ use crate::mint_url::MintUrl; use crate::nuts::nut15::Mpp; use crate::nuts::{ BlindedMessage, CheckStateRequest, CheckStateResponse, CurrencyUnit, Id, KeySet, KeysResponse, - KeysetResponse, MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, - MintBolt11Request, MintBolt11Response, MintInfo, MintQuoteBolt11Request, - MintQuoteBolt11Response, PreMintSecrets, Proof, PublicKey, RestoreRequest, RestoreResponse, - SwapRequest, SwapResponse, + KeysetResponse, MeltBolt11Request, MeltBolt12Request, MeltQuoteBolt11Request, + MeltQuoteBolt11Response, MeltQuoteBolt12Request, MintBolt11Request, MintBolt11Response, + MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, PreMintSecrets, Proof, PublicKey, + RestoreRequest, RestoreResponse, SwapRequest, SwapResponse, }; use crate::{Amount, Bolt11Invoice}; @@ -139,6 +139,37 @@ impl HttpClient { } } + /// Mint Quote [NUT-04] + #[instrument(skip(self), fields(mint_url = %mint_url))] + pub async fn post_mint_bolt12_quote( + &self, + mint_url: MintUrl, + amount: Amount, + unit: CurrencyUnit, + description: Option, + ) -> Result { + let url = mint_url.join_paths(&["v1", "mint", "quote", "bolt12"])?; + + let request = MintQuoteBolt11Request { + amount, + unit, + description, + }; + + let res = self.inner.post(url).json(&request).send().await?; + println!("{:?}", res); + + let res = res.json::().await?; + + match serde_json::from_value::(res.clone()) { + Ok(mint_quote_response) => Ok(mint_quote_response), + Err(err) => { + tracing::warn!("{}", err); + Err(ErrorResponse::from_value(res)?.into()) + } + } + } + /// Mint Quote status #[instrument(skip(self), fields(mint_url = %mint_url))] pub async fn get_mint_quote_status( @@ -223,6 +254,33 @@ impl HttpClient { } } + /// Melt Bol12 + #[instrument(skip(self, request), fields(mint_url = %mint_url))] + pub async fn post_melt_bolt12_quote( + &self, + mint_url: MintUrl, + unit: CurrencyUnit, + request: String, + amount: Option, + ) -> Result { + let url = mint_url.join_paths(&["v1", "melt", "quote", "bolt12"])?; + + let request = MeltQuoteBolt12Request { + request, + unit, + amount, + }; + + let res = self.inner.post(url).json(&request).send().await?; + + let res = res.json::().await?; + + match serde_json::from_value::(res.clone()) { + Ok(melt_quote_response) => Ok(melt_quote_response), + Err(_) => Err(ErrorResponse::from_value(res)?.into()), + } + } + /// Melt Quote Status #[instrument(skip(self), fields(mint_url = %mint_url))] pub async fn get_melt_quote_status( @@ -278,6 +336,39 @@ impl HttpClient { } } + /// Melt Bolt12 [NUT-05] + /// [Nut-08] Lightning fee return if outputs defined + #[instrument(skip(self, quote, inputs, outputs), fields(mint_url = %mint_url))] + pub async fn post_melt_bolt12( + &self, + mint_url: MintUrl, + quote: String, + inputs: Vec, + outputs: Option>, + ) -> Result { + let url = mint_url.join_paths(&["v1", "melt", "bolt12"])?; + + let request = MeltBolt12Request { + quote, + inputs, + outputs, + }; + + let res = self + .inner + .post(url) + .json(&request) + .send() + .await? + .json::() + .await?; + + match serde_json::from_value::(res.clone()) { + Ok(melt_quote_response) => Ok(melt_quote_response), + Err(_) => Err(ErrorResponse::from_value(res)?.into()), + } + } + /// Split Token [NUT-06] #[instrument(skip(self, swap_request), fields(mint_url = %mint_url))] pub async fn post_swap( diff --git a/crates/cdk/src/wallet/melt.rs b/crates/cdk/src/wallet/melt.rs index 6d0f709b2..36cb26e99 100644 --- a/crates/cdk/src/wallet/melt.rs +++ b/crates/cdk/src/wallet/melt.rs @@ -1,12 +1,14 @@ use std::str::FromStr; +use lightning::offers::offer::Offer; use lightning_invoice::Bolt11Invoice; use tracing::instrument; use crate::nuts::nut00::ProofsMethods; use crate::{ + amount::amount_for_offer, dhke::construct_proofs, - nuts::{CurrencyUnit, MeltQuoteBolt11Response, PreMintSecrets, Proofs, State}, + nuts::{CurrencyUnit, MeltQuoteBolt11Response, PaymentMethod, PreMintSecrets, Proofs, State}, types::{Melted, ProofInfo}, util::unix_time, Amount, Error, Wallet, @@ -70,6 +72,53 @@ impl Wallet { id: quote_res.quote, amount, request, + payment_method: PaymentMethod::Bolt11, + unit: self.unit, + fee_reserve: quote_res.fee_reserve, + state: quote_res.state, + expiry: quote_res.expiry, + payment_preimage: quote_res.payment_preimage, + }; + + self.localstore.add_melt_quote(quote.clone()).await?; + + Ok(quote) + } + + /// Melt Quote bolt12 + #[instrument(skip(self, request))] + pub async fn melt_bolt12_quote( + &self, + request: String, + amount: Option, + ) -> Result { + let offer = Offer::from_str(&request)?; + + let amount = match amount { + Some(amount) => amount, + None => amount_for_offer(&offer, &self.unit).unwrap(), + }; + + let quote_res = self + .client + .post_melt_bolt12_quote( + self.mint_url.clone(), + self.unit, + request.to_string(), + Some(amount), + ) + .await + .unwrap(); + + if quote_res.amount != amount { + return Err(Error::IncorrectQuoteAmount); + } + + let quote = MeltQuote { + id: quote_res.quote, + amount, + request, + payment_method: PaymentMethod::Bolt12, unit: self.unit, fee_reserve: quote_res.fee_reserve, state: quote_res.state, @@ -146,15 +195,28 @@ impl Wallet { proofs_total - quote_info.amount, )?; - let melt_response = self - .client - .post_melt( - self.mint_url.clone(), - quote_id.to_string(), - proofs.clone(), - Some(premint_secrets.blinded_messages()), - ) - .await; + let melt_response = match quote_info.payment_method { + PaymentMethod::Bolt11 => { + self.client + .post_melt( + self.mint_url.clone(), + quote_id.to_string(), + proofs.clone(), + Some(premint_secrets.blinded_messages()), + ) + .await + } + PaymentMethod::Bolt12 => { + self.client + .post_melt_bolt12( + self.mint_url.clone(), + quote_id.to_string(), + proofs.clone(), + Some(premint_secrets.blinded_messages()), + ) + .await + } + }; let melt_response = match melt_response { Ok(melt_response) => melt_response, diff --git a/crates/cdk/src/wallet/mint.rs b/crates/cdk/src/wallet/mint.rs index b429daaf8..8c48aa901 100644 --- a/crates/cdk/src/wallet/mint.rs +++ b/crates/cdk/src/wallet/mint.rs @@ -84,6 +84,54 @@ impl Wallet { Ok(quote) } + /// Mint Bolt12 + #[instrument(skip(self))] + pub async fn mint_bolt12_quote( + &self, + amount: Amount, + description: Option, + ) -> Result { + let mint_url = self.mint_url.clone(); + let unit = self.unit; + + // If we have a description, we check that the mint supports it. + // If we have a description, we check that the mint supports it. + if description.is_some() { + let mint_method_settings = self + .localstore + .get_mint(mint_url.clone()) + .await? + .ok_or(Error::IncorrectMint)? + .nuts + .nut04 + .get_settings(&unit, &crate::nuts::PaymentMethod::Bolt11) + .ok_or(Error::UnsupportedUnit)?; + + if !mint_method_settings.description { + return Err(Error::InvoiceDescriptionUnsupported); + } + } + + let quote_res = self + .client + .post_mint_bolt12_quote(mint_url.clone(), amount, unit, description) + .await?; + + let quote = MintQuote { + mint_url, + id: quote_res.quote.clone(), + amount, + unit, + request: quote_res.request, + state: quote_res.state, + expiry: quote_res.expiry.unwrap_or(0), + }; + + self.localstore.add_mint_quote(quote.clone()).await?; + + Ok(quote) + } + /// Check mint quote status #[instrument(skip(self, quote_id))] pub async fn mint_quote_state(&self, quote_id: &str) -> Result { @@ -196,10 +244,25 @@ impl Wallet { let count = count.map_or(0, |c| c + 1); + let status = self.mint_quote_state(quote_id).await?; + + println!( + "Amount paid: {}, Amount issued: {}", + status.amount_paid, status.amount_issued + ); + + let amount = status.amount_paid - status.amount_issued; + + let amount = if amount == Amount::ZERO { + quote_info.amount + } else { + amount + }; + let premint_secrets = match &spending_conditions { Some(spending_conditions) => PreMintSecrets::with_conditions( active_keyset_id, - quote_info.amount, + amount, &amount_split_target, spending_conditions, )?, @@ -207,7 +270,7 @@ impl Wallet { active_keyset_id, count, self.xpriv, - quote_info.amount, + amount, &amount_split_target, )?, }; @@ -241,7 +304,7 @@ impl Wallet { let minted_amount = proofs.total_amount()?; // Remove filled quote from store - self.localstore.remove_mint_quote("e_info.id).await?; + //self.localstore.remove_mint_quote("e_info.id).await?; if spending_conditions.is_none() { // Update counter for keyset diff --git a/crates/cdk/src/wallet/multi_mint_wallet.rs b/crates/cdk/src/wallet/multi_mint_wallet.rs index 6f3c33341..521c97d75 100644 --- a/crates/cdk/src/wallet/multi_mint_wallet.rs +++ b/crates/cdk/src/wallet/multi_mint_wallet.rs @@ -16,7 +16,7 @@ use super::types::SendKind; use super::Error; use crate::amount::SplitTarget; use crate::mint_url::MintUrl; -use crate::nuts::{CurrencyUnit, Proof, SecretKey, SpendingConditions, Token}; +use crate::nuts::{CurrencyUnit, PaymentMethod, Proof, SecretKey, SpendingConditions, Token}; use crate::types::Melted; use crate::wallet::types::MintQuote; use crate::{Amount, Wallet}; @@ -166,6 +166,7 @@ impl MultiMintWallet { wallet_key: &WalletKey, amount: Amount, description: Option, + payment_method: PaymentMethod, ) -> Result { let wallet = self .get_wallet(wallet_key) diff --git a/crates/cdk/src/wallet/types.rs b/crates/cdk/src/wallet/types.rs index 309a4c1cf..a5df013ee 100644 --- a/crates/cdk/src/wallet/types.rs +++ b/crates/cdk/src/wallet/types.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::mint_url::MintUrl; -use crate::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState}; +use crate::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState, PaymentMethod}; use crate::Amount; /// Mint Quote Info @@ -36,6 +36,8 @@ pub struct MeltQuote { pub amount: Amount, /// Quote Payment request e.g. bolt11 pub request: String, + /// Payment Method + pub payment_method: PaymentMethod, /// Quote fee reserve pub fee_reserve: Amount, /// Quote state