Skip to content

NUT-XX: Mint Quote Lookup by Public Key - #1834

Open
TheMhv wants to merge 12 commits into
cashubtc:mainfrom
TheMhv:feat/mint_quote_lookup
Open

NUT-XX: Mint Quote Lookup by Public Key#1834
TheMhv wants to merge 12 commits into
cashubtc:mainfrom
TheMhv:feat/mint_quote_lookup

Conversation

@TheMhv

@TheMhv TheMhv commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

Description

The CDK implementation of NUT-XX: Mint Quote Lookup by Public Key

Closes: #1746


Notes to the reviewers

  • Create a new route for mint quote /mint/quote/{method}/pubkey
  • Create a new get_mint_quotes_by_pubkey database function and add your test
  • Create a migration to add index into pubkey from mint_quote table in Database
  • Create the struct for Request defined in NUT

Suggested CHANGELOG Updates

CHANGED

ADDED

  • A new NUT file nuts/nutxx.rs
  • A new route v1/mint/quote/{method}/pubkey
  • Migrations for add index to pubkey field in mint_quote table

REMOVED

FIXED


Checklist

@github-project-automation github-project-automation Bot moved this to Backlog in CDK Apr 3, 2026
@TheMhv TheMhv changed the title NUT-XX: Mint Quote Lookup by Public Key (WIP) NUT-XX: Mint Quote Lookup by Public Key Apr 3, 2026
@codecov

codecov Bot commented Apr 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.77778% with 44 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.67%. Comparing base (1ef1031) to head (1c5cf35).

Files with missing lines Patch % Lines
crates/cdk-axum/src/router_handlers.rs 0.00% 20 Missing ⚠️
crates/cdk/src/mint/issue/mod.rs 0.00% 19 Missing ⚠️
crates/cdk-axum/src/lib.rs 0.00% 4 Missing ⚠️
crates/cdk-sql-common/src/mint/quotes.rs 80.00% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main    #1834    +/-   ##
========================================
  Coverage   78.67%   78.67%            
========================================
  Files         386      387     +1     
  Lines      103373   103569   +196     
========================================
+ Hits        81330    81485   +155     
- Misses      22043    22084    +41     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread crates/cashu/src/nuts/nutxx.rs Outdated
Comment thread crates/cashu/src/nuts/nutxx.rs Outdated
Comment thread crates/cdk-sql-common/src/mint/quotes.rs
Comment thread crates/cdk-sql-common/src/mint/quotes.rs
Comment thread crates/cdk-sql-common/src/mint/quotes.rs
@github-project-automation github-project-automation Bot moved this from Backlog to In progress in CDK Apr 4, 2026
@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch 2 times, most recently from 6869944 to 4797303 Compare April 5, 2026 14:50
Comment thread crates/cdk-common/src/mint.rs
Comment thread crates/cdk-axum/src/custom_handlers.rs Outdated
Comment thread crates/cashu/src/nuts/nutxx.rs Outdated
@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch 3 times, most recently from 900c85a to 0a811de Compare April 6, 2026 15:09
@thesimplekid
thesimplekid self-requested a review April 8, 2026 15:46
@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch 3 times, most recently from bf90e64 to c19a25f Compare April 23, 2026 18:16
@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch from c19a25f to 7e6b290 Compare April 28, 2026 13:08
@TheMhv
TheMhv marked this pull request as ready for review April 28, 2026 14:48
@TheMhv TheMhv changed the title (WIP) NUT-XX: Mint Quote Lookup by Public Key NUT-XX: Mint Quote Lookup by Public Key Apr 28, 2026
@c03rad0r

c03rad0r commented Jun 3, 2026

Copy link
Copy Markdown

Thanks for driving this forward. I have an ESP32-S3 device with an on-device ecash wallet (using the nucula library) that I'd like to connect directly to a CDK mint supporting NUT-XX ("Get quotes by pubkeys", cashubtc/nuts#341). My current workaround routes tokens through a stratum proxy translator, but direct mint queries from the ESP32 would simplify the architecture significantly.

