Dev #4
+350
@@ -0,0 +1,350 @@
|
||||
# NOTES-LNURL.md
|
||||
|
||||
What the LNURL mint endpoints **actually** return, recorded from the live reference
|
||||
instance `https://lnurl.21mint.me` on 2026-08-21 and from a locally run
|
||||
[dni/lnurl-mint](https://github.com/dni/lnurl-mint) (commit `a70cae1`) used to
|
||||
reproduce the states the live instance does not currently exhibit.
|
||||
|
||||
Same rule as NOTES.md: where the software's README and the wire disagree, the wire
|
||||
wins, and the disagreement is written down rather than quietly resolved.
|
||||
|
||||
---
|
||||
|
||||
## 1. The probe endpoint: `GET /.well-known/lnurlw/{username}`
|
||||
|
||||
**This is the LNURL equivalent of Cashu's `/v1/info`, and it is not the endpoint the
|
||||
brief expected.** Findings, in the order they mattered:
|
||||
|
||||
### `/p` does not exist
|
||||
|
||||
The brief (and older readings of the project) describe `GET /p` as the LUD-06
|
||||
payRequest carrying the `withdrawLink` mint advertisement. It is a **404** on the live
|
||||
instance:
|
||||
|
||||
```
|
||||
$ curl -i https://lnurl.21mint.me/p
|
||||
HTTP/2 404
|
||||
content-type: application/json
|
||||
|
||||
{"detail":"Not Found"}
|
||||
```
|
||||
|
||||
The repository's own README says so explicitly — the LUD-16 well-known alias is
|
||||
"this mint's **only** payRequest entry point (no separate bare `/p`)" — and
|
||||
`router.py` registers no such route. `/p/cb` (the callback) exists; bare `/p` never
|
||||
did. Note the body shape: `{"detail":"Not Found"}` with a real 404 status is FastAPI's
|
||||
*unmatched route* handler. Every **registered** route answers errors as LNURL does
|
||||
instead (see §5), so this shape is a reliable "this software does not have that
|
||||
endpoint" signal.
|
||||
|
||||
### Both well-known aliases answer, and only one carries the limits
|
||||
|
||||
| Endpoint | Carries |
|
||||
| --- | --- |
|
||||
| `GET /.well-known/lnurlp/{username}` | LUD-06 payRequest: `minSendable`/`maxSendable`, `metadata`, `withdrawLink` |
|
||||
| `GET /.well-known/lnurlw/{username}` | LUD-03-shaped withdrawRequest: `minWithdrawable`/`maxWithdrawable`, `defaultDescription`, `mintPubkey`, `payLink`, node identity |
|
||||
|
||||
`{username}` is the configured `USERNAME` (default `mint`) **or** the LUD-16 reserved
|
||||
bare-domain `_`. Both resolve to the identical mint identity; `_` is used as the probe
|
||||
path because it needs no prior knowledge of the operator's chosen username.
|
||||
|
||||
**The chosen primary probe endpoint is `/.well-known/lnurlw/_`**, because it is the one
|
||||
carrying the withdraw limits, the description, and the mint's node identity — every
|
||||
field the site renders. `/.well-known/lnurlp/_` is fetched as a **fallback only**, when
|
||||
the withdraw side does not answer.
|
||||
|
||||
### Live response, `/.well-known/lnurlw/_`
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "withdrawRequest",
|
||||
"callback": "https://lnurl.21mint.me/w",
|
||||
"minWithdrawable": 5000,
|
||||
"maxWithdrawable": 999899000,
|
||||
"defaultDescription": "lnurlcash bearer note on lnurl.21mint.me",
|
||||
"mintPubkey": "021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555",
|
||||
"payLink": "https://lnurl.21mint.me/.well-known/lnurlp/mint",
|
||||
"nodeAlias": "Azzamo",
|
||||
"nodeUri": "021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555@145.239.92.138:9736",
|
||||
"nodeColor": "#68f442",
|
||||
"nodeCapacity": 30027500000,
|
||||
"nodeNumChannels": 8,
|
||||
"nodeNumPeers": 18
|
||||
}
|
||||
```
|
||||
|
||||
Headers: `server: nginx/1.22.1`, `content-type: application/json`. **No version header
|
||||
of any kind**, on this or any other endpoint.
|
||||
|
||||
Notes on the fields:
|
||||
|
||||
- **All amounts are millisatoshi.** `minWithdrawable: 5000` is 5 sat;
|
||||
`maxWithdrawable: 999899000` is 999,899 sat. `nodeCapacity` is msat too
|
||||
(30,027,500 sat here). Divide by 1000 for display.
|
||||
- `maxWithdrawable` is **fee-adjusted**: it is `MAX_SENDABLE_MSAT` minus the mint fee
|
||||
at that amount (`router.max_mintable_msat`), not the raw setting. Likewise
|
||||
`minWithdrawable` is `MIN_MINT_MSAT`. So these two numbers are the bounds a freshly
|
||||
minted note's *value* can fall into — which is exactly what a reader wants — and not
|
||||
the bounds of what they must *pay*. The payRequest's `minSendable`/`maxSendable` are
|
||||
the pay-side numbers, and they differ (11,000 vs 10,000 msat locally, because of the
|
||||
1000 msat base fee).
|
||||
- `defaultDescription` is always `"lnurlcash bearer note on {host}"` — generated, never
|
||||
operator-written. Useful as a description fallback and nothing more; it says what the
|
||||
software is, not what this mint is.
|
||||
- **`k1` is absent.** The model declares it `str | None = None`; its absence from the
|
||||
wire is the first proof that `response_model_exclude_none = True` is in force
|
||||
(`error_handler.py:26`). That matters for everything in §3.
|
||||
|
||||
### Live response, `/.well-known/lnurlp/_`
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "payRequest",
|
||||
"callback": "https://lnurl.21mint.me/p/cb",
|
||||
"minSendable": 6000,
|
||||
"maxSendable": 1000000000,
|
||||
"metadata": "[[\"text/plain\", \"Mint an lnurlcash bearer note on lnurl.21mint.me\"], [\"text/identifier\", \"_@lnurl.21mint.me\"], [\"text/plain\", \"Mint fees: 1000,100\"]]",
|
||||
"withdrawLink": "https://lnurl.21mint.me/w"
|
||||
}
|
||||
```
|
||||
|
||||
- `metadata` is a **JSON-encoded string containing a JSON array** of `[mime, value]`
|
||||
pairs — parse twice.
|
||||
- `text/identifier` **echoes the username actually queried**. Querying `_` returns
|
||||
`_@lnurl.21mint.me`; querying `mint` returns `mint@lnurl.21mint.me`. So the probe
|
||||
cannot read the operator's real username off the `_` response. It reads it off
|
||||
`payLink` on the withdraw side instead (`…/lnurlp/mint` → username `mint`), which is
|
||||
built from `settings.username` unconditionally.
|
||||
- The `Mint fees: <base_msat>,<ppm>` entry is present only when a fee is configured;
|
||||
its absence means fee-free, per spec.
|
||||
|
||||
---
|
||||
|
||||
## 2. Every other endpoint, and what it is worth to a probe
|
||||
|
||||
| Endpoint | Live result | Verdict |
|
||||
| --- | --- | --- |
|
||||
| `GET /` | 200, 13,804 bytes of HTML, `<title>21 lnurl-mint</title>` | Fetched **only** for the onion address (§4) |
|
||||
| `GET /openapi.json` | 200, `info.version` = `"0.1.0"`, `info.title` = `"lnurl-mint"` | **The only version source there is** (§6) |
|
||||
| `GET /docs` | 200, Swagger UI | Ignored |
|
||||
| `GET /w` (no `k1`) | 200 `{"status":"ERROR","reason":"Request validation error: Field required \`k1\`."}` | Ignored — never a mint advertisement |
|
||||
| `GET /w?k1=deadbeef` | 200 `{"status":"ERROR","reason":"Unknown note."}` | Ignored — per-note, not per-mint |
|
||||
| `GET /verify/{hash}` | 200 `{"status":"ERROR","reason":"Not found"}` | **Useless as a capability probe** (§5) |
|
||||
| `GET /p` | 404 `{"detail":"Not Found"}` | Does not exist |
|
||||
|
||||
The site probes **`/.well-known/lnurlw/_`, then `/.well-known/lnurlp/_` on failure, and
|
||||
`GET /openapi.json` + `GET /` opportunistically**. Nothing else is fetched, and nothing
|
||||
that mutates state is ever called — `/p/cb` would make the mint issue a real invoice on
|
||||
every probe cycle, which is not a thing a directory gets to do to a stranger's node.
|
||||
|
||||
---
|
||||
|
||||
## 3. The degraded state: no funding source (**empirically reproduced**)
|
||||
|
||||
The README: *"Without one, minting and melting are unavailable (rotate/split/merge of
|
||||
existing notes still work)."* The question was whether that is visible over HTTP. It is.
|
||||
|
||||
`_mint_address_response` (`router.py:480`) only populates the node fields
|
||||
`if funding_source.backend:`, and wraps the lookup in `try/except` that logs and leaves
|
||||
them `None` on failure. With `response_model_exclude_none = True`, `None` fields are
|
||||
**omitted from the wire entirely**.
|
||||
|
||||
Run locally with no `FUNDINGSOURCE_*` set at all:
|
||||
|
||||
```
|
||||
$ curl http://127.0.0.1:8137/.well-known/lnurlw/_
|
||||
{"tag":"withdrawRequest","callback":"https://lnurl.test/w","minWithdrawable":10000,
|
||||
"maxWithdrawable":999999000,"defaultDescription":"lnurlcash bearer note on lnurl.test",
|
||||
"payLink":"https://lnurl.test/.well-known/lnurlp/mint"}
|
||||
```
|
||||
|
||||
`mintPubkey`, `nodeAlias`, `nodeUri`, `nodeColor`, `nodeCapacity`, `nodeNumChannels`
|
||||
and `nodeNumPeers` are **all gone**. The response is otherwise a completely valid
|
||||
withdrawRequest with real limits — the mint is up, serving, and answering. Its startup
|
||||
log says the rest out loud:
|
||||
|
||||
```
|
||||
WARNING:root:No funding source configured (FUNDINGSOURCE_BACKEND unset) -
|
||||
minting, melting, and offline verification are all unavailable.
|
||||
```
|
||||
|
||||
`/.well-known/lnurlp/_` **still answers normally** in this state (200, full payRequest),
|
||||
so the pay side is *not* a degraded-state signal — only the withdraw side is.
|
||||
|
||||
### The detection rule, and the honest thing to say about it
|
||||
|
||||
> **`mintPubkey` absent from a valid mint-address response ⇒ no funding source is
|
||||
> reachable ⇒ minting and melting are unavailable right now.**
|
||||
|
||||
One bit, and it necessarily conflates two causes:
|
||||
|
||||
1. no funding source was ever configured, and
|
||||
2. one was configured but the node is unreachable at this moment (`except Exception`
|
||||
above catches it and returns the same reduced response).
|
||||
|
||||
They are **not distinguishable over HTTP**, and the site does not try. It does not need
|
||||
to: the user-facing consequence is identical in both cases — nothing moves in or out
|
||||
over Lightning until it comes back — and that is exactly what the warning says. Calling
|
||||
it "no funding source" specifically would be a guess; calling it "minting and melting
|
||||
unavailable" is the observation.
|
||||
|
||||
The same bit also carries two other meanings, which is worth stating plainly because
|
||||
three site features read it:
|
||||
|
||||
- it is the `signed-notes` capability (`signing.mint_pubkey` needs the same funding
|
||||
source, and the README ties them: *"without a funding source, both fields are simply
|
||||
omitted"*), and
|
||||
- it is the canonical `d` identifier for the Nostr announcement (see
|
||||
`docs/KIND-LNURL-MINT.md`).
|
||||
|
||||
That last one is why the `d` rule has to be sticky: a mint indexed while its node was
|
||||
up must not silently change identity because its node had a bad minute. See the doc.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tor / onion: HTML only, and only over clearnet
|
||||
|
||||
`ONION_URL` is **never** in any JSON response. It appears in exactly one place: the
|
||||
one-pager's "Also via Tor" block (`frontend._tor_section`), and only when the request
|
||||
did *not* arrive over the onion host itself (otherwise `public_base_url` already made
|
||||
the onion the primary and the block would be a duplicate). A clearnet probe is
|
||||
therefore the case where it does show.
|
||||
|
||||
Reproduced locally with `ONION_URL` set:
|
||||
|
||||
```html
|
||||
<h2>Also via Tor</h2>
|
||||
<div class="qr">…</div>
|
||||
<button class="copy" data-copy="LNURL1…" title="Copy LNURL">LNURL1…</button>
|
||||
<button class="copy" data-copy="mint@abcdefgh….onion" title="Copy lightning address">⚡ mint@…onion</button>
|
||||
```
|
||||
|
||||
Cheaply parseable: scan the `GET /` body for a `*.onion` host. The live instance
|
||||
advertises none, so `onion` is absent there.
|
||||
|
||||
**Caveat worth recording:** the fixture address in lnurl-mint's own tests
|
||||
(`abcdefghijklmnop1234567890abcdefghijklmnop1234567890abcdefgh.onion`) contains
|
||||
`0`, `1`, `8` and `9`, which are **not in base32's alphabet** — it is not a valid v3
|
||||
address. A strict `[a-z2-7]{56}` matcher rejects it. The site's matcher is deliberately
|
||||
looser (`[a-z0-9]{16,60}\.onion`) so that a real-but-unusual address is still shown;
|
||||
nothing is fetched over Tor, so a wrong match costs a displayed string and nothing more.
|
||||
|
||||
---
|
||||
|
||||
## 5. LUD-21 verify is **not** probe-detectable (and why the site does not pretend)
|
||||
|
||||
`VERIFY_ENABLED=false` makes `/verify/{payment_hash}` raise `HTTPException(404, "Not
|
||||
found")`. An *enabled* endpoint asked about a payment hash it has never seen raises
|
||||
`HTTPException(404, "Not found")` too. Both then pass through
|
||||
`LnurlErrorResponseHandler`, which converts any `HTTPException` into
|
||||
`200 {"status": "ERROR", "reason": <detail>}`.
|
||||
|
||||
Measured, same binary, both settings:
|
||||
|
||||
```
|
||||
VERIFY_ENABLED=true → 200 {"status":"ERROR","reason":"Not found"}
|
||||
VERIFY_ENABLED=false → 200 {"status":"ERROR","reason":"Not found"}
|
||||
```
|
||||
|
||||
**Byte-identical.** There is no status code, no header, and no reason string that
|
||||
separates them.
|
||||
|
||||
The only place a `verify` URL is genuinely advertised is the response of `/p/cb` — the
|
||||
pay callback — and calling that **creates a real Lightning invoice on the operator's
|
||||
node**. Doing that every ten minutes, forever, to every mint in the index, is not
|
||||
acceptable behaviour for a directory.
|
||||
|
||||
**Resolution:** `lud21` is an **announcement-only** capability. The probe never sets it
|
||||
and never clears it. If an operator declares it in their `features` tag, the site shows
|
||||
it; otherwise the row is simply absent. This is written into
|
||||
`docs/KIND-LNURL-MINT.md` as a normative property of the vocabulary, not left as an
|
||||
implementation quirk.
|
||||
|
||||
---
|
||||
|
||||
## 6. Version: `/openapi.json`, and only there
|
||||
|
||||
No `Server`, `X-Powered-By` or any other header identifies the software — nginx fronts
|
||||
it and reports only itself. The one-pager's `<title>` is operator-configurable
|
||||
(`settings.title`; the live instance sets `"21 lnurl-mint"`), so it identifies nothing
|
||||
reliably.
|
||||
|
||||
FastAPI's generated `GET /openapi.json` carries both:
|
||||
|
||||
```json
|
||||
{"info": {"title": "lnurl-mint",
|
||||
"description": "Minimal lnurlcash (LUD-25, Lightning bearer assets) mint - LUD-03/LUD-06 only.",
|
||||
"version": "0.1.0"}}
|
||||
```
|
||||
|
||||
- `info.title` = `"lnurl-mint"` — a genuine software fingerprint, hardcoded in
|
||||
`server.py`, not operator-settable.
|
||||
- `info.version` = `__version__`, resolved from installed package metadata
|
||||
(`lnurl_mint/__init__.py`), falling back to `LNURL_MINT_VERSION` or
|
||||
`"0.0.0+unknown"`.
|
||||
|
||||
Two observed values: the live instance reports `0.1.0`; a source checkout with no
|
||||
installed metadata reports `0.0.0+unknown`. The site stores `lnurl-mint/<version>`,
|
||||
and treats a `0.0.0+unknown` as **no version** rather than displaying it — it is the
|
||||
library's "I don't know" sentinel, not a release.
|
||||
|
||||
The live instance still serves `/docs` from a jsdelivr CDN, whereas HEAD (`a70cae1`,
|
||||
*"report real version in openapi, vendor swagger ui for /docs"*) vendors it. So the
|
||||
deployed build predates that commit, and its `0.1.0` is the pre-commit hardcoded value.
|
||||
Recorded because it means **a version string from this endpoint is a weak signal**: it
|
||||
was not always wired to the real package version.
|
||||
|
||||
---
|
||||
|
||||
## 7. Error convention, and what "online" must therefore mean
|
||||
|
||||
Every **registered** route returns HTTP **200** for its errors, with an LNURL body:
|
||||
|
||||
```json
|
||||
{"status": "ERROR", "reason": "…"}
|
||||
```
|
||||
|
||||
Only an **unregistered path** gives a real 404 (`{"detail":"Not Found"}`).
|
||||
|
||||
Consequence, and it is the single most important parsing rule here: **HTTP 200 does not
|
||||
mean the mint is up.** A probe that stopped at the status code would call every
|
||||
misconfigured host, every `Unknown user.`, and every wrong-path hit "online".
|
||||
|
||||
So the site's online test is on the parsed body, never the status:
|
||||
|
||||
- `tag == "withdrawRequest"` **and** numeric `minWithdrawable`/`maxWithdrawable` → online
|
||||
(or degraded, per §3);
|
||||
- `tag == "payRequest"` with numeric `minSendable`/`maxSendable` (fallback endpoint) → online;
|
||||
- anything else that parses as JSON — including `{"status":"ERROR"}` — → **invalid**, a
|
||||
distinct third outcome that is neither online nor offline. It renders the
|
||||
"Endpoint responding but invalid" banner, because a host that answers with something
|
||||
that is not a mint advertisement is a different problem from a host that does not
|
||||
answer at all.
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary of what the endpoints expose beyond the withdraw params
|
||||
|
||||
Everything below is real, observed, and stored in `ecosystem_json`:
|
||||
|
||||
| Field | Source | Used for |
|
||||
| --- | --- | --- |
|
||||
| `mintPubkey` | lnurlw | `d` identifier, `signed-notes` feature, degraded detection, sidebar row |
|
||||
| `payLink` | lnurlw | Deriving the LUD-16 lightning address (`{username}@{host}`) |
|
||||
| `nodeAlias` | lnurlw | Identity header fallback name |
|
||||
| `nodeUri` | lnurlw | `pubkey@host:port` — the sidebar's node connect string |
|
||||
| `nodeColor` | lnurlw | Not rendered (a node's own colour is not this site's palette) |
|
||||
| `nodeCapacity` | lnurlw | msat, publicly announced channel capacity — sidebar |
|
||||
| `nodeNumChannels` / `nodeNumPeers` | lnurlw | Sidebar |
|
||||
| `defaultDescription` | lnurlw | Description fallback |
|
||||
| `minSendable` / `maxSendable` | lnurlp | Pay-side limits, distinct from withdraw limits |
|
||||
| `metadata` → `text/plain` | lnurlp | Description fallback, ranked above `defaultDescription` |
|
||||
| `metadata` → `text/identifier` | lnurlp | Confirms the lightning address |
|
||||
| `metadata` → `Mint fees:` | lnurlp | Mint fee disclosure (base msat, ppm) |
|
||||
| `info.version` / `info.title` | openapi | Software cell |
|
||||
| `*.onion` | `GET /` HTML | `onion` feature, sidebar row |
|
||||
|
||||
`nodeCapacity` is explicitly the **publicly announced** capacity only — lnurl-mint
|
||||
sources it from the public graph (`lnd GetNodeInfo` / `cln listchannels`), never from a
|
||||
private `listfunds`/`ListChannels` view. Displaying it discloses nothing the node's own
|
||||
gossip does not already.
|
||||
@@ -130,6 +130,38 @@ are matched by bare domain (too loose, a review of `mint.minibits.cash/Bitcoin`
|
||||
`mint.minibits.cash/Anything`). The new backend fixes both directions: one normalizer, path is part
|
||||
of identity, applied to mints and reviews alike (see `api/src/normalize.ts`).
|
||||
|
||||
### The slug is deterministic, not reversible
|
||||
|
||||
Found while building on-demand indexing (`POST /api/index`), which is the first thing that
|
||||
has to turn a *page address* back into a *mint address* rather than the other way round.
|
||||
|
||||
`mint.example.com/Bitcoin` slugs to `mint.example.com-bitcoin`, and so would a mint whose
|
||||
hostname really is `mint.example.com-bitcoin`. Looking a row up is unaffected — both
|
||||
spellings are stored, and the slug column is what the lookup uses — but *indexing* from a
|
||||
slug means opening a socket to whatever it decodes to, and guessing wrong there means
|
||||
probing, and possibly listing, somebody else's server.
|
||||
|
||||
So `addressFromSlug` (shared/src/indexing.ts) only derives an address from the one
|
||||
unambiguous shape: a plain hostname, optionally with the `-3338` port suffix. Anything
|
||||
else returns null and the 404 page says it cannot work the address out from the link
|
||||
alone, offering the dialog where a reader can paste the full URL including its path.
|
||||
The `lnurl-` collision prefix is deliberately *not* stripped there either: a slug only
|
||||
takes that prefix at insert time, so a link carrying one describes a row that already
|
||||
exists and never reaches that code, while a hostname that merely begins `lnurl-` is a
|
||||
real possibility.
|
||||
|
||||
Two URL shapes that fought the normalizer while testing this, both now covered by
|
||||
`api/src/check-index.ts`:
|
||||
|
||||
- **The port suffix round trip.** `https://smilemoji.cash:3338` slugs to
|
||||
`smilemoji.cash-3338`, which reads as a hostname with a trailing number until you know
|
||||
the rule. The derivation has to put the colon back, and only for a 2 to 5 digit tail.
|
||||
- **Case, scheme and trailing slash together.** `MINT.600.WTF`, `http://mint.600.wtf`,
|
||||
`mint.600.wtf/` and `https://mint.600.wtf:443/` are one mint, and the endpoint has to
|
||||
answer "already indexed" for all four rather than creating a second row. They collapse
|
||||
in `normalizeMintUrl`, before the row is looked up and before the in-flight map is
|
||||
keyed, which is what makes the dedup and the lookup agree.
|
||||
|
||||
## Old scoring
|
||||
|
||||
`src/hooks/usePopularMints.ts`:
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user