Minimal backend implementing lnurlcash (LUD-XX), Lightning bearer
assets on top of plain LUD-03 withdrawRequest and
LUD-06 payRequest. A stripped-down sibling of
lnurl_server; nothing but the mint.
A bearer note is a k1 this mint has credited with value. It is minted by paying a
LUD-06 invoice (the payment preimage is the note), circulates offline as
lnurlw://<host>/w?k1=<k1>, and can be rotated, split, merged, or melted back
to a BOLT-11 payment.
| Endpoint | Role |
|---|---|
GET / |
one-pager frontend: mint QR code (LNURL of /p), lightning address, node info |
GET /p |
LUD-06 payRequest, extended with withdrawLink (the mint advertisement) |
GET /p/cb |
LUD-06 callback, invoice whose preimage becomes a note once paid |
GET /verify/{payment_hash} |
LUD-21, settlement status for an invoice minted via /p/cb |
GET /w |
LUD-03 withdrawRequest for a note (?k1=), informational, never burns |
GET /w/cb |
the mutating callback: melt (pr), rotate, split (amount), merge (many k1) |
GET /.well-known/lnurlp/{username} |
LUD-16 alias for /p, the mint is payable at {USERNAME}@{BASE_URL host} |
Callback semantics (/w/cb):
k1 |
pr |
amount |
Result |
|---|---|---|---|
| one | yes | – | melt: note reserved, pr (of exactly its value) paid, burned once settled |
| one | no | no | rotate: burned, fresh k1' of the same value returned |
| many | no | yes | split: all burned, response carries k1 (amount) + change |
| many | no | – | merge: all burned, one note worth the sum returned |
pr MUST NOT be combined with multiple k1s or with amount, melt several notes
by merging them first. The informational endpoint's response always echoes the
literal secret it was queried with (never a derived id), and ignores an amount
query param if present, notes may encode a wallet-declared value in their URL
(?k1=...&amount=...) for offline display, but it is never authoritative;
maxWithdrawable is.
Per the spec, a melted k1 MUST NOT be burned until its outgoing payment
actually settles, so while a payment is in flight its note is only reserved
(pending), not yet burned - any other callback naming that k1 (another
melt, a rotate, a split, a merge) fails with {"status": "ERROR", "reason": "pending"} until it resolves, at which point the note is either burned for
good (payment settled) or released back to outstanding (payment confirmed
failed).
Per the spec's security considerations, no spendable secret is ever persisted: notes
are stored keyed by sha256(k1), for a minted note that is exactly the payment
hash of the invoice that funded it, so the preimage is discarded at invoice-creation
time. The spec also asks SERVICE not to log query strings on the withdraw
endpoints, since a bearer note's k1 can sit in one far longer than an ephemeral
LUD-03 k1 would, this mint disables uvicorn's per-request access log entirely
(see server.py's lifespan) rather than leave secrets in server logs by default;
run it behind a reverse proxy if you want access logs for the other routes.
Offline verification (optional): if a funding source is configured, GET /w advertises a mintPubkey - that node's own identity, the same key
it signs BOLT-11 invoices with - and rotate/split/merge responses carry a
recoverable signature/changeSignature over each new note, letting a holder
verify a note's issuer and amount without contacting the mint (see
signing.py). Notes are signed via the funding source's own signmessage RPC
(lnd's /v1/signmessage, cln's signmessage), which both wrap the message
with the standard "Lightning Signed Message:" prefix and double-sha256 it
before signing - the same convention other Lightning tooling already uses to
prove node ownership, rather than a bespoke raw-digest scheme neither backend
can actually produce. There's no separate setting for this: without a funding
source, both fields are simply omitted, same as any other unconfigured
optional field, and signing failures (e.g. a briefly unreachable node) are
swallowed rather than failing the rotate/split/merge itself.
Verify (optional, LUD-21): set VERIFY_ENABLED=true to
advertise a verify URL in /p/cb's response, letting a wallet with no node
of its own poll /verify/{payment_hash} for settlement status instead of
watching the invoice itself. Once settled, the response's preimage is the
freshly minted bearer note's spend secret (see LUD-XX) - unlike a plain
LUD-21 proof-of-payment, that wallet needs it to claim the note at all, so it
must be handed over despite SERVICE's own node already being a permanent
prior holder of that same secret; the wallet MUST rotate the note immediately
after (see LUD-XX's Security considerations) rather than treat verify as
having closed that exposure window. preimage is fetched live from the
funding source on every call, never cached locally, same as every other
secret this mint handles. /verify/{payment_hash} itself always works when
hit directly; VERIFY_ENABLED only controls whether /p/cb advertises it.
Tor: set ONION_URL to this mint's hidden service address (e.g.
http://<v3-address>.onion) to advertise it on the frontend one-pager as an
alternative way to reach the mint, alongside its clearnet QR/address. This
isn't just cosmetic: if a wallet is actually connecting through that address,
ONION_URL is used as the base for the LNURL/callback URLs instead of
BASE_URL (see config.py's public_base_url) - otherwise a fixed clearnet
BASE_URL would leak into a Tor visitor's QR code, pointing their wallet's
callback at a host it can't (or shouldn't have to) reach, breaking payment
over Tor entirely. Running the hidden service itself is outside this app's
scope - point a Tor HiddenServiceDir (or an onion-services-capable reverse
proxy) at whatever host/port this mint is already listening on, the same way
you'd front it with Caddy/nginx for clearnet.
uv sync
uv run fastapi dev lnurl_mint/server.pyConfigure the funding source via .env (see .env.example), lnd or cln REST.
Without one, minting and melting are unavailable (rotate/split/merge of existing
notes still work).
cln rune: this mint only ever calls invoice, pay, signmessage,
listinvoices and getinfo (see node.py), so scope FUNDINGSOURCE_RUNE to
just those instead of handing it a full-access rune:
lightning-cli createrune restrictions='[["method=invoice","method=pay","method=signmessage","method=listinvoices","method=getinfo"]]'The command's JSON output's rune field is the value for FUNDINGSOURCE_RUNE.
The single [...] restriction is an OR list (any of these five methods, and
nothing else) - a comma-separated top-level list instead would AND further
restrictions on top (e.g. pnum=0 to also disallow all requests with
parameters).
make dev/make serve/make run (Docker) default to port 8111, not 8000,
meant to run alongside a full lnurl_server instance on the same host, which
typically already claims 8000. Override with make run PORT=....
Pull the published image (see Release) or build it locally:
docker pull dni256/lnurl-mint # or: make buildmake build/make run (or the equivalent docker run below) use
--network host, so the container shares the host's network namespace
directly - FUNDINGSOURCE_URL=https://localhost:3010 in .env then reaches
a node running on the host exactly like a bare make dev/make serve would,
with no host.docker.internal/gateway-IP workaround needed. The tradeoff is
the usual one for host networking: no port remapping (the app listens on
PORT directly, see below) and no network isolation from the host.
mkdir -p data && touch data/mint.db
docker run --restart always -d --name lnurl-mint \
--network host \
-e PORT=8111 \
--env-file .env \
-v "$(pwd)/data/mint.db:/app/mint.db" \
dni256/lnurl-mintPORT (baked into the image as ENV PORT=8111, overridable at docker run -e PORT=...) is what the app itself listens on - with --network host there's
no -p host:container mapping to remap a port with, so this is the only way
to choose it. FUNDINGSOURCE_CERT_PATH, if you set one, must point at a path
that exists inside the container - if your lnd/cln cert lives on the host,
bind-mount it in too (-v /host/path/tls.cert:/tls.cert:ro and point
FUNDINGSOURCE_CERT_PATH=/tls.cert at the mounted path).
make build/make run wrap exactly the recipe above (same .env, same
volume) - make run PORT=... picks the port, defaulting to 8111 (see make dev's docstring above for why). Prefer real network isolation instead (e.g.
deploying where the funding source is reachable over the network rather than
on localhost)? Drop --network host and use -p <host-port>:8111 instead,
same as any other container. Front it with a reverse proxy for TLS if it's
reachable from the internet.
uv run pytestPushing a v* tag (git tag v1.2.0 && git push origin v1.2.0) triggers
.github/workflows/release.yml, which:
- builds the image and pushes
dni256/lnurl-mintto Docker Hub, tagged1.2.0,1.2,1, andlatest - creates a GitHub Release for the tag (via
gh release create --generate-notes), with notes auto-generated from the commits/PRs merged since the previous tag
The Docker push needs two repo secrets (Settings → Secrets and variables → Actions):
DOCKERHUB_USERNAME- the Docker Hub account/org to push underDOCKERHUB_TOKEN- an access token (not the account password), created at hub.docker.com/settings/security
The GitHub Release needs no extra secret (just the default GITHUB_TOKEN).