I can offer testing against actual ESP32 hardware with my wallet integration. The secp256k1 Schnorr signing is already available on-device. I'm very much looking forward to integrating this — let me know if there's a branch I should target for integration testing.

@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch 2 times, most recently from 22f427a to 7454f41 Compare June 7, 2026 11:31

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified findings approved for disclosure:

  • Signature check bypass lets pubkey quote lookup disclose other users' quotes (medium) - An attacker can query the new endpoint with arbitrary victim pubkeys and an empty or truncated signature list to read private mint-quote metadata/invoices for those pubkeys without proving ownership.

Comment thread crates/cdk/src/mint/issue/mod.rs
Comment thread crates/cdk-axum/src/custom_handlers.rs Outdated

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified findings approved for disclosure:

  • Mint quote by pubkey response is serialized with the wrong root shape (medium) - Spec-compliant clients cannot deserialize the new quote lookup response because the mint returns a top-level array instead of the required object with quotes.
  • Mint quote by pubkey request uses non-standard signature field name (medium) - Spec-compliant clients sending pubkey_signatures cannot use the new endpoint because the mint expects the non-standard pubkeys_signatures field.

Comment thread crates/cdk-axum/src/custom_handlers.rs Outdated
.map_err(|e| {
tracing::error!("Invalid Signature: {}", e);
into_response(cdk::Error::SignatureMissingOrInvalid)
})?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response shape for this new endpoint does not match the NUT-29 draft linked from the code. The spec defines PostMintQuotesByPubkeyResponse as an object:

{ "quotes": <Array[MintQuoteResponse]> }

But this handler returns Json(response) directly, and response is the Vec<MintQuoteResponse<QuoteId>> returned by get_mint_quote_by_pubkey, so clients receive a top-level JSON array. Spec-compliant wallets deserializing the response object will fail. Please wrap the vector in a response struct with a quotes field before serializing.

Comment thread crates/cashu/src/nuts/nutxx.rs Outdated
@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch from 7454f41 to 95ed28d Compare June 19, 2026 13:16
@TheMhv

TheMhv commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch from 35cb21d to cdf494c Compare June 23, 2026 17:18
@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch 2 times, most recently from 00f0dc8 to e1d5623 Compare July 6, 2026 14:29

@ye0man ye0man left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

based on feedback from the wider team and to remain consistent with the spec, we should undo 948578d and add back method param

@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch from 88aad43 to 5d9a509 Compare August 14, 2026 13:37
@TheMhv

TheMhv commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

@cdk-bot review

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified findings approved for disclosure:

  • get_mint_quotes_by_pubkey SELECT omits last_checked, so every matching row fails to decode (high) - On SQLite and Postgres backends (the only SQL implementors), any NUT-XX quote-by-pubkey lookup that matches at least one quote returns an internal error (ConversionError::MissingColumn(15, 14)) instead of the quotes. Only empty lookups succeed. The endpoint's success path is broken.
  • Custom payment method named "pubkey" is silently shadowed by the new NUT-XX route (low) - A mint configuring a custom payment method literally named "pubkey" would have its POST /v1/mint/quote/pubkey (create quote) endpoint silently routed to the NUT-XX lookup handler instead of post_mint_custom_quote. Additionally, NUT-21 RoutePath::MintQuote("pubkey") no longer round-trips through FromStr. Narrow impact — only affects the unlikely configuration of a method named "pubkey".
    Additional locations included in summary:
    • crates/cashu/src/nuts/auth/nut21.rs:177
      Unanchored locations included in summary:
    • crates/cdk-axum/src/custom_router.rs:70

