nostr-transport: lnurlw_create_link returns lnurl=null; consumers forced to compose the bech32 themselves #1

Closed
opened 2026-06-01 17:52:21 +00:00 by padreug · 0 comments
Owner

Context

When consumers call lnurlw_create_link via the nostr-transport kind-21000 RPC, the returned WithdrawLink has lnurl and lnurl_url set to None. The HTTP path populates those fields; the nostr path does not.

Concrete comparison:

views_api.py:41-48 (HTTP — populates lnurl):

lnurl = create_lnurl(linkk, request)   # uses req.url_for(...)
...
linkk.lnurl = str(lnurl.bech32)
linkk.lnurl_url = str(lnurl.url)

transport_rpcs.py:40-46 (nostr — leaves lnurl=None):

async def handle_lnurlw_create_link(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict:
    body = request.body or {}
    data = CreateWithdrawData(**body)
    link = await create_withdraw_link(data, auth.wallet.id)
    return _to_dict(link)   # never calls create_lnurl()

helpers.create_lnurl(link, req) requires a FastAPI Request (for req.url_for(...)) and so can't be invoked from the nostr RPC handler. The result is that link.lnurl and link.lnurl_url come back as their default None values.

Impact on downstream consumers (bitspire / aiolabs/lamassu-next)

Surfaced 2026-06-01 during a joint smoke for the operator-fee-config flow (lamassu-next#57). The ATM does:

const link = await lnbits.createWithdrawLink(walletId, { ... })

if (!CONFIG.lnbitsHttpUrl) {
  throw new Error('VITE_LNBITS_HTTP_URL is required for LNbits cash-in')
}

const callbackUrl = `${CONFIG.lnbitsHttpUrl.replace(/\/+$/, '')}/withdraw/api/v1/lnurl/${link.unique_hash}`
const lnurl = encodeLnurl(callbackUrl)

(apps/machine/src/services/lightning.ts:692-711)

Every ATM is therefore provisioned with a separate VITE_LNBITS_HTTP_URL env var (also surfaces as lnbitsHttpUrl in deploy/nixos/bitspire-atm.nix). Three downsides:

  1. Extra provisioning step that has no equivalent on the HTTP-views path — over HTTP, LNbits derives its own URL from request.url_root; over nostr, every consumer has to be told.
  2. Config-drift surface. If a deployment moves LNbits's external HTTPS URL (DNS change, port move, reverse-proxy rewrite), every ATM in the field stops issuing redeemable LNURL-withdraw QRs until reconfigured. LNbits itself adapts automatically over HTTP; the nostr consumers don't.
  3. Source-of-truth violation. LNbits knows its own external URL. Consumers should not have to be told.

It also confused a sibling session today during smoke-test triage, since the bitspire boot script echoes LNbits HTTP: ${lnbitsHttpUrl} — reads like ATM-→-LNbits connectivity (which is 100% nostr post-path-B), when in fact the value is only used to embed a customer-wallet callback URL into LNURL QRs. The variable is load-bearing for cash-in despite all RPC traffic being over nostr.

Proposed fix

In transport_rpcs.handle_lnurlw_create_link (and any sibling RPC that today wraps an HTTP-views-populated URL field), compose the URL from settings.lnbits_baseurl instead of req.url_for(...), then bech32-encode it the same way helpers.create_lnurl does.

Sketch:

from lnurl import encode as lnurl_encode
from lnbits.settings import settings

def _compose_lnurl_from_baseurl(link: WithdrawLink) -> tuple[str, str]:
    base = settings.lnbits_baseurl.rstrip("/")
    if link.is_unique:
        usescssv = link.usescsv.split(",")
        tohash = link.id + link.unique_hash + usescssv[link.number]
        multihash = uuid(name=tohash)
        url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}/{multihash}"
    else:
        url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}"
    encoded = lnurl_encode(url)
    return str(encoded.bech32), str(encoded.url)

