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>
210 lines
8.4 KiB
Markdown
210 lines
8.4 KiB
Markdown
# /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 `--clink` mode is preserved for validating CLINK kind-21003 management commands, which are the one piece of CLINK still wired on `dev` (operator dispense commands). CLINK kinds 21001/21002 are dormant on `dev`.
|
||
|
||
## 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:
|
||
|
||
```jsonc
|
||
// 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_id` is unique per outbound RPC (no reuse across sessions)
|
||
- [ ] The pending map is keyed by `request_id` and cleaned up on resolve/reject
|
||
- [ ] Subscription pushes are matched by `subscription_id` (top-level field, not in `data`)
|
||
- [ ] The handler resolves the subscription's `subscriptionId` only 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 `finalizeEvent` from `nostr-tools` using the ATM's `identity.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` (not `encryptContent`/`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 returns `link.lnurl` populated from `settings.lnbits_baseurl`
|
||
- [ ] The ATM's `identity.privateKey` is loaded once from `/var/lib/bitspire/.env` and never logged
|
||
|
||
### 4. Cash-out flow (`--lnbits`)
|
||
|
||
In `services/lightning.ts → generateInvoice / watchInvoice`:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
- [ ] `subscribePayments` filter always specifies `payment_hash` for invoice watching (filter-less subscriptions are server-rejected; broader filters expose us to acting on unrelated settlements)
|
||
- [ ] `max_seconds` is clamped — LNbits clamps server-side to [1, 600], so values outside that range are silently changed
|
||
- [ ] The callback checks `push.payment_hash` matches 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`:
|
||
|
||
```typescript
|
||
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 replay
|
||
- [ ] `min_withdrawable === max_withdrawable === ctx.satsAmount` — exact amount, no operator slippage
|
||
- [ ] The LNURL comes from `link.lnurl` directly (LNbits populates it from `settings.lnbits_baseurl` over 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:
|
||
|
||
- [ ] `CLINKClient` is initialized with `operatorPubkey: CONFIG.operatorPubkeys` (no Lightning.Pub pubkey mixed in — that's LP-era)
|
||
- [ ] `clink.onManagement` handler routes through `handleManagementCommand` which dispatches by `request.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.value` check)
|
||
|
||
## Output format
|
||
|
||
```markdown
|
||
## 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:
|
||
|
||
```markdown
|
||
## 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
|