Comment thread crates/cdk-sql-common/src/mint/quotes.rs
Comment thread crates/cdk-axum/src/lib.rs
@ye0man ye0man added this to the 0.19.0 milestone Aug 24, 2026
@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch 2 times, most recently from 23d1a77 to 1ac0049 Compare August 24, 2026 18:37
@TheMhv

TheMhv commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@cdk-bot review

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified findings approved for disclosure:

  • NUT-XX lookup route only mounted when custom payment methods are configured, yet nutxx support is advertised unconditionally () - Mints built without custom payment methods advertise NUT-XX support in their /v1/info response but return HTTP 404 for the lookup endpoint POST /v1/mint/quote/{method}/pubkey. Spec-conformant wallets that read the advertisement will attempt the lookup and fail. In standard cdk-mintd deployments that include "bolt11" in custom_methods the route is available, but programmatic/embedded mints using create_mint_router(mint, vec![]) are affected.
    Additional locations included in summary:
    • crates/cdk/src/mint/builder.rs:107
      Unanchored locations included in summary:
    • crates/cdk-axum/src/lib.rs:126
  • New route tests POST to a path that no longer exists; happy-path assertions are shadowed by the quote-creation route (method = "pubkey") () - All three new route tests POST to the wrong path (/v1/mint/quote/pubkey instead of /v1/mint/quote/{method}/pubkey). Two of the three tests would fail in CI; the third passes as a false positive (404 instead of the intended 401/403). No test actually exercises the post_mint_quote_by_pubkey handler, so the route is untested.
    Additional locations included in summary:
    • crates/cdk-axum/src/custom_router.rs:50

Comment thread crates/cdk-axum/src/custom_router.rs Outdated
Comment thread crates/cdk-axum/tests/nutxx_mint_quote_lookup_route.rs Outdated
@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch 2 times, most recently from dfb55b6 to 0ee8f20 Compare August 25, 2026 13:28
@TheMhv

TheMhv commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

@cdk-bot review

@cdk-bot cdk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified findings approved for disclosure:

  • NUT-XX route registered at /v1/{method}/pubkey instead of /v1/mint/quote/{method}/pubkey — spec URL is never served (high) - see inline comment
  • MintQuoteByPubkey missing from RoutePath::match_specificity — case-sensitive fallback allows NUT-21/22 auth bypass with mixed-case method segment (medium) - see inline comment
    Unanchored locations included in summary:
    • crates/cashu/src/nuts/auth/nut21.rs:234
  • get_mint_quotes_by_pubkey: unbounded row count plus 2N hydration queries on an anonymously reachable endpoint (medium) - see inline comment
    Additional locations included in summary:
    • crates/cdk/src/mint/issue/mod.rs:455

Comment thread crates/cdk-axum/src/lib.rs Outdated
Comment thread crates/cdk-axum/src/router_handlers.rs Outdated
Ok(mint_quotes)
}