async def handle_lnurlw_create_link(auth, request: NostrRpcRequest) -> dict:
    body = request.body or {}
    data = CreateWithdrawData(**body)
    link = await create_withdraw_link(data, auth.wallet.id)
    if settings.lnbits_baseurl:
        link.lnurl, link.lnurl_url = _compose_lnurl_from_baseurl(link)
    return _to_dict(link)

When lnbits_baseurl is unset, retain the current behavior (return None) so consumers can still fall back to their own composition — but when it IS set (i.e., on every real deployment), the RPC populates the field.

The same shape applies to:

  • handle_lnurlw_get_link
  • handle_lnurlw_update_link
  • Any sibling that returns a WithdrawLink shape

Sister extensions to audit

Any nostr-transport RPC wrapping an LNbits extension that today populates a URL via the HTTP-views layer is likely affected the same way. At minimum:

  • aiolabs/lnurlplnurlp_create_link likely returns a null lnurl on the nostr path
  • Any other extension where customer-facing URLs are produced server-side

Worth a quick grep across the fork set for req.url_for / request.url_root calls inside helpers.py files alongside transport_rpcs.py files that don't pass request through.

Consumer-side benefit

For bitspire specifically (lamassu-next#57 follow-up): drops VITE_LNBITS_HTTP_URL from /var/lib/bitspire/.env, removes the lnbitsHttpUrl option from deploy/nixos/bitspire-atm.nix, removes the misleading boot echo, and lets apps/machine/src/services/lightning.ts:710 collapse to const lnurl = link.lnurl; if (!lnurl) throw new Error(...).

Other downstream consumers of the nostr-transport API get the same simplification.

Cross-refs

  • Surfaced in cross-session coordination log 2026-06-01 (lamassu-next side); see entry §17:46Z and the correction §18:00Z.
  • Bitspire workaround lives at aiolabs/lamassu-next:apps/machine/src/services/lightning.ts:692-711 and aiolabs/lamassu-next:deploy/nixos/bitspire-atm.nix option lnbitsHttpUrl.
  • Related: aiolabs/lamassu-next#57 (operator-fees-over-nostr; the smoke that surfaced this).
## Context When consumers call `lnurlw_create_link` via the nostr-transport kind-21000 RPC, the returned `WithdrawLink` has `lnurl` and `lnurl_url` set to `None`. The HTTP path populates those fields; the nostr path does not. Concrete comparison: **`views_api.py:41-48`** (HTTP — populates `lnurl`): ```python lnurl = create_lnurl(linkk, request) # uses req.url_for(...) ... linkk.lnurl = str(lnurl.bech32) linkk.lnurl_url = str(lnurl.url) ``` **`transport_rpcs.py:40-46`** (nostr — leaves `lnurl=None`): ```python async def handle_lnurlw_create_link(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict: body = request.body or {} data = CreateWithdrawData(**body) link = await create_withdraw_link(data, auth.wallet.id) return _to_dict(link) # never calls create_lnurl() ``` `helpers.create_lnurl(link, req)` requires a FastAPI `Request` (for `req.url_for(...)`) and so can't be invoked from the nostr RPC handler. The result is that `link.lnurl` and `link.lnurl_url` come back as their default `None` values. ## Impact on downstream consumers (bitspire / `aiolabs/lamassu-next`) Surfaced 2026-06-01 during a joint smoke for the operator-fee-config flow (lamassu-next#57). The ATM does: ```ts const link = await lnbits.createWithdrawLink(walletId, { ... }) if (!CONFIG.lnbitsHttpUrl) { throw new Error('VITE_LNBITS_HTTP_URL is required for LNbits cash-in') } const callbackUrl = `${CONFIG.lnbitsHttpUrl.replace(/\/+$/, '')}/withdraw/api/v1/lnurl/${link.unique_hash}` const lnurl = encodeLnurl(callbackUrl) ``` (`apps/machine/src/services/lightning.ts:692-711`) Every ATM is therefore provisioned with a separate `VITE_LNBITS_HTTP_URL` env var (also surfaces as `lnbitsHttpUrl` in `deploy/nixos/bitspire-atm.nix`). Three downsides: 1. **Extra provisioning step** that has no equivalent on the HTTP-views path — over HTTP, LNbits derives its own URL from `request.url_root`; over nostr, every consumer has to be told. 2. **Config-drift surface.** If a deployment moves LNbits's external HTTPS URL (DNS change, port move, reverse-proxy rewrite), every ATM in the field stops issuing redeemable LNURL-withdraw QRs until reconfigured. LNbits itself adapts automatically over HTTP; the nostr consumers don't. 3. **Source-of-truth violation.** LNbits knows its own external URL. Consumers should not have to be told. It also confused a sibling session today during smoke-test triage, since the bitspire boot script echoes `LNbits HTTP: ${lnbitsHttpUrl}` — reads like ATM-→-LNbits connectivity (which is 100% nostr post-path-B), when in fact the value is *only* used to embed a customer-wallet callback URL into LNURL QRs. The variable is load-bearing for cash-in despite all RPC traffic being over nostr. ## Proposed fix In `transport_rpcs.handle_lnurlw_create_link` (and any sibling RPC that today wraps an HTTP-views-populated URL field), compose the URL from `settings.lnbits_baseurl` instead of `req.url_for(...)`, then bech32-encode it the same way `helpers.create_lnurl` does. Sketch: ```python from lnurl import encode as lnurl_encode from lnbits.settings import settings def _compose_lnurl_from_baseurl(link: WithdrawLink) -> tuple[str, str]: base = settings.lnbits_baseurl.rstrip("/") if link.is_unique: usescssv = link.usescsv.split(",") tohash = link.id + link.unique_hash + usescssv[link.number] multihash = uuid(name=tohash) url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}/{multihash}" else: url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}" encoded = lnurl_encode(url) return str(encoded.bech32), str(encoded.url) async def handle_lnurlw_create_link(auth, request: NostrRpcRequest) -> dict: body = request.body or {} data = CreateWithdrawData(**body) link = await create_withdraw_link(data, auth.wallet.id) if settings.lnbits_baseurl: link.lnurl, link.lnurl_url = _compose_lnurl_from_baseurl(link) return _to_dict(link) ``` When `lnbits_baseurl` is unset, retain the current behavior (return `None`) so consumers can still fall back to their own composition — but when it IS set (i.e., on every real deployment), the RPC populates the field. The same shape applies to: - `handle_lnurlw_get_link` - `handle_lnurlw_update_link` - Any sibling that returns a `WithdrawLink` shape ## Sister extensions to audit Any nostr-transport RPC wrapping an LNbits extension that today populates a URL via the HTTP-views layer is likely affected the same way. At minimum: - `aiolabs/lnurlp` — `lnurlp_create_link` likely returns a null lnurl on the nostr path - Any other extension where customer-facing URLs are produced server-side Worth a quick grep across the fork set for `req.url_for` / `request.url_root` calls inside `helpers.py` files alongside `transport_rpcs.py` files that don't pass `request` through. ## Consumer-side benefit For bitspire specifically (lamassu-next#57 follow-up): drops `VITE_LNBITS_HTTP_URL` from `/var/lib/bitspire/.env`, removes the `lnbitsHttpUrl` option from `deploy/nixos/bitspire-atm.nix`, removes the misleading boot echo, and lets `apps/machine/src/services/lightning.ts:710` collapse to `const lnurl = link.lnurl; if (!lnurl) throw new Error(...)`. Other downstream consumers of the nostr-transport API get the same simplification. ## Cross-refs - Surfaced in cross-session coordination log 2026-06-01 (lamassu-next side); see entry §`17:46Z` and the correction §`18:00Z`. - Bitspire workaround lives at `aiolabs/lamassu-next:apps/machine/src/services/lightning.ts:692-711` and `aiolabs/lamassu-next:deploy/nixos/bitspire-atm.nix` option `lnbitsHttpUrl`. - Related: `aiolabs/lamassu-next#57` (operator-fees-over-nostr; the smoke that surfaced this).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/withdraw#1
No description provided.