Files
CashuMints.space/docs/KIND-LNURL-MINT.md
T
michilisandCursor 36c01861f5 Document LNURL mint kind and live probe notes.
Capture the NIP kind contract and wire-level NOTES so indexing and probing
can follow observed LNURL mint behavior rather than outdated assumptions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:20 +02:00

19 KiB
Raw Blame History

NIP-87 extension: kind:38174, LNURL mint announcements

draft optional

Status. This is a proposed extension to NIP-87, written to be submitted as a PR to nostr-protocol/nips. It is not part of NIP-87 today. It is implemented and published by cashumints.space, which is also where the reference indexer for it lives. Nothing here changes 38172, 38173 or 38000; a client that implements only NIP-87 as written keeps working unchanged, and gets partial support for these events for free (see Reviews).


Rationale

NIP-87 gives ecash mints a way to be discovered, and gives users a way to recommend them: an operator publishes an addressable announcement, a user publishes a kind:38000 naming the announcement's kind in a k tag, and a client queries by that k to find recommendations for one ecosystem without seeing the others.

It covers exactly two ecosystems, one kind each: 38172 for Cashu, 38173 for Fedimint. A third has since shipped — lnurlcash (LUD-25), Lightning bearer notes served over plain LUD-03 withdrawRequest and LUD-06 payRequest, implemented by dni/lnurl-mint — and it has no kind. The result today is that an lnurlcash mint either goes unannounced or is announced as something it is not.

