Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions crates/cashu/src/nuts/auth/nut21.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ pub enum RoutePath {
Wildcard(String),
/// Mint Quote for a specific payment method
MintQuote(String),
/// Mint Quote lookup by public key (NUT-XX), method-agnostic
MintQuoteByPubkey,
/// Mint for a specific payment method
Mint(String),
/// Melt Quote for a specific payment method
Expand Down Expand Up @@ -172,6 +174,9 @@ impl std::str::FromStr for RoutePath {
"/v1/restore" => Ok(RoutePath::Restore),
"/v1/auth/blind/mint" => Ok(RoutePath::MintBlindAuth),
"/v1/ws" => Ok(RoutePath::Ws),
// Must precede the `/v1/mint/quote/` prefix branch below, which would otherwise
// read this as a payment method literally named "pubkey".
"/v1/mint/quote/pubkey" => Ok(RoutePath::MintQuoteByPubkey),
_ => {
// Try to parse as a payment method route
if let Some(method) = s.strip_prefix("/v1/mint/quote/") {
Expand Down Expand Up @@ -339,6 +344,7 @@ impl std::fmt::Display for RoutePath {
match self {
RoutePath::Wildcard(prefix) => write!(f, "{}*", prefix),
RoutePath::MintQuote(method) => write!(f, "/v1/mint/quote/{}", method),
RoutePath::MintQuoteByPubkey => write!(f, "/v1/mint/quote/pubkey"),
RoutePath::Mint(method) => write!(f, "/v1/mint/{}", method),
RoutePath::MeltQuote(method) => write!(f, "/v1/melt/quote/{}", method),
RoutePath::Melt(method) => write!(f, "/v1/melt/{}", method),
Expand Down Expand Up @@ -692,6 +698,15 @@ mod tests {
let json = serde_json::to_string(&RoutePath::MintQuote("paypal".to_string())).unwrap();
assert_eq!(json, "\"/v1/mint/quote/paypal\"");

// NUT-XX lookup is a static path. It must not be read as a payment method named
// "pubkey" by the `/v1/mint/quote/` prefix branch in `FromStr`.
let json = serde_json::to_string(&RoutePath::MintQuoteByPubkey).unwrap();
assert_eq!(json, "\"/v1/mint/quote/pubkey\"");
assert_eq!(
RoutePath::from_str("/v1/mint/quote/pubkey").unwrap(),
RoutePath::MintQuoteByPubkey
);

// Test deserialization of payment method paths
let path: RoutePath = serde_json::from_str("\"/v1/mint/bolt11\"").unwrap();
assert_eq!(
Expand Down
14 changes: 14 additions & 0 deletions crates/cashu/src/nuts/nut06.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,12 @@ pub struct Nuts {
#[serde(rename = "29")]
#[serde(skip_serializing_if = "nut29::Settings::is_empty")]
pub nut29: nut29::Settings,
/// NUTXX Settings (mint quote lookup by pubkey)
///
/// Keyed `XX` until the NUT is assigned a number.
#[serde(default)]
#[serde(rename = "XX")]
pub nutxx: SupportedSettings,
}

impl Nuts {
Expand Down Expand Up @@ -462,6 +468,14 @@ impl Nuts {
}
}

/// NutXX settings (mint quote lookup by pubkey)
pub fn nutxx(self, supported: bool) -> Self {
Self {
nutxx: SupportedSettings { supported },
..self
}
}

/// Units where minting is supported
pub fn supported_mint_units(&self) -> Vec<&CurrencyUnit> {
self.nut04
Expand Down
150 changes: 146 additions & 4 deletions crates/cashu/src/nuts/nutxx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,155 @@
//!
//! <https://github.com/cashubtc/nuts/blob/get-quotes-by-pubkeys/xx.md>

use bitcoin::secp256k1::schnorr::Signature;
use serde::{Deserialize, Serialize};

use super::PublicKey;

/// Domain separator for mint quote lookup signatures [NUT-XX]
pub const MINT_QUOTE_LOOKUP_DOMAIN: &[u8] = b"Cashu_MintQuoteLookup_v1";

/// Maximum number of pubkeys accepted in a single lookup request.
///
/// The endpoint is unauthenticated until the signatures are checked, so the request length
/// bounds how much signature verification an anonymous caller can ask the mint to perform.
pub const MAX_LOOKUP_PUBKEYS: usize = 50;

/// Mint quote by pubkey request [NUT-XX]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MintQuoteByPubkeyRequest {
/// Pubkeys
pub pubkeys: Vec<String>,
/// Signatures
pub pubkey_signatures: Vec<String>,
/// NUT-20 public keys to look up quotes for
pub pubkeys: Vec<PublicKey>,
/// Schnorr signatures, in the same order as `pubkeys`
pub pubkey_signatures: Vec<Signature>,
}

/// Mint quote by pubkey response [NUT-XX]
///
/// Generic over the quote representation so the mint can answer with its own response type
/// without this crate depending on the unified `MintQuoteResponse` enum in `cdk-common`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MintQuoteByPubkeyResponse<T> {
/// Quotes locked to the requested pubkeys, in [NUT-04] response format
pub quotes: Vec<T>,
}

/// Build the message a wallet signs to prove control of `pubkey`.
///
/// Per [NUT-XX] the signature covers the SHA-256 hash of
/// `"Cashu_MintQuoteLookup_v1" || mint_pubkey || pubkey`, with the pubkeys concatenated as
/// their UTF-8 hex string representations.
///
/// The returned value is the *pre-image*, not the digest — pass it straight to
/// [`PublicKey::verify`] / [`crate::SecretKey::sign`], both of which hash their argument.
pub fn mint_quote_lookup_msg_to_sign(mint_pubkey: &PublicKey, pubkey: &PublicKey) -> Vec<u8> {
let mint_pubkey = mint_pubkey.to_hex();
let pubkey = pubkey.to_hex();

let mut msg =
Vec::with_capacity(MINT_QUOTE_LOOKUP_DOMAIN.len() + mint_pubkey.len() + pubkey.len());
msg.extend_from_slice(MINT_QUOTE_LOOKUP_DOMAIN);
msg.extend_from_slice(mint_pubkey.as_bytes());
msg.extend_from_slice(pubkey.as_bytes());
msg
}

#[cfg(test)]
mod tests {
use super::*;
use crate::SecretKey;

/// Fixed keys so the vector below is reproducible.
const MINT_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001";
const WALLET_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000002";

fn fixed_keys() -> (PublicKey, PublicKey) {
(
SecretKey::from_hex(MINT_SECRET).unwrap().public_key(),
SecretKey::from_hex(WALLET_SECRET).unwrap().public_key(),
)
}

/// Pins the pre-image so the construction cannot drift from the NUT without this failing.
#[test]
fn test_msg_to_sign_vector() {
let (mint_pubkey, pubkey) = fixed_keys();

let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey);

assert_eq!(
String::from_utf8(msg).unwrap(),
"Cashu_MintQuoteLookup_v1\
0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\
02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"
);
}

/// A signature produced over the pre-image verifies, and one bound to a different mint
/// does not — this is what stops a signature being replayed at another mint.
#[test]
fn test_sign_and_verify_is_mint_bound() {
let (mint_pubkey, _) = fixed_keys();
let secret_key = SecretKey::generate();
let pubkey = secret_key.public_key();

let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey);
let signature = secret_key.sign(&msg).unwrap();
assert!(pubkey.verify(&msg, &signature).is_ok());

let other_mint = SecretKey::generate().public_key();
let other_msg = mint_quote_lookup_msg_to_sign(&other_mint, &pubkey);
assert!(pubkey.verify(&other_msg, &signature).is_err());
}

/// A signature is bound to the pubkey it authorises, so one pubkey's signature cannot be
/// used to read another pubkey's quotes.
#[test]
fn test_signature_is_bound_to_pubkey() {
let (mint_pubkey, _) = fixed_keys();
let secret_key = SecretKey::generate();
let victim = SecretKey::generate().public_key();

let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &secret_key.public_key());
let signature = secret_key.sign(&msg).unwrap();

let victim_msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &victim);
assert!(victim.verify(&victim_msg, &signature).is_err());
}

/// The wire format is hex strings, per the NUT.
#[test]
fn test_request_wire_format() {
let (mint_pubkey, _) = fixed_keys();
let secret_key = SecretKey::generate();
let pubkey = secret_key.public_key();
let msg = mint_quote_lookup_msg_to_sign(&mint_pubkey, &pubkey);
let signature = secret_key.sign(&msg).unwrap();

let json = serde_json::to_string(&MintQuoteByPubkeyRequest {
pubkeys: vec![pubkey],
pubkey_signatures: vec![signature],
})
.unwrap();

assert!(json.contains(&pubkey.to_hex()));
assert!(json.contains(&signature.to_string()));

let request: MintQuoteByPubkeyRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request.pubkeys, vec![pubkey]);
assert_eq!(request.pubkey_signatures, vec![signature]);
}

/// The response envelope is an object with a `quotes` array, not a bare array.
#[test]
fn test_response_wire_format() {
let response = MintQuoteByPubkeyResponse {
quotes: vec![serde_json::json!({"quote": "abc", "method": "bolt11"})],
};

assert_eq!(
serde_json::to_value(&response).unwrap(),
serde_json::json!({"quotes": [{"quote": "abc", "method": "bolt11"}]})
);
}
}
2 changes: 2 additions & 0 deletions crates/cdk-axum/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ cdk-fake-wallet = { workspace = true }
cdk-sqlite = { workspace = true, features = ["mint"] }
cdk-signatory = { workspace = true }
bip39 = { workspace = true }
tower = { workspace = true, features = ["util"] }
tokio = { workspace = true, features = ["full"] }

[lints]
workspace = true
70 changes: 36 additions & 34 deletions crates/cdk-axum/src/custom_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use cdk::mint::QuoteId;
use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath};
use cdk::nuts::nutxx::MintQuoteByPubkeyRequest;
use cdk::nuts::nutxx::{MintQuoteByPubkeyRequest, MintQuoteByPubkeyResponse};
use cdk::nuts::{
BatchCheckMintQuoteRequest, BatchMintRequest, MeltOnchainRequest, MeltQuoteBolt11Request,
MeltQuoteBolt12Request, MeltQuoteCustomRequest, MeltQuoteOnchainRequest,
MintQuoteBolt11Request, MintQuoteBolt11Response, MintQuoteBolt12Request,
MintQuoteBolt12Response, MintQuoteCustomRequest, MintQuoteOnchainRequest,
MintQuoteOnchainResponse, MintRequest, MintResponse, PaymentMethod,
};
use cdk::MintQuoteResponse;
use cdk::{MeltQuoteCreateResponse, MeltQuoteResponse};
use serde_json::Value;
use tracing::instrument;
Expand Down Expand Up @@ -728,55 +729,56 @@ pub async fn cache_post_batch_mint(
Ok(result)
}

/// Generic handler for get mint quotes by pubkey
#[instrument(skip_all, fields(method = ?method))]
/// Flatten a `MintQuoteResponse` to the NUT-04 quote object that goes on the wire.
///
/// `MintQuoteResponse` is an externally tagged enum, so serialising it directly would wrap
/// each quote in a `{"Bolt11": …}` envelope that no NUT describes.
fn mint_quote_response_to_value(
response: MintQuoteResponse<QuoteId>,
) -> Result<Value, serde_json::Error> {
match response {
MintQuoteResponse::Bolt11(r) => serde_json::to_value(r),
MintQuoteResponse::Bolt12(r) => serde_json::to_value(r),
MintQuoteResponse::Onchain(r) => serde_json::to_value(r),
MintQuoteResponse::Custom { response, .. } => serde_json::to_value(response),
}
}

/// Handler for mint quote lookup by public key (NUT-XX)
///
/// Method-agnostic: a pubkey may hold quotes across several payment methods and they are all
/// returned together, each in its own NUT-04 response format.
#[instrument(skip_all)]
pub async fn post_mint_quote_by_pubkey(
auth: AuthHeader,
State(state): State<MintState>,
Path(method): Path<String>,
Json(payload): Json<Value>,
Json(request): Json<MintQuoteByPubkeyRequest>,
) -> Result<Response, Response> {
state
.mint
.verify_auth(
auth.into(),
&ProtectedEndpoint::new(Method::Post, RoutePath::MintQuote(method.clone())),
&ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteByPubkey),
)
.await
.map_err(into_response)?;

let request: MintQuoteByPubkeyRequest = serde_json::from_value(payload).map_err(|e| {
tracing::error!("Failed to parse request: {}", e);
into_response(cdk::Error::InvalidPaymentRequest)
})?;

let pubkeys = request
.pubkeys
.iter()
.map(|s| s.parse())
.collect::<Result<_, _>>()
.map_err(|e| {
tracing::error!("Invalid Public Key: {}", e);
into_response(cdk::Error::InvalidPaymentRequest)
})?;

let signatures = request
.pubkeys_signatures
.iter()
.map(|s| s.parse())
.collect::<Result<_, _>>()
.map_err(|e| {
tracing::error!("Invalid Signature: {}", e);
into_response(cdk::Error::SignatureMissingOrInvalid)
})?;

let response = state
let quotes = state
.mint
.get_mint_quote_by_pubkey(pubkeys, signatures)
.get_mint_quote_by_pubkey(request.pubkeys, request.pubkey_signatures)
.await
.map_err(into_response)?;

Ok(Json(response).into_response())
let quotes = quotes
.into_iter()
.map(mint_quote_response_to_value)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| {
tracing::error!("Failed to serialize mint quotes: {}", e);
into_response(cdk::Error::Internal)
})?;

