pub(crate) mod saga;
use cdk_common::nut00::KnownMethod;
use cdk_common::nut04::MintMethodOptions;
use cdk_common::{MintQuoteRequest, MintQuoteResponse, PaymentMethod};
pub(crate) use saga::MintSaga;
use tracing::instrument;
use crate::amount::SplitTarget;
use crate::nuts::{BatchCheckMintQuoteRequest, Proofs, SecretKey, SpendingConditions};
use crate::util::unix_time;
use crate::wallet::recovery::RecoveryAction;
use crate::wallet::{DerivationCounterNamespace, MintQuote, MintQuoteState};
use crate::{Amount, Error, Wallet};
pub(crate) fn apply_mint_quote_response(
quote: &mut MintQuote,
response: &MintQuoteResponse<String>,
) -> bool {
match response {
MintQuoteResponse::Bolt11(response) => {
let state =
if response.amount_paid > Amount::ZERO || response.amount_issued > Amount::ZERO {
match cdk_common::mint_quote::quote_state_from_amounts(
response.amount_paid,
response.amount_issued,
) {
Ok(state) => state,
Err(err) => {
tracing::debug!("Ignoring invalid mint quote accounting: {}", err);
return false;
}
}
} else {
response.state
};
let (amount_paid, amount_issued) = match state {
MintQuoteState::Paid => {
let amount_paid = if response.amount_paid > Amount::ZERO {
response.amount_paid
} else {
response.amount.unwrap_or_default()
};
(amount_paid, response.amount_issued)
}
MintQuoteState::Issued => {
let amount = response.amount.unwrap_or_default();
let amount_paid = if response.amount_paid > Amount::ZERO {
response.amount_paid
} else {
amount
};
let amount_issued = if response.amount_issued > Amount::ZERO {
response.amount_issued
} else {
amount
};
(amount_paid, amount_issued)
}
MintQuoteState::Unpaid => (response.amount_paid, response.amount_issued),
};
if is_stale_mint_quote_update(quote, response.updated_at, amount_paid, amount_issued) {
return false;
}
quote.state = state;
quote.amount_paid = amount_paid;
quote.amount_issued = amount_issued;
quote.updated_at = quote.updated_at.max(response.updated_at);
true
}
MintQuoteResponse::Bolt12(response) => apply_accounting_mint_quote_update(
quote,
response.amount_paid,
response.amount_issued,
response.updated_at,
),
MintQuoteResponse::Onchain(response) => apply_accounting_mint_quote_update(
quote,
response.amount_paid,
response.amount_issued,
response.updated_at,
),
MintQuoteResponse::Custom { response, .. } => apply_accounting_mint_quote_update(
quote,
response.amount_paid,
response.amount_issued,
response.updated_at,
),
}
}
pub(crate) fn apply_accounting_mint_quote_update(
quote: &mut MintQuote,
amount_paid: Amount,
amount_issued: Amount,
updated_at: u64,
) -> bool {
if amount_issued > amount_paid {
tracing::debug!(
"Ignoring invalid mint quote accounting: amount_issued {} exceeds amount_paid {}",
amount_issued,
amount_paid
);
return false;
}
if is_stale_mint_quote_update(quote, updated_at, amount_paid, amount_issued) {
return false;
}
quote.amount_paid = amount_paid;
quote.amount_issued = amount_issued;
quote.updated_at = quote.updated_at.max(updated_at);
quote.update_state_from_amounts();
true
}
fn is_stale_mint_quote_update(
quote: &MintQuote,
updated_at: u64,
amount_paid: Amount,
amount_issued: Amount,
) -> bool {
updated_at < quote.updated_at
|| amount_paid < quote.amount_paid
|| amount_issued < quote.amount_issued
}
fn local_mint_quote_amount(method: &PaymentMethod, amount: Option<Amount>) -> Option<Amount> {
match method {
PaymentMethod::Known(KnownMethod::Onchain) => None,
_ => amount,
}
}
fn mint_quote_response_amount(response: &MintQuoteResponse<String>) -> Option<Amount> {
match response {
MintQuoteResponse::Bolt11(r) => r.amount,
MintQuoteResponse::Bolt12(r) => r.amount,
MintQuoteResponse::Custom { response: r, .. } => r.amount,
MintQuoteResponse::Onchain(_) => None,
}
}
impl Wallet {
async fn next_mint_quote_signing_key(&self) -> Result<SecretKey, Error> {
let counter = self
.reserve_derivation_index(DerivationCounterNamespace::Nut20Quote, 0)
.await?;
Ok(crate::nuts::nut20::derive_quote_locking_key(
&self.seed, counter,
)?)
}
pub(crate) async fn mint_quote_signing_key(
&self,
quote: &MintQuote,
) -> Result<Option<SecretKey>, Error> {
#[cfg(feature = "npubcash")]
if let Some(key) = self.npubcash_quote_key("e.id).await? {
return Ok(Some(self.npubcash_quote_secret_key(key)?));
}
Ok(quote.secret_key.clone())
}
#[instrument(skip(self, method))]
pub async fn mint_quote(
&self,
method: PaymentMethod,
amount: Option<Amount>,
description: Option<String>,
extra: Option<String>,
) -> Result<MintQuote, Error> {
let quote = self
.request_mint_quote(method, amount, description, extra)
.await?;
self.localstore.add_mint_quote(quote.clone()).await?;
Ok(quote)
}
pub(crate) async fn request_mint_quote(
&self,
method: PaymentMethod,
amount: Option<Amount>,
description: Option<String>,
extra: Option<String>,
) -> Result<MintQuote, Error> {
let mint_info = self.load_mint_info().await?;
let mint_url = self.mint_url.clone();
let unit = self.unit.clone();
if description.is_some() {
let settings = mint_info
.nuts
.nut04
.get_settings(&unit, &method)
.ok_or(Error::UnsupportedUnit)?;
match settings.options {
Some(MintMethodOptions::Bolt11 { description }) if description => (),
_ => return Err(Error::InvoiceDescriptionUnsupported),
}
}
self.keysets(Default::default()).await?;
let secret_key = self.next_mint_quote_signing_key().await?;
let request = match &method {
PaymentMethod::Known(KnownMethod::Bolt11) => {
let amount = amount.ok_or(Error::AmountUndefined)?;
MintQuoteRequest::Bolt11(cdk_common::nut23::MintQuoteBolt11Request {
amount,
unit: unit.clone(),
description,
pubkey: Some(secret_key.public_key()),
})
}
PaymentMethod::Known(KnownMethod::Bolt12) => {
MintQuoteRequest::Bolt12(cdk_common::nut25::MintQuoteBolt12Request {
amount,
unit: unit.clone(),
description,
pubkey: secret_key.public_key(),
})
}
PaymentMethod::Custom(_) => {
let amount = amount.ok_or(Error::AmountUndefined)?;
MintQuoteRequest::Custom {
method: method.clone(),
request: cdk_common::nuts::MintQuoteCustomRequest {
amount: Some(amount),
unit: unit.clone(),
description,
pubkey: Some(secret_key.public_key()),
extra: serde_json::from_str(extra.as_deref().unwrap_or("{}"))?,
},
}
}
PaymentMethod::Known(KnownMethod::Onchain) => {
MintQuoteRequest::Onchain(cdk_common::nuts::nut30::MintQuoteOnchainRequest {
unit: unit.clone(),
pubkey: secret_key.public_key(),
})
}
};
let response: MintQuoteResponse<String> = self.client.post_mint_quote(request).await?;
let quote_id = response.quote().to_string();
let request_str = response.request().to_string();
let expiry = response.expiry();
let mut quote = MintQuote::new(
quote_id,
mint_url,
method.clone(),
local_mint_quote_amount(&method, amount),
unit,
request_str,
expiry.unwrap_or(0),
Some(secret_key),
);
apply_mint_quote_response(&mut quote, &response);
Ok(quote)
}
async fn check_state(&self, mint_quote: &mut MintQuote) -> Result<(), Error> {
let mint_quote_response: MintQuoteResponse<String> = self
.client
.get_mint_quote_status(mint_quote.payment_method.clone(), &mint_quote.id)
.await?;
apply_mint_quote_response(mint_quote, &mint_quote_response);
Ok(())
}
#[instrument(skip_all)]
async fn inner_check_mint_quote_status(
&self,
mut mint_quote: MintQuote,
) -> Result<MintQuote, Error> {
let quote_id = mint_quote.id.clone();
self.check_state(&mut mint_quote).await?;
if let Some(ref operation_id_str) = mint_quote.used_by_operation {
if let Ok(operation_id) = uuid::Uuid::parse_str(operation_id_str) {
match self.localstore.get_saga(&operation_id).await {
Ok(Some(saga)) => {
tracing::info!(
"Mint quote {} has in-progress saga {}, attempting to complete",
quote_id,
operation_id
);
let recovery_action = self.resume_issue_saga(&saga).await?;
if recovery_action == RecoveryAction::Compensated {
tracing::info!(
"Saga {} was compensated, attempting fresh mint for quote {}",
operation_id,
quote_id
);
} else {
mint_quote = self
.localstore
.get_mint_quote("e_id)
.await?
.ok_or(Error::UnknownQuote)?;
}
}
Ok(None) => {
tracing::warn!(
"Mint quote {} has orphaned reservation for operation {}, releasing",
quote_id,
operation_id
);
if let Err(e) = self.localstore.release_mint_quote(&operation_id).await {
tracing::warn!("Failed to release orphaned mint quote: {}", e);
}
}
Err(e) => {
tracing::warn!("Failed to check saga for mint quote {}: {}", quote_id, e);
return Err(Error::Database(e));
}
}
}
}
self.localstore.add_mint_quote(mint_quote.clone()).await?;
Ok(mint_quote)
}
#[instrument(skip(self, quote_id))]
pub async fn check_mint_quote_status(&self, quote_id: &str) -> Result<MintQuote, Error> {
let mint_quote = self
.localstore
.get_mint_quote(quote_id)
.await?
.ok_or(Error::UnknownQuote)?;
let mint_quote = self.inner_check_mint_quote_status(mint_quote).await?;
Ok(mint_quote)
}
#[instrument(skip(self, quote_id))]
pub async fn check_mint_quote(&self, quote_id: &str) -> Result<MintQuote, Error> {
self.check_mint_quote_status(quote_id).await
}
#[instrument(skip(self))]
pub async fn check_all_mint_quotes(&self) -> Result<Vec<MintQuote>, Error> {
let mint_quotes = self.localstore.get_unissued_mint_quotes().await?;
let mut updated_quotes = Vec::new();
for mint_quote in mint_quotes {
if mint_quote.mint_url != self.mint_url || mint_quote.unit != self.unit {
continue;
}
match self.inner_check_mint_quote_status(mint_quote).await {
Ok(q) => updated_quotes.push(q),
Err(err) => {
tracing::warn!("Could not check quote state: {}", err);
continue;
}
}
}
Ok(updated_quotes)
}
#[instrument(skip(self))]
pub async fn mint_unissued_quotes(&self) -> Result<Amount, Error> {
let mint_quotes = self.localstore.get_unissued_mint_quotes().await?;
let mut total_amount = Amount::ZERO;
for mint_quote in mint_quotes {
if mint_quote.mint_url != self.mint_url || mint_quote.unit != self.unit {
continue;
}
let current_amount_issued = mint_quote.amount_issued;
let mint_quote = match self.inner_check_mint_quote_status(mint_quote).await {
Ok(q) => q,
Err(err) => {
tracing::warn!("Could not check quote state: {}", err);
continue;
}
};
if mint_quote.amount_mintable() > Amount::ZERO {
if let Err(err) = self
.mint(&mint_quote.id, SplitTarget::default(), None)
.await
{
tracing::warn!("Could not mint quote {}: {}", mint_quote.id, err);
continue;
}
}
let updated_quote = match self.localstore.get_mint_quote(&mint_quote.id).await {
Ok(Some(q)) => q,
_ => continue,
};
total_amount = total_amount
.checked_add(
updated_quote
.amount_issued
.checked_sub(current_amount_issued)
.unwrap_or_default(),
)
.ok_or(Error::AmountOverflow)?;
}
Ok(total_amount)
}
#[instrument(skip(self))]
pub async fn get_active_mint_quotes(&self) -> Result<Vec<MintQuote>, Error> {
let mut mint_quotes = self.localstore.get_mint_quotes().await?;
let unix_time = unix_time();
mint_quotes.retain(|quote| {
quote.mint_url == self.mint_url
&& quote.unit == self.unit
&& quote.state != MintQuoteState::Issued
&& quote.expiry > unix_time
});
Ok(mint_quotes)
}
#[instrument(skip(self))]
pub async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, Error> {
let mut pending_quotes = self.localstore.get_unissued_mint_quotes().await?;
pending_quotes.retain(|quote| quote.mint_url == self.mint_url && quote.unit == self.unit);
Ok(pending_quotes)
}
#[instrument(skip(self))]
pub async fn mint(
&self,
quote_id: &str,
amount_split_target: SplitTarget,
spending_conditions: Option<SpendingConditions>,
) -> Result<Proofs, Error> {
self.retry_on_inactive_keyset(|| async {
let saga = MintSaga::new(self);
let saga = saga
.prepare(
quote_id,
amount_split_target.clone(),
spending_conditions.clone(),
)
.await?;
let saga = saga.execute().await?;
Ok(saga.into_proofs())
})
.await
}
#[instrument(skip(self))]
pub async fn mint_unified(
&self,
quote_id: &str,
amount_split_target: SplitTarget,
spending_conditions: Option<SpendingConditions>,
) -> Result<Proofs, Error> {
self.mint(quote_id, amount_split_target, spending_conditions)
.await
}
#[instrument(skip(self, quote_id))]
pub async fn fetch_mint_quote(
&self,
quote_id: &str,
payment_method: Option<PaymentMethod>,
) -> Result<MintQuote, Error> {
let existing_quote = self.localstore.get_mint_quote(quote_id).await?;
let method = match (&existing_quote, &payment_method) {
(Some(q), _) => q.payment_method.clone(),
(None, Some(m)) => m.clone(),
(None, None) => return Err(Error::PaymentMethodRequired),
};
let response: MintQuoteResponse<String> = self
.client
.get_mint_quote_status(method.clone(), quote_id)
.await?;
let quote = match existing_quote {
Some(mut existing) => {
apply_mint_quote_response(&mut existing, &response);
existing
}
None => {
let amount = mint_quote_response_amount(&response);
let unit = match &response {
MintQuoteResponse::Bolt11(r) => r.unit.clone(),
MintQuoteResponse::Bolt12(r) => Some(r.unit.clone()),
MintQuoteResponse::Custom { response: r, .. } => r.unit.clone(),
MintQuoteResponse::Onchain(r) => Some(r.unit.clone()),
};
let mut quote = MintQuote::new(
quote_id.to_string(),
self.mint_url.clone(),
method,
amount,
unit.unwrap_or(self.unit.clone()),
response.request().to_string(),
response.expiry().unwrap_or(0),
None,
);
apply_mint_quote_response(&mut quote, &response);
quote
}
};
self.localstore.add_mint_quote(quote.clone()).await?;
Ok(quote)
}
#[instrument(skip(self, quote_ids))]
pub async fn batch_check_mint_quote_status(
&self,
quote_ids: &[&str],
) -> Result<Vec<MintQuote>, Error> {
if quote_ids.is_empty() {
return Err(Error::UnknownQuote);
}
let mut quotes: Vec<MintQuote> = Vec::new();
for quote_id in quote_ids {
let quote = self
.localstore
.get_mint_quote(quote_id)
.await?
.ok_or(Error::UnknownQuote)?;
quotes.push(quote);
}
let payment_method = quotes[0].payment_method.clone();
for quote in "es {
if quote.payment_method != payment_method {
return Err(Error::InvalidPaymentMethod);
}
}
let request = BatchCheckMintQuoteRequest {
quotes: quote_ids.iter().map(|s| s.to_string()).collect(),
};
let responses = self
.client
.post_batch_check_mint_quote_status(&payment_method, request)
.await?;
for (quote, response) in quotes.iter_mut().zip(responses.iter()) {
apply_mint_quote_response(quote, response);
self.localstore.add_mint_quote(quote.clone()).await?;
}
Ok(quotes)
}
#[instrument(skip(self, quote_ids, spending_conditions, external_keys))]
pub async fn batch_mint(
&self,
quote_ids: &[&str],
amount_split_target: SplitTarget,
spending_conditions: Option<SpendingConditions>,
external_keys: Option<std::collections::HashMap<String, SecretKey>>,
) -> Result<Proofs, Error> {
let saga = MintSaga::new(self);
let prepared = saga
.prepare_batch(
quote_ids,
amount_split_target,
spending_conditions,
external_keys.as_ref(),
)
.await?;
let finalized = prepared.execute().await?;
Ok(finalized.into_proofs())
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use bip39::Mnemonic;
use cdk_common::mint_url::MintUrl;
use cdk_common::nuts::CurrencyUnit;
use super::*;
#[tokio::test]
async fn mint_quote_signing_keys_follow_nut20_counter_across_wallet_instances() {
let mnemonic = Mnemonic::from_str(
"half depart obvious quality work element tank gorilla view sugar picture humble",
)
.expect("valid mnemonic");
let seed = mnemonic.to_seed_normalized("");
let db = crate::wallet::test_utils::create_test_db().await;
let wallet = Wallet::new(
"https://mint.example.com",
CurrencyUnit::Sat,
db.clone(),
seed,
None,
)
.expect("wallet should build");
let first = wallet
.next_mint_quote_signing_key()
.await
.expect("first key should derive");
let second = wallet
.next_mint_quote_signing_key()
.await
.expect("second key should derive");
let restarted_wallet = Wallet::new(
"https://mint.example.com",
CurrencyUnit::Sat,
db,
seed,
None,
)
.expect("wallet should rebuild");
let third = restarted_wallet
.next_mint_quote_signing_key()
.await
.expect("third key should derive");
assert_eq!(
first.public_key().to_hex(),
"03062837166e56114b59a4d1fd3a5a812bf7aadc1dde758428cf943d80acd41539"
);
assert_eq!(
second.public_key().to_hex(),
"02b47d9d41725f5ce6f08c874835cef25376cb1e95f6cb073fef52ca8fd986cf15"
);
assert_eq!(
third.public_key().to_hex(),
"029acbd3a46fd75bc05ba0226d0b4d909b2fb6e96c80544a094a1a3567737e44d3"
);
}
#[test]
fn local_onchain_mint_quote_amount_is_not_stored() {
let amount = Some(Amount::from(1_000));
assert_eq!(
local_mint_quote_amount(&PaymentMethod::Known(KnownMethod::Onchain), amount),
None
);
}
#[test]
fn fetched_onchain_mint_quote_does_not_use_amount_paid_as_amount() {
let response =
MintQuoteResponse::Onchain(cdk_common::nuts::nut30::MintQuoteOnchainResponse {
quote: "quote-id".to_string(),
request: "bc1qexample".to_string(),
unit: CurrencyUnit::Sat,
method: PaymentMethod::Known(KnownMethod::Onchain),
expiry: Some(1_700_000_000),
pubkey: SecretKey::generate().public_key(),
amount_paid: Amount::from(1_000),
amount_issued: Amount::from(250),
updated_at: 0,
});
assert_eq!(mint_quote_response_amount(&response), None);
}
#[test]
fn stale_mint_quote_response_does_not_decrease_accounting() {
let mut quote = MintQuote::new(
"quote-id".to_string(),
MintUrl::from_str("https://mint.example.com").expect("valid mint url"),
PaymentMethod::Custom("custom".to_string()),
Some(Amount::from(200)),
CurrencyUnit::Sat,
"custom-request".to_string(),
1_700_000_000,
None,
);
quote.amount_paid = Amount::from(100);
quote.amount_issued = Amount::from(20);
quote.updated_at = 10;
quote.update_state_from_amounts();
let stale_response = custom_mint_quote_response(Amount::from(200), Amount::from(20), 9);
assert!(!apply_mint_quote_response(&mut quote, &stale_response));
assert_eq!(quote.amount_paid, Amount::from(100));
assert_eq!(quote.amount_issued, Amount::from(20));
assert_eq!(quote.updated_at, 10);
let decreasing_response =
custom_mint_quote_response(Amount::from(90), Amount::from(20), 11);
assert!(!apply_mint_quote_response(&mut quote, &decreasing_response));
assert_eq!(quote.amount_paid, Amount::from(100));
assert_eq!(quote.amount_issued, Amount::from(20));
assert_eq!(quote.updated_at, 10);
let fresh_response = custom_mint_quote_response(Amount::from(150), Amount::from(30), 12);
assert!(apply_mint_quote_response(&mut quote, &fresh_response));
assert_eq!(quote.amount_paid, Amount::from(150));
assert_eq!(quote.amount_issued, Amount::from(30));
assert_eq!(quote.updated_at, 12);
}
#[test]
fn invalid_mint_quote_response_does_not_apply_accounting() {
let mut quote = MintQuote::new(
"quote-id".to_string(),
MintUrl::from_str("https://mint.example.com").expect("valid mint url"),
PaymentMethod::Custom("custom".to_string()),
Some(Amount::from(200)),
CurrencyUnit::Sat,
"custom-request".to_string(),
1_700_000_000,
None,
);
quote.amount_paid = Amount::from(100);
quote.amount_issued = Amount::from(20);
quote.updated_at = 10;
quote.update_state_from_amounts();
let invalid_response = custom_mint_quote_response(Amount::from(120), Amount::from(150), 11);
assert!(!apply_mint_quote_response(&mut quote, &invalid_response));
assert_eq!(quote.amount_paid, Amount::from(100));
assert_eq!(quote.amount_issued, Amount::from(20));
assert_eq!(quote.updated_at, 10);
assert_eq!(quote.state, MintQuoteState::Paid);
}
fn custom_mint_quote_response(
amount_paid: Amount,
amount_issued: Amount,
updated_at: u64,
) -> MintQuoteResponse<String> {
MintQuoteResponse::Custom {
method: PaymentMethod::Custom("custom".to_string()),
response: cdk_common::nut04::MintQuoteCustomResponse {
quote: "quote-id".to_string(),
request: "custom-request".to_string(),
method: PaymentMethod::Custom("custom".to_string()),
amount: Some(Amount::from(200)),
amount_paid,
amount_issued,
updated_at,
unit: Some(CurrencyUnit::Sat),
expiry: Some(1_700_000_000),
pubkey: None,
extra: serde_json::Value::Null,
},
}
}
}