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>
385 lines
19 KiB
Markdown
385 lines
19 KiB
Markdown
# 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.
|