refactor: ln backends within mint

This commit is contained in:
thesimplekid
2024-09-26 12:58:19 +02:00
parent 5139c47dac
commit 008c913583
10 changed files with 534 additions and 529 deletions

View File

@@ -3,34 +3,19 @@
#![warn(missing_docs)]
#![warn(rustdoc::bare_urls)]
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use anyhow::Result;
use axum::routing::{get, post};
use axum::Router;
use cdk::cdk_lightning::{self, MintLightning};
use cdk::mint::Mint;
use cdk::mint_url::MintUrl;
use cdk::types::LnKey;
use router_handlers::*;
mod router_handlers;
/// Create mint [`Router`] with required endpoints for cashu mint
pub async fn create_mint_router(
mint_url: &str,
mint: Arc<Mint>,
ln: HashMap<LnKey, Arc<dyn MintLightning<Err = cdk_lightning::Error> + Send + Sync>>,
quote_ttl: u64,
) -> Result<Router> {
let state = MintState {
ln,
mint,
mint_url: MintUrl::from_str(mint_url)?,
quote_ttl,
};
pub async fn create_mint_router(mint: Arc<Mint>) -> Result<Router> {
let state = MintState { mint };
let v1_router = Router::new()
.route("/keys", get(get_keys))
@@ -61,8 +46,5 @@ pub async fn create_mint_router(
/// CDK Mint State
#[derive(Clone)]
pub struct MintState {
ln: HashMap<LnKey, Arc<dyn MintLightning<Err = cdk_lightning::Error> + Send + Sync>>,
mint: Arc<Mint>,
mint_url: MintUrl,
quote_ttl: u64,
}

View File

@@ -1,21 +1,18 @@
use anyhow::{bail, Result};
use anyhow::Result;
use axum::extract::{Json, Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use cdk::cdk_lightning::{to_unit, MintLightning, PayInvoiceResponse};
use cdk::error::{Error, ErrorResponse};
use cdk::mint::MeltQuote;
use cdk::error::ErrorResponse;
use cdk::nuts::nut05::MeltBolt11Response;
use cdk::nuts::{
CheckStateRequest, CheckStateResponse, CurrencyUnit, Id, KeysResponse, KeysetResponse,
MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltQuoteState,
MintBolt11Request, MintBolt11Response, MintInfo, MintQuoteBolt11Request,
MintQuoteBolt11Response, PaymentMethod, RestoreRequest, RestoreResponse, SwapRequest,
SwapResponse,
CheckStateRequest, CheckStateResponse, Id, KeysResponse, KeysetResponse, MeltBolt11Request,
MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, MintBolt11Response,
MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, RestoreRequest, RestoreResponse,
SwapRequest, SwapResponse,
};
use cdk::util::unix_time;
use crate::{LnKey, MintState};
use crate::MintState;
pub async fn get_keys(State(state): State<MintState>) -> Result<Json<KeysResponse>, Response> {
let pubkeys = state.mint.pubkeys().await.map_err(|err| {
@@ -51,52 +48,13 @@ pub async fn get_mint_bolt11_quote(
State(state): State<MintState>,
Json(payload): Json<MintQuoteBolt11Request>,
) -> Result<Json<MintQuoteBolt11Response>, Response> {
let ln = state
.ln
.get(&LnKey::new(payload.unit, PaymentMethod::Bolt11))
.ok_or_else(|| {
tracing::info!("Bolt11 mint request for unsupported unit");
into_response(Error::UnitUnsupported)
})?;
let quote_expiry = unix_time() + state.quote_ttl;
if payload.description.is_some() && !ln.get_settings().invoice_description {
tracing::error!("Backend does not support invoice description");
return Err(into_response(Error::InvoiceDescriptionUnsupported));
}
let create_invoice_response = ln
.create_invoice(
payload.amount,
&payload.unit,
payload.description.unwrap_or("".to_string()),
quote_expiry,
)
.await
.map_err(|err| {
tracing::error!("Could not create invoice: {}", err);
into_response(Error::InvalidPaymentRequest)
})?;
let quote = state
.mint
.new_mint_quote(
state.mint_url,
create_invoice_response.request.to_string(),
payload.unit,
payload.amount,
create_invoice_response.expiry.unwrap_or(0),
create_invoice_response.request_lookup_id,
)
.get_mint_bolt11_quote(payload)
.await
.map_err(|err| {
tracing::error!("Could not create new mint quote: {}", err);
into_response(err)
})?;
.map_err(into_response)?;
Ok(Json(quote.into()))
Ok(Json(quote))
}
pub async fn get_check_mint_bolt11_quote(
@@ -135,42 +93,13 @@ pub async fn get_melt_bolt11_quote(
State(state): State<MintState>,
Json(payload): Json<MeltQuoteBolt11Request>,
) -> Result<Json<MeltQuoteBolt11Response>, Response> {
let ln = state
.ln
.get(&LnKey::new(payload.unit, PaymentMethod::Bolt11))
.ok_or_else(|| {
tracing::info!("Could not get ln backend for {}, bolt11 ", payload.unit);
into_response(Error::UnitUnsupported)
})?;
let payment_quote = ln.get_payment_quote(&payload).await.map_err(|err| {
tracing::error!(
"Could not get payment quote for mint quote, {} bolt11, {}",
payload.unit,
err
);
into_response(Error::UnitUnsupported)
})?;
let quote = state
.mint
.new_melt_quote(
payload.request.to_string(),
payload.unit,
payment_quote.amount,
payment_quote.fee,
unix_time() + state.quote_ttl,
payment_quote.request_lookup_id,
)
.get_melt_bolt11_quote(&payload)
.await
.map_err(|err| {
tracing::error!("Could not create melt quote: {}", err);
into_response(err)
})?;
.map_err(into_response)?;
Ok(Json(quote.into()))
Ok(Json(quote))
}
pub async fn get_check_melt_bolt11_quote(
@@ -193,206 +122,13 @@ pub async fn post_melt_bolt11(
State(state): State<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) => {
tracing::debug!("Error attempting to verify melt quote: {}", err);
if let Err(err) = state.mint.process_unpaid_melt(&payload).await {
tracing::error!(
"Could not reset melt quote {} state: {}",
payload.quote,
err
);
}
return Err(into_response(err));
}
};
let settled_internally_amount =
match state.mint.handle_internal_melt_mint(&quote, &payload).await {
Ok(amount) => amount,
Err(err) => {
tracing::error!("Attempting to settle internally failed");
if let Err(err) = state.mint.process_unpaid_melt(&payload).await {
tracing::error!(
"Could not reset melt quote {} state: {}",
payload.quote,
err
);
}
return Err(into_response(err));
}
};
let (preimage, amount_spent_quote_unit) = match settled_internally_amount {
Some(amount_spent) => (None, amount_spent),
None => {
// If the quote unit is SAT or MSAT we can check that the expected fees are
// provided. We also check if the quote is less then the invoice
// amount in the case that it is a mmp However, if the quote is not
// of a bitcoin unit we cannot do these checks as the mint
// is unaware of a conversion rate. In this case it is assumed that the quote is
// correct and the mint should pay the full invoice amount if inputs
// > `then quote.amount` are included. This is checked in the
// `verify_melt` method.
let partial_amount = match quote.unit {
CurrencyUnit::Sat | CurrencyUnit::Msat => {
match state
.mint
.check_melt_expected_ln_fees(&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::Internal));
}
}
}
_ => None,
};
let ln = match state.ln.get(&LnKey::new(quote.unit, PaymentMethod::Bolt11)) {
Some(ln) => ln,
None => {
tracing::info!("Could not get ln backend for {}, bolt11 ", quote.unit);
if let Err(err) = state.mint.process_unpaid_melt(&payload).await {
tracing::error!("Could not reset melt quote state: {}", err);
}
return Err(into_response(Error::UnitUnsupported));
}
};
let pre = match ln
.pay_invoice(quote.clone(), partial_amount, Some(quote.fee_reserve))
.await
{
Ok(pay)
if pay.status == MeltQuoteState::Unknown
|| pay.status == MeltQuoteState::Failed =>
{
let check_response = check_payment_state(Arc::clone(ln), &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) => {
// If the error is that the invoice was already paid we do not want to hold
// hold the proofs as pending to we reset them and return an error.
if matches!(err, cdk::cdk_lightning::Error::InvoiceAlreadyPaid) {
tracing::debug!("Invoice already paid, resetting melt quote");
if let Err(err) = state.mint.process_unpaid_melt(&payload).await {
tracing::error!("Could not reset melt quote state: {}", err);
}
return Err(into_response(Error::RequestAlreadyPaid));
}
tracing::error!("Error returned attempting to pay: {} {}", quote.id, err);
let check_response = check_payment_state(Arc::clone(ln), &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
}
};
match pre.status {
MeltQuoteState::Paid => (),
MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => {
tracing::info!("Lightning payment for quote {} failed.", payload.quote);
if let Err(err) = state.mint.process_unpaid_melt(&payload).await {
tracing::error!("Could not reset melt quote state: {}", err);
}
return Err(into_response(Error::PaymentFailed));
}
MeltQuoteState::Pending => {
tracing::warn!(
"LN payment pending, proofs are stuck as pending for quote: {}",
payload.quote
);
return Err(into_response(Error::PendingQuote));
}
}
// Convert from unit of backend to quote unit
// Note: this should never fail since these conversions happen earlier and would fail there.
// Since it will not fail and even if it does the ln payment has already been paid, proofs should still be burned
let amount_spent = to_unit(pre.total_spent, &pre.unit, &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
);
let mut melt_quote = quote;
melt_quote.request_lookup_id = payment_lookup_id;
if let Err(err) = state.mint.localstore.add_melt_quote(melt_quote).await {
tracing::warn!("Could not update payment lookup id: {}", err);
}
}
(pre.payment_preimage, amount_spent)
}
};
// If we made it here the payment has been made.
// We process the melt burning the inputs and returning change
let res = state
.mint
.process_melt_request(&payload, preimage, amount_spent_quote_unit)
.melt_bolt11(&payload)
.await
.map_err(|err| {
tracing::error!("Could not process melt request: {}", err);
into_response(err)
})?;
.map_err(into_response)?;
Ok(Json(res.into()))
Ok(Json(res))
}
pub async fn post_check(

View File

@@ -13,11 +13,11 @@ use cdk::{
types::LnKey,
};
use cdk_fake_wallet::FakeWallet;
use futures::StreamExt;
use tokio::sync::Notify;
use tower_http::cors::CorsLayer;
use tracing_subscriber::EnvFilter;
use crate::{handle_paid_invoice, init_regtest::create_mint};
use crate::init_regtest::create_mint;
pub async fn start_fake_mint<D>(addr: &str, port: u16, database: D) -> Result<()>
where
@@ -36,7 +36,10 @@ where
// Parse input
tracing_subscriber::fmt().with_env_filter(env_filter).init();
let mint = create_mint(database).await?;
let mut ln_backends: HashMap<
LnKey,
Arc<dyn MintLightning<Err = cdk::cdk_lightning::Error> + Sync + Send>,
> = HashMap::new();
let fee_reserve = FeeReserve {
min_fee_reserve: 1.into(),
@@ -52,28 +55,18 @@ where
0,
);
let mut ln_backends: HashMap<
LnKey,
Arc<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 = create_mint(database, ln_backends.clone()).await?;
let mint_arc = Arc::new(mint);
let v1_service = cdk_axum::create_mint_router(
&format!("http://{}:{}", addr, port),
Arc::clone(&mint_arc),
ln_backends.clone(),
quote_ttl,
)
.await
.unwrap();
let v1_service = cdk_axum::create_mint_router(Arc::clone(&mint_arc))
.await
.unwrap();
let mint_service = Router::new()
.merge(v1_service)
@@ -81,28 +74,13 @@ where
let mint = Arc::clone(&mint_arc);
for wallet in ln_backends.values() {
let wallet_clone = Arc::clone(wallet);
let mint = Arc::clone(&mint);
tokio::spawn(async move {
match wallet_clone.wait_any_invoice().await {
Ok(mut stream) => {
while let Some(request_lookup_id) = stream.next().await {
if let Err(err) =
handle_paid_invoice(Arc::clone(&mint), &request_lookup_id).await
{
// nosemgrep: direct-panic
panic!("{:?}", err);
}
}
}
Err(err) => {
// nosemgrep: direct-panic
panic!("Could not get invoice stream: {}", err);
}
}
});
}
let shutdown = Arc::new(Notify::new());
tokio::spawn({
let shutdown = Arc::clone(&shutdown);
async move { mint.wait_for_paid_invoices(shutdown).await }
});
println!("Staring Axum server");
axum::Server::bind(&format!("{}:{}", addr, port).as_str().parse().unwrap())
.serve(mint_service.into_make_service())

View File

@@ -8,14 +8,14 @@ use cdk::{
cdk_lightning::MintLightning,
mint::{FeeReserve, Mint},
nuts::{CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings},
types::LnKey,
types::{LnKey, QuoteTTL},
};
use cdk_cln::Cln as CdkCln;
use futures::StreamExt;
use ln_regtest_rs::{
bitcoin_client::BitcoinClient, bitcoind::Bitcoind, cln::Clnd, cln_client::ClnClient, lnd::Lnd,
lnd_client::LndClient,
};
use tokio::sync::Notify;
use tower_http::cors::CorsLayer;
use tracing_subscriber::EnvFilter;
@@ -140,7 +140,13 @@ pub async fn create_cln_backend(cln_client: &ClnClient) -> Result<CdkCln> {
.await?)
}
pub async fn create_mint<D>(database: D) -> Result<Mint>
pub async fn create_mint<D>(
database: D,
ln_backends: HashMap<
LnKey,
Arc<dyn MintLightning<Err = cdk::cdk_lightning::Error> + Sync + Send>,
>,
) -> Result<Mint>
where
D: MintDatabase<Err = cdk_database::Error> + Send + Sync + 'static,
{
@@ -160,11 +166,15 @@ where
let mut supported_units: HashMap<CurrencyUnit, (u64, u8)> = HashMap::new();
supported_units.insert(CurrencyUnit::Sat, (0, 32));
let quote_ttl = QuoteTTL::new(10000, 10000);
let mint = Mint::new(
&get_mint_url(),
&mnemonic.to_seed_normalized(""),
mint_info,
quote_ttl,
Arc::new(database),
ln_backends,
supported_units,
)
.await?;
@@ -189,7 +199,6 @@ where
// Parse input
tracing_subscriber::fmt().with_env_filter(env_filter).init();
let mint = create_mint(database).await?;
let cln_client = init_cln_client().await?;
let cln_backend = create_cln_backend(&cln_client).await?;
@@ -204,18 +213,13 @@ where
Arc::new(cln_backend),
);
let quote_ttl = 100000;
let mint = create_mint(database, ln_backends.clone()).await?;
let mint_arc = Arc::new(mint);
let v1_service = cdk_axum::create_mint_router(
&get_mint_url(),
Arc::clone(&mint_arc),
ln_backends.clone(),
quote_ttl,
)
.await
.unwrap();
let v1_service = cdk_axum::create_mint_router(Arc::clone(&mint_arc))
.await
.unwrap();
let mint_service = Router::new()
.merge(v1_service)
@@ -223,28 +227,13 @@ where
let mint = Arc::clone(&mint_arc);
for wallet in ln_backends.values() {
let wallet_clone = Arc::clone(wallet);
let mint = Arc::clone(&mint);
tokio::spawn(async move {
match wallet_clone.wait_any_invoice().await {
Ok(mut stream) => {
while let Some(request_lookup_id) = stream.next().await {
if let Err(err) =
handle_paid_invoice(Arc::clone(&mint), &request_lookup_id).await
{
// nosemgrep: direct-panic
panic!("{:?}", err);
}
}
}
Err(err) => {
// nosemgrep: direct-panic
panic!("Could not get invoice stream: {}", err);
}
}
});
}
let shutdown = Arc::new(Notify::new());
tokio::spawn({
let shutdown = Arc::clone(&shutdown);
async move { mint.wait_for_paid_invoices(shutdown).await }
});
println!("Staring Axum server");
axum::Server::bind(&format!("{}:{}", addr, port).as_str().parse().unwrap())
.serve(mint_service.into_make_service())
@@ -253,25 +242,6 @@ where
Ok(())
}
/// Update mint quote when called for a paid invoice
async fn handle_paid_invoice(mint: Arc<Mint>, request_lookup_id: &str) -> Result<()> {
println!("Invoice with lookup id paid: {}", request_lookup_id);
if let Ok(Some(mint_quote)) = mint
.localstore
.get_mint_quote_by_request_lookup_id(request_lookup_id)
.await
{
println!(
"Quote {} paid by lookup id {}",
mint_quote.id, request_lookup_id
);
mint.localstore
.update_mint_quote_state(&mint_quote.id, cdk::nuts::MintQuoteState::Paid)
.await?;
}
Ok(())
}
pub async fn fund_ln(
bitcoin_client: &BitcoinClient,
cln_client: &ClnClient,

View File

@@ -14,12 +14,12 @@ use cdk::nuts::{
CurrencyUnit, Id, KeySet, MeltMethodSettings, MintInfo, MintMethodSettings, MintQuoteState,
Nuts, PaymentMethod, PreMintSecrets, Proofs, State,
};
use cdk::types::LnKey;
use cdk::types::{LnKey, QuoteTTL};
use cdk::wallet::client::HttpClient;
use cdk::{Mint, Wallet};
use cdk_fake_wallet::FakeWallet;
use futures::StreamExt;
use init_regtest::{get_mint_addr, get_mint_port, get_mint_url};
use tokio::sync::Notify;
use tokio::time::sleep;
use tower_http::cors::CorsLayer;
@@ -72,26 +72,22 @@ pub async fn start_mint(
let mnemonic = Mnemonic::generate(12)?;
let quote_ttl = QuoteTTL::new(10000, 10000);
let mint = Mint::new(
&get_mint_url(),
&mnemonic.to_seed_normalized(""),
mint_info,
quote_ttl,
Arc::new(MintMemoryDatabase::default()),
ln_backends.clone(),
supported_units,
)
.await?;
let quote_ttl = 100000;
let mint_arc = Arc::new(mint);
let v1_service = cdk_axum::create_mint_router(
&get_mint_url(),
Arc::clone(&mint_arc),
ln_backends.clone(),
quote_ttl,
)
.await?;
let v1_service = cdk_axum::create_mint_router(Arc::clone(&mint_arc)).await?;
let mint_service = Router::new()
.merge(v1_service)
@@ -99,28 +95,12 @@ pub async fn start_mint(
let mint = Arc::clone(&mint_arc);
for wallet in ln_backends.values() {
let wallet_clone = Arc::clone(wallet);
let mint = Arc::clone(&mint);
tokio::spawn(async move {
match wallet_clone.wait_any_invoice().await {
Ok(mut stream) => {
while let Some(request_lookup_id) = stream.next().await {
if let Err(err) =
handle_paid_invoice(Arc::clone(&mint), &request_lookup_id).await
{
// nosemgrep: direct-panic
panic!("{:?}", err);
}
}
}
Err(err) => {
// nosemgrep: direct-panic
panic!("Could not get invoice stream: {}", err);
}
}
});
}
let shutdown = Arc::new(Notify::new());
tokio::spawn({
let shutdown = Arc::clone(&shutdown);
async move { mint.wait_for_paid_invoices(shutdown).await }
});
axum::Server::bind(
&format!("{}:{}", get_mint_addr(), get_mint_port())
@@ -133,25 +113,6 @@ pub async fn start_mint(
Ok(())
}
/// Update mint quote when called for a paid invoice
async fn handle_paid_invoice(mint: Arc<Mint>, request_lookup_id: &str) -> Result<()> {
println!("Invoice with lookup id paid: {}", request_lookup_id);
if let Ok(Some(mint_quote)) = mint
.localstore
.get_mint_quote_by_request_lookup_id(request_lookup_id)
.await
{
println!(
"Quote {} paid by lookup id {}",
mint_quote.id, request_lookup_id
);
mint.localstore
.update_mint_quote_state(&mint_quote.id, cdk::nuts::MintQuoteState::Paid)
.await?;
}
Ok(())
}
pub async fn wallet_mint(
wallet: Arc<Wallet>,
amount: Amount,

View File

@@ -5,10 +5,12 @@ use bip39::Mnemonic;
use cdk::amount::{Amount, SplitTarget};
use cdk::cdk_database::mint_memory::MintMemoryDatabase;
use cdk::dhke::construct_proofs;
use cdk::mint::MintQuote;
use cdk::nuts::{
CurrencyUnit, Id, MintBolt11Request, MintInfo, Nuts, PreMintSecrets, Proofs, SecretKey,
SpendingConditions, SwapRequest,
};
use cdk::types::QuoteTTL;
use cdk::util::unix_time;
use cdk::Mint;
use std::collections::HashMap;
@@ -36,11 +38,15 @@ async fn new_mint(fee: u64) -> Mint {
let mnemonic = Mnemonic::generate(12).unwrap();
let quote_ttl = QuoteTTL::new(10000, 10000);
Mint::new(
MINT_URL,
&mnemonic.to_seed_normalized(""),
mint_info,
quote_ttl,
Arc::new(MintMemoryDatabase::default()),
HashMap::new(),
supported_units,
)
.await
@@ -59,16 +65,16 @@ async fn mint_proofs(
) -> Result<Proofs> {
let request_lookup = uuid::Uuid::new_v4().to_string();
let mint_quote = mint
.new_mint_quote(
MINT_URL.parse()?,
"".to_string(),
CurrencyUnit::Sat,
amount,
unix_time() + 36000,
request_lookup.to_string(),
)
.await?;
let quote = MintQuote::new(
mint.mint_url.clone(),
"".to_string(),
CurrencyUnit::Sat,
amount,
unix_time() + 36000,
request_lookup.to_string(),
);
mint.localstore.add_mint_quote(quote.clone()).await?;
mint.pay_mint_quote_for_request_id(&request_lookup).await?;
let keyset_id = Id::from(&keys);
@@ -76,7 +82,7 @@ async fn mint_proofs(
let premint = PreMintSecrets::random(keyset_id, amount, split_target)?;
let mint_request = MintBolt11Request {
quote: mint_quote.id,
quote: quote.id,
outputs: premint.blinded_messages(),
};

View File

@@ -20,7 +20,7 @@ use cdk::nuts::{
nut04, nut05, ContactInfo, CurrencyUnit, MeltMethodSettings, MeltQuoteState, MintInfo,
MintMethodSettings, MintVersion, MppMethodSettings, Nuts, PaymentMethod,
};
use cdk::types::LnKey;
use cdk::types::{LnKey, QuoteTTL};
use cdk_cln::Cln;
use cdk_fake_wallet::FakeWallet;
use cdk_lnbits::LNbits;
@@ -32,8 +32,7 @@ use cdk_strike::Strike;
use clap::Parser;
use cli::CLIArgs;
use config::{DatabaseEngine, LnBackend};
use futures::StreamExt;
use tokio::sync::Mutex;
use tokio::sync::{Mutex, Notify};
use tower_http::cors::CorsLayer;
use tracing_subscriber::EnvFilter;
use url::Url;
@@ -425,11 +424,15 @@ async fn main() -> anyhow::Result<()> {
let mnemonic = Mnemonic::from_str(&settings.info.mnemonic)?;
let quote_ttl = QuoteTTL::new(10000, 10000);
let mint = Mint::new(
&settings.info.url,
&mnemonic.to_seed_normalized(""),
mint_info,
quote_ttl,
localstore,
ln_backends.clone(),
supported_units,
)
.await?;
@@ -449,17 +452,14 @@ async fn main() -> anyhow::Result<()> {
// Pending melt quotes where the paynment has **failed** inputs are reset to unspent
check_pending_melt_quotes(Arc::clone(&mint), &ln_backends).await?;
let mint_url = settings.info.url;
let listen_addr = settings.info.listen_host;
let listen_port = settings.info.listen_port;
let quote_ttl = settings
let _quote_ttl = settings
.info
.seconds_quote_is_valid_for
.unwrap_or(DEFAULT_QUOTE_TTL_SECS);
let v1_service =
cdk_axum::create_mint_router(&mint_url, Arc::clone(&mint), ln_backends.clone(), quote_ttl)
.await?;
let v1_service = cdk_axum::create_mint_router(Arc::clone(&mint)).await?;
let mut mint_service = Router::new()
.merge(v1_service)
@@ -469,45 +469,35 @@ async fn main() -> anyhow::Result<()> {
mint_service = mint_service.merge(router);
}
// Spawn task to wait for invoces to be paid and update mint quotes
for (_, ln) in ln_backends {
let mint = Arc::clone(&mint);
tokio::spawn(async move {
loop {
match ln.wait_any_invoice().await {
Ok(mut stream) => {
while let Some(request_lookup_id) = stream.next().await {
if let Err(err) =
handle_paid_invoice(mint.clone(), &request_lookup_id).await
{
tracing::warn!("{:?}", err);
}
}
}
Err(err) => {
tracing::warn!("Could not get invoice stream: {}", err);
}
}
}
});
}
let shutdown = Arc::new(Notify::new());
axum::Server::bind(
tokio::spawn({
let shutdown = Arc::clone(&shutdown);
async move { mint.wait_for_paid_invoices(shutdown).await }
});
let axum_result = axum::Server::bind(
&format!("{}:{}", listen_addr, listen_port)
.as_str()
.parse()?,
)
.serve(mint_service.into_make_service())
.await?;
.await;
Ok(())
}
shutdown.notify_waiters();
match axum_result {
Ok(_) => {
tracing::info!("Axum server stopped with okay status");
}
Err(err) => {
tracing::warn!("Axum server stopped with error");
tracing::error!("{}", err);
bail!("Axum exited with error")
}
}
/// Update mint quote when called for a paid invoice
async fn handle_paid_invoice(mint: Arc<Mint>, request_lookup_id: &str) -> Result<()> {
tracing::debug!("Invoice with lookup id paid: {}", request_lookup_id);
mint.pay_mint_quote_for_request_id(request_lookup_id)
.await?;
Ok(())
}

View File

@@ -46,7 +46,7 @@ sync_wrapper = "0.1.2"
bech32 = "0.9.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { version = "1", features = [
tokio = { version = "1.21", features = [
"rt-multi-thread",
"time",
"macros",
@@ -54,7 +54,7 @@ tokio = { version = "1", features = [
] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { version = "1", features = ["rt", "macros", "sync", "time"] }
tokio = { version = "1.21", features = ["rt", "macros", "sync", "time"] }
getrandom = { version = "0.2", features = ["js"] }
instant = { version = "0.1", features = ["wasm-bindgen", "inaccurate"] }

View File

@@ -4,23 +4,27 @@ use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
use anyhow::bail;
use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv};
use bitcoin::secp256k1::{self, Secp256k1};
use futures::StreamExt;
use lightning_invoice::Bolt11Invoice;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tokio::sync::{Notify, RwLock};
use tokio::task::JoinSet;
use tracing::instrument;
use self::nut05::QuoteState;
use self::nut05::{MeltBolt11Response, QuoteState};
use self::nut11::EnforceSigFlag;
use crate::cdk_database::{self, MintDatabase};
use crate::cdk_lightning::to_unit;
use crate::cdk_lightning::{self, to_unit, MintLightning, PayInvoiceResponse};
use crate::dhke::{hash_to_curve, sign_message, verify_message};
use crate::error::Error;
use crate::fees::calculate_fee;
use crate::mint_url::MintUrl;
use crate::nuts::nut11::enforce_sig_flag;
use crate::nuts::*;
use crate::types::{LnKey, QuoteTTL};
use crate::util::unix_time;
use crate::Amount;
@@ -35,8 +39,12 @@ pub struct Mint {
pub mint_url: MintUrl,
/// Mint Info
pub mint_info: MintInfo,
/// Quotes ttl
pub quote_ttl: QuoteTTL,
/// Mint Storage backend
pub localstore: Arc<dyn MintDatabase<Err = cdk_database::Error> + Send + Sync>,
/// Ln backends for mint
pub ln: HashMap<LnKey, Arc<dyn MintLightning<Err = cdk_lightning::Error> + Send + Sync>>,
/// Active Mint Keysets
keysets: Arc<RwLock<HashMap<Id, MintKeySet>>>,
secp_ctx: Secp256k1<secp256k1::All>,
@@ -49,7 +57,9 @@ impl Mint {
mint_url: &str,
seed: &[u8],
mint_info: MintInfo,
quote_ttl: QuoteTTL,
localstore: Arc<dyn MintDatabase<Err = cdk_database::Error> + Send + Sync>,
ln: HashMap<LnKey, Arc<dyn MintLightning<Err = cdk_lightning::Error> + Send + Sync>>,
// Hashmap where the key is the unit and value is (input fee ppk, max_order)
supported_units: HashMap<CurrencyUnit, (u64, u8)>,
) -> Result<Self, Error> {
@@ -160,9 +170,11 @@ impl Mint {
mint_url: MintUrl::from_str(mint_url)?,
keysets: Arc::new(RwLock::new(active_keysets)),
secp_ctx,
quote_ttl,
xpriv,
localstore,
mint_info,
ln,
})
}
@@ -190,17 +202,12 @@ impl Mint {
&self.mint_info
}
/// New mint quote
#[instrument(skip_all)]
pub async fn new_mint_quote(
/// Checks that minting is enabled, request is supported unit and within range
fn check_mint_request_acceptable(
&self,
mint_url: MintUrl,
request: String,
unit: CurrencyUnit,
amount: Amount,
expiry: u64,
ln_lookup: String,
) -> Result<MintQuote, Error> {
unit: CurrencyUnit,
) -> Result<(), Error> {
let nut04 = &self.mint_info.nuts.nut04;
if nut04.disabled {
@@ -236,18 +243,72 @@ impl Mint {
}
}
let quote = MintQuote::new(mint_url, request, unit, amount, expiry, ln_lookup.clone());
Ok(())
}
/// Create new mint bolt11 quote
#[instrument(skip_all)]
pub async fn get_mint_bolt11_quote(
&self,
mint_quote_request: MintQuoteBolt11Request,
) -> Result<MintQuoteBolt11Response, Error> {
let MintQuoteBolt11Request {
amount,
unit,
description,
} = mint_quote_request;
self.check_mint_request_acceptable(amount, unit)?;
let ln = self
.ln
.get(&LnKey::new(unit, PaymentMethod::Bolt11))
.ok_or_else(|| {
tracing::info!("Bolt11 mint request for unsupported unit");
Error::UnitUnsupported
})?;
let quote_expiry = unix_time() + self.quote_ttl.mint_ttl;
if description.is_some() && !ln.get_settings().invoice_description {
tracing::error!("Backend does not support invoice description");
return Err(Error::InvoiceDescriptionUnsupported);
}
let create_invoice_response = ln
.create_invoice(
amount,
&unit,
description.unwrap_or("".to_string()),
quote_expiry,
)
.await
.map_err(|err| {
tracing::error!("Could not create invoice: {}", err);
Error::InvalidPaymentRequest
})?;
let quote = MintQuote::new(
self.mint_url.clone(),
create_invoice_response.request.to_string(),
unit,
amount,
create_invoice_response.expiry.unwrap_or(0),
create_invoice_response.request_lookup_id.clone(),
);
tracing::debug!(
"New mint quote {} for {} {} with request id {}",
quote.id,
amount,
unit,
&ln_lookup
create_invoice_response.request_lookup_id,
);
self.localstore.add_mint_quote(quote.clone()).await?;
Ok(quote)
Ok(quote.into())
}
/// Check mint quote
@@ -345,24 +406,72 @@ impl Mint {
Ok(())
}
/// New melt quote
#[instrument(skip_all)]
pub async fn new_melt_quote(
/// Wait for any invoice to be paid
/// For each backend starts a task that waits for any invoice to be paid
/// Once invoice is paid mint quote status is updated
#[allow(clippy::incompatible_msrv)]
// Clippy thinks select is not stable but it compiles fine on MSRV (1.63.0)
pub async fn wait_for_paid_invoices(&self, shutdown: Arc<Notify>) -> Result<(), Error> {
let mint_arc = Arc::new(self.clone());
let mut join_set = JoinSet::new();
for (key, ln) in self.ln.iter() {
let mint = Arc::clone(&mint_arc);
let ln = Arc::clone(ln);
let shutdown = Arc::clone(&shutdown);
let key = *key;
join_set.spawn(async move {
loop {
tokio::select! {
_ = shutdown.notified() => {
tracing::info!("Shutdown signal received, stopping task for {:?}", key);
break;
}
result = ln.wait_any_invoice() => {
match result {
Ok(mut stream) => {
while let Some(request_lookup_id) = stream.next().await {
if let Err(err) = mint.pay_mint_quote_for_request_id(&request_lookup_id).await {
tracing::warn!("{:?}", err);
}
}
}
Err(err) => {
tracing::warn!("Could not get invoice stream for {:?}: {}",key, err);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
}
}
}
});
}
// Spawn a task to manage the JoinSet
while let Some(result) = join_set.join_next().await {
match result {
Ok(_) => tracing::info!("A task completed successfully."),
Err(err) => tracing::warn!("A task failed: {:?}", err),
}
}
Ok(())
}
fn check_melt_request_acceptable(
&self,
request: String,
unit: CurrencyUnit,
amount: Amount,
fee_reserve: Amount,
expiry: u64,
request_lookup_id: String,
) -> Result<MeltQuote, Error> {
unit: CurrencyUnit,
method: PaymentMethod,
) -> Result<(), Error> {
let nut05 = &self.mint_info.nuts.nut05;
if nut05.disabled {
return Err(Error::MeltingDisabled);
}
match nut05.get_settings(&unit, &PaymentMethod::Bolt11) {
match nut05.get_settings(&unit, &method) {
Some(settings) => {
if settings
.max_amount
@@ -391,13 +500,61 @@ impl Mint {
}
}
let quote = MeltQuote::new(
Ok(())
}
/// Get melt bolt11 quote
#[instrument(skip_all)]
pub async fn get_melt_bolt11_quote(
&self,
melt_request: &MeltQuoteBolt11Request,
) -> Result<MeltQuoteBolt11Response, Error> {
let MeltQuoteBolt11Request {
request,
unit,
amount,
fee_reserve,
expiry,
request_lookup_id.clone(),
options: _,
} = melt_request;
let amount = match melt_request.options {
Some(mpp_amount) => mpp_amount.amount,
None => {
let amount_msat = request
.amount_milli_satoshis()
.ok_or(Error::InvoiceAmountUndefined)?;
to_unit(amount_msat, &CurrencyUnit::Msat, unit)
.map_err(|_err| Error::UnsupportedUnit)?
}
};
self.check_melt_request_acceptable(amount, *unit, PaymentMethod::Bolt11)?;
let ln = self
.ln
.get(&LnKey::new(*unit, PaymentMethod::Bolt11))
.ok_or_else(|| {
tracing::info!("Could not get ln backend for {}, bolt11 ", unit);
Error::UnitUnsupported
})?;
let payment_quote = ln.get_payment_quote(melt_request).await.map_err(|err| {
tracing::error!(
"Could not get payment quote for mint quote, {} bolt11, {}",
unit,
err
);
Error::UnitUnsupported
})?;
let quote = MeltQuote::new(
request.to_string(),
*unit,
payment_quote.amount,
payment_quote.fee,
unix_time() + self.quote_ttl.melt_ttl,
payment_quote.request_lookup_id.clone(),
);
tracing::debug!(
@@ -405,12 +562,12 @@ impl Mint {
quote.id,
amount,
unit,
request_lookup_id
payment_quote.request_lookup_id
);
self.localstore.add_melt_quote(quote.clone()).await?;
Ok(quote)
Ok(quote.into())
}
/// Fee required for proof set
@@ -1257,6 +1414,212 @@ impl Mint {
Ok(())
}
/// Melt Bolt11
#[instrument(skip_all)]
pub async fn melt_bolt11(
&self,
melt_request: &MeltBolt11Request,
) -> Result<MeltBolt11Response, Error> {
use std::sync::Arc;
async fn check_payment_state(
ln: Arc<dyn MintLightning<Err = cdk_lightning::Error> + Send + Sync>,
melt_quote: &MeltQuote,
) -> anyhow::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 self.verify_melt_request(melt_request).await {
Ok(quote) => quote,
Err(err) => {
tracing::debug!("Error attempting to verify melt quote: {}", err);
if let Err(err) = self.process_unpaid_melt(melt_request).await {
tracing::error!(
"Could not reset melt quote {} state: {}",
melt_request.quote,
err
);
}
return Err(err);
}
};
let settled_internally_amount =
match self.handle_internal_melt_mint(&quote, melt_request).await {
Ok(amount) => amount,
Err(err) => {
tracing::error!("Attempting to settle internally failed");
if let Err(err) = self.process_unpaid_melt(melt_request).await {
tracing::error!(
"Could not reset melt quote {} state: {}",
melt_request.quote,
err
);
}
return Err(err);
}
};
let (preimage, amount_spent_quote_unit) = match settled_internally_amount {
Some(amount_spent) => (None, amount_spent),
None => {
// If the quote unit is SAT or MSAT we can check that the expected fees are
// provided. We also check if the quote is less then the invoice
// amount in the case that it is a mmp However, if the quote is not
// of a bitcoin unit we cannot do these checks as the mint
// is unaware of a conversion rate. In this case it is assumed that the quote is
// correct and the mint should pay the full invoice amount if inputs
// > `then quote.amount` are included. This is checked in the
// `verify_melt` method.
let partial_amount = match quote.unit {
CurrencyUnit::Sat | CurrencyUnit::Msat => {
match self.check_melt_expected_ln_fees(&quote, melt_request).await {
Ok(amount) => amount,
Err(err) => {
tracing::error!("Fee is not expected: {}", err);
if let Err(err) = self.process_unpaid_melt(melt_request).await {
tracing::error!("Could not reset melt quote state: {}", err);
}
return Err(Error::Internal);
}
}
}
_ => None,
};
let ln = match self.ln.get(&LnKey::new(quote.unit, PaymentMethod::Bolt11)) {
Some(ln) => ln,
None => {
tracing::info!("Could not get ln backend for {}, bolt11 ", quote.unit);
if let Err(err) = self.process_unpaid_melt(melt_request).await {
tracing::error!("Could not reset melt quote state: {}", err);
}
return Err(Error::UnitUnsupported);
}
};
let pre = match ln
.pay_invoice(quote.clone(), partial_amount, Some(quote.fee_reserve))
.await
{
Ok(pay)
if pay.status == MeltQuoteState::Unknown
|| pay.status == MeltQuoteState::Failed =>
{
let check_response = check_payment_state(Arc::clone(ln), &quote)
.await
.map_err(|_| Error::Internal)?;
if check_response.status == MeltQuoteState::Paid {
tracing::warn!("Pay invoice returned {} but check returned {}. Proofs stuck as pending", pay.status.to_string(), check_response.status.to_string());
return Err(Error::Internal);
}
check_response
}
Ok(pay) => pay,
Err(err) => {
// If the error is that the invoice was already paid we do not want to hold
// hold the proofs as pending to we reset them and return an error.
if matches!(err, cdk_lightning::Error::InvoiceAlreadyPaid) {
tracing::debug!("Invoice already paid, resetting melt quote");
if let Err(err) = self.process_unpaid_melt(melt_request).await {
tracing::error!("Could not reset melt quote state: {}", err);
}
return Err(Error::RequestAlreadyPaid);
}
tracing::error!("Error returned attempting to pay: {} {}", quote.id, err);
let check_response = check_payment_state(Arc::clone(ln), &quote)
.await
.map_err(|_| Error::Internal)?;
// If there error is something else we want to check the status of the payment ensure it is not pending or has been made.
if check_response.status == MeltQuoteState::Paid {
tracing::warn!("Pay invoice returned an error but check returned {}. Proofs stuck as pending", check_response.status.to_string());
return Err(Error::Internal);
}
check_response
}
};
match pre.status {
MeltQuoteState::Paid => (),
MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => {
tracing::info!(
"Lightning payment for quote {} failed.",
melt_request.quote
);
if let Err(err) = self.process_unpaid_melt(melt_request).await {
tracing::error!("Could not reset melt quote state: {}", err);
}
return Err(Error::PaymentFailed);
}
MeltQuoteState::Pending => {
tracing::warn!(
"LN payment pending, proofs are stuck as pending for quote: {}",
melt_request.quote
);
return Err(Error::PendingQuote);
}
}
// Convert from unit of backend to quote unit
// Note: this should never fail since these conversions happen earlier and would fail there.
// Since it will not fail and even if it does the ln payment has already been paid, proofs should still be burned
let amount_spent =
to_unit(pre.total_spent, &pre.unit, &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
);
let mut melt_quote = quote;
melt_quote.request_lookup_id = payment_lookup_id;
if let Err(err) = self.localstore.add_melt_quote(melt_quote).await {
tracing::warn!("Could not update payment lookup id: {}", err);
}
}
(pre.payment_preimage, amount_spent)
}
};
// If we made it here the payment has been made.
// We process the melt burning the inputs and returning change
let res = self
.process_melt_request(melt_request, preimage, amount_spent_quote_unit)
.await
.map_err(|err| {
tracing::error!("Could not process melt request: {}", err);
err
})?;
Ok(res.into())
}
/// Process melt request marking [`Proofs`] as spent
/// The melt request must be verifyed using [`Self::verify_melt_request`]
/// before calling [`Self::process_melt_request`]
@@ -1680,6 +2043,7 @@ mod tests {
mint_info: MintInfo,
supported_units: HashMap<CurrencyUnit, (u64, u8)>,
melt_requests: Vec<(MeltBolt11Request, LnKey)>,
quote_ttl: QuoteTTL,
}
async fn create_mint(config: MintConfig<'_>) -> Result<Mint, Error> {
@@ -1703,7 +2067,9 @@ mod tests {
config.mint_url,
config.seed,
config.mint_info,
config.quote_ttl,
localstore,
HashMap::new(),
config.supported_units,
)
.await

View File

@@ -155,6 +155,22 @@ impl LnKey {
}
}
/// Secs wuotes are valid
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct QuoteTTL {
/// Seconds mint quote is valid
pub mint_ttl: u64,
/// Seconds melt quote is valid
pub melt_ttl: u64,
}
impl QuoteTTL {
/// Create new [`QuoteTTL`]
pub fn new(mint_ttl: u64, melt_ttl: u64) -> QuoteTTL {
Self { mint_ttl, melt_ttl }
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;