# NIP-87 extension: `kind:38174`, LNURL mint announcements `draft` `optional` > **Status.** This is a proposed extension to [NIP-87][nip87], written to be submitted > as a PR to [nostr-protocol/nips][nips]. It is not part of NIP-87 today. It is > implemented and published by [cashumints.space](https://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](#reviews)). [nip87]: https://github.com/nostr-protocol/nips/blob/master/87.md [nips]: https://github.com/nostr-protocol/nips --- ## 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][lud25]), Lightning bearer notes served over plain [LUD-03][lud03] `withdrawRequest` and [LUD-06][lud06] `payRequest`, implemented by [dni/lnurl-mint][lnurlmint] — 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. [lud25]: https://github.com/lnurl/luds/blob/luds/25.md [lud03]: https://github.com/lnurl/luds/blob/luds/03.md [lud06]: https://github.com/lnurl/luds/blob/luds/06.md [lud16]: https://github.com/lnurl/luds/blob/luds/16.md [lud21]: https://github.com/lnurl/luds/blob/luds/21.md [lnurlmint]: https://github.com/dni/lnurl-mint --- ## 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`][nipsreadme] 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. [nipsreadme]: https://github.com/nostr-protocol/nips/blob/master/README.md --- ## The announcement event LNURL mints SHOULD publish `kind:38174` to announce their capabilities and how to reach them. ```json { "kind": 38174, "pubkey": "", "content": "", "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][lud16]'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`: ```json ["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](#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: ,`, 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. ```json {"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`: ```json { "kind": 38000, "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::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::` 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", ""]` tag with `n` in `1..5`, else 2. a `["rating", ""]` 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: ```json ["REQ", "", {"kinds": [38000], "authors": ["", ""], "#k": ["38174"]}] ``` Every review of one specific mint: ```json ["REQ", "", {"kinds": [38000], "#k": ["38174"], "#d": ["021ab8…3555"]}] ``` Announcements, directly: ```json ["REQ", "", {"kinds": [38174]}] ["REQ", "", {"kinds": [38174], "#d": ["021ab8…3555"]}] ``` All three ecosystems in one subscription, which is what an aggregator actually opens: ```json ["REQ", "", {"kinds": [38172, 38173, 38174]}] ["REQ", "", {"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.