Closes gap 2 from coord log 2026-06-01T18:30Z. The LNbits withdraw extension's nostr-transport RPC now populates `link.lnurl` from `settings.lnbits_baseurl` (aiolabs/withdraw#1 / commit e9d911e), so the ATM no longer needs a separate HTTP URL on the wire to compose the LNURL-withdraw callback itself. What goes: - `VITE_LNBITS_HTTP_URL` env var (renderer + Electron main) - `lnbitsHttpUrl` field on `LightningConfig`, `RuntimeConfig`, and the Window mirror in `src/types/electron.d.ts` - The manual `${lnbitsHttpUrl}/withdraw/api/v1/lnurl/${unique_hash}` composition in `generateLnurlWithdraw` - The `encodeLnurl` bech32 helper in `lightning.ts` (LNbits returns bech32-encoded; we just `.toUpperCase()` to match BOLT/LNURL convention) - `@scure/base` dep from `apps/machine/package.json` (only used by the removed helper; clink still uses it directly) - The `lnbitsHttpUrl` option + `LNBITS_HTTP_URL=…` env var + boot echo in `deploy/nixos/bitspire-atm.nix` - Doc references in CLAUDE.md, README.md, deploy/nixos/README.md, docs/architecture-comparison.md, and the lightning-check skill What stays: - `link.lnurl` consumption, with an explicit error if LNbits returns null (which signals `LNBITS_BASEURL` is unset on the server side — better to fail clearly than silently) - The receiver-side bech32 uppercasing (LNbits returns lowercase per the standard library) Why this is a net win: - Removes a config-drift surface — if LNbits's external URL moved (DNS, port, reverse-proxy rewrite), every ATM in the field would stop issuing redeemable LNURL-withdraw QRs until reconfigured. Now LNbits derives its own URL from `settings.lnbits_baseurl`, one source of truth. - Removes an extra provisioning step. No more `LNBITS_HTTP_URL=…` before running `provision-atm.sh`; the relay + server pubkey suffice. - Removes the misleading boot echo that triggered the §`18:30Z` smoke triage confusion ("LNbits HTTP: <url>" read like ATM-→-LNbits connectivity, when it was only ever a URL embedded in customer QRs). Also adds a `# pragma: allowlist secret` marker above the `VITE_ATM_PRIVATE_KEY` doc block in `.env.example` so the global secret scanner stops false-positiving on the documentation prose. Workspace typecheck + 24/24 apps/machine tests still green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8.4 KiB
/lightning-check — Lightning backend conformity
Purpose
Validate the LNbits nostr-transport integration in bitSpire — wire envelope, encryption, subscription handling, invoice/withdraw correctness. Replaces the original Lightning.Pub-flavored version of this skill; the LP backend was removed from dev in commit a51d7af.
The
--clinkmode is preserved for validating CLINK kind-21003 management commands, which are the one piece of CLINK still wired ondev(operator dispense commands). CLINK kinds 21001/21002 are dormant ondev.
Invocation
/lightning-check [target] [--lnbits] [--lnurlw] [--clink]
Where:
target— file or directory to check (default:packages/lnbits/src/+apps/machine/src/services/lightning.ts)--lnbits— focus on LNbits nostr-transport RPC + subscription surface--lnurlw— focus on LNURL-withdraw cash-in flow (link creation, callback URL composition, subscription)--clink— focus on the kind-21003 management surface (operator commands)
What to validate
1. Wire envelope (kind 21000)
Each RPC request/response is a kind-21000 event with NIP-44 v2-encrypted JSON in content. The plaintext shape is:
// Request
{
"rpc_name": "create_invoice",
"request_id": "create_invoice-7-ab12cd",
"wallet_id": "<uuid>", // present for AUTH_WALLET RPCs
"body": { /* RPC-specific */ },
"query": { /* RPC-specific */ } // optional
}
// One-shot reply
{
"status": "OK" | "ERROR",
"request_id": "create_invoice-7-ab12cd",
"data": { /* RPC-specific */ },
"error": "..." // present when status==ERROR
}
// Subscription push
{
"status": "OK",
"request_id": "sub-9-ef34gh",
"subscription_id": "<server-issued>",
"data": { "payment": { /* Payment */ } } |
{ "closed": true, "reason": "ttl"|"unsubscribed" }
}
Required checks:
request_idis unique per outbound RPC (no reuse across sessions)- The pending map is keyed by
request_idand cleaned up on resolve/reject - Subscription pushes are matched by
subscription_id(top-level field, not indata) - The handler resolves the subscription's
subscriptionIdonly after the ACK arrives — pre-ACK pushes (rare but possible) are handled - The reply listener decrypts with
decryptContentV2(identity, serverPubkey, ev.content)and silently skips events that don't decrypt (wrong sender) - Events are signed with
finalizeEventfromnostr-toolsusing the ATM'sidentity.privateKey— server identifies the calling account from the signature alone
2. Encryption (NIP-44 v2)
LNbits transport requires NIP-44 v2, NOT NIP-04. Required checks:
- All kind-21000 content is
encryptContentV2/decryptContentV2(notencryptContent/decryptContent, which are the v1/NIP-04 paths kept around for CLINK compatibility) - The shared secret is derived from
identity.privateKey × serverPubkey(or the reverse on the server side — symmetric) - No code logs ciphertext or shared secrets
3. Authentication model
LNbits derives the calling account from the event signature. There is no admin token, no API key, no client cert on the ATM. Required checks:
- No code stores or references an admin token
- No code makes outbound HTTP to LNbits (the LNURL-withdraw callback URL embedded in cash-in QRs is the customer wallet's path — the ATM itself doesn't hit HTTP). Post aiolabs/withdraw#1 /
e9d911e, the ATM does not even compose that URL: LNbits's nostr-transport returnslink.lnurlpopulated fromsettings.lnbits_baseurl - The ATM's
identity.privateKeyis loaded once from/var/lib/bitspire/.envand never logged
4. Cash-out flow (--lnbits)
In services/lightning.ts → generateInvoice / watchInvoice:
// Generate
const payment = await lnbits.createInvoice(walletId, {
amount: amountSats,
memo: 'bitSpire - Cash Out',
unit: 'sat',
})
// Watch — must filter by payment_hash to avoid acting on unrelated settlements
const decoded = await lnbits.decodePayment(invoice)
const paymentHash = decoded.payment_hash
const subId = await lnbits.subscribePayments(walletId, {
payment_hash: paymentHash,
max_seconds: 600,
}, push => {
if (push.payment_hash !== paymentHash) return // belt-and-suspenders
if (push.status !== 'success') return
callback(push.preimage ?? 'payment-confirmed')
})
Required checks:
subscribePaymentsfilter always specifiespayment_hashfor invoice watching (filter-less subscriptions are server-rejected; broader filters expose us to acting on unrelated settlements)max_secondsis clamped — LNbits clamps server-side to [1, 600], so values outside that range are silently changed- The callback checks
push.payment_hashmatches the expected one (defense in depth) - The callback checks
push.status === 'success'before treating as paid - On cleanup,
unsubscribe(walletId, subId)is called
5. Cash-in flow (--lnurlw)
In services/lightning.ts → generateLnurlWithdraw:
const link = await lnbits.createWithdrawLink(walletId, {
title: '...',
min_withdrawable: ctx.satsAmount,
max_withdrawable: ctx.satsAmount,
uses: 1,
wait_time: 1,
is_unique: false,
})
if (!link.lnurl) {
throw new Error('LNbits returned link.lnurl=null — check LNBITS_BASEURL on the server')
}
const lnurl = link.lnurl.toUpperCase()
const subId = await lnbits.subscribePayments(walletId, {
tag: 'withdraw',
link_id: link.id,
max_seconds: 600,
}, push => { /* mark session claimed, dispense */ })
Required checks:
uses: 1— single-use prevents replaymin_withdrawable === max_withdrawable === ctx.satsAmount— exact amount, no operator slippage- The LNURL comes from
link.lnurldirectly (LNbits populates it fromsettings.lnbits_baseurlover the nostr-transport per aiolabs/withdraw#1 /e9d911e); error out if it's null instead of falling back to a hardcoded HTTP URL - Uppercase the bech32 string per BOLT/LNURL convention (the LNbits side returns it lowercase)
- Subscription filter is
tag: 'withdraw'+link_id: link.id— this is what the withdraw extension stamps on settlement events - On session abort, the cleanup closure both unsubscribes AND deletes the withdraw link (so a half-completed session doesn't leave a redeemable QR alive on LNbits)
6. Operator commands (--clink, kind-21003)
The kind-21003 management surface is the one piece of CLINK still wired on dev. It's LP-independent (operators talk directly to the ATM over nostr) so it survived the migration.
Required checks:
CLINKClientis initialized withoperatorPubkey: CONFIG.operatorPubkeys(no Lightning.Pub pubkey mixed in — that's LP-era)clink.onManagementhandler routes throughhandleManagementCommandwhich dispatches byrequest.command(manual dispense, status query, etc.)- Operator authentication is by pubkey allowlist — not a separate token
- Sensitive ops (
dispenseCash) only fire when the state machine is idle (isIdle.valuecheck)
Output format
## Lightning backend conformity: [target]
### LNbits transport
| Concern | Status | Notes |
|---|---|---|
| Wire envelope | ✅ | |
| NIP-44 v2 encryption | ✅ | |
| Signature-only auth | ✅ | No admin tokens found |
| Subscription handling | ⚠️ | Missing payment_hash filter on watchInvoice cleanup |
### Cash-out / Cash-in flows
- [ ] `file:line` — Issue description + fix
### Operator (kind-21003)
- [ ] `file:line` — Issue description
### Recommendations
- [ ] Performance/UX improvements
Example
/lightning-check packages/lnbits/src/ --lnbits
Output:
## Lightning backend conformity: packages/lnbits/src/
### LNbits transport
| Concern | Status | Notes |
|---|---|---|
| Wire envelope | ✅ | request_id uniqueness OK, pending map cleaned up on timeout |
| NIP-44 v2 encryption | ✅ | encryptContentV2/decryptContentV2 used exclusively |
| Subscription handling | ✅ | subscriptionId resolved after ACK, push handlers race-safe |
| Cleanup | ⚠️ | unsubscribe error swallowed silently — log at warn level |
### Cash-out flow
- [ ] `packages/lnbits/src/client.ts:212` — watchInvoice resolves on first success push but doesn't unsubscribe until subIdRef is assigned; if push lands before subscribePayments resolves, leaks the subscription
Related skills
/nostr-check— validates NIP-01 / NIP-44 conformance at the nostr-client layer/security— Bitcoin/Lightning vuln audit/test— runs vitest + flow validation