Skip to content
calanizPublic

About

A small utility program I wrote to manage lightning address payments to my node

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

52 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

About

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.

Zap notes

  • --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 as nostrPubkey.
  • --zap-state (default ./zap-state.json): persists pending zaps and the LND settle_index, so receipts survive restarts.
  • The macaroon additionally needs invoice-subscription access (the invoices:read/invoices:write permissions below cover it).

nostr.key format

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.key

This 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)))
EOF

Verifying published receipts

verify_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 .nostrPubkey

Nostr Wallet Connect (NIP-47)

The 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-list

The server starts NWC when connections exist (nwc.json present) or --nwc-relay is given:

./lnurl --server ...                           # relays discovered at startup

The 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.macaroon

A 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.

Tests

pipenv install --dev
pipenv run pytest

The 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.

Setup

  1. Create a application macaroon
lnd $ lnd-cli bakemacaroon invoices:read invoices:write address:read address:write --save_to ./app.macaroon
  1. Install dependencies with pipenv (Python 3.11+; creates ./.venv from the committed Pipfile.lock)
lnd $ PIPENV_VENV_IN_PROJECT=1 pipenv install
  1. Run the server
lnd $ pipenv run ./lnurl --server

Updating the LND API bindings

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

  1. 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
  1. 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.)

Screenshots

Production

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/lnurl

Install 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 install

On 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).

About

A small utility program I wrote to manage lightning address payments to my node

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages