diff --git a/bindings/cdk-js/src/nuts/nut04.rs b/bindings/cdk-js/src/nuts/nut04.rs index 6cbdb33ce..aa90310c3 100644 --- a/bindings/cdk-js/src/nuts/nut04.rs +++ b/bindings/cdk-js/src/nuts/nut04.rs @@ -88,10 +88,18 @@ impl From for JsMintBolt11Request { impl JsMintBolt11Request { /// Try From Base 64 String #[wasm_bindgen(constructor)] - pub fn new(quote: String, outputs: JsValue) -> Result { + pub fn new( + quote: String, + outputs: JsValue, + witness: Option, + ) -> Result { let outputs = serde_wasm_bindgen::from_value(outputs).map_err(into_err)?; Ok(JsMintBolt11Request { - inner: MintBolt11Request { quote, outputs }, + inner: MintBolt11Request { + quote, + outputs, + witness, + }, }) } diff --git a/bindings/cdk-js/src/wallet.rs b/bindings/cdk-js/src/wallet.rs index 8f4cf405c..50e8c9642 100644 --- a/bindings/cdk-js/src/wallet.rs +++ b/bindings/cdk-js/src/wallet.rs @@ -93,7 +93,7 @@ impl JsWallet { ) -> Result { let quote = self .inner - .mint_quote(amount.into(), description) + .mint_quote(amount.into(), description, None) .await .map_err(into_err)?; @@ -142,7 +142,7 @@ impl JsWallet { Ok(self .inner - .mint("e_id, target, conditions) + .mint("e_id, target, conditions, None) .await .map_err(into_err)? .into()) diff --git a/crates/cdk-axum/src/bolt12_router.rs b/crates/cdk-axum/src/bolt12_router.rs new file mode 100644 index 000000000..25a83e642 --- /dev/null +++ b/crates/cdk-axum/src/bolt12_router.rs @@ -0,0 +1,129 @@ +use anyhow::Result; +use axum::extract::{Json, Path, State}; +use axum::response::Response; +use cdk::nuts::{ + MeltBolt12Request, MeltQuoteBolt11Response, MeltQuoteBolt12Request, MintBolt11Request, + MintBolt11Response, MintQuoteBolt12Request, MintQuoteBolt12Response, +}; + +use crate::{into_response, MintState}; + +#[cfg_attr(feature = "swagger", utoipa::path( + get, + context_path = "/v1", + path = "/mint/quote/bolt12", + responses( + (status = 200, description = "Successful response", body = MintQuoteBolt12Response, content_type = "application/json") + ) +))] +/// 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", + path = "/mint/quote/bolt12/{quote_id}", + params( + ("quote_id" = String, description = "The quote ID"), + ), + responses( + (status = 200, description = "Successful response", body = MintQuoteBolt12Response, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +/// Get mint bolt12 quote +pub async fn get_check_mint_bolt12_quote( + State(state): State, + Path(quote_id): Path, +) -> Result, Response> { + let quote = state + .mint + .check_mint_bolt12_quote("e_id) + .await + .map_err(into_response)?; + + Ok(Json(quote)) +} + +#[cfg_attr(feature = "swagger", utoipa::path( + post, + context_path = "/v1", + path = "/mint/bolt12", + request_body(content = MintBolt11Request, description = "Request params", content_type = "application/json"), + responses( + (status = 200, description = "Successful response", body = MintBolt11Response, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +/// 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", + path = "/melt/quote/bolt12", + request_body(content = MeltQuoteBolt12Request, description = "Quote params", content_type = "application/json"), + responses( + (status = 200, description = "Successful response", body = MeltQuoteBolt11Response, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +pub async fn get_melt_bolt12_quote( + State(state): State, + Json(payload): Json, +) -> Result, Response> { + let quote = state + .mint + .get_melt_bolt12_quote(&payload) + .await + .map_err(into_response)?; + + Ok(Json(quote)) +} + +#[cfg_attr(feature = "swagger", utoipa::path( + post, + context_path = "/v1", + path = "/melt/bolt12", + request_body(content = MeltBolt12Request, description = "Melt params", content_type = "application/json"), + responses( + (status = 200, description = "Successful response", body = MeltQuoteBolt11Response, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +/// Melt tokens for a Bitcoin payment that the mint will make for the user in exchange +/// +/// Requests tokens to be destroyed and sent out via Lightning. +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-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index 6c0c24231..9a7d0e5b7 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -9,10 +9,15 @@ use std::time::Duration; use anyhow::Result; use axum::routing::{get, post}; use axum::Router; +use bolt12_router::{ + get_check_mint_bolt12_quote, get_melt_bolt12_quote, get_mint_bolt12_quote, post_melt_bolt12, + post_mint_bolt12, +}; use cdk::mint::Mint; use moka::future::Cache; use router_handlers::*; +mod bolt12_router; mod router_handlers; mod ws; @@ -130,7 +135,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() @@ -140,7 +150,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)) @@ -162,7 +172,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_bolt12_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 29300be8e..8344b2a08 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -157,8 +157,6 @@ pub async fn post_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, @@ -283,11 +281,7 @@ pub async fn post_melt_bolt11( State(state): State, Json(payload): Json, ) -> Result, Response> { - let res = state - .mint - .melt_bolt11(&payload) - .await - .map_err(into_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..fe36f3876 100644 --- a/crates/cdk-cli/src/main.rs +++ b/crates/cdk-cli/src/main.rs @@ -25,7 +25,7 @@ const DEFAULT_WORK_DIR: &str = ".cdk-cli"; #[derive(Parser)] #[command(name = "cashu-tool")] #[command(author = "thesimplekid ")] -#[command(version = "0.1.0")] +#[command(version = "0.4.0")] #[command(author, version, about, long_about = None)] struct Cli { /// Database engine to use (sqlite/redb) @@ -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 @@ -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..e2f9a0282 100644 --- a/crates/cdk-cli/src/sub_commands/melt.rs +++ b/crates/cdk-cli/src/sub_commands/melt.rs @@ -3,9 +3,9 @@ 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 clap::Args; use crate::sub_commands::balance::mint_balances; @@ -15,13 +15,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 +50,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..11771fdfd 100644 --- a/crates/cdk-cli/src/sub_commands/mint.rs +++ b/crates/cdk-cli/src/sub_commands/mint.rs @@ -6,26 +6,32 @@ 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, SecretKey}; use cdk::wallet::multi_mint_wallet::WalletKey; use cdk::wallet::{MultiMintWallet, Wallet}; -use cdk::Amount; use clap::Args; -use serde::{Deserialize, Serialize}; use tokio::time::sleep; -#[derive(Args, Serialize, Deserialize)] +#[derive(Args)] pub struct MintSubCommand { /// Mint url mint_url: MintUrl, /// Amount - amount: u64, + amount: Option, /// 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, + /// Expiry + #[arg(short, long)] + expiry: Option, + /// Expiry + #[arg(short, long)] + single_use: Option, } pub async fn mint( @@ -51,9 +57,37 @@ pub async fn mint( } }; - let quote = wallet - .mint_quote(Amount::from(sub_command_args.amount), description) - .await?; + let secret_key = SecretKey::generate(); + + let method = PaymentMethod::from_str(&sub_command_args.method)?; + + let quote = match method { + PaymentMethod::Bolt11 => { + println!("Bolt11"); + wallet + .mint_quote( + sub_command_args + .amount + .expect("Amount must be defined") + .into(), + description, + Some(secret_key.public_key()), + ) + .await? + } + PaymentMethod::Bolt12 => { + wallet + .mint_bolt12_quote( + sub_command_args.amount.map(|a| a.into()), + description, + sub_command_args.single_use.unwrap_or(false), + sub_command_args.expiry, + secret_key.public_key(), + ) + .await? + } + _ => panic!("Unsupported unit"), + }; println!("Quote: {:#?}", quote); @@ -69,7 +103,9 @@ pub async fn mint( sleep(Duration::from_secs(2)).await; } - let receive_amount = wallet.mint("e.id, SplitTarget::default(), None).await?; + let receive_amount = wallet + .mint("e.id, SplitTarget::default(), None, None) + .await?; println!("Received {receive_amount} from mint {mint_url}"); 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..7367e8fe3 --- /dev/null +++ b/crates/cdk-cli/src/sub_commands/remint_bolt12.rs @@ -0,0 +1,57 @@ +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 + } + }; + + // TODO: Pubkey + let receive_amount = wallet + .mint("e_id, SplitTarget::default(), None, 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/bolt12.rs b/crates/cdk-cln/src/bolt12.rs new file mode 100644 index 000000000..10ac37fdd --- /dev/null +++ b/crates/cdk-cln/src/bolt12.rs @@ -0,0 +1,368 @@ +use std::pin::Pin; +use std::str::FromStr; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use cdk::amount::{amount_for_offer, to_unit, Amount}; +use cdk::cdk_lightning::bolt12::{Bolt12Settings, MintBolt12Lightning}; +use cdk::cdk_lightning::{ + self, Bolt12PaymentQuoteResponse, CreateOfferResponse, MintLightning, PayInvoiceResponse, + WaitInvoiceResponse, +}; +use cdk::mint; +use cdk::mint::types::PaymentRequest; +use cdk::nuts::{CurrencyUnit, MeltQuoteBolt12Request, MeltQuoteState}; +use cdk::util::{hex, unix_time}; +use cln_rpc::model::requests::{ + FetchinvoiceRequest, OfferRequest, PayRequest, WaitanyinvoiceRequest, +}; +use cln_rpc::model::responses::{PayStatus, WaitanyinvoiceResponse, WaitanyinvoiceStatus}; +use cln_rpc::model::Request; +use cln_rpc::primitives::Amount as CLN_Amount; +use futures::{Stream, StreamExt}; +use lightning::offers::invoice::Bolt12Invoice; +use lightning::offers::offer::Offer; +use uuid::Uuid; + +use super::{Cln, Error}; +use crate::fetch_invoice_by_payment_hash; + +#[async_trait] +impl MintBolt12Lightning for Cln { + type Err = cdk_lightning::Error; + + fn get_settings(&self) -> Bolt12Settings { + Bolt12Settings { + mint: true, + melt: true, + unit: CurrencyUnit::Msat, + offer_description: true, + } + } + + fn is_wait_invoice_active(&self) -> bool { + self.wait_invoice_is_active.load(Ordering::SeqCst) + } + + fn cancel_wait_invoice(&self) { + self.wait_invoice_cancel_token.cancel() + } + + // Clippy thinks select is not stable but it compiles fine on MSRV (1.63.0) + #[allow(clippy::incompatible_msrv)] + async fn wait_any_offer( + &self, + ) -> 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?; + + let stream = futures::stream::unfold( + ( + cln_client, + last_pay_index, + self.wait_invoice_cancel_token.clone(), + Arc::clone(&self.bolt12_wait_invoice_is_active), + ), + |(mut cln_client, mut last_pay_idx, cancel_token, is_active)| async move { + // Set the stream as active + is_active.store(true, Ordering::SeqCst); + + loop { + tokio::select! { + _ = cancel_token.cancelled() => { + // Set the stream as inactive + is_active.store(false, Ordering::SeqCst); + // End the stream + return None; + } + result = cln_client.call(cln_rpc::Request::WaitAnyInvoice(WaitanyinvoiceRequest { + timeout: None, + lastpay_index: last_pay_idx, + })) => { + match result { + Ok(invoice) => { + + // Try to convert the invoice to WaitanyinvoiceResponse + let wait_any_response_result: Result = + invoice.try_into(); + + let wait_any_response = match wait_any_response_result { + Ok(response) => response, + Err(e) => { + tracing::warn!( + "Failed to parse WaitAnyInvoice response: {:?}", + e + ); + // Continue to the next iteration without panicking + continue; + } + }; + + // Check the status of the invoice + // We only want to yield invoices that have been paid + match wait_any_response.status { + WaitanyinvoiceStatus::PAID => (), + WaitanyinvoiceStatus::EXPIRED => continue, + } + + last_pay_idx = wait_any_response.pay_index; + + 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_lookup_id = 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, + // we need to do a second query for it. + Some(_) => { + match fetch_invoice_by_payment_hash( + &mut cln_client, + &payment_hash, + ) + .await + { + Ok(Some(invoice)) => { + if let Some(local_offer_id) = invoice.local_offer_id { + local_offer_id.to_string() + } else { + continue; + } + } + Ok(None) => continue, + Err(e) => { + tracing::warn!( + "Error fetching invoice by payment hash: {e}" + ); + continue; + } + } + } + None => payment_hash.clone(), + }; + + let response = WaitInvoiceResponse { + request_lookup_id, + payment_amount: amount_sats.into(), + unit: CurrencyUnit::Sat, + payment_id: payment_hash + }; + + break Some((response, (cln_client, last_pay_idx, cancel_token, is_active))); + } + Err(e) => { + tracing::warn!("Error fetching invoice: {e}"); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + } + } + } + } + }, + ) + .boxed(); + + Ok(stream) + } + + 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(), + }; + + // It seems that the only way to force cln to create a unique offer + // is to encode some random data in the offer + let issuer = Uuid::new_v4().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: Some(issuer), + 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()) + } + } + } +} 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 b9ed45eed..cb2683771 100644 --- a/crates/cdk-cln/src/lib.rs +++ b/crates/cdk-cln/src/lib.rs @@ -14,7 +14,9 @@ use async_trait::async_trait; use cdk::amount::{to_unit, Amount}; use cdk::cdk_lightning::{ self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, + WaitInvoiceResponse, }; +use cdk::mint::types::PaymentRequest; use cdk::mint::FeeReserve; use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use cdk::util::{hex, unix_time}; @@ -34,6 +36,7 @@ use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use uuid::Uuid; +mod bolt12; pub mod error; /// CLN mint backend @@ -44,6 +47,7 @@ pub struct Cln { fee_reserve: FeeReserve, wait_invoice_cancel_token: CancellationToken, wait_invoice_is_active: Arc, + bolt12_wait_invoice_is_active: Arc, } impl Cln { @@ -57,6 +61,7 @@ impl Cln { fee_reserve, wait_invoice_cancel_token: CancellationToken::new(), wait_invoice_is_active: Arc::new(AtomicBool::new(false)), + bolt12_wait_invoice_is_active: Arc::new(AtomicBool::new(false)), }) } } @@ -83,11 +88,11 @@ impl MintLightning for Cln { self.wait_invoice_cancel_token.cancel() } - #[allow(clippy::incompatible_msrv)] // Clippy thinks select is not stable but it compiles fine on MSRV (1.63.0) + #[allow(clippy::incompatible_msrv)] 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?; @@ -144,37 +149,20 @@ impl MintLightning for Cln { let payment_hash = wait_any_response.payment_hash.to_string(); - 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, - // we need to do a second query for it. - Some(_) => { - match fetch_invoice_by_payment_hash( - &mut cln_client, - &payment_hash, - ) - .await - { - Ok(Some(invoice)) => { - if let Some(local_offer_id) = invoice.local_offer_id { - local_offer_id.to_string() - } else { - continue; - } - } - Ok(None) => continue, - Err(e) => { - tracing::warn!( - "Error fetching invoice by payment hash: {e}" - ); - continue; - } - } - } - None => payment_hash, + + // 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 response = WaitInvoiceResponse { + request_lookup_id: payment_hash.clone(), + payment_amount: amount_sats.into(), + unit: CurrencyUnit::Sat, + payment_id: payment_hash }; - return Some((request_look_up, (cln_client, last_pay_idx, cancel_token, is_active))); + break Some((response, (cln_client, last_pay_idx, cancel_token, is_active))); } Err(e) => { tracing::warn!("Error fetching invoice: {e}"); @@ -231,7 +219,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?; @@ -251,7 +243,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, @@ -370,41 +362,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( diff --git a/crates/cdk-fake-wallet/Cargo.toml b/crates/cdk-fake-wallet/Cargo.toml index 8d1b5dffe..5ab90eede 100644 --- a/crates/cdk-fake-wallet/Cargo.toml +++ b/crates/cdk-fake-wallet/Cargo.toml @@ -11,6 +11,7 @@ description = "CDK fake ln backend" [dependencies] async-trait = "0.1.74" +anyhow = "1" bitcoin = { version = "0.32.2", default-features = false } cdk = { path = "../cdk", version = "0.4.0", default-features = false, features = ["mint"] } futures = { version = "0.3.28", default-features = false } @@ -22,5 +23,6 @@ serde = "1" serde_json = "1" uuid = { version = "1", features = ["v4"] } lightning-invoice = { version = "0.32.0", features = ["serde", "std"] } +lightning = { version = "0.0.125", default-features = false, features = ["std"]} tokio-stream = "0.1.15" rand = "0.8.5" diff --git a/crates/cdk-fake-wallet/src/bolt12.rs b/crates/cdk-fake-wallet/src/bolt12.rs new file mode 100644 index 000000000..4bfc17e5a --- /dev/null +++ b/crates/cdk-fake-wallet/src/bolt12.rs @@ -0,0 +1,204 @@ +use std::pin::Pin; +use std::str::FromStr; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use anyhow::anyhow; +use async_trait::async_trait; +use bitcoin::key::Secp256k1; +use cdk::amount::{to_unit, Amount}; +use cdk::cdk_lightning::bolt12::{Bolt12Settings, MintBolt12Lightning}; +use cdk::cdk_lightning::{ + self, Bolt12PaymentQuoteResponse, CreateOfferResponse, PayInvoiceResponse, WaitInvoiceResponse, +}; +use cdk::mint; +use cdk::mint::types::PaymentRequest; +use cdk::nuts::{CurrencyUnit, MeltQuoteBolt12Request}; +use futures::stream::StreamExt; +use futures::Stream; +use lightning::offers::offer::{Amount as LDKAmount, Offer, OfferBuilder}; +use tokio::time; +use tokio_stream::wrappers::ReceiverStream; +use uuid::Uuid; + +use crate::FakeWallet; + +#[async_trait] +impl MintBolt12Lightning for FakeWallet { + type Err = cdk_lightning::Error; + + fn get_settings(&self) -> Bolt12Settings { + Bolt12Settings { + mint: true, + melt: true, + unit: CurrencyUnit::Sat, + offer_description: true, + } + } + + fn is_wait_invoice_active(&self) -> bool { + self.wait_invoice_is_active.load(Ordering::SeqCst) + } + + fn cancel_wait_invoice(&self) { + self.wait_invoice_cancel_token.cancel() + } + + async fn wait_any_offer( + &self, + ) -> Result + Send>>, Self::Err> { + let receiver = self + .bolt12_receiver + .lock() + .await + .take() + .ok_or(super::Error::NoReceiver)?; + let receiver_stream = ReceiverStream::new(receiver); + self.wait_invoice_is_active.store(true, Ordering::SeqCst); + + Ok(Box::pin(receiver_stream.map(|label| WaitInvoiceResponse { + request_lookup_id: label.clone(), + payment_amount: Amount::ZERO, + unit: CurrencyUnit::Sat, + payment_id: label, + }))) + } + + async fn get_bolt12_payment_quote( + &self, + melt_quote_request: &MeltQuoteBolt12Request, + ) -> Result { + let amount = match melt_quote_request.amount { + Some(amount) => amount, + None => { + let offer = Offer::from_str(&melt_quote_request.request) + .map_err(|_| anyhow!("Invalid offer in request"))?; + + match offer.amount() { + Some(LDKAmount::Bitcoin { amount_msats }) => amount_msats.into(), + None => { + return Err(cdk_lightning::Error::Anyhow(anyhow!( + "Amount not defined in offer or request" + ))) + } + _ => return Err(cdk_lightning::Error::Anyhow(anyhow!("Unsupported unit"))), + } + } + }; + + 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 fee = match relative_fee_reserve > absolute_fee_reserve { + true => relative_fee_reserve, + false => absolute_fee_reserve, + }; + + Ok(Bolt12PaymentQuoteResponse { + request_lookup_id: Uuid::new_v4().to_string(), + amount, + fee: fee.into(), + state: cdk::nuts::MeltQuoteState::Unpaid, + invoice: Some("".to_string()), + }) + } + + async fn pay_bolt12_offer( + &self, + melt_quote: mint::MeltQuote, + _amount: Option, + _max_fee_amount: Option, + ) -> Result { + let bolt12 = &match melt_quote.request { + PaymentRequest::Bolt11 { .. } => return Err(super::Error::WrongRequestType.into()), + PaymentRequest::Bolt12 { offer, invoice: _ } => offer, + }; + + // let description = bolt12.description().to_string(); + + // let status: Option = serde_json::from_str(&description).ok(); + + // let mut payment_states = self.payment_states.lock().await; + // let payment_status = status + // .clone() + // .map(|s| s.pay_invoice_state) + // .unwrap_or(MeltQuoteState::Paid); + + // let checkout_going_status = status + // .clone() + // .map(|s| s.check_payment_state) + // .unwrap_or(MeltQuoteState::Paid); + + // payment_states.insert(payment_hash.clone(), checkout_going_status); + + // if let Some(description) = status { + // if description.check_err { + // let mut fail = self.failed_payment_check.lock().await; + // fail.insert(payment_hash.clone()); + // } + + // if description.pay_err { + // return Err(Error::UnknownInvoice.into()); + // } + // } + + Ok(PayInvoiceResponse { + payment_preimage: Some("".to_string()), + payment_lookup_id: bolt12.to_string(), + status: super::MeltQuoteState::Paid, + total_spent: melt_quote.amount, + unit: melt_quote.unit, + }) + } + + async fn create_bolt12_offer( + &self, + amount: Option, + unit: &CurrencyUnit, + description: String, + unix_expiry: u64, + _single_use: bool, + ) -> Result { + let secret_key = bitcoin::secp256k1::SecretKey::new(&mut rand::thread_rng()); + + let secp_ctx = Secp256k1::new(); + + let offer_builder = OfferBuilder::new(secret_key.public_key(&secp_ctx)) + .description(description) + .absolute_expiry(Duration::from_secs(unix_expiry)); + + let offer_builder = match amount { + Some(amount) => { + let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?; + offer_builder.amount_msats(amount.into()) + } + None => offer_builder, + }; + + let offer = offer_builder.build().unwrap(); + + let offer_string = offer.to_string(); + + let sender = self.bolt12_sender.clone(); + + let duration = time::Duration::from_secs(self.payment_delay); + + tokio::spawn(async move { + // Wait for the random delay to elapse + time::sleep(duration).await; + + // Send the message after waiting for the specified duration + if sender.send(offer_string.clone()).await.is_err() { + tracing::error!("Failed to send label: {}", offer_string); + } + }); + + Ok(CreateOfferResponse { + request_lookup_id: offer.to_string(), + request: offer, + expiry: Some(unix_expiry), + }) + } +} 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 9e2878371..1389bf955 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; @@ -17,8 +16,10 @@ use bitcoin::secp256k1::{Secp256k1, SecretKey}; use cdk::amount::{to_unit, Amount}; use cdk::cdk_lightning::{ self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, + WaitInvoiceResponse, }; use cdk::mint; +use cdk::mint::types::PaymentRequest; use cdk::mint::FeeReserve; use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use cdk::util::unix_time; @@ -33,6 +34,7 @@ use tokio::time; use tokio_stream::wrappers::ReceiverStream; use tokio_util::sync::CancellationToken; +mod bolt12; pub mod error; /// Fake Wallet @@ -41,6 +43,8 @@ pub struct FakeWallet { fee_reserve: FeeReserve, sender: tokio::sync::mpsc::Sender, receiver: Arc>>>, + bolt12_sender: tokio::sync::mpsc::Sender, + bolt12_receiver: Arc>>>, payment_states: Arc>>, failed_payment_check: Arc>>, payment_delay: u64, @@ -57,11 +61,14 @@ impl FakeWallet { payment_delay: u64, ) -> Self { let (sender, receiver) = tokio::sync::mpsc::channel(8); + let (bolt12_sender, bolt12_receiver) = tokio::sync::mpsc::channel(8); Self { fee_reserve, sender, receiver: Arc::new(Mutex::new(Some(receiver))), + bolt12_sender, + bolt12_receiver: Arc::new(Mutex::new(Some(bolt12_receiver))), payment_states: Arc::new(Mutex::new(payment_states)), failed_payment_check: Arc::new(Mutex::new(fail_payment_check)), payment_delay, @@ -117,10 +124,17 @@ 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| WaitInvoiceResponse { + request_lookup_id: label.clone(), + payment_amount: Amount::ZERO, + unit: CurrencyUnit::Sat, + payment_id: label, + }))) } async fn get_payment_quote( @@ -162,7 +176,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(); diff --git a/crates/cdk-integration-tests/Cargo.toml b/crates/cdk-integration-tests/Cargo.toml index 8753408c1..1ea94b322 100644 --- a/crates/cdk-integration-tests/Cargo.toml +++ b/crates/cdk-integration-tests/Cargo.toml @@ -33,7 +33,7 @@ uuid = { version = "1", features = ["v4"] } serde = "1" serde_json = "1" # ln-regtest-rs = { path = "../../../../ln-regtest-rs" } -ln-regtest-rs = { git = "https://github.com/thesimplekid/ln-regtest-rs", rev = "1d88d3d0b" } +ln-regtest-rs = { git = "https://github.com/thesimplekid/ln-regtest-rs", rev = "3a542821f" } lightning-invoice = { version = "0.32.0", features = ["serde", "std"] } tracing = { version = "0.1", default-features = false, features = [ "attributes", diff --git a/crates/cdk-integration-tests/src/init_fake_wallet.rs b/crates/cdk-integration-tests/src/init_fake_wallet.rs index d3216b820..980dea3c0 100644 --- a/crates/cdk-integration-tests/src/init_fake_wallet.rs +++ b/crates/cdk-integration-tests/src/init_fake_wallet.rs @@ -7,7 +7,6 @@ use cdk::cdk_database::{self, MintDatabase}; use cdk::cdk_lightning::MintLightning; use cdk::mint::FeeReserve; use cdk::nuts::CurrencyUnit; -use cdk::types::LnKey; use cdk_fake_wallet::FakeWallet; use tokio::sync::Notify; use tower_http::cors::CorsLayer; @@ -33,7 +32,7 @@ where tracing_subscriber::fmt().with_env_filter(env_filter).init(); let mut ln_backends: HashMap< - LnKey, + CurrencyUnit, Arc + Sync + Send>, > = HashMap::new(); @@ -44,19 +43,17 @@ where let fake_wallet = FakeWallet::new(fee_reserve, HashMap::default(), HashSet::default(), 0); - ln_backends.insert( - LnKey::new(CurrencyUnit::Sat, cdk::nuts::PaymentMethod::Bolt11), - Arc::new(fake_wallet), - ); + ln_backends.insert(CurrencyUnit::Sat, Arc::new(fake_wallet)); - let mint = create_mint(database, ln_backends.clone()).await?; + let mint = create_mint(database, ln_backends.clone(), HashMap::new()).await?; let cache_ttl = 3600; 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 525f8e4ed..c5d260152 100644 --- a/crates/cdk-integration-tests/src/init_regtest.rs +++ b/crates/cdk-integration-tests/src/init_regtest.rs @@ -7,32 +7,27 @@ use anyhow::Result; use axum::Router; use bip39::Mnemonic; use cdk::cdk_database::{self, MintDatabase}; +use cdk::cdk_lightning::bolt12::MintBolt12Lightning; use cdk::cdk_lightning::MintLightning; -use cdk::mint::{FeeReserve, Mint}; -use cdk::nuts::{CurrencyUnit, MintInfo}; -use cdk::types::{LnKey, QuoteTTL}; +use cdk::mint::{FeeReserve, Mint, MintBuilder, MintMeltLimits}; +use cdk::nuts::CurrencyUnit; use cdk_cln::Cln as CdkCln; use ln_regtest_rs::bitcoin_client::BitcoinClient; use ln_regtest_rs::bitcoind::Bitcoind; -use ln_regtest_rs::cln::Clnd; -use ln_regtest_rs::cln_client::ClnClient; +use ln_regtest_rs::ln_client::{ClnClient, LightningClient, LndClient}; use ln_regtest_rs::lnd::Lnd; -use ln_regtest_rs::lnd_client::LndClient; use tokio::sync::Notify; use tower_http::cors::CorsLayer; -use tracing_subscriber::EnvFilter; -const BITCOIND_ADDR: &str = "127.0.0.1:18443"; -const ZMQ_RAW_BLOCK: &str = "tcp://127.0.0.1:28332"; -const ZMQ_RAW_TX: &str = "tcp://127.0.0.1:28333"; -const BITCOIN_RPC_USER: &str = "testuser"; -const BITCOIN_RPC_PASS: &str = "testpass"; -const CLN_ADDR: &str = "127.0.0.1:19846"; -const LND_ADDR: &str = "0.0.0.0:18444"; -const LND_RPC_ADDR: &str = "https://127.0.0.1:10009"; +pub const BITCOIND_ADDR: &str = "127.0.0.1:18443"; +pub const ZMQ_RAW_BLOCK: &str = "tcp://127.0.0.1:28332"; +pub const ZMQ_RAW_TX: &str = "tcp://127.0.0.1:28333"; +pub const BITCOIN_RPC_USER: &str = "testuser"; +pub const BITCOIN_RPC_PASS: &str = "testpass"; +const LND_ADDR: &str = "0.0.0.0:18449"; +const LND_RPC_ADDR: &str = "localhost:10009"; const BITCOIN_DIR: &str = "bitcoin"; -const CLN_DIR: &str = "cln"; const LND_DIR: &str = "lnd"; pub fn get_mint_addr() -> String { @@ -85,26 +80,12 @@ pub fn init_bitcoin_client() -> Result { ) } -pub fn get_cln_dir() -> PathBuf { - let dir = get_temp_dir().join(CLN_DIR); +pub fn get_cln_dir(name: &str) -> PathBuf { + let dir = get_temp_dir().join(name); std::fs::create_dir_all(&dir).unwrap(); dir } -pub fn init_cln() -> Clnd { - Clnd::new( - get_bitcoin_dir(), - get_cln_dir(), - CLN_ADDR.to_string().parse().unwrap(), - BITCOIN_RPC_USER.to_string(), - BITCOIN_RPC_PASS.to_string(), - ) -} - -pub async fn init_cln_client() -> Result { - ClnClient::new(get_cln_dir(), None).await -} - pub fn get_lnd_dir() -> PathBuf { let dir = get_temp_dir().join(LND_DIR); std::fs::create_dir_all(&dir).unwrap(); @@ -116,6 +97,7 @@ pub async fn init_lnd() -> Lnd { get_bitcoin_dir(), get_lnd_dir(), LND_ADDR.parse().unwrap(), + LND_RPC_ADDR.to_string(), BITCOIN_RPC_USER.to_string(), BITCOIN_RPC_PASS.to_string(), ZMQ_RAW_BLOCK.to_string(), @@ -127,7 +109,12 @@ pub async fn init_lnd_client() -> Result { let lnd_dir = get_lnd_dir(); let cert_file = lnd_dir.join("tls.cert"); let macaroon_file = lnd_dir.join("data/chain/bitcoin/regtest/admin.macaroon"); - LndClient::new(LND_RPC_ADDR.parse().unwrap(), cert_file, macaroon_file).await + LndClient::new( + format!("https://{}", LND_RPC_ADDR).parse().unwrap(), + cert_file, + macaroon_file, + ) + .await } pub async fn create_cln_backend(cln_client: &ClnClient) -> Result { @@ -144,78 +131,72 @@ pub async fn create_cln_backend(cln_client: &ClnClient) -> Result { pub async fn create_mint( database: D, ln_backends: HashMap< - LnKey, + CurrencyUnit, Arc + Sync + Send>, >, + bolt12_ln_backends: HashMap< + CurrencyUnit, + Arc + Sync + Send>, + >, ) -> Result where D: MintDatabase + Send + Sync + 'static, { - let nuts = cdk::nuts::Nuts::new() - .nut07(true) - .nut08(true) - .nut09(true) - .nut10(true) - .nut11(true) - .nut12(true) - .nut14(true); - - let mint_info = MintInfo::new().nuts(nuts); - let mnemonic = Mnemonic::generate(12)?; - let mut supported_units: HashMap = HashMap::new(); - supported_units.insert(CurrencyUnit::Sat, (0, 32)); + let mut mint_builder = MintBuilder::new() + .with_localstore(Arc::new(database)) + .with_seed(mnemonic.to_seed_normalized("").to_vec()) + .with_quote_ttl(10000, 10000) + .with_mint_url(get_mint_url()); + + let mint_melt_limits = MintMeltLimits { + mint_min: 1.into(), + mint_max: 10_000.into(), + melt_min: 1.into(), + melt_max: 10_000.into(), + }; - let quote_ttl = QuoteTTL::new(10000, 10000); + for (unit, ln_backend) in ln_backends { + println!("11 {}", unit); + mint_builder = mint_builder.add_ln_backend(unit, mint_melt_limits, ln_backend); + } - let mint = Mint::new( - &get_mint_url(), - &mnemonic.to_seed_normalized(""), - mint_info, - quote_ttl, - Arc::new(database), - ln_backends, - supported_units, - HashMap::new(), - ) - .await?; + for (unit, ln_backend) in bolt12_ln_backends { + println!("12 {}", unit); + mint_builder = mint_builder.add_bolt12_ln_backend(unit, mint_melt_limits, ln_backend); + } + + let mint = mint_builder.build().await?; Ok(mint) } -pub async fn start_cln_mint(addr: &str, port: u16, database: D) -> Result<()> +pub async fn start_cln_mint(cln_path: PathBuf, addr: &str, port: u16, database: D) -> Result<()> where D: MintDatabase + Send + Sync + 'static, { - let default_filter = "debug"; - - let sqlx_filter = "sqlx=warn"; - let hyper_filter = "hyper=warn"; - - let env_filter = EnvFilter::new(format!( - "{},{},{}", - default_filter, sqlx_filter, hyper_filter - )); - - // Parse input - tracing_subscriber::fmt().with_env_filter(env_filter).init(); - - let cln_client = init_cln_client().await?; + let cln_client = ClnClient::new(cln_path, None).await?; let cln_backend = create_cln_backend(&cln_client).await?; let mut ln_backends: HashMap< - LnKey, + CurrencyUnit, Arc + Sync + Send>, > = HashMap::new(); - ln_backends.insert( - LnKey::new(CurrencyUnit::Sat, cdk::nuts::PaymentMethod::Bolt11), - Arc::new(cln_backend), - ); + let cln_arc = Arc::new(cln_backend); - let mint = create_mint(database, ln_backends.clone()).await?; + ln_backends.insert(CurrencyUnit::Sat, cln_arc.clone()); + + let mut bolt12_ln_backends: HashMap< + CurrencyUnit, + Arc + Sync + Send>, + > = HashMap::new(); + + bolt12_ln_backends.insert(CurrencyUnit::Sat, cln_arc.clone()); + + let mint = create_mint(database, ln_backends.clone(), bolt12_ln_backends.clone()).await?; let cache_time_to_live = 3600; let cache_time_to_idle = 3600; let mint_arc = Arc::new(mint); @@ -224,6 +205,7 @@ where Arc::clone(&mint_arc), cache_time_to_live, cache_time_to_idle, + true, ) .await .unwrap(); @@ -241,6 +223,13 @@ where async move { mint.wait_for_paid_invoices(shutdown).await } }); + let mint = Arc::clone(&mint_arc); + + tokio::spawn({ + let shutdown = Arc::clone(&shutdown); + async move { mint.wait_for_paid_offers(shutdown).await } + }); + println!("Staring Axum server"); axum::Server::bind(&format!("{}:{}", addr, port).as_str().parse().unwrap()) .serve(mint_service.into_make_service()) @@ -249,40 +238,41 @@ where Ok(()) } -pub async fn fund_ln( - bitcoin_client: &BitcoinClient, - cln_client: &ClnClient, - lnd_client: &LndClient, -) -> Result<()> { - let lnd_address = lnd_client.get_new_address().await?; +pub async fn fund_ln(bitcoin_client: &BitcoinClient, ln_client: &C) -> Result<()> +where + C: LightningClient, +{ + let ln_address = ln_client.get_new_onchain_address().await?; - bitcoin_client.send_to_address(&lnd_address, 2_000_000)?; + bitcoin_client.send_to_address(&ln_address, 2_000_000)?; - let cln_address = cln_client.get_new_address().await?; - bitcoin_client.send_to_address(&cln_address, 2_000_000)?; + ln_client.wait_chain_sync().await?; - let mining_address = bitcoin_client.get_new_address()?; - bitcoin_client.generate_blocks(&mining_address, 200)?; + let mine_to_address = bitcoin_client.get_new_address()?; + bitcoin_client.generate_blocks(&mine_to_address, 10)?; - cln_client.wait_chain_sync().await?; - lnd_client.wait_chain_sync().await?; + ln_client.wait_chain_sync().await?; Ok(()) } -pub async fn open_channel( +pub async fn open_channel( bitcoin_client: &BitcoinClient, - cln_client: &ClnClient, - lnd_client: &LndClient, -) -> Result<()> { - let cln_info = cln_client.get_info().await?; + cln_client: &C1, + lnd_client: &C2, +) -> Result<()> +where + C1: LightningClient, + C2: LightningClient, +{ + let cln_info = cln_client.get_connect_info().await?; - let cln_pubkey = cln_info.id; - let cln_address = "127.0.0.1"; - let cln_port = 19846; + let cln_pubkey = cln_info.pubkey; + let cln_address = cln_info.address; + let cln_port = cln_info.port; lnd_client - .connect(cln_pubkey.to_string(), cln_address.to_string(), cln_port) + .connect_peer(cln_pubkey.to_string(), cln_address.to_string(), cln_port) .await .unwrap(); diff --git a/crates/cdk-integration-tests/src/lib.rs b/crates/cdk-integration-tests/src/lib.rs index 2e52b0345..7d5f3cb41 100644 --- a/crates/cdk-integration-tests/src/lib.rs +++ b/crates/cdk-integration-tests/src/lib.rs @@ -39,7 +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(), + fee_reserve, HashMap::default(), HashSet::default(), 0, @@ -52,7 +52,7 @@ pub fn create_backends_fake_wallet( pub async fn start_mint( ln_backends: HashMap< - LnKey, + CurrencyUnit, Arc + Sync + Send>, >, supported_units: HashMap, @@ -79,19 +79,21 @@ pub async fn start_mint( quote_ttl, Arc::new(MintMemoryDatabase::default()), ln_backends.clone(), + HashMap::new(), supported_units, HashMap::new(), ) .await?; + let cache_time_to_live = 3600; 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?; @@ -125,7 +127,7 @@ pub async fn wallet_mint( split_target: SplitTarget, description: Option, ) -> Result<()> { - let quote = wallet.mint_quote(amount, description).await?; + let quote = wallet.mint_quote(amount, description, None).await?; loop { let status = wallet.mint_quote_state("e.id).await?; @@ -138,7 +140,7 @@ pub async fn wallet_mint( sleep(Duration::from_secs(2)).await; } - let receive_amount = wallet.mint("e.id, split_target, None).await?; + let receive_amount = wallet.mint("e.id, split_target, None, None).await?; println!("Minted: {}", receive_amount); @@ -161,6 +163,7 @@ pub async fn mint_proofs( amount, unit: CurrencyUnit::Sat, description, + pubkey: None, }; let mint_quote = wallet_client @@ -187,6 +190,7 @@ pub async fn mint_proofs( let request = MintBolt11Request { quote: mint_quote.quote, outputs: premint_secrets.blinded_messages(), + witness: None, }; let mint_response = wallet_client.post_mint(mint_url.parse()?, request).await?; diff --git a/crates/cdk-integration-tests/src/main.rs b/crates/cdk-integration-tests/src/main.rs index 5cf76c4da..d62698363 100644 --- a/crates/cdk-integration-tests/src/main.rs +++ b/crates/cdk-integration-tests/src/main.rs @@ -3,14 +3,30 @@ use std::env; use anyhow::Result; use cdk::cdk_database::mint_memory::MintMemoryDatabase; use cdk_integration_tests::init_regtest::{ - fund_ln, get_temp_dir, init_bitcoin_client, init_bitcoind, init_cln, init_cln_client, init_lnd, - init_lnd_client, open_channel, start_cln_mint, + fund_ln, get_bitcoin_dir, get_cln_dir, get_temp_dir, init_bitcoin_client, init_bitcoind, + init_lnd, init_lnd_client, open_channel, start_cln_mint, BITCOIN_RPC_PASS, BITCOIN_RPC_USER, }; use cdk_redb::MintRedbDatabase; use cdk_sqlite::MintSqliteDatabase; +use ln_regtest_rs::{ + cln::Clnd, + ln_client::{ClnClient, LightningClient}, +}; +use tracing_subscriber::EnvFilter; + +const CLN_ADDR: &str = "127.0.0.1:19846"; +const CLN_TWO_ADDR: &str = "127.0.0.1:19847"; #[tokio::main] async fn main() -> Result<()> { + let default_filter = "debug"; + + let sqlx_filter = "sqlx=warn,h2=warn,hyper=warn"; + + let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter)); + + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + let mut bitcoind = init_bitcoind(); bitcoind.start_bitcoind()?; @@ -21,19 +37,47 @@ async fn main() -> Result<()> { let new_add = bitcoin_client.get_new_address()?; bitcoin_client.generate_blocks(&new_add, 200).unwrap(); - let mut clnd = init_cln(); + let cln_one_dir = get_cln_dir("one"); + let mut clnd = Clnd::new( + get_bitcoin_dir(), + cln_one_dir.clone(), + CLN_ADDR.into(), + BITCOIN_RPC_USER.to_string(), + BITCOIN_RPC_PASS.to_string(), + ); clnd.start_clnd()?; - let cln_client = init_cln_client().await?; + let cln_client = ClnClient::new(cln_one_dir.clone(), None).await?; + + cln_client.wait_chain_sync().await.unwrap(); + + fund_ln(&bitcoin_client, &cln_client).await.unwrap(); + + // Create second cln + let cln_two_dir = get_cln_dir("two"); + let mut clnd_two = Clnd::new( + get_bitcoin_dir(), + cln_two_dir.clone(), + CLN_TWO_ADDR.into(), + BITCOIN_RPC_USER.to_string(), + BITCOIN_RPC_PASS.to_string(), + ); + clnd_two.start_clnd()?; + + let cln_two_client = ClnClient::new(cln_two_dir.clone(), None).await?; + + cln_client.wait_chain_sync().await.unwrap(); + + fund_ln(&bitcoin_client, &cln_two_client).await.unwrap(); let mut lnd = init_lnd().await; lnd.start_lnd().unwrap(); let lnd_client = init_lnd_client().await.unwrap(); - fund_ln(&bitcoin_client, &cln_client, &lnd_client) - .await - .unwrap(); + lnd_client.wait_chain_sync().await.unwrap(); + + fund_ln(&bitcoin_client, &lnd_client).await.unwrap(); open_channel(&bitcoin_client, &cln_client, &lnd_client) .await @@ -44,18 +88,20 @@ async fn main() -> Result<()> { let mint_db_kind = env::var("MINT_DATABASE")?; + let db_path = get_temp_dir().join("mint"); + match mint_db_kind.as_str() { "MEMORY" => { - start_cln_mint(addr, port, MintMemoryDatabase::default()).await?; + start_cln_mint(cln_one_dir, addr, port, MintMemoryDatabase::default()).await?; } "SQLITE" => { - let sqlite_db = MintSqliteDatabase::new(&get_temp_dir().join("mint")).await?; + let sqlite_db = MintSqliteDatabase::new(&db_path).await?; sqlite_db.migrate().await; - start_cln_mint(addr, port, sqlite_db).await?; + start_cln_mint(cln_one_dir, addr, port, sqlite_db).await?; } "REDB" => { - let redb_db = MintRedbDatabase::new(&get_temp_dir().join("mint")).unwrap(); - start_cln_mint(addr, port, redb_db).await?; + let redb_db = MintRedbDatabase::new(&db_path).unwrap(); + start_cln_mint(cln_one_dir, addr, port, redb_db).await?; } _ => panic!("Unknown mint db type: {}", mint_db_kind), }; diff --git a/crates/cdk-integration-tests/tests/fake_wallet.rs b/crates/cdk-integration-tests/tests/fake_wallet.rs index cf0ea1bef..06319b7cd 100644 --- a/crates/cdk-integration-tests/tests/fake_wallet.rs +++ b/crates/cdk-integration-tests/tests/fake_wallet.rs @@ -1,12 +1,13 @@ use std::sync::Arc; use std::time::Duration; -use anyhow::Result; +use anyhow::{bail, Result}; use bip39::Mnemonic; use cdk::amount::SplitTarget; use cdk::cdk_database::WalletMemoryDatabase; use cdk::nuts::{ - CurrencyUnit, MeltBolt11Request, MeltQuoteState, MintQuoteState, PreMintSecrets, State, + CurrencyUnit, MeltBolt11Request, MeltQuoteState, MintQuoteState, PreMintSecrets, SecretKey, + State, }; use cdk::wallet::client::{HttpClient, HttpClientMethods}; use cdk::wallet::Wallet; @@ -27,12 +28,12 @@ async fn test_fake_tokens_pending() -> Result<()> { None, )?; - let mint_quote = wallet.mint_quote(100.into(), None).await?; + let mint_quote = wallet.mint_quote(100.into(), None, None).await?; wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; let fake_description = FakeInvoiceDescription { @@ -67,12 +68,12 @@ async fn test_fake_melt_payment_fail() -> Result<()> { None, )?; - let mint_quote = wallet.mint_quote(100.into(), None).await?; + let mint_quote = wallet.mint_quote(100.into(), None, None).await?; wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; let fake_description = FakeInvoiceDescription { @@ -130,12 +131,12 @@ async fn test_fake_melt_payment_fail_and_check() -> Result<()> { None, )?; - let mint_quote = wallet.mint_quote(100.into(), None).await?; + let mint_quote = wallet.mint_quote(100.into(), None, None).await?; wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; let fake_description = FakeInvoiceDescription { @@ -175,12 +176,12 @@ async fn test_fake_melt_payment_return_fail_status() -> Result<()> { None, )?; - let mint_quote = wallet.mint_quote(100.into(), None).await?; + let mint_quote = wallet.mint_quote(100.into(), None, None).await?; wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; let fake_description = FakeInvoiceDescription { @@ -235,12 +236,12 @@ async fn test_fake_melt_payment_error_unknown() -> Result<()> { None, )?; - let mint_quote = wallet.mint_quote(100.into(), None).await?; + let mint_quote = wallet.mint_quote(100.into(), None, None).await?; wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; let fake_description = FakeInvoiceDescription { @@ -296,12 +297,12 @@ async fn test_fake_melt_payment_err_paid() -> Result<()> { None, )?; - let mint_quote = wallet.mint_quote(100.into(), None).await?; + let mint_quote = wallet.mint_quote(100.into(), None, None).await?; wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; let fake_description = FakeInvoiceDescription { @@ -334,12 +335,12 @@ async fn test_fake_melt_change_in_quote() -> Result<()> { None, )?; - let mint_quote = wallet.mint_quote(100.into(), None).await?; + let mint_quote = wallet.mint_quote(100.into(), None, None).await?; wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; let fake_description = FakeInvoiceDescription::default(); @@ -367,16 +368,98 @@ async fn test_fake_melt_change_in_quote() -> Result<()> { assert!(melt_response.change.is_some()); let check = wallet.melt_quote_status(&melt_quote.id).await?; - let mut melt_change = melt_response.change.unwrap(); - melt_change.sort_by(|a, b| a.amount.cmp(&b.amount)); + let mut check_change = check.change.unwrap(); - let mut check = check.change.unwrap(); - check.sort_by(|a, b| a.amount.cmp(&b.amount)); + let mut melt_response_change = melt_response.change.unwrap(); + melt_response_change.sort_by(|a, b| a.amount.cmp(&b.amount)); - assert_eq!(melt_change, check); + check_change.sort_by(|a, b| a.amount.cmp(&b.amount)); + + assert_eq!(melt_response_change, check_change); Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_fake_mint_with_witness() -> Result<()> { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(WalletMemoryDatabase::default()), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + let secret = SecretKey::generate(); + let mint_quote = wallet + .mint_quote(100.into(), None, Some(secret.public_key())) + .await?; + + wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; + + let mint_amount = wallet + .mint(&mint_quote.id, SplitTarget::default(), None, Some(secret)) + .await?; + + assert!(mint_amount == 100.into()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_fake_mint_without_witness() -> Result<()> { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(WalletMemoryDatabase::default()), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + let secret = SecretKey::generate(); + let mint_quote = wallet + .mint_quote(100.into(), None, Some(secret.public_key())) + .await?; + + wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; + + let mint_amount = wallet + .mint(&mint_quote.id, SplitTarget::default(), None, None) + .await; + + match mint_amount { + Err(cdk::error::Error::SecretKeyNotProvided) => Ok(()), + _ => bail!("Wrong mint response for minting without witness"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_fake_mint_with_wrong_witness() -> Result<()> { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(WalletMemoryDatabase::default()), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + let secret = SecretKey::generate(); + let mint_quote = wallet + .mint_quote(100.into(), None, Some(secret.public_key())) + .await?; + + wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; + let secret = SecretKey::generate(); + + let mint_amount = wallet + .mint(&mint_quote.id, SplitTarget::default(), None, Some(secret)) + .await; + + match mint_amount { + Err(cdk::error::Error::IncorrectSecretKey) => Ok(()), + _ => { + bail!("Wrong mint response for minting without witness") + } + } +} + // Keep polling the state of the mint quote id until it's paid async fn wait_for_mint_to_be_paid(wallet: &Wallet, mint_quote_id: &str) -> Result<()> { loop { diff --git a/crates/cdk-integration-tests/tests/mint.rs b/crates/cdk-integration-tests/tests/mint.rs index 3c9f1258a..266feef9e 100644 --- a/crates/cdk-integration-tests/tests/mint.rs +++ b/crates/cdk-integration-tests/tests/mint.rs @@ -8,13 +8,14 @@ use anyhow::{bail, Result}; use bip39::Mnemonic; use cdk::amount::{Amount, SplitTarget}; use cdk::cdk_database::mint_memory::MintMemoryDatabase; +use cdk::cdk_lightning::WaitInvoiceResponse; use cdk::dhke::construct_proofs; use cdk::mint::MintQuote; use cdk::nuts::nut00::ProofsMethods; use cdk::nuts::nut17::Params; use cdk::nuts::{ - CurrencyUnit, Id, MintBolt11Request, MintInfo, NotificationPayload, Nuts, PreMintSecrets, - ProofState, Proofs, SecretKey, SpendingConditions, State, SwapRequest, + CurrencyUnit, Id, MintBolt11Request, MintInfo, NotificationPayload, Nuts, PaymentMethod, + PreMintSecrets, ProofState, Proofs, SecretKey, SpendingConditions, State, SwapRequest, }; use cdk::types::QuoteTTL; use cdk::util::unix_time; @@ -52,6 +53,7 @@ async fn new_mint(fee: u64) -> Mint { quote_ttl, Arc::new(MintMemoryDatabase::default()), HashMap::new(), + HashMap::new(), supported_units, HashMap::new(), ) @@ -74,15 +76,28 @@ async fn mint_proofs( let quote = MintQuote::new( mint.mint_url.clone(), "".to_string(), + PaymentMethod::Bolt11, CurrencyUnit::Sat, - amount, + Some(amount), unix_time() + 36000, request_lookup.to_string(), + Amount::ZERO, + Amount::ZERO, + true, + vec![], + None, ); mint.localstore.add_mint_quote(quote.clone()).await?; - mint.pay_mint_quote_for_request_id(&request_lookup).await?; + let wait_invoice = WaitInvoiceResponse { + request_lookup_id: request_lookup.clone(), + payment_amount: amount, + unit: CurrencyUnit::Sat, + payment_id: request_lookup, + }; + + mint.pay_mint_quote_for_request_id(wait_invoice).await?; let keyset_id = Id::from(&keys); let premint = PreMintSecrets::random(keyset_id, amount, split_target)?; @@ -90,6 +105,7 @@ async fn mint_proofs( let mint_request = MintBolt11Request { quote: quote.id, outputs: premint.blinded_messages(), + witness: None, }; let after_mint = mint.process_mint_request(mint_request).await?; diff --git a/crates/cdk-integration-tests/tests/regtest.rs b/crates/cdk-integration-tests/tests/regtest.rs index 107fa49eb..60b9bc03c 100644 --- a/crates/cdk-integration-tests/tests/regtest.rs +++ b/crates/cdk-integration-tests/tests/regtest.rs @@ -7,6 +7,7 @@ use anyhow::{bail, Result}; use bip39::Mnemonic; use cdk::amount::{Amount, SplitTarget}; use cdk::cdk_database::WalletMemoryDatabase; +use cdk::nuts::SecretKey; use cdk::nuts::{ CurrencyUnit, MeltQuoteState, MintBolt11Request, MintQuoteState, NotificationPayload, PreMintSecrets, State, @@ -14,10 +15,11 @@ use cdk::nuts::{ use cdk::wallet::client::{HttpClient, HttpClientMethods}; use cdk::wallet::Wallet; use cdk_integration_tests::init_regtest::{ - get_mint_url, get_mint_ws_url, init_cln_client, init_lnd_client, + get_cln_dir, get_mint_url, get_mint_ws_url, init_lnd_client, }; use futures::{SinkExt, StreamExt}; use lightning_invoice::Bolt11Invoice; +use ln_regtest_rs::ln_client::{ClnClient, LightningClient}; use ln_regtest_rs::InvoiceStatus; use serde_json::json; use tokio::time::{sleep, timeout}; @@ -73,12 +75,12 @@ async fn test_regtest_mint_melt_round_trip() -> Result<()> { .expect("Failed to connect"); let (mut write, mut reader) = ws_stream.split(); - let mint_quote = wallet.mint_quote(100.into(), None).await?; + let mint_quote = wallet.mint_quote(100.into(), None, None).await?; lnd_client.pay_invoice(mint_quote.request).await?; let mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; assert!(mint_amount == 100.into()); @@ -151,14 +153,14 @@ async fn test_regtest_mint_melt() -> Result<()> { let mint_amount = Amount::from(100); - let mint_quote = wallet.mint_quote(mint_amount, None).await?; + let mint_quote = wallet.mint_quote(mint_amount, None, None).await?; assert_eq!(mint_quote.amount, mint_amount); lnd_client.pay_invoice(mint_quote.request).await?; let mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; assert!(mint_amount == 100.into()); @@ -179,12 +181,12 @@ async fn test_restore() -> Result<()> { None, )?; - let mint_quote = wallet.mint_quote(100.into(), None).await?; + let mint_quote = wallet.mint_quote(100.into(), None, None).await?; lnd_client.pay_invoice(mint_quote.request).await?; let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; assert!(wallet.total_balance().await? == 100.into()); @@ -235,12 +237,12 @@ async fn test_pay_invoice_twice() -> Result<()> { None, )?; - let mint_quote = wallet.mint_quote(100.into(), None).await?; + let mint_quote = wallet.mint_quote(100.into(), None, None).await?; lnd_client.pay_invoice(mint_quote.request).await?; let mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; assert_eq!(mint_amount, 100.into()); @@ -275,7 +277,7 @@ async fn test_pay_invoice_twice() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_internal_payment() -> Result<()> { +async fn test_regtest_internal_payment() -> Result<()> { let lnd_client = init_lnd_client().await?; let seed = Mnemonic::generate(12)?.to_seed_normalized(""); @@ -287,12 +289,12 @@ async fn test_internal_payment() -> Result<()> { None, )?; - let mint_quote = wallet.mint_quote(100.into(), None).await?; + let mint_quote = wallet.mint_quote(100.into(), None, None).await?; lnd_client.pay_invoice(mint_quote.request).await?; let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; assert!(wallet.total_balance().await? == 100.into()); @@ -307,7 +309,7 @@ async fn test_internal_payment() -> Result<()> { None, )?; - let mint_quote = wallet_2.mint_quote(10.into(), None).await?; + let mint_quote = wallet_2.mint_quote(10.into(), None, None).await?; let melt = wallet.melt_quote(mint_quote.request.clone(), None).await?; @@ -316,14 +318,16 @@ async fn test_internal_payment() -> Result<()> { let _melted = wallet.melt(&melt.id).await.unwrap(); let _wallet_2_mint = wallet_2 - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await .unwrap(); - let cln_client = init_cln_client().await?; + let cln_one_dir = get_cln_dir("one"); + let cln_client = ClnClient::new(cln_one_dir.clone(), None).await?; + let payment_hash = Bolt11Invoice::from_str(&mint_quote.request)?; let check_paid = cln_client - .check_incoming_invoice(payment_hash.payment_hash().to_string()) + .check_incoming_payment_status(&payment_hash.payment_hash().to_string()) .await?; match check_paid { @@ -358,7 +362,7 @@ async fn test_cached_mint() -> Result<()> { let mint_amount = Amount::from(100); - let quote = wallet.mint_quote(mint_amount, None).await?; + let quote = wallet.mint_quote(mint_amount, None, None).await?; lnd_client.pay_invoice(quote.request).await?; loop { @@ -381,6 +385,7 @@ async fn test_cached_mint() -> Result<()> { let request = MintBolt11Request { quote: quote.id, outputs: premint_secrets.blinded_messages(), + witness: None, }; let response = http_client @@ -393,3 +398,29 @@ async fn test_cached_mint() -> Result<()> { assert!(response == response1); Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_bolt12_mint() -> Result<()> { + let cln_one_dir = get_cln_dir("two"); + let cln_client = ClnClient::new(cln_one_dir.clone(), None).await?; + + let seed = Mnemonic::generate(12)?.to_seed_normalized(""); + + let wallet = Wallet::new( + &get_mint_url(), + CurrencyUnit::Sat, + Arc::new(WalletMemoryDatabase::default()), + &seed, + None, + )?; + + let secret_key = SecretKey::generate(); + + let q = wallet + .mint_bolt12_quote(None, None, false, None, secret_key.public_key()) + .await?; + + // TODO: Need to update ln-regtest-rs to pay offer and complete this test + + Ok(()) +} 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 f983847b5..e1ea7d79f 100644 --- a/crates/cdk-lnbits/src/lib.rs +++ b/crates/cdk-lnbits/src/lib.rs @@ -13,7 +13,9 @@ use axum::Router; use cdk::amount::{to_unit, Amount, MSAT_IN_SAT}; use cdk::cdk_lightning::{ self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, + WaitInvoiceResponse, }; +use cdk::mint::types::PaymentRequest; use cdk::mint::FeeReserve; use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use cdk::util::unix_time; @@ -86,7 +88,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() @@ -123,7 +125,14 @@ impl MintLightning for LNbits { match check { Ok(state) => { if state { - Some((msg, (receiver, lnbits_api, cancel_token, is_active))) + let response = WaitInvoiceResponse { + request_lookup_id: msg.clone(), + payment_amount: Amount::ZERO, + unit: CurrencyUnit::Sat, + payment_id: msg + }; + + Some((response , (receiver, lnbits_api, cancel_token, is_active))) } else { None } @@ -187,9 +196,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"); diff --git a/crates/cdk-lnd/src/error.rs b/crates/cdk-lnd/src/error.rs index 3b6f427b2..3c62d6093 100644 --- a/crates/cdk-lnd/src/error.rs +++ b/crates/cdk-lnd/src/error.rs @@ -20,6 +20,12 @@ pub enum Error { /// Payment failed #[error("LND payment failed")] PaymentFailed, + /// Unsupported method + #[error("Unsupported method")] + UnsupportedMethod, + /// 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 96ce2f4e7..e76df7ec0 100644 --- a/crates/cdk-lnd/src/lib.rs +++ b/crates/cdk-lnd/src/lib.rs @@ -16,7 +16,9 @@ use async_trait::async_trait; use cdk::amount::{to_unit, Amount, MSAT_IN_SAT}; use cdk::cdk_lightning::{ self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, + WaitInvoiceResponse, }; +use cdk::mint::types::PaymentRequest; use cdk::mint::FeeReserve; use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use cdk::util::{hex, unix_time}; @@ -93,7 +95,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 @@ -135,7 +137,15 @@ impl MintLightning for Lnd { match msg { Ok(Some(msg)) => { if msg.state == 1 { - Some((hex::encode(msg.r_hash), (stream, cancel_token, is_active))) + let payment_hash = hex::encode(msg.r_hash); + let wait_response = WaitInvoiceResponse { + request_lookup_id: payment_hash.clone(), + payment_amount: Amount::ZERO, + unit: CurrencyUnit::Sat, + payment_id: payment_hash + }; + + Some((wait_response , (stream, cancel_token, is_active))) } else { None } @@ -199,10 +209,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); diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index b12372ef0..ed28a05c3 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/main.rs b/crates/cdk-mintd/src/main.rs index 325ddd933..5253685bb 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -144,12 +144,19 @@ async fn main() -> anyhow::Result<()> { }; ln_backends.insert(ln_key, cln.clone()); - mint_builder = mint_builder.add_ln_backend( - CurrencyUnit::Sat, - PaymentMethod::Bolt11, - mint_melt_limits, - cln.clone(), - ); + mint_builder = + mint_builder.add_ln_backend(CurrencyUnit::Sat, mint_melt_limits, cln.clone()); + + if cln_settings.bolt12 { + let ln_key = LnKey { + unit: CurrencyUnit::Sat, + method: PaymentMethod::Bolt12, + }; + ln_backends.insert(ln_key, cln.clone()); + + mint_builder = + mint_builder.add_bolt12_ln_backend(CurrencyUnit::Sat, mint_melt_limits, cln) + } } LnBackend::Strike => { let strike_settings = settings.clone().strike.expect("Checked on config load"); @@ -163,12 +170,8 @@ async fn main() -> anyhow::Result<()> { .setup(&mut ln_routers, &settings, unit.clone()) .await?; - mint_builder = mint_builder.add_ln_backend( - unit, - PaymentMethod::Bolt11, - mint_melt_limits, - Arc::new(strike), - ); + mint_builder = + mint_builder.add_ln_backend(unit, mint_melt_limits, Arc::new(strike)); } } LnBackend::LNbits => { @@ -177,12 +180,8 @@ async fn main() -> anyhow::Result<()> { .setup(&mut ln_routers, &settings, CurrencyUnit::Sat) .await?; - mint_builder = mint_builder.add_ln_backend( - CurrencyUnit::Sat, - PaymentMethod::Bolt11, - mint_melt_limits, - Arc::new(lnbits), - ); + mint_builder = + mint_builder.add_ln_backend(CurrencyUnit::Sat, mint_melt_limits, Arc::new(lnbits)); } LnBackend::Phoenixd => { let phd_settings = settings.clone().phoenixd.expect("Checked at config load"); @@ -190,12 +189,8 @@ async fn main() -> anyhow::Result<()> { .setup(&mut ln_routers, &settings, CurrencyUnit::Sat) .await?; - mint_builder = mint_builder.add_ln_backend( - CurrencyUnit::Sat, - PaymentMethod::Bolt11, - mint_melt_limits, - Arc::new(phd), - ); + mint_builder = + mint_builder.add_ln_backend(CurrencyUnit::Sat, mint_melt_limits, Arc::new(phd)); } LnBackend::Lnd => { let lnd_settings = settings.clone().lnd.expect("Checked at config load"); @@ -203,12 +198,8 @@ async fn main() -> anyhow::Result<()> { .setup(&mut ln_routers, &settings, CurrencyUnit::Msat) .await?; - mint_builder = mint_builder.add_ln_backend( - CurrencyUnit::Sat, - PaymentMethod::Bolt11, - mint_melt_limits, - Arc::new(lnd), - ); + mint_builder = + mint_builder.add_ln_backend(CurrencyUnit::Sat, mint_melt_limits, Arc::new(lnd)); } LnBackend::FakeWallet => { let fake_wallet = settings.clone().fake_wallet.expect("Fake wallet defined"); @@ -220,12 +211,11 @@ async fn main() -> anyhow::Result<()> { let fake = Arc::new(fake); - mint_builder = mint_builder.add_ln_backend( - unit.clone(), - PaymentMethod::Bolt11, - mint_melt_limits, - fake.clone(), - ); + mint_builder = + mint_builder.add_ln_backend(unit.clone(), mint_melt_limits, fake.clone()); + + mint_builder = + mint_builder.add_bolt12_ln_backend(unit, mint_melt_limits, fake.clone()); } } }; @@ -292,7 +282,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?; + // If there are any backend that support bolt12 we need to add the bolt12 router + let include_bolt12 = !mint.bolt12_backends.is_empty(); + 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) 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/bolt12.rs b/crates/cdk-phoenixd/src/bolt12.rs new file mode 100644 index 000000000..1777f3971 --- /dev/null +++ b/crates/cdk-phoenixd/src/bolt12.rs @@ -0,0 +1,143 @@ +use std::pin::Pin; +use std::str::FromStr; + +use anyhow::anyhow; +use async_trait::async_trait; +use cdk::amount::{amount_for_offer, Amount}; +use cdk::cdk_lightning::bolt12::{Bolt12Settings, MintBolt12Lightning}; +use cdk::cdk_lightning::{ + self, Bolt12PaymentQuoteResponse, CreateOfferResponse, MintLightning, PayInvoiceResponse, + WaitInvoiceResponse, +}; +use cdk::mint; +use cdk::mint::types::PaymentRequest; +use cdk::nuts::{CurrencyUnit, MeltQuoteBolt12Request, MeltQuoteState}; +use cdk::util::hex; +use futures::Stream; +use lightning::offers::offer::Offer; + +use super::Error; +use crate::Phoenixd; + +#[async_trait] +impl MintBolt12Lightning for Phoenixd { + type Err = cdk_lightning::Error; + + fn get_settings(&self) -> Bolt12Settings { + Bolt12Settings { + mint: true, + melt: false, + unit: CurrencyUnit::Sat, + offer_description: false, + } + } + + fn is_wait_invoice_active(&self) -> bool { + // Paying to PHD bolt12 offer is not supported so this can never be active + false + } + + fn cancel_wait_invoice(&self) { + // Paying to PHD bolt12 offer is not supported so there is nothing to cancel + } + + 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()) + } + + /// Listen for bolt12 offers to be paid + async fn wait_any_offer( + &self, + ) -> Result + Send>>, Self::Err> { + Err(Error::UnsupportedMethod.into()) + } +} 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 48d0912e6..879f48c0e 100644 --- a/crates/cdk-phoenixd/src/lib.rs +++ b/crates/cdk-phoenixd/src/lib.rs @@ -4,16 +4,19 @@ #![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::{to_unit, Amount}; use cdk::cdk_lightning::{ self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, + WaitInvoiceResponse, }; +use cdk::mint::types::PaymentRequest; use cdk::mint::FeeReserve; use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use cdk::{mint, Bolt11Invoice}; @@ -24,6 +27,7 @@ use phoenixd_rs::{InvoiceRequest, Phoenixd as PhoenixdApi}; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; +mod bolt12; pub mod error; /// Phoenixd @@ -92,7 +96,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() @@ -126,8 +130,14 @@ impl MintLightning for Phoenixd { match check { Ok(state) => { if state.is_paid { + let wait_invoice = WaitInvoiceResponse { + request_lookup_id: msg.payment_hash.clone(), + payment_amount: Amount::ZERO, + unit: CurrencyUnit::Sat, + payment_id: msg.payment_hash + }; // Yield the payment hash and continue the stream - Some((msg.payment_hash, (receiver, phoenixd_api, cancel_token, is_active))) + Some((wait_invoice, (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 @@ -199,9 +209,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. @@ -209,12 +224,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, }) @@ -268,6 +281,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 { @@ -277,13 +303,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, } } 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..03ba0a34c 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, PaymentMethod, 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,19 @@ 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: true, + payment_method: PaymentMethod::Bolt11, + payment_ids: vec![], + pubkey: 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/migrations/20241108093102_mint_mint_quote_pubkey.sql b/crates/cdk-sqlite/src/mint/migrations/20241108093102_mint_mint_quote_pubkey.sql new file mode 100644 index 000000000..06501e14f --- /dev/null +++ b/crates/cdk-sqlite/src/mint/migrations/20241108093102_mint_mint_quote_pubkey.sql @@ -0,0 +1 @@ +ALTER TABLE mint_quote ADD pubkey TEXT; diff --git a/crates/cdk-sqlite/src/mint/migrations/20241117101725_bolt12_migration.sql b/crates/cdk-sqlite/src/mint/migrations/20241117101725_bolt12_migration.sql new file mode 100644 index 000000000..94e48f218 --- /dev/null +++ b/crates/cdk-sqlite/src/mint/migrations/20241117101725_bolt12_migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE mint_quote ADD single_use INTEGER; +ALTER TABLE mint_quote ADD payment_method TEXT; +ALTER TABLE mint_quote ADD payment_ids TEXT; +ALTER TABLE mint_quote ADD amount_paid INTEGER; +ALTER TABLE mint_quote ADD amount_issued INTEGER; + diff --git a/crates/cdk-sqlite/src/mint/mod.rs b/crates/cdk-sqlite/src/mint/mod.rs index c72e5220a..dea777c1c 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; @@ -205,18 +206,25 @@ WHERE active = 1 let res = sqlx::query( r#" INSERT OR REPLACE INTO mint_quote -(id, mint_url, amount, unit, request, state, expiry, request_lookup_id) -VALUES (?, ?, ?, ?, ?, ?, ?, ?); +(id, mint_url, amount, unit, request, state, expiry, request_lookup_id, single_use, payment_method, payment_ids, amount_paid, amount_issued, pubkey) +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()) .bind(quote.expiry as i64) .bind(quote.request_lookup_id) + .bind(quote.single_use) + .bind(quote.payment_method.to_string()) + .bind(serde_json::to_string("e.payment_ids)?) + .bind(u64::from(quote.amount_paid) as i64) + .bind(u64::from(quote.amount_issued) as i64 ) + .bind(quote.pubkey.map(|p| p.to_string())) .execute(&mut transaction) .await; @@ -472,7 +480,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) @@ -1277,6 +1285,12 @@ fn sqlite_row_to_mint_quote(row: SqliteRow) -> Result { let row_expiry: i64 = row.try_get("expiry").map_err(Error::from)?; let row_request_lookup_id: Option = row.try_get("request_lookup_id").map_err(Error::from)?; + let row_single_use: Option = row.try_get("single_use").map_err(Error::from)?; + let row_amount_paid: Option = row.try_get("amount_paid").map_err(Error::from)?; + let row_amount_issued: Option = row.try_get("amount_issued").map_err(Error::from)?; + let row_payment_method: Option = row.try_get("payment_method").map_err(Error::from)?; + let row_payment_ids: Option = row.try_get("payment_ids").map_err(Error::from)?; + let row_pubkey: Option = row.try_get("pubkey").map_err(Error::from)?; let request_lookup_id = match row_request_lookup_id { Some(id) => id, @@ -1286,15 +1300,34 @@ fn sqlite_row_to_mint_quote(row: SqliteRow) -> Result { }, }; + let payment_method = match row_payment_method { + Some(method) => PaymentMethod::from_str(&method)?, + None => PaymentMethod::Bolt11, + }; + + let payment_ids: Vec = match row_payment_ids { + Some(ids) => serde_json::from_str(&ids)?, + None => vec![], + }; + let pubkey = row_pubkey + .map(|key| PublicKey::from_str(&key)) + .transpose()?; + 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, + amount_paid: (row_amount_paid.unwrap_or_default() as u64).into(), + amount_issued: (row_amount_issued.unwrap_or_default() as u64).into(), + single_use: row_single_use.unwrap_or(true), + payment_method, + payment_ids, + pubkey, }) } @@ -1312,11 +1345,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/migrations/20241025175105_wallet_mint_quote_payment_method.sql b/crates/cdk-sqlite/src/wallet/migrations/20241025175105_wallet_mint_quote_payment_method.sql new file mode 100644 index 000000000..0cd6fe5ac --- /dev/null +++ b/crates/cdk-sqlite/src/wallet/migrations/20241025175105_wallet_mint_quote_payment_method.sql @@ -0,0 +1,3 @@ +ALTER TABLE mint_quote ADD payment_method TEXT; +ALTER TABLE mint_quote ADD amount_paid INTEGER; +ALTER TABLE mint_quote ADD amount_minted INTEGER; diff --git a/crates/cdk-sqlite/src/wallet/migrations/20241108092756_wallet_mint_quote_pubkey.sql b/crates/cdk-sqlite/src/wallet/migrations/20241108092756_wallet_mint_quote_pubkey.sql new file mode 100644 index 000000000..06501e14f --- /dev/null +++ b/crates/cdk-sqlite/src/wallet/migrations/20241108092756_wallet_mint_quote_pubkey.sql @@ -0,0 +1 @@ +ALTER TABLE mint_quote ADD pubkey TEXT; diff --git a/crates/cdk-sqlite/src/wallet/mod.rs b/crates/cdk-sqlite/src/wallet/mod.rs index 0decfac3b..766730b91 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; @@ -342,8 +342,8 @@ WHERE id=? sqlx::query( r#" INSERT OR REPLACE INTO mint_quote -(id, mint_url, amount, unit, request, state, expiry) -VALUES (?, ?, ?, ?, ?, ?, ?); +(id, mint_url, amount, unit, request, state, expiry, payment_method, amount_paid, amount_minted, pubkey) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); "#, ) .bind(quote.id.to_string()) @@ -353,6 +353,10 @@ VALUES (?, ?, ?, ?, ?, ?, ?); .bind(quote.request) .bind(quote.state.to_string()) .bind(quote.expiry as i64) + .bind(quote.payment_method.to_string()) + .bind(u64::from(quote.amount_paid) as i64) + .bind(u64::from(quote.amount_minted) as i64) + .bind(quote.pubkey.map(|p| p.to_string())) .execute(&self.pool) .await .map_err(Error::from)?; @@ -425,8 +429,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()) @@ -436,6 +440,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)?; @@ -823,9 +828,22 @@ fn sqlite_row_to_mint_quote(row: &SqliteRow) -> Result { let row_request: String = row.try_get("request").map_err(Error::from)?; 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_method: Option = row.try_get("payment_method").map_err(Error::from)?; + let row_amount_paid: Option = row.try_get("amount_paid").map_err(Error::from)?; + let row_amount_minted: Option = row.try_get("amount_minted").map_err(Error::from)?; let state = MintQuoteState::from_str(&row_state)?; + let payment_method = row_method.and_then(|m| PaymentMethod::from_str(&m).ok()); + + let amount_paid = row_amount_paid.unwrap_or(0) as u64; + let amount_minted = row_amount_minted.unwrap_or(0) as u64; + let row_pubkey: Option = row.try_get("pubkey").map_err(Error::from)?; + + let pubkey = row_pubkey + .map(|key| PublicKey::from_str(&key)) + .transpose()?; + Ok(MintQuote { id: row_id, mint_url: MintUrl::from_str(&row_mint_url)?, @@ -834,6 +852,10 @@ fn sqlite_row_to_mint_quote(row: &SqliteRow) -> Result { request: row_request, state, expiry: row_expiry as u64, + payment_method: payment_method.unwrap_or_default(), + amount_minted: amount_minted.into(), + amount_paid: amount_paid.into(), + pubkey, }) } @@ -846,6 +868,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 { @@ -853,6 +878,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 ddb103cbb..a5ba28c91 100644 --- a/crates/cdk-strike/src/lib.rs +++ b/crates/cdk-strike/src/lib.rs @@ -13,6 +13,7 @@ use axum::Router; use cdk::amount::Amount; use cdk::cdk_lightning::{ self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, + WaitInvoiceResponse, }; use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use cdk::util::unix_time; @@ -84,7 +85,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?; @@ -98,6 +99,7 @@ impl MintLightning for Strike { let strike_api = self.strike_api.clone(); let cancel_token = self.wait_invoice_cancel_token.clone(); + let unit = self.unit.clone(); Ok(futures::stream::unfold( ( @@ -105,8 +107,9 @@ impl MintLightning for Strike { strike_api, cancel_token, Arc::clone(&self.wait_invoice_is_active), + unit ), - |(mut receiver, strike_api, cancel_token, is_active)| async move { + |(mut receiver, strike_api, cancel_token, is_active, unit)| async move { tokio::select! { _ = cancel_token.cancelled() => { @@ -124,7 +127,13 @@ impl MintLightning for Strike { match check { Ok(state) => { if state.state == InvoiceState::Paid { - Some((msg, (receiver, strike_api, cancel_token, is_active))) + let wait_response = WaitInvoiceResponse { + request_lookup_id: msg.clone(), + payment_amount: Amount::ZERO, + unit: unit.clone(), + payment_id: msg + }; + Some((wait_response , (receiver, strike_api, cancel_token, is_active, unit))) } else { None } diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index 4d72da318..a990c4551 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -26,6 +26,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/examples/mint-token.rs b/crates/cdk/examples/mint-token.rs index 195fb0ff7..ef2713364 100644 --- a/crates/cdk/examples/mint-token.rs +++ b/crates/cdk/examples/mint-token.rs @@ -22,7 +22,7 @@ async fn main() -> Result<(), Error> { let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); - let quote = wallet.mint_quote(amount, None).await.unwrap(); + let quote = wallet.mint_quote(amount, None, None).await.unwrap(); println!("Quote: {:#?}", quote); @@ -39,7 +39,7 @@ async fn main() -> Result<(), Error> { } let receive_amount = wallet - .mint("e.id, SplitTarget::default(), None) + .mint("e.id, SplitTarget::default(), None, None) .await .unwrap(); diff --git a/crates/cdk/examples/p2pk.rs b/crates/cdk/examples/p2pk.rs index 6e51f781e..8868fc138 100644 --- a/crates/cdk/examples/p2pk.rs +++ b/crates/cdk/examples/p2pk.rs @@ -22,7 +22,7 @@ async fn main() -> Result<(), Error> { let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); - let quote = wallet.mint_quote(amount, None).await.unwrap(); + let quote = wallet.mint_quote(amount, None, None).await.unwrap(); println!("Minting nuts ..."); @@ -39,7 +39,7 @@ async fn main() -> Result<(), Error> { } let _receive_amount = wallet - .mint("e.id, SplitTarget::default(), None) + .mint("e.id, SplitTarget::default(), None, None) .await .unwrap(); diff --git a/crates/cdk/examples/proof-selection.rs b/crates/cdk/examples/proof-selection.rs index 210b77319..95f67924d 100644 --- a/crates/cdk/examples/proof-selection.rs +++ b/crates/cdk/examples/proof-selection.rs @@ -24,7 +24,7 @@ async fn main() { for amount in [64] { let amount = Amount::from(amount); - let quote = wallet.mint_quote(amount, None).await.unwrap(); + let quote = wallet.mint_quote(amount, None, None).await.unwrap(); println!("Pay request: {}", quote.request); @@ -41,7 +41,7 @@ async fn main() { } let receive_amount = wallet - .mint("e.id, SplitTarget::default(), None) + .mint("e.id, SplitTarget::default(), None, None) .await .unwrap(); diff --git a/crates/cdk/examples/wallet.rs b/crates/cdk/examples/wallet.rs index 93b6fa23e..dc35014c9 100644 --- a/crates/cdk/examples/wallet.rs +++ b/crates/cdk/examples/wallet.rs @@ -24,7 +24,7 @@ async fn main() { let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); - let quote = wallet.mint_quote(amount, None).await.unwrap(); + let quote = wallet.mint_quote(amount, None, None).await.unwrap(); println!("Pay request: {}", quote.request); @@ -41,7 +41,7 @@ async fn main() { } let receive_amount = wallet - .mint("e.id, SplitTarget::default(), None) + .mint("e.id, SplitTarget::default(), None, None) .await .unwrap(); diff --git a/crates/cdk/src/amount.rs b/crates/cdk/src/amount.rs index d2c599d04..b5aa8d902 100644 --- a/crates/cdk/src/amount.rs +++ b/crates/cdk/src/amount.rs @@ -6,6 +6,7 @@ use std::cmp::Ordering; use std::fmt; use std::str::FromStr; +use lightning::offers::offer::Offer; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use thiserror::Error; @@ -23,6 +24,12 @@ pub enum Error { /// Cannot convert units #[error("Cannot convert units")] CannotConvertUnits, + /// Amount undefined + #[error("Amount undefined")] + AmountUndefined, + /// Utf8 parse error + #[error(transparent)] + Utf8ParseError(#[from] std::string::FromUtf8Error), } /// Amount can be any unit @@ -300,6 +307,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::AmountUndefined)?; + + 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_database/mint_memory.rs b/crates/cdk/src/cdk_database/mint_memory.rs index 3b4e1e79e..1cc2d1603 100644 --- a/crates/cdk/src/cdk_database/mint_memory.rs +++ b/crates/cdk/src/cdk_database/mint_memory.rs @@ -371,8 +371,6 @@ impl MintDatabase for MintMemoryDatabase { if let Some(quote_id) = quote_id { let mut current_quote_signatures = self.quote_signatures.write().await; current_quote_signatures.insert(quote_id.clone(), blind_signatures.to_vec()); - let t = current_quote_signatures.get("e_id); - println!("after insert: {:?}", t); } Ok(()) diff --git a/crates/cdk/src/cdk_lightning/bolt12.rs b/crates/cdk/src/cdk_lightning/bolt12.rs new file mode 100644 index 000000000..4eb7cdaa9 --- /dev/null +++ b/crates/cdk/src/cdk_lightning/bolt12.rs @@ -0,0 +1,71 @@ +//! CDK Mint Bolt12 + +use std::pin::Pin; + +use async_trait::async_trait; +use futures::Stream; +use serde::{Deserialize, Serialize}; + +use super::{ + Bolt12PaymentQuoteResponse, CreateOfferResponse, Error, PayInvoiceResponse, WaitInvoiceResponse, +}; +use crate::nuts::{CurrencyUnit, MeltQuoteBolt12Request}; +use crate::{mint, Amount}; + +/// MintLighting Bolt12 Trait +#[async_trait] +pub trait MintBolt12Lightning { + /// Mint Lightning Error + type Err: Into + From; + + /// Backend Settings + fn get_settings(&self) -> Bolt12Settings; + + /// Is wait invoice active + fn is_wait_invoice_active(&self) -> bool; + + /// Cancel wait invoice + fn cancel_wait_invoice(&self); + + /// Listen for bolt12 offers to be paid + async fn wait_any_offer( + &self, + ) -> Result + Send>>, Self::Err>; + + /// 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; +} + +/// Ln backend settings +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Bolt12Settings { + /// Mint supported + pub mint: bool, + /// Melt supported + pub melt: bool, + /// Base unit of backend + pub unit: CurrencyUnit, + /// Invoice Description supported + pub offer_description: bool, +} diff --git a/crates/cdk/src/cdk_lightning/mod.rs b/crates/cdk/src/cdk_lightning/mod.rs index 1b9c31a6c..0b64e8365 100644 --- a/crates/cdk/src/cdk_lightning/mod.rs +++ b/crates/cdk/src/cdk_lightning/mod.rs @@ -4,6 +4,7 @@ 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; @@ -11,6 +12,8 @@ use thiserror::Error; use crate::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use crate::{mint, Amount}; +pub mod bolt12; + /// CDK Lightning Error #[derive(Debug, Error)] pub enum Error { @@ -20,12 +23,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), @@ -43,6 +52,21 @@ pub enum Error { Amount(#[from] crate::amount::Error), } +/// Wait any invoice response +#[derive(Debug, Clone, Hash, Serialize, Deserialize, Default)] +pub struct WaitInvoiceResponse { + /// Request look up id + /// Id that relates the quote and payment request + pub request_lookup_id: String, + /// Payment amount + pub payment_amount: Amount, + /// Unit + pub unit: CurrencyUnit, + /// Unique id of payment + // Payment hash + pub payment_id: String, +} + /// MintLighting Trait #[async_trait] pub trait MintLightning { @@ -80,7 +104,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,7 +126,7 @@ pub trait MintLightning { } /// 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, @@ -112,6 +136,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 { @@ -140,8 +175,23 @@ 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, diff --git a/crates/cdk/src/error.rs b/crates/cdk/src/error.rs index dae63c646..a0f94c352 100644 --- a/crates/cdk/src/error.rs +++ b/crates/cdk/src/error.rs @@ -107,6 +107,9 @@ pub enum Error { /// Internal Error #[error("Internal Error")] Internal, + /// Payment already added to quote + #[error("Payment is already accounted for in quote")] + PaymentAlreadySeen, // Wallet Errors /// P2PK spending conditions not met @@ -162,6 +165,12 @@ pub enum Error { /// Invoice Description not supported #[error("Invoice Description not supported")] InvoiceDescriptionUnsupported, + /// Secretkey to sign mint quote not provided + #[error("Secretkey to sign mint quote not provided")] + SecretKeyNotProvided, + /// Incorrect secret key provided + #[error("Incorrect secretkey provided")] + IncorrectSecretKey, /// Custom Error #[error("`{0}`")] Custom(String), @@ -176,7 +185,7 @@ pub enum Error { /// Parse int error #[error(transparent)] ParseInt(#[from] std::num::ParseIntError), - /// Parse Url Error + /// Parse 9rl Error #[error(transparent)] UrlParseError(#[from] url::ParseError), /// Utf8 parse error @@ -195,6 +204,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 @@ -239,6 +251,15 @@ pub enum Error { /// NUT18 Error #[error(transparent)] NUT18(#[from] crate::nuts::nut18::Error), + /// NUT19 Error + #[error(transparent)] + NUT19(#[from] crate::nuts::nut19::Error), + /// NUT20 Error + #[error(transparent)] + NUT20(#[from] crate::nuts::nut20::Error), + /// NUT18 Error + #[error(transparent)] + NUT21(#[from] crate::nuts::nut21::Error), /// Database Error #[cfg(any(feature = "wallet", feature = "mint"))] #[error(transparent)] @@ -249,6 +270,12 @@ pub enum Error { Lightning(#[from] crate::cdk_lightning::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) @@ -373,6 +400,11 @@ impl From for ErrorResponse { error: Some(err.to_string()), detail: None, }, + Error::NUT19(err) => ErrorResponse { + code: ErrorCode::WitnessMissingOrInvalid, + error: Some(err.to_string()), + detail: None, + }, _ => ErrorResponse { code: ErrorCode::Unknown(9999), error: Some(err.to_string()), @@ -443,6 +475,8 @@ pub enum ErrorCode { TransactionUnbalanced, /// Amount outside of allowed range AmountOutofLimitRange, + /// Witness missing or invalid + WitnessMissingOrInvalid, /// Unknown error code Unknown(u16), } @@ -467,6 +501,7 @@ impl ErrorCode { 20005 => Self::QuotePending, 20006 => Self::InvoiceAlreadyPaid, 20007 => Self::QuoteExpired, + 20008 => Self::WitnessMissingOrInvalid, _ => Self::Unknown(code), } } @@ -490,6 +525,7 @@ impl ErrorCode { Self::QuotePending => 20005, Self::InvoiceAlreadyPaid => 20006, Self::QuoteExpired => 20007, + Self::WitnessMissingOrInvalid => 20008, Self::Unknown(code) => *code, } } diff --git a/crates/cdk/src/mint/builder.rs b/crates/cdk/src/mint/builder.rs index a71c259cf..753ff858f 100644 --- a/crates/cdk/src/mint/builder.rs +++ b/crates/cdk/src/mint/builder.rs @@ -7,13 +7,14 @@ use anyhow::anyhow; use crate::amount::Amount; use crate::cdk_database::{self, MintDatabase}; +use crate::cdk_lightning::bolt12::MintBolt12Lightning; use crate::cdk_lightning::{self, MintLightning}; use crate::mint::Mint; use crate::nuts::{ ContactInfo, CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings, MintVersion, MppMethodSettings, PaymentMethod, }; -use crate::types::{LnKey, QuoteTTL}; +use crate::types::QuoteTTL; /// Cashu Mint #[derive(Default)] @@ -24,8 +25,17 @@ pub struct MintBuilder { mint_info: MintInfo, /// Mint Storage backend localstore: Option + Send + Sync>>, - /// Ln backends for mint - ln: Option + Send + Sync>>>, + /// Bolt11 ln backends for mint + ln: Option< + HashMap + Send + Sync>>, + >, + /// Bolt12 backends for mint + bolt12_backends: Option< + HashMap< + CurrencyUnit, + Arc + Send + Sync>, + >, + >, seed: Option>, quote_ttl: Option, supported_units: HashMap, @@ -106,61 +116,54 @@ impl MintBuilder { pub fn add_ln_backend( mut self, unit: CurrencyUnit, - method: PaymentMethod, limits: MintMeltLimits, ln_backend: Arc + Send + Sync>, ) -> Self { - let ln_key = LnKey { - unit: unit.clone(), - method, - }; - let mut ln = self.ln.unwrap_or_default(); let settings = ln_backend.get_settings(); + let method = PaymentMethod::Bolt11; + if settings.mpp { let mpp_settings = MppMethodSettings { method, unit: unit.clone(), mpp: true, }; - let mut mpp = self.mint_info.nuts.nut15.clone(); + let mut mpp = self.mint_info.nuts.nut15.clone().unwrap_or_default(); mpp.methods.push(mpp_settings); - self.mint_info.nuts.nut15 = mpp; + self.mint_info.nuts.nut15 = Some(mpp); } - match method { - PaymentMethod::Bolt11 => { - let mint_method_settings = MintMethodSettings { - method, - unit: unit.clone(), - 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; - } - } + let mint_method_settings = MintMethodSettings { + method, + unit: unit.clone(), + 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: unit.clone(), + 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; - ln.insert(ln_key.clone(), ln_backend); + ln.insert(unit.clone(), ln_backend); let mut supported_units = self.supported_units.clone(); - supported_units.insert(ln_key.unit, (0, 32)); + // TODO: The max order and fee should be configutable + supported_units.insert(unit, (0, 32)); self.supported_units = supported_units; self.ln = Some(ln); @@ -168,6 +171,68 @@ impl MintBuilder { self } + /// Add ln backend + pub fn add_bolt12_ln_backend( + mut self, + unit: CurrencyUnit, + limits: MintMeltLimits, + ln_backend: Arc + Send + Sync>, + ) -> Self { + let mut ln = self.bolt12_backends.unwrap_or_default(); + + let method = PaymentMethod::Bolt12; + + let settings = ln_backend.get_settings(); + + // If the backend supports minting we add it to info signalling + if settings.mint { + let mint_method_settings = MintMethodSettings { + method, + unit: unit.clone(), + min_amount: Some(limits.mint_min), + max_amount: Some(limits.mint_max), + description: true, + }; + + let mut nut20_settings = self.mint_info.nuts.nut20.unwrap_or_default(); + + tracing::warn!("{:?}", nut20_settings); + + nut20_settings.methods.push(mint_method_settings); + nut20_settings.disabled = false; + + self.mint_info.nuts.nut20 = Some(nut20_settings); + } + + // If the backend supports melting we add it to info signalling + if settings.melt { + let melt_method_settings = MeltMethodSettings { + method, + unit: unit.clone(), + min_amount: Some(limits.melt_min), + max_amount: Some(limits.melt_max), + }; + + let mut nut21_settings = self.mint_info.nuts.nut21.unwrap_or_default(); + nut21_settings.methods.push(melt_method_settings); + nut21_settings.disabled = false; + + self.mint_info.nuts.nut21 = Some(nut21_settings); + } + + ln.insert(unit.clone(), ln_backend); + + let mut supported_units = self.supported_units.clone(); + + // TODO: The max order and fee should be configutable + supported_units.insert(unit, (0, 32)); + self.supported_units = supported_units; + + self.bolt12_backends = 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 }; @@ -195,6 +260,7 @@ impl MintBuilder { .clone() .ok_or(anyhow!("Localstore not set"))?, self.ln.clone().ok_or(anyhow!("Ln backends not set"))?, + self.bolt12_backends.clone().unwrap_or(HashMap::new()), self.supported_units.clone(), HashMap::new(), ) diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs index 8ac01d69c..54b213bf7 100644 --- a/crates/cdk/src/mint/melt.rs +++ b/crates/cdk/src/mint/melt.rs @@ -1,21 +1,23 @@ 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 super::nut05::MeltRequestTrait; +use super::types::PaymentRequest; use super::{ - CurrencyUnit, MeltBolt11Request, MeltQuote, MeltQuoteBolt11Request, MeltQuoteBolt11Response, - Mint, PaymentMethod, PublicKey, State, + BlindSignature, CurrencyUnit, MeltQuote, MeltQuoteBolt11Request, MeltQuoteBolt11Response, + MeltQuoteBolt12Request, Mint, PaymentMethod, State, }; -use crate::amount::to_unit; +use crate::amount::{amount_for_offer, to_unit}; use crate::cdk_lightning::{MintLightning, PayInvoiceResponse}; use crate::mint::SigFlag; use crate::nuts::nut00::ProofsMethods; use crate::nuts::nut11::{enforce_sig_flag, EnforceSigFlag}; -use crate::nuts::{Id, MeltQuoteState}; -use crate::types::LnKey; +use crate::nuts::{Id, MeltQuoteState, PublicKey}; use crate::util::unix_time; use crate::{cdk_lightning, Amount, Error}; @@ -74,14 +76,11 @@ impl Mint { self.check_melt_request_acceptable(amount, unit.clone(), PaymentMethod::Bolt11)?; - let ln = self - .ln - .get(&LnKey::new(unit.clone(), PaymentMethod::Bolt11)) - .ok_or_else(|| { - tracing::info!("Could not get ln backend for {}, bolt11 ", unit); + let ln = self.ln.get(unit).ok_or_else(|| { + tracing::info!("Could not get ln backend for {}, bolt11 ", unit); - Error::UnitUnsupported - })?; + Error::UnitUnsupported + })?; let payment_quote = ln.get_payment_quote(melt_request).await.map_err(|err| { tracing::error!( @@ -93,8 +92,81 @@ impl Mint { Error::UnitUnsupported })?; + let request = crate::mint::types::PaymentRequest::Bolt11 { + bolt11: request.clone(), + }; + let quote = MeltQuote::new( - request.to_string(), + request, + unit.clone(), + 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.clone(), PaymentMethod::Bolt12)?; + + let ln = self.bolt12_backends.get(unit).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.clone(), payment_quote.amount, payment_quote.fee, @@ -167,70 +239,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 { @@ -240,34 +323,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; @@ -287,8 +369,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()); @@ -301,13 +382,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 @@ -336,7 +419,7 @@ impl Mint { return Err(Error::MultipleUnits); } - tracing::debug!("Verified melt quote: {}", melt_request.quote); + tracing::debug!("Verified melt quote: {}", quote_id); Ok(quote) } @@ -345,18 +428,25 @@ 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?; - if let Ok(Some(quote)) = self.localstore.get_melt_quote(&melt_request.quote).await { + if let Ok(Some(quote)) = self + .localstore + .get_melt_quote(melt_request.get_quote_id()) + .await + { self.pubsub_manager .melt_quote_status("e, None, None, MeltQuoteState::Unpaid); } @@ -369,34 +459,30 @@ impl Mint { 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) => { @@ -405,7 +491,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 ); } @@ -413,15 +499,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 ); } @@ -455,10 +545,7 @@ impl Mint { } _ => None, }; - let ln = match self - .ln - .get(&LnKey::new(quote.unit.clone(), PaymentMethod::Bolt11)) - { + let ln = match self.ln.get("e.unit) { Some(ln) => ln, None => { tracing::info!("Could not get ln backend for {}, bolt11 ", quote.unit); @@ -470,17 +557,55 @@ 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 => { + let ln = match self.ln.get("e.unit) { + Some(ln) => ln, + None => { + tracing::info!( + "Could not get ln backend for {}, bolt11 ", + quote.unit.clone() + ); + if let Err(err) = self.process_unpaid_melt(melt_request).await { + tracing::error!("Could not reset melt quote state: {}", err); + } + + return Err(Error::UnitUnsupported); + } + }; + ln.pay_invoice(quote.clone(), partial_amount, Some(quote.fee_reserve)) + .await + } + PaymentMethod::Bolt12 => { + let ln = match self.bolt12_backends.get("e.unit) { + Some(ln) => ln, + None => { + tracing::info!( + "Could not get ln backend for {}, bolt11 ", + quote.unit + ); + if let Err(err) = self.process_unpaid_melt(melt_request).await { + tracing::error!("Could not reset melt quote state: {}", err); + } + + return Err(Error::UnitUnsupported); + } + }; + + 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()); @@ -504,9 +629,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()); @@ -522,7 +648,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); @@ -532,7 +658,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); } @@ -553,7 +679,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 { @@ -567,48 +693,77 @@ 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?; self.pubsub_manager.melt_quote_status( "e, - payment_preimage.clone(), + quote.payment_preimage.clone(), None, MeltQuoteState::Paid, ); @@ -619,10 +774,14 @@ impl Mint { 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(); @@ -640,7 +799,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()); @@ -657,7 +816,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; @@ -681,15 +840,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_20.rs b/crates/cdk/src/mint/mint_20.rs new file mode 100644 index 000000000..c65f41c9e --- /dev/null +++ b/crates/cdk/src/mint/mint_20.rs @@ -0,0 +1,102 @@ +use tracing::instrument; + +use super::nut20::{MintQuoteBolt12Request, MintQuoteBolt12Response}; +use super::{Mint, MintQuote, PaymentMethod}; +use crate::util::unix_time; +use crate::{Amount, Error}; + +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, + pubkey, + } = mint_quote_request; + + let nut18 = &self + .mint_info + .nuts + .nut20 + .as_ref() + .ok_or(Error::UnsupportedUnit)?; + + if nut18.disabled { + return Err(Error::MintingDisabled); + } + + let ln = self.bolt12_backends.get(&unit).ok_or_else(|| { + tracing::info!("Bolt12 mint request for unsupported unit"); + + Error::UnitUnsupported + })?; + + let quote_expiry = match expiry { + Some(expiry) => expiry, + None => unix_time() + self.quote_ttl.mint_ttl, + }; + + 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(), + PaymentMethod::Bolt12, + unit.clone(), + amount, + create_invoice_response.expiry.unwrap_or(0), + create_invoice_response.request_lookup_id.clone(), + Amount::ZERO, + Amount::ZERO, + single_use, + vec![], + Some(pubkey), + ); + + 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.try_into()?) + } + + /// Check mint quote + #[instrument(skip(self))] + pub async fn check_mint_bolt12_quote( + &self, + quote_id: &str, + ) -> Result { + let quote = self + .localstore + .get_mint_quote(quote_id) + .await? + .ok_or(Error::UnknownQuote)?; + + Ok(quote.try_into()?) + } +} diff --git a/crates/cdk/src/mint/mint_nut04.rs b/crates/cdk/src/mint/mint_nut04.rs index 5631fd043..1a38c4487 100644 --- a/crates/cdk/src/mint/mint_nut04.rs +++ b/crates/cdk/src/mint/mint_nut04.rs @@ -4,8 +4,8 @@ use super::{ nut04, CurrencyUnit, Mint, MintQuote, MintQuoteBolt11Request, MintQuoteBolt11Response, NotificationPayload, PaymentMethod, PublicKey, }; +use crate::cdk_lightning::WaitInvoiceResponse; use crate::nuts::MintQuoteState; -use crate::types::LnKey; use crate::util::unix_time; use crate::{Amount, Error}; @@ -64,18 +64,16 @@ impl Mint { amount, unit, description, + pubkey, } = mint_quote_request; self.check_mint_request_acceptable(amount, &unit)?; - let ln = self - .ln - .get(&LnKey::new(unit.clone(), PaymentMethod::Bolt11)) - .ok_or_else(|| { - tracing::info!("Bolt11 mint request for unsupported unit"); + let ln = self.ln.get(&unit).ok_or_else(|| { + tracing::info!("Bolt11 mint request for unsupported unit"); - Error::UnitUnsupported - })?; + Error::UnitUnsupported + })?; let quote_expiry = unix_time() + self.quote_ttl.mint_ttl; @@ -100,14 +98,20 @@ impl Mint { let quote = MintQuote::new( self.mint_url.clone(), create_invoice_response.request.to_string(), + PaymentMethod::Bolt11, unit.clone(), - amount, + Some(amount), create_invoice_response.expiry.unwrap_or(0), create_invoice_response.request_lookup_id.clone(), + Amount::ZERO, + Amount::ZERO, + true, + vec![], + pubkey, ); tracing::debug!( - "New mint quote {} for {} {} with request id {}", + "New bolt11 mint quote {} for {} {} with request id {}", quote.id, amount, unit, @@ -146,6 +150,7 @@ impl Mint { request: quote.request, state, expiry: Some(quote.expiry), + pubkey: quote.pubkey, }) } @@ -197,49 +202,90 @@ impl Mint { #[instrument(skip_all)] pub async fn pay_mint_quote_for_request_id( &self, - request_lookup_id: &str, + wait_invoice_response: WaitInvoiceResponse, ) -> Result<(), Error> { + let WaitInvoiceResponse { + request_lookup_id, + payment_amount, + unit, + payment_id, + } = wait_invoice_response; if let Ok(Some(mint_quote)) = self .localstore - .get_mint_quote_by_request_lookup_id(request_lookup_id) + .get_mint_quote_by_request_lookup_id(&request_lookup_id) .await { tracing::debug!( - "Received payment notification for mint quote {}", - mint_quote.id + "Quote {} with lookup id {} paid by {}", + mint_quote.id, + request_lookup_id, + payment_id ); - if mint_quote.state != MintQuoteState::Issued - && mint_quote.state != MintQuoteState::Paid - { - let unix_time = unix_time(); - - if mint_quote.expiry < unix_time { - tracing::warn!( - "Mint quote {} paid at {} expired at {}, leaving current state", - mint_quote.id, - mint_quote.expiry, - unix_time, - ); - return Err(Error::ExpiredQuote(mint_quote.expiry, unix_time)); - } - tracing::debug!( - "Marking quote {} paid by lookup id {}", - mint_quote.id, - request_lookup_id + if (mint_quote.single_use || mint_quote.payment_method == PaymentMethod::Bolt11) + && mint_quote.state == MintQuoteState::Issued + { + tracing::info!( + "Payment notification for quote {} already issued.", + mint_quote.id ); + return Err(Error::IssuedQuote); + } - self.localstore - .update_mint_quote_state(&mint_quote.id, MintQuoteState::Paid) - .await?; - } else { - tracing::debug!( - "{} Quote already {} continuing", - mint_quote.id, - mint_quote.state + self.localstore + .update_mint_quote_state(&mint_quote.id, MintQuoteState::Paid) + .await?; + + let quote = self + .localstore + .get_mint_quote(&mint_quote.id) + .await? + .unwrap(); + + assert!(unit == quote.unit); + + let mut payment_ids = quote.payment_ids; + + // We check if this payment has already been seen for this mint quote + // If it is we do not want to continue and add it to the paid balance of the quote + if payment_ids.contains(&payment_id) { + tracing::info!( + "Received update for payment {} already seen for quote {}", + payment_id, + mint_quote.id ); + return Err(Error::PaymentAlreadySeen); } + let amount_paid = quote.amount_paid + payment_amount; + + // Since this is the first time we've seen this payment we add it to seen payment. + payment_ids.push(payment_id); + + let quote = MintQuote { + id: quote.id, + mint_url: quote.mint_url, + amount: quote.amount, + payment_method: quote.payment_method, + 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: quote.single_use, + payment_ids, + pubkey: quote.pubkey, + }; + + tracing::debug!( + "Quote: {}, Amount paid: {}, amount issued: {}", + quote.id, + amount_paid, + quote.amount_issued + ); + self.pubsub_manager .mint_quote_bolt11_status(mint_quote, MintQuoteState::Paid); } @@ -252,7 +298,7 @@ impl Mint { &self, mint_request: nut04::MintBolt11Request, ) -> Result { - let mint_quote = + let quote = if let Some(mint_quote) = self.localstore.get_mint_quote(&mint_request.quote).await? { mint_quote } else { @@ -272,11 +318,19 @@ 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 => (), } + // If the there is a public key provoided in mint quote request + // verify the signature is provided for the mint request + if let Some(pubkey) = quote.pubkey { + mint_request.verify_witness(pubkey)?; + } + let blinded_messages: Vec = mint_request .outputs .iter() @@ -300,8 +354,7 @@ impl Mint { self.localstore .update_mint_quote_state(&mint_request.quote, MintQuoteState::Paid) - .await - .unwrap(); + .await?; return Err(Error::BlindedMessageAlreadySigned); } @@ -329,6 +382,28 @@ impl Mint { .update_mint_quote_state(&mint_request.quote, MintQuoteState::Issued) .await?; + let mint_quote = MintQuote { + id: quote.id, + mint_url: quote.mint_url, + payment_method: quote.payment_method, + 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 + + mint_request + .total_amount() + .map_err(|_| Error::AmountOverflow)?, + request_lookup_id: quote.request_lookup_id, + single_use: quote.single_use, + payment_ids: quote.payment_ids, + pubkey: quote.pubkey, + }; + + self.localstore.add_mint_quote(mint_quote.clone()).await?; + self.pubsub_manager .mint_quote_bolt11_status(mint_quote, MintQuoteState::Issued); diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index 3a1fc751b..42eaf8b53 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -12,14 +12,16 @@ 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::bolt12::MintBolt12Lightning; use crate::cdk_lightning::{self, MintLightning}; use crate::dhke::{sign_message, verify_message}; use crate::error::Error; use crate::fees::calculate_fee; use crate::mint_url::MintUrl; use crate::nuts::*; -use crate::types::{LnKey, QuoteTTL}; +use crate::types::QuoteTTL; use crate::util::unix_time; use crate::Amount; @@ -28,6 +30,7 @@ mod check_spendable; mod info; mod keysets; mod melt; +mod mint_20; mod mint_nut04; mod start_up_check; mod swap; @@ -48,7 +51,12 @@ pub struct Mint { /// Mint Storage backend pub localstore: Arc + Send + Sync>, /// Ln backends for mint - pub ln: HashMap + Send + Sync>>, + pub ln: HashMap + Send + Sync>>, + /// Ln backends for mint + pub bolt12_backends: HashMap< + CurrencyUnit, + Arc + Send + Sync>, + >, /// Subscription manager pub pubsub_manager: Arc, /// Active Mint Keysets @@ -66,7 +74,11 @@ impl Mint { mint_info: MintInfo, quote_ttl: QuoteTTL, localstore: Arc + Send + Sync>, - ln: HashMap + Send + Sync>>, + ln: HashMap + Send + Sync>>, + bolt12: HashMap< + CurrencyUnit, + Arc + Send + Sync>, + >, // Hashmap where the key is the unit and value is (input fee ppk, max_order) supported_units: HashMap, custom_paths: HashMap, @@ -193,6 +205,7 @@ impl Mint { localstore, mint_info, ln, + bolt12_backends: bolt12, }) } @@ -224,8 +237,66 @@ impl Mint { result = ln.wait_any_invoice() => { 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 { + while let Some(wait_invoice_response) = stream.next().await { + if let Err(err) = mint.pay_mint_quote_for_request_id(wait_invoice_response).await { + tracing::warn!("{:?}", err); + } + } + } + Err(err) => { + tracing::warn!("Could not get invoice stream for {:?}: {}",key, err); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + } + } + } + } + } + }); + } + } + + // Spawn a task to manage the JoinSet + while let Some(result) = join_set.join_next().await { + match result { + Ok(_) => tracing::info!("A task completed successfully."), + Err(err) => tracing::warn!("A task failed: {:?}", err), + } + } + + Ok(()) + } + + /// Wait for any offer to be paid + /// For each backend starts a task that waits for any offers to be paid + /// Once invoice is paid mint quote status is updated + #[allow(clippy::incompatible_msrv)] + // Clippy thinks select is not stable but it compiles fine on MSRV (1.63.0) + pub async fn wait_for_paid_offers(&self, shutdown: Arc) -> Result<(), Error> { + let mint_arc = Arc::new(self.clone()); + + let mut join_set = JoinSet::new(); + + for (key, bolt12) in self.bolt12_backends.iter() { + if !bolt12.is_wait_invoice_active() { + let mint = Arc::clone(&mint_arc); + let bolt12 = Arc::clone(bolt12); + let shutdown = Arc::clone(&shutdown); + let key = key.clone(); + join_set.spawn(async move { + if !bolt12.is_wait_invoice_active() { + loop { + tokio::select! { + _ = shutdown.notified() => { + tracing::info!("Shutdown signal received, stopping task for {:?}", key); + bolt12.cancel_wait_invoice(); + break; + } + result = bolt12.wait_any_offer() => { + match result { + Ok(mut stream) => { + while let Some(wait_invoice_response) = stream.next().await { + if let Err(err) = mint.pay_mint_quote_for_request_id(wait_invoice_response).await { tracing::warn!("{:?}", err); } } @@ -380,13 +451,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), @@ -401,18 +473,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); } @@ -507,7 +574,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, @@ -590,10 +657,7 @@ fn create_new_keyset( } fn derivation_path_from_unit(unit: CurrencyUnit, index: u32) -> Option { - let unit_index = match unit.derivation_index() { - Some(index) => index, - None => return None, - }; + let unit_index = unit.derivation_index()?; Some(DerivationPath::from(vec![ ChildNumber::from_hardened_idx(0).expect("0 is a valid index"), @@ -743,6 +807,7 @@ mod tests { config.quote_ttl, localstore, HashMap::new(), + HashMap::new(), config.supported_units, HashMap::new(), ) diff --git a/crates/cdk/src/mint/start_up_check.rs b/crates/cdk/src/mint/start_up_check.rs index fa640da03..59eef658d 100644 --- a/crates/cdk/src/mint/start_up_check.rs +++ b/crates/cdk/src/mint/start_up_check.rs @@ -4,8 +4,7 @@ //! These ensure that the status of the mint or melt quote matches in the mint db and on the node. use super::{Error, Mint}; -use crate::mint::{MeltQuote, MeltQuoteState, PaymentMethod}; -use crate::types::LnKey; +use crate::mint::{MeltQuote, MeltQuoteState}; impl Mint { /// Check the status of all pending mint quotes in the mint db @@ -56,22 +55,15 @@ impl Mint { tracing::debug!("Checking status for melt quote {}.", pending_quote.id); let melt_request_ln_key = self.localstore.get_melt_request(&pending_quote.id).await?; - let (melt_request, ln_key) = match melt_request_ln_key { - None => { - let ln_key = LnKey { - unit: pending_quote.unit, - method: PaymentMethod::Bolt11, - }; - - (None, ln_key) - } - Some((melt_request, ln_key)) => (Some(melt_request), ln_key), + let (melt_request, unit) = match melt_request_ln_key { + None => (None, pending_quote.unit), + Some((melt_request, ln_key)) => (Some(melt_request), ln_key.unit), }; - let ln_backend = match self.ln.get(&ln_key) { + let ln_backend = match self.ln.get(&unit) { Some(ln_backend) => ln_backend, None => { - tracing::warn!("No backend for ln key: {:?}", ln_key); + tracing::warn!("No backend for ln key: {:?}", unit); continue; } }; @@ -87,7 +79,6 @@ impl Mint { if let Err(err) = self .process_melt_request( &melt_request, - pay_invoice_response.payment_preimage, pay_invoice_response.total_spent, ) .await diff --git a/crates/cdk/src/mint/types.rs b/crates/cdk/src/mint/types.rs index 44047fd9b..25f198869 100644 --- a/crates/cdk/src/mint/types.rs +++ b/crates/cdk/src/mint/types.rs @@ -1,9 +1,11 @@ //! Mint Types +use lightning::offers::offer::Offer; +use lightning_invoice::Bolt11Invoice; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use super::CurrencyUnit; +use super::{CurrencyUnit, PaymentMethod, PublicKey}; use crate::mint_url::MintUrl; use crate::nuts::{MeltQuoteState, MintQuoteState}; use crate::Amount; @@ -16,7 +18,10 @@ pub struct MintQuote { /// Mint Url pub mint_url: MintUrl, /// Amount of quote - pub amount: Amount, + pub amount: Option, + /// Payment Method + #[serde(default)] + pub payment_method: PaymentMethod, /// Unit of quote pub unit: CurrencyUnit, /// Quote payment request e.g. bolt11 @@ -27,17 +32,38 @@ pub struct MintQuote { pub expiry: u64, /// Value used by ln backend to look up state of request pub request_lookup_id: String, + /// Amount paid + #[serde(default)] + pub amount_paid: Amount, + /// Amount issued + #[serde(default)] + pub amount_issued: Amount, + /// Single use + #[serde(default)] + pub single_use: bool, + /// Payment of payment(s) that filled quote + #[serde(default)] + pub payment_ids: Vec, + /// Pubkey + pub pubkey: Option, } impl MintQuote { /// Create new [`MintQuote`] + #[allow(clippy::too_many_arguments)] pub fn new( mint_url: MintUrl, request: String, + payment_method: PaymentMethod, unit: CurrencyUnit, - amount: Amount, + amount: Option, expiry: u64, request_lookup_id: String, + amount_paid: Amount, + amount_issued: Amount, + single_use: bool, + payment_ids: Vec, + pubkey: Option, ) -> Self { let id = Uuid::new_v4(); @@ -45,11 +71,17 @@ impl MintQuote { mint_url, id: id.to_string(), amount, + payment_method, unit, request, state: MintQuoteState::Unpaid, expiry, request_lookup_id, + amount_paid, + amount_issued, + single_use, + payment_ids, + pubkey, } } } @@ -64,7 +96,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 +112,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 +134,47 @@ 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 std::str::FromStr; + + use serde::{self, Deserialize, Deserializer, Serializer}; + + use super::Offer; + + 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 eb1f81707..eba87bdc9 100644 --- a/crates/cdk/src/nuts/mod.rs +++ b/crates/cdk/src/nuts/mod.rs @@ -21,6 +21,9 @@ pub mod nut15; #[cfg(feature = "mint")] pub mod nut17; pub mod nut18; +pub mod nut19; +pub mod nut20; +pub mod nut21; pub use nut00::{ BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, PreMint, PreMintSecrets, Proof, @@ -52,3 +55,5 @@ pub use nut15::{Mpp, MppMethodSettings, Settings as NUT15Settings}; #[cfg(feature = "mint")] pub use nut17::{NotificationPayload, PubSubManager}; pub use nut18::{PaymentRequest, PaymentRequestPayload, Transport}; +pub use nut20::{MintQuoteBolt12Request, MintQuoteBolt12Response}; +pub use nut21::{MeltBolt12Request, MeltQuoteBolt12Request}; diff --git a/crates/cdk/src/nuts/nut00/mod.rs b/crates/cdk/src/nuts/nut00/mod.rs index 929b0c058..c6a85dc29 100644 --- a/crates/cdk/src/nuts/nut00/mod.rs +++ b/crates/cdk/src/nuts/nut00/mod.rs @@ -394,12 +394,12 @@ impl CurrencyUnit { impl FromStr for CurrencyUnit { type Err = Error; fn from_str(value: &str) -> Result { - let value = &value.to_uppercase(); + let value = value.to_lowercase(); match value.as_str() { - "SAT" => Ok(Self::Sat), - "MSAT" => Ok(Self::Msat), - "USD" => Ok(Self::Usd), - "EUR" => Ok(Self::Eur), + "sat" => Ok(Self::Sat), + "msat" => Ok(Self::Msat), + "usd" => Ok(Self::Usd), + "eur" => Ok(Self::Eur), c => Ok(Self::Custom(c.to_string())), } } @@ -449,13 +449,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), } } @@ -465,6 +469,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 40a6f8d4b..2cd65c0a0 100644 --- a/crates/cdk/src/nuts/nut04.rs +++ b/crates/cdk/src/nuts/nut04.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod}; -use super::MintQuoteState; +use super::{MintQuoteState, PublicKey}; use crate::Amount; /// NUT04 Error @@ -32,7 +32,11 @@ pub struct MintQuoteBolt11Request { /// Unit wallet would like to pay with pub unit: CurrencyUnit, /// Memo to create the invoice with + #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// NUT-19 Pubkey + #[serde(skip_serializing_if = "Option::is_none")] + pub pubkey: Option, } /// Possible states of a quote @@ -90,6 +94,9 @@ pub struct MintQuoteBolt11Response { pub state: MintQuoteState, /// Unix timestamp until the quote is valid pub expiry: Option, + /// NUT-19 Pubkey + #[serde(skip_serializing_if = "Option::is_none")] + pub pubkey: Option, } #[cfg(feature = "mint")] @@ -100,6 +107,7 @@ impl From for MintQuoteBolt11Response { request: mint_quote.request, state: mint_quote.state, expiry: Some(mint_quote.expiry), + pubkey: mint_quote.pubkey, } } } @@ -114,6 +122,9 @@ pub struct MintBolt11Request { /// Outputs #[cfg_attr(feature = "swagger", schema(max_items = 1_000))] pub outputs: Vec, + /// Signature + #[serde(skip_serializing_if = "Option::is_none")] + pub witness: Option, } impl MintBolt11Request { @@ -189,16 +200,8 @@ impl Settings { impl Default for Settings { fn default() -> Self { - let bolt11_mint = MintMethodSettings { - method: PaymentMethod::Bolt11, - unit: CurrencyUnit::Sat, - min_amount: Some(Amount::from(1)), - max_amount: Some(Amount::from(1000000)), - description: true, - }; - Settings { - methods: vec![bolt11_mint], + methods: vec![], disabled: false, } } diff --git a/crates/cdk/src/nuts/nut05.rs b/crates/cdk/src/nuts/nut05.rs index df9064ef6..86eb1f641 100644 --- a/crates/cdk/src/nuts/nut05.rs +++ b/crates/cdk/src/nuts/nut05.rs @@ -242,12 +242,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 @@ -300,15 +347,8 @@ pub struct Settings { impl Default for Settings { fn default() -> Self { - let bolt11_mint = MeltMethodSettings { - method: PaymentMethod::Bolt11, - unit: CurrencyUnit::Sat, - min_amount: Some(Amount::from(1)), - max_amount: Some(Amount::from(1000000)), - }; - Settings { - methods: vec![bolt11_mint], + methods: vec![], disabled: false, } } diff --git a/crates/cdk/src/nuts/nut06.rs b/crates/cdk/src/nuts/nut06.rs index 17ba18b6d..ce62cec06 100644 --- a/crates/cdk/src/nuts/nut06.rs +++ b/crates/cdk/src/nuts/nut06.rs @@ -232,14 +232,20 @@ 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, /// NUT17 Settings - #[serde(default)] - #[serde(rename = "17")] #[cfg(feature = "mint")] - pub nut17: super::nut17::SupportedSettings, + pub nut17: Option, + /// NUT20 Settings + #[serde(rename = "20")] + #[serde(skip_serializing_if = "Option::is_none")] + pub nut20: Option, + /// NUT21 Settings + #[serde(rename = "21")] + #[serde(skip_serializing_if = "Option::is_none")] + pub nut21: Option, } impl Nuts { @@ -323,21 +329,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 nut20(self, nut04_settings: nut04::Settings) -> Self { + Self { + nut20: Some(nut04_settings), + ..self + } + } + + /// Nut21 settings + pub fn nut21(self, nut05_settings: nut05::Settings) -> Self { + Self { + nut21: 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/nut17/mod.rs b/crates/cdk/src/nuts/nut17/mod.rs index 3ddd1f828..6f57457ad 100644 --- a/crates/cdk/src/nuts/nut17/mod.rs +++ b/crates/cdk/src/nuts/nut17/mod.rs @@ -118,10 +118,9 @@ impl Indexable for NotificationPayload { } } +/// Kind #[derive(Debug, Clone, Copy, Eq, Ord, PartialOrd, PartialEq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] - -/// Kind pub enum Kind { /// Bolt 11 Melt Quote Bolt11MeltQuote, diff --git a/crates/cdk/src/nuts/nut19.rs b/crates/cdk/src/nuts/nut19.rs new file mode 100644 index 000000000..a4e789625 --- /dev/null +++ b/crates/cdk/src/nuts/nut19.rs @@ -0,0 +1,145 @@ +//! Mint Quote Signatures + +use std::str::FromStr; + +use bitcoin::secp256k1::schnorr::Signature; +use thiserror::Error; + +use super::{MintBolt11Request, PublicKey, SecretKey}; + +/// Nut19 Error +#[derive(Debug, Error)] +pub enum Error { + /// Witness not provided + #[error("Witness not provided")] + WitnessMissing, + /// Quote witness invalid signature + #[error("Quote witness invalid signature")] + InvalidWitness, + /// Nut01 error + #[error(transparent)] + NUT01(#[from] crate::nuts::nut01::Error), +} + +impl MintBolt11Request { + /// Constructs the message to be signed according to NUT-19 specification. + /// + /// The message is constructed by concatenating (as UTF-8 encoded bytes): + /// 1. The quote ID (as UTF-8) + /// 2. All blinded secrets (B_0 through B_n) converted to hex strings (as UTF-8) + /// + /// Format: `quote_id || B_0 || B_1 || ... || B_n` + /// where each component is encoded as UTF-8 bytes + pub fn msg_to_sign(&self) -> Vec { + // Pre-calculate capacity to avoid reallocations + let capacity = self.quote.len() + (self.outputs.len() * 66); + let mut msg = Vec::with_capacity(capacity); + msg.append(&mut self.quote.clone().into_bytes()); // String.into_bytes() produces UTF-8 + for output in &self.outputs { + // to_hex() creates a hex string, into_bytes() converts it to UTF-8 bytes + msg.append(&mut output.blinded_secret.to_hex().into_bytes()); + } + msg + } + + /// Sign [`MintBolt11Request`] + pub fn sign(&mut self, secret_key: SecretKey) -> Result<(), Error> { + let msg = self.msg_to_sign(); + + let signature: Signature = secret_key.sign(&msg)?; + + self.witness = Some(signature.to_string()); + + Ok(()) + } + + /// Verify signature on [`MintBolt11Request`] + pub fn verify_witness(&self, pubkey: PublicKey) -> Result<(), Error> { + let witness = self.witness.as_ref().ok_or(Error::WitnessMissing)?; + + let signature = Signature::from_str(witness).map_err(|_| Error::InvalidWitness)?; + + let msg_to_sign = self.msg_to_sign(); + + pubkey.verify(&msg_to_sign, &signature)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_msg_to_sign() { + let request: MintBolt11Request = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}],"witness":"cb2b8e7ea69362dfe2a07093f2bbc319226db33db2ef686c940b5ec976bcbfc78df0cd35b3e998adf437b09ee2c950bd66dfe9eb64abd706e43ebc7c669c36c3"}"#).unwrap(); + + // let expected_msg_to_sign = "9d745270-1405-46de-b5c5-e2762b4f5e000342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c31102be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b5302209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"; + + let expected_msg_to_sign = [ + 57, 100, 55, 52, 53, 50, 55, 48, 45, 49, 52, 48, 53, 45, 52, 54, 100, 101, 45, 98, 53, + 99, 53, 45, 101, 50, 55, 54, 50, 98, 52, 102, 53, 101, 48, 48, 48, 51, 52, 50, 101, 53, + 98, 99, 99, 55, 55, 102, 53, 98, 50, 97, 51, 99, 50, 97, 102, 98, 52, 48, 98, 98, 53, + 57, 49, 97, 49, 101, 50, 55, 100, 97, 56, 51, 99, 100, 100, 99, 57, 54, 56, 97, 98, + 100, 99, 48, 101, 99, 52, 57, 48, 52, 50, 48, 49, 97, 50, 48, 49, 56, 51, 52, 48, 51, + 50, 102, 100, 51, 99, 52, 100, 99, 52, 57, 97, 50, 56, 52, 52, 97, 56, 57, 57, 57, 56, + 100, 53, 101, 57, 100, 53, 98, 48, 102, 48, 98, 48, 48, 100, 100, 101, 57, 51, 49, 48, + 48, 54, 51, 97, 99, 98, 56, 97, 57, 50, 101, 50, 102, 100, 97, 102, 97, 52, 49, 50, 54, + 100, 52, 48, 51, 51, 98, 54, 102, 100, 101, 53, 48, 98, 54, 97, 48, 100, 102, 101, 54, + 49, 97, 100, 49, 52, 56, 102, 102, 102, 49, 54, 55, 97, 100, 57, 99, 102, 56, 51, 48, + 56, 100, 101, 100, 53, 102, 54, 102, 54, 98, 50, 102, 101, 48, 48, 48, 97, 48, 51, 54, + 99, 52, 54, 52, 99, 51, 49, 49, 48, 50, 98, 101, 53, 97, 53, 53, 102, 48, 51, 101, 53, + 99, 48, 97, 97, 101, 97, 55, 55, 53, 57, 53, 100, 53, 55, 52, 98, 99, 101, 57, 50, 99, + 54, 100, 53, 55, 97, 50, 97, 48, 102, 98, 50, 98, 53, 57, 53, 53, 99, 48, 98, 56, 55, + 101, 52, 53, 50, 48, 101, 48, 54, 98, 53, 51, 48, 50, 50, 48, 57, 102, 99, 50, 56, 55, + 51, 102, 50, 56, 53, 50, 49, 99, 98, 100, 100, 101, 55, 102, 55, 98, 51, 98, 98, 49, + 53, 50, 49, 48, 48, 50, 52, 54, 51, 102, 53, 57, 55, 57, 54, 56, 54, 102, 100, 49, 53, + 54, 102, 50, 51, 102, 101, 54, 97, 56, 97, 97, 50, 98, 55, 57, + ] + .to_vec(); + + let request_msg_to_sign = request.msg_to_sign(); + + assert_eq!(expected_msg_to_sign, request_msg_to_sign); + } + + #[test] + fn test_valid_signature() { + let pubkey = PublicKey::from_hex( + "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + ) + .unwrap(); + + let request: MintBolt11Request = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}], "witness": "d4b386f21f7aa7172f0994ee6e4dd966539484247ea71c99b81b8e09b1bb2acbc0026a43c221fd773471dc30d6a32b04692e6837ddaccf0830a63128308e4ee0"}"#).unwrap(); + + assert!(request.verify_witness(pubkey).is_ok()); + } + + #[test] + fn test_mint_request_signature() { + let mut request: MintBolt11Request = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}]}"#).unwrap(); + + let secret = + SecretKey::from_hex("50d7fd7aa2b2fe4607f41f4ce6f8794fc184dd47b8cdfbe4b3d1249aa02d35aa") + .unwrap(); + + request.sign(secret.clone()).unwrap(); + + assert!(request.verify_witness(secret.public_key()).is_ok()); + } + + #[test] + fn test_invalid_signature() { + let pubkey = PublicKey::from_hex( + "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + ) + .unwrap(); + + let request: MintBolt11Request = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}],"witness":"cb2b8e7ea69362dfe2a07093f2bbc319226db33db2ef686c940b5ec976bcbfc78df0cd35b3e998adf437b09ee2c950bd66dfe9eb64abd706e43ebc7c669c36c3"}"#).unwrap(); + + // Signature is on a different quote id verification should fail + assert!(request.verify_witness(pubkey).is_err()); + } +} diff --git a/crates/cdk/src/nuts/nut20.rs b/crates/cdk/src/nuts/nut20.rs new file mode 100644 index 000000000..a3429c9af --- /dev/null +++ b/crates/cdk/src/nuts/nut20.rs @@ -0,0 +1,77 @@ +//! NUT-17: Mint Tokens via Bolt11 +//! +//! + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::nut00::CurrencyUnit; +use super::PublicKey; +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, + /// Publickey not defined + #[error("Publickey not defined")] + PublickeyUndefined, +} + +/// Mint quote request [NUT-19] +#[derive(Debug, Clone, Hash, 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: bool, + /// Expiry + pub expiry: Option, + /// Pubkey + pub pubkey: PublicKey, +} + +/// Mint quote response [NUT-19] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct MintQuoteBolt12Response { + /// Quote Id + pub quote: String, + /// Payment request to fulfil + pub request: String, + /// Single use + pub single_use: bool, + /// 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, + /// Pubkey + pub pubkey: PublicKey, +} + +#[cfg(feature = "mint")] +impl TryFrom for MintQuoteBolt12Response { + type Error = Error; + + fn try_from(mint_quote: crate::mint::MintQuote) -> Result { + Ok(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, + pubkey: mint_quote.pubkey.ok_or(Error::PublickeyUndefined)?, + }) + } +} diff --git a/crates/cdk/src/nuts/nut21.rs b/crates/cdk/src/nuts/nut21.rs new file mode 100644 index 000000000..30af3ac58 --- /dev/null +++ b/crates/cdk/src/nuts/nut21.rs @@ -0,0 +1,77 @@ +//! Bolt12 +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::nut05::MeltRequestTrait; +use super::{BlindedMessage, CurrencyUnit, PaymentMethod, Proofs}; +use crate::Amount; + +/// 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/types.rs b/crates/cdk/src/types.rs index 23ab9e897..4869b2350 100644 --- a/crates/cdk/src/types.rs +++ b/crates/cdk/src/types.rs @@ -141,6 +141,7 @@ impl ProofInfo { /// Key used in hashmap of ln backends to identify what unit and payment method /// it is for +// TODO: Check if this is actually used anywhere #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct LnKey { /// Unit of Payment backend diff --git a/crates/cdk/src/wallet/client.rs b/crates/cdk/src/wallet/client.rs index 4296e6c23..4d3b334f3 100644 --- a/crates/cdk/src/wallet/client.rs +++ b/crates/cdk/src/wallet/client.rs @@ -12,11 +12,14 @@ use super::Error; use crate::error::ErrorResponse; use crate::mint_url::MintUrl; use crate::nuts::{ - CheckStateRequest, CheckStateResponse, Id, KeySet, KeysResponse, KeysetResponse, - MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, - MintBolt11Response, MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, RestoreRequest, - RestoreResponse, SwapRequest, SwapResponse, + BlindedMessage, CheckStateRequest, CheckStateResponse, CurrencyUnit, Id, KeySet, KeysResponse, + KeysetResponse, MeltBolt11Request, MeltBolt12Request, MeltQuoteBolt11Request, + MeltQuoteBolt11Response, MeltQuoteBolt12Request, MintBolt11Request, MintBolt11Response, + MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, MintQuoteBolt12Request, + MintQuoteBolt12Response, PreMintSecrets, Proof, RestoreRequest, RestoreResponse, SwapRequest, + SwapResponse, }; +use crate::Amount; /// Http Client #[derive(Debug, Clone)] @@ -135,6 +138,29 @@ impl HttpClientMethods for HttpClient { } } + /// Mint Quote [NUT-19] + #[instrument(skip(self), fields(mint_url = %mint_url))] + async fn post_mint_bolt12_quote( + &self, + mint_url: MintUrl, + request: MintQuoteBolt12Request, + ) -> Result { + let url = mint_url.join_paths(&["v1", "mint", "quote", "bolt12"])?; + + 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))] async fn get_mint_quote_status( @@ -179,6 +205,58 @@ impl HttpClientMethods for HttpClient { } } + /// Mint Quote status + #[instrument(skip(self), fields(mint_url = %mint_url))] + async fn get_mint_bolt12_quote_status( + &self, + mint_url: MintUrl, + quote_id: &str, + ) -> Result { + let url = mint_url.join_paths(&["v1", "mint", "quote", "bolt12", quote_id])?; + + let res = self.inner.get(url).send().await?.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 Tokens [NUT-19] + #[instrument(skip(self, quote, premint_secrets), fields(mint_url = %mint_url))] + async fn post_mint_bolt12( + &self, + mint_url: MintUrl, + quote: &str, + premint_secrets: PreMintSecrets, + ) -> Result { + let url = mint_url.join_paths(&["v1", "mint", "bolt12"])?; + + let request = MintBolt11Request { + quote: quote.to_string(), + outputs: premint_secrets.blinded_messages(), + // TODO: Add witness + witness: None, + }; + + let res = self + .inner + .post(url) + .json(&request) + .send() + .await? + .json::() + .await?; + + match serde_json::from_value::(res.clone()) { + Ok(mint_quote_response) => Ok(mint_quote_response), + Err(_) => Err(ErrorResponse::from_value(res)?.into()), + } + } + /// Melt Quote [NUT-05] #[instrument(skip(self, request), fields(mint_url = %mint_url))] async fn post_melt_quote( @@ -203,6 +281,33 @@ impl HttpClientMethods for HttpClient { } } + /// Melt Bol12 + #[instrument(skip(self, request), fields(mint_url = %mint_url))] + 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))] async fn get_melt_quote_status( @@ -250,6 +355,39 @@ impl HttpClientMethods for HttpClient { } } + /// Melt Bolt12 [NUT-05] + /// [Nut-08] Lightning fee return if outputs defined + #[instrument(skip(self, quote, inputs, outputs), fields(mint_url = %mint_url))] + 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()), + } + } + /// Swap Token [NUT-03] #[instrument(skip(self, swap_request), fields(mint_url = %mint_url))] async fn post_swap( @@ -418,4 +556,45 @@ pub trait HttpClientMethods: Debug { mint_url: MintUrl, request: RestoreRequest, ) -> Result; + + /// Mint Quote [NUT-19] + async fn post_mint_bolt12_quote( + &self, + mint_url: MintUrl, + request: MintQuoteBolt12Request, + ) -> Result; + + /// Mint Quote status + async fn get_mint_bolt12_quote_status( + &self, + mint_url: MintUrl, + quote_id: &str, + ) -> Result; + + /// Mint Tokens [NUT-19] + async fn post_mint_bolt12( + &self, + mint_url: MintUrl, + quote: &str, + premint_secrets: PreMintSecrets, + ) -> Result; + + /// Melt Bol12 + async fn post_melt_bolt12_quote( + &self, + mint_url: MintUrl, + unit: CurrencyUnit, + request: String, + amount: Option, + ) -> Result; + + /// Melt Bolt12 [NUT-05] + /// [Nut-08] Lightning fee return if outputs defined + async fn post_melt_bolt12( + &self, + mint_url: MintUrl, + quote: String, + inputs: Vec, + outputs: Option>, + ) -> Result; } diff --git a/crates/cdk/src/wallet/melt.rs b/crates/cdk/src/wallet/melt.rs index e8a0a0b51..9d2e9fd08 100644 --- a/crates/cdk/src/wallet/melt.rs +++ b/crates/cdk/src/wallet/melt.rs @@ -1,14 +1,16 @@ use std::str::FromStr; +use lightning::offers::offer::Offer; use lightning_invoice::Bolt11Invoice; use tracing::instrument; use super::MeltQuote; +use crate::amount::amount_for_offer; use crate::dhke::construct_proofs; use crate::nuts::nut00::ProofsMethods; use crate::nuts::{ CurrencyUnit, MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, Mpp, - PreMintSecrets, Proofs, State, + PaymentMethod, PreMintSecrets, Proofs, State, }; use crate::types::{Melted, ProofInfo}; use crate::util::unix_time; @@ -78,6 +80,53 @@ impl Wallet { id: quote_res.quote, amount, request, + payment_method: PaymentMethod::Bolt11, + unit: self.unit.clone(), + 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.clone(), + 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.clone(), fee_reserve: quote_res.fee_reserve, state: quote_res.state, @@ -154,14 +203,27 @@ impl Wallet { proofs_total - quote_info.amount, )?; - let request = MeltBolt11Request { - quote: quote_id.to_string(), - inputs: proofs.clone(), - outputs: Some(premint_secrets.blinded_messages()), + let melt_response = match quote_info.payment_method { + PaymentMethod::Bolt11 => { + let request = MeltBolt11Request { + quote: quote_id.to_string(), + inputs: proofs.clone(), + outputs: Some(premint_secrets.blinded_messages()), + }; + self.client.post_melt(self.mint_url.clone(), request).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 = self.client.post_melt(self.mint_url.clone(), request).await; - let melt_response = match melt_response { Ok(melt_response) => melt_response, Err(err) => { diff --git a/crates/cdk/src/wallet/mint.rs b/crates/cdk/src/wallet/mint.rs index 77b9bb706..f2f904ea4 100644 --- a/crates/cdk/src/wallet/mint.rs +++ b/crates/cdk/src/wallet/mint.rs @@ -5,8 +5,8 @@ use crate::amount::SplitTarget; use crate::dhke::construct_proofs; use crate::nuts::nut00::ProofsMethods; use crate::nuts::{ - nut12, MintBolt11Request, MintQuoteBolt11Request, MintQuoteBolt11Response, PreMintSecrets, - SpendingConditions, State, + nut12, MintBolt11Request, MintQuoteBolt11Request, MintQuoteBolt11Response, PaymentMethod, + PreMintSecrets, PublicKey, SecretKey, SpendingConditions, State, }; use crate::types::ProofInfo; use crate::util::unix_time; @@ -35,7 +35,7 @@ impl Wallet { /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; /// let amount = Amount::from(100); /// - /// let quote = wallet.mint_quote(amount, None).await?; + /// let quote = wallet.mint_quote(amount, None, None).await?; /// Ok(()) /// } /// ``` @@ -44,6 +44,7 @@ impl Wallet { &self, amount: Amount, description: Option, + pubkey: Option, ) -> Result { let mint_url = self.mint_url.clone(); let unit = self.unit.clone(); @@ -69,6 +70,7 @@ impl Wallet { amount, unit: unit.clone(), description, + pubkey, }; let quote_res = self @@ -79,11 +81,15 @@ impl Wallet { let quote = MintQuote { mint_url, id: quote_res.quote.clone(), + payment_method: PaymentMethod::Bolt11, amount, unit: unit.clone(), request: quote_res.request, state: quote_res.state, expiry: quote_res.expiry.unwrap_or(0), + amount_minted: Amount::ZERO, + amount_paid: Amount::ZERO, + pubkey, }; self.localstore.add_mint_quote(quote.clone()).await?; @@ -124,8 +130,9 @@ impl Wallet { let mint_quote_response = self.mint_quote_state(&mint_quote.id).await?; if mint_quote_response.state == MintQuoteState::Paid { + // TODO: Need to pass in keys here let amount = self - .mint(&mint_quote.id, SplitTarget::default(), None) + .mint(&mint_quote.id, SplitTarget::default(), None, None) .await?; total_amount += amount; } else if mint_quote.expiry.le(&unix_time()) { @@ -157,10 +164,12 @@ impl Wallet { /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); /// let amount = Amount::from(100); /// - /// let quote = wallet.mint_quote(amount, None).await?; + /// let quote = wallet.mint_quote(amount, None, None).await?; /// let quote_id = quote.id; /// // To be called after quote request is paid - /// let amount_minted = wallet.mint("e_id, SplitTarget::default(), None).await?; + /// let amount_minted = wallet + /// .mint("e_id, SplitTarget::default(), None, None) + /// .await?; /// /// Ok(()) /// } @@ -171,6 +180,7 @@ impl Wallet { quote_id: &str, amount_split_target: SplitTarget, spending_conditions: Option, + secret_key: Option, ) -> Result { // Check that mint is in store of mints if self @@ -203,10 +213,12 @@ impl Wallet { let count = count.map_or(0, |c| c + 1); + let amount = quote_info.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, )?, @@ -214,16 +226,26 @@ impl Wallet { active_keyset_id, count, self.xpriv, - quote_info.amount, + amount, &amount_split_target, )?, }; - let request = MintBolt11Request { + let mut request = MintBolt11Request { quote: quote_id.to_string(), outputs: premint_secrets.blinded_messages(), + witness: None, }; + if let Some(pubkey) = quote_info.pubkey { + let secret_key = secret_key.ok_or(Error::SecretKeyNotProvided)?; + if pubkey != secret_key.public_key() { + return Err(Error::IncorrectSecretKey); + } + + request.sign(secret_key)?; + } + let mint_res = self .client .post_mint(self.mint_url.clone(), request) @@ -253,7 +275,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/mint_bolt12.rs b/crates/cdk/src/wallet/mint_bolt12.rs new file mode 100644 index 000000000..18c808a73 --- /dev/null +++ b/crates/cdk/src/wallet/mint_bolt12.rs @@ -0,0 +1,236 @@ +use tracing::instrument; + +use super::MintQuote; +use crate::amount::SplitTarget; +use crate::dhke::construct_proofs; +use crate::nuts::nut00::ProofsMethods; +use crate::nuts::{ + nut12, MintBolt11Request, MintQuoteBolt12Request, MintQuoteBolt12Response, PaymentMethod, + PreMintSecrets, PublicKey, SpendingConditions, State, +}; +use crate::types::ProofInfo; +use crate::util::unix_time; +use crate::{Amount, Error, Wallet}; + +impl Wallet { + /// Mint Bolt12 + #[instrument(skip(self))] + pub async fn mint_bolt12_quote( + &self, + amount: Option, + description: Option, + single_use: bool, + expiry: Option, + pubkey: PublicKey, + ) -> 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 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 mint_request = MintQuoteBolt12Request { + amount, + unit: self.unit.clone(), + description, + single_use, + expiry, + pubkey, + }; + + let quote_res = self + .client + .post_mint_bolt12_quote(mint_url.clone(), mint_request) + .await?; + + let quote = MintQuote { + mint_url, + id: quote_res.quote.clone(), + payment_method: PaymentMethod::Bolt12, + amount: amount.unwrap_or(Amount::ZERO), + unit: self.unit.clone(), + request: quote_res.request, + state: crate::nuts::MintQuoteState::Unpaid, + expiry: quote_res.expiry.unwrap_or(0), + amount_minted: Amount::ZERO, + amount_paid: Amount::ZERO, + // TODO: Add pubkey + pubkey: None, + }; + + self.localstore.add_mint_quote(quote.clone()).await?; + + Ok(quote) + } + + /// Mint bolt12 + #[instrument(skip(self))] + pub async fn mint_bolt12( + &self, + quote_id: &str, + amount: Option, + amount_split_target: SplitTarget, + spending_conditions: Option, + ) -> Result { + // Check that mint is in store of mints + if self + .localstore + .get_mint(self.mint_url.clone()) + .await? + .is_none() + { + self.get_mint_info().await?; + } + + let quote_info = self.localstore.get_mint_quote(quote_id).await?; + + let quote_info = if let Some(quote) = quote_info { + if quote.expiry.le(&unix_time()) && quote.expiry.ne(&0) { + return Err(Error::ExpiredQuote(quote.expiry, unix_time())); + } + + quote.clone() + } else { + return Err(Error::UnknownQuote); + }; + + let active_keyset_id = self.get_active_mint_keyset().await?.id; + + let count = self + .localstore + .get_keyset_counter(&active_keyset_id) + .await?; + + let count = count.map_or(0, |c| c + 1); + + let amount = match amount { + Some(amount) => amount, + None => { + // If an amount it not supplied with check the status of the quote + // The mint will tell us how much can be minted + let state = self.mint_bolt12_quote_state(quote_id).await?; + + state.amount_paid - state.amount_issued + } + }; + + let premint_secrets = match &spending_conditions { + Some(spending_conditions) => PreMintSecrets::with_conditions( + active_keyset_id, + amount, + &amount_split_target, + spending_conditions, + )?, + None => PreMintSecrets::from_xpriv( + active_keyset_id, + count, + self.xpriv, + amount, + &amount_split_target, + )?, + }; + + let mint_request = MintBolt11Request { + quote: quote_id.to_string(), + outputs: premint_secrets.blinded_messages(), + // Add witness + witness: None, + }; + + let mint_res = self + .client + .post_mint(self.mint_url.clone(), mint_request) + .await?; + + let keys = self.get_keyset_keys(active_keyset_id).await?; + + // Verify the signature DLEQ is valid + { + for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) { + let keys = self.get_keyset_keys(sig.keyset_id).await?; + let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?; + match sig.verify_dleq(key, premint.blinded_message.blinded_secret) { + Ok(_) | Err(nut12::Error::MissingDleqProof) => (), + Err(_) => return Err(Error::CouldNotVerifyDleq), + } + } + } + + let proofs = construct_proofs( + mint_res.signatures, + premint_secrets.rs(), + premint_secrets.secrets(), + &keys, + )?; + + let minted_amount = proofs.total_amount()?; + + // Remove filled quote from store + //self.localstore.remove_mint_quote("e_info.id).await?; + + if spending_conditions.is_none() { + // Update counter for keyset + self.localstore + .increment_keyset_counter(&active_keyset_id, proofs.len() as u32) + .await?; + } + + let proofs = proofs + .into_iter() + .map(|proof| { + ProofInfo::new( + proof, + self.mint_url.clone(), + State::Unspent, + quote_info.unit.clone(), + ) + }) + .collect::, _>>()?; + + // Add new proofs to store + self.localstore.update_proofs(proofs, vec![]).await?; + + Ok(minted_amount) + } + + /// Check mint quote status + #[instrument(skip(self, quote_id))] + pub async fn mint_bolt12_quote_state( + &self, + quote_id: &str, + ) -> Result { + let response = self + .client + .get_mint_bolt12_quote_status(self.mint_url.clone(), quote_id) + .await?; + + match self.localstore.get_mint_quote(quote_id).await? { + Some(quote) => { + let mut quote = quote; + quote.amount_minted = response.amount_issued; + quote.amount_paid = response.amount_paid; + + self.localstore.add_mint_quote(quote).await?; + } + None => { + tracing::info!("Quote mint {} unknown", quote_id); + } + } + + Ok(response) + } +} diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index bff6d4bfe..c74519550 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -28,6 +28,7 @@ pub mod client; mod keysets; mod melt; mod mint; +mod mint_bolt12; pub mod multi_mint_wallet; mod proofs; mod receive; diff --git a/crates/cdk/src/wallet/multi_mint_wallet.rs b/crates/cdk/src/wallet/multi_mint_wallet.rs index b0f048b2e..f23895148 100644 --- a/crates/cdk/src/wallet/multi_mint_wallet.rs +++ b/crates/cdk/src/wallet/multi_mint_wallet.rs @@ -16,7 +16,9 @@ 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, PublicKey, SecretKey, SpendingConditions, Token, +}; use crate::types::Melted; use crate::wallet::types::MintQuote; use crate::{Amount, Wallet}; @@ -166,13 +168,15 @@ impl MultiMintWallet { wallet_key: &WalletKey, amount: Amount, description: Option, + payment_method: PaymentMethod, + pubkey: Option, ) -> Result { let wallet = self .get_wallet(wallet_key) .await .ok_or(Error::UnknownWallet(wallet_key.clone()))?; - wallet.mint_quote(amount, description).await + wallet.mint_quote(amount, description, pubkey).await } /// Check all mint quotes @@ -215,13 +219,14 @@ impl MultiMintWallet { wallet_key: &WalletKey, quote_id: &str, conditions: Option, + secret_key: Option, ) -> Result { let wallet = self .get_wallet(wallet_key) .await .ok_or(Error::UnknownWallet(wallet_key.clone()))?; wallet - .mint(quote_id, SplitTarget::default(), conditions) + .mint(quote_id, SplitTarget::default(), conditions, secret_key) .await } diff --git a/crates/cdk/src/wallet/types.rs b/crates/cdk/src/wallet/types.rs index 309a4c1cf..a7f58cc6e 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, PublicKey}; use crate::Amount; /// Mint Quote Info @@ -13,6 +13,9 @@ pub struct MintQuote { pub id: String, /// Mint Url pub mint_url: MintUrl, + /// Payment method + #[serde(default)] + pub payment_method: PaymentMethod, /// Amount of quote pub amount: Amount, /// Unit of quote @@ -23,6 +26,12 @@ pub struct MintQuote { pub state: MintQuoteState, /// Expiration time of quote pub expiry: u64, + /// Amount minted + pub amount_minted: Amount, + /// Amount paid to the mint for the quote + pub amount_paid: Amount, + /// Publickey [NUT-19] + pub pubkey: Option, } /// Melt Quote Info @@ -36,6 +45,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 diff --git a/flake.lock b/flake.lock index f01519d10..f26185cb6 100644 --- a/flake.lock +++ b/flake.lock @@ -21,11 +21,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1726560853, - "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", - "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { @@ -57,11 +57,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1731386116, - "narHash": "sha256-lKA770aUmjPHdTaJWnP3yQ9OI1TigenUqVC3wweqZuI=", + "lastModified": 1731797254, + "narHash": "sha256-df3dJApLPhd11AlueuoN0Q4fHo/hagP75LlM5K1sz9g=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "689fed12a013f56d4c4d3f612489634267d86529", + "rev": "e8c38b73aeb218e27163376a2d617e61a2ad9b59", "type": "github" }, "original": { @@ -139,11 +139,11 @@ ] }, "locked": { - "lastModified": 1731378398, - "narHash": "sha256-a0QWaiX8+AJ9/XBLGMDy6c90GD7HzpxKVdlFwCke5Pw=", + "lastModified": 1731897198, + "narHash": "sha256-Ou7vLETSKwmE/HRQz4cImXXJBr/k9gp4J4z/PF8LzTE=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "0ae9fc2f2fe5361837d59c0bdebbda176427111e", + "rev": "0be641045af6d8666c11c2c40e45ffc9667839b5", "type": "github" }, "original": { diff --git a/misc/itests.sh b/misc/itests.sh index 50eb7f7fb..5676a9613 100755 --- a/misc/itests.sh +++ b/misc/itests.sh @@ -15,7 +15,8 @@ cleanup() { # Kill processes lncli --lnddir="$cdk_itests/lnd" --network=regtest stop - lightning-cli --regtest --lightning-dir="$cdk_itests/cln/" stop + lightning-cli --regtest --lightning-dir="$cdk_itests/one/" stop + lightning-cli --regtest --lightning-dir="$cdk_itests/two/" stop bitcoin-cli --datadir="$cdk_itests/bitcoin" -rpcuser=testuser -rpcpassword=testpass -rpcport=18443 stop # Remove the temporary directory