Skip to content
Open
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.

21 changes: 20 additions & 1 deletion 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)
MintQuoteByPubkey(String),
/// Mint for a specific payment method
Mint(String),
/// Melt Quote for a specific payment method
Expand Down Expand Up @@ -175,7 +177,13 @@ impl std::str::FromStr for RoutePath {
_ => {
// Try to parse as a payment method route
if let Some(method) = s.strip_prefix("/v1/mint/quote/") {
Ok(RoutePath::MintQuote(normalize_payment_method(method)))
if let Some(method) = method.strip_suffix("/pubkey") {
Ok(RoutePath::MintQuoteByPubkey(normalize_payment_method(
method,
)))
} else {
Ok(RoutePath::MintQuote(normalize_payment_method(method)))
}
} else if let Some(method) = s.strip_prefix("/v1/mint/") {
Ok(RoutePath::Mint(normalize_payment_method(method)))
} else if let Some(method) = s.strip_prefix("/v1/melt/quote/") {
Expand Down Expand Up @@ -339,6 +347,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(method) => write!(f, "/v1/mint/quote/{}/pubkey", method),
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 +701,16 @@ mod tests {
let json = serde_json::to_string(&RoutePath::MintQuote("paypal".to_string())).unwrap();
assert_eq!(json, "\"/v1/mint/quote/paypal\"");

let json =
serde_json::to_string(&RoutePath::MintQuoteByPubkey("bolt11".to_string())).unwrap();
assert_eq!(json, "\"/v1/mint/quote/bolt11/pubkey\"");

let path: RoutePath = serde_json::from_str("\"/v1/mint/quote/bolt11/pubkey\"").unwrap();
assert_eq!(
path,
RoutePath::MintQuoteByPubkey(PaymentMethod::Known(KnownMethod::Bolt11).to_string())
);

// Test deserialization of payment method paths
let path: RoutePath = serde_json::from_str("\"/v1/mint/bolt11\"").unwrap();
assert_eq!(
Expand Down
1 change: 1 addition & 0 deletions crates/cashu/src/nuts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub mod nut27;
pub mod nut28;
pub mod nut29;
pub mod nut30;
pub mod nutxx;

mod auth;

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 @@ -348,6 +348,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 @@ -473,6 +479,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
156 changes: 156 additions & 0 deletions crates/cashu/src/nuts/nutxx.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//! NUT-XX: Mint Quote Lookup by Public Key
//!
//! <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 {
/// 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 @@ -46,6 +46,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
6 changes: 5 additions & 1 deletion crates/cdk-axum/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,11 @@ 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))
.route(
"/mint/quote/{method}/pubkey",
post(post_mint_quote_by_pubkey),
);

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

Expand Down
61 changes: 61 additions & 0 deletions crates/cdk-axum/src/router_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ use cdk::nuts::{
RestoreRequest, RestoreResponse, SwapRequest, SwapResponse,
};
use cdk::util::unix_time;
use cdk_common::nutxx::{MintQuoteByPubkeyRequest, MintQuoteByPubkeyResponse};
use cdk_common::{MintQuoteResponse, PaymentMethod, QuoteId};
use paste::paste;
use serde_json::Value;
use tracing::instrument;

use crate::auth::AuthHeader;
Expand Down Expand Up @@ -242,6 +245,64 @@ pub(crate) async fn post_restore(
Ok(Json(restore_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,
Path(method): Path<String>,
State(state): State<MintState>,
Json(request): Json<MintQuoteByPubkeyRequest>,
) -> Result<Response, Response> {
state
.mint
.verify_auth(
auth.into(),
&ProtectedEndpoint::new(
Method::Post,
RoutePath::MintQuoteByPubkey(method.clone().to_lowercase()),
),
)
.await
.map_err(into_response)?;

let method = PaymentMethod::from(method);

let quotes = state
.mint
.get_mint_quote_by_pubkey(method, request.pubkeys, request.pubkey_signatures)
.await
.map_err(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())
}

/// 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),
}
}

#[cfg(feature = "info-page")]
const CSS: &str = r#"
:root {
Expand Down
Loading
Loading