diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eacede532..6394a6abf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: run: nix develop -i -L .#stable --command cargo test ${{ matrix.build-args }} itest: - name: "Integration tests" + name: "Integration regtest tests" runs-on: ubuntu-latest strategy: matrix: @@ -102,6 +102,35 @@ jobs: run: nix develop -i -L .#stable --command cargo clippy ${{ matrix.build-args }} -- -D warnings - name: Test run: nix develop -i -L .#stable --command just itest ${{ matrix.database }} + + fake-wallet-itest: + name: "Integration fake wallet tests" + runs-on: ubuntu-latest + strategy: + matrix: + build-args: + [ + -p cdk-integration-tests, + ] + database: + [ + REDB, + SQLITE, + MEMORY + ] + steps: + - name: checkout + uses: actions/checkout@v4 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v11 + - name: Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@v6 + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + - name: Clippy + run: nix develop -i -L .#stable --command cargo clippy ${{ matrix.build-args }} -- -D warnings + - name: Test fake mint + run: nix develop -i -L .#stable --command just fake-mint-itest ${{ matrix.database }} msrv-build: name: "MSRV build" diff --git a/crates/cdk-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index fea7cf32c..51cefca2f 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -13,7 +13,7 @@ use axum::Router; use cdk::cdk_lightning::{self, MintLightning}; use cdk::mint::Mint; use cdk::mint_url::MintUrl; -use cdk::nuts::{CurrencyUnit, PaymentMethod}; +use cdk::types::LnKey; use router_handlers::*; mod router_handlers; @@ -66,20 +66,3 @@ pub struct MintState { mint_url: MintUrl, quote_ttl: u64, } - -/// Key used in hashmap of ln backends to identify what unit and payment method -/// it is for -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub struct LnKey { - /// Unit of Payment backend - pub unit: CurrencyUnit, - /// Method of payment backend - pub method: PaymentMethod, -} - -impl LnKey { - /// Create new [`LnKey`] - pub fn new(unit: CurrencyUnit, method: PaymentMethod) -> Self { - Self { unit, method } - } -} diff --git a/crates/cdk-axum/src/router_handlers.rs b/crates/cdk-axum/src/router_handlers.rs index fd6788ea0..c23203dba 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -1,22 +1,19 @@ -use std::str::FromStr; - -use anyhow::Result; +use anyhow::{bail, Result}; use axum::extract::{Json, Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use cdk::amount::Amount; -use cdk::cdk_lightning::to_unit; +use cdk::cdk_lightning::{to_unit, MintLightning, PayInvoiceResponse}; use cdk::error::{Error, ErrorResponse}; +use cdk::mint::MeltQuote; use cdk::nuts::nut05::MeltBolt11Response; use cdk::nuts::{ CheckStateRequest, CheckStateResponse, CurrencyUnit, Id, KeysResponse, KeysetResponse, MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltQuoteState, MintBolt11Request, MintBolt11Response, MintInfo, MintQuoteBolt11Request, - MintQuoteBolt11Response, MintQuoteState, PaymentMethod, RestoreRequest, RestoreResponse, - SwapRequest, SwapResponse, + MintQuoteBolt11Response, PaymentMethod, RestoreRequest, RestoreResponse, SwapRequest, + SwapResponse, }; use cdk::util::unix_time; -use cdk::Bolt11Invoice; use crate::{LnKey, MintState}; @@ -196,6 +193,28 @@ 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) => { @@ -212,138 +231,52 @@ pub async fn post_melt_bolt11( } }; - // Check to see if there is a corresponding mint quote for a melt. - // In this case the mint can settle the payment internally and no ln payment is - // needed - let mint_quote = match state - .mint - .localstore - .get_mint_quote_by_request("e.request) - .await - { - Ok(mint_quote) => mint_quote, - Err(err) => { - tracing::debug!("Error attempting to get mint quote: {}", 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)); - } - }; - - let inputs_amount_quote_unit = payload.proofs_amount().map_err(|_| { - tracing::error!("Proof inputs in melt quote overflowed"); - into_response(Error::AmountOverflow) - })?; - - let (preimage, amount_spent_quote_unit) = match mint_quote { - Some(mint_quote) => { - if mint_quote.state == MintQuoteState::Issued - || mint_quote.state == MintQuoteState::Paid - { - return Err(into_response(Error::RequestAlreadyPaid)); - } - - let mut mint_quote = mint_quote; - - if mint_quote.amount > inputs_amount_quote_unit { - tracing::debug!( - "Not enough inuts provided: {} needed {}", - inputs_amount_quote_unit, - mint_quote.amount - ); - 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::InsufficientFunds)); - } - - mint_quote.state = MintQuoteState::Paid; - - let amount = quote.amount; - - if let Err(_err) = state.mint.update_mint_quote(mint_quote).await { + 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: {}", err); + tracing::error!( + "Could not reset melt quote {} state: {}", + payload.quote, + err + ); } - return Err(into_response(Error::Internal)); + return Err(into_response(err)); } + }; - (None, amount) - } + let (preimage, amount_spent_quote_unit) = match settled_internally_amount { + Some(amount_spent) => (None, amount_spent), None => { - let invoice = match Bolt11Invoice::from_str("e.request) { - Ok(bolt11) => bolt11, - Err(_) => { - tracing::error!("Melt quote has invalid payment request"); - 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::InvalidPaymentRequest)); - } - }; - - let mut partial_amount = 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 id not + // 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. - if quote.unit == CurrencyUnit::Msat || quote.unit == CurrencyUnit::Sat { - let quote_msats = to_unit(quote.amount, "e.unit, &CurrencyUnit::Msat) - .expect("Quote unit is checked above that it can convert to msat"); - - let invoice_amount_msats: Amount = match invoice.amount_milli_satoshis() { - Some(amount) => amount.into(), - None => { - if let Err(err) = state.mint.process_unpaid_melt(&payload).await { - tracing::error!("Could not reset melt quote state: {}", err); + // > `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)); } - return Err(into_response(Error::InvoiceAmountUndefined)); - } - }; - - partial_amount = match invoice_amount_msats > quote_msats { - true => { - let partial_msats = invoice_amount_msats - quote_msats; - - Some( - to_unit(partial_msats, &CurrencyUnit::Msat, "e.unit) - .map_err(|_| into_response(Error::UnitUnsupported))?, - ) } - false => None, - }; - - let amount_to_pay = match partial_amount { - Some(amount_to_pay) => amount_to_pay, - None => to_unit(invoice_amount_msats, &CurrencyUnit::Msat, "e.unit) - .map_err(|_| into_response(Error::UnitUnsupported))?, - }; - - if amount_to_pay + quote.fee_reserve > inputs_amount_quote_unit { - tracing::debug!( - "Not enough inuts provided: {} msats needed {} msats", - inputs_amount_quote_unit, - amount_to_pay - ); - - 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::TransactionUnbalanced( - inputs_amount_quote_unit.into(), - amount_to_pay.into(), - quote.fee_reserve.into(), - ))); } - } + _ => None, + }; let ln = match state.ln.get(&LnKey::new(quote.unit, PaymentMethod::Bolt11)) { Some(ln) => ln, @@ -361,46 +294,95 @@ pub async fn post_melt_bolt11( .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) => { - tracing::error!("Could not pay invoice: {}", err); - if let Err(err) = state.mint.process_unpaid_melt(&payload).await { - tracing::error!("Could not reset melt quote state: {}", 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)); } - let err = match err { - cdk::cdk_lightning::Error::InvoiceAlreadyPaid => Error::RequestAlreadyPaid, - _ => Error::PaymentFailed, - }; + tracing::error!("Error returned attempting to pay: {} {}", quote.id, err); - return Err(into_response(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 } }; - // Check that melt quote status paid by in ln backend - if pre.status != MeltQuoteState::Paid { - if let Err(err) = state.mint.process_unpaid_melt(&payload).await { - tracing::error!("Could not reset melt quote state: {}", err); + 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)); } - - return Err(into_response(Error::PaymentFailed)); } // Convert from unit of backend to quote unit - let amount_spent = to_unit(pre.total_spent, &pre.unit, "e.unit).map_err(|_| { - tracing::error!( - "Could not convert from {} to {} in melt.", - pre.unit, - 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 ); - into_response(Error::UnitUnsupported) - })?; + 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) diff --git a/crates/cdk-cln/src/error.rs b/crates/cdk-cln/src/error.rs index 2ee1263b3..18ef459f1 100644 --- a/crates/cdk-cln/src/error.rs +++ b/crates/cdk-cln/src/error.rs @@ -14,6 +14,9 @@ pub enum Error { /// Unknown invoice #[error("Unknown invoice")] UnknownInvoice, + /// Invalid payment hash + #[error("Invalid hash")] + InvalidHash, /// 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 b35295a45..c5fe190e5 100644 --- a/crates/cdk-cln/src/lib.rs +++ b/crates/cdk-cln/src/lib.rs @@ -113,7 +113,7 @@ impl MintLightning for Cln { last_pay_idx = invoice.pay_index; - break Some((invoice.label, (cln_client, last_pay_idx))); + break Some((invoice.payment_hash.to_string(), (cln_client, last_pay_idx))); } }, ) @@ -159,12 +159,13 @@ impl MintLightning for Cln { partial_amount: Option, max_fee: Option, ) -> Result { - let mut cln_client = self.cln_client.lock().await; - - let pay_state = - check_pay_invoice_status(&mut cln_client, melt_quote.request.to_string()).await?; + let bolt11 = Bolt11Invoice::from_str(&melt_quote.request)?; + let pay_state = self + .check_outgoing_payment(&bolt11.payment_hash().to_string()) + .await?; - match pay_state { + 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); @@ -173,9 +174,9 @@ impl MintLightning for Cln { tracing::debug!("Melt attempted on invoice already pending"); return Err(Self::Err::InvoicePaymentPending); } - MeltQuoteState::Unpaid => (), } + let mut cln_client = self.cln_client.lock().await; let cln_response = cln_client .call(Request::Pay(PayRequest { bolt11: melt_quote.request.to_string(), @@ -207,19 +208,18 @@ impl MintLightning for Cln { }) .transpose()?, })) - .await - .map_err(Error::from)?; + .await; let response = match cln_response { - cln_rpc::Response::Pay(pay_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::Unpaid, + PayStatus::FAILED => MeltQuoteState::Failed, }; PayInvoiceResponse { payment_preimage: Some(hex::encode(pay_response.payment_preimage.to_vec())), - payment_hash: pay_response.payment_hash.to_string(), + payment_lookup_id: pay_response.payment_hash.to_string(), status, total_spent: to_unit( pay_response.amount_sent_msat.msat(), @@ -230,8 +230,11 @@ impl MintLightning for Cln { } } _ => { - tracing::warn!("CLN returned wrong response kind"); - return Err(cdk_lightning::Error::from(Error::WrongClnResponse)); + tracing::error!( + "Error attempting to pay invoice: {}", + bolt11.payment_hash().to_string() + ); + return Err(Error::WrongClnResponse.into()); } }; @@ -274,9 +277,10 @@ impl MintLightning for Cln { cln_rpc::Response::Invoice(invoice_res) => { let request = Bolt11Invoice::from_str(&invoice_res.bolt11)?; let expiry = request.expires_at().map(|t| t.as_secs()); + let payment_hash = request.payment_hash(); Ok(CreateInvoiceResponse { - request_lookup_id: label, + request_lookup_id: payment_hash.to_string(), request, expiry, }) @@ -288,16 +292,16 @@ impl MintLightning for Cln { } } - async fn check_invoice_status( + async fn check_incoming_invoice_status( &self, - request_lookup_id: &str, + payment_hash: &str, ) -> Result { let mut cln_client = self.cln_client.lock().await; let cln_response = cln_client .call(Request::ListInvoices(ListinvoicesRequest { - payment_hash: None, - label: Some(request_lookup_id.to_string()), + payment_hash: Some(payment_hash.to_string()), + label: None, invstring: None, offer_id: None, index: None, @@ -316,7 +320,7 @@ impl MintLightning for Cln { None => { tracing::info!( "Check invoice called on unknown look up id: {}", - request_lookup_id + payment_hash ); return Err(Error::WrongClnResponse.into()); } @@ -330,6 +334,51 @@ impl MintLightning for Cln { Ok(status) } + + async fn check_outgoing_payment( + &self, + payment_hash: &str, + ) -> Result { + let mut cln_client = self.cln_client.lock().await; + + let cln_response = cln_client + .call(Request::ListPays(ListpaysRequest { + payment_hash: Some(payment_hash.parse().map_err(|_| Error::InvalidHash)?), + bolt11: None, + status: None, + })) + .await + .map_err(Error::from)?; + + match cln_response { + cln_rpc::Response::ListPays(pays_response) => match pays_response.pays.first() { + Some(pays_response) => { + let status = cln_pays_status_to_mint_state(pays_response.status); + + Ok(PayInvoiceResponse { + payment_lookup_id: pays_response.payment_hash.to_string(), + payment_preimage: pays_response.preimage.map(|p| hex::encode(p.to_vec())), + status, + total_spent: pays_response + .amount_sent_msat + .map_or(Amount::ZERO, |a| a.msat().into()), + unit: CurrencyUnit::Msat, + }) + } + None => Ok(PayInvoiceResponse { + payment_lookup_id: payment_hash.to_string(), + payment_preimage: None, + status: MeltQuoteState::Unknown, + total_spent: Amount::ZERO, + unit: CurrencyUnit::Msat, + }), + }, + _ => { + tracing::warn!("CLN returned wrong response kind"); + Err(Error::WrongClnResponse.into()) + } + } + } } impl Cln { @@ -370,37 +419,10 @@ fn cln_invoice_status_to_mint_state(status: ListinvoicesInvoicesStatus) -> MintQ } } -async fn check_pay_invoice_status( - cln_client: &mut cln_rpc::ClnRpc, - bolt11: String, -) -> Result { - let cln_response = cln_client - .call(Request::ListPays(ListpaysRequest { - bolt11: Some(bolt11), - payment_hash: None, - status: None, - })) - .await - .map_err(Error::from)?; - - let state = match cln_response { - cln_rpc::Response::ListPays(pay_response) => { - let pay = pay_response.pays.first(); - - match pay { - Some(pay) => match pay.status { - ListpaysPaysStatus::COMPLETE => MeltQuoteState::Paid, - ListpaysPaysStatus::PENDING => MeltQuoteState::Pending, - ListpaysPaysStatus::FAILED => MeltQuoteState::Unpaid, - }, - None => MeltQuoteState::Unpaid, - } - } - _ => { - tracing::warn!("CLN returned wrong response kind. When checking pay status"); - return Err(cdk_lightning::Error::from(Error::WrongClnResponse)); - } - }; - - Ok(state) +fn cln_pays_status_to_mint_state(status: ListpaysPaysStatus) -> MeltQuoteState { + match status { + ListpaysPaysStatus::PENDING => MeltQuoteState::Pending, + ListpaysPaysStatus::COMPLETE => MeltQuoteState::Paid, + ListpaysPaysStatus::FAILED => MeltQuoteState::Failed, + } } diff --git a/crates/cdk-fake-wallet/Cargo.toml b/crates/cdk-fake-wallet/Cargo.toml index 42e6f0190..0cf03b831 100644 --- a/crates/cdk-fake-wallet/Cargo.toml +++ b/crates/cdk-fake-wallet/Cargo.toml @@ -17,6 +17,8 @@ futures = { version = "0.3.28", default-features = false } tokio = { version = "1", default-features = false } tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] } thiserror = "1" +serde = "1" +serde_json = "1" uuid = { version = "1", features = ["v4"] } lightning-invoice = { version = "0.32.0", features = ["serde", "std"] } tokio-stream = "0.1.15" diff --git a/crates/cdk-fake-wallet/src/lib.rs b/crates/cdk-fake-wallet/src/lib.rs index 822ca7439..05ab03bc4 100644 --- a/crates/cdk-fake-wallet/src/lib.rs +++ b/crates/cdk-fake-wallet/src/lib.rs @@ -5,7 +5,9 @@ #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] +use std::collections::{HashMap, HashSet}; use std::pin::Pin; +use std::str::FromStr; use std::sync::Arc; use async_trait::async_trait; @@ -26,11 +28,12 @@ use cdk::util::unix_time; use error::Error; use futures::stream::StreamExt; use futures::Stream; -use lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret}; +use lightning_invoice::{Bolt11Invoice, Currency, InvoiceBuilder, PaymentSecret}; +use rand::Rng; +use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use tokio::time; use tokio_stream::wrappers::ReceiverStream; -use uuid::Uuid; pub mod error; @@ -42,6 +45,9 @@ pub struct FakeWallet { receiver: Arc>>>, mint_settings: MintMethodSettings, melt_settings: MeltMethodSettings, + payment_states: Arc>>, + failed_payment_check: Arc>>, + payment_delay: u64, } impl FakeWallet { @@ -50,6 +56,9 @@ impl FakeWallet { fee_reserve: FeeReserve, mint_settings: MintMethodSettings, melt_settings: MeltMethodSettings, + payment_states: HashMap, + fail_payment_check: HashSet, + payment_delay: u64, ) -> Self { let (sender, receiver) = tokio::sync::mpsc::channel(8); @@ -59,10 +68,26 @@ impl FakeWallet { receiver: Arc::new(Mutex::new(Some(receiver))), mint_settings, melt_settings, + payment_states: Arc::new(Mutex::new(payment_states)), + failed_payment_check: Arc::new(Mutex::new(fail_payment_check)), + payment_delay, } } } +/// Struct for signaling what methods should respond via invoice description +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct FakeInvoiceDescription { + /// State to be returned from pay invoice state + pub pay_invoice_state: MeltQuoteState, + /// State to be returned by check payment state + pub check_payment_state: MeltQuoteState, + /// Should pay invoice error + pub pay_err: bool, + /// Should check failure + pub check_err: bool, +} + #[async_trait] impl MintLightning for FakeWallet { type Err = cdk_lightning::Error; @@ -124,10 +149,42 @@ impl MintLightning for FakeWallet { _partial_msats: Option, _max_fee_msats: Option, ) -> Result { + let bolt11 = Bolt11Invoice::from_str(&melt_quote.request)?; + + let payment_hash = bolt11.payment_hash().to_string(); + + let description = bolt11.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_hash: "".to_string(), - status: MeltQuoteState::Paid, + payment_lookup_id: payment_hash, + status: payment_status, total_spent: melt_quote.amount, unit: melt_quote.unit, }) @@ -143,62 +200,95 @@ impl MintLightning for FakeWallet { let time_now = unix_time(); assert!(unix_expiry > time_now); - let label = Uuid::new_v4().to_string(); - - let private_key = SecretKey::from_slice( - &[ - 0xe1, 0x26, 0xf6, 0x8f, 0x7e, 0xaf, 0xcc, 0x8b, 0x74, 0xf5, 0x4d, 0x26, 0x9f, 0xe2, - 0x06, 0xbe, 0x71, 0x50, 0x00, 0xf9, 0x4d, 0xac, 0x06, 0x7d, 0x1c, 0x04, 0xa8, 0xca, - 0x3b, 0x2d, 0xb7, 0x34, - ][..], - ) - .unwrap(); + let amount_msat = to_unit(amount, unit, &CurrencyUnit::Msat)?; - let payment_hash = sha256::Hash::from_slice(&[0; 32][..]).unwrap(); - let payment_secret = PaymentSecret([42u8; 32]); + let invoice = create_fake_invoice(amount_msat.into(), description); - let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?; + let sender = self.sender.clone(); - let invoice = InvoiceBuilder::new(Currency::Bitcoin) - .description(description) - .payment_hash(payment_hash) - .payment_secret(payment_secret) - .amount_milli_satoshis(amount.into()) - .current_timestamp() - .min_final_cltv_expiry_delta(144) - .build_signed(|hash| Secp256k1::new().sign_ecdsa_recoverable(hash, &private_key)) - .unwrap(); + let payment_hash = invoice.payment_hash(); - // Create a random delay between 3 and 6 seconds - let duration = time::Duration::from_secs(3) - + time::Duration::from_millis(rand::random::() % 3001); + let payment_hash_clone = payment_hash.to_string(); - let sender = self.sender.clone(); - let label_clone = label.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(label_clone.clone()).await.is_err() { - tracing::error!("Failed to send label: {}", label_clone); + if sender.send(payment_hash_clone.clone()).await.is_err() { + tracing::error!("Failed to send label: {}", payment_hash_clone); } }); let expiry = invoice.expires_at().map(|t| t.as_secs()); Ok(CreateInvoiceResponse { - request_lookup_id: label, + request_lookup_id: payment_hash.to_string(), request: invoice, expiry, }) } - async fn check_invoice_status( + async fn check_incoming_invoice_status( &self, _request_lookup_id: &str, ) -> Result { Ok(MintQuoteState::Paid) } + + async fn check_outgoing_payment( + &self, + request_lookup_id: &str, + ) -> Result { + // For fake wallet if the state is not explicitly set default to paid + let states = self.payment_states.lock().await; + let status = states.get(request_lookup_id).cloned(); + + let status = status.unwrap_or(MeltQuoteState::Paid); + + let fail_payments = self.failed_payment_check.lock().await; + + if fail_payments.contains(request_lookup_id) { + return Err(cdk_lightning::Error::InvoicePaymentPending); + } + + Ok(PayInvoiceResponse { + payment_preimage: Some("".to_string()), + payment_lookup_id: request_lookup_id.to_string(), + status, + total_spent: Amount::ZERO, + unit: self.get_settings().unit, + }) + } +} + +/// Create fake invoice +pub fn create_fake_invoice(amount_msat: u64, description: String) -> Bolt11Invoice { + let private_key = SecretKey::from_slice( + &[ + 0xe1, 0x26, 0xf6, 0x8f, 0x7e, 0xaf, 0xcc, 0x8b, 0x74, 0xf5, 0x4d, 0x26, 0x9f, 0xe2, + 0x06, 0xbe, 0x71, 0x50, 0x00, 0xf9, 0x4d, 0xac, 0x06, 0x7d, 0x1c, 0x04, 0xa8, 0xca, + 0x3b, 0x2d, 0xb7, 0x34, + ][..], + ) + .unwrap(); + + let mut rng = rand::thread_rng(); + let mut random_bytes = [0u8; 32]; + rng.fill(&mut random_bytes); + + let payment_hash = sha256::Hash::from_slice(&random_bytes).unwrap(); + let payment_secret = PaymentSecret([42u8; 32]); + + InvoiceBuilder::new(Currency::Bitcoin) + .description(description) + .payment_hash(payment_hash) + .payment_secret(payment_secret) + .amount_milli_satoshis(amount_msat) + .current_timestamp() + .min_final_cltv_expiry_delta(144) + .build_signed(|hash| Secp256k1::new().sign_ecdsa_recoverable(hash, &private_key)) + .unwrap() } diff --git a/crates/cdk-integration-tests/Cargo.toml b/crates/cdk-integration-tests/Cargo.toml index a4a5f4f8d..7e9e78bd4 100644 --- a/crates/cdk-integration-tests/Cargo.toml +++ b/crates/cdk-integration-tests/Cargo.toml @@ -28,6 +28,8 @@ tower-http = { version = "0.4.4", features = ["cors"] } futures = { version = "0.3.28", default-features = false, features = ["executor"] } once_cell = "1.19.0" 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" } lightning-invoice = { version = "0.32.0", features = ["serde", "std"] } diff --git a/crates/cdk-integration-tests/src/bin/fake_wallet.rs b/crates/cdk-integration-tests/src/bin/fake_wallet.rs new file mode 100644 index 000000000..162dc7ee5 --- /dev/null +++ b/crates/cdk-integration-tests/src/bin/fake_wallet.rs @@ -0,0 +1,32 @@ +use std::env; + +use anyhow::Result; +use cdk::cdk_database::mint_memory::MintMemoryDatabase; +use cdk_integration_tests::{init_fake_wallet::start_fake_mint, init_regtest::get_temp_dir}; +use cdk_redb::MintRedbDatabase; +use cdk_sqlite::MintSqliteDatabase; + +#[tokio::main] +async fn main() -> Result<()> { + let addr = "127.0.0.1"; + let port = 8086; + + let mint_db_kind = env::var("MINT_DATABASE")?; + + match mint_db_kind.as_str() { + "MEMORY" => { + start_fake_mint(addr, port, MintMemoryDatabase::default()).await?; + } + "SQLITE" => { + let sqlite_db = MintSqliteDatabase::new(&get_temp_dir().join("mint")).await?; + sqlite_db.migrate().await; + start_fake_mint(addr, port, sqlite_db).await?; + } + "REDB" => { + let redb_db = MintRedbDatabase::new(&get_temp_dir().join("mint")).unwrap(); + start_fake_mint(addr, port, redb_db).await?; + } + _ => panic!("Unknown mint db type: {}", mint_db_kind), + }; + Ok(()) +} diff --git a/crates/cdk-integration-tests/src/init_fake_wallet.rs b/crates/cdk-integration-tests/src/init_fake_wallet.rs new file mode 100644 index 000000000..e3444084e --- /dev/null +++ b/crates/cdk-integration-tests/src/init_fake_wallet.rs @@ -0,0 +1,112 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use anyhow::Result; +use axum::Router; +use cdk::{ + cdk_database::{self, MintDatabase}, + cdk_lightning::MintLightning, + mint::FeeReserve, + nuts::{CurrencyUnit, MeltMethodSettings, MintMethodSettings}, + types::LnKey, +}; +use cdk_fake_wallet::FakeWallet; +use futures::StreamExt; +use tower_http::cors::CorsLayer; +use tracing_subscriber::EnvFilter; + +use crate::{handle_paid_invoice, init_regtest::create_mint}; + +pub async fn start_fake_mint(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 mint = create_mint(database).await?; + + let fee_reserve = FeeReserve { + min_fee_reserve: 1.into(), + percent_fee_reserve: 1.0, + }; + + let fake_wallet = FakeWallet::new( + fee_reserve, + MintMethodSettings::default(), + MeltMethodSettings::default(), + HashMap::default(), + HashSet::default(), + 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_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 mint_service = Router::new() + .merge(v1_service) + .layer(CorsLayer::permissive()); + + 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); + } + } + }); + } + println!("Staring Axum server"); + axum::Server::bind(&format!("{}:{}", addr, port).as_str().parse().unwrap()) + .serve(mint_service.into_make_service()) + .await?; + + Ok(()) +} diff --git a/crates/cdk-integration-tests/src/init_regtest.rs b/crates/cdk-integration-tests/src/init_regtest.rs index 8d2f20b38..3c8dac65b 100644 --- a/crates/cdk-integration-tests/src/init_regtest.rs +++ b/crates/cdk-integration-tests/src/init_regtest.rs @@ -8,8 +8,8 @@ use cdk::{ cdk_lightning::MintLightning, mint::{FeeReserve, Mint}, nuts::{CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings}, + types::LnKey, }; -use cdk_axum::LnKey; use cdk_cln::Cln as CdkCln; use futures::StreamExt; use ln_regtest_rs::{ diff --git a/crates/cdk-integration-tests/src/lib.rs b/crates/cdk-integration-tests/src/lib.rs index 41de06473..e14d0a9ff 100644 --- a/crates/cdk-integration-tests/src/lib.rs +++ b/crates/cdk-integration-tests/src/lib.rs @@ -1,8 +1,8 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; -use anyhow::Result; +use anyhow::{bail, Result}; use axum::Router; use bip39::Mnemonic; use cdk::amount::{Amount, SplitTarget}; @@ -12,17 +12,18 @@ use cdk::dhke::construct_proofs; use cdk::mint::FeeReserve; use cdk::nuts::{ CurrencyUnit, Id, KeySet, MeltMethodSettings, MintInfo, MintMethodSettings, MintQuoteState, - Nuts, PaymentMethod, PreMintSecrets, Proofs, + Nuts, PaymentMethod, PreMintSecrets, Proofs, State, }; +use cdk::types::LnKey; use cdk::wallet::client::HttpClient; use cdk::{Mint, Wallet}; -use cdk_axum::LnKey; use cdk_fake_wallet::FakeWallet; use futures::StreamExt; use init_regtest::{get_mint_addr, get_mint_port, get_mint_url}; use tokio::time::sleep; use tower_http::cors::CorsLayer; +pub mod init_fake_wallet; pub mod init_regtest; pub fn create_backends_fake_wallet( @@ -41,6 +42,9 @@ pub fn create_backends_fake_wallet( fee_reserve.clone(), MintMethodSettings::default(), MeltMethodSettings::default(), + HashMap::default(), + HashSet::default(), + 0, )); ln_backends.insert(ln_key, wallet.clone()); @@ -224,3 +228,40 @@ pub async fn mint_proofs( Ok(pre_swap_proofs) } + +// Get all pending from wallet and attempt to swap +// Will panic if there are no pending +// Will return Ok if swap fails as expected +pub async fn attempt_to_swap_pending(wallet: &Wallet) -> Result<()> { + let pending = wallet + .localstore + .get_proofs(None, None, Some(vec![State::Pending]), None) + .await?; + + assert!(!pending.is_empty()); + + let swap = wallet + .swap( + None, + SplitTarget::None, + pending.into_iter().map(|p| p.proof).collect(), + None, + false, + ) + .await; + + match swap { + Ok(_swap) => { + bail!("These proofs should be pending") + } + Err(err) => match err { + cdk::error::Error::TokenPending => (), + _ => { + println!("{:?}", err); + bail!("Wrong error") + } + }, + } + + Ok(()) +} diff --git a/crates/cdk-integration-tests/tests/fake_wallet.rs b/crates/cdk-integration-tests/tests/fake_wallet.rs new file mode 100644 index 000000000..6677c6521 --- /dev/null +++ b/crates/cdk-integration-tests/tests/fake_wallet.rs @@ -0,0 +1,313 @@ +use std::{sync::Arc, time::Duration}; + +use anyhow::Result; +use bip39::Mnemonic; +use cdk::{ + amount::SplitTarget, + cdk_database::WalletMemoryDatabase, + nuts::{CurrencyUnit, MeltQuoteState, State}, + wallet::Wallet, +}; +use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription}; +use cdk_integration_tests::attempt_to_swap_pending; +use tokio::time::sleep; + +const MINT_URL: &str = "http://127.0.0.1:8086"; + +// If both pay and check return pending input proofs should remain pending +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_fake_tokens_pending() -> Result<()> { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(WalletMemoryDatabase::default()), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + let mint_quote = wallet.mint_quote(100.into(), None).await?; + + sleep(Duration::from_secs(5)).await; + + let _mint_amount = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await?; + + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Pending, + check_payment_state: MeltQuoteState::Pending, + pay_err: false, + check_err: false, + }; + + let invoice = create_fake_invoice(1000, serde_json::to_string(&fake_description).unwrap()); + + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?; + + let melt = wallet.melt(&melt_quote.id).await; + + assert!(melt.is_err()); + + attempt_to_swap_pending(&wallet).await?; + + Ok(()) +} + +// If the pay error fails and the check returns unknown or failed +// The inputs proofs should be unset as spending +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_fake_melt_payment_fail() -> Result<()> { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(WalletMemoryDatabase::default()), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + let mint_quote = wallet.mint_quote(100.into(), None).await?; + + let _mint_amount = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await?; + + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Unknown, + check_payment_state: MeltQuoteState::Unknown, + pay_err: true, + check_err: false, + }; + + let invoice = create_fake_invoice(1000, serde_json::to_string(&fake_description).unwrap()); + + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?; + + // The melt should error at the payment invoice command + let melt = wallet.melt(&melt_quote.id).await; + assert!(melt.is_err()); + + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Failed, + check_payment_state: MeltQuoteState::Failed, + pay_err: true, + check_err: false, + }; + + let invoice = create_fake_invoice(1000, serde_json::to_string(&fake_description).unwrap()); + + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?; + + // The melt should error at the payment invoice command + let melt = wallet.melt(&melt_quote.id).await; + assert!(melt.is_err()); + + // The mint should have unset proofs from pending since payment failed + let all_proof = wallet.get_proofs().await?; + let states = wallet.check_proofs_spent(all_proof).await?; + for state in states { + assert!(state.state == State::Unspent); + } + + let wallet_bal = wallet.total_balance().await?; + assert!(wallet_bal == 100.into()); + + Ok(()) +} + +// When both the pay_invoice and check_invoice both fail +// the proofs should remain as pending +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_fake_melt_payment_fail_and_check() -> Result<()> { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(WalletMemoryDatabase::default()), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + let mint_quote = wallet.mint_quote(100.into(), None).await?; + + let _mint_amount = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await?; + + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Unknown, + check_payment_state: MeltQuoteState::Unknown, + pay_err: true, + check_err: true, + }; + + let invoice = create_fake_invoice(7000, serde_json::to_string(&fake_description).unwrap()); + + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?; + + // The melt should error at the payment invoice command + let melt = wallet.melt(&melt_quote.id).await; + assert!(melt.is_err()); + + let pending = wallet + .localstore + .get_proofs(None, None, Some(vec![State::Pending]), None) + .await?; + + assert!(!pending.is_empty()); + + Ok(()) +} + +// In the case that the ln backend returns a failed status but does not error +// The mint should do a second check, then remove proofs from pending +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_fake_melt_payment_return_fail_status() -> Result<()> { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(WalletMemoryDatabase::default()), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + let mint_quote = wallet.mint_quote(100.into(), None).await?; + + let _mint_amount = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await?; + + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Failed, + check_payment_state: MeltQuoteState::Failed, + pay_err: false, + check_err: false, + }; + + let invoice = create_fake_invoice(7000, serde_json::to_string(&fake_description).unwrap()); + + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?; + + // The melt should error at the payment invoice command + let melt = wallet.melt(&melt_quote.id).await; + assert!(melt.is_err()); + + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Unknown, + check_payment_state: MeltQuoteState::Unknown, + pay_err: false, + check_err: false, + }; + + let invoice = create_fake_invoice(7000, serde_json::to_string(&fake_description).unwrap()); + + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?; + + // The melt should error at the payment invoice command + let melt = wallet.melt(&melt_quote.id).await; + assert!(melt.is_err()); + + let pending = wallet + .localstore + .get_proofs(None, None, Some(vec![State::Pending]), None) + .await?; + + assert!(pending.is_empty()); + + Ok(()) +} + +// In the case that the ln backend returns a failed status but does not error +// The mint should do a second check, then remove proofs from pending +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_fake_melt_payment_error_unknown() -> Result<()> { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(WalletMemoryDatabase::default()), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + let mint_quote = wallet.mint_quote(100.into(), None).await?; + + let _mint_amount = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await?; + + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Failed, + check_payment_state: MeltQuoteState::Unknown, + pay_err: true, + check_err: false, + }; + + let invoice = create_fake_invoice(7000, serde_json::to_string(&fake_description).unwrap()); + + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?; + + // The melt should error at the payment invoice command + let melt = wallet.melt(&melt_quote.id).await; + assert!(melt.is_err()); + + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Unknown, + check_payment_state: MeltQuoteState::Unknown, + pay_err: true, + check_err: false, + }; + + let invoice = create_fake_invoice(7000, serde_json::to_string(&fake_description).unwrap()); + + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?; + + // The melt should error at the payment invoice command + let melt = wallet.melt(&melt_quote.id).await; + assert!(melt.is_err()); + + let pending = wallet + .localstore + .get_proofs(None, None, Some(vec![State::Pending]), None) + .await?; + + assert!(pending.is_empty()); + + Ok(()) +} + +// In the case that the ln backend returns an err +// The mint should do a second check, that returns paid +// Proofs should remain pending +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_fake_melt_payment_err_paid() -> Result<()> { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(WalletMemoryDatabase::default()), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + let mint_quote = wallet.mint_quote(100.into(), None).await?; + + let _mint_amount = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await?; + + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Failed, + check_payment_state: MeltQuoteState::Paid, + pay_err: true, + check_err: false, + }; + + let invoice = create_fake_invoice(7000, serde_json::to_string(&fake_description).unwrap()); + + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?; + + // The melt should error at the payment invoice command + let melt = wallet.melt(&melt_quote.id).await; + assert!(melt.is_err()); + + attempt_to_swap_pending(&wallet).await?; + + Ok(()) +} diff --git a/crates/cdk-integration-tests/tests/mint.rs b/crates/cdk-integration-tests/tests/mint.rs index 1667f8e92..a0beed994 100644 --- a/crates/cdk-integration-tests/tests/mint.rs +++ b/crates/cdk-integration-tests/tests/mint.rs @@ -1,20 +1,19 @@ //! Mint tests -use cdk::amount::{Amount, SplitTarget}; -use cdk::dhke::construct_proofs; -use cdk::util::unix_time; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::OnceCell; - use anyhow::{bail, Result}; use bip39::Mnemonic; +use cdk::amount::{Amount, SplitTarget}; use cdk::cdk_database::mint_memory::MintMemoryDatabase; +use cdk::dhke::construct_proofs; use cdk::nuts::{ CurrencyUnit, Id, MintBolt11Request, MintInfo, Nuts, PreMintSecrets, Proofs, SecretKey, SpendingConditions, SwapRequest, }; +use cdk::util::unix_time; use cdk::Mint; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::OnceCell; pub const MINT_URL: &str = "http://127.0.0.1:8088"; diff --git a/crates/cdk-lnbits/Cargo.toml b/crates/cdk-lnbits/Cargo.toml index 1c75f16d6..e8d230582 100644 --- a/crates/cdk-lnbits/Cargo.toml +++ b/crates/cdk-lnbits/Cargo.toml @@ -19,4 +19,6 @@ futures = { version = "0.3.28", default-features = false } tokio = { version = "1", default-features = false } tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] } thiserror = "1" -lnbits-rs = "0.2.0" +# lnbits-rs = "0.2.0" +# lnbits-rs = { path = "../../../../lnbits-rs" } +lnbits-rs = { git = "https://github.com/thesimplekid/lnbits-rs.git", rev = "9fff4d" } diff --git a/crates/cdk-lnbits/src/lib.rs b/crates/cdk-lnbits/src/lib.rs index 1c3a6cb4e..02fb4ecbf 100644 --- a/crates/cdk-lnbits/src/lib.rs +++ b/crates/cdk-lnbits/src/lib.rs @@ -12,7 +12,7 @@ use axum::Router; use cdk::amount::Amount; use cdk::cdk_lightning::{ self, to_unit, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, - Settings, + Settings, MSAT_IN_SAT, }; use cdk::mint::FeeReserve; use cdk::nuts::{ @@ -189,7 +189,7 @@ impl MintLightning for LNbits { let total_spent = Amount::from((invoice_info.amount + invoice_info.fee).unsigned_abs()); Ok(PayInvoiceResponse { - payment_hash: pay_response.payment_hash, + payment_lookup_id: pay_response.payment_hash, payment_preimage: Some(invoice_info.payment_hash), status, total_spent, @@ -243,13 +243,13 @@ impl MintLightning for LNbits { }) } - async fn check_invoice_status( + async fn check_incoming_invoice_status( &self, - request_lookup_id: &str, + payment_hash: &str, ) -> Result { let paid = self .lnbits_api - .is_invoice_paid(request_lookup_id) + .is_invoice_paid(payment_hash) .await .map_err(|err| { tracing::error!("Could not check invoice status"); @@ -264,6 +264,43 @@ impl MintLightning for LNbits { Ok(state) } + + async fn check_outgoing_payment( + &self, + payment_hash: &str, + ) -> Result { + let payment = self + .lnbits_api + .get_payment_info(payment_hash) + .await + .map_err(|err| { + tracing::error!("Could not check invoice status"); + tracing::error!("{}", err.to_string()); + Self::Err::Anyhow(anyhow!("Could not check invoice status")) + })?; + + let pay_response = PayInvoiceResponse { + payment_lookup_id: payment.details.payment_hash, + payment_preimage: Some(payment.preimage), + status: lnbits_to_melt_status(&payment.details.status, payment.details.pending), + total_spent: Amount::from( + payment.details.amount.unsigned_abs() + + payment.details.fee.unsigned_abs() / MSAT_IN_SAT, + ), + unit: self.get_settings().unit, + }; + + Ok(pay_response) + } +} + +fn lnbits_to_melt_status(status: &str, pending: bool) -> MeltQuoteState { + match (status, pending) { + ("success", false) => MeltQuoteState::Paid, + ("failed", false) => MeltQuoteState::Unpaid, + (_, false) => MeltQuoteState::Unknown, + (_, true) => MeltQuoteState::Pending, + } } impl LNbits { diff --git a/crates/cdk-lnd/src/error.rs b/crates/cdk-lnd/src/error.rs index 02f178e11..3b6f427b2 100644 --- a/crates/cdk-lnd/src/error.rs +++ b/crates/cdk-lnd/src/error.rs @@ -14,9 +14,15 @@ pub enum Error { /// Connection error #[error("LND connection error")] Connection, + /// Invalid hash + #[error("Invalid hash")] + InvalidHash, /// Payment failed #[error("LND payment failed")] PaymentFailed, + /// Unknown payment status + #[error("LND unknown payment status")] + UnknownPaymentStatus, } impl From for cdk::cdk_lightning::Error { diff --git a/crates/cdk-lnd/src/lib.rs b/crates/cdk-lnd/src/lib.rs index a8a26df3c..26e71f960 100644 --- a/crates/cdk-lnd/src/lib.rs +++ b/crates/cdk-lnd/src/lib.rs @@ -26,6 +26,7 @@ use cdk::util::{hex, unix_time}; use cdk::{mint, Bolt11Invoice}; use error::Error; use fedimint_tonic_lnd::lnrpc::fee_limit::Limit; +use fedimint_tonic_lnd::lnrpc::payment::PaymentStatus; use fedimint_tonic_lnd::lnrpc::FeeLimit; use fedimint_tonic_lnd::Client; use futures::{Stream, StreamExt}; @@ -206,7 +207,7 @@ impl MintLightning for Lnd { }; Ok(PayInvoiceResponse { - payment_hash: hex::encode(payment_response.payment_hash), + payment_lookup_id: hex::encode(payment_response.payment_hash), payment_preimage, status, total_spent: total_amount.into(), @@ -251,7 +252,7 @@ impl MintLightning for Lnd { }) } - async fn check_invoice_status( + async fn check_incoming_invoice_status( &self, request_lookup_id: &str, ) -> Result { @@ -282,4 +283,68 @@ impl MintLightning for Lnd { _ => Err(Self::Err::Anyhow(anyhow!("Invalid status"))), } } + + async fn check_outgoing_payment( + &self, + payment_hash: &str, + ) -> Result { + let track_request = fedimint_tonic_lnd::routerrpc::TrackPaymentRequest { + payment_hash: hex::decode(payment_hash).map_err(|_| Error::InvalidHash)?, + no_inflight_updates: true, + }; + let mut payment_stream = self + .client + .lock() + .await + .router() + .track_payment_v2(track_request) + .await + .unwrap() + .into_inner(); + + while let Some(update_result) = payment_stream.next().await { + match update_result { + Ok(update) => { + let status = update.status(); + + let response = match status { + PaymentStatus::Unknown => PayInvoiceResponse { + payment_lookup_id: payment_hash.to_string(), + payment_preimage: Some(update.payment_preimage), + status: MeltQuoteState::Unknown, + total_spent: Amount::ZERO, + unit: self.get_settings().unit, + }, + PaymentStatus::InFlight => { + // Continue waiting for the next update + continue; + } + PaymentStatus::Succeeded => PayInvoiceResponse { + payment_lookup_id: payment_hash.to_string(), + payment_preimage: Some(update.payment_preimage), + status: MeltQuoteState::Paid, + total_spent: Amount::from((update.value_sat + update.fee_sat) as u64), + unit: CurrencyUnit::Sat, + }, + PaymentStatus::Failed => PayInvoiceResponse { + payment_lookup_id: payment_hash.to_string(), + payment_preimage: Some(update.payment_preimage), + status: MeltQuoteState::Failed, + total_spent: Amount::ZERO, + unit: self.get_settings().unit, + }, + }; + + return Ok(response); + } + Err(_) => { + // Handle the case where the update itself is an error (e.g., stream failure) + return Err(Error::UnknownPaymentStatus.into()); + } + } + } + + // If the stream is exhausted without a final status + Err(Error::UnknownPaymentStatus.into()) + } } diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index 87fbf22c5..c317c3ea5 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -3,7 +3,7 @@ #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; @@ -14,13 +14,13 @@ use bip39::Mnemonic; use cdk::cdk_database::{self, MintDatabase}; use cdk::cdk_lightning; use cdk::cdk_lightning::MintLightning; -use cdk::mint::{FeeReserve, Mint}; +use cdk::mint::{FeeReserve, MeltQuote, Mint}; use cdk::mint_url::MintUrl; use cdk::nuts::{ - nut04, nut05, ContactInfo, CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings, - MintVersion, MppMethodSettings, Nuts, PaymentMethod, + nut04, nut05, ContactInfo, CurrencyUnit, MeltMethodSettings, MeltQuoteState, MintInfo, + MintMethodSettings, MintVersion, MppMethodSettings, Nuts, PaymentMethod, }; -use cdk_axum::LnKey; +use cdk::types::LnKey; use cdk_cln::Cln; use cdk_fake_wallet::FakeWallet; use cdk_lnbits::LNbits; @@ -329,6 +329,9 @@ async fn main() -> anyhow::Result<()> { fee_reserve.clone(), MintMethodSettings::default(), MeltMethodSettings::default(), + HashMap::default(), + HashSet::default(), + 0, )); ln_backends.insert(ln_key, wallet); @@ -438,9 +441,14 @@ async fn main() -> anyhow::Result<()> { // it is possible that a mint quote was paid but the mint has not been updated // this will check and update the mint state of those quotes for ln in ln_backends.values() { - check_pending_quotes(Arc::clone(&mint), Arc::clone(ln)).await?; + check_pending_mint_quotes(Arc::clone(&mint), Arc::clone(ln)).await?; } + // Checks the status of all pending melt quotes + // Pending melt quotes where the payment has gone through inputs are burnt + // 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; @@ -462,7 +470,6 @@ async fn main() -> anyhow::Result<()> { } // 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 { @@ -505,7 +512,7 @@ async fn handle_paid_invoice(mint: Arc, request_lookup_id: &str) -> Result } /// Used on mint start up to check status of all pending mint quotes -async fn check_pending_quotes( +async fn check_pending_mint_quotes( mint: Arc, ln: Arc + Send + Sync>, ) -> Result<()> { @@ -519,10 +526,10 @@ async fn check_pending_quotes( for quote in unpaid_quotes { tracing::trace!("Checking status of mint quote: {}", quote.id); let lookup_id = quote.request_lookup_id; - match ln.check_invoice_status(&lookup_id).await { + match ln.check_incoming_invoice_status(&lookup_id).await { Ok(state) => { if state != quote.state { - tracing::trace!("Mintquote status changed: {}", quote.id); + tracing::trace!("Mint quote status changed: {}", quote.id); mint.localstore .update_mint_quote_state("e.id, state) .await?; @@ -539,6 +546,93 @@ async fn check_pending_quotes( Ok(()) } +async fn check_pending_melt_quotes( + mint: Arc, + ln_backends: &HashMap + Send + Sync>>, +) -> Result<()> { + let melt_quotes = mint.localstore.get_melt_quotes().await?; + let pending_quotes: Vec = melt_quotes + .into_iter() + .filter(|q| q.state == MeltQuoteState::Pending || q.state == MeltQuoteState::Unknown) + .collect(); + + for pending_quote in pending_quotes { + let melt_request_ln_key = mint.localstore.get_melt_request(&pending_quote.id).await?; + + let (melt_request, ln_key) = match melt_request_ln_key { + None => ( + None, + LnKey { + unit: pending_quote.unit, + method: PaymentMethod::Bolt11, + }, + ), + Some((melt_request, ln_key)) => (Some(melt_request), ln_key), + }; + + let ln_backend = match ln_backends.get(&ln_key) { + Some(ln_backend) => ln_backend, + None => { + tracing::warn!("No backend for ln key: {:?}", ln_key); + continue; + } + }; + + let pay_invoice_response = ln_backend + .check_outgoing_payment(&pending_quote.request_lookup_id) + .await?; + + match melt_request { + Some(melt_request) => { + match pay_invoice_response.status { + MeltQuoteState::Paid => { + if let Err(err) = mint + .process_melt_request( + &melt_request, + pay_invoice_response.payment_preimage, + pay_invoice_response.total_spent, + ) + .await + { + tracing::error!( + "Could not process melt request for pending quote: {}", + melt_request.quote + ); + tracing::error!("{}", err); + } + } + MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => { + // Payment has not been made we want to unset + tracing::info!("Lightning payment for quote {} failed.", pending_quote.id); + if let Err(err) = mint.process_unpaid_melt(&melt_request).await { + tracing::error!("Could not reset melt quote state: {}", err); + } + } + MeltQuoteState::Pending => { + tracing::warn!( + "LN payment pending, proofs are stuck as pending for quote: {}", + melt_request.quote + ); + // Quote is still pending we do not want to do anything + // continue to check next quote + } + } + } + None => { + tracing::warn!( + "There is no stored melt request for pending melt quote: {}", + pending_quote.id + ); + + mint.localstore + .update_melt_quote_state(&pending_quote.id, pay_invoice_response.status) + .await?; + } + }; + } + Ok(()) +} + fn expand_path(path: &str) -> Option { if path.starts_with('~') { if let Some(home_dir) = home::home_dir().as_mut() { diff --git a/crates/cdk-phoenixd/Cargo.toml b/crates/cdk-phoenixd/Cargo.toml index 4a5b6e531..e39ca5fa6 100644 --- a/crates/cdk-phoenixd/Cargo.toml +++ b/crates/cdk-phoenixd/Cargo.toml @@ -19,5 +19,6 @@ futures = { version = "0.3.28", default-features = false } tokio = { version = "1", default-features = false } tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] } thiserror = "1" -phoenixd-rs = "0.3.0" +# phoenixd-rs = "0.3.0" +phoenixd-rs = { git = "https://github.com/thesimplekid/phoenixd-rs", rev = "22a44f0"} uuid = { version = "1", features = ["v4"] } diff --git a/crates/cdk-phoenixd/src/error.rs b/crates/cdk-phoenixd/src/error.rs index 8eaf60efd..85e56c4eb 100644 --- a/crates/cdk-phoenixd/src/error.rs +++ b/crates/cdk-phoenixd/src/error.rs @@ -14,6 +14,9 @@ pub enum Error { /// Unsupported unit #[error("Unit Unsupported")] UnsupportedUnit, + /// phd error + #[error(transparent)] + Phd(#[from] phoenixd_rs::Error), /// 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 1c313b6e9..0ad386e90 100644 --- a/crates/cdk-phoenixd/src/lib.rs +++ b/crates/cdk-phoenixd/src/lib.rs @@ -175,24 +175,18 @@ impl MintLightning for Phoenixd { .pay_bolt11_invoice(&melt_quote.request, partial_amount.map(|a| a.into())) .await?; - // The pay response does not include the fee paided to Aciq so we check it here + // The pay invoice response does not give the needed fee info so we have to check. let check_outgoing_response = self - .check_outgoing_invoice(&pay_response.payment_id) + .check_outgoing_payment(&pay_response.payment_id) .await?; - if check_outgoing_response.state != MeltQuoteState::Paid { - return Err(anyhow!("Invoice is not paid").into()); - } - - let total_spent_sats = check_outgoing_response.fee + check_outgoing_response.amount; - let bolt11: Bolt11Invoice = melt_quote.request.parse()?; Ok(PayInvoiceResponse { - payment_hash: bolt11.payment_hash().to_string(), + payment_lookup_id: bolt11.payment_hash().to_string(), payment_preimage: Some(pay_response.payment_preimage), status: MeltQuoteState::Paid, - total_spent: total_spent_sats, + total_spent: check_outgoing_response.total_spent, unit: CurrencyUnit::Sat, }) } @@ -226,7 +220,10 @@ impl MintLightning for Phoenixd { }) } - async fn check_invoice_status(&self, payment_hash: &str) -> Result { + async fn check_incoming_invoice_status( + &self, + payment_hash: &str, + ) -> Result { let invoice = self.phoenixd_api.get_incoming_invoice(payment_hash).await?; let state = match invoice.is_paid { @@ -236,33 +233,45 @@ impl MintLightning for Phoenixd { Ok(state) } -} -impl Phoenixd { - /// Check the status of an outgooing invoice - // TODO: This should likely bee added to the trait. Both CLN and PhD use a form - // of it - async fn check_outgoing_invoice( + /// Check the status of an outgoing invoice + async fn check_outgoing_payment( &self, - payment_hash: &str, - ) -> Result { - let res = self.phoenixd_api.get_outgoing_invoice(payment_hash).await?; - - // Phenixd gives fees in msats so we need to round up to the nearst sat - let fee_sats = (res.fees + 999) / MSAT_IN_SAT; - - let state = match res.is_paid { - true => MeltQuoteState::Paid, - false => MeltQuoteState::Unpaid, - }; - - let quote_response = PaymentQuoteResponse { - request_lookup_id: res.payment_hash, - amount: res.sent.into(), - fee: fee_sats.into(), - state, + payment_id: &str, + ) -> Result { + let res = self.phoenixd_api.get_outgoing_invoice(payment_id).await; + + let state = match res { + Ok(res) => { + let status = match res.is_paid { + true => MeltQuoteState::Paid, + 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(), + unit: CurrencyUnit::Sat, + } + } + Err(err) => match err { + phoenixd_rs::Error::NotFound => PayInvoiceResponse { + payment_lookup_id: payment_id.to_string(), + payment_preimage: None, + status: MeltQuoteState::Unknown, + total_spent: Amount::ZERO, + unit: CurrencyUnit::Sat, + }, + _ => { + return Err(Error::from(err).into()); + } + }, }; - Ok(quote_response) + Ok(state) } } diff --git a/crates/cdk-redb/src/mint/mod.rs b/crates/cdk-redb/src/mint/mod.rs index f8646b625..55d62221f 100644 --- a/crates/cdk-redb/src/mint/mod.rs +++ b/crates/cdk-redb/src/mint/mod.rs @@ -11,9 +11,10 @@ use cdk::cdk_database::MintDatabase; use cdk::dhke::hash_to_curve; use cdk::mint::{MintKeySetInfo, MintQuote}; use cdk::nuts::{ - BlindSignature, CurrencyUnit, Id, MeltQuoteState, MintQuoteState, Proof, Proofs, PublicKey, - State, + BlindSignature, CurrencyUnit, Id, MeltBolt11Request, MeltQuoteState, MintQuoteState, Proof, + Proofs, PublicKey, State, }; +use cdk::types::LnKey; use cdk::{cdk_database, mint}; use migrations::migrate_01_to_02; use redb::{Database, MultimapTableDefinition, ReadableTable, TableDefinition}; @@ -39,6 +40,8 @@ const QUOTE_PROOFS_TABLE: MultimapTableDefinition<&str, [u8; 33]> = const QUOTE_SIGNATURES_TABLE: MultimapTableDefinition<&str, &str> = MultimapTableDefinition::new("quote_signatures"); +const MELT_REQUESTS: TableDefinition<&str, (&str, &str)> = TableDefinition::new("melt_requests"); + const DATABASE_VERSION: u32 = 4; /// Mint Redbdatabase @@ -735,4 +738,45 @@ impl MintDatabase for MintRedbDatabase { }) .collect()) } + + /// Add melt request + async fn add_melt_request( + &self, + melt_request: MeltBolt11Request, + ln_key: LnKey, + ) -> Result<(), Self::Err> { + let write_txn = self.db.begin_write().map_err(Error::from)?; + let mut table = write_txn.open_table(MELT_REQUESTS).map_err(Error::from)?; + + table + .insert( + melt_request.quote.as_str(), + ( + serde_json::to_string(&melt_request)?.as_str(), + serde_json::to_string(&ln_key)?.as_str(), + ), + ) + .map_err(Error::from)?; + + Ok(()) + } + /// Get melt request + async fn get_melt_request( + &self, + quote_id: &str, + ) -> Result, Self::Err> { + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn.open_table(MELT_REQUESTS).map_err(Error::from)?; + + match table.get(quote_id).map_err(Error::from)? { + Some(melt_request) => { + let (melt_request_str, ln_key_str) = melt_request.value(); + let melt_request = serde_json::from_str(melt_request_str)?; + let ln_key = serde_json::from_str(ln_key_str)?; + + Ok(Some((melt_request, ln_key))) + } + None => Ok(None), + } + } } diff --git a/crates/cdk-sqlite/src/mint/error.rs b/crates/cdk-sqlite/src/mint/error.rs index 8683a489d..5f2be0907 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, + /// Serde Error + #[error(transparent)] + Serde(#[from] serde_json::Error), } impl From for cdk::cdk_database::Error { diff --git a/crates/cdk-sqlite/src/mint/migrations/20240923153640_melt_requests.sql b/crates/cdk-sqlite/src/mint/migrations/20240923153640_melt_requests.sql new file mode 100644 index 000000000..3299bff1f --- /dev/null +++ b/crates/cdk-sqlite/src/mint/migrations/20240923153640_melt_requests.sql @@ -0,0 +1,8 @@ +-- Melt Request Table +CREATE TABLE IF NOT EXISTS melt_request ( +id TEXT PRIMARY KEY, +inputs TEXT NOT NULL, +outputs TEXT, +method TEXT NOT NULL, +unit TEXT NOT NULL +); diff --git a/crates/cdk-sqlite/src/mint/mod.rs b/crates/cdk-sqlite/src/mint/mod.rs index c2fdf48c7..7f5920e0a 100644 --- a/crates/cdk-sqlite/src/mint/mod.rs +++ b/crates/cdk-sqlite/src/mint/mod.rs @@ -12,10 +12,11 @@ use cdk::mint::{MintKeySetInfo, MintQuote}; use cdk::mint_url::MintUrl; use cdk::nuts::nut05::QuoteState; use cdk::nuts::{ - BlindSignature, CurrencyUnit, Id, MeltQuoteState, MintQuoteState, Proof, Proofs, PublicKey, - State, + BlindSignature, CurrencyUnit, Id, MeltBolt11Request, MeltQuoteState, MintQuoteState, + PaymentMethod, Proof, Proofs, PublicKey, State, }; use cdk::secret::Secret; +use cdk::types::LnKey; use cdk::{mint, Amount}; use error::Error; use lightning_invoice::Bolt11Invoice; @@ -1121,6 +1122,86 @@ WHERE keyset_id=?; } } } + + async fn add_melt_request( + &self, + melt_request: MeltBolt11Request, + ln_key: LnKey, + ) -> Result<(), Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + + let res = sqlx::query( + r#" +INSERT OR REPLACE INTO melt_request +(id, inputs, outputs, method, unit) +VALUES (?, ?, ?, ?, ?); + "#, + ) + .bind(melt_request.quote) + .bind(serde_json::to_string(&melt_request.inputs)?) + .bind(serde_json::to_string(&melt_request.outputs)?) + .bind(ln_key.method.to_string()) + .bind(ln_key.unit.to_string()) + .execute(&mut transaction) + .await; + + match res { + Ok(_) => { + transaction.commit().await.map_err(Error::from)?; + Ok(()) + } + Err(err) => { + tracing::error!("SQLite Could not update keyset"); + if let Err(err) = transaction.rollback().await { + tracing::error!("Could not rollback sql transaction: {}", err); + } + + Err(Error::from(err).into()) + } + } + } + + async fn get_melt_request( + &self, + quote_id: &str, + ) -> Result, Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + + let rec = sqlx::query( + r#" +SELECT * +FROM melt_request +WHERE id=?; + "#, + ) + .bind(quote_id) + .fetch_one(&mut transaction) + .await; + + match rec { + Ok(rec) => { + transaction.commit().await.map_err(Error::from)?; + + let (request, key) = sqlite_row_to_melt_request(rec)?; + + Ok(Some((request, key))) + } + Err(err) => match err { + sqlx::Error::RowNotFound => { + transaction.commit().await.map_err(Error::from)?; + return Ok(None); + } + _ => { + return { + if let Err(err) = transaction.rollback().await { + tracing::error!("Could not rollback sql transaction: {}", err); + } + Err(Error::SQLX(err).into()) + } + } + }, + } + } } fn sqlite_row_to_keyset_info(row: SqliteRow) -> Result { @@ -1259,3 +1340,24 @@ fn sqlite_row_to_blind_signature(row: SqliteRow) -> Result Result<(MeltBolt11Request, LnKey), Error> { + let quote_id: String = row.try_get("id").map_err(Error::from)?; + let row_inputs: String = row.try_get("inputs").map_err(Error::from)?; + let row_outputs: Option = row.try_get("outputs").map_err(Error::from)?; + let row_method: String = row.try_get("method").map_err(Error::from)?; + let row_unit: String = row.try_get("unit").map_err(Error::from)?; + + let melt_request = MeltBolt11Request { + quote: quote_id, + inputs: serde_json::from_str(&row_inputs)?, + outputs: row_outputs.and_then(|o| serde_json::from_str(&o).ok()), + }; + + let ln_key = LnKey { + unit: CurrencyUnit::from_str(&row_unit)?, + method: PaymentMethod::from_str(&row_method)?, + }; + + Ok((melt_request, ln_key)) +} diff --git a/crates/cdk-strike/Cargo.toml b/crates/cdk-strike/Cargo.toml index d97d18bf8..f48d53329 100644 --- a/crates/cdk-strike/Cargo.toml +++ b/crates/cdk-strike/Cargo.toml @@ -20,4 +20,6 @@ tokio = { version = "1", default-features = false } tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] } thiserror = "1" uuid = { version = "1", features = ["v4"] } -strike-rs = "0.3.0" +# strike-rs = "0.3.0" +# strike-rs = { path = "../../../../strike-rs" } +strike-rs = { git = "https://github.com/thesimplekid/strike-rs.git", rev = "c6167ce" } diff --git a/crates/cdk-strike/src/error.rs b/crates/cdk-strike/src/error.rs index 34dba31dd..b9915d8d8 100644 --- a/crates/cdk-strike/src/error.rs +++ b/crates/cdk-strike/src/error.rs @@ -11,6 +11,9 @@ pub enum Error { /// Unknown invoice #[error("Unknown invoice")] UnknownInvoice, + /// Strikers error + #[error(transparent)] + StrikeRs(#[from] strike_rs::Error), /// 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 17448068d..c918005ec 100644 --- a/crates/cdk-strike/src/lib.rs +++ b/crates/cdk-strike/src/lib.rs @@ -99,7 +99,7 @@ impl MintLightning for Strike { |(mut receiver, strike_api)| async move { match receiver.recv().await { Some(msg) => { - let check = strike_api.find_invoice(&msg).await; + let check = strike_api.get_incoming_invoice(&msg).await; match check { Ok(state) => { @@ -172,10 +172,8 @@ impl MintLightning for Strike { let total_spent = from_strike_amount(pay_response.total_amount, &melt_quote.unit)?.into(); - let bolt11: Bolt11Invoice = melt_quote.request.parse()?; - Ok(PayInvoiceResponse { - payment_hash: bolt11.payment_hash().to_string(), + payment_lookup_id: pay_response.payment_id, payment_preimage: None, status: state, total_spent, @@ -217,11 +215,14 @@ impl MintLightning for Strike { }) } - async fn check_invoice_status( + async fn check_incoming_invoice_status( &self, request_lookup_id: &str, ) -> Result { - let invoice = self.strike_api.find_invoice(request_lookup_id).await?; + let invoice = self + .strike_api + .get_incoming_invoice(request_lookup_id) + .await?; let state = match invoice.state { InvoiceState::Paid => MintQuoteState::Paid, @@ -232,6 +233,46 @@ impl MintLightning for Strike { Ok(state) } + + async fn check_outgoing_payment( + &self, + payment_id: &str, + ) -> Result { + let invoice = self.strike_api.get_outgoing_payment(payment_id).await; + + let pay_invoice_response = match invoice { + Ok(invoice) => { + let state = match invoice.state { + InvoiceState::Paid => MeltQuoteState::Paid, + InvoiceState::Unpaid => MeltQuoteState::Unpaid, + InvoiceState::Completed => MeltQuoteState::Paid, + InvoiceState::Pending => MeltQuoteState::Pending, + }; + + PayInvoiceResponse { + payment_lookup_id: invoice.payment_id, + payment_preimage: None, + status: state, + total_spent: from_strike_amount(invoice.total_amount, &self.unit)?.into(), + unit: self.unit, + } + } + Err(err) => match err { + strike_rs::Error::NotFound => PayInvoiceResponse { + payment_lookup_id: payment_id.to_string(), + payment_preimage: None, + status: MeltQuoteState::Unknown, + total_spent: Amount::ZERO, + unit: self.unit, + }, + _ => { + return Err(Error::from(err).into()); + } + }, + }; + + Ok(pay_invoice_response) + } } impl Strike { diff --git a/crates/cdk/src/cdk_database/mint_memory.rs b/crates/cdk/src/cdk_database/mint_memory.rs index 81f158867..ac9f18728 100644 --- a/crates/cdk/src/cdk_database/mint_memory.rs +++ b/crates/cdk/src/cdk_database/mint_memory.rs @@ -11,9 +11,10 @@ use crate::dhke::hash_to_curve; use crate::mint::{self, MintKeySetInfo, MintQuote}; use crate::nuts::nut07::State; use crate::nuts::{ - nut07, BlindSignature, CurrencyUnit, Id, MeltQuoteState, MintQuoteState, Proof, Proofs, - PublicKey, + nut07, BlindSignature, CurrencyUnit, Id, MeltBolt11Request, MeltQuoteState, MintQuoteState, + Proof, Proofs, PublicKey, }; +use crate::types::LnKey; /// Mint Memory Database #[derive(Debug, Clone, Default)] @@ -27,6 +28,7 @@ pub struct MintMemoryDatabase { quote_proofs: Arc>>>, blinded_signatures: Arc>>, quote_signatures: Arc>>>, + melt_requests: Arc>>, } impl MintMemoryDatabase { @@ -42,6 +44,7 @@ impl MintMemoryDatabase { quote_proofs: HashMap>, blinded_signatures: HashMap<[u8; 33], BlindSignature>, quote_signatures: HashMap>, + melt_request: Vec<(MeltBolt11Request, LnKey)>, ) -> Result { let mut proofs = HashMap::new(); let mut proof_states = HashMap::new(); @@ -58,6 +61,11 @@ impl MintMemoryDatabase { proof_states.insert(y, State::Spent); } + let melt_requests = melt_request + .into_iter() + .map(|(request, ln_key)| (request.quote.clone(), (request, ln_key))) + .collect(); + Ok(Self { active_keysets: Arc::new(RwLock::new(active_keysets)), keysets: Arc::new(RwLock::new( @@ -74,6 +82,7 @@ impl MintMemoryDatabase { blinded_signatures: Arc::new(RwLock::new(blinded_signatures)), quote_proofs: Arc::new(Mutex::new(quote_proofs)), quote_signatures: Arc::new(RwLock::new(quote_signatures)), + melt_requests: Arc::new(RwLock::new(melt_requests)), }) } } @@ -225,6 +234,27 @@ impl MintDatabase for MintMemoryDatabase { Ok(()) } + async fn add_melt_request( + &self, + melt_request: MeltBolt11Request, + ln_key: LnKey, + ) -> Result<(), Self::Err> { + let mut melt_requests = self.melt_requests.write().await; + melt_requests.insert(melt_request.quote.clone(), (melt_request, ln_key)); + Ok(()) + } + + async fn get_melt_request( + &self, + quote_id: &str, + ) -> Result, Self::Err> { + let melt_requests = self.melt_requests.read().await; + + let melt_request = melt_requests.get(quote_id); + + Ok(melt_request.cloned()) + } + async fn add_proofs(&self, proofs: Proofs, quote_id: Option) -> Result<(), Self::Err> { let mut db_proofs = self.proofs.write().await; diff --git a/crates/cdk/src/cdk_database/mod.rs b/crates/cdk/src/cdk_database/mod.rs index c10aac51c..ed0890afe 100644 --- a/crates/cdk/src/cdk_database/mod.rs +++ b/crates/cdk/src/cdk_database/mod.rs @@ -17,11 +17,15 @@ use crate::mint::MintQuote as MintMintQuote; #[cfg(feature = "wallet")] use crate::mint_url::MintUrl; #[cfg(feature = "mint")] +use crate::nuts::MeltBolt11Request; +#[cfg(feature = "mint")] use crate::nuts::{BlindSignature, MeltQuoteState, MintQuoteState, Proof, Proofs}; #[cfg(any(feature = "wallet", feature = "mint"))] use crate::nuts::{CurrencyUnit, Id, PublicKey, State}; #[cfg(feature = "wallet")] use crate::nuts::{KeySetInfo, Keys, MintInfo, SpendingConditions}; +#[cfg(feature = "mint")] +use crate::types::LnKey; #[cfg(feature = "wallet")] use crate::types::ProofInfo; #[cfg(feature = "wallet")] @@ -220,6 +224,18 @@ pub trait MintDatabase { /// Remove [`mint::MeltQuote`] async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err>; + /// Add melt request + async fn add_melt_request( + &self, + melt_request: MeltBolt11Request, + ln_key: LnKey, + ) -> Result<(), Self::Err>; + /// Get melt request + async fn get_melt_request( + &self, + quote_id: &str, + ) -> Result, Self::Err>; + /// Add [`MintKeySetInfo`] async fn add_keyset_info(&self, keyset: MintKeySetInfo) -> Result<(), Self::Err>; /// Get [`MintKeySetInfo`] diff --git a/crates/cdk/src/cdk_lightning/mod.rs b/crates/cdk/src/cdk_lightning/mod.rs index 3542a1688..663eee45f 100644 --- a/crates/cdk/src/cdk_lightning/mod.rs +++ b/crates/cdk/src/cdk_lightning/mod.rs @@ -26,6 +26,9 @@ pub enum Error { /// Unsupported unit #[error("Unsupported unit")] UnsupportedUnit, + /// Payment state is unknown + #[error("Payment state is unknown")] + UnknownPaymentState, /// Lightning Error #[error(transparent)] Lightning(Box), @@ -83,10 +86,16 @@ pub trait MintLightning { ) -> Result + Send>>, Self::Err>; /// Check the status of an incoming payment - async fn check_invoice_status( + async fn check_incoming_invoice_status( &self, request_lookup_id: &str, ) -> Result; + + /// Check the status of an outgoing payment + async fn check_outgoing_payment( + &self, + request_lookup_id: &str, + ) -> Result; } /// Create invoice response @@ -104,7 +113,7 @@ pub struct CreateInvoiceResponse { #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct PayInvoiceResponse { /// Payment hash - pub payment_hash: String, + pub payment_lookup_id: String, /// Payment Preimage pub payment_preimage: Option, /// Status diff --git a/crates/cdk/src/error.rs b/crates/cdk/src/error.rs index 80ef55faa..5a39d7312 100644 --- a/crates/cdk/src/error.rs +++ b/crates/cdk/src/error.rs @@ -25,6 +25,9 @@ pub enum Error { /// Payment failed #[error("Payment failed")] PaymentFailed, + /// Payment pending + #[error("Payment pending")] + PaymentPending, /// Invoice already paid #[error("Request already paid")] RequestAlreadyPaid, @@ -66,6 +69,9 @@ pub enum Error { /// Quote has already been paid #[error("Quote is already paid")] PaidQuote, + /// Payment state is unknown + #[error("Payment state is unknown")] + UnknownPaymentState, /// Melting is disabled #[error("Minting is disabled")] MeltingDisabled, @@ -352,6 +358,11 @@ impl From for ErrorResponse { error: Some(err.to_string()), detail: None, }, + Error::TokenPending => ErrorResponse { + code: ErrorCode::TokenPending, + error: Some(err.to_string()), + detail: None, + }, _ => ErrorResponse { code: ErrorCode::Unknown(9999), error: Some(err.to_string()), @@ -380,6 +391,7 @@ impl From for Error { ErrorCode::AmountOutofLimitRange => { Self::AmountOutofLimitRange(Amount::default(), Amount::default(), Amount::default()) } + ErrorCode::TokenPending => Self::TokenPending, _ => Self::UnknownErrorResponse(err.to_string()), } } @@ -390,6 +402,8 @@ impl From for Error { pub enum ErrorCode { /// Token is already spent TokenAlreadySpent, + /// Token Pending + TokenPending, /// Quote is not paid QuoteNotPaid, /// Quote is not expired @@ -432,6 +446,7 @@ impl ErrorCode { 11002 => Self::TransactionUnbalanced, 11005 => Self::UnitUnsupported, 11006 => Self::AmountOutofLimitRange, + 11007 => Self::TokenPending, 12001 => Self::KeysetNotFound, 12002 => Self::KeysetInactive, 20000 => Self::LightningError, @@ -454,6 +469,7 @@ impl ErrorCode { Self::TransactionUnbalanced => 11002, Self::UnitUnsupported => 11005, Self::AmountOutofLimitRange => 11006, + Self::TokenPending => 11007, Self::KeysetNotFound => 12001, Self::KeysetInactive => 12002, Self::LightningError => 20000, diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index f656dbe8b..2fd52d20b 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; use bitcoin::secp256k1::{self, Secp256k1}; +use lightning_invoice::Bolt11Invoice; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tracing::instrument; @@ -13,6 +14,7 @@ use tracing::instrument; use self::nut05::QuoteState; use self::nut11::EnforceSigFlag; use crate::cdk_database::{self, MintDatabase}; +use crate::cdk_lightning::to_unit; use crate::dhke::{hash_to_curve, sign_message, verify_message}; use crate::error::Error; use crate::fees::calculate_fee; @@ -993,6 +995,117 @@ impl Mint { Ok(()) } + /// Check melt has expected fees + #[instrument(skip_all)] + 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) + .map_err(|_| Error::UnitUnsupported)?, + ) + } + false => 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 inputs_amount_quote_unit = melt_request.proofs_amount().map_err(|_| { + tracing::error!("Proof inputs in melt quote overflowed"); + Error::AmountOverflow + })?; + + if amount_to_pay + melt_quote.fee_reserve > inputs_amount_quote_unit { + tracing::debug!( + "Not enough inputs provided: {} msats needed {} msats", + inputs_amount_quote_unit, + amount_to_pay + ); + + return Err(Error::TransactionUnbalanced( + inputs_amount_quote_unit.into(), + amount_to_pay.into(), + melt_quote.fee_reserve.into(), + )); + } + + Ok(partial_amount) + } + + /// Verify melt request is valid + /// Check to see if there is a corresponding mint quote for a melt. + /// In this case the mint can settle the payment internally and no ln payment is + /// needed + #[instrument(skip_all)] + pub async fn handle_internal_melt_mint( + &self, + melt_quote: &MeltQuote, + melt_request: &MeltBolt11Request, + ) -> Result, Error> { + let mint_quote = match self + .localstore + .get_mint_quote_by_request(&melt_quote.request) + .await + { + Ok(Some(mint_quote)) => mint_quote, + // Not an internal melt -> mint + Ok(None) => return Ok(None), + Err(err) => { + tracing::debug!("Error attempting to get mint quote: {}", err); + return Err(Error::Internal); + } + }; + + // Mint quote has already been settled, proofs should not be burned or held. + if mint_quote.state == MintQuoteState::Issued || mint_quote.state == MintQuoteState::Paid { + 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 { + tracing::debug!( + "Not enough inuts provided: {} needed {}", + inputs_amount_quote_unit, + mint_quote.amount + ); + return Err(Error::InsufficientFunds); + } + + mint_quote.state = MintQuoteState::Paid; + + let amount = melt_quote.amount; + + self.update_mint_quote(mint_quote).await?; + + Ok(Some(amount)) + } + /// Verify melt request is valid #[instrument(skip_all)] pub async fn verify_melt_request( @@ -1005,13 +1118,16 @@ impl Mint { .await?; match state { - MeltQuoteState::Unpaid => (), + MeltQuoteState::Unpaid | MeltQuoteState::Failed => (), MeltQuoteState::Pending => { return Err(Error::PendingQuote); } MeltQuoteState::Paid => { return Err(Error::PaidQuote); } + MeltQuoteState::Unknown => { + return Err(Error::UnknownPaymentState); + } } let ys = melt_request @@ -1456,6 +1572,8 @@ mod tests { use bitcoin::Network; use secp256k1::Secp256k1; + use crate::types::LnKey; + use super::*; #[test] @@ -1561,6 +1679,7 @@ mod tests { seed: &'a [u8], mint_info: MintInfo, supported_units: HashMap, + melt_requests: Vec<(MeltBolt11Request, LnKey)>, } async fn create_mint(config: MintConfig<'_>) -> Result { @@ -1575,6 +1694,7 @@ mod tests { config.quote_proofs, config.blinded_signatures, config.quote_signatures, + config.melt_requests, ) .unwrap(), ); diff --git a/crates/cdk/src/nuts/nut05.rs b/crates/cdk/src/nuts/nut05.rs index 52441f8f7..00a8f7082 100644 --- a/crates/cdk/src/nuts/nut05.rs +++ b/crates/cdk/src/nuts/nut05.rs @@ -48,6 +48,10 @@ pub enum QuoteState { Paid, /// Paying quote is in progress Pending, + /// Unknown state + Unknown, + /// Failed + Failed, } impl fmt::Display for QuoteState { @@ -56,6 +60,8 @@ impl fmt::Display for QuoteState { Self::Unpaid => write!(f, "UNPAID"), Self::Paid => write!(f, "PAID"), Self::Pending => write!(f, "PENDING"), + Self::Unknown => write!(f, "UNKNOWN"), + Self::Failed => write!(f, "FAILED"), } } } @@ -68,6 +74,8 @@ impl FromStr for QuoteState { "PENDING" => Ok(Self::Pending), "PAID" => Ok(Self::Paid), "UNPAID" => Ok(Self::Unpaid), + "UNKNOWN" => Ok(Self::Unknown), + "FAILED" => Ok(Self::Failed), _ => Err(Error::UnknownState), } } diff --git a/crates/cdk/src/types.rs b/crates/cdk/src/types.rs index e393ce5ac..ad75a7529 100644 --- a/crates/cdk/src/types.rs +++ b/crates/cdk/src/types.rs @@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize}; use crate::error::Error; use crate::mint_url::MintUrl; use crate::nuts::{ - CurrencyUnit, MeltQuoteState, Proof, Proofs, PublicKey, SpendingConditions, State, + CurrencyUnit, MeltQuoteState, PaymentMethod, Proof, Proofs, PublicKey, SpendingConditions, + State, }; use crate::Amount; @@ -137,6 +138,23 @@ impl ProofInfo { } } +/// Key used in hashmap of ln backends to identify what unit and payment method +/// it is for +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct LnKey { + /// Unit of Payment backend + pub unit: CurrencyUnit, + /// Method of payment backend + pub method: PaymentMethod, +} + +impl LnKey { + /// Create new [`LnKey`] + pub fn new(unit: CurrencyUnit, method: PaymentMethod) -> Self { + Self { unit, method } + } +} + #[cfg(test)] mod tests { use std::str::FromStr; diff --git a/misc/fake_itests.sh b/misc/fake_itests.sh new file mode 100755 index 000000000..fac00d263 --- /dev/null +++ b/misc/fake_itests.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +# Function to perform cleanup +cleanup() { + echo "Cleaning up..." + + # Kill the Rust binary process + echo "Killing the Rust binary with PID $RUST_BIN_PID" + kill $CDK_ITEST_MINT_BIN_PID + + # Wait for the Rust binary to terminate + wait $CDK_ITEST_MINT_BIN_PID + + echo "Mint binary terminated" + + # Remove the temporary directory + rm -rf "$cdk_itests" + echo "Temp directory removed: $cdk_itests" + unset cdk_itests + unset cdk_itests_mint_addr + unset cdk_itests_mint_port +} + +# Set up trap to call cleanup on script exit +trap cleanup EXIT + +# Create a temporary directory +export cdk_itests=$(mktemp -d) +export cdk_itests_mint_addr="127.0.0.1"; +export cdk_itests_mint_port=8086; + +URL="http://$cdk_itests_mint_addr:$cdk_itests_mint_port/v1/info" +# Check if the temporary directory was created successfully +if [[ ! -d "$cdk_itests" ]]; then + echo "Failed to create temp directory" + exit 1 +fi + +echo "Temp directory created: $cdk_itests" +export MINT_DATABASE="$1"; + +cargo build -p cdk-integration-tests +cargo build --bin fake_wallet +cargo run --bin fake_wallet & +# Capture its PID +CDK_ITEST_MINT_BIN_PID=$! + +TIMEOUT=100 +START_TIME=$(date +%s) +# Loop until the endpoint returns a 200 OK status or timeout is reached +while true; do + # Get the current time + CURRENT_TIME=$(date +%s) + + # Calculate the elapsed time + ELAPSED_TIME=$((CURRENT_TIME - START_TIME)) + + # Check if the elapsed time exceeds the timeout + if [ $ELAPSED_TIME -ge $TIMEOUT ]; then + echo "Timeout of $TIMEOUT seconds reached. Exiting..." + exit 1 + fi + + # Make a request to the endpoint and capture the HTTP status code + HTTP_STATUS=$(curl -o /dev/null -s -w "%{http_code}" $URL) + + # Check if the HTTP status is 200 OK + if [ "$HTTP_STATUS" -eq 200 ]; then + echo "Received 200 OK from $URL" + break + else + echo "Waiting for 200 OK response, current status: $HTTP_STATUS" + sleep 2 # Wait for 2 seconds before retrying + fi +done + + +# Run cargo test +cargo test -p cdk-integration-tests --test fake_wallet +cargo test -p cdk-integration-tests --test mint + +# Capture the exit status of cargo test +test_status=$? + +# Exit with the status of the tests +exit $test_status diff --git a/misc/test.just b/misc/test.just index 29f81dbaa..8b48df95b 100644 --- a/misc/test.just +++ b/misc/test.just @@ -2,3 +2,8 @@ itest db: #!/usr/bin/env bash ./misc/itests.sh "{{db}}" + +fake-mint-itest db: + #!/usr/bin/env bash + ./misc/fake_itests.sh "{{db}}" +