use std::sync::Arc;
use cdk_common::database::mint::Acquired;
use cdk_common::mint::{MintQuote, Operation};
use cdk_common::payment::{
Bolt11IncomingPaymentOptions, Bolt12IncomingPaymentOptions, CustomIncomingPaymentOptions,
IncomingPaymentOptions, WaitPaymentResponse,
};
use cdk_common::quote_id::QuoteId;
use cdk_common::util::unix_time;
use cdk_common::{
database, ensure_cdk, Amount, BatchMintRequest, BlindedMessage, CurrencyUnit, Error,
MintQuoteBolt11Request, MintQuoteBolt11Response, MintQuoteBolt12Request,
MintQuoteBolt12Response, MintQuoteCustomRequest, MintQuoteCustomResponse, MintQuoteState,
MintRequest, MintResponse, NotificationPayload, PaymentMethod, PublicKey,
};
#[cfg(feature = "prometheus")]
use cdk_prometheus::METRICS;
use tracing::instrument;
use crate::mint::verification::MAX_REQUEST_FIELD_LEN;
use crate::Mint;
mod auth;
use cdk_common::nut00::KnownMethod;
#[derive(Debug, Clone)]
pub enum MintInput {
Single(MintRequest<QuoteId>),
Batch(BatchMintRequest<QuoteId>),
}
#[derive(Debug, Clone)]
struct QuoteEntry {
quote_id: QuoteId,
signature: Option<String>,
expected_amount: Option<u64>,
}
impl MintInput {
pub fn validate(&self) -> Result<(), Error> {
match self {
MintInput::Single(_) => Ok(()),
MintInput::Batch(batch) => {
if batch.quotes.is_empty() {
return Err(Error::UnknownQuote);
}
let unique_ids: std::collections::HashSet<_> = batch.quotes.iter().collect();
if unique_ids.len() != batch.quotes.len() {
return Err(Error::DuplicateInputs);
}
if let Some(ref amounts) = batch.quote_amounts {
if amounts.len() != batch.quotes.len() {
return Err(Error::TransactionUnbalanced(0, 0, 0));
}
}
if let Some(ref sigs) = batch.signatures {
if sigs.len() != batch.quotes.len() {
return Err(Error::SignatureMissingOrInvalid);
}
}
Ok(())
}
}
}
fn quote_entries(&self) -> Vec<QuoteEntry> {
match self {
MintInput::Single(req) => {
vec![QuoteEntry {
quote_id: req.quote.clone(),
signature: req.signature.clone(),
expected_amount: None,
}]
}
MintInput::Batch(batch) => batch
.quotes
.iter()
.enumerate()
.map(|(i, quote_id)| QuoteEntry {
quote_id: quote_id.clone(),
signature: batch
.signatures
.as_ref()
.and_then(|sigs| sigs.get(i).cloned())
.flatten(),
expected_amount: batch
.quote_amounts
.as_ref()
.and_then(|a| a.get(i).map(|amt| u64::from(*amt))),
})
.collect(),
}
}
pub fn quote_ids(&self) -> Vec<QuoteId> {
match self {
MintInput::Single(req) => vec![req.quote.clone()],
MintInput::Batch(batch) => batch.quotes.clone(),
}
}
pub fn outputs(&self) -> &[BlindedMessage] {
match self {
MintInput::Single(req) => &req.outputs,
MintInput::Batch(batch) => &batch.outputs,
}
}
pub fn is_batch(&self) -> bool {
matches!(self, MintInput::Batch(_))
}
}
#[derive(Debug)]
pub enum MintQuoteRequest {
Bolt11(MintQuoteBolt11Request),
Bolt12(MintQuoteBolt12Request),
Custom {
method: String,
request: MintQuoteCustomRequest,
},
}
impl From<MintQuoteBolt11Request> for MintQuoteRequest {
fn from(request: MintQuoteBolt11Request) -> Self {
MintQuoteRequest::Bolt11(request)
}
}
impl From<MintQuoteBolt12Request> for MintQuoteRequest {
fn from(request: MintQuoteBolt12Request) -> Self {
MintQuoteRequest::Bolt12(request)
}
}
impl MintQuoteRequest {
pub fn amount(&self) -> Option<Amount> {
match self {
MintQuoteRequest::Bolt11(request) => Some(request.amount),
MintQuoteRequest::Bolt12(request) => request.amount,
MintQuoteRequest::Custom { request, .. } => Some(request.amount),
}
}
pub fn unit(&self) -> CurrencyUnit {
match self {
MintQuoteRequest::Bolt11(request) => request.unit.clone(),
MintQuoteRequest::Bolt12(request) => request.unit.clone(),
MintQuoteRequest::Custom { request, .. } => request.unit.clone(),
}
}
pub fn payment_method(&self) -> PaymentMethod {
match self {
MintQuoteRequest::Bolt11(_) => PaymentMethod::Known(KnownMethod::Bolt11),
MintQuoteRequest::Bolt12(_) => PaymentMethod::Known(KnownMethod::Bolt12),
MintQuoteRequest::Custom { method, .. } => PaymentMethod::from(method.clone()),
}
}
pub fn pubkey(&self) -> Option<PublicKey> {
match self {
MintQuoteRequest::Bolt11(request) => request.pubkey,
MintQuoteRequest::Bolt12(request) => Some(request.pubkey),
MintQuoteRequest::Custom { request, .. } => request.pubkey,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MintQuoteResponse {
Bolt11(MintQuoteBolt11Response<QuoteId>),
Bolt12(MintQuoteBolt12Response<QuoteId>),
Custom {
method: String,
response: MintQuoteCustomResponse<QuoteId>,
},
}
impl TryFrom<MintQuoteResponse> for MintQuoteBolt11Response<QuoteId> {
type Error = Error;
fn try_from(response: MintQuoteResponse) -> Result<Self, Self::Error> {
match response {
MintQuoteResponse::Bolt11(bolt11_response) => Ok(bolt11_response),
_ => Err(Error::InvalidPaymentMethod),
}
}
}
impl TryFrom<MintQuoteResponse> for MintQuoteBolt12Response<QuoteId> {
type Error = Error;
fn try_from(response: MintQuoteResponse) -> Result<Self, Self::Error> {
match response {
MintQuoteResponse::Bolt12(bolt12_response) => Ok(bolt12_response),
_ => Err(Error::InvalidPaymentMethod),
}
}
}
impl TryFrom<MintQuote> for MintQuoteResponse {
type Error = Error;
fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
if quote.payment_method.is_bolt11() {
let bolt11_response: MintQuoteBolt11Response<QuoteId> = quote.into();
Ok(MintQuoteResponse::Bolt11(bolt11_response))
} else if quote.payment_method.is_bolt12() {
let bolt12_response = MintQuoteBolt12Response::try_from(quote)?;
Ok(MintQuoteResponse::Bolt12(bolt12_response))
} else {
let method = quote.payment_method.to_string();
let custom_response = MintQuoteCustomResponse::try_from(quote)?;
Ok(MintQuoteResponse::Custom {
method,
response: custom_response,
})
}
}
}
impl From<MintQuoteResponse> for MintQuoteBolt11Response<String> {
fn from(response: MintQuoteResponse) -> Self {
match response {
MintQuoteResponse::Bolt11(bolt11_response) => MintQuoteBolt11Response {
quote: bolt11_response.quote.to_string(),
state: bolt11_response.state,
request: bolt11_response.request,
expiry: bolt11_response.expiry,
pubkey: bolt11_response.pubkey,
amount: bolt11_response.amount,
unit: bolt11_response.unit,
},
_ => panic!("Expected Bolt11 response"),
}
}
}
impl Mint {
pub async fn check_mint_request_acceptable(
&self,
mint_quote_request: &MintQuoteRequest,
) -> Result<(), Error> {
let mint_info = self.mint_info().await?;
let unit = mint_quote_request.unit();
let amount = mint_quote_request.amount();
let payment_method = mint_quote_request.payment_method();
let nut04 = &mint_info.nuts.nut04;
ensure_cdk!(!nut04.disabled, Error::MintingDisabled);
let disabled = nut04.disabled;
ensure_cdk!(!disabled, Error::MintingDisabled);
let settings = nut04
.get_settings(&unit, &payment_method)
.ok_or(Error::UnsupportedUnit)?;
let min_amount = settings.min_amount;
let max_amount = settings.max_amount;
if let Some(amount) = amount {
let is_above_max = max_amount.is_some_and(|max_amount| amount > max_amount);
let is_below_min = min_amount.is_some_and(|min_amount| amount < min_amount);
let is_out_of_range = is_above_max || is_below_min;
ensure_cdk!(
!is_out_of_range,
Error::AmountOutofLimitRange(
min_amount.unwrap_or_default(),
max_amount.unwrap_or_default(),
amount,
)
);
}
Ok(())
}
#[instrument(skip_all)]
pub async fn get_mint_quote(
&self,
mint_quote_request: MintQuoteRequest,
) -> Result<MintQuoteResponse, Error> {
#[cfg(feature = "prometheus")]
METRICS.inc_in_flight_requests("get_mint_quote");
let result = async {
let unit = mint_quote_request.unit();
let amount = mint_quote_request.amount();
let payment_method = mint_quote_request.payment_method();
self.check_mint_request_acceptable(&mint_quote_request)
.await?;
let pubkey = mint_quote_request.pubkey();
let ln = self.get_payment_processor(unit.clone(), payment_method.clone())?;
let payment_options = match mint_quote_request {
MintQuoteRequest::Bolt11(bolt11_request) => {
if let Some(ref desc) = bolt11_request.description {
if desc.len() > MAX_REQUEST_FIELD_LEN {
return Err(Error::RequestFieldTooLarge {
field: "description".to_string(),
actual: desc.len(),
max: MAX_REQUEST_FIELD_LEN,
});
}
}
let mint_ttl = self.quote_ttl().await?.mint_ttl;
let quote_expiry = unix_time() + mint_ttl;
let settings = ln.get_settings().await?;
let description = bolt11_request.description;
if let Some(ref bolt11_settings) = settings.bolt11 {
if description.is_some() && !bolt11_settings.invoice_description {
tracing::error!("Backend does not support invoice description");
return Err(Error::InvoiceDescriptionUnsupported);
}
}
let bolt11_options = Bolt11IncomingPaymentOptions {
description,
amount: bolt11_request.amount.with_unit(unit.clone()),
unix_expiry: Some(quote_expiry),
};
IncomingPaymentOptions::Bolt11(bolt11_options)
}
MintQuoteRequest::Bolt12(bolt12_request) => {
if let Some(ref desc) = bolt12_request.description {
if desc.len() > MAX_REQUEST_FIELD_LEN {
return Err(Error::RequestFieldTooLarge {
field: "description".to_string(),
actual: desc.len(),
max: MAX_REQUEST_FIELD_LEN,
});
}
}
let description = bolt12_request.description;
let bolt12_options = Bolt12IncomingPaymentOptions {
description,
amount: amount.map(|a| a.with_unit(unit.clone())),
unix_expiry: None,
};
IncomingPaymentOptions::Bolt12(Box::new(bolt12_options))
}
MintQuoteRequest::Custom { method, request } => {
if let Some(ref desc) = request.description {
if desc.len() > MAX_REQUEST_FIELD_LEN {
return Err(Error::RequestFieldTooLarge {
field: "description".to_string(),
actual: desc.len(),
max: MAX_REQUEST_FIELD_LEN,
});
}
}
if !request.extra.is_null() {
let extra_str = request.extra.to_string();
if extra_str.len() > MAX_REQUEST_FIELD_LEN {
return Err(Error::RequestFieldTooLarge {
field: "extra".to_string(),
actual: extra_str.len(),
max: MAX_REQUEST_FIELD_LEN,
});
}
}
let mint_ttl = self.quote_ttl().await?.mint_ttl;
let quote_expiry = unix_time() + mint_ttl;
let extra_json = if request.extra.is_null() {
None
} else {
Some(request.extra.to_string())
};
let custom_options = CustomIncomingPaymentOptions {
method: method.to_string(),
description: request.description,
amount: request.amount.with_unit(unit.clone()),
unix_expiry: Some(quote_expiry),
extra_json,
};
IncomingPaymentOptions::Custom(Box::new(custom_options))
}
};
let create_invoice_response = ln
.create_incoming_payment_request(payment_options)
.await
.map_err(|err| {
tracing::error!("Could not create invoice: {}", err);
Error::InvalidPaymentRequest
})?;
let quote = MintQuote::new(
None,
create_invoice_response.request.to_string(),
unit.clone(),
amount.map(|a| a.with_unit(unit.clone())),
create_invoice_response.expiry.unwrap_or(0),
create_invoice_response.request_lookup_id.clone(),
pubkey,
Amount::new(0, unit.clone()),
Amount::new(0, unit.clone()),
payment_method.clone(),
unix_time(),
vec![],
vec![],
Some(create_invoice_response.extra_json.unwrap_or_default()),
);
tracing::debug!(
"New {} mint quote {} for {:?} {} with request id {:?}",
payment_method,
quote.id,
amount,
unit,
create_invoice_response.request_lookup_id.to_string(),
);
let mut tx = self.localstore.begin_transaction().await?;
tx.add_mint_quote(quote.clone()).await?;
tx.commit().await?;
if payment_method.is_bolt11() {
let res: MintQuoteBolt11Response<QuoteId> = quote.clone().into();
self.pubsub_manager
.publish(NotificationPayload::MintQuoteBolt11Response(res));
} else if payment_method.is_bolt12() {
let res: MintQuoteBolt12Response<QuoteId> = quote.clone().try_into()?;
self.pubsub_manager
.publish(NotificationPayload::MintQuoteBolt12Response(res));
}
quote.try_into()
}
.await;
#[cfg(feature = "prometheus")]
{
METRICS.dec_in_flight_requests("get_mint_quote");
METRICS.record_mint_operation("get_mint_quote", result.is_ok());
if result.is_err() {
METRICS.record_error();
}
}
result
}
#[instrument(skip_all)]
pub async fn mint_quotes(&self) -> Result<Vec<MintQuote>, Error> {
#[cfg(feature = "prometheus")]
METRICS.inc_in_flight_requests("mint_quotes");
let result = async {
let quotes = self.localstore.get_mint_quotes().await?;
Ok(quotes)
}
.await;
#[cfg(feature = "prometheus")]
{
METRICS.dec_in_flight_requests("mint_quotes");
METRICS.record_mint_operation("mint_quotes", result.is_ok());
if result.is_err() {
METRICS.record_error();
}
}
result
}
#[instrument(skip_all)]
pub async fn pay_mint_quote_for_request_id(
&self,
wait_payment_response: WaitPaymentResponse,
) -> Result<(), Error> {
#[cfg(feature = "prometheus")]
METRICS.inc_in_flight_requests("pay_mint_quote_for_request_id");
let result = async {
if wait_payment_response.payment_amount.value() == 0 {
tracing::warn!(
"Received payment response with 0 amount with payment id {}.",
wait_payment_response.payment_id.to_string()
);
return Err(Error::AmountUndefined);
}
let mut tx = self.localstore.begin_transaction().await?;
let should_notify = if let Ok(Some(mut mint_quote)) = tx
.get_mint_quote_by_request_lookup_id(&wait_payment_response.payment_identifier)
.await
{
let notify = self
.pay_mint_quote(&mut tx, &mut mint_quote, wait_payment_response)
.await?;
if notify {
Some((mint_quote.clone(), mint_quote.amount_paid()))
} else {
None
}
} else {
tracing::warn!(
"Could not get request for request lookup id {:?}.",
wait_payment_response.payment_identifier
);
None
};
tx.commit().await?;
if let Some((quote, amount_paid)) = should_notify {
self.pubsub_manager.mint_quote_payment("e, amount_paid);
}
Ok(())
}
.await;
#[cfg(feature = "prometheus")]
{
METRICS.dec_in_flight_requests("pay_mint_quote_for_request_id");
METRICS.record_mint_operation("pay_mint_quote_for_request_id", result.is_ok());
if result.is_err() {
METRICS.record_error();
}
}
result
}
#[instrument(skip_all)]
pub async fn pay_mint_quote(
&self,
tx: &mut Box<dyn database::MintTransaction<database::Error> + Send + Sync>,
mint_quote: &mut Acquired<MintQuote>,
wait_payment_response: WaitPaymentResponse,
) -> Result<bool, Error> {
#[cfg(feature = "prometheus")]
METRICS.inc_in_flight_requests("pay_mint_quote");
let result =
async { Self::handle_mint_quote_payment(tx, mint_quote, wait_payment_response).await }
.await;
#[cfg(feature = "prometheus")]
{
METRICS.dec_in_flight_requests("pay_mint_quote");
METRICS.record_mint_operation("pay_mint_quote", result.is_ok());
if result.is_err() {
METRICS.record_error();
}
}
result
}
#[instrument(skip(self))]
pub async fn check_mint_quote(&self, quote_id: &QuoteId) -> Result<MintQuoteResponse, Error> {
#[cfg(feature = "prometheus")]
METRICS.inc_in_flight_requests("check_mint_quote");
let result: Result<MintQuoteResponse, Error> = async {
Ok(self
.check_mint_quotes(std::slice::from_ref(quote_id))
.await?
.first()
.ok_or(Error::UnknownQuote)?
.to_owned())
}
.await;
#[cfg(feature = "prometheus")]
{
METRICS.dec_in_flight_requests("check_mint_quote");
METRICS.record_mint_operation("check_mint_quote", result.is_ok());
if result.is_err() {
METRICS.record_error();
}
}
result
}
#[instrument(skip(self))]
pub async fn check_mint_quotes(
&self,
quote_ids: &[QuoteId],
) -> Result<Vec<MintQuoteResponse>, Error> {
#[cfg(feature = "prometheus")]
METRICS.inc_in_flight_requests("check_mint_quotes");
let result = async {
if quote_ids.is_empty() {
return Err(Error::UnknownQuote);
}
let unique_ids: std::collections::HashSet<_> = quote_ids.iter().collect();
if unique_ids.len() != quote_ids.len() {
return Err(Error::DuplicateInputs);
}
let mut responses = Vec::with_capacity(quote_ids.len());
for quote_id in quote_ids {
let mut quote = self
.localstore
.get_mint_quote(quote_id)
.await?
.ok_or(Error::UnknownQuote)?;
self.check_mint_quote_paid(&mut quote).await?;
responses.push(quote.try_into()?);
}
Ok(responses)
}
.await;
#[cfg(feature = "prometheus")]
{
METRICS.dec_in_flight_requests("check_mint_quotes");
METRICS.record_mint_operation("check_mint_quotes", result.is_ok());
if result.is_err() {
METRICS.record_error();
}
}
result
}
#[instrument(skip_all)]
pub async fn process_mint_request(&self, input: MintInput) -> Result<MintResponse, Error> {
#[cfg(feature = "prometheus")]
METRICS.inc_in_flight_requests("process_mint_request");
let result = async {
input.validate()?;
let nut29_settings = if let MintInput::Batch(batch) = &input {
let mint_info = self.mint_info().await?;
let settings = mint_info.nuts.nut29;
if settings.is_empty() {
return Err(Error::UnsupportedPaymentMethod);
}
if let Some(max_batch_size) = settings.max_batch_size {
let max = usize::try_from(max_batch_size).unwrap_or(usize::MAX);
if batch.quotes.len() > max {
return Err(Error::MaxInputsExceeded {
actual: batch.quotes.len(),
max,
});
}
}
Some(settings)
} else {
None
};
let quote_entries = input.quote_entries();
let quote_ids = input.quote_ids();
let outputs_amount = self
.verify_outputs(input.outputs())
.inspect_err(|_| {
tracing::debug!("Could not verify mint outputs");
})?
.amount;
let mut quote_map = std::collections::HashMap::new();
for quote_id in "e_ids {
let mut mint_quote = self
.localstore
.get_mint_quote(quote_id)
.await?
.ok_or(Error::UnknownQuote)?;
self.check_mint_quote_paid(&mut mint_quote).await?;
quote_map.insert(quote_id.clone(), mint_quote);
}
let Some(first_quote) = quote_map.values().next() else {
return Err(Error::UnknownQuote);
};
let batch_method = first_quote.payment_method.clone();
let batch_unit = first_quote.unit.clone();
for (quote_id, quote) in "e_map {
if quote.payment_method != batch_method {
tracing::error!(
"Quote {} has payment method {} but expected {}",
quote_id,
quote.payment_method,
batch_method
);
return Err(Error::InvalidPaymentMethod);
}
if quote.unit != batch_unit {
tracing::error!(
"Quote {} has unit {} but expected {}",
quote_id,
quote.unit,
batch_unit
);
return Err(Error::UnitMismatch);
}
}
if let Some(settings) = &nut29_settings {
if let Some(methods) = &settings.methods {
let method = batch_method.to_string();
if !methods.iter().any(|configured| configured == &method) {
return Err(Error::UnsupportedPaymentMethod);
}
}
}
let mut total_expected_value: u64 = 0;
let mut expected_amounts: std::collections::HashMap<QuoteId, Amount<CurrencyUnit>> =
std::collections::HashMap::new();
for entry in "e_entries {
let mint_quote = quote_map.get(&entry.quote_id).ok_or(Error::UnknownQuote)?;
match mint_quote.state() {
MintQuoteState::Unpaid => {
return Err(Error::UnpaidQuote);
}
MintQuoteState::Issued => {
if mint_quote.payment_method.is_bolt12()
&& mint_quote.amount_paid() > mint_quote.amount_issued()
{
tracing::warn!(
"Mint quote {} should have been set to issued upon new payment",
entry.quote_id
);
}
return Err(Error::IssuedQuote);
}
MintQuoteState::Paid => (),
}
let expected_amount = if mint_quote.payment_method.is_bolt11() {
mint_quote.amount.clone().ok_or(Error::AmountUndefined)?
} else if let Some(expected) = entry.expected_amount {
Amount::new(expected, mint_quote.unit.clone())
} else {
mint_quote.amount_mintable()
};
let mintable = mint_quote.amount_mintable();
if expected_amount > mintable {
tracing::error!(
"Quote {} expected amount {} exceeds mintable {}",
entry.quote_id,
expected_amount,
mintable
);
return Err(Error::TransactionUnbalanced(
mintable.value(),
expected_amount.value(),
0,
));
}
if expected_amount == Amount::new(0, mint_quote.unit.clone()) {
tracing::error!("Quote {} has no mintable amount", entry.quote_id);
return Err(Error::UnpaidQuote);
}
if mint_quote.payment_method.is_bolt12() && mint_quote.pubkey.is_none() {
tracing::warn!(
"Bolt12 mint quote {} created without pubkey",
entry.quote_id
);
return Err(Error::SignatureMissingOrInvalid);
}
if let Some(ref pubkey) = mint_quote.pubkey {
match &input {
MintInput::Single(request) => request
.verify_signature(*pubkey)
.map_err(|_| Error::SignatureMissingOrInvalid)?,
MintInput::Batch(request) => {
let signature = entry
.signature
.as_ref()
.ok_or(Error::SignatureMissingOrInvalid)?;
request
.verify_quote_signature(&entry.quote_id, signature, pubkey)
.map_err(|_| Error::SignatureMissingOrInvalid)?;
}
}
} else if entry.signature.is_some() {
return Err(Error::SignatureMissingOrInvalid);
}
total_expected_value = total_expected_value
.checked_add(expected_amount.value())
.ok_or(Error::AmountOverflow)?;
expected_amounts.insert(entry.quote_id.clone(), expected_amount);
}
ensure_cdk!(outputs_amount.unit() == &batch_unit, Error::UnsupportedUnit);
if outputs_amount.value() != total_expected_value {
return Err(Error::TransactionUnbalanced(
total_expected_value,
outputs_amount.value(),
0,
));
}
let all_blind_signatures = self.blind_sign(input.outputs().to_vec()).await?;
let blinded_secrets = input
.outputs()
.iter()
.map(|p| p.blinded_secret)
.collect::<Vec<PublicKey>>();
let mut tx = self.localstore.begin_transaction().await?;
if input.is_batch() {
let batch_operation =
Operation::new_batch_mint(outputs_amount.clone().into(), batch_method.clone());
tx.add_blinded_messages(None, input.outputs(), &batch_operation)
.await?;
tx.add_blind_signatures(&blinded_secrets, &all_blind_signatures, None)
.await?;
let fee_by_keyset = std::collections::HashMap::new();
tx.add_completed_operation(&batch_operation, &fee_by_keyset)
.await?;
}
for quote_id in "e_ids {
let mut mint_quote = tx
.get_mint_quote(quote_id)
.await?
.ok_or(Error::UnknownQuote)?;
match mint_quote.state() {
MintQuoteState::Unpaid => {
return Err(Error::UnpaidQuote);
}
MintQuoteState::Issued => {
return Err(Error::IssuedQuote);
}
MintQuoteState::Paid => (),
}
let amount_issued = if input.is_batch() {
expected_amounts
.get(quote_id)
.cloned()
.ok_or(Error::UnknownQuote)?
} else {
outputs_amount.clone()
};
let operation = Operation::new_mint(
amount_issued.clone().into(),
mint_quote.payment_method.clone(),
);
if !input.is_batch() {
tx.add_blinded_messages(Some(quote_id), input.outputs(), &operation)
.await?;
tx.add_blind_signatures(
&blinded_secrets,
&all_blind_signatures,
Some(quote_id.clone()),
)
.await?;
}
mint_quote.add_issuance(amount_issued)?;
tx.update_mint_quote(&mut mint_quote).await?;
if !input.is_batch() {
let fee_by_keyset = std::collections::HashMap::new();
tx.add_completed_operation(&operation, &fee_by_keyset)
.await?;
}
}
tx.commit().await?;
let localstore = Arc::clone(&self.localstore);
let pubsub_manager = Arc::clone(&self.pubsub_manager);
tokio::spawn(async move {
if let Ok(quotes) = localstore.get_mint_quotes_by_ids("e_ids).await {
for mint_quote in quotes.iter().flatten() {
pubsub_manager.mint_quote_issue(mint_quote, mint_quote.amount_issued());
}
}
});
Ok(MintResponse {
signatures: all_blind_signatures,
})
}
.await;
#[cfg(feature = "prometheus")]
{
METRICS.dec_in_flight_requests("process_mint_request");
METRICS.record_mint_operation("process_mint_request", result.is_ok());
if result.is_err() {
METRICS.record_error();
}
}
result
}
}
#[cfg(test)]
mod batch_mint_tests {
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use bip39::Mnemonic;
use cdk_common::amount::SplitTarget;
use cdk_common::nut00::KnownMethod;
use cdk_common::nuts::PreMintSecrets;
use cdk_common::{
Amount, BatchMintRequest, CurrencyUnit, Error, MintQuoteBolt11Request,
MintQuoteBolt11Response, MintQuoteState, MintRequest, PaymentMethod, QuoteId,
};
use cdk_fake_wallet::FakeWallet;
use tokio::time::sleep;
use crate::mint::{Mint, MintBuilder, MintMeltLimits};
use crate::types::{FeeReserve, QuoteTTL};
async fn create_test_mint() -> Mint {
let db = Arc::new(cdk_sqlite::mint::memory::empty().await.unwrap());
let mut mint_builder = MintBuilder::new(db.clone());
let fee_reserve = FeeReserve {
min_fee_reserve: 1.into(),
percent_fee_reserve: 1.0,
};
let ln_fake_backend = FakeWallet::new(
fee_reserve.clone(),
HashMap::default(),
HashSet::default(),
2,
CurrencyUnit::Sat,
);
mint_builder
.add_payment_processor(
CurrencyUnit::Sat,
PaymentMethod::Known(KnownMethod::Bolt11),
MintMeltLimits::new(1, 10_000),
Arc::new(ln_fake_backend),
)
.await
.unwrap();
let mnemonic = Mnemonic::generate(12).unwrap();
mint_builder = mint_builder
.with_name("test mint".to_string())
.with_description("test mint for unit tests".to_string())
.with_urls(vec!["https://test-mint".to_string()])
.with_batch_minting(None, Some(vec!["bolt11".to_string()]));
let quote_ttl = QuoteTTL::new(10000, 10000);
let mint = mint_builder
.build_with_seed(db.clone(), &mnemonic.to_seed_normalized(""))
.await
.unwrap();
mint.set_quote_ttl(quote_ttl).await.unwrap();
mint.start().await.unwrap();
mint
}
async fn configure_nut29(
mint: &Mint,
max_batch_size: Option<u64>,
methods: Option<Vec<String>>,
) {
let mut mint_info = mint.mint_info().await.unwrap();
mint_info.nuts.nut29 = cdk_common::nut29::Settings::new(max_batch_size, methods);
mint.set_mint_info(mint_info).await.unwrap();
}
async fn wait_for_quote_paid(mint: &Mint, quote_id: &QuoteId) {
loop {
let check = mint
.check_mint_quotes(std::slice::from_ref(quote_id))
.await
.unwrap();
if let crate::mint::MintQuoteResponse::Bolt11(quote) = &check[0] {
if quote.state == MintQuoteState::Paid {
break;
}
}
sleep(std::time::Duration::from_secs(1)).await;
}
}
#[tokio::test]
async fn test_process_batch_mint_basic() {
let mint = create_test_mint().await;
let quote1: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
let quote2: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
wait_for_quote_paid(&mint, "e1.quote).await;
wait_for_quote_paid(&mint, "e2.quote).await;
let keyset_id = *mint.get_active_keysets().get(&CurrencyUnit::Sat).unwrap();
let keys = mint
.keyset_pubkeys(&keyset_id)
.unwrap()
.keysets
.first()
.unwrap()
.keys
.clone();
let fees: (u64, Vec<u64>) = (0, keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>());
let premint_secrets = PreMintSecrets::random(
keyset_id,
Amount::from(64),
&SplitTarget::None,
&fees.into(),
)
.unwrap();
let batch_request = BatchMintRequest {
quotes: vec![quote1.quote.clone(), quote2.quote.clone()],
quote_amounts: None,
outputs: premint_secrets.blinded_messages().to_vec(),
signatures: None,
};
let response = mint
.process_mint_request(crate::mint::MintInput::Batch(batch_request))
.await
.unwrap();
let total_sig_amount: u64 = response.signatures.iter().map(|s| s.amount.to_u64()).sum();
assert_eq!(total_sig_amount, 64);
}
#[tokio::test]
async fn test_process_batch_mint_unpaid_quote() {
let mint = create_test_mint().await;
let quote1: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
let quote2: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
wait_for_quote_paid(&mint, "e1.quote).await;
let keyset_id = *mint.get_active_keysets().get(&CurrencyUnit::Sat).unwrap();
let keys = mint
.keyset_pubkeys(&keyset_id)
.unwrap()
.keysets
.first()
.unwrap()
.keys
.clone();
let fees: (u64, Vec<u64>) = (0, keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>());
let premint_secrets = PreMintSecrets::random(
keyset_id,
Amount::from(32), &SplitTarget::None,
&fees.into(),
)
.unwrap();
let batch_request = BatchMintRequest {
quotes: vec![quote1.quote.clone(), quote2.quote.clone()],
quote_amounts: None,
outputs: premint_secrets.blinded_messages().to_vec(),
signatures: None,
};
let result = mint
.process_mint_request(crate::mint::MintInput::Batch(batch_request))
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
Error::UnpaidQuote | Error::TransactionUnbalanced(_, _, _)
));
}
#[tokio::test]
async fn test_process_batch_mint_inflated_outputs() {
let mint = create_test_mint().await;
let quote1: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
let quote2: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
wait_for_quote_paid(&mint, "e1.quote).await;
wait_for_quote_paid(&mint, "e2.quote).await;
let keyset_id = *mint.get_active_keysets().get(&CurrencyUnit::Sat).unwrap();
let keys = mint
.keyset_pubkeys(&keyset_id)
.unwrap()
.keysets
.first()
.unwrap()
.keys
.clone();
let fees: (u64, Vec<u64>) = (0, keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>());
let premint_secrets = PreMintSecrets::random(
keyset_id,
Amount::from(128),
&SplitTarget::None,
&fees.into(),
)
.unwrap();
let batch_request = BatchMintRequest {
quotes: vec![quote1.quote.clone(), quote2.quote.clone()],
quote_amounts: None,
outputs: premint_secrets.blinded_messages().to_vec(),
signatures: None,
};
let result = mint
.process_mint_request(crate::mint::MintInput::Batch(batch_request))
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
Error::TransactionUnbalanced(_, _, _)
));
}
#[tokio::test]
async fn test_process_batch_mint_duplicate_quotes() {
let mint = create_test_mint().await;
let quote1: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
wait_for_quote_paid(&mint, "e1.quote).await;
let keyset_id = *mint.get_active_keysets().get(&CurrencyUnit::Sat).unwrap();
let keys = mint
.keyset_pubkeys(&keyset_id)
.unwrap()
.keysets
.first()
.unwrap()
.keys
.clone();
let fees: (u64, Vec<u64>) = (0, keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>());
let premint_secrets = PreMintSecrets::random(
keyset_id,
Amount::from(32),
&SplitTarget::None,
&fees.into(),
)
.unwrap();
let batch_request = BatchMintRequest {
quotes: vec![quote1.quote.clone(), quote1.quote.clone()],
quote_amounts: None,
outputs: premint_secrets.blinded_messages().to_vec(),
signatures: None,
};
let result = mint
.process_mint_request(crate::mint::MintInput::Batch(batch_request))
.await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::DuplicateInputs));
}
#[tokio::test]
async fn test_process_batch_mint_mismatched_amounts_length() {
let mint = create_test_mint().await;
let quote1: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
let quote2: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
wait_for_quote_paid(&mint, "e1.quote).await;
wait_for_quote_paid(&mint, "e2.quote).await;
let keyset_id = *mint.get_active_keysets().get(&CurrencyUnit::Sat).unwrap();
let keys = mint
.keyset_pubkeys(&keyset_id)
.unwrap()
.keysets
.first()
.unwrap()
.keys
.clone();
let fees: (u64, Vec<u64>) = (0, keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>());
let premint_secrets = PreMintSecrets::random(
keyset_id,
Amount::from(64),
&SplitTarget::None,
&fees.into(),
)
.unwrap();
let batch_request = BatchMintRequest {
quotes: vec![quote1.quote.clone(), quote2.quote.clone()],
quote_amounts: Some(vec![Amount::from(32)]), outputs: premint_secrets.blinded_messages().to_vec(),
signatures: None,
};
let result = mint
.process_mint_request(crate::mint::MintInput::Batch(batch_request))
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
Error::TransactionUnbalanced(_, _, _)
));
}
#[tokio::test]
async fn test_process_batch_mint_atomicity() {
let mint = create_test_mint().await;
let quote1: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
wait_for_quote_paid(&mint, "e1.quote).await;
let keyset_id = *mint.get_active_keysets().get(&CurrencyUnit::Sat).unwrap();
let keys = mint
.keyset_pubkeys(&keyset_id)
.unwrap()
.keysets
.first()
.unwrap()
.keys
.clone();
let fees: (u64, Vec<u64>) = (0, keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>());
let premint_single = PreMintSecrets::random(
keyset_id,
Amount::from(32),
&SplitTarget::None,
&fees.clone().into(),
)
.unwrap();
let single_request = MintRequest {
quote: quote1.quote.clone(),
outputs: premint_single.blinded_messages().to_vec(),
signature: None,
};
mint.process_mint_request(crate::mint::MintInput::Single(single_request))
.await
.unwrap();
let quote2: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
wait_for_quote_paid(&mint, "e2.quote).await;
let premint_batch = PreMintSecrets::random(
keyset_id,
Amount::from(64),
&SplitTarget::None,
&fees.clone().into(),
)
.unwrap();
let batch_request = BatchMintRequest {
quotes: vec![quote1.quote.clone(), quote2.quote.clone()],
quote_amounts: None,
outputs: premint_batch.blinded_messages().to_vec(),
signatures: None,
};
let result = mint
.process_mint_request(crate::mint::MintInput::Batch(batch_request))
.await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::IssuedQuote));
let statuses = mint
.check_mint_quotes(&[quote1.quote.clone(), quote2.quote.clone()])
.await
.unwrap();
let quote1_status = statuses
.iter()
.find_map(|status| match status {
crate::mint::MintQuoteResponse::Bolt11(quote) if quote.quote == quote1.quote => {
Some(quote.state)
}
_ => None,
})
.expect("quote1 status");
let quote2_status = statuses
.iter()
.find_map(|status| match status {
crate::mint::MintQuoteResponse::Bolt11(quote) if quote.quote == quote2.quote => {
Some(quote.state)
}
_ => None,
})
.expect("quote2 status");
assert_eq!(quote1_status, MintQuoteState::Issued);
assert_eq!(quote2_status, MintQuoteState::Paid);
}
#[tokio::test]
async fn test_process_batch_mint_enforces_max_batch_size() {
let mint = create_test_mint().await;
configure_nut29(&mint, Some(1), None).await;
let quote1: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
let quote2: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
wait_for_quote_paid(&mint, "e1.quote).await;
wait_for_quote_paid(&mint, "e2.quote).await;
let keyset_id = *mint.get_active_keysets().get(&CurrencyUnit::Sat).unwrap();
let keys = mint
.keyset_pubkeys(&keyset_id)
.unwrap()
.keysets
.first()
.unwrap()
.keys
.clone();
let fees: (u64, Vec<u64>) = (0, keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>());
let premint_secrets = PreMintSecrets::random(
keyset_id,
Amount::from(64),
&SplitTarget::None,
&fees.into(),
)
.unwrap();
let batch_request = BatchMintRequest {
quotes: vec![quote1.quote.clone(), quote2.quote.clone()],
quote_amounts: None,
outputs: premint_secrets.blinded_messages().to_vec(),
signatures: None,
};
let result = mint
.process_mint_request(crate::mint::MintInput::Batch(batch_request))
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
Error::MaxInputsExceeded { actual: 2, max: 1 }
));
}
#[tokio::test]
async fn test_process_batch_mint_enforces_allowed_methods() {
let mint = create_test_mint().await;
configure_nut29(&mint, None, Some(vec!["bolt12".to_string()])).await;
let quote1: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
let quote2: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
wait_for_quote_paid(&mint, "e1.quote).await;
wait_for_quote_paid(&mint, "e2.quote).await;
let keyset_id = *mint.get_active_keysets().get(&CurrencyUnit::Sat).unwrap();
let keys = mint
.keyset_pubkeys(&keyset_id)
.unwrap()
.keysets
.first()
.unwrap()
.keys
.clone();
let fees: (u64, Vec<u64>) = (0, keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>());
let premint_secrets = PreMintSecrets::random(
keyset_id,
Amount::from(64),
&SplitTarget::None,
&fees.into(),
)
.unwrap();
let batch_request = BatchMintRequest {
quotes: vec![quote1.quote.clone(), quote2.quote.clone()],
quote_amounts: None,
outputs: premint_secrets.blinded_messages().to_vec(),
signatures: None,
};
let result = mint
.process_mint_request(crate::mint::MintInput::Batch(batch_request))
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
Error::UnsupportedPaymentMethod
));
}
#[tokio::test]
async fn test_process_batch_mint_rejects_when_nut29_not_configured() {
let mint = create_test_mint().await;
configure_nut29(&mint, None, None).await;
let quote1: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
let quote2: MintQuoteBolt11Response<QuoteId> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(32),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.try_into()
.unwrap();
wait_for_quote_paid(&mint, "e1.quote).await;
wait_for_quote_paid(&mint, "e2.quote).await;
let keyset_id = *mint.get_active_keysets().get(&CurrencyUnit::Sat).unwrap();
let keys = mint
.keyset_pubkeys(&keyset_id)
.unwrap()
.keysets
.first()
.unwrap()
.keys
.clone();
let fees: (u64, Vec<u64>) = (0, keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>());
let premint_secrets = PreMintSecrets::random(
keyset_id,
Amount::from(64),
&SplitTarget::None,
&fees.into(),
)
.unwrap();
let batch_request = BatchMintRequest {
quotes: vec![quote1.quote.clone(), quote2.quote.clone()],
quote_amounts: None,
outputs: premint_secrets.blinded_messages().to_vec(),
signatures: None,
};
let result = mint
.process_mint_request(crate::mint::MintInput::Batch(batch_request))
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
Error::UnsupportedPaymentMethod
));
}
}