docs: finish the LNbits-era doc sweep across docs/ + .claude/skills/
Second + final batch of the doc refresh. README + CLAUDE went out in
8c9ae29; deploy/nixos/README + obsolete-flow flags in 924844f. This
commit covers everything left.
docs/machine-installation.md
Was describing a manual AppImage scp deploy + a `lamassu-kiosk`
systemd unit that hasn't been the deployment path for months.
Replaced with a high-level "what the pipeline does and why"
overview that points at deploy/nixos/README.md for the full
command-by-command walkthrough. Includes the BATM3 chassis-mod
note (custom Dell OptiPlex retrofit, not a stock Dell).
docs/architecture-comparison.md
Rewrote the comparison to be lamassu-server (≤ v8.1.5) vs bitSpire
(LNbits-backed) instead of the original lamassu-server vs LP-backed
lamassu-next framing. Updated the cash-out + cash-in flow diagrams
to show the actual nostr-transport path (LNbits-bundled nostrrelay
extension at ws://<host>:5001/nostrrelay/test, no separate strfry
container). Replaced the migration-path section with a softer
"when to choose what" framing that includes Lamassu's current
commercial offering as a legitimate third option. Added a header
pointer to the Acknowledgements section.
docs/business-model.md
Light touch-ups: Lightning.Pub → LNbits where it appeared, swapped
the [[ndebit-cash-in-flow]] link for [[architecture-comparison]],
noted the kind-30078 service beacon for availability broadcasts.
docs/device-configuration.md
Dropped "Lamassu" branding from the machine-model headings
(Sintra / tejo / douro / batm3 are referenced by hardware identity
here, not by Lamassu's product line). Added the Sintra-specific
ttyS4-vs-placeholder-ttyS1..3 gotcha we hard-learned during the
first flash. Corrected the BATM3 entry: stock GeneralBytes chassis
with a Dell OptiPlex 9030 AIO motherboard physically grafted in,
NOT a Dell out of the box. Updated the example /dev/ttyJ* symlink
output to match what a healthy Sintra actually shows.
docs/adr/001-hal-architecture.md
ADRs are historical artifacts — kept the original decision text
intact. Added a postscript noting:
- The package rename @lamassu/hal → @bitSpire/hal
- The v8.1.5 boundary on any lamassu-machine source-tree
references (Lamassu's 2024-01-26 license transition)
- That the "Remaining Work" list is complete and the first
successful Sintra hardware integration ran on 2026-05-13
.claude/skills/lightning-check.md
Rewrote end-to-end. Was Lightning.Pub-flavoured with CLINK kinds
21001/21002 as the primary flows; now validates the LNbits nostr-
transport surface (kind-21000 envelope, NIP-44 v2 encryption,
subscribe_payments filter discipline, lnurlw link composition).
Preserved a --clink mode for the still-live kind-21003 operator-
management surface. Includes a "what to check" rubric for cash-out
vs cash-in flows that mirrors the actual code in
apps/machine/src/services/lightning.ts.
.claude/skills/hal-check.md
Two pivots: (1) acknowledge ADR-001's TypeScript-not-Rust choice
and reframe all the safety checklists in TS-flavour (type safety,
discriminated unions, single-writer serial, bounded emitters)
instead of Rust-flavour (unsafe, borrow checker). (2) Add explicit
v8.1.5 provenance boundary plus a "forbidden operations" section
that prohibits diffing or porting from v8.1.6+ lamassu-machine
source. Updated the port-validation source-reference table to
list TS file paths under packages/hal/ instead of Rust paths.
.claude/skills/docs.md
@lamassu/* → @bitSpire/*. Replaced the Lightning.Pub mermaid
diagram with a current cash-out flow showing the nostr-transport
RPC + subscribe_payments push path. Left the createOffer noffer
example in the API-docs template section since it's illustrative
("here's what a good TSDoc block looks like") rather than current
reference documentation.
.claude/skills/test.md
One-line: @lamassu/nostr-client → @bitSpire/nostr-client in the
pnpm-filter example.
deploy/nixos/README.md
Single touch-up: clarified the douro/batm3 hardware-module comments
to reflect that BATM3 is a custom-installed Dell board in a
GeneralBytes BATM3 chassis (not a Dell OEM).
Files NOT touched in this sweep (intentionally):
- packages/hal/src/**/*.ts attribution comments — those reference
"lamassu-machine" in their port-source headers. Those are
factually accurate (the drivers ARE ported from there, up to
v8.1.5) and constitute necessary license/attribution metadata.
Editing them would erase the provenance trail.
- .claude/skills/{nostr-check,security}.md — already protocol-
neutral, no LP/lamassu references to clean up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0b94bef4be
commit
53b0d382e8
10 changed files with 692 additions and 792 deletions
|
|
@ -1,168 +1,208 @@
|
|||
# /lightning-check - Lightning.Pub Conformity Agent
|
||||
# /lightning-check — Lightning backend conformity
|
||||
|
||||
## Purpose
|
||||
Validate Lightning.Pub integration and CLINK protocol conformity.
|
||||
|
||||
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] [--clink] [--wallet]
|
||||
/lightning-check [target] [--lnbits] [--lnurlw] [--clink]
|
||||
```
|
||||
|
||||
Where:
|
||||
- `target` - File or directory to check
|
||||
- `--clink` - Focus on CLINK offer/debit flow
|
||||
- `--wallet` - Focus on wallet operations
|
||||
|
||||
## Lightning.Pub Integration
|
||||
- `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)
|
||||
|
||||
### Connection
|
||||
Lightning.Pub uses Nostr for all communication:
|
||||
## 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 (other than via `VITE_LNBITS_HTTP_URL` for the LNURL-withdraw callback, which is the customer wallet's path — the ATM itself doesn't hit HTTP)
|
||||
- [ ] 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
|
||||
// Connection via nprofile
|
||||
const nprofile = 'nprofile1...' // Contains pubkey + relay hints
|
||||
// Generate
|
||||
const payment = await lnbits.createInvoice(walletId, {
|
||||
amount: amountSats,
|
||||
memo: 'bitSpire - Cash Out',
|
||||
unit: 'sat',
|
||||
})
|
||||
|
||||
// All operations are Nostr events to Lightning.Pub's pubkey
|
||||
await relay.publish({
|
||||
kind: 21002, // CLINK debit
|
||||
content: encrypted_request,
|
||||
tags: [['p', lightningPubPubkey]],
|
||||
// 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
|
||||
- [ ] Using correct event kinds (21001, 21002, 21003)
|
||||
- [ ] Proper NIP-44 encryption for requests
|
||||
- [ ] Handling async responses via subscription
|
||||
- [ ] Proper error handling for payment failures
|
||||
Required checks:
|
||||
|
||||
## CLINK Protocol
|
||||
- [ ] `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
|
||||
|
||||
### Offer Flow (Kind 21001)
|
||||
```
|
||||
User scans noffer → Wallet sends 21002 → ATM responds with invoice → User pays
|
||||
### 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,
|
||||
})
|
||||
|
||||
const callbackUrl = `${CONFIG.lnbitsHttpUrl.replace(/\/+$/, '')}/withdraw/api/v1/lnurl/${link.unique_hash}`
|
||||
const lnurl = encodeLnurl(callbackUrl) // bech32 HRP="lnurl", uppercase
|
||||
|
||||
const subId = await lnbits.subscribePayments(walletId, {
|
||||
tag: 'withdraw',
|
||||
link_id: link.id,
|
||||
max_seconds: 600,
|
||||
}, push => { /* mark session claimed, dispense */ })
|
||||
```
|
||||
|
||||
Validation:
|
||||
- [ ] noffer encoding is valid
|
||||
- [ ] Relays included in offer
|
||||
- [ ] Price type correctly specified (fixed/variable/spontaneous)
|
||||
- [ ] Amount bounds validated
|
||||
Required checks:
|
||||
|
||||
### Debit Flow (Kind 21002)
|
||||
Request structure:
|
||||
```json
|
||||
{
|
||||
"method": "pay_invoice",
|
||||
"params": {
|
||||
"invoice": "lnbc...",
|
||||
"amount_msat": 100000
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] `uses: 1` — single-use prevents replay
|
||||
- [ ] `min_withdrawable === max_withdrawable === ctx.satsAmount` — exact amount, no operator slippage
|
||||
- [ ] The LNURL is composed from `VITE_LNBITS_HTTP_URL` + `link.unique_hash` (the transport `lnurlw_create_link` returns `link.lnurl === null` — those fields are filled by HTTP views, not the create RPC)
|
||||
- [ ] bech32 encoding uses HRP `"lnurl"` and the result is uppercased per BOLT/LNURL convention
|
||||
- [ ] 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)
|
||||
|
||||
Response structure:
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"preimage": "...",
|
||||
"fee_msat": 1000
|
||||
}
|
||||
}
|
||||
```
|
||||
### 6. Operator commands (`--clink`, kind-21003)
|
||||
|
||||
Validation:
|
||||
- [ ] Invoice format valid (BOLT11)
|
||||
- [ ] Amount matches invoice
|
||||
- [ ] Preimage verified against payment hash
|
||||
- [ ] Fee within acceptable bounds
|
||||
- [ ] Timeout handling
|
||||
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.
|
||||
|
||||
### Manage Flow (Kind 21003)
|
||||
For operator commands:
|
||||
```json
|
||||
{
|
||||
"method": "get_balance",
|
||||
"params": {}
|
||||
}
|
||||
```
|
||||
Required checks:
|
||||
|
||||
Validation:
|
||||
- [ ] Only authorized operators can send
|
||||
- [ ] Commands are properly authenticated
|
||||
- [ ] Responses handled securely
|
||||
- [ ] `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)
|
||||
|
||||
## Invoice Validation
|
||||
|
||||
### BOLT11 Checks
|
||||
- [ ] Valid bech32 encoding
|
||||
- [ ] Expiry not passed
|
||||
- [ ] Amount matches expected
|
||||
- [ ] Description hash valid (if used)
|
||||
- [ ] Payment hash extractable
|
||||
|
||||
### Security Checks
|
||||
- [ ] Never pay same invoice twice
|
||||
- [ ] Amount limits enforced
|
||||
- [ ] Rate limiting on payments
|
||||
- [ ] Proper logging (no sensitive data)
|
||||
|
||||
## Wallet Operations
|
||||
|
||||
### Balance Queries
|
||||
- [ ] Cached appropriately (not every render)
|
||||
- [ ] Error handling for offline
|
||||
- [ ] Display in correct units (sats, not msat)
|
||||
|
||||
### Invoice Generation
|
||||
- [ ] Unique payment hashes
|
||||
- [ ] Reasonable expiry times
|
||||
- [ ] Description for record-keeping
|
||||
- [ ] Amount in correct units
|
||||
|
||||
## Output Format
|
||||
## Output format
|
||||
|
||||
```markdown
|
||||
## Lightning.Pub Conformity: [target]
|
||||
## Lightning backend conformity: [target]
|
||||
|
||||
### CLINK Protocol
|
||||
| Flow | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| Offer (21001) | ✅ | |
|
||||
| Debit (21002) | ⚠️ | Missing timeout |
|
||||
| Manage (21003) | ✅ | |
|
||||
### 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 |
|
||||
|
||||
### Invoice Handling
|
||||
- [ ] `file:line` - Issue description
|
||||
### Cash-out / Cash-in flows
|
||||
- [ ] `file:line` — Issue description + fix
|
||||
|
||||
### Integration Issues
|
||||
- [ ] Description with fix suggestion
|
||||
### Operator (kind-21003)
|
||||
- [ ] `file:line` — Issue description
|
||||
|
||||
### Recommendations
|
||||
- [ ] Performance/UX improvements
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
## Example
|
||||
|
||||
```
|
||||
/lightning-check packages/lightning/src/ --clink
|
||||
/lightning-check packages/lnbits/src/ --lnbits
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```markdown
|
||||
## Lightning.Pub Conformity: packages/lightning/src/
|
||||
## Lightning backend conformity: packages/lnbits/src/
|
||||
|
||||
### CLINK Protocol
|
||||
| Flow | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| Offer (21001) | ✅ | Properly encoded |
|
||||
| Debit (21002) | ⚠️ | No timeout handling |
|
||||
### 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 |
|
||||
|
||||
### Issues Found
|
||||
- [ ] `client.ts:89` - Debit request has no timeout, could hang indefinitely
|
||||
- [ ] `client.ts:112` - Payment hash not verified against preimage
|
||||
|
||||
### Recommendations
|
||||
- Add 30-second timeout for CLINK debit requests
|
||||
- Implement invoice deduplication to prevent double-pay
|
||||
### 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue