A small utility program I wrote to manage lightning address payments to my node.
Supports NIP-57
lightning zaps: the pay endpoint advertises allowsNostr/nostrPubkey, the
callback validates kind-9734 zap requests and issues invoices committed to
them (description hash), and when LND settles the invoice a signed kind-9735
zap receipt is published to the relays named in the zap request.
--nostr-key(default./nostr.key): hex secret key used to sign zap receipts. Generated automatically (mode 0600) on first run; the derived pubkey is logged at startup and served asnostrPubkey.--zap-state(default./zap-state.json): persists pending zaps and the LNDsettle_index, so receipts survive restarts.- The macaroon additionally needs invoice-subscription access (the
invoices:read/invoices:writepermissions below cover it).
A single line containing a 32-byte BIP-340 secret key as 64 hex characters, for example (do NOT use this example key — anyone holding the secret key can forge zap receipts from your server):
7f3b0f6a2cc59f549af08b0e0f8d2a1de19e33c1c26adad9db453789ba9b7d0e
The server generates this file (mode 0600) on first run if it is missing. To create one by hand instead:
openssl rand -hex 32 > nostr.key
chmod 600 nostr.keyThis is a dedicated signing identity for zap receipts — do not reuse your
personal nostr key. The matching public key is logged at startup and served
as nostrPubkey. To print it (hex, plus the npub form nostr clients show):
python3 - <<'EOF'
import bech32, schnorr
pubkey = schnorr.pubkey_gen(bytes.fromhex(open('nostr.key').read().strip()))
print("nostrPubkey:", pubkey.hex())
print("npub:", bech32.bech32_encode('npub', bech32.convertbits(pubkey, 8, 5)))
EOFverify_receipts.py fetches zap receipts for a pubkey from a relay and
checks them the way a nostr client does (NIP-57 Appendix F): valid
signature, signed by this server's nostrPubkey, embedding a valid zap
request. To verify receipts for my keys:
pipenv run python verify_receipts.py \
--pubkey npub1kzqw3mypp08nsqh97pquh8tyxqsfa2y97fzx6qeql89ax8f4yfwqkfwurh \
--server-pubkey 0b363a25caeef339c0920397aa1eb4b49bee3bbcb6c5af941655f8f590fe557e \
--relay wss://nos.lol--pubkey is the zap recipient's identity (hex or npub); --server-pubkey
is whatever the live pay endpoint advertises:
curl -s https://lnurl.alanisoft.com/ | jq -r .nostrPubkeyThe server can also act as a
NIP-47 wallet
service: nostr apps holding a connection URI can operate the LND node over
an encrypted relay channel. Supported methods: pay_invoice,
make_invoice, lookup_invoice, get_balance, get_info, and
list_transactions (NWC-05). Connected apps also receive NWC-02
notifications (payment_received when an invoice they created settles,
payment_sent after their own outgoing payment). Both NIP-44 v2 and the
legacy NIP-04 encryption are supported — the service answers each request in
whatever scheme the client used and, for backward compatibility with clients
like Damus that still use NIP-04, emits notifications in both kinds (23197
NIP-44 and 23196 NIP-04). NIP-44 is preferred and advertised first in the
info event.
Budgeted connections are treated as limited-trust and see only their own
activity: get_balance returns their remaining budget (not the node
balance), list_transactions returns only invoices/payments they created,
and notifications are scoped to the connection they concern (never
broadcast). Full-trust connections (no --nwc-budget) get the node-wide
list_transactions view.
Relays default to the published relay list of your nostr identity: the
--nwc-identity NIP-05 identifier (default cesar@alanisoft.com) is
resolved via /.well-known/nostr.json, and its newest NIP-65 (kind 10002)
relay list is used, capped at 5. Pass --nwc-relay (repeatable) to pin
relays explicitly instead. The service listens on every relay and answers
each request exactly once, whichever relay delivers it first; connection
URIs embed the full relay list.
Mint a connection URI per app (prints the URI + scannable QR):
./lnurl --nwc-add phone # relays discovered from identity
./lnurl --nwc-add phone --nwc-relay wss://relay.getalby.com/v1 # pinned
./lnurl --nwc-listThe server starts NWC when connections exist (nwc.json present) or
--nwc-relay is given:
./lnurl --server ... # relays discovered at startupThe wallet key and authorized client pubkeys live in --nwc-state
(default ./nwc.json, mode 0600); client secrets are never stored. NWC
needs a macaroon with more permissions than the zap flow:
lnd $ lncli bakemacaroon info:read invoices:read invoices:write \
offchain:read offchain:write address:read address:write \
--save_to ./app.macaroonA connection URI is spending access to your node. Treat URIs like cash and mint one per app. To limit the blast radius of a leaked URI, give each connection a spending budget and an expiry:
./lnurl --nwc-add phone --nwc-budget 50000/monthly --nwc-expires 90d--nwc-budget is in sats with an optional renewal period (daily,
weekly, monthly; no period means a lifetime cap). Budgeted
connections see their remaining budget as their get_balance (the node
balance stays private), payments that would exceed the budget fail with
QUOTA_EXCEEDED (amounts are read from the bolt11 invoice itself), and
spent amounts including fees are tracked in nwc.json. Expired
connections get UNAUTHORIZED. --nwc-list shows budget usage and
expiry; delete a connection from nwc.json to revoke it immediately.
Per-payment fees are limited to 1% (10 sat floor). Requests are
replay-guarded (10-minute freshness window, expiration tag honored)
and only accepted from pubkeys minted by --nwc-add.
pipenv install --dev
pipenv run pytestThe suite covers the BIP-340 official test vectors, NIP-01 event serialization checked against real relay events, every NIP-57 Appendix D validation rule, receipt construction (Appendix E), and an end-to-end zap flow against a fake LND gRPC server with Appendix F client-side validation.
- Create a application macaroon
lnd $ lnd-cli bakemacaroon invoices:read invoices:write address:read address:write --save_to ./app.macaroon- Install dependencies with pipenv (Python 3.11+;
creates
./.venvfrom the committedPipfile.lock)
lnd $ PIPENV_VENV_IN_PROJECT=1 pipenv install- Run the server
lnd $ pipenv run ./lnurl --serverlightning_pb2.py and lightning_pb2_grpc.py are generated from
lightning.proto, which is checked in from the lnd repository (currently
tag v0.21.1-beta). To update them:
- Fetch the proto from the desired lnd release tag:
curl -sL https://raw.githubusercontent.com/lightningnetwork/lnd/v0.21.1-beta/lnrpc/lightning.proto -o lightning.proto- Regenerate the bindings and run the tests:
pipenv run python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. lightning.proto
pipenv run pytest(Very old versions of lightning.proto imported REST annotations from the googleapis repository; modern ones are self-contained, so the former googleapis submodule has been removed.)
Run the service as a dedicated user so its secrets (macaroon, nostr key) are
isolated and so firewall rules can target the service by UID (e.g. an
nftables meta skuid lnurl rule that lets only this daemon publish zap
receipts on outbound 443):
sudo useradd --system --no-create-home --shell /usr/sbin/nologin lnurl
sudo chown lnurl:lnurl /usr/local/lnurl/app.macaroon
# nostr.key and zap-state.json are created by the service on first run
sudo chown lnurl:lnurl /usr/local/lnurlInstall the locked dependencies into an in-project virtualenv (the unit below runs its interpreter directly, so pipenv is not needed at runtime):
cd /usr/local/lnurl && PIPENV_VENV_IN_PROJECT=1 pipenv installOn Raspberry Pi OS, pipenv install fails with mismatched-hash errors:
/etc/pip.conf adds the piwheels.org extra index, whose ARM-rebuilt wheels
have different hashes than the PyPI artifacts recorded in Pipfile.lock.
On 64-bit Raspberry Pi OS (uname -m = aarch64) PyPI has native wheels
for everything, so just bypass piwheels for the install:
cd /usr/local/lnurl && PIP_CONFIG_FILE=/dev/null PIPENV_VENV_IN_PROJECT=1 pipenv install(32-bit Raspberry Pi OS has no PyPI wheels for grpcio — building it from
source on a Pi is impractical, so use the 64-bit OS. Install pipenv itself
via pipx install pipenv rather than apt; Debian's package is dated.)
A sample systemd file
[Unit]
Description=LND Lightning Address
Requires=lnd.service
After=lnd.service
[Service]
ExecStart=/usr/local/lnurl/.venv/bin/python3 /usr/local/lnurl/lnurl --server --macaroon=/usr/local/lnurl/app.macaroon --image-thumb=/usr/local/lnurl/image-thumb.png --nostr-key=/usr/local/lnurl/nostr.key --zap-state=/usr/local/lnurl/zap-state.json
WorkingDirectory=/usr/local/lnurl
User=lnurl
Group=lnurl
Type=simple
KillMode=process
TimeoutSec=180
Restart=always
RestartSec=60
[Install]
WantedBy=multi-user.target
It's recommened you host the server behind a nginx, etc. The daemon itself also needs outbound TCP 443 to publish zap receipts to relays; nginx does not (receipts are sent directly by the daemon, not proxied).