Ok(Json(MintQuoteByPubkeyResponse { quotes }).into_response())
}

#[cfg(test)]
Expand Down
3 changes: 1 addition & 2 deletions crates/cdk-axum/src/custom_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use cdk::nuts::PaymentMethod;
use crate::custom_handlers::{
cache_post_batch_mint, cache_post_melt_custom, cache_post_mint_custom,
get_check_melt_custom_quote, get_check_mint_custom_quote, post_batch_check_mint_quote,
post_melt_custom_quote, post_mint_custom_quote, post_mint_quote_by_pubkey,
post_melt_custom_quote, post_mint_custom_quote,
};
use crate::MintState;

Expand Down Expand Up @@ -46,7 +46,6 @@ pub fn create_custom_routers(state: MintState, custom_methods: Vec<String>) -> R
"/mint/quote/{method}/check",
post(post_batch_check_mint_quote),
)
.route("/mint/quote/pubkey", post(post_mint_quote_by_pubkey))
.route("/mint/{method}", post(cache_post_mint_custom))
.route("/mint/{method}/batch", post(cache_post_batch_mint))
.route("/melt/quote/{method}", post(post_melt_custom_quote))
Expand Down
8 changes: 7 additions & 1 deletion crates/cdk-axum/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,13 @@ pub async fn create_mint_router_with_custom_cache(
.route("/ws", get(ws_handler))
.route("/checkstate", post(post_check))
.route("/info", get(get_mint_info))
.route("/restore", post(post_restore));
.route("/restore", post(post_restore))
// NUT-XX quote lookup is method-agnostic, so it belongs here rather than in the
// per-method custom router, which is only mounted when custom methods are configured.
.route(
"/mint/quote/pubkey",
post(custom_handlers::post_mint_quote_by_pubkey),
);

let mut mint_router = Router::new().nest("/v1", v1_router);

Expand Down
Loading