Files
CashuMints.space/docs/KIND-LNURL-MINT.md
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

385 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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": "<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][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: <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.
```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": "<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:
```json
["REQ", "<id>", {"kinds": [38000], "authors": ["<user>", "<contacts…>"], "#k": ["38174"]}]
```
Every review of one specific mint:
```json
["REQ", "<id>", {"kinds": [38000], "#k": ["38174"], "#d": ["021ab8…3555"]}]
```
Announcements, directly:
```json
["REQ", "<id>", {"kinds": [38174]}]
["REQ", "<id>", {"kinds": [38174], "#d": ["021ab8…3555"]}]
```
All three ecosystems in one subscription, which is what an aggregator actually opens:
```json
["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.