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>
16 KiB
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 (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/_
{
"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: 5000is 5 sat;maxWithdrawable: 999899000is 999,899 sat.nodeCapacityis msat too (30,027,500 sat here). Divide by 1000 for display. maxWithdrawableis fee-adjusted: it isMAX_SENDABLE_MSATminus the mint fee at that amount (router.max_mintable_msat), not the raw setting. LikewiseminWithdrawableisMIN_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'sminSendable/maxSendableare the pay-side numbers, and they differ (11,000 vs 10,000 msat locally, because of the 1000 msat base fee).defaultDescriptionis 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.k1is absent. The model declares itstr | None = None; its absence from the wire is the first proof thatresponse_model_exclude_none = Trueis in force (error_handler.py:26). That matters for everything in §3.
Live response, /.well-known/lnurlp/_
{
"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"
}
metadatais a JSON-encoded string containing a JSON array of[mime, value]pairs — parse twice.text/identifierechoes the username actually queried. Querying_returns_@lnurl.21mint.me; queryingmintreturnsmint@lnurl.21mint.me. So the probe cannot read the operator's real username off the_response. It reads it offpayLinkon the withdraw side instead (…/lnurlp/mint→ usernamemint), which is built fromsettings.usernameunconditionally.- 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
mintPubkeyabsent 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:
- no funding source was ever configured, and
- one was configured but the node is unreachable at this moment (
except Exceptionabove 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-notescapability (signing.mint_pubkeyneeds the same funding source, and the README ties them: "without a funding source, both fields are simply omitted"), and - it is the canonical
didentifier for the Nostr announcement (seedocs/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:
<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:
{"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 inserver.py, not operator-settable.info.version=__version__, resolved from installed package metadata (lnurl_mint/__init__.py), falling back toLNURL_MINT_VERSIONor"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:
{"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 numericminWithdrawable/maxWithdrawable→ online (or degraded, per §3);tag == "payRequest"with numericminSendable/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.