cdk_axum/
router_handlers.rs

1use anyhow::Result;
2use axum::extract::ws::WebSocketUpgrade;
3use axum::extract::{FromRequestParts, Json, Path, State};
4use axum::http::request::Parts;
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use cdk::error::{ErrorCode, ErrorResponse};
8use cdk::mint::QuoteId;
9#[cfg(feature = "auth")]
10use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath};
11use cdk::nuts::{
12    CheckStateRequest, CheckStateResponse, Id, KeysResponse, KeysetResponse,
13    MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest, MintInfo, MintQuoteBolt11Request,
14    MintQuoteBolt11Response, MintRequest, MintResponse, RestoreRequest, RestoreResponse,
15    SwapRequest, SwapResponse,
16};
17use cdk::util::unix_time;
18use paste::paste;
19use tracing::instrument;
20
21#[cfg(feature = "auth")]
22use crate::auth::AuthHeader;
23use crate::ws::main_websocket;
24use crate::MintState;
25
26const PREFER_HEADER_KEY: &str = "Prefer";
27
28/// Header extractor for the Prefer header
29///
30/// This extractor checks for the `Prefer: respond-async` header
31/// to determine if the client wants asynchronous processing
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct PreferHeader {
34    pub respond_async: bool,
35}
36
37impl<S> FromRequestParts<S> for PreferHeader
38where
39    S: Send + Sync,
40{
41    type Rejection = (StatusCode, String);
42
43    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
44        // Check for Prefer header
45        if let Some(prefer_value) = parts.headers.get(PREFER_HEADER_KEY) {
46            let value = prefer_value.to_str().map_err(|_| {
47                (
48                    StatusCode::BAD_REQUEST,
49                    "Invalid Prefer header value".to_string(),
50                )
51            })?;
52
53            // Check if it contains "respond-async"
54            let respond_async = value.to_lowercase().contains("respond-async");
55
56            return Ok(PreferHeader { respond_async });
57        }
58
59        // No Prefer header found - default to synchronous processing
60        Ok(PreferHeader {
61            respond_async: false,
62        })
63    }
64}
65
66/// Macro to add cache to endpoint
67#[macro_export]
68macro_rules! post_cache_wrapper {
69    ($handler:ident, $request_type:ty, $response_type:ty) => {
70        paste! {
71            /// Cache wrapper function for $handler:
72            /// Wrap $handler into a function that caches responses using the request as key
73            pub async fn [<cache_ $handler>](
74                #[cfg(feature = "auth")] auth: AuthHeader,
75                state: State<MintState>,
76                payload: Json<$request_type>
77            ) -> Result<Json<$response_type>, Response> {
78                use std::ops::Deref;
79                let json_extracted_payload = payload.deref();
80                let State(mint_state) = state.clone();
81                let cache_key = match mint_state.cache.calculate_key(&json_extracted_payload) {
82                    Some(key) => key,
83                    None => {
84                        // Could not calculate key, just return the handler result
85                        #[cfg(feature = "auth")]
86                        return $handler(auth, state, payload).await;
87                        #[cfg(not(feature = "auth"))]
88                        return $handler( state, payload).await;
89                    }
90                };
91                if let Some(cached_response) = mint_state.cache.get::<$response_type>(&cache_key).await {
92                    return Ok(Json(cached_response));
93                }
94                #[cfg(feature = "auth")]
95                let response = $handler(auth, state, payload).await?;
96                #[cfg(not(feature = "auth"))]
97                let response = $handler(state, payload).await?;
98                mint_state.cache.set(cache_key, &response.deref()).await;
99                Ok(response)
100            }
101        }
102    };
103}
104
105/// Macro to add cache to endpoint with prefer header support (for async operations)
106#[macro_export]
107macro_rules! post_cache_wrapper_with_prefer {
108    ($handler:ident, $request_type:ty, $response_type:ty) => {
109        paste! {
110            /// Cache wrapper function for $handler with PreferHeader support:
111            /// Wrap $handler into a function that caches responses using the request as key
112            pub async fn [<cache_ $handler>](
113                #[cfg(feature = "auth")] auth: AuthHeader,
114                prefer: PreferHeader,
115                state: State<MintState>,
116                payload: Json<$request_type>
117            ) -> Result<Json<$response_type>, Response> {
118                use std::ops::Deref;
119
120                let json_extracted_payload = payload.deref();
121                let State(mint_state) = state.clone();
122                let cache_key = match mint_state.cache.calculate_key(&json_extracted_payload) {
123                    Some(key) => key,
124                    None => {
125                        // Could not calculate key, just return the handler result
126                        #[cfg(feature = "auth")]
127                        return $handler(auth, prefer, state, payload).await;
128                        #[cfg(not(feature = "auth"))]
129                        return $handler(prefer, state, payload).await;
130                    }
131                };
132                if let Some(cached_response) = mint_state.cache.get::<$response_type>(&cache_key).await {
133                    return Ok(Json(cached_response));
134                }
135                #[cfg(feature = "auth")]
136                let response = $handler(auth, prefer, state, payload).await?;
137                #[cfg(not(feature = "auth"))]
138                let response = $handler(prefer, state, payload).await?;
139                mint_state.cache.set(cache_key, &response.deref()).await;
140                Ok(response)
141            }
142        }
143    };
144}
145
146post_cache_wrapper!(post_swap, SwapRequest, SwapResponse);
147post_cache_wrapper!(post_mint_bolt11, MintRequest<QuoteId>, MintResponse);
148post_cache_wrapper_with_prefer!(
149    post_melt_bolt11,
150    MeltRequest<QuoteId>,
151    MeltQuoteBolt11Response<QuoteId>
152);
153
154#[cfg_attr(feature = "swagger", utoipa::path(
155    get,
156    context_path = "/v1",
157    path = "/keys",
158    responses(
159        (status = 200, description = "Successful response", body = KeysResponse, content_type = "application/json")
160    )
161))]
162/// Get the public keys of the newest mint keyset
163///
164/// This endpoint returns a dictionary of all supported token values of the mint and their associated public key.
165#[instrument(skip_all)]
166pub(crate) async fn get_keys(
167    State(state): State<MintState>,
168) -> Result<Json<KeysResponse>, Response> {
169    Ok(Json(state.mint.pubkeys()))
170}
171
172#[cfg_attr(feature = "swagger", utoipa::path(
173    get,
174    context_path = "/v1",
175    path = "/keys/{keyset_id}",
176    params(
177        ("keyset_id" = String, description = "The keyset ID"),
178    ),
179    responses(
180        (status = 200, description = "Successful response", body = KeysResponse, content_type = "application/json"),
181        (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
182    )
183))]
184/// Get the public keys of a specific keyset
185///
186/// Get the public keys of the mint from a specific keyset ID.
187#[instrument(skip_all, fields(keyset_id = ?keyset_id))]
188pub(crate) async fn get_keyset_pubkeys(
189    State(state): State<MintState>,
190    Path(keyset_id): Path<Id>,
191) -> Result<Json<KeysResponse>, Response> {
192    let pubkeys = state.mint.keyset_pubkeys(&keyset_id).map_err(|err| {
193        tracing::error!("Could not get keyset pubkeys: {}", err);
194        into_response(err)
195    })?;
196
197    Ok(Json(pubkeys))
198}
199
200#[cfg_attr(feature = "swagger", utoipa::path(
201    get,
202    context_path = "/v1",
203    path = "/keysets",
204    responses(
205        (status = 200, description = "Successful response", body = KeysetResponse, content_type = "application/json"),
206        (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
207    )
208))]
209/// Get all active keyset IDs of the mint
210///
211/// This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.
212#[instrument(skip_all)]
213pub(crate) async fn get_keysets(
214    State(state): State<MintState>,
215) -> Result<Json<KeysetResponse>, Response> {
216    Ok(Json(state.mint.keysets()))
217}
218
219#[cfg_attr(feature = "swagger", utoipa::path(
220    post,
221    context_path = "/v1",
222    path = "/mint/quote/bolt11",
223    request_body(content = MintQuoteBolt11Request, description = "Request params", content_type = "application/json"),
224    responses(
225        (status = 200, description = "Successful response", body = MintQuoteBolt11Response<String>, content_type = "application/json"),
226        (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
227    )
228))]
229/// Request a quote for minting of new tokens
230///
231/// Request minting of new tokens. The mint responds with a Lightning invoice. This endpoint can be used for a Lightning invoice UX flow.
232#[instrument(skip_all, fields(amount = ?payload.amount))]
233pub(crate) async fn post_mint_bolt11_quote(
234    #[cfg(feature = "auth")] auth: AuthHeader,
235    State(state): State<MintState>,
236    Json(payload): Json<MintQuoteBolt11Request>,
237) -> Result<Json<MintQuoteBolt11Response<QuoteId>>, Response> {
238    #[cfg(feature = "auth")]
239    state
240        .mint
241        .verify_auth(
242            auth.into(),
243            &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteBolt11),
244        )
245        .await
246        .map_err(into_response)?;
247
248    let quote = state
249        .mint
250        .get_mint_quote(payload.into())
251        .await
252        .map_err(into_response)?;
253
254    Ok(Json(quote.try_into().map_err(into_response)?))
255}
256
257#[cfg_attr(feature = "swagger", utoipa::path(
258    get,
259    context_path = "/v1",
260    path = "/mint/quote/bolt11/{quote_id}",
261    params(
262        ("quote_id" = String, description = "The quote ID"),
263    ),
264    responses(
265        (status = 200, description = "Successful response", body = MintQuoteBolt11Response<String>, content_type = "application/json"),
266        (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
267    )
268))]
269/// Get mint quote by ID
270///
271/// Get mint quote state.
272#[instrument(skip_all, fields(quote_id = ?quote_id))]
273pub(crate) async fn get_check_mint_bolt11_quote(
274    #[cfg(feature = "auth")] auth: AuthHeader,
275    State(state): State<MintState>,
276    Path(quote_id): Path<QuoteId>,
277) -> Result<Json<MintQuoteBolt11Response<QuoteId>>, Response> {
278    #[cfg(feature = "auth")]
279    {
280        state
281            .mint
282            .verify_auth(
283                auth.into(),
284                &ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11),
285            )
286            .await
287            .map_err(into_response)?;
288    }
289
290    let quote = state
291        .mint
292        .check_mint_quote(&quote_id)
293        .await
294        .map_err(|err| {
295            tracing::error!("Could not check mint quote {}: {}", quote_id, err);
296            into_response(err)
297        })?;
298
299    Ok(Json(quote.try_into().map_err(into_response)?))
300}
301
302#[instrument(skip_all)]
303pub(crate) async fn ws_handler(
304    #[cfg(feature = "auth")] auth: AuthHeader,
305    State(state): State<MintState>,
306    ws: WebSocketUpgrade,
307) -> Result<impl IntoResponse, Response> {
308    #[cfg(feature = "auth")]
309    {
310        state
311            .mint
312            .verify_auth(
313                auth.into(),
314                &ProtectedEndpoint::new(Method::Get, RoutePath::Ws),
315            )
316            .await
317            .map_err(into_response)?;
318    }
319
320    Ok(ws.on_upgrade(|ws| main_websocket(ws, state)))
321}
322
323/// Mint tokens by paying a BOLT11 Lightning invoice.
324///
325/// Requests the minting of tokens belonging to a paid payment request.
326///
327/// Call this endpoint after `POST /v1/mint/quote`.
328#[cfg_attr(feature = "swagger", utoipa::path(
329    post,
330    context_path = "/v1",
331    path = "/mint/bolt11",
332    request_body(content = MintRequest<String>, description = "Request params", content_type = "application/json"),
333    responses(
334        (status = 200, description = "Successful response", body = MintResponse, content_type = "application/json"),
335        (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
336    )
337))]
338#[instrument(skip_all, fields(quote_id = ?payload.quote))]
339pub(crate) async fn post_mint_bolt11(
340    #[cfg(feature = "auth")] auth: AuthHeader,
341    State(state): State<MintState>,
342    Json(payload): Json<MintRequest<QuoteId>>,
343) -> Result<Json<MintResponse>, Response> {
344    #[cfg(feature = "auth")]
345    {
346        state
347            .mint
348            .verify_auth(
349                auth.into(),
350                &ProtectedEndpoint::new(Method::Post, RoutePath::MintBolt11),
351            )
352            .await
353            .map_err(into_response)?;
354    }
355
356    let res = state
357        .mint
358        .process_mint_request(payload)
359        .await
360        .map_err(|err| {
361            tracing::error!("Could not process mint: {}", err);
362            into_response(err)
363        })?;
364
365    Ok(Json(res))
366}
367
368#[cfg_attr(feature = "swagger", utoipa::path(
369    post,
370    context_path = "/v1",
371    path = "/melt/quote/bolt11",
372    request_body(content = MeltQuoteBolt11Request, description = "Quote params", content_type = "application/json"),
373    responses(
374        (status = 200, description = "Successful response", body = MeltQuoteBolt11Response<String>, content_type = "application/json"),
375        (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
376    )
377))]
378#[instrument(skip_all)]
379/// Request a quote for melting tokens
380pub(crate) async fn post_melt_bolt11_quote(
381    #[cfg(feature = "auth")] auth: AuthHeader,
382    State(state): State<MintState>,
383    Json(payload): Json<MeltQuoteBolt11Request>,
384) -> Result<Json<MeltQuoteBolt11Response<QuoteId>>, Response> {
385    #[cfg(feature = "auth")]
386    {
387        state
388            .mint
389            .verify_auth(
390                auth.into(),
391                &ProtectedEndpoint::new(Method::Post, RoutePath::MeltQuoteBolt11),
392            )
393            .await
394            .map_err(into_response)?;
395    }
396
397    let quote = state
398        .mint
399        .get_melt_quote(payload.into())
400        .await
401        .map_err(into_response)?;
402
403    Ok(Json(quote))
404}
405
406#[cfg_attr(feature = "swagger", utoipa::path(
407    get,
408    context_path = "/v1",
409    path = "/melt/quote/bolt11/{quote_id}",
410    params(
411        ("quote_id" = String, description = "The quote ID"),
412    ),
413    responses(
414        (status = 200, description = "Successful response", body = MeltQuoteBolt11Response<String>, content_type = "application/json"),
415        (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
416    )
417))]
418/// Get melt quote by ID
419///
420/// Get melt quote state.
421#[instrument(skip_all, fields(quote_id = ?quote_id))]
422pub(crate) async fn get_check_melt_bolt11_quote(
423    #[cfg(feature = "auth")] auth: AuthHeader,
424    State(state): State<MintState>,
425    Path(quote_id): Path<QuoteId>,
426) -> Result<Json<MeltQuoteBolt11Response<QuoteId>>, Response> {
427    #[cfg(feature = "auth")]
428    {
429        state
430            .mint
431            .verify_auth(
432                auth.into(),
433                &ProtectedEndpoint::new(Method::Get, RoutePath::MeltQuoteBolt11),
434            )
435            .await
436            .map_err(into_response)?;
437    }
438
439    let quote = state
440        .mint
441        .check_melt_quote(&quote_id)
442        .await
443        .map_err(|err| {
444            tracing::error!("Could not check melt quote: {}", err);
445            into_response(err)
446        })?;
447
448    Ok(Json(quote))
449}
450
451#[cfg_attr(feature = "swagger", utoipa::path(
452    post,
453    context_path = "/v1",
454    path = "/melt/bolt11",
455    request_body(content = MeltRequest<String>, description = "Melt params", content_type = "application/json"),
456    responses(
457        (status = 200, description = "Successful response", body = MeltQuoteBolt11Response<String>, content_type = "application/json"),
458        (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
459    )
460))]
461/// Melt tokens for a Bitcoin payment that the mint will make for the user in exchange
462///
463/// Requests tokens to be destroyed and sent out via Lightning.
464#[instrument(skip_all)]
465pub(crate) async fn post_melt_bolt11(
466    #[cfg(feature = "auth")] auth: AuthHeader,
467    prefer: PreferHeader,
468    State(state): State<MintState>,
469    Json(payload): Json<MeltRequest<QuoteId>>,
470) -> Result<Json<MeltQuoteBolt11Response<QuoteId>>, Response> {
471    #[cfg(feature = "auth")]
472    {
473        state
474            .mint
475            .verify_auth(
476                auth.into(),
477                &ProtectedEndpoint::new(Method::Post, RoutePath::MeltBolt11),
478            )
479            .await
480            .map_err(into_response)?;
481    }
482
483    let res = if prefer.respond_async {
484        // Asynchronous processing - return immediately after setup
485        state
486            .mint
487            .melt_async(&payload)
488            .await
489            .map_err(into_response)?
490    } else {
491        // Synchronous processing - wait for completion
492        state.mint.melt(&payload).await.map_err(into_response)?
493    };
494
495    Ok(Json(res))
496}
497
498#[cfg_attr(feature = "swagger", utoipa::path(
499    post,
500    context_path = "/v1",
501    path = "/checkstate",
502    request_body(content = CheckStateRequest, description = "State params", content_type = "application/json"),
503    responses(
504        (status = 200, description = "Successful response", body = CheckStateResponse, content_type = "application/json"),
505        (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
506    )
507))]
508/// Check whether a proof is spent already or is pending in a transaction
509///
510/// Check whether a secret has been spent already or not.
511#[instrument(skip_all, fields(y_count = ?payload.ys.len()))]
512pub(crate) async fn post_check(
513    #[cfg(feature = "auth")] auth: AuthHeader,
514    State(state): State<MintState>,
515    Json(payload): Json<CheckStateRequest>,
516) -> Result<Json<CheckStateResponse>, Response> {
517    #[cfg(feature = "auth")]
518    {
519        state
520            .mint
521            .verify_auth(
522                auth.into(),
523                &ProtectedEndpoint::new(Method::Post, RoutePath::Checkstate),
524            )
525            .await
526            .map_err(into_response)?;
527    }
528
529    let state = state.mint.check_state(&payload).await.map_err(|err| {
530        tracing::error!("Could not check state of proofs");
531        into_response(err)
532    })?;
533
534    Ok(Json(state))
535}
536
537#[cfg_attr(feature = "swagger", utoipa::path(
538    get,
539    context_path = "/v1",
540    path = "/info",
541    responses(
542        (status = 200, description = "Successful response", body = MintInfo)
543    )
544))]
545/// Mint information, operator contact information, and other info
546#[instrument(skip_all)]
547pub(crate) async fn get_mint_info(
548    State(state): State<MintState>,
549) -> Result<Json<MintInfo>, Response> {
550    Ok(Json(
551        state
552            .mint
553            .mint_info()
554            .await
555            .map_err(|err| {
556                tracing::error!("Could not get mint info: {}", err);
557                into_response(err)
558            })?
559            .clone()
560            .time(unix_time()),
561    ))
562}
563
564#[cfg_attr(feature = "swagger", utoipa::path(
565    post,
566    context_path = "/v1",
567    path = "/swap",
568    request_body(content = SwapRequest, description = "Swap params", content_type = "application/json"),
569    responses(
570        (status = 200, description = "Successful response", body = SwapResponse, content_type = "application/json"),
571        (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
572    )
573))]
574/// Swap inputs for outputs of the same value
575///
576/// Requests a set of Proofs to be swapped for another set of BlindSignatures.
577///
578/// This endpoint can be used by Alice to swap a set of proofs before making a payment to Carol. It can then used by Carol to redeem the tokens for new proofs.
579#[instrument(skip_all, fields(inputs_count = ?payload.inputs().len()))]
580pub(crate) async fn post_swap(
581    #[cfg(feature = "auth")] auth: AuthHeader,
582    State(state): State<MintState>,
583    Json(payload): Json<SwapRequest>,
584) -> Result<Json<SwapResponse>, Response> {
585    #[cfg(feature = "auth")]
586    {
587        state
588            .mint
589            .verify_auth(
590                auth.into(),
591                &ProtectedEndpoint::new(Method::Post, RoutePath::Swap),
592            )
593            .await
594            .map_err(into_response)?;
595    }
596
597    let swap_response = state
598        .mint
599        .process_swap_request(payload)
600        .await
601        .map_err(|err| {
602            tracing::error!("Could not process swap request: {}", err);
603            into_response(err)
604        })?;
605
606    Ok(Json(swap_response))
607}
608
609#[cfg_attr(feature = "swagger", utoipa::path(
610    post,
611    context_path = "/v1",
612    path = "/restore",
613    request_body(content = RestoreRequest, description = "Restore params", content_type = "application/json"),
614    responses(
615        (status = 200, description = "Successful response", body = RestoreResponse, content_type = "application/json"),
616        (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
617    )
618))]
619/// Restores blind signature for a set of outputs.
620#[instrument(skip_all, fields(outputs_count = ?payload.outputs.len()))]
621pub(crate) async fn post_restore(
622    #[cfg(feature = "auth")] auth: AuthHeader,
623    State(state): State<MintState>,
624    Json(payload): Json<RestoreRequest>,
625) -> Result<Json<RestoreResponse>, Response> {
626    #[cfg(feature = "auth")]
627    {
628        state
629            .mint
630            .verify_auth(
631                auth.into(),
632                &ProtectedEndpoint::new(Method::Post, RoutePath::Restore),
633            )
634            .await
635            .map_err(into_response)?;
636    }
637
638    let restore_response = state.mint.restore(payload).await.map_err(|err| {
639        tracing::error!("Could not process restore: {}", err);
640        into_response(err)
641    })?;
642
643    Ok(Json(restore_response))
644}
645
646#[instrument(skip_all)]
647pub(crate) fn into_response<T>(error: T) -> Response
648where
649    T: Into<ErrorResponse>,
650{
651    let err_response: ErrorResponse = error.into();
652    let status_code = match err_response.code {
653        // Client errors (400 Bad Request)
654        ErrorCode::TokenAlreadySpent
655        | ErrorCode::TokenPending
656        | ErrorCode::QuoteNotPaid
657        | ErrorCode::QuoteExpired
658        | ErrorCode::QuotePending
659        | ErrorCode::KeysetNotFound
660        | ErrorCode::KeysetInactive
661        | ErrorCode::BlindedMessageAlreadySigned
662        | ErrorCode::UnsupportedUnit
663        | ErrorCode::TokensAlreadyIssued
664        | ErrorCode::MintingDisabled
665        | ErrorCode::InvoiceAlreadyPaid
666        | ErrorCode::TokenNotVerified
667        | ErrorCode::TransactionUnbalanced
668        | ErrorCode::AmountOutofLimitRange
669        | ErrorCode::WitnessMissingOrInvalid
670        | ErrorCode::DuplicateSignature
671        | ErrorCode::DuplicateInputs
672        | ErrorCode::DuplicateOutputs
673        | ErrorCode::MultipleUnits
674        | ErrorCode::UnitMismatch
675        | ErrorCode::ClearAuthRequired
676        | ErrorCode::BlindAuthRequired => StatusCode::BAD_REQUEST,
677
678        // Auth failures (401 Unauthorized)
679        ErrorCode::ClearAuthFailed | ErrorCode::BlindAuthFailed => StatusCode::UNAUTHORIZED,
680
681        // Lightning/payment errors and unknown errors (500 Internal Server Error)
682        ErrorCode::LightningError | ErrorCode::Unknown(_) => StatusCode::INTERNAL_SERVER_ERROR,
683    };
684
685    (status_code, Json(err_response)).into_response()
686}