bitspire/.claude/skills/lightning-check.md
Padreug 4f68ddc40b refactor(machine): drop VITE_LNBITS_HTTP_URL — lnurl now arrives populated from LNbits (#57 gap 2)
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>
2026-06-01 20:33:28 +02:00

210 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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