diff --git a/crates/cdk-common/src/error.rs b/crates/cdk-common/src/error.rs index bdcbfec6e..6ea261bda 100644 --- a/crates/cdk-common/src/error.rs +++ b/crates/cdk-common/src/error.rs @@ -585,6 +585,8 @@ mod tests { assert!(Error::AmountOverflow.is_definitive_failure()); assert!(Error::TokenAlreadySpent.is_definitive_failure()); assert!(Error::MintingDisabled.is_definitive_failure()); + assert!(Error::MaxInputsExceeded { actual: 2, max: 1 }.is_definitive_failure()); + assert!(Error::MaxOutputsExceeded { actual: 2, max: 1 }.is_definitive_failure()); // Test HTTP client errors (4xx) - simulated assert!(Error::HttpError(Some(400), "Bad Request".to_string()).is_definitive_failure()); @@ -620,6 +622,29 @@ mod tests { assert!(!Error::TokenPending.is_definitive_failure()); assert!(!Error::PendingQuote.is_definitive_failure()); } + + #[test] + fn test_max_outputs_and_inputs_error_responses_decode() { + let max_inputs = Error::from(ErrorResponse { + code: ErrorCode::MaxInputsExceeded, + detail: "Maximum inputs exceeded: 2 provided, max 1".to_string(), + }); + assert!(matches!( + max_inputs, + Error::MaxInputsExceeded { actual: 2, max: 1 } + )); + assert!(max_inputs.is_definitive_failure()); + + let max_outputs = Error::from(ErrorResponse { + code: ErrorCode::MaxOutputsExceeded, + detail: "Maximum outputs exceeded: 2 provided, max 1".to_string(), + }); + assert!(matches!( + max_outputs, + Error::MaxOutputsExceeded { actual: 2, max: 1 } + )); + assert!(max_outputs.is_definitive_failure()); + } } impl Error { @@ -666,6 +691,8 @@ impl Error { | Self::TransactionUnbalanced(_, _, _) | Self::DuplicateInputs | Self::DuplicateOutputs + | Self::MaxInputsExceeded { .. } + | Self::MaxOutputsExceeded { .. } | Self::DuplicateQuoteIds | Self::BatchSizeExceeded { .. } | Self::MultipleUnits @@ -1151,6 +1178,13 @@ impl From for Error { } } +fn parse_limit_counts(detail: &str) -> Option<(usize, usize)> { + let (_, counts) = detail.rsplit_once(": ")?; + let (actual, max) = counts.split_once(" provided, max ")?; + + Some((actual.trim().parse().ok()?, max.trim().parse().ok()?)) +} + impl From for Error { fn from(err: ErrorResponse) -> Error { match err.code { @@ -1167,6 +1201,14 @@ impl From for Error { } ErrorCode::DuplicateInputs => Self::DuplicateInputs, ErrorCode::DuplicateOutputs => Self::DuplicateOutputs, + ErrorCode::MaxInputsExceeded => { + let (actual, max) = parse_limit_counts(&err.detail).unwrap_or((0, 0)); + Self::MaxInputsExceeded { actual, max } + } + ErrorCode::MaxOutputsExceeded => { + let (actual, max) = parse_limit_counts(&err.detail).unwrap_or((0, 0)); + Self::MaxOutputsExceeded { actual, max } + } ErrorCode::DuplicateQuoteIds => Self::DuplicateQuoteIds, ErrorCode::BatchSizeExceeded => Self::BatchSizeExceeded { actual: 0, max: 0 }, ErrorCode::MultipleUnits => Self::MultipleUnits, diff --git a/crates/cdk/src/wallet/issue/saga/resume.rs b/crates/cdk/src/wallet/issue/saga/resume.rs index 4238158e3..69deafdbd 100644 --- a/crates/cdk/src/wallet/issue/saga/resume.rs +++ b/crates/cdk/src/wallet/issue/saga/resume.rs @@ -30,6 +30,13 @@ use crate::wallet::recovery::{RecoveryAction, RecoveryHelpers}; use crate::wallet::saga::CompensatingAction; use crate::{Error, Wallet}; +fn is_mint_limit_error(error: &Error) -> bool { + matches!( + error, + Error::MaxInputsExceeded { .. } | Error::MaxOutputsExceeded { .. } + ) +} + impl Wallet { /// Resume an incomplete issue saga after crash recovery. /// @@ -94,7 +101,14 @@ impl Wallet { let quote_ids = data.quote_ids(); // Try replay first - if let Some(proofs) = self.try_replay_mint(saga_id, data).await? { + let replay_result = self.try_replay_mint(saga_id, data).await; + if let Err(e) = &replay_result { + if is_mint_limit_error(e) { + self.compensate_issue(saga_id).await?; + } + } + + if let Some(proofs) = replay_result? { // Replay succeeded - save proofs and clean up self.localstore .update_proofs(proofs.clone(), vec![]) @@ -325,6 +339,15 @@ impl Wallet { { Ok(response) => response, Err(e) => { + if is_mint_limit_error(&e) { + tracing::warn!( + "Issue saga {} - batch replay failed with mint limit: {}", + saga_id, + e + ); + return Err(e); + } + tracing::info!( "Issue saga {} - batch replay failed ({}), falling back to restore", saga_id, @@ -431,6 +454,15 @@ impl Wallet { { Ok(response) => response, Err(e) => { + if is_mint_limit_error(&e) { + tracing::warn!( + "Issue saga {} - replay failed with mint limit: {}", + saga_id, + e + ); + return Err(e); + } + tracing::info!( "Issue saga {} - replay failed ({}), falling back to restore", saga_id, @@ -519,18 +551,35 @@ impl Wallet { mod tests { use std::sync::Arc; + use cdk_common::amount::{FeeAndAmounts, SplitTarget}; use cdk_common::nuts::{CurrencyUnit, RestoreResponse}; use cdk_common::wallet::{ IssueSagaState, MintOperationData, OperationData, WalletSaga, WalletSagaState, }; use cdk_common::Amount; + use crate::nuts::PreMintSecrets; use crate::wallet::recovery::RecoveryAction; use crate::wallet::saga::test_utils::{create_test_db, test_mint_url}; use crate::wallet::test_utils::{ - create_test_wallet_with_mock, test_mint_quote, MockMintConnector, + create_test_wallet_with_mock, test_keyset_id, test_mint_quote, MockMintConnector, }; + #[test] + fn test_only_mint_limit_errors_abort_replay_recovery() { + assert!(super::is_mint_limit_error( + &crate::Error::MaxOutputsExceeded { actual: 2, max: 1 } + )); + assert!(super::is_mint_limit_error( + &crate::Error::MaxInputsExceeded { actual: 2, max: 1 } + )); + assert!(!super::is_mint_limit_error(&crate::Error::IssuedQuote)); + assert!(!super::is_mint_limit_error(&crate::Error::HttpError( + Some(429), + "Too Many Requests".to_string() + ))); + } + #[tokio::test] async fn test_recover_issue_secrets_prepared() { // Compensate: quote released @@ -694,4 +743,62 @@ mod tests { let transactions = db.list_transactions(None, None, None).await.unwrap(); assert!(transactions.is_empty()); } + + #[tokio::test] + async fn test_recover_issue_mint_requested_max_outputs_does_not_restore() { + let db = create_test_db().await; + let mint_url = test_mint_url(); + let saga_id = uuid::Uuid::new_v4(); + let quote_id = format!("test_mint_quote_{}", uuid::Uuid::new_v4()); + + let mock_client = Arc::new(MockMintConnector::new()); + mock_client + .set_post_mint_response(Err(crate::Error::MaxOutputsExceeded { actual: 2, max: 1 })); + + let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await; + let fee_and_amounts = FeeAndAmounts::from((0, vec![1])); + let premint_secrets = PreMintSecrets::from_seed( + test_keyset_id(), + 0, + &wallet.seed, + Amount::from(1), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); + + let saga = WalletSaga::new( + saga_id, + WalletSagaState::Issue(IssueSagaState::MintRequested), + Amount::from(1), + mint_url.clone(), + CurrencyUnit::Sat, + OperationData::Mint(MintOperationData::new_single( + quote_id.clone(), + Amount::from(1), + Some(0), + Some(1), + Some(premint_secrets.blinded_messages()), + )), + ); + db.add_saga(saga).await.unwrap(); + + let mut mint_quote = test_mint_quote(mint_url); + mint_quote.id = quote_id.clone(); + mint_quote.used_by_operation = Some(saga_id.to_string()); + db.add_mint_quote(mint_quote).await.unwrap(); + + let result = wallet + .resume_issue_saga(&db.get_saga(&saga_id).await.unwrap().unwrap()) + .await; + + assert!(matches!( + result, + Err(crate::Error::MaxOutputsExceeded { actual: 2, max: 1 }) + )); + assert!(db.get_saga(&saga_id).await.unwrap().is_none()); + + let mint_quote = db.get_mint_quote("e_id).await.unwrap().unwrap(); + assert!(mint_quote.used_by_operation.is_none()); + } }