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

8.4 KiB
Raw Permalink Blame History

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

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

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

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)

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

## 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
  • /nostr-check — validates NIP-01 / NIP-44 conformance at the nostr-client layer
  • /security — Bitcoin/Lightning vuln audit
  • /test — runs vitest + flow validation