Document LNURL mint kind and live probe notes.

Capture the NIP kind contract and wire-level NOTES so indexing and probing
can follow observed LNURL mint behavior rather than outdated assumptions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-22 03:44:20 +02:00
co-authored by Cursor
parent be322cb0d8
commit 36c01861f5
3 changed files with 766 additions and 0 deletions
+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.