feat: check outgoing payment status flow

This commit is contained in:
thesimplekid
2024-09-19 14:52:49 +02:00
parent a87117f55f
commit 5139c47dac
38 changed files with 1678 additions and 340 deletions

View File

@@ -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:
@@ -103,6 +103,35 @@ jobs:
- 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"
runs-on: ubuntu-latest

View File

@@ -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 }
}
}

View File

@@ -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<MintState>,
Json(payload): Json<MeltBolt11Request>,
) -> Result<Json<MeltBolt11Response>, Response> {
use std::sync::Arc;
async fn check_payment_state(
ln: Arc<dyn MintLightning<Err = cdk::cdk_lightning::Error> + Send + Sync>,
melt_quote: &MeltQuote,
) -> Result<PayInvoiceResponse> {
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,139 +231,53 @@ 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(&quote.request)
.await
{
Ok(mint_quote) => mint_quote,
let settled_internally_amount =
match state.mint.handle_internal_melt_mint(&quote, &payload).await {
Ok(amount) => amount,
Err(err) => {
tracing::debug!("Error attempting to get mint quote: {}", 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);
}
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
tracing::error!(
"Could not reset melt quote {} state: {}",
payload.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::InsufficientFunds));
}
mint_quote.state = MintQuoteState::Paid;
let amount = quote.amount;
if let Err(_err) = state.mint.update_mint_quote(mint_quote).await {
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, amount)
}
None => {
let invoice = match Bolt11Invoice::from_str(&quote.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));
return Err(into_response(err));
}
};
let mut partial_amount = None;
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 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, &quote.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 => {
// > `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(&quote, &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::InvoiceAmountUndefined));
return Err(into_response(Error::Internal));
}
}
}
_ => None,
};
partial_amount = match invoice_amount_msats > quote_msats {
true => {
let partial_msats = invoice_amount_msats - quote_msats;
Some(
to_unit(partial_msats, &CurrencyUnit::Msat, &quote.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, &quote.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(),
)));
}
}
let ln = match state.ln.get(&LnKey::new(quote.unit, PaymentMethod::Bolt11)) {
Some(ln) => ln,
None => {
@@ -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), &quote)
.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 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), &quote)
.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 {
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
let amount_spent = to_unit(pre.total_spent, &pre.unit, &quote.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, &quote.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)

View File

@@ -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),

View File

