Files
michilisandCursor 36c01861f5 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>
2026-08-22 03:44:20 +02:00

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: 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/_

{
  "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:

<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 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:

{"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.