diff --git a/crates/cdk-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index 51cefca2f..de841aa04 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -3,34 +3,19 @@ #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] -use std::collections::HashMap; -use std::str::FromStr; use std::sync::Arc; use anyhow::Result; use axum::routing::{get, post}; use axum::Router; -use cdk::cdk_lightning::{self, MintLightning}; use cdk::mint::Mint; -use cdk::mint_url::MintUrl; -use cdk::types::LnKey; use router_handlers::*; mod router_handlers; /// Create mint [`Router`] with required endpoints for cashu mint -pub async fn create_mint_router( - mint_url: &str, - mint: Arc, - ln: HashMap + Send + Sync>>, - quote_ttl: u64, -) -> Result { - let state = MintState { - ln, - mint, - mint_url: MintUrl::from_str(mint_url)?, - quote_ttl, - }; +pub async fn create_mint_router(mint: Arc) -> Result { + let state = MintState { mint }; let v1_router = Router::new() .route("/keys", get(get_keys)) @@ -61,8 +46,5 @@ pub async fn create_mint_router( /// CDK Mint State #[derive(Clone)] pub struct MintState { - ln: HashMap + Send + Sync>>, mint: Arc, - mint_url: MintUrl, - quote_ttl: u64, } diff --git a/crates/cdk-axum/src/router_handlers.rs b/crates/cdk-axum/src/router_handlers.rs index c23203dba..4d348b1e8 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -1,21 +1,18 @@ -use anyhow::{bail, Result}; +use anyhow::Result; use axum::extract::{Json, Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use cdk::cdk_lightning::{to_unit, MintLightning, PayInvoiceResponse}; -use cdk::error::{Error, ErrorResponse}; -use cdk::mint::MeltQuote; +use cdk::error::ErrorResponse; use cdk::nuts::nut05::MeltBolt11Response; use cdk::nuts::{ - CheckStateRequest, CheckStateResponse, CurrencyUnit, Id, KeysResponse, KeysetResponse, - MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltQuoteState, - MintBolt11Request, MintBolt11Response, MintInfo, MintQuoteBolt11Request, - MintQuoteBolt11Response, PaymentMethod, RestoreRequest, RestoreResponse, SwapRequest, - SwapResponse, + CheckStateRequest, CheckStateResponse, Id, KeysResponse, KeysetResponse, MeltBolt11Request, + MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, MintBolt11Response, + MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, RestoreRequest, RestoreResponse, + SwapRequest, SwapResponse, }; use cdk::util::unix_time; -use crate::{LnKey, MintState}; +use crate::MintState; pub async fn get_keys(State(state): State) -> Result, Response> { let pubkeys = state.mint.pubkeys().await.map_err(|err| { @@ -51,52 +48,13 @@ pub async fn get_mint_bolt11_quote( State(state): State, Json(payload): Json, ) -> Result, Response> { - let ln = state - .ln - .get(&LnKey::new(payload.unit, PaymentMethod::Bolt11)) - .ok_or_else(|| { - tracing::info!("Bolt11 mint request for unsupported unit"); - - into_response(Error::UnitUnsupported) - })?; - - let quote_expiry = unix_time() + state.quote_ttl; - - if payload.description.is_some() && !ln.get_settings().invoice_description { - tracing::error!("Backend does not support invoice description"); - return Err(into_response(Error::InvoiceDescriptionUnsupported)); - } - - let create_invoice_response = ln - .create_invoice( - payload.amount, - &payload.unit, - payload.description.unwrap_or("".to_string()), - quote_expiry, - ) - .await - .map_err(|err| { - tracing::error!("Could not create invoice: {}", err); - into_response(Error::InvalidPaymentRequest) - })?; - let quote = state .mint - .new_mint_quote( - state.mint_url, - create_invoice_response.request.to_string(), - payload.unit, - payload.amount, - create_invoice_response.expiry.unwrap_or(0), - create_invoice_response.request_lookup_id, - ) + .get_mint_bolt11_quote(payload) .await - .map_err(|err| { - tracing::error!("Could not create new mint quote: {}", err); - into_response(err) - })?; + .map_err(into_response)?; - Ok(Json(quote.into())) + Ok(Json(quote)) } pub async fn get_check_mint_bolt11_quote( @@ -135,42 +93,13 @@ pub async fn get_melt_bolt11_quote( State(state): State, Json(payload): Json, ) -> Result, Response> { - let ln = state - .ln - .get(&LnKey::new(payload.unit, PaymentMethod::Bolt11)) - .ok_or_else(|| { - tracing::info!("Could not get ln backend for {}, bolt11 ", payload.unit); - - into_response(Error::UnitUnsupported) - })?; - - let payment_quote = ln.get_payment_quote(&payload).await.map_err(|err| { - tracing::error!( - "Could not get payment quote for mint quote, {} bolt11, {}", - payload.unit, - err - ); - - into_response(Error::UnitUnsupported) - })?; - let quote = state .mint - .new_melt_quote( - payload.request.to_string(), - payload.unit, - payment_quote.amount, - payment_quote.fee, - unix_time() + state.quote_ttl, - payment_quote.request_lookup_id, - ) + .get_melt_bolt11_quote(&payload) .await - .map_err(|err| { - tracing::error!("Could not create melt quote: {}", err); - into_response(err) - })?; + .map_err(into_response)?; - Ok(Json(quote.into())) + Ok(Json(quote)) } pub async fn get_check_melt_bolt11_quote( @@ -193,206 +122,13 @@ pub async fn post_melt_bolt11( State(state): State, Json(payload): Json, ) -> Result, Response> { - use std::sync::Arc; - async fn check_payment_state( - ln: Arc + Send + Sync>, - melt_quote: &MeltQuote, - ) -> 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") - } - } - } - - let quote = match state.mint.verify_melt_request(&payload).await { - Ok(quote) => quote, - Err(err) => { - tracing::debug!("Error attempting to verify melt quote: {}", err); - - if let Err(err) = state.mint.process_unpaid_melt(&payload).await { - tracing::error!( - "Could not reset melt quote {} state: {}", - payload.quote, - err - ); - } - return Err(into_response(err)); - } - }; - - let settled_internally_amount = - match state.mint.handle_internal_melt_mint("e, &payload).await { - Ok(amount) => amount, - Err(err) => { - tracing::error!("Attempting to settle internally failed"); - if let Err(err) = state.mint.process_unpaid_melt(&payload).await { - tracing::error!( - "Could not reset melt quote {} state: {}", - payload.quote, - err - ); - } - return Err(into_response(err)); - } - }; - - let (preimage, amount_spent_quote_unit) = match settled_internally_amount { - Some(amount_spent) => (None, amount_spent), - None => { - // If the quote unit is SAT or MSAT we can check that the expected fees are - // provided. We also check if the quote is less then the invoice - // amount in the case that it is a mmp However, if the quote is not - // of a bitcoin unit we cannot do these checks as the mint - // is unaware of a conversion rate. In this case it is assumed that the quote is - // correct and the mint should pay the full invoice amount if inputs - // > `then quote.amount` are included. This is checked in the - // `verify_melt` method. - let partial_amount = match quote.unit { - CurrencyUnit::Sat | CurrencyUnit::Msat => { - match state - .mint - .check_melt_expected_ln_fees("e, &payload) - .await - { - Ok(amount) => amount, - Err(err) => { - tracing::error!("Fee is not expected: {}", err); - if let Err(err) = state.mint.process_unpaid_melt(&payload).await { - tracing::error!("Could not reset melt quote state: {}", err); - } - return Err(into_response(Error::Internal)); - } - } - } - _ => None, - }; - - let ln = match state.ln.get(&LnKey::new(quote.unit, PaymentMethod::Bolt11)) { - Some(ln) => ln, - None => { - tracing::info!("Could not get ln backend for {}, bolt11 ", quote.unit); - if let Err(err) = state.mint.process_unpaid_melt(&payload).await { - tracing::error!("Could not reset melt quote state: {}", err); - } - - return Err(into_response(Error::UnitUnsupported)); - } - }; - - let pre = match ln - .pay_invoice(quote.clone(), partial_amount, Some(quote.fee_reserve)) - .await - { - Ok(pay) - if pay.status == MeltQuoteState::Unknown - || pay.status == MeltQuoteState::Failed => - { - let check_response = check_payment_state(Arc::clone(ln), "e) - .await - .map_err(|_| into_response(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()); - - return Err(into_response(Error::Internal)); - } - - check_response - } - Ok(pay) => pay, - Err(err) => { - // If the error is that the invoice was already paid we do not want to hold - // hold the proofs as pending to we reset them and return an error. - if matches!(err, cdk::cdk_lightning::Error::InvoiceAlreadyPaid) { - tracing::debug!("Invoice already paid, resetting melt quote"); - if let Err(err) = state.mint.process_unpaid_melt(&payload).await { - tracing::error!("Could not reset melt quote state: {}", err); - } - return Err(into_response(Error::RequestAlreadyPaid)); - } - - tracing::error!("Error returned attempting to pay: {} {}", quote.id, err); - - let check_response = check_payment_state(Arc::clone(ln), "e) - .await - .map_err(|_| into_response(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()); - - return Err(into_response(Error::Internal)); - } - check_response - } - }; - - match pre.status { - MeltQuoteState::Paid => (), - MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => { - tracing::info!("Lightning payment for quote {} failed.", payload.quote); - if let Err(err) = state.mint.process_unpaid_melt(&payload).await { - tracing::error!("Could not reset melt quote state: {}", err); - } - return Err(into_response(Error::PaymentFailed)); - } - MeltQuoteState::Pending => { - tracing::warn!( - "LN payment pending, proofs are stuck as pending for quote: {}", - payload.quote - ); - return Err(into_response(Error::PendingQuote)); - } - } - - // Convert from unit of backend to quote unit - // Note: this should never fail since these conversions happen earlier and would fail there. - // Since it will not fail and even if it does the ln payment has already been paid, proofs should still be burned - let amount_spent = to_unit(pre.total_spent, &pre.unit, "e.unit).unwrap_or_default(); - - let payment_lookup_id = pre.payment_lookup_id; - - if payment_lookup_id != quote.request_lookup_id { - tracing::info!( - "Payment lookup id changed post payment from {} to {}", - quote.request_lookup_id, - payment_lookup_id - ); - - let mut melt_quote = quote; - melt_quote.request_lookup_id = payment_lookup_id; - - if let Err(err) = state.mint.localstore.add_melt_quote(melt_quote).await { - tracing::warn!("Could not update payment lookup id: {}", err); - } - } - - (pre.payment_preimage, amount_spent) - } - }; - - // If we made it here the payment has been made. - // We process the melt burning the inputs and returning change let res = state .mint - .process_melt_request(&payload, preimage, amount_spent_quote_unit) + .melt_bolt11(&payload) .await - .map_err(|err| { - tracing::error!("Could not process melt request: {}", err); - into_response(err) - })?; + .map_err(into_response)?; - Ok(Json(res.into())) + Ok(Json(res)) } pub async fn post_check( diff --git a/crates/cdk-integration-tests/src/init_fake_wallet.rs b/crates/cdk-integration-tests/src/init_fake_wallet.rs index e3444084e..5ad1a1cb0 100644 --- a/crates/cdk-integration-tests/src/init_fake_wallet.rs +++ b/crates/cdk-integration-tests/src/init_fake_wallet.rs @@ -13,11 +13,11 @@ use cdk::{ types::LnKey, }; use cdk_fake_wallet::FakeWallet; -use futures::StreamExt; +use tokio::sync::Notify; use tower_http::cors::CorsLayer; use tracing_subscriber::EnvFilter; -use crate::{handle_paid_invoice, init_regtest::create_mint}; +use crate::init_regtest::create_mint; pub async fn start_fake_mint(addr: &str, port: u16, database: D) -> Result<()> where @@ -36,7 +36,10 @@ where // Parse input tracing_subscriber::fmt().with_env_filter(env_filter).init(); - let mint = create_mint(database).await?; + let mut ln_backends: HashMap< + LnKey, + Arc + Sync + Send>, + > = HashMap::new(); let fee_reserve = FeeReserve { min_fee_reserve: 1.into(), @@ -52,28 +55,18 @@ where 0, ); - let mut ln_backends: HashMap< - LnKey, - Arc + Sync + Send>, - > = HashMap::new(); - ln_backends.insert( LnKey::new(CurrencyUnit::Sat, cdk::nuts::PaymentMethod::Bolt11), Arc::new(fake_wallet), ); - let quote_ttl = 100000; + let mint = create_mint(database, ln_backends.clone()).await?; let mint_arc = Arc::new(mint); - let v1_service = cdk_axum::create_mint_router( - &format!("http://{}:{}", addr, port), - Arc::clone(&mint_arc), - ln_backends.clone(), - quote_ttl, - ) - .await - .unwrap(); + let v1_service = cdk_axum::create_mint_router(Arc::clone(&mint_arc)) + .await + .unwrap(); let mint_service = Router::new() .merge(v1_service) @@ -81,28 +74,13 @@ where let mint = Arc::clone(&mint_arc); - for wallet in ln_backends.values() { - let wallet_clone = Arc::clone(wallet); - let mint = Arc::clone(&mint); - tokio::spawn(async move { - match wallet_clone.wait_any_invoice().await { - Ok(mut stream) => { - while let Some(request_lookup_id) = stream.next().await { - if let Err(err) = - handle_paid_invoice(Arc::clone(&mint), &request_lookup_id).await - { - // nosemgrep: direct-panic - panic!("{:?}", err); - } - } - } - Err(err) => { - // nosemgrep: direct-panic - panic!("Could not get invoice stream: {}", err); - } - } - }); - } + let shutdown = Arc::new(Notify::new()); + + tokio::spawn({ + let shutdown = Arc::clone(&shutdown); + async move { mint.wait_for_paid_invoices(shutdown).await } + }); + println!("Staring Axum server"); axum::Server::bind(&format!("{}:{}", addr, port).as_str().parse().unwrap()) .serve(mint_service.into_make_service()) diff --git a/crates/cdk-integration-tests/src/init_regtest.rs b/crates/cdk-integration-tests/src/init_regtest.rs index 3c8dac65b..cc46d4b11 100644 --- a/crates/cdk-integration-tests/src/init_regtest.rs +++ b/crates/cdk-integration-tests/src/init_regtest.rs @@ -8,14 +8,14 @@ use cdk::{ cdk_lightning::MintLightning, mint::{FeeReserve, Mint}, nuts::{CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings}, - types::LnKey, + types::{LnKey, QuoteTTL}, }; use cdk_cln::Cln as CdkCln; -use futures::StreamExt; use ln_regtest_rs::{ bitcoin_client::BitcoinClient, bitcoind::Bitcoind, cln::Clnd, cln_client::ClnClient, lnd::Lnd, lnd_client::LndClient, }; +use tokio::sync::Notify; use tower_http::cors::CorsLayer; use tracing_subscriber::EnvFilter; @@ -140,7 +140,13 @@ pub async fn create_cln_backend(cln_client: &ClnClient) -> Result { .await?) } -pub async fn create_mint(database: D) -> Result +pub async fn create_mint( + database: D, + ln_backends: HashMap< + LnKey, + Arc + Sync + Send>, + >, +) -> Result where D: MintDatabase + Send + Sync + 'static, { @@ -160,11 +166,15 @@ where let mut supported_units: HashMap = HashMap::new(); supported_units.insert(CurrencyUnit::Sat, (0, 32)); + let quote_ttl = QuoteTTL::new(10000, 10000); + let mint = Mint::new( &get_mint_url(), &mnemonic.to_seed_normalized(""), mint_info, + quote_ttl, Arc::new(database), + ln_backends, supported_units, ) .await?; @@ -189,7 +199,6 @@ where // Parse input tracing_subscriber::fmt().with_env_filter(env_filter).init(); - let mint = create_mint(database).await?; let cln_client = init_cln_client().await?; let cln_backend = create_cln_backend(&cln_client).await?; @@ -204,18 +213,13 @@ where Arc::new(cln_backend), ); - let quote_ttl = 100000; + let mint = create_mint(database, ln_backends.clone()).await?; let mint_arc = Arc::new(mint); - let v1_service = cdk_axum::create_mint_router( - &get_mint_url(), - Arc::clone(&mint_arc), - ln_backends.clone(), - quote_ttl, - ) - .await - .unwrap(); + let v1_service = cdk_axum::create_mint_router(Arc::clone(&mint_arc)) + .await + .unwrap(); let mint_service = Router::new() .merge(v1_service) @@ -223,28 +227,13 @@ where let mint = Arc::clone(&mint_arc); - for wallet in ln_backends.values() { - let wallet_clone = Arc::clone(wallet); - let mint = Arc::clone(&mint); - tokio::spawn(async move { - match wallet_clone.wait_any_invoice().await { - Ok(mut stream) => { - while let Some(request_lookup_id) = stream.next().await { - if let Err(err) = - handle_paid_invoice(Arc::clone(&mint), &request_lookup_id).await - { - // nosemgrep: direct-panic - panic!("{:?}", err); - } - } - } - Err(err) => { - // nosemgrep: direct-panic - panic!("Could not get invoice stream: {}", err); - } - } - }); - } + let shutdown = Arc::new(Notify::new()); + + tokio::spawn({ + let shutdown = Arc::clone(&shutdown); + async move { mint.wait_for_paid_invoices(shutdown).await } + }); + println!("Staring Axum server"); axum::Server::bind(&format!("{}:{}", addr, port).as_str().parse().unwrap()) .serve(mint_service.into_make_service()) @@ -253,25 +242,6 @@ where Ok(()) } -/// Update mint quote when called for a paid invoice -async fn handle_paid_invoice(mint: Arc, request_lookup_id: &str) -> Result<()> { - println!("Invoice with lookup id paid: {}", request_lookup_id); - if let Ok(Some(mint_quote)) = mint - .localstore - .get_mint_quote_by_request_lookup_id(request_lookup_id) - .await - { - println!( - "Quote {} paid by lookup id {}", - mint_quote.id, request_lookup_id - ); - mint.localstore - .update_mint_quote_state(&mint_quote.id, cdk::nuts::MintQuoteState::Paid) - .await?; - } - Ok(()) -} - pub async fn fund_ln( bitcoin_client: &BitcoinClient, cln_client: &ClnClient, diff --git a/crates/cdk-integration-tests/src/lib.rs b/crates/cdk-integration-tests/src/lib.rs index e14d0a9ff..ff2d4dfc2 100644 --- a/crates/cdk-integration-tests/src/lib.rs +++ b/crates/cdk-integration-tests/src/lib.rs @@ -14,12 +14,12 @@ use cdk::nuts::{ CurrencyUnit, Id, KeySet, MeltMethodSettings, MintInfo, MintMethodSettings, MintQuoteState, Nuts, PaymentMethod, PreMintSecrets, Proofs, State, }; -use cdk::types::LnKey; +use cdk::types::{LnKey, QuoteTTL}; use cdk::wallet::client::HttpClient; use cdk::{Mint, Wallet}; use cdk_fake_wallet::FakeWallet; -use futures::StreamExt; use init_regtest::{get_mint_addr, get_mint_port, get_mint_url}; +use tokio::sync::Notify; use tokio::time::sleep; use tower_http::cors::CorsLayer; @@ -72,26 +72,22 @@ pub async fn start_mint( let mnemonic = Mnemonic::generate(12)?; + let quote_ttl = QuoteTTL::new(10000, 10000); + let mint = Mint::new( &get_mint_url(), &mnemonic.to_seed_normalized(""), mint_info, + quote_ttl, Arc::new(MintMemoryDatabase::default()), + ln_backends.clone(), supported_units, ) .await?; - let quote_ttl = 100000; - let mint_arc = Arc::new(mint); - let v1_service = cdk_axum::create_mint_router( - &get_mint_url(), - Arc::clone(&mint_arc), - ln_backends.clone(), - quote_ttl, - ) - .await?; + let v1_service = cdk_axum::create_mint_router(Arc::clone(&mint_arc)).await?; let mint_service = Router::new() .merge(v1_service) @@ -99,28 +95,12 @@ pub async fn start_mint( let mint = Arc::clone(&mint_arc); - for wallet in ln_backends.values() { - let wallet_clone = Arc::clone(wallet); - let mint = Arc::clone(&mint); - tokio::spawn(async move { - match wallet_clone.wait_any_invoice().await { - Ok(mut stream) => { - while let Some(request_lookup_id) = stream.next().await { - if let Err(err) = - handle_paid_invoice(Arc::clone(&mint), &request_lookup_id).await - { - // nosemgrep: direct-panic - panic!("{:?}", err); - } - } - } - Err(err) => { - // nosemgrep: direct-panic - panic!("Could not get invoice stream: {}", err); - } - } - }); - } + let shutdown = Arc::new(Notify::new()); + + tokio::spawn({ + let shutdown = Arc::clone(&shutdown); + async move { mint.wait_for_paid_invoices(shutdown).await } + }); axum::Server::bind( &format!("{}:{}", get_mint_addr(), get_mint_port()) @@ -133,25 +113,6 @@ pub async fn start_mint( Ok(()) } -/// Update mint quote when called for a paid invoice -async fn handle_paid_invoice(mint: Arc, request_lookup_id: &str) -> Result<()> { - println!("Invoice with lookup id paid: {}", request_lookup_id); - if let Ok(Some(mint_quote)) = mint - .localstore - .get_mint_quote_by_request_lookup_id(request_lookup_id) - .await - { - println!( - "Quote {} paid by lookup id {}", - mint_quote.id, request_lookup_id - ); - mint.localstore - .update_mint_quote_state(&mint_quote.id, cdk::nuts::MintQuoteState::Paid) - .await?; - } - Ok(()) -} - pub async fn wallet_mint( wallet: Arc, amount: Amount, diff --git a/crates/cdk-integration-tests/tests/mint.rs b/crates/cdk-integration-tests/tests/mint.rs index a0beed994..c86e2dd31 100644 --- a/crates/cdk-integration-tests/tests/mint.rs +++ b/crates/cdk-integration-tests/tests/mint.rs @@ -5,10 +5,12 @@ use bip39::Mnemonic; use cdk::amount::{Amount, SplitTarget}; use cdk::cdk_database::mint_memory::MintMemoryDatabase; use cdk::dhke::construct_proofs; +use cdk::mint::MintQuote; use cdk::nuts::{ CurrencyUnit, Id, MintBolt11Request, MintInfo, Nuts, PreMintSecrets, Proofs, SecretKey, SpendingConditions, SwapRequest, }; +use cdk::types::QuoteTTL; use cdk::util::unix_time; use cdk::Mint; use std::collections::HashMap; @@ -36,11 +38,15 @@ async fn new_mint(fee: u64) -> Mint { let mnemonic = Mnemonic::generate(12).unwrap(); + let quote_ttl = QuoteTTL::new(10000, 10000); + Mint::new( MINT_URL, &mnemonic.to_seed_normalized(""), mint_info, + quote_ttl, Arc::new(MintMemoryDatabase::default()), + HashMap::new(), supported_units, ) .await @@ -59,16 +65,16 @@ async fn mint_proofs( ) -> Result { let request_lookup = uuid::Uuid::new_v4().to_string(); - let mint_quote = mint - .new_mint_quote( - MINT_URL.parse()?, - "".to_string(), - CurrencyUnit::Sat, - amount, - unix_time() + 36000, - request_lookup.to_string(), - ) - .await?; + let quote = MintQuote::new( + mint.mint_url.clone(), + "".to_string(), + CurrencyUnit::Sat, + amount, + unix_time() + 36000, + request_lookup.to_string(), + ); + + mint.localstore.add_mint_quote(quote.clone()).await?; mint.pay_mint_quote_for_request_id(&request_lookup).await?; let keyset_id = Id::from(&keys); @@ -76,7 +82,7 @@ async fn mint_proofs( let premint = PreMintSecrets::random(keyset_id, amount, split_target)?; let mint_request = MintBolt11Request { - quote: mint_quote.id, + quote: quote.id, outputs: premint.blinded_messages(), }; diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index c317c3ea5..b86a41e3b 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -20,7 +20,7 @@ use cdk::nuts::{ nut04, nut05, ContactInfo, CurrencyUnit, MeltMethodSettings, MeltQuoteState, MintInfo, MintMethodSettings, MintVersion, MppMethodSettings, Nuts, PaymentMethod, }; -use cdk::types::LnKey; +use cdk::types::{LnKey, QuoteTTL}; use cdk_cln::Cln; use cdk_fake_wallet::FakeWallet; use cdk_lnbits::LNbits; @@ -32,8 +32,7 @@ use cdk_strike::Strike; use clap::Parser; use cli::CLIArgs; use config::{DatabaseEngine, LnBackend}; -use futures::StreamExt; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, Notify}; use tower_http::cors::CorsLayer; use tracing_subscriber::EnvFilter; use url::Url; @@ -425,11 +424,15 @@ async fn main() -> anyhow::Result<()> { let mnemonic = Mnemonic::from_str(&settings.info.mnemonic)?; + let quote_ttl = QuoteTTL::new(10000, 10000); + let mint = Mint::new( &settings.info.url, &mnemonic.to_seed_normalized(""), mint_info, + quote_ttl, localstore, + ln_backends.clone(), supported_units, ) .await?; @@ -449,17 +452,14 @@ async fn main() -> anyhow::Result<()> { // Pending melt quotes where the paynment has **failed** inputs are reset to unspent check_pending_melt_quotes(Arc::clone(&mint), &ln_backends).await?; - let mint_url = settings.info.url; let listen_addr = settings.info.listen_host; let listen_port = settings.info.listen_port; - let quote_ttl = settings + let _quote_ttl = settings .info .seconds_quote_is_valid_for .unwrap_or(DEFAULT_QUOTE_TTL_SECS); - let v1_service = - cdk_axum::create_mint_router(&mint_url, Arc::clone(&mint), ln_backends.clone(), quote_ttl) - .await?; + let v1_service = cdk_axum::create_mint_router(Arc::clone(&mint)).await?; let mut mint_service = Router::new() .merge(v1_service) @@ -469,45 +469,35 @@ async fn main() -> anyhow::Result<()> { mint_service = mint_service.merge(router); } - // Spawn task to wait for invoces to be paid and update mint quotes - for (_, ln) in ln_backends { - let mint = Arc::clone(&mint); - tokio::spawn(async move { - loop { - match ln.wait_any_invoice().await { - Ok(mut stream) => { - while let Some(request_lookup_id) = stream.next().await { - if let Err(err) = - handle_paid_invoice(mint.clone(), &request_lookup_id).await - { - tracing::warn!("{:?}", err); - } - } - } - Err(err) => { - tracing::warn!("Could not get invoice stream: {}", err); - } - } - } - }); - } + let shutdown = Arc::new(Notify::new()); + + tokio::spawn({ + let shutdown = Arc::clone(&shutdown); + async move { mint.wait_for_paid_invoices(shutdown).await } + }); - axum::Server::bind( + let axum_result = axum::Server::bind( &format!("{}:{}", listen_addr, listen_port) .as_str() .parse()?, ) .serve(mint_service.into_make_service()) - .await?; + .await; - Ok(()) -} + shutdown.notify_waiters(); + + match axum_result { + Ok(_) => { + tracing::info!("Axum server stopped with okay status"); + } + Err(err) => { + tracing::warn!("Axum server stopped with error"); + tracing::error!("{}", err); + + bail!("Axum exited with error") + } + } -/// Update mint quote when called for a paid invoice -async fn handle_paid_invoice(mint: Arc, request_lookup_id: &str) -> Result<()> { - tracing::debug!("Invoice with lookup id paid: {}", request_lookup_id); - mint.pay_mint_quote_for_request_id(request_lookup_id) - .await?; Ok(()) } diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index dd0347f05..6df40fdb4 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -46,7 +46,7 @@ sync_wrapper = "0.1.2" bech32 = "0.9.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tokio = { version = "1", features = [ +tokio = { version = "1.21", features = [ "rt-multi-thread", "time", "macros", @@ -54,7 +54,7 @@ tokio = { version = "1", features = [ ] } [target.'cfg(target_arch = "wasm32")'.dependencies] -tokio = { version = "1", features = ["rt", "macros", "sync", "time"] } +tokio = { version = "1.21", features = ["rt", "macros", "sync", "time"] } getrandom = { version = "0.2", features = ["js"] } instant = { version = "0.1", features = ["wasm-bindgen", "inaccurate"] } diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index 2fd52d20b..780b57f4c 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -4,23 +4,27 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::sync::Arc; +use anyhow::bail; use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; use bitcoin::secp256k1::{self, Secp256k1}; +use futures::StreamExt; use lightning_invoice::Bolt11Invoice; use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; +use tokio::sync::{Notify, RwLock}; +use tokio::task::JoinSet; use tracing::instrument; -use self::nut05::QuoteState; +use self::nut05::{MeltBolt11Response, QuoteState}; use self::nut11::EnforceSigFlag; use crate::cdk_database::{self, MintDatabase}; -use crate::cdk_lightning::to_unit; +use crate::cdk_lightning::{self, to_unit, MintLightning, PayInvoiceResponse}; use crate::dhke::{hash_to_curve, sign_message, verify_message}; use crate::error::Error; use crate::fees::calculate_fee; use crate::mint_url::MintUrl; use crate::nuts::nut11::enforce_sig_flag; use crate::nuts::*; +use crate::types::{LnKey, QuoteTTL}; use crate::util::unix_time; use crate::Amount; @@ -35,8 +39,12 @@ pub struct Mint { pub mint_url: MintUrl, /// Mint Info pub mint_info: MintInfo, + /// Quotes ttl + pub quote_ttl: QuoteTTL, /// Mint Storage backend pub localstore: Arc + Send + Sync>, + /// Ln backends for mint + pub ln: HashMap + Send + Sync>>, /// Active Mint Keysets keysets: Arc>>, secp_ctx: Secp256k1, @@ -49,7 +57,9 @@ impl Mint { mint_url: &str, seed: &[u8], mint_info: MintInfo, + quote_ttl: QuoteTTL, localstore: Arc + Send + Sync>, + ln: HashMap + Send + Sync>>, // Hashmap where the key is the unit and value is (input fee ppk, max_order) supported_units: HashMap, ) -> Result { @@ -160,9 +170,11 @@ impl Mint { mint_url: MintUrl::from_str(mint_url)?, keysets: Arc::new(RwLock::new(active_keysets)), secp_ctx, + quote_ttl, xpriv, localstore, mint_info, + ln, }) } @@ -190,17 +202,12 @@ impl Mint { &self.mint_info } - /// New mint quote - #[instrument(skip_all)] - pub async fn new_mint_quote( + /// Checks that minting is enabled, request is supported unit and within range + fn check_mint_request_acceptable( &self, - mint_url: MintUrl, - request: String, - unit: CurrencyUnit, amount: Amount, - expiry: u64, - ln_lookup: String, - ) -> Result { + unit: CurrencyUnit, + ) -> Result<(), Error> { let nut04 = &self.mint_info.nuts.nut04; if nut04.disabled { @@ -236,18 +243,72 @@ impl Mint { } } - let quote = MintQuote::new(mint_url, request, unit, amount, expiry, ln_lookup.clone()); + Ok(()) + } + + /// Create new mint bolt11 quote + #[instrument(skip_all)] + pub async fn get_mint_bolt11_quote( + &self, + mint_quote_request: MintQuoteBolt11Request, + ) -> Result { + let MintQuoteBolt11Request { + amount, + unit, + description, + } = mint_quote_request; + + self.check_mint_request_acceptable(amount, unit)?; + + let ln = self + .ln + .get(&LnKey::new(unit, PaymentMethod::Bolt11)) + .ok_or_else(|| { + tracing::info!("Bolt11 mint request for unsupported unit"); + + Error::UnitUnsupported + })?; + + let quote_expiry = unix_time() + self.quote_ttl.mint_ttl; + + if description.is_some() && !ln.get_settings().invoice_description { + tracing::error!("Backend does not support invoice description"); + return Err(Error::InvoiceDescriptionUnsupported); + } + + let create_invoice_response = ln + .create_invoice( + amount, + &unit, + description.unwrap_or("".to_string()), + quote_expiry, + ) + .await + .map_err(|err| { + tracing::error!("Could not create invoice: {}", err); + Error::InvalidPaymentRequest + })?; + + let quote = MintQuote::new( + self.mint_url.clone(), + create_invoice_response.request.to_string(), + unit, + amount, + create_invoice_response.expiry.unwrap_or(0), + create_invoice_response.request_lookup_id.clone(), + ); + tracing::debug!( "New mint quote {} for {} {} with request id {}", quote.id, amount, unit, - &ln_lookup + create_invoice_response.request_lookup_id, ); self.localstore.add_mint_quote(quote.clone()).await?; - Ok(quote) + Ok(quote.into()) } /// Check mint quote @@ -345,24 +406,72 @@ impl Mint { Ok(()) } - /// New melt quote - #[instrument(skip_all)] - pub async fn new_melt_quote( + /// Wait for any invoice to be paid + /// For each backend starts a task that waits for any invoice 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_invoices(&self, shutdown: Arc) -> Result<(), Error> { + let mint_arc = Arc::new(self.clone()); + + let mut join_set = JoinSet::new(); + + for (key, ln) in self.ln.iter() { + let mint = Arc::clone(&mint_arc); + let ln = Arc::clone(ln); + let shutdown = Arc::clone(&shutdown); + let key = *key; + join_set.spawn(async move { + loop { + tokio::select! { + _ = shutdown.notified() => { + tracing::info!("Shutdown signal received, stopping task for {:?}", key); + break; + } + 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 { + 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(()) + } + + fn check_melt_request_acceptable( &self, - request: String, - unit: CurrencyUnit, amount: Amount, - fee_reserve: Amount, - expiry: u64, - request_lookup_id: String, - ) -> Result { + unit: CurrencyUnit, + method: PaymentMethod, + ) -> Result<(), Error> { let nut05 = &self.mint_info.nuts.nut05; if nut05.disabled { return Err(Error::MeltingDisabled); } - match nut05.get_settings(&unit, &PaymentMethod::Bolt11) { + match nut05.get_settings(&unit, &method) { Some(settings) => { if settings .max_amount @@ -391,13 +500,61 @@ impl Mint { } } - let quote = MeltQuote::new( + Ok(()) + } + + /// Get melt bolt11 quote + #[instrument(skip_all)] + pub async fn get_melt_bolt11_quote( + &self, + melt_request: &MeltQuoteBolt11Request, + ) -> Result { + let MeltQuoteBolt11Request { request, unit, - amount, - fee_reserve, - expiry, - request_lookup_id.clone(), + options: _, + } = melt_request; + + let amount = match melt_request.options { + Some(mpp_amount) => mpp_amount.amount, + None => { + let amount_msat = request + .amount_milli_satoshis() + .ok_or(Error::InvoiceAmountUndefined)?; + + to_unit(amount_msat, &CurrencyUnit::Msat, unit) + .map_err(|_err| Error::UnsupportedUnit)? + } + }; + + self.check_melt_request_acceptable(amount, *unit, PaymentMethod::Bolt11)?; + + let ln = self + .ln + .get(&LnKey::new(*unit, PaymentMethod::Bolt11)) + .ok_or_else(|| { + tracing::info!("Could not get ln backend for {}, bolt11 ", unit); + + Error::UnitUnsupported + })?; + + let payment_quote = ln.get_payment_quote(melt_request).await.map_err(|err| { + tracing::error!( + "Could not get payment quote for mint quote, {} bolt11, {}", + unit, + err + ); + + Error::UnitUnsupported + })?; + + let quote = MeltQuote::new( + request.to_string(), + *unit, + payment_quote.amount, + payment_quote.fee, + unix_time() + self.quote_ttl.melt_ttl, + payment_quote.request_lookup_id.clone(), ); tracing::debug!( @@ -405,12 +562,12 @@ impl Mint { quote.id, amount, unit, - request_lookup_id + payment_quote.request_lookup_id ); self.localstore.add_melt_quote(quote.clone()).await?; - Ok(quote) + Ok(quote.into()) } /// Fee required for proof set @@ -1257,6 +1414,212 @@ 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") + } + } + } + + let quote = match self.verify_melt_request(melt_request).await { + Ok(quote) => quote, + Err(err) => { + tracing::debug!("Error attempting to verify melt quote: {}", err); + + if let Err(err) = self.process_unpaid_melt(melt_request).await { + tracing::error!( + "Could not reset melt quote {} state: {}", + melt_request.quote, + err + ); + } + return Err(err); + } + }; + + let settled_internally_amount = + match self.handle_internal_melt_mint("e, melt_request).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, + err + ); + } + return Err(err); + } + }; + + let (preimage, amount_spent_quote_unit) = match settled_internally_amount { + Some(amount_spent) => (None, amount_spent), + None => { + // If the quote unit is SAT or MSAT we can check that the expected fees are + // provided. We also check if the quote is less then the invoice + // amount in the case that it is a mmp However, if the quote is not + // of a bitcoin unit we cannot do these checks as the mint + // is unaware of a conversion rate. In this case it is assumed that the quote is + // correct and the mint should pay the full invoice amount if inputs + // > `then quote.amount` are included. This is checked in the + // `verify_melt` method. + let partial_amount = match quote.unit { + CurrencyUnit::Sat | CurrencyUnit::Msat => { + match self.check_melt_expected_ln_fees("e, melt_request).await { + Ok(amount) => amount, + Err(err) => { + tracing::error!("Fee is not expected: {}", err); + if let Err(err) = self.process_unpaid_melt(melt_request).await { + tracing::error!("Could not reset melt quote state: {}", err); + } + return Err(Error::Internal); + } + } + } + _ => None, + }; + let ln = match self.ln.get(&LnKey::new(quote.unit, PaymentMethod::Bolt11)) { + 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); + } + }; + + let pre = match ln + .pay_invoice(quote.clone(), partial_amount, Some(quote.fee_reserve)) + .await + { + 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)?; + + 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()); + + return Err(Error::Internal); + } + + check_response + } + Ok(pay) => pay, + Err(err) => { + // If the error is that the invoice was already paid we do not want to hold + // hold the proofs as pending to we reset them and return an error. + if matches!(err, cdk_lightning::Error::InvoiceAlreadyPaid) { + tracing::debug!("Invoice already paid, resetting melt quote"); + if let Err(err) = self.process_unpaid_melt(melt_request).await { + tracing::error!("Could not reset melt quote state: {}", err); + } + return Err(Error::RequestAlreadyPaid); + } + + 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)?; + // 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()); + + return Err(Error::Internal); + } + check_response + } + }; + + match pre.status { + MeltQuoteState::Paid => (), + MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => { + tracing::info!( + "Lightning payment for quote {} failed.", + melt_request.quote + ); + if let Err(err) = self.process_unpaid_melt(melt_request).await { + tracing::error!("Could not reset melt quote state: {}", err); + } + return Err(Error::PaymentFailed); + } + MeltQuoteState::Pending => { + tracing::warn!( + "LN payment pending, proofs are stuck as pending for quote: {}", + melt_request.quote + ); + return Err(Error::PendingQuote); + } + } + + // Convert from unit of backend to quote unit + // Note: this should never fail since these conversions happen earlier and would fail there. + // Since it will not fail and even if it does the ln payment has already been paid, proofs should still be burned + let amount_spent = + to_unit(pre.total_spent, &pre.unit, "e.unit).unwrap_or_default(); + + let payment_lookup_id = pre.payment_lookup_id; + + if payment_lookup_id != quote.request_lookup_id { + tracing::info!( + "Payment lookup id changed post payment from {} to {}", + quote.request_lookup_id, + payment_lookup_id + ); + + let mut melt_quote = quote; + melt_quote.request_lookup_id = payment_lookup_id; + + if let Err(err) = self.localstore.add_melt_quote(melt_quote).await { + tracing::warn!("Could not update payment lookup id: {}", err); + } + } + + (pre.payment_preimage, amount_spent) + } + }; + + // 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) + .await + .map_err(|err| { + tracing::error!("Could not process melt request: {}", err); + err + })?; + + Ok(res.into()) + } + /// Process melt request marking [`Proofs`] as spent /// The melt request must be verifyed using [`Self::verify_melt_request`] /// before calling [`Self::process_melt_request`] @@ -1680,6 +2043,7 @@ mod tests { mint_info: MintInfo, supported_units: HashMap, melt_requests: Vec<(MeltBolt11Request, LnKey)>, + quote_ttl: QuoteTTL, } async fn create_mint(config: MintConfig<'_>) -> Result { @@ -1703,7 +2067,9 @@ mod tests { config.mint_url, config.seed, config.mint_info, + config.quote_ttl, localstore, + HashMap::new(), config.supported_units, ) .await diff --git a/crates/cdk/src/types.rs b/crates/cdk/src/types.rs index ad75a7529..60c360fe8 100644 --- a/crates/cdk/src/types.rs +++ b/crates/cdk/src/types.rs @@ -155,6 +155,22 @@ impl LnKey { } } +/// Secs wuotes are valid +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct QuoteTTL { + /// Seconds mint quote is valid + pub mint_ttl: u64, + /// Seconds melt quote is valid + pub melt_ttl: u64, +} + +impl QuoteTTL { + /// Create new [`QuoteTTL`] + pub fn new(mint_ttl: u64, melt_ttl: u64) -> QuoteTTL { + Self { mint_ttl, melt_ttl } + } +} + #[cfg(test)] mod tests { use std::str::FromStr;