@@ -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<Amount>,
max_fee: Option<Amount>,
) -> Result<PayInvoiceResponse, Self::Err> {
let mut cln_client = self.cln_client.lock().await;
let bolt11 = Bolt11Invoice::from_str(&melt_quote.request)?;
let pay_state = self
.check_outgoing_payment(&bolt11.payment_hash().to_string())
.await?;
let pay_state =
check_pay_invoice_status(&mut cln_client, melt_quote.request.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<MintQuoteState, Self::Err> {
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<PayInvoiceResponse, Self::Err> {
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<MeltQuoteState, cdk_lightning::Error> {
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,
fn cln_pays_status_to_mint_state(status: ListpaysPaysStatus) -> MeltQuoteState {
match status {
ListpaysPaysStatus::PENDING => MeltQuoteState::Pending,
ListpaysPaysStatus::FAILED => MeltQuoteState::Unpaid,
},
None => MeltQuoteState::Unpaid,
ListpaysPaysStatus::COMPLETE => MeltQuoteState::Paid,
ListpaysPaysStatus::FAILED => MeltQuoteState::Failed,
}
}
_ => {
tracing::warn!("CLN returned wrong response kind. When checking pay status");
return Err(cdk_lightning::Error::from(Error::WrongClnResponse));
}
};
Ok(state)
}

View File

@@ -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"

View File

@@ -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<Mutex<Option<tokio::sync::mpsc::Receiver<String>>>>,
mint_settings: MintMethodSettings,
melt_settings: MeltMethodSettings,
payment_states: Arc<Mutex<HashMap<String, MeltQuoteState>>>,
failed_payment_check: Arc<Mutex<HashSet<String>>>,
payment_delay: u64,
}
impl FakeWallet {
@@ -50,6 +56,9 @@ impl FakeWallet {
fee_reserve: FeeReserve,
mint_settings: MintMethodSettings,
melt_settings: MeltMethodSettings,
payment_states: HashMap<String, MeltQuoteState>,
fail_payment_check: HashSet<String>,
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<Amount>,
_max_fee_msats: Option<Amount>,
) -> Result<PayInvoiceResponse, Self::Err> {
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<FakeInvoiceDescription> = 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,8 +200,72 @@ impl MintLightning for FakeWallet {
let time_now = unix_time();
assert!(unix_expiry > time_now);
let label = Uuid::new_v4().to_string();
let amount_msat = to_unit(amount, unit, &CurrencyUnit::Msat)?;
let invoice = create_fake_invoice(amount_msat.into(), description);
let sender = self.sender.clone();
let payment_hash = invoice.payment_hash();
let payment_hash_clone = payment_hash.to_string();
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(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: payment_hash.to_string(),
request: invoice,
expiry,
})
}
async fn check_incoming_invoice_status(
&self,
_request_lookup_id: &str,
) -> Result<MintQuoteState, Self::Err> {
Ok(MintQuoteState::Paid)
}
async fn check_outgoing_payment(
&self,
request_lookup_id: &str,
) -> Result<PayInvoiceResponse, Self::Err> {
// 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,
@@ -154,51 +275,20 @@ impl MintLightning for FakeWallet {
)
.unwrap();
let payment_hash = sha256::Hash::from_slice(&[0; 32][..]).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]);
let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?;
let invoice = InvoiceBuilder::new(Currency::Bitcoin)
InvoiceBuilder::new(Currency::Bitcoin)
.description(description)
.payment_hash(payment_hash)
.payment_secret(payment_secret)
.amount_milli_satoshis(amount.into())
.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();
// Create a random delay between 3 and 6 seconds
let duration = time::Duration::from_secs(3)
+ time::Duration::from_millis(rand::random::<u64>() % 3001);
let sender = self.sender.clone();
let label_clone = label.clone();
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);
}
});
let expiry = invoice.expires_at().map(|t| t.as_secs());
Ok(CreateInvoiceResponse {
request_lookup_id: label,
request: invoice,
expiry,
})
}
async fn check_invoice_status(
&self,
_request_lookup_id: &str,
) -> Result<MintQuoteState, Self::Err> {
Ok(MintQuoteState::Paid)
}
.unwrap()
}

View File

@@ -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"] }

View File

@@ -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(())
}

View File

@@ -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<D>(addr: &str, port: u16, database: D) -> Result<()>
where
D: MintDatabase<Err = cdk_database::Error> + 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<dyn MintLightning<Err = cdk::cdk_lightning::Error> + 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(())
}

View File

@@ -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::{

View File

@@ -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(())
}

View File

@@ -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(())
}

View File

@@ -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";

View File

@@ -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" }

View File