The pattern generalises cleanly, and this document does nothing but apply it:

  • an LNURL mint has a stable identifier (its funding node's public key) → d
  • it has an address you connect to (its https base URL) → u
  • it runs on a network → n
  • it has a capability list → features, the analogue of nuts and modules

The one thing genuinely new here is the capability vocabulary, because Cashu's NUT numbers and Fedimint's module names have no LNURL equivalent to borrow. It is defined in full below, and every value in it is grounded in a specific code path in lnurl-mint, because a vocabulary that outruns any implementation is a wish list.


Choosing the kind number

38174 — the next free slot in the NIP-87 family.

Checked before claiming it, on 2026-08-21:

Source Result
nips/README.md kind index 38172 and 38173 listed; 38174 absent, and nothing else in 38100–38199 is assigned
GitHub search over nostr-protocol/nips, issues and PRs, for 38174 0 results (open or closed)

38174 is in the addressable range (30000 ≤ kind < 40000), which is required: an announcement must be replaceable per (pubkey, kind, d) so an operator can update their capability list in place, exactly as 38172 and 38173 are.

If 38174 is claimed before this is merged, take the next free kind in the same range, prefer keeping the family contiguous, and record the collision and the replacement here — implementations read the kind from one constant (KIND_LNURL_ANNOUNCEMENT) precisely so that this is a one-line change.


The announcement event

LNURL mints SHOULD publish kind:38174 to announce their capabilities and how to reach them.

{
  "kind": 38174,
  "pubkey": "<publisher-pubkey>",
  "content": "<optional-kind:0-style-metadata>",
  "tags": [
    ["d", "021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555"],
    ["u", "https://lnurl.21mint.me"],
    ["features", "mint,melt,rotate,split,merge,lud06,lud03,lud16,lud21,signed-notes"],
    ["n", "mainnet"]
  ]
}

d — the identifier

The d tag MUST be one of the following, in this order of preference:

  1. The mint's mintPubkey, lowercase hex, when the mint advertises one. This is the funding node's own identity key — a 33-byte compressed secp256k1 public key, 66 hex characters, beginning 02 or 03 — exposed on the mint-address endpoint whenever a funding source is configured, and the same key the mint signs notes with under LUD-25 offline verification.
  2. The normalized host, when it does not: the lowercase hostname, plus :port if non-default, plus the base path with no trailing slash, and no scheme. For https://mint.example.com/lnurl/ that is mint.example.com/lnurl.

The two forms are unambiguous by construction: form 1 is always exactly 66 hex characters, form 2 always contains a . and never matches ^[0-9a-f]{66}$.

When a mint gains a pubkey after being announced by host

This is not hypothetical. mintPubkey is present only while a funding source is configured and reachable; a mint run without one — or announced during an outage — legitimately has no pubkey to publish, and gains one later.

The rules, which exist so that identity never silently forks:

  • Prefer the pubkey once it exists. A publisher that has been announcing by host and learns a mintPubkey SHOULD begin publishing under the pubkey d.
  • The identifier is sticky. Once a mint has been announced under a pubkey d, a publisher MUST NOT revert to the host form because the node happened to be unreachable at publish time. Absence of mintPubkey in one response is a statement about the node's availability in that instant, not about the mint's identity.
  • The old announcement SHOULD be left in place, not deleted. It is addressable, so it stays queryable, and reviews already pointing at the host d keep resolving. Publishers MAY additionally re-publish the host-d event with the same u tag so both identifiers lead to the same live address.
  • Consumers MUST accept either. An indexer resolving a review's d SHOULD try, in order: exact match on a known mintPubkey; exact match on a known normalized host; then fall back to the u tag. It MUST NOT treat the two identifiers for one mint as two mints — deduplication is by normalized base URL (u), which is the one value present in every form of the event.

u — the address

The https base URL of the mint, with no trailing slash: https://lnurl.21mint.me.

u is REQUIRED in this kind, unlike in 38172 where it is a SHOULD. An LNURL mint's d may be a bare host string with no scheme, which is not fetchable, so without u there would be events carrying no usable address at all. Multiple u tags MAY appear; the first is canonical. .onion addresses MUST NOT be the canonical u — they belong in features as onion and are discovered from the mint itself.

Everything a client needs hangs off this base:

Path Role
{u}/.well-known/lnurlw/_ mint advertisement: withdraw limits, mintPubkey, node identity
{u}/.well-known/lnurlp/_ LUD-06 payRequest (also {u}/.well-known/lnurlp/{username})
{u}/w?k1=… LUD-03 withdrawRequest for one note
{u}/w/cb melt / rotate / split / merge
{u}/p/cb LUD-06 callback, mints a note
{u}/verify/{payment_hash} LUD-21, when lud21 is advertised

_ is LUD-16's reserved bare-domain username and reaches the same identity as the operator's configured username, so a consumer can probe without knowing it.

n — the network

Same values and same meaning as NIP-87: mainnet, testnet, signet or regtest. Absent reads as mainnet. Consumers SHOULD accept bitcoin as a synonym for mainnet, because every Fedimint announcement in the wild writes it and publishers copy each other.

features — the capability list

One tag, one comma-separated list of lowercase tokens, the direct analogue of Cashu's nuts and Fedimint's modules:

["features", "mint,melt,rotate,split,merge,lud06,lud03,lud16,lud21,signed-notes"]

Consumers MUST tolerate whitespace after commas, MUST lowercase before comparing, and MUST ignore tokens they do not recognise rather than rejecting the event — that is what lets this vocabulary grow without a new kind.

The vocabulary

Every value below names something lnurl-mint actually implements. The "Grounded in" column is the specific thing that makes the claim checkable.

Note operations — the four branches of the LUD-25 callback GET /w/cb, plus minting. These are what the mint does.

Value Meaning Grounded in
mint A new bearer note can be created by paying a Lightning invoice; the payment preimage becomes the note. router.get_pay_callback (/p/cb). Requires a funding source — it issues the invoice. Also refused outright while SUNSET_MINT is on.
melt A note can be redeemed back to a BOLT-11 payment. /w/cb with a pr parameter. Requires a funding source — it pays the invoice.
rotate A note's secret can be replaced by one the holder generates, without changing its value. /w/cb with neither pr nor amount; the holder supplies h = sha256(new secret). No funding source needed.
split One note becomes two, of a chosen amount and the remainder. /w/cb with amount; holder supplies h and h2. No funding source needed. Refused while SUNSET_MINT is on, since it grows outstanding liability.
merge Several notes become one worth their sum. /w/cb with multiple k1. No funding source needed.

The mint/melt versus rotate/split/merge division is not cosmetic: it is exactly the line a missing funding source falls along. Without one the service still runs and the last three still work, while nothing moves in or out over Lightning. That is a real, distinct state, and consumers should be able to render it — see Availability is not capability.

LNURL sub-specifications — the wire formats spoken, so a wallet knows what to expect.

Value Meaning Grounded in
lud06 Serves a LUD-06 payRequest and its callback. router.get_lnaddress returns tag: "payRequest"; /p/cb returns pr.
lud03 Serves a LUD-03 withdrawRequest per note, informational, never burning. router.get_withdraw (GET /w?k1=). Advertised as callback on the mint-address response.
lud16 Payable at a lightning address, {username}@{host} and the bare-domain _@{host}. /.well-known/lnurlp/{username}, with text/identifier in the payRequest metadata.
lud21 Serves GET /verify/{payment_hash} so a wallet with no node can poll settlement. router.verify_invoice, gated on VERIFY_ENABLED. Announcement-only — see below.

Optional extras.

Value Meaning Grounded in
signed-notes The mint advertises a mintPubkey and returns recoverable signatures over each new note, so a holder can verify issuer and amount offline. signing.mint_pubkey / signing.sign_note; sig/sig2 on the /w/cb response. Requires a funding source (it signs via the node's signmessage).
onion The mint also answers on a Tor hidden service. ONION_URL; advertised in the one-pager's "Also via Tor" block, and used as the callback base when a request arrives over it.

Not in the vocabulary, deliberately: anything about fees (they are already disclosed in the payRequest metadata as Mint fees: <base_msat>,<ppm>, which is authoritative and live), anything about the mint's balance or liability, and anything a consumer would have to take on trust with no way to check.

Availability is not capability

features says what the mint implements. It does not say what is working right now. A mint whose funding node is unreachable still implements mint and melt; it just cannot perform them this minute.

Consumers that probe SHOULD present these as two different things. The concrete, checkable signal, for lnurl-mint: if a mint-address response omits mintPubkey while still returning valid minWithdrawable/maxWithdrawable, the funding source is not reachable, and mint, melt and signed-notes are unavailable until it is — while rotate, split and merge continue to work normally.

Consumers MUST NOT rewrite the announcement's features from a probe. The announcement is the operator's statement of what they built; the probe is an observation about a moment.

What a prober may and may not conclude

This matters enough to be normative, because two of these are counter-intuitive and one is a trap.

Value Probe-detectable? How
lud06, lud16 Yes /.well-known/lnurlp/_ returns tag: "payRequest"; payLink on the withdraw side names the address.
lud03 Yes The mint-address response's callback points at /w.
signed-notes Yes mintPubkey present on the mint-address response.
onion Yes A *.onion host in the one-pager at GET /. Never in any JSON.
mint, melt Partly Implementation cannot be probed without minting; availability is the mintPubkey bit above.
rotate, split, merge No Only /w/cb proves them, and calling it mutates or destroys a stranger's note.
lud21 No — and this is the trap See below.

lud21 cannot be probed. VERIFY_ENABLED=false makes /verify/{hash} raise a 404 with detail "Not found". An enabled endpoint asked about an unknown payment hash raises a 404 with detail "Not found" too. Both are then converted by the LNURL error handler into an identical 200 {"status":"ERROR","reason":"Not found"}. The two states are byte-identical on the wire — verified against a live instance and a local build of both configurations. The only genuine advertisement of a verify URL is in the response to /p/cb, and calling that creates a real invoice on the operator's node, which no directory should be doing on a timer.

So lud21 is announcement-only: a prober MUST NOT set it, and MUST NOT clear it either.

content

Optional, and exactly NIP-87's rule: a stringified kind-0-style metadata object (name, picture, about, …). If content is empty, consumers should fall back to the kind:0 of the event's pubkey for the mint's display information.

{"name": "21 Mint", "picture": "https://lnurl.21mint.me/icon.png", "about": "lnurlcash bearer notes, mainnet."}

Consumers MUST treat content as untrusted text written by anyone: unparseable JSON, wrong types and hostile strings degrade to "no metadata", never to an error and never to unescaped output.


Reviews

There is no new review kind. A review of an LNURL mint is a plain NIP-87 kind:38000 whose k tag names 38174:

{
  "kind": 38000,
  "pubkey": "<reviewer-pubkey>",
  "content": "[5/5] Rotations are instant and melts have never failed me.",
  "tags": [
    ["k", "38174"],
    ["d", "021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555"],
    ["u", "https://lnurl.21mint.me", "lnurl"],
    ["a", "38174:<announcer-pubkey>:021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555", "wss://relay.cashumints.space"]
  ]
}

Reusing 38000 is the whole point of doing it this way. An existing NIP-87 client that knows nothing about 38174 still sees a well-formed recommendation event from an author it follows, still reads the rating and the text, and still knows it is not about Cashu or Fedimint because the k tag says a kind it does not recognise. It half-understands these reviews for free, and degrades to ignoring them rather than to misfiling them.

  • k — REQUIRED, "38174". This is what separates an LNURL review from a Cashu one, and it is load-bearing: a Cashu mint's pubkey and an LNURL mint's mintPubkey are both 66-hex-character d values, so a resolver keying on d alone could file one as the other.
  • d — the announcement's identifier, per the d rules above. As NIP-87 says, this can be computed from the mint's own pubkey even when no announcement event exists.
  • u — OPTIONAL, the mint's https base URL. A third element MAY carry the free-form marker lnurl, matching how NIP-87's own example marks cashu and fedimint.
  • a — OPTIONAL, 38174:<announcer-pubkey>:<d> plus a relay hint, pointing at the announcement event itself.

Rating

Ratings follow this site's existing convention (NOTES.md), unchanged across all three ecosystems, because a reader comparing a Cashu mint to an LNURL one must be comparing the same scale:

  1. a ["rating", "<n>"] tag with n in 1..5, else
  2. a ["rating", "<f>"] tag with f in 0..1, scaled to 1..5 (NIP-87 suggests this form), else
  3. an [N/5] prefix on content, which is how the great majority of real reviews on the network encode it.

Unparseable means no rating, and the review is excluded from averages. It is never defaulted to 5.


Query patterns

Recommendations for LNURL mints from people a user follows:

["REQ", "<id>", {"kinds": [38000], "authors": ["<user>", "<contacts…>"], "#k": ["38174"]}]

Every review of one specific mint:

["REQ", "<id>", {"kinds": [38000], "#k": ["38174"], "#d": ["021ab8…3555"]}]

Announcements, directly:

["REQ", "<id>", {"kinds": [38174]}]
["REQ", "<id>", {"kinds": [38174], "#d": ["021ab8…3555"]}]

All three ecosystems in one subscription, which is what an aggregator actually opens:

["REQ", "<id>", {"kinds": [38172, 38173, 38174]}]
["REQ", "<id>", {"kinds": [38000], "#k": ["38172", "38173", "38174"]}]

As NIP-87 already warns for 38172/38173: querying announcements directly, with no web of trust between the reader and the publisher, will surface whatever anyone chose to publish. Clients doing that SHOULD apply spam prevention or restrict to relays they trust. Nothing about an announcement is evidence that the mint behind it is honest, or even that it exists.


Reference implementation

  • Constant and parse rules — shared/src/nostr.ts (KIND_LNURL_ANNOUNCEMENT, ANNOUNCEMENT_KINDS), shared/src/lnurl.ts (vocabulary, d rules, announcement parser).
  • Discovery and review resolution — api/src/discovery.ts.
  • Probing — api/src/lnurl-probe.ts. What it found on a live instance, and every ambiguity it had to resolve, is recorded in NOTES-LNURL.md.
  • Publishing 38174 — api/src/announce.ts. Off by default; see the README section "Publishing LNURL mint announcements" before turning it on, because it writes to public relays.

The implementation and this document are meant to be checked against each other: api/src/check-lnurl.ts asserts the d rules, the vocabulary, the tag shapes and the round trip described here, so a change to one that is not a change to the other fails the test suite.