Dev #4

Merged
Michilis merged 4 commits from dev into main 2026-08-22 04:00:47 +00:00
3 changed files with 766 additions and 0 deletions
Showing only changes of commit 36c01861f5 - Show all commits
+350
View File
@@ -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.
+32
View File
@@ -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`:
+384
View File
@@ -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.