async fn get_mint_quotes_by_pubkey(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anonymous endpoint fans out into 1 + 2N SQL queries with no row cap

The new SQL implementation:

// crates/cdk-sql-common/src/mint/quotes.rs
SELECT ... FROM mint_quote WHERE pubkey IN (:pubkeys)   // no LIMIT
...
for quote in mint_quotes.as_mut_slice() {
    let payments = get_mint_quote_payments(&*conn, &quote.id).await?;   // +1 query per quote
    let issuance = get_mint_quote_issuance(&*conn, &quote.id).await?;   // +1 query per quote
    ...
}

MAX_LOOKUP_PUBKEYS = 50 caps the number of pubkeys (and thus signature verifications) per request, but nothing caps how many quote rows those pubkeys resolve to. Since mint-quote creation is unauthenticated on a default mint and accepts an arbitrary NUT-20 pubkey, an attacker can:

  1. Over time, create thousands of quotes locked to their own key(s) (each quote creation may also cost the LN backend an invoice).
  2. Call POST /v1/mint/quote/{method}/pubkey anonymously with a valid signature and trigger 1 + 2N database queries per request, repeatedly.

There is also no rate limiting on this endpoint — the tower dependency added to cdk-axum is a dev-dependency used only by the new tests. The per-quote hydration mirrors get_mint_quotes, but that trait method is only used by internal/management paths, not by an anonymously reachable HTTP handler, so this PR newly exposes the pattern to untrusted input.

Fix options (any combination):

  • Cap total returned rows (LIMIT / rejection when a pubkey set resolves to more than a bounded number of quotes).
  • Batch the hydration: one IN (:quote_ids) query each for payments and issuance instead of two queries per quote.
  • Add a rate limit (e.g. tower ServiceBuilder::rate_limit, keyed per-IP) on this unauthenticated route.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thesimplekid @crodas I don't know how we can solve this things without adding a rate limit or quote result limit.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah brought this up on the spec a bit ago. cashubtc/nuts#341 (comment)

@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch from d72bb6b to 66077ef Compare August 27, 2026 13:09
@thesimplekid

Copy link
Copy Markdown
Collaborator

Can we clean up the commit history here.

TheMhv and others added 12 commits August 27, 2026 12:10
The branch was rebased onto a main that already had 04cbe81 ("align mint
quote accounting with NUT-04"), which added method/amount_paid/
amount_issued/updated_at to the quote response types and a 15th
`updated_at` argument to `MintQuote::new`. Nothing conflicted, because the
new code is all in new blocks, so three sites broke silently.

- `get_mint_quote_by_pubkey` built the four response types by hand. Replaced
  with the existing `TryFrom<MintQuote> for MintQuoteResponse<QuoteId>` in
  cdk-common, which is the conversion every other quote path already uses
  and stays current on its own. Fixes four E0063s and removes 60 lines.
- The axum handler read `pubkeys_signatures`; the struct declares
  `pubkey_signatures` (E0609).
- The database fixture passed 14 of 15 arguments to `MintQuote::new` (E0061).
`get_mint_quotes_by_pubkey` copied its SELECT list from `get_mint_quotes`
forty lines above but dropped `updated_at`. `sql_row_to_mint_quote` reads
fourteen columns and got thirteen, so every call to the endpoint failed:

    Database(Conversion(MissingColumn(14, 13)))

This is the second bug in the feature caused by copying something that then
moved; the column list is a candidate for a shared const.
NUT-XX: "The mint MUST reject the request unless every signature is valid."

Two problems stopped that from holding.

The length guard did nothing. `pubkeys.len().ne(&signatures.len()).then(..)`
builds an `Option<Error>` and drops it, so nothing enforced the equality.
`pubkeys.iter().zip(signatures.iter())` then stops at the shorter iterator,
which means an empty `pubkey_signatures` array skipped verification
entirely: anyone who knew a pubkey could read that user's quotes, and
pubkeys travel in mint quote requests. Now `ensure_cdk!`.

Also bound the request length. The endpoint is unauthenticated until the
signatures verify, so without a cap an anonymous caller can ask the mint for
an unbounded number of Schnorr verifications plus an `IN (...)` query.
`MAX_LOOKUP_PUBKEYS` is a plain constant; happy to make it a NUT-06 setting
like NUT-29's `max_batch_size` if that reads better.

The signature was verified over a double hash. The NUT signs
SHA256("Cashu_MintQuoteLookup_v1" || mint_pubkey || pubkey), but
`PublicKey::verify` already hashes its argument before BIP-340 verification
— which is why NUT-20 passes it the raw message. This hashed first and
passed the digest, so the mint checked SHA256(SHA256(preimage)) and rejected
conformant wallets while accepting only double-hashed ones.

Message construction now lives in `mint_quote_lookup_msg_to_sign` next to
the request type, with a byte-level vector so it cannot drift from the NUT
silently, and a test that the digest does not verify.
The endpoint could not be reached on any mint.

The route was registered inside `create_custom_routers`, which
`create_mint_router` only nests when `custom_methods` is non-empty. An
ordinary bolt11/bolt12 mint therefore answered 404. The lookup is
method-agnostic, so it belongs next to the other v1 routes rather than in
the per-method custom router.

Where the custom router *was* mounted, every request failed instead: the
last commit removed `{method}` from the path but the handler still declared
`Path(method): Path<String>`, so axum rejected before the body ran with
"Wrong number of path arguments for `Path`. Expected 1 but got 0."

Dropping the path parameter left `verify_auth` with nothing to key on, so
NUT-21 gains a `RoutePath::MintQuoteByPubkey` variant rather than borrowing
`MintQuote(method)`. Its `FromStr` arm has to precede the
`/v1/mint/quote/` prefix branch, which would otherwise read this path as a
payment method literally named "pubkey" — there is a round-trip assertion
in `test_route_path_serialization` so a reordering cannot pass silently.
Response. The NUT defines `PostMintQuotesByPubkeyResponse` as
`{"quotes": [<MintQuoteResponse>, ...]}` with the NUT-04 quote objects.
The handler returned `Json(Vec<MintQuoteResponse<QuoteId>>)`, and that enum
is externally tagged, so the body was a bare array whose elements were each
wrapped in a `{"Bolt11": ...}` envelope no NUT describes. The envelope type
now lives in nutxx.rs, and the handler flattens each quote to its inner
response the way `melt_quote_response_to_json` already does in this file.
Each quote carries its own `method` field, so nothing is lost by flattening.

Request. `pubkeys` and `pubkey_signatures` are now `Vec<PublicKey>` and
`Vec<Signature>`. Both already serialise as hex, so the wire format is
unchanged, but the two manual `.parse()` blocks in the handler go away and
malformed input is reported by serde instead of as
`Error::InvalidPaymentRequest`, which read oddly on a lookup endpoint.

Settings. A mint that serves the endpoint has to say so, or wallets cannot
discover it: `"XX": {"supported": <bool>}` in the NUT-06 info response, set
by `MintBuilder` alongside the other supported NUTs. Note this is keyed "XX"
until the NUT is assigned a number, and it reaches the FFI `Nuts` record as
`nutxx_supported` so the round trip stays lossless.

Caveat for existing deployments: `Mint::new` prefers stored mint info and
only merges pubkey/nut21/nut22 from code defaults, so a mint upgrading to
this build keeps its stored settings and will not advertise support until
something rewrites its mint info.
`get_mint_quote_by_public_key` was defined but never added to the
`mint_db_test!` list, so no backend ran it. That is how the missing
`updated_at` column reached this branch: registering the test makes it fail
immediately on the unfixed query. It now runs against both sqlite and
postgres, and also covers quotes owned by another pubkey, several pubkeys at
once, an unknown pubkey, and an empty request.

New coverage for the rest of the feature:
- crates/cdk/tests: signed lookup succeeds; a digest-signed signature is
  refused while a preimage-signed one is accepted; missing or short
  signature arrays are refused; another key's and another mint's signatures
  are refused; oversized requests are refused; support is advertised in
  NUT-06.
- crates/cdk-axum/tests: the route answers with and without custom payment
  methods configured, the body is `{"quotes": [...]}` with flat NUT-04
  quote objects, and an unsigned request does not return 200.

Index naming: `idx_pubkey` was schema-global in both SQLite and Postgres and
did not match the other indexes on this table (`idx_mint_quote_expiry`,
`idx_mint_quote_created_time`, ...). Renamed to `idx_mint_quote_pubkey`. The
migration was also re-dated, having sorted ahead of eleven migrations
already in the tree.
@TheMhv
TheMhv force-pushed the feat/mint_quote_lookup branch from 66077ef to 1c5cf35 Compare August 27, 2026 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

New Nut: Implement get quotes by pubkeys

7 participants