use cdk_common::mint::{MeltFinalizationData, MeltQuote, MeltSagaState, Saga, SagaStateEnum};
use cdk_common::nuts::MeltQuoteState;
use cdk_common::payment::MakePaymentResponse;
use tracing::instrument;
use crate::mint::subscription::PubSubManager;
use crate::mint::Mint;
use crate::Error;
#[cfg(not(test))]
const MELT_PAYMENT_STATUS_CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[cfg(test)]
const MELT_PAYMENT_STATUS_CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
async fn check_melt_payment_status_bounded(
mint: &Mint,
quote: &MeltQuote,
) -> Result<Option<MakePaymentResponse>, Error> {
match tokio::time::timeout(
MELT_PAYMENT_STATUS_CHECK_TIMEOUT,
mint.check_melt_payment_status(quote),
)
.await
{
Ok(Ok(response)) => Ok(Some(response)),
Ok(Err(err)) => Err(err),
Err(_elapsed) => {
tracing::warn!(
"Payment status check for melt quote {} timed out after {:?}. Leaving pending.",
quote.id,
MELT_PAYMENT_STATUS_CHECK_TIMEOUT
);
Ok(None)
}
}
}
#[instrument(skip_all)]
pub(crate) async fn process_melt_saga_outcome(
saga: &Saga,
quote: &mut MeltQuote,
payment_response: &MakePaymentResponse,
db: &cdk_common::database::DynMintDatabase,
pubsub: &PubSubManager,
mint: &Mint,
) -> Result<(), Error> {
process_melt_saga_outcome_inner(saga, quote, payment_response, db, pubsub, mint, false).await
}
pub(crate) async fn process_melt_saga_failure_event(
saga: &Saga,
quote: &mut MeltQuote,
payment_response: &MakePaymentResponse,
db: &cdk_common::database::DynMintDatabase,
pubsub: &PubSubManager,
mint: &Mint,
) -> Result<(), Error> {
process_melt_saga_outcome_inner(saga, quote, payment_response, db, pubsub, mint, true).await
}
#[allow(clippy::too_many_arguments)]
async fn process_melt_saga_outcome_inner(
saga: &Saga,
quote: &mut MeltQuote,
payment_response: &MakePaymentResponse,
db: &cdk_common::database::DynMintDatabase,
pubsub: &PubSubManager,
mint: &Mint,
definitive_failure_event: bool,
) -> Result<(), Error> {
match payment_response.status {
MeltQuoteState::Paid => {
match super::melt::shared::try_acquire_melt_dispatch_lock(db, "e.id).await? {
super::melt::shared::DispatchLockAttempt::Acquired(mut tx) => {
match tx.get_saga_for_update(&saga.operation_id).await? {
Some(mut current_saga) => {
if let Err(err) =
super::melt::shared::persist_melt_finalization_handoff(
&mut tx,
&mut current_saga,
payment_response,
)
.await
{
tx.rollback().await?;
return Err(err);
}
tx.commit().await?;
}
None => {
tx.rollback().await?;
}
}
}
super::melt::shared::DispatchLockAttempt::Contended => {
tracing::info!(
"Melt quote {} dispatch lock is contended; using the trusted paid outcome to preempt any later dispatch rollback (saga {})",
quote.id,
saga.operation_id
);
}
super::melt::shared::DispatchLockAttempt::Unsupported => {}
}
finalize_paid_melt_outcome(saga, quote, payment_response, db, pubsub, mint).await
}
MeltQuoteState::Unpaid | MeltQuoteState::Failed => {
reconcile_terminal_melt(saga, quote, db, pubsub, mint, definitive_failure_event).await
}
MeltQuoteState::Pending => {
persist_pending_after_dispatch(saga, quote, payment_response, db).await?;
tracing::debug!(
"Melt quote {} (saga {}) payment remains Pending",
quote.id,
saga.operation_id
);
Ok(())
}
MeltQuoteState::Unknown => {
tracing::debug!(
"Melt quote {} (saga {}) payment status still {}, skipping action",
quote.id,
saga.operation_id,
payment_response.status
);
Ok(())
}
}
}
async fn persist_pending_after_dispatch(
stale_saga: &Saga,
quote: &mut MeltQuote,
payment_response: &MakePaymentResponse,
db: &cdk_common::database::DynMintDatabase,
) -> Result<(), Error> {
match super::melt::shared::try_acquire_melt_dispatch_lock(db, "e.id).await? {
super::melt::shared::DispatchLockAttempt::Acquired(mut tx) => {
let Some(saga) = tx.get_saga_for_update(&stale_saga.operation_id).await? else {
tx.rollback().await?;
*quote = db
.get_melt_quote("e.id)
.await?
.ok_or(Error::UnknownQuote)?;
return Ok(());
};
*quote = tx
.get_melt_quote("e.id)
.await?
.ok_or(Error::UnknownQuote)?
.inner();
if quote.state == MeltQuoteState::Pending {
persist_payment_lookup_id_in_transaction(&mut tx, &saga, quote, payment_response)
.await?;
tx.commit().await?;
} else {
tx.rollback().await?;
}
Ok(())
}
super::melt::shared::DispatchLockAttempt::Contended => {
*quote = db
.get_melt_quote("e.id)
.await?
.ok_or(Error::UnknownQuote)?;
Ok(())
}
super::melt::shared::DispatchLockAttempt::Unsupported => {
persist_payment_lookup_id(stale_saga, quote, payment_response, db).await
}
}
}
#[allow(clippy::too_many_arguments)]
async fn reconcile_terminal_melt(
saga: &Saga,
quote: &mut MeltQuote,
db: &cdk_common::database::DynMintDatabase,
pubsub: &PubSubManager,
mint: &Mint,
definitive_failure_event: bool,
) -> Result<(), Error> {
let result = match super::melt::shared::try_acquire_melt_dispatch_lock(db, "e.id).await? {
super::melt::shared::DispatchLockAttempt::Acquired(tx) => {
reconcile_terminal_melt_with_dispatch_lock(
tx,
saga,
quote,
db,
pubsub,
mint,
definitive_failure_event,
)
.await
}
super::melt::shared::DispatchLockAttempt::Contended => {
tracing::info!(
"Melt quote {} dispatch lock is contended; leaving pending (saga {})",
quote.id,
saga.operation_id
);
*quote = db
.get_melt_quote("e.id)
.await?
.ok_or(Error::UnknownQuote)?;
Ok(None)
}
super::melt::shared::DispatchLockAttempt::Unsupported => {
reconcile_terminal_melt_without_advisory_lock(
saga,
quote,
db,
pubsub,
mint,
definitive_failure_event,
)
.await
}
};
match result? {
Some((saga, payment_response)) => {
finalize_paid_melt_outcome(&saga, quote, &payment_response, db, pubsub, mint).await
}
None => Ok(()),
}
}
#[allow(clippy::too_many_arguments)]
async fn reconcile_terminal_melt_with_dispatch_lock(
mut tx: cdk_common::database::DynMintTransaction,
stale_saga: &Saga,
quote: &mut MeltQuote,
db: &cdk_common::database::DynMintDatabase,
pubsub: &PubSubManager,
mint: &Mint,
definitive_failure_event: bool,
) -> Result<Option<(Saga, MakePaymentResponse)>, Error> {
let Some(mut saga) = tx.get_saga_for_update(&stale_saga.operation_id).await? else {
tx.rollback().await?;
*quote = db
.get_melt_quote("e.id)
.await?
.ok_or(Error::UnknownQuote)?;
return Ok(None);
};
*quote = tx
.get_melt_quote("e.id)
.await?
.ok_or(Error::UnknownQuote)?
.inner();
let internal_response = match Mint::internal_melt_settlement_response_tx(&mut tx, quote).await {
Ok(internal_response) => internal_response,
Err(err) => {
tracing::error!(
"Could not determine internal settlement state for melt quote {} (saga {}): {}. Leaving pending.",
quote.id,
saga.operation_id,
err
);
tx.rollback().await?;
return Ok(None);
}
};
if let Some(internal_response) = internal_response {
let saga = saga.inner();
tx.rollback().await?;
return Ok(Some((saga, internal_response)));
}
let fresh_response = match check_melt_payment_status_bounded(mint, quote).await {
Ok(Some(response)) => response,
Ok(None) => {
tx.rollback().await?;
return Ok(None);
}
Err(err) => {
tracing::error!(
"Cannot verify payment status for melt quote {} (saga {}): {}. Leaving pending.",
quote.id,
saga.operation_id,
err
);
tx.rollback().await?;
return Ok(None);
}
};
match fresh_response.status {
MeltQuoteState::Paid => {
if let Err(err) = super::melt::shared::persist_melt_finalization_handoff(
&mut tx,
&mut saga,
&fresh_response,
)
.await
{
tx.rollback().await?;
return Err(err);
}
let saga = saga.inner();
tx.commit().await?;
return Ok(Some((saga, fresh_response)));
}
MeltQuoteState::Pending => {
persist_payment_lookup_id_in_transaction(&mut tx, &saga, quote, &fresh_response)
.await?;
tx.commit().await?;
return Ok(None);
}
MeltQuoteState::Unknown => {
tx.rollback().await?;
return Ok(None);
}
MeltQuoteState::Unpaid | MeltQuoteState::Failed => {}
}
if matches!(
&saga.state,
SagaStateEnum::Melt(MeltSagaState::PaymentAttempted | MeltSagaState::PaymentPending)
) && !definitive_failure_event
{
tracing::warn!(
"Ignoring contradictory {} status for melt quote {} because saga {} has an ambiguous dispatch state",
fresh_response.status,
quote.id,
saga.operation_id
);
tx.rollback().await?;
return Ok(None);
}
let input_ys = tx.get_proof_ys_by_operation_id(&saga.operation_id).await?;
let blinded_secrets = tx
.get_melt_request_and_blinded_messages("e.id)
.await?
.map(|request| {
request
.change_outputs
.into_iter()
.map(|output| output.blinded_secret)
.collect::<Vec<_>>()
})
.unwrap_or_default();
match super::melt::shared::rollback_melt_quote_with_dispatch_lock(
tx,
pubsub,
"e.id,
&input_ys,
&blinded_secrets,
&saga.operation_id,
)
.await
{
Ok(()) => {
*quote = db
.get_melt_quote("e.id)
.await?
.ok_or(Error::UnknownQuote)?;
}
Err(Error::UnknownPaymentState) => {}
Err(err) => return Err(err),
}
Ok(None)
}
#[allow(clippy::too_many_arguments)]
async fn reconcile_terminal_melt_without_advisory_lock(
stale_saga: &Saga,
quote: &mut MeltQuote,
db: &cdk_common::database::DynMintDatabase,
pubsub: &PubSubManager,
mint: &Mint,
definitive_failure_event: bool,
) -> Result<Option<(Saga, MakePaymentResponse)>, Error> {
let current_saga = db.get_melt_saga_by_quote_id("e.id).await?;
let Some(saga) = current_saga else {
*quote = db
.get_melt_quote("e.id)
.await?
.ok_or(Error::UnknownQuote)?;
if quote.state != MeltQuoteState::Paid {
tracing::warn!(
"Melt saga {} disappeared while quote {} is {}; leaving it unchanged",
stale_saga.operation_id,
quote.id,
quote.state
);
}
return Ok(None);
};
*quote = db
.get_melt_quote("e.id)
.await?
.ok_or(Error::UnknownQuote)?;
match mint.internal_melt_settlement_response(quote).await {
Ok(Some(internal_response)) => {
return Ok(Some((saga, internal_response)));
}
Ok(None) => {}
Err(err) => {
tracing::error!(
"Could not determine internal settlement state for melt quote {} (saga {}): {}. Leaving pending.",
quote.id,
saga.operation_id,
err
);
return Ok(None);
}
}
let fresh_response = match check_melt_payment_status_bounded(mint, quote).await {
Ok(Some(response)) => response,
Ok(None) => {
return Ok(None);
}
Err(err) => {
tracing::error!(
"Cannot verify payment status for melt quote {} (saga {}): {}. Leaving pending.",
quote.id,
saga.operation_id,
err
);
return Ok(None);
}
};
match fresh_response.status {
MeltQuoteState::Paid => return Ok(Some((saga, fresh_response))),
MeltQuoteState::Pending => {
persist_payment_lookup_id(&saga, quote, &fresh_response, db).await?;
return Ok(None);
}
MeltQuoteState::Unknown => return Ok(None),
MeltQuoteState::Unpaid | MeltQuoteState::Failed => {}
}
if matches!(
&saga.state,
SagaStateEnum::Melt(MeltSagaState::PaymentAttempted | MeltSagaState::PaymentPending)
) && !definitive_failure_event
{
tracing::warn!(
"Ignoring contradictory {} status for melt quote {} because saga {} has an ambiguous dispatch state",
fresh_response.status,
quote.id,
saga.operation_id
);
return Ok(None);
}
let input_ys = db.get_proof_ys_by_operation_id(&saga.operation_id).await?;
let blinded_secrets = db
.get_blinded_secrets_by_operation_id(&saga.operation_id)
.await?;
let rollback = super::melt::shared::rollback_melt_quote(
db,
pubsub,
"e.id,
&input_ys,
&blinded_secrets,
&saga.operation_id,
)
.await;
match rollback {
Ok(()) => {
*quote = db
.get_melt_quote("e.id)
.await?
.ok_or(Error::UnknownQuote)?;
}
Err(Error::UnknownPaymentState) => {
tracing::info!(
"Rollback refused for melt quote {}; finalization owns terminality (saga {})",
quote.id,
saga.operation_id
);
}
Err(err) => return Err(err),
}
Ok(None)
}
async fn persist_payment_lookup_id(
saga: &Saga,
quote: &mut MeltQuote,
payment_response: &MakePaymentResponse,
db: &cdk_common::database::DynMintDatabase,
) -> Result<(), Error> {
let mut tx = db.begin_transaction().await?;
persist_payment_lookup_id_in_transaction(&mut tx, saga, quote, payment_response).await?;
tx.commit().await?;
Ok(())
}
async fn persist_payment_lookup_id_in_transaction(
tx: &mut cdk_common::database::DynMintTransaction,
saga: &Saga,
quote: &mut MeltQuote,
payment_response: &MakePaymentResponse,
) -> Result<(), Error> {
let mut current_saga = tx
.get_saga_for_update(&saga.operation_id)
.await?
.ok_or(Error::Internal)?;
if matches!(
¤t_saga.state,
SagaStateEnum::Melt(MeltSagaState::Finalizing)
) {
tracing::info!(
"Ignoring pending payment result for melt quote {} because saga {} is already finalizing",
quote.id,
saga.operation_id
);
return Ok(());
}
let mut current_quote = tx
.get_melt_quote("e.id)
.await?
.ok_or(Error::UnknownQuote)?;
if current_quote.request_lookup_id.as_ref() != Some(&payment_response.payment_lookup_id) {
tx.update_melt_quote_request_lookup_id(
&mut current_quote,
&payment_response.payment_lookup_id,
)
.await?;
}
tx.update_acquired_saga(
&mut current_saga,
SagaStateEnum::Melt(MeltSagaState::PaymentPending),
)
.await?;
quote.request_lookup_id = Some(payment_response.payment_lookup_id.clone());
Ok(())
}
async fn finalize_paid_melt_outcome(
saga: &Saga,
quote: &mut MeltQuote,
payment_response: &MakePaymentResponse,
db: &cdk_common::database::DynMintDatabase,
pubsub: &PubSubManager,
mint: &Mint,
) -> Result<(), Error> {
tracing::info!(
"Finalizing paid melt quote {} (saga {})",
quote.id,
saga.operation_id
);
let total_spent =
super::melt::shared::total_spent_for_quote_unit(&payment_response.total_spent, "e.unit)
.map_err(|e| {
tracing::error!(
"Failed to convert recovered total_spent for quote {}: {:?}",
quote.id,
e
);
Error::UnitMismatch
})?;
let mut tx = db.begin_transaction().await?;
let Some(mut acquired_saga) = tx.get_saga_for_update(&saga.operation_id).await? else {
tx.rollback().await?;
return match db.get_melt_quote("e.id).await? {
Some(current) if current.state == MeltQuoteState::Paid => {
tracing::info!(
"Melt quote {} already finalized; ignoring paid outcome for missing saga {}",
quote.id,
saga.operation_id
);
*quote = current;
Ok(())
}
_ => {
tracing::error!(
"Paid outcome for melt quote {} but saga {} is missing and quote is not Paid",
quote.id,
saga.operation_id
);
Err(Error::Internal)
}
};
};
let finalization_data = MeltFinalizationData {
total_spent: total_spent.clone(),
payment_lookup_id: payment_response.payment_lookup_id.clone(),
payment_proof: payment_response.payment_proof.clone(),
};
tx.update_acquired_saga_with_finalization_data(
&mut acquired_saga,
SagaStateEnum::Melt(MeltSagaState::Finalizing),
Some(&finalization_data),
)
.await?;
tx.commit().await?;
super::melt::shared::finalize_melt_quote(
mint,
db,
pubsub,
quote,
total_spent,
payment_response.payment_proof.clone(),
&payment_response.payment_lookup_id,
Some(saga.operation_id),
)
.await?;
quote.state = MeltQuoteState::Paid;
quote.payment_proof = payment_response.payment_proof.clone();
quote.request_lookup_id = Some(payment_response.payment_lookup_id.clone());
Ok(())
}
#[cfg(test)]
mod tests {
use cdk_common::mint::{OperationKind, Saga};
use cdk_common::nut00::KnownMethod;
use cdk_common::nuts::{CurrencyUnit, MeltQuoteBolt11Request, ProofsMethods, State};
use cdk_common::payment::PaymentIdentifier;
use cdk_common::{Amount, PaymentMethod};
use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
use super::*;
use crate::mint::melt::melt_saga::MeltSaga;
use crate::test_helpers::mint::{create_test_mint, mint_test_proofs};
#[tokio::test]
async fn slow_backend_status_check_fails_closed_without_holding_locks() {
use std::sync::Arc;
use std::time::Duration;
use cdk_common::payment::{self, MintPayment};
use crate::mint::{MintBuilder, MintMeltLimits};
use crate::types::{FeeReserve, QuoteTTL};
struct HangingStatusBackend {
inner: cdk_fake_wallet::FakeWallet,
}
#[async_trait::async_trait]
impl MintPayment for HangingStatusBackend {
type Err = payment::Error;
async fn get_settings(&self) -> Result<payment::SettingsResponse, Self::Err> {
self.inner.get_settings().await
}
async fn create_incoming_payment_request(
&self,
options: payment::IncomingPaymentOptions,
) -> Result<payment::CreateIncomingPaymentResponse, Self::Err> {
self.inner.create_incoming_payment_request(options).await
}
async fn get_payment_quote(
&self,
unit: &CurrencyUnit,
options: payment::OutgoingPaymentOptions,
) -> Result<payment::PaymentQuoteResponse, Self::Err> {
self.inner.get_payment_quote(unit, options).await
}
async fn make_payment(
&self,
unit: &CurrencyUnit,
options: payment::OutgoingPaymentOptions,
) -> Result<MakePaymentResponse, Self::Err> {
self.inner.make_payment(unit, options).await
}
async fn check_incoming_payment_status(
&self,
payment_identifier: &PaymentIdentifier,
) -> Result<Vec<payment::WaitPaymentResponse>, Self::Err> {
self.inner
.check_incoming_payment_status(payment_identifier)
.await
}
async fn check_outgoing_payment(
&self,
_payment_identifier: &PaymentIdentifier,
) -> Result<MakePaymentResponse, Self::Err> {
std::future::pending::<()>().await;
unreachable!("pending never resolves")
}
async fn wait_payment_event(
&self,
) -> Result<
std::pin::Pin<Box<dyn futures::Stream<Item = payment::Event> + Send>>,
Self::Err,
> {
Ok(Box::pin(futures::stream::pending()))
}
fn is_payment_event_stream_active(&self) -> bool {
false
}
fn cancel_payment_event_stream(&self) {}
}
let db = Arc::new(cdk_sqlite::mint::memory::empty().await.unwrap());
let mut mint_builder = MintBuilder::new(db.clone());
let backend = HangingStatusBackend {
inner: cdk_fake_wallet::FakeWallet::new(
FeeReserve {
min_fee_reserve: 1.into(),
percent_fee_reserve: 1.0,
},
std::collections::HashMap::default(),
std::collections::HashSet::default(),
2,
CurrencyUnit::Sat,
),
};
mint_builder
.add_payment_processor(
CurrencyUnit::Sat,
PaymentMethod::Known(KnownMethod::Bolt11),
MintMeltLimits::new(1, 10_000),
Arc::new(backend),
)
.await
.unwrap();
let mnemonic = bip39::Mnemonic::generate(12).unwrap();
let mint = mint_builder
.with_name("test mint".to_string())
.with_description("test mint".to_string())
.with_urls(vec!["https://test-mint".to_string()])
.build_with_seed(db.clone(), &mnemonic.to_seed_normalized(""))
.await
.unwrap();
mint.set_quote_ttl(QuoteTTL::new(10000, 10000))
.await
.unwrap();
mint.start().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = assert_single_melt_saga_operation_id(&mint).await;
drop(setup_saga);
let mut tx = mint.localstore.begin_transaction().await.unwrap();
let mut acquired = tx
.get_saga_for_update(&operation_id)
.await
.unwrap()
.expect("saga should exist");
tx.update_acquired_saga(
&mut acquired,
SagaStateEnum::Melt(MeltSagaState::PaymentPending),
)
.await
.unwrap();
tx.commit().await.unwrap();
let saga = assert_saga_exists(&mint, &operation_id).await;
let mut quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("quote should exist");
let payment_response = MakePaymentResponse {
payment_lookup_id: quote
.request_lookup_id
.clone()
.expect("bolt11 quote should have a lookup id"),
payment_proof: None,
status: MeltQuoteState::Failed,
total_spent: quote.amount(),
};
let started = std::time::Instant::now();
process_melt_saga_outcome(
&saga,
&mut quote,
&payment_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap();
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(60),
"reconciliation returned after the bounded status check, not the hanging backend (elapsed {:?})",
elapsed
);
assert_eq!(quote.state, MeltQuoteState::Pending);
assert_saga_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
}
#[tokio::test]
async fn test_paid_outcome_finalizes_and_records_completed_operation() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = assert_single_melt_saga_operation_id(&mint).await;
let (payment_saga, decision) = setup_saga
.attempt_internal_settlement(&melt_request)
.await
.unwrap();
let _confirmed_saga = payment_saga.make_payment(decision).await.unwrap();
let mut quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
let saga = assert_saga_exists(&mint, &operation_id).await;
let payment_response = MakePaymentResponse {
payment_lookup_id: PaymentIdentifier::CustomId("paid_outcome_lookup".to_string()),
payment_proof: Some("paid_outcome_preimage".to_string()),
status: MeltQuoteState::Paid,
total_spent: Amount::from(9_250).with_unit(CurrencyUnit::Sat),
};
process_melt_saga_outcome(
&saga,
&mut quote,
&payment_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap();
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Spent)).await;
let completed_operation = mint
.localstore
.get_completed_operation(&operation_id)
.await
.unwrap()
.expect("completed operation should be recorded");
assert_eq!(completed_operation.kind(), OperationKind::Melt);
assert_eq!(completed_operation.id(), &operation_id);
let paid_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("quote should exist after finalization");
assert_eq!(paid_quote.state, MeltQuoteState::Paid);
}
#[tokio::test]
async fn test_failed_outcome_rolls_back_and_deletes_saga() {
let fake_description = FakeInvoiceDescription {
pay_invoice_state: MeltQuoteState::Failed,
check_payment_state: MeltQuoteState::Failed,
pay_err: false,
check_err: false,
};
let amount_msats: u64 = Amount::from(9_000).into();
let invoice = create_fake_invoice(
amount_msats,
serde_json::to_string(&fake_description).unwrap(),
);
let payment_states = std::collections::HashMap::from([(
invoice.payment_hash().to_string(),
(
MeltQuoteState::Failed,
Amount::from(9_000).with_unit(CurrencyUnit::Sat),
),
)]);
let mint = create_test_mint_with_payment_states(payment_states)
.await
.unwrap();
let request = cdk_common::melt::MeltQuoteRequest::Bolt11(MeltQuoteBolt11Request {
request: invoice,
unit: CurrencyUnit::Sat,
options: None,
});
let quote_response = mint.get_melt_quote(request).await.unwrap();
let quote = mint
.localstore
.get_melt_quote(quote_response.quote().unwrap())
.await
.unwrap()
.expect("quote should exist in database");
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = assert_single_melt_saga_operation_id(&mint).await;
drop(setup_saga);
let mut quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
let saga = assert_saga_exists(&mint, &operation_id).await;
let payment_response = MakePaymentResponse {
payment_lookup_id: PaymentIdentifier::CustomId("failed_outcome_lookup".to_string()),
payment_proof: None,
status: MeltQuoteState::Failed,
total_spent: quote.amount(),
};
process_melt_saga_outcome(
&saga,
&mut quote,
&payment_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap();
assert_eq!(quote.state, MeltQuoteState::Unpaid);
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, None).await;
let recovered_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("quote should still exist after rollback");
assert_eq!(recovered_quote.state, MeltQuoteState::Unpaid);
}
#[tokio::test]
async fn test_ambiguous_dispatch_recovers_only_from_definitive_failure() {
for saga_state in [
MeltSagaState::PaymentAttempted,
MeltSagaState::PaymentPending,
] {
for fresh_status in [MeltQuoteState::Unpaid, MeltQuoteState::Failed] {
let fake_description = FakeInvoiceDescription {
pay_invoice_state: fresh_status,
check_payment_state: fresh_status,
pay_err: false,
check_err: false,
};
let amount_msats: u64 = Amount::from(9_000).into();
let invoice = create_fake_invoice(
amount_msats,
serde_json::to_string(&fake_description).unwrap(),
);
let payment_states = std::collections::HashMap::from([(
invoice.payment_hash().to_string(),
(
fresh_status,
Amount::from(9_000).with_unit(CurrencyUnit::Sat),
),
)]);
let mint = create_test_mint_with_payment_states(payment_states)
.await
.unwrap();
let request = cdk_common::melt::MeltQuoteRequest::Bolt11(MeltQuoteBolt11Request {
request: invoice,
unit: CurrencyUnit::Sat,
options: None,
});
let quote_response = mint.get_melt_quote(request).await.unwrap();
let quote = mint
.localstore
.get_melt_quote(quote_response.quote().unwrap())
.await
.unwrap()
.expect("quote should exist");
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = assert_single_melt_saga_operation_id(&mint).await;
drop(setup_saga);
let mut tx = mint.localstore.begin_transaction().await.unwrap();
let mut acquired_saga = tx
.get_saga_for_update(&operation_id)
.await
.unwrap()
.expect("saga should exist");
tx.update_acquired_saga(
&mut acquired_saga,
SagaStateEnum::Melt(saga_state.clone()),
)
.await
.unwrap();
tx.commit().await.unwrap();
let saga = assert_saga_exists(&mint, &operation_id).await;
let mut quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("quote should exist");
let payment_response = MakePaymentResponse {
payment_lookup_id: quote
.request_lookup_id
.clone()
.expect("bolt11 quote should have a lookup id"),
payment_proof: None,
status: fresh_status,
total_spent: quote.amount(),
};
process_melt_saga_outcome(
&saga,
&mut quote,
&payment_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap();
let _ = assert_saga_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
assert_eq!(quote.state, MeltQuoteState::Pending);
let stored_saga = assert_saga_exists(&mint, &operation_id).await;
assert_eq!(stored_saga.state, SagaStateEnum::Melt(saga_state.clone()));
process_melt_saga_failure_event(
&stored_saga,
&mut quote,
&payment_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap();
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, None).await;
assert_eq!(quote.state, MeltQuoteState::Unpaid);
}
}
}
#[tokio::test]
async fn test_failed_outcome_for_already_paid_quote_is_no_op() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = assert_single_melt_saga_operation_id(&mint).await;
let (payment_saga, decision) = setup_saga
.attempt_internal_settlement(&melt_request)
.await
.unwrap();
let confirmed_saga = match payment_saga.make_payment(decision).await.unwrap() {
crate::mint::melt::melt_saga::PaymentOutcome::Confirmed(confirmed_saga) => {
confirmed_saga
}
crate::mint::melt::melt_saga::PaymentOutcome::Pending { .. } => {
panic!("Expected confirmed payment outcome")
}
};
confirmed_saga.finalize().await.unwrap();
let mut paid_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
let saga = Saga {
operation_id,
operation_kind: OperationKind::Melt,
quote_id: Some(quote.id.to_string()),
state: SagaStateEnum::Melt(MeltSagaState::PaymentAttempted),
created_at: 0,
finalization_data: None,
updated_at: 0,
};
let payment_response = MakePaymentResponse {
payment_lookup_id: PaymentIdentifier::CustomId("failed_after_paid_lookup".to_string()),
payment_proof: None,
status: MeltQuoteState::Failed,
total_spent: paid_quote.amount(),
};
process_melt_saga_outcome(
&saga,
&mut paid_quote,
&payment_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap();
assert_eq!(paid_quote.state, MeltQuoteState::Paid);
let persisted_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("quote should still exist");
assert_eq!(persisted_quote.state, MeltQuoteState::Paid);
}
#[tokio::test]
async fn test_stale_failed_outcome_reloads_paid_quote_after_finalization() {
let fake_description = FakeInvoiceDescription {
pay_invoice_state: MeltQuoteState::Failed,
check_payment_state: MeltQuoteState::Failed,
pay_err: false,
check_err: false,
};
let amount_msats: u64 = Amount::from(9_000).into();
let invoice = create_fake_invoice(
amount_msats,
serde_json::to_string(&fake_description).unwrap(),
);
let payment_states = std::collections::HashMap::from([(
invoice.payment_hash().to_string(),
(
MeltQuoteState::Failed,
Amount::from(9_000).with_unit(CurrencyUnit::Sat),
),
)]);
let mint = create_test_mint_with_payment_states(payment_states)
.await
.unwrap();
let request = cdk_common::melt::MeltQuoteRequest::Bolt11(MeltQuoteBolt11Request {
request: invoice,
unit: CurrencyUnit::Sat,
options: None,
});
let quote_response = mint.get_melt_quote(request).await.unwrap();
let quote_id = quote_response.quote().unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let melt_quote = mint
.localstore
.get_melt_quote(quote_id)
.await
.unwrap()
.expect("quote should exist");
let melt_request = create_test_melt_request(&proofs, &melt_quote);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = assert_single_melt_saga_operation_id(&mint).await;
drop(setup_saga);
let mut tx = mint.localstore.begin_transaction().await.unwrap();
let mut saga = tx
.get_saga_for_update(&operation_id)
.await
.unwrap()
.expect("saga should exist");
tx.update_acquired_saga(
&mut saga,
SagaStateEnum::Melt(MeltSagaState::PaymentAttempted),
)
.await
.unwrap();
tx.commit().await.unwrap();
let stale_saga = assert_saga_exists(&mint, &operation_id).await;
let mut stale_quote = mint
.localstore
.get_melt_quote(quote_id)
.await
.unwrap()
.expect("quote should exist");
let mut finalizer_quote = stale_quote.clone();
let paid_response = MakePaymentResponse {
payment_lookup_id: PaymentIdentifier::CustomId("paid_event_lookup".to_string()),
payment_proof: Some("paid_event_preimage".to_string()),
status: MeltQuoteState::Paid,
total_spent: Amount::from(9_250).with_unit(CurrencyUnit::Sat),
};
finalize_paid_melt_outcome(
&stale_saga,
&mut finalizer_quote,
&paid_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap();
let failed_response = MakePaymentResponse {
payment_lookup_id: PaymentIdentifier::CustomId("stale_failed_lookup".to_string()),
payment_proof: None,
status: MeltQuoteState::Failed,
total_spent: stale_quote.amount(),
};
process_melt_saga_outcome(
&stale_saga,
&mut stale_quote,
&failed_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap();
assert_eq!(stale_quote.state, MeltQuoteState::Paid);
assert_eq!(
stale_quote.payment_proof.as_deref(),
Some("paid_event_preimage")
);
assert_saga_not_exists(&mint, &operation_id).await;
let persisted_quote = mint
.localstore
.get_melt_quote(quote_id)
.await
.unwrap()
.expect("quote should still exist");
assert_eq!(persisted_quote.state, MeltQuoteState::Paid);
}
#[tokio::test]
async fn test_failed_outcome_for_internal_settlement_finalizes_not_rolls_back() {
use std::str::FromStr;
use cdk_common::nuts::MintQuoteState;
use cdk_common::{MintQuoteBolt11Request, MintQuoteBolt11Response, QuoteId};
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let mint_quote_response: MintQuoteBolt11Response<_> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(4_000),
unit: CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.into();
let mint_quote_id = QuoteId::from_str(&mint_quote_response.quote).unwrap();
let mint_quote = mint
.localstore
.get_mint_quote(&mint_quote_id)
.await
.unwrap()
.expect("mint quote should exist");
let melt_quote_request =
cdk_common::melt::MeltQuoteRequest::Bolt11(MeltQuoteBolt11Request {
request: mint_quote.request.to_string().parse().unwrap(),
unit: CurrencyUnit::Sat,
options: None,
});
let melt_quote_response = mint.get_melt_quote(melt_quote_request).await.unwrap();
let melt_quote = mint
.localstore
.get_melt_quote(melt_quote_response.quote().expect("single-quote method"))
.await
.unwrap()
.expect("melt quote should exist");
let melt_request = create_test_melt_request(&proofs, &melt_quote);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = assert_single_melt_saga_operation_id(&mint).await;
let (payment_saga, _decision) = setup_saga
.attempt_internal_settlement(&melt_request)
.await
.unwrap();
drop(payment_saga);
let mint_quote_before = mint
.localstore
.get_mint_quote(&mint_quote_id)
.await
.unwrap()
.expect("mint quote should exist");
assert_eq!(mint_quote_before.state(), MintQuoteState::Paid);
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
let mut quote = mint
.localstore
.get_melt_quote(&melt_quote.id)
.await
.unwrap()
.unwrap();
let saga = assert_saga_exists(&mint, &operation_id).await;
let payment_response = MakePaymentResponse {
payment_lookup_id: PaymentIdentifier::CustomId(
"internal_settlement_failed_lookup".to_string(),
),
payment_proof: None,
status: MeltQuoteState::Failed,
total_spent: quote.amount(),
};
process_melt_saga_outcome(
&saga,
&mut quote,
&payment_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap();
let mint_quote_after = mint
.localstore
.get_mint_quote(&mint_quote_id)
.await
.unwrap()
.expect("mint quote should exist");
assert_eq!(mint_quote_after.state(), MintQuoteState::Paid);
assert_proofs_state(&mint, &input_ys, Some(State::Spent)).await;
assert_eq!(quote.state, MeltQuoteState::Paid);
assert_saga_not_exists(&mint, &operation_id).await;
}
#[tokio::test]
async fn test_failed_outcome_does_not_rollback_finalizing_saga() {
let fake_description = FakeInvoiceDescription {
pay_invoice_state: MeltQuoteState::Failed,
check_payment_state: MeltQuoteState::Failed,
pay_err: false,
check_err: false,
};
let amount_msats: u64 = Amount::from(9_000).into();
let invoice = create_fake_invoice(
amount_msats,
serde_json::to_string(&fake_description).unwrap(),
);
let payment_states = std::collections::HashMap::from([(
invoice.payment_hash().to_string(),
(
MeltQuoteState::Failed,
Amount::from(9_000).with_unit(CurrencyUnit::Sat),
),
)]);
let mint = create_test_mint_with_payment_states(payment_states)
.await
.unwrap();
let request = cdk_common::melt::MeltQuoteRequest::Bolt11(MeltQuoteBolt11Request {
request: invoice,
unit: CurrencyUnit::Sat,
options: None,
});
let quote_response = mint.get_melt_quote(request).await.unwrap();
let quote_id = quote_response.quote().unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = mint
.localstore
.get_melt_quote(quote_id)
.await
.unwrap()
.unwrap();
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = assert_single_melt_saga_operation_id(&mint).await;
drop(setup_saga);
let mut tx = mint.localstore.begin_transaction().await.unwrap();
let mut saga = tx
.get_saga_for_update(&operation_id)
.await
.unwrap()
.expect("saga should exist");
tx.update_acquired_saga(
&mut saga,
SagaStateEnum::Melt(MeltSagaState::PaymentAttempted),
)
.await
.unwrap();
tx.commit().await.unwrap();
let stale_saga = assert_saga_exists(&mint, &operation_id).await;
let finalization_data = MeltFinalizationData {
total_spent: Amount::from(9_250).with_unit(CurrencyUnit::Sat),
payment_lookup_id: PaymentIdentifier::CustomId("paid_lookup".to_string()),
payment_proof: Some("paid_preimage".to_string()),
};
let mut tx = mint.localstore.begin_transaction().await.unwrap();
let mut saga = tx
.get_saga_for_update(&operation_id)
.await
.unwrap()
.expect("saga should exist");
tx.update_acquired_saga_with_finalization_data(
&mut saga,
SagaStateEnum::Melt(MeltSagaState::Finalizing),
Some(&finalization_data),
)
.await
.unwrap();
tx.commit().await.unwrap();
let mut quote = mint
.localstore
.get_melt_quote(quote_id)
.await
.unwrap()
.unwrap();
let failed_response = MakePaymentResponse {
payment_lookup_id: PaymentIdentifier::CustomId("failed_lookup".to_string()),
payment_proof: None,
status: MeltQuoteState::Failed,
total_spent: quote.amount(),
};
process_melt_saga_outcome(
&stale_saga,
&mut quote,
&failed_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap();
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
let persisted_quote = mint
.localstore
.get_melt_quote(quote_id)
.await
.unwrap()
.unwrap();
assert_eq!(persisted_quote.state, MeltQuoteState::Pending);
let saga_after = assert_saga_exists(&mint, &operation_id).await;
assert_eq!(
saga_after.state,
SagaStateEnum::Melt(MeltSagaState::Finalizing)
);
}
#[tokio::test]
async fn test_stale_pending_outcome_does_not_regress_finalizing_saga() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let _setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = assert_single_melt_saga_operation_id(&mint).await;
let mut quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
let stale_saga = assert_saga_exists(&mint, &operation_id).await;
let finalization_data = MeltFinalizationData {
total_spent: Amount::from(9_250).with_unit(CurrencyUnit::Sat),
payment_lookup_id: PaymentIdentifier::CustomId("paid_lookup".to_string()),
payment_proof: Some("paid_preimage".to_string()),
};
let mut tx = mint.localstore.begin_transaction().await.unwrap();
let mut saga = tx
.get_saga_for_update(&operation_id)
.await
.unwrap()
.expect("saga should exist");
tx.update_acquired_saga_with_finalization_data(
&mut saga,
SagaStateEnum::Melt(MeltSagaState::Finalizing),
Some(&finalization_data),
)
.await
.unwrap();
tx.commit().await.unwrap();
let payment_response = MakePaymentResponse {
payment_lookup_id: PaymentIdentifier::CustomId("pending_outcome_lookup".to_string()),
payment_proof: None,
status: MeltQuoteState::Pending,
total_spent: quote.amount(),
};
process_melt_saga_outcome(
&stale_saga,
&mut quote,
&payment_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap();
let persisted_saga = assert_saga_exists(&mint, &operation_id).await;
assert_eq!(
persisted_saga.state,
SagaStateEnum::Melt(MeltSagaState::Finalizing)
);
assert_eq!(persisted_saga.finalization_data, Some(finalization_data));
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
let pending_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("quote should still exist");
assert_eq!(pending_quote.state, MeltQuoteState::Pending);
}
#[tokio::test]
async fn test_unknown_outcome_leaves_state_unchanged() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let _setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = assert_single_melt_saga_operation_id(&mint).await;
let mut quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
let saga = assert_saga_exists(&mint, &operation_id).await;
let payment_response = MakePaymentResponse {
payment_lookup_id: PaymentIdentifier::CustomId("unknown_outcome_lookup".to_string()),
payment_proof: None,
status: MeltQuoteState::Unknown,
total_spent: quote.amount(),
};
process_melt_saga_outcome(
&saga,
&mut quote,
&payment_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap();
assert_saga_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
let pending_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("quote should still exist");
assert_eq!(pending_quote.state, MeltQuoteState::Pending);
}
#[tokio::test]
async fn test_paid_outcome_with_unit_mismatch_returns_error_without_mutation() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let _setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = assert_single_melt_saga_operation_id(&mint).await;
let mut quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
let saga = assert_saga_exists(&mint, &operation_id).await;
let payment_response = MakePaymentResponse {
payment_lookup_id: PaymentIdentifier::CustomId("unit_mismatch_lookup".to_string()),
payment_proof: Some("unit_mismatch_preimage".to_string()),
status: MeltQuoteState::Paid,
total_spent: Amount::from(9_250).with_unit(CurrencyUnit::Usd),
};
let err = process_melt_saga_outcome(
&saga,
&mut quote,
&payment_response,
&mint.localstore,
&mint.pubsub_manager,
&mint,
)
.await
.unwrap_err();
assert!(matches!(err, Error::UnitMismatch));
assert_saga_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
let still_pending_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("quote should still exist");
assert_eq!(still_pending_quote.state, MeltQuoteState::Pending);
let completed_operation = mint
.localstore
.get_completed_operation(&operation_id)
.await
.unwrap();
assert!(completed_operation.is_none());
}
async fn create_test_mint_with_payment_states(
payment_states: std::collections::HashMap<String, (MeltQuoteState, Amount<CurrencyUnit>)>,
) -> Result<crate::mint::Mint, Error> {
use crate::mint::{MintBuilder, MintMeltLimits};
use crate::types::{FeeReserve, QuoteTTL};
let db = std::sync::Arc::new(cdk_sqlite::mint::memory::empty().await?);
let mut mint_builder = MintBuilder::new(db.clone());
let fee_reserve = FeeReserve {
min_fee_reserve: 1.into(),
percent_fee_reserve: 1.0,
};
let backend = cdk_fake_wallet::FakeWallet::new(
fee_reserve,
payment_states,
std::collections::HashSet::default(),
2,
CurrencyUnit::Sat,
);
mint_builder
.add_payment_processor(
CurrencyUnit::Sat,
PaymentMethod::Known(KnownMethod::Bolt11),
MintMeltLimits::new(1, 10_000),
std::sync::Arc::new(backend),
)
.await?;
let mnemonic = bip39::Mnemonic::generate(12).map_err(|e| Error::Custom(e.to_string()))?;
let mint = mint_builder
.with_name("test mint".to_string())
.with_description("test mint for saga recovery tests".to_string())
.with_urls(vec!["https://test-mint".to_string()])
.build_with_seed(db.clone(), &mnemonic.to_seed_normalized(""))
.await?;
mint.set_quote_ttl(QuoteTTL::new(10000, 10000)).await?;
mint.start().await?;
Ok(mint)
}
async fn create_test_melt_quote(mint: &crate::mint::Mint, amount: Amount) -> MeltQuote {
use cdk_common::melt::MeltQuoteRequest;
let fake_description = FakeInvoiceDescription {
pay_invoice_state: MeltQuoteState::Paid,
check_payment_state: MeltQuoteState::Paid,
pay_err: false,
check_err: false,
};
let amount_msats: u64 = amount.into();
let invoice = create_fake_invoice(
amount_msats,
serde_json::to_string(&fake_description).unwrap(),
);
let request = MeltQuoteRequest::Bolt11(MeltQuoteBolt11Request {
request: invoice,
unit: CurrencyUnit::Sat,
options: None,
});
let quote_response = mint.get_melt_quote(request).await.unwrap();
mint.localstore
.get_melt_quote(quote_response.quote().unwrap())
.await
.unwrap()
.expect("quote should exist in database")
}
fn create_test_melt_request(
proofs: &cdk_common::nuts::Proofs,
quote: &MeltQuote,
) -> cdk_common::nuts::MeltRequest<cdk_common::QuoteId> {
cdk_common::nuts::MeltRequest::new(quote.id.clone(), proofs.clone(), None)
}
async fn assert_saga_exists(mint: &crate::mint::Mint, operation_id: &uuid::Uuid) -> Saga {
mint.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap()
.into_iter()
.find(|s| s.operation_id == *operation_id)
.expect("saga should exist in database")
}
async fn assert_single_melt_saga_operation_id(mint: &crate::mint::Mint) -> uuid::Uuid {
let sagas = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
assert_eq!(sagas.len(), 1, "expected exactly one melt saga");
sagas[0].operation_id
}
async fn assert_saga_not_exists(mint: &crate::mint::Mint, operation_id: &uuid::Uuid) {
let sagas = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
assert!(
!sagas.iter().any(|s| s.operation_id == *operation_id),
"saga should not exist in database"
);
}
async fn assert_proofs_state(
mint: &crate::mint::Mint,
ys: &[cdk_common::PublicKey],
expected_state: Option<State>,
) {
let states = mint.localstore.get_proofs_states(ys).await.unwrap();
for state in states {
assert_eq!(state, expected_state, "proof state mismatch");
}
}
}