@@ -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<MintQuoteState, Self::Err> {
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<PayInvoiceResponse, Self::Err> {
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 {

View File

@@ -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<Error> for cdk::cdk_lightning::Error {

View File

@@ -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<MintQuoteState, Self::Err> {
@@ -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<PayInvoiceResponse, Self::Err> {
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())
}
}

View File

@@ -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<Mint>, 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<Mint>,
ln: Arc<dyn MintLightning<Err = cdk_lightning::Error> + 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(&quote.id, state)
.await?;
@@ -539,6 +546,93 @@ async fn check_pending_quotes(
Ok(())
}
async fn check_pending_melt_quotes(
mint: Arc<Mint>,
ln_backends: &HashMap<LnKey, Arc<dyn MintLightning<Err = cdk_lightning::Error> + Send + Sync>>,
) -> Result<()> {
let melt_quotes = mint.localstore.get_melt_quotes().await?;
let pending_quotes: Vec<MeltQuote> = 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<PathBuf> {
if path.starts_with('~') {
if let Some(home_dir) = home::home_dir().as_mut() {

View File

@@ -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"] }

View File

@@ -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),

View File

@@ -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<MintQuoteState, Self::Err> {
async fn check_incoming_invoice_status(
&self,
payment_hash: &str,
) -> Result<MintQuoteState, Self::Err> {
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<PaymentQuoteResponse, Error> {
let res = self.phoenixd_api.get_outgoing_invoice(payment_hash).await?;
payment_id: &str,
) -> Result<PayInvoiceResponse, Self::Err> {
let res = self.phoenixd_api.get_outgoing_invoice(payment_id).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 {
let state = match res {
Ok(res) => {
let status = 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,
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)
}
}

View File

@@ -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<Option<(MeltBolt11Request, LnKey)>, 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),
}
}
}

View File

@@ -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<Error> for cdk::cdk_database::Error {

View File

@@ -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
);

View File

@@ -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<Option<(MeltBolt11Request, LnKey)>, 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<MintKeySetInfo, Error> {
@@ -1259,3 +1340,24 @@ fn sqlite_row_to_blind_signature(row: SqliteRow) -> Result<BlindSignature, Error
dleq: None,
})
}
fn sqlite_row_to_melt_request(row: SqliteRow) -> 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<String> = 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))
}

View File

@@ -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" }

View File

@@ -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),

View File

@@ -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<MintQuoteState, Self::Err> {
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<PayInvoiceResponse, Self::Err> {
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 {

View File

@@ -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<Mutex<HashMap<String, Vec<PublicKey>>>>,
blinded_signatures: Arc<RwLock<HashMap<[u8; 33], BlindSignature>>>,
quote_signatures: Arc<RwLock<HashMap<String, Vec<BlindSignature>>>>,
melt_requests: Arc<RwLock<HashMap<String, (MeltBolt11Request, LnKey)>>>,
}
impl MintMemoryDatabase {
@@ -42,6 +44,7 @@ impl MintMemoryDatabase {
quote_proofs: HashMap<String, Vec<PublicKey>>,
blinded_signatures: HashMap<[u8; 33], BlindSignature>,
quote_signatures: HashMap<String, Vec<BlindSignature>>,
melt_request: Vec<(MeltBolt11Request, LnKey)>,
) -> Result<Self, Error> {
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<Option<(MeltBolt11Request, LnKey)>, 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<String>) -> Result<(), Self::Err> {
let mut db_proofs = self.proofs.write().await;

View File

@@ -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<Option<(MeltBolt11Request, LnKey)>, Self::Err>;
/// Add [`MintKeySetInfo`]
async fn add_keyset_info(&self, keyset: MintKeySetInfo) -> Result<(), Self::Err>;
/// Get [`MintKeySetInfo`]

View File

@@ -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<dyn std::error::Error + Send + Sync>),
@@ -83,10 +86,16 @@ pub trait MintLightning {
) -> Result<Pin<Box<dyn Stream<Item = String> + 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<MintQuoteState, Self::Err>;
/// Check the status of an outgoing payment
async fn check_outgoing_payment(
&self,
request_lookup_id: &str,
) -> Result<PayInvoiceResponse, Self::Err>;
}
/// 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<String>,
/// Status

View File

@@ -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<Error> 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<ErrorResponse> 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<ErrorResponse> 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,

View File

@@ -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<Option<Amount>, 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<Option<Amount>, 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<CurrencyUnit, (u64, u8)>,
melt_requests: Vec<(MeltBolt11Request, LnKey)>,
}
async fn create_mint(config: MintConfig<'_>) -> Result<Mint, Error> {
@@ -1575,6 +1694,7 @@ mod tests {
config.quote_proofs,
config.blinded_signatures,
config.quote_signatures,
config.melt_requests,
)
.unwrap(),
);

View File

@@ -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),
}
}

View File

@@ -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;

86
misc/fake_itests.sh Executable file
View File

@@ -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

View File

@@ -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}}"