//! Generic handlers for custom payment methods //! //! These handlers work for ANY custom payment method without requiring //! method-specific validation or request parsing. //! //! Special handling for bolt11 and bolt12: //! When the method parameter is "bolt11" or "bolt12", these handlers use the //! specific Bolt11/Bolt12 request/response types instead of the generic custom types. use axum::extract::{FromRequestParts, Json, Path, State}; use axum::http::request::Parts; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use cdk::mint::QuoteId; #[cfg(feature = "auth")] use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath}; use cdk::nuts::{ MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltQuoteBolt12Request, MeltQuoteCustomRequest, MintQuoteBolt11Request, MintQuoteBolt11Response, MintQuoteBolt12Request, MintQuoteBolt12Response, MintQuoteCustomRequest, MintRequest, MintResponse, }; use serde_json::Value; use tracing::instrument; #[cfg(feature = "auth")] use crate::auth::AuthHeader; use crate::router_handlers::into_response; use crate::MintState; const PREFER_HEADER_KEY: &str = "Prefer"; /// Header extractor for the Prefer header /// /// This extractor checks for the `Prefer: respond-async` header /// to determine if the client wants asynchronous processing #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PreferHeader { pub respond_async: bool, } impl FromRequestParts for PreferHeader where S: Send + Sync, { type Rejection = (StatusCode, String); async fn from_request_parts( parts: &mut Parts, _state: &S, ) -> anyhow::Result { // Check for Prefer header if let Some(prefer_value) = parts.headers.get(PREFER_HEADER_KEY) { let value = prefer_value.to_str().map_err(|_| { ( StatusCode::BAD_REQUEST, "Invalid Prefer header value".to_string(), ) })?; // Check if it contains "respond-async" let respond_async = value.to_lowercase().contains("respond-async"); return Ok(PreferHeader { respond_async }); } // No Prefer header found - default to synchronous processing Ok(PreferHeader { respond_async: false, }) } } /// Generic handler for custom payment method mint quotes /// /// This handler works for ANY custom payment method (e.g., paypal, venmo, cashapp, bolt11, bolt12). /// For bolt11/bolt12, it handles the specific request/response types. /// For other methods, it passes the request data directly to the payment processor. #[instrument(skip_all, fields(method = ?method))] pub async fn post_mint_custom_quote( #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Path(method): Path, Json(payload): Json, ) -> Result { #[cfg(feature = "auth")] { state .mint .verify_auth( auth.into(), &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuote(method.clone())), ) .await .map_err(into_response)?; } match method.as_str() { "bolt11" => { let bolt11_request: MintQuoteBolt11Request = serde_json::from_value(payload).map_err(|e| { tracing::error!("Failed to parse bolt11 request: {}", e); into_response(cdk::Error::InvalidPaymentMethod) })?; let quote = state .mint .get_mint_quote(bolt11_request.into()) .await .map_err(into_response)?; let response: MintQuoteBolt11Response = quote.try_into().map_err(into_response)?; Ok(Json(response).into_response()) } "bolt12" => { let bolt12_request: MintQuoteBolt12Request = serde_json::from_value(payload).map_err(|e| { tracing::error!("Failed to parse bolt12 request: {}", e); into_response(cdk::Error::InvalidPaymentMethod) })?; let quote = state .mint .get_mint_quote(bolt12_request.into()) .await .map_err(into_response)?; let response: MintQuoteBolt12Response = quote.try_into().map_err(into_response)?; Ok(Json(response).into_response()) } _ => { let custom_request: MintQuoteCustomRequest = serde_json::from_value(payload).map_err(|e| { tracing::error!("Failed to parse custom request: {}", e); into_response(cdk::Error::InvalidPaymentMethod) })?; let quote_request = cdk::mint::MintQuoteRequest::Custom { method, request: custom_request, }; let response = state .mint .get_mint_quote(quote_request) .await .map_err(into_response)?; match response { cdk::mint::MintQuoteResponse::Custom { response, .. } => { Ok(Json(response).into_response()) } _ => Err(into_response(cdk::Error::InvalidPaymentMethod)), } } } } /// Get custom payment method mint quote status #[instrument(skip_all, fields(method = ?method, quote_id = ?quote_id))] pub async fn get_check_mint_custom_quote( #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Path((method, quote_id)): Path<(String, QuoteId)>, ) -> Result { #[cfg(feature = "auth")] { state .mint .verify_auth( auth.into(), &ProtectedEndpoint::new(Method::Get, RoutePath::MintQuote(method.clone())), ) .await .map_err(into_response)?; } let quote_response = state .mint .check_mint_quote("e_id) .await .map_err(into_response)?; match method.as_str() { "bolt11" => { let response: MintQuoteBolt11Response = quote_response.try_into().map_err(into_response)?; Ok(Json(response).into_response()) } "bolt12" => { let response: MintQuoteBolt12Response = quote_response.try_into().map_err(into_response)?; Ok(Json(response).into_response()) } _ => { // Extract and verify it's a Custom payment method match quote_response { cdk::mint::MintQuoteResponse::Custom { method: quote_method, response, } => { if quote_method != method { return Err(into_response(cdk::Error::InvalidPaymentMethod)); } Ok(Json(response).into_response()) } _ => Err(into_response(cdk::Error::InvalidPaymentMethod)), } } } } /// Mint tokens with custom payment method #[instrument(skip_all, fields(method = ?method, quote_id = ?payload.quote))] pub async fn post_mint_custom( #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Path(method): Path, Json(payload): Json>, ) -> Result, Response> { #[cfg(feature = "auth")] { state .mint .verify_auth( auth.into(), &ProtectedEndpoint::new(Method::Post, RoutePath::Mint(method.clone())), ) .await .map_err(into_response)?; } // Note: process_mint_request will validate the quote internally // including checking if it's paid and matches the expected payment method let res = state .mint .process_mint_request(payload) .await .map_err(into_response)?; Ok(Json(res)) } /// Request a melt quote for custom payment method #[instrument(skip_all, fields(method = ?method))] pub async fn post_melt_custom_quote( #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Path(method): Path, Json(payload): Json, ) -> Result>, Response> { #[cfg(feature = "auth")] { state .mint .verify_auth( auth.into(), &ProtectedEndpoint::new(Method::Post, RoutePath::MeltQuote(method.clone())), ) .await .map_err(into_response)?; } let response = match method.as_str() { "bolt11" => { let bolt11_request: MeltQuoteBolt11Request = serde_json::from_value(payload).map_err(|e| { tracing::error!("Failed to parse bolt11 melt request: {}", e); into_response(cdk::Error::InvalidPaymentMethod) })?; state .mint .get_melt_quote(bolt11_request.into()) .await .map_err(into_response)? } "bolt12" => { let bolt12_request: MeltQuoteBolt12Request = serde_json::from_value(payload).map_err(|e| { tracing::error!("Failed to parse bolt12 melt request: {}", e); into_response(cdk::Error::InvalidPaymentMethod) })?; state .mint .get_melt_quote(bolt12_request.into()) .await .map_err(into_response)? } _ => { let custom_request: MeltQuoteCustomRequest = serde_json::from_value(payload).map_err(|e| { tracing::error!("Failed to parse custom melt request: {}", e); into_response(cdk::Error::InvalidPaymentMethod) })?; state .mint .get_melt_quote(custom_request.into()) .await .map_err(into_response)? } }; Ok(Json(response)) } /// Get custom payment method melt quote status #[instrument(skip_all, fields(method = ?method, quote_id = ?quote_id))] pub async fn get_check_melt_custom_quote( #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Path((method, quote_id)): Path<(String, QuoteId)>, ) -> Result>, Response> { #[cfg(feature = "auth")] { state .mint .verify_auth( auth.into(), &ProtectedEndpoint::new(Method::Get, RoutePath::MeltQuote(method.clone())), ) .await .map_err(into_response)?; } // Note: check_melt_quote returns the response directly // The payment method validation is done when the quote was created let quote = state .mint .check_melt_quote("e_id) .await .map_err(into_response)?; Ok(Json(quote)) } /// Melt tokens with custom payment method #[instrument(skip_all, fields(method = ?method))] pub async fn post_melt_custom( #[cfg(feature = "auth")] auth: AuthHeader, prefer: PreferHeader, State(state): State, Path(method): Path, Json(payload): Json>, ) -> Result>, Response> { #[cfg(feature = "auth")] { state .mint .verify_auth( auth.into(), &ProtectedEndpoint::new(Method::Post, RoutePath::Melt(method.clone())), ) .await .map_err(into_response)?; } let res = if prefer.respond_async { // Asynchronous processing - return immediately after setup state .mint .melt_async(&payload) .await .map_err(into_response)? } else { // Synchronous processing - wait for completion state.mint.melt(&payload).await.map_err(into_response)? }; Ok(Json(res)) } // ============================================================================ // CACHED HANDLERS FOR NUT-19 SUPPORT // ============================================================================ /// Cached version of post_mint_custom for NUT-19 caching support #[instrument(skip_all, fields(method = ?method, quote_id = ?payload.quote))] pub async fn cache_post_mint_custom( #[cfg(feature = "auth")] auth: AuthHeader, state: State, method: Path, payload: Json>, ) -> Result, Response> { use std::ops::Deref; let State(mint_state) = state.clone(); let json_extracted_payload = payload.deref(); let cache_key = match mint_state.cache.calculate_key(json_extracted_payload) { Some(key) => key, None => { // Could not calculate key, just return the handler result #[cfg(feature = "auth")] return post_mint_custom(auth, state, method, payload).await; #[cfg(not(feature = "auth"))] return post_mint_custom(state, method, payload).await; } }; if let Some(cached_response) = mint_state.cache.get::(&cache_key).await { return Ok(Json(cached_response)); } #[cfg(feature = "auth")] let result = post_mint_custom(auth, state, method, payload).await?; #[cfg(not(feature = "auth"))] let result = post_mint_custom(state, method, payload).await?; // Cache the response mint_state.cache.set(cache_key, result.deref()).await; Ok(result) } #[cfg(test)] mod tests { use axum::http::{HeaderValue, Request, StatusCode}; use super::*; fn create_test_request(prefer_header: Option<&str>) -> Request<()> { let mut req = Request::builder() .method("POST") .uri("/test") .body(()) .unwrap(); if let Some(header_value) = prefer_header { req.headers_mut().insert( PREFER_HEADER_KEY, HeaderValue::from_str(header_value).unwrap(), ); } req } fn create_test_request_with_bytes(bytes: &[u8]) -> Request<()> { let mut req = Request::builder() .method("POST") .uri("/test") .body(()) .unwrap(); req.headers_mut() .insert(PREFER_HEADER_KEY, HeaderValue::from_bytes(bytes).unwrap()); req } #[tokio::test] async fn test_prefer_header_respond_async() { let req = create_test_request(Some("respond-async")); let (mut parts, _) = req.into_parts(); let result = PreferHeader::from_request_parts(&mut parts, &()).await; assert!(result.is_ok()); assert!(result.unwrap().respond_async); } #[tokio::test] async fn test_prefer_header_respond_async_with_other_values() { let req = create_test_request(Some("respond-async; wait=10")); let (mut parts, _) = req.into_parts(); let result = PreferHeader::from_request_parts(&mut parts, &()).await; assert!(result.is_ok()); assert!(result.unwrap().respond_async); } #[tokio::test] async fn test_prefer_header_case_insensitive() { let req = create_test_request(Some("RESPOND-ASYNC")); let (mut parts, _) = req.into_parts(); let result = PreferHeader::from_request_parts(&mut parts, &()).await; assert!(result.is_ok()); assert!(result.unwrap().respond_async); } #[tokio::test] async fn test_prefer_header_no_respond_async() { let req = create_test_request(Some("wait=10")); let (mut parts, _) = req.into_parts(); let result = PreferHeader::from_request_parts(&mut parts, &()).await; assert!(result.is_ok()); assert!(!result.unwrap().respond_async); } #[tokio::test] async fn test_prefer_header_missing() { let req = create_test_request(None); let (mut parts, _) = req.into_parts(); let result = PreferHeader::from_request_parts(&mut parts, &()).await; assert!(result.is_ok()); assert!(!result.unwrap().respond_async); } #[tokio::test] async fn test_prefer_header_invalid_value() { let req = create_test_request_with_bytes(&[0xFF, 0xFE]); let (mut parts, _) = req.into_parts(); let result = PreferHeader::from_request_parts(&mut parts, &()).await; assert!(result.is_err()); let (status, message) = result.unwrap_err(); assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(message, "Invalid Prefer header value"); } #[tokio::test] async fn test_prefer_header_empty_value() { let req = create_test_request(Some("")); let (mut parts, _) = req.into_parts(); let result = PreferHeader::from_request_parts(&mut parts, &()).await; assert!(result.is_ok()); assert!(!result.unwrap().respond_async); } } /// Cached version of post_melt_custom for NUT-19 caching support #[instrument(skip_all, fields(method = ?method))] pub async fn cache_post_melt_custom( #[cfg(feature = "auth")] auth: AuthHeader, prefer: PreferHeader, state: State, method: Path, payload: Json>, ) -> Result>, Response> { use std::ops::Deref; let State(mint_state) = state.clone(); let json_extracted_payload = payload.deref(); let cache_key = match mint_state.cache.calculate_key(json_extracted_payload) { Some(key) => key, None => { // Could not calculate key, just return the handler result #[cfg(feature = "auth")] return post_melt_custom(auth, prefer, state, method, payload).await; #[cfg(not(feature = "auth"))] return post_melt_custom(prefer, state, method, payload).await; } }; if let Some(cached_response) = mint_state .cache .get::>(&cache_key) .await { return Ok(Json(cached_response)); } #[cfg(feature = "auth")] let result = post_melt_custom(auth, prefer, state, method, payload).await?; #[cfg(not(feature = "auth"))] let result = post_melt_custom(prefer, state, method, payload).await?; // Cache the response mint_state.cache.set(cache_key, result.deref()).await; Ok(result) }