Compare commits
No commits in common. "fix/sintra-timezone" and "main" have entirely different histories.
fix/sintra
...
main
111 changed files with 1743 additions and 10314 deletions
36
CLAUDE.md
36
CLAUDE.md
|
|
@ -15,11 +15,9 @@ Core principles:
|
|||
|
||||
## Provenance + legal status
|
||||
|
||||
The HAL drivers (validators / dispensers / printers) and the cash-flow state machine derive from Lamassu Industries AG's `lamassu-machine` repository, **only up to commit `c0b69d1ed196d396c5f057478c2ea290babd58ab`** ("chore: v8.6.0-beta.9", 2023-09-19) — the last commit published into the public domain (`UNLICENSE` in tree). The very next commit, `a9234d124d` ("chore: add LICENSE (#1019)", 2023-09-19), removed `UNLICENSE` and added Lamassu's proprietary "Appendix A SLA". **The `v8.1.5` tag (2023-09-21) already ships the Appendix A license** — the previously documented "8.1.5 is the open boundary" was wrong (verified against GitHub history 2026-07-04). Note the public-domain boundary sits on the 8.6-beta line, which is *further along* than 8.1.5 feature-wise.
|
||||
The HAL drivers (validators / dispensers / printers) and the cash-flow state machine derive from Lamassu Industries AG's open-source `lamassu-machine` and `lamassu-server` repositories, **only up to v8.1.5** — the last release published under a fully-open license. Lamassu transitioned to a proprietary, source-available license (their custom "Appendix A SLA") on 2024-01-26 and gated v8.1.6+ behind a paid OSA subscription.
|
||||
|
||||
**Hard rule when working in this repo:** do not pull, port, or copy lamassu-machine code from `a9234d124d` or later (which includes every 8.1.5+ tag). Reference only `c0b69d1` or earlier. If a HAL bug fix or feature exists upstream past that commit, either (a) reimplement from protocol docs / hardware specs without looking at the licensed source, or (b) raise the question with the maintainer first. To fetch the open tree safely: `git fetch --depth 1 origin c0b69d1ed196d396c5f057478c2ea290babd58ab` — never check out a tag.
|
||||
|
||||
The `lamassu-server` boundary has not been re-verified against its own history and may differ — check its license-change commit before referencing it.
|
||||
**Hard rule when working in this repo:** do not pull, port, or copy code from lamassu-machine / lamassu-server at v8.1.6 or later. If a HAL bug fix or feature exists upstream past 8.1.5, either (a) reimplement from protocol docs / hardware specs without looking at v8.1.6+ source, or (b) raise the question with the maintainer first. The 8.1.5 tree is fair game; everything after is licensed code we have no rights to.
|
||||
|
||||
bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lamassu Industries AG.
|
||||
|
||||
|
|
@ -84,34 +82,13 @@ Renderer reads (Electron IPC or Vite `import.meta.env`):
|
|||
|
||||
| Var | Required | Notes |
|
||||
|---|---|---|
|
||||
| `VITE_RELAY_URL` | no (seed-provided) | Relay both ATM and LNbits subscribe to. **Comes from the pairing seed** (aiolabs/bitspire#70); set this only as an override — it WINS over the seed via env-first precedence. Dev override: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
|
||||
| `VITE_LNBITS_SERVER_PUBKEY` | no (seed-provided) | 64-char hex transport pubkey. **Comes from the seed's `lnbits_npub`** (#70); env override only. LNbits prints it on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
|
||||
| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<base64url>`) from spirekeeper. Carries the relay(s), the LNbits transport pubkey (`lnbits_npub`), the spire signing pubkey (`spire_npub`), and a one-shot NIP-46 connect token (#70 slimmed the shape). First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. |
|
||||
| `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. |
|
||||
| `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
|
||||
| `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
|
||||
| `VITE_ATM_PRIVATE_KEY` | yes (prod) | 64-char hex. The ATM's nostr identity. Generates ephemeral on first boot if unset (dev only) |
|
||||
| `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |
|
||||
|
||||
The LP-era vars (`VITE_LIGHTNING_PUB_PUBKEY`, `VITE_LIGHTNING_PUB_API_URL`, `VITE_EXTENSION_API_URL`, `VITE_ADMIN_TOKEN`) are gone from the dev branch's `.env.example` and `LightningConfig` interface.
|
||||
|
||||
## Pairing (on-machine QR wizard)
|
||||
|
||||
A machine with no seed **and** no stored binding boots `unpaired` and, under
|
||||
Electron, renders an interactive wizard (`src/components/PairingWizard.vue`)
|
||||
instead of a dead-end fault screen. The operator displays the `spire-seed`
|
||||
QR (minted by spirekeeper's `/pair`) to the machine's camera; the wizard:
|
||||
|
||||
1. captures + decodes via a `PairingSource` (`src/services/pairing/`) — camera
|
||||
today (decode through `qr`, paulmillr's zero-dep lib), NFC scaffolded;
|
||||
2. validates the scan parses as a spire-seed (`ingestScannedSeed`), rejecting
|
||||
a stray QR;
|
||||
3. persists it as `VITE_SPIRE_SEED` via the `state:save-spire-seed` IPC and
|
||||
relaunches (`app:relaunch`).
|
||||
|
||||
Pairing itself is **not** done in the wizard — relaunch lets the normal boot
|
||||
path (`signer-resolver` → `connectNewSeed`) redeem the one-shot token, so
|
||||
there's one tested pairing path. A revoked/expired binding lands on the same
|
||||
wizard (re-pair = scan a fresh seed). Provisioning `VITE_SPIRE_SEED` up front
|
||||
still works and skips the wizard.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
|
|
@ -211,7 +188,7 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l
|
|||
|
||||
## Security priorities
|
||||
|
||||
1. **Private keys** — Never log nsec. In production the ATM holds no signing nsec: `VITE_SPIRE_SEED` (in `/var/lib/bitspire/.env`, mode 0600) carries a one-shot connect token, and the ATM's own NIP-46 *transport* key (`client_secret_hex`) lives in `state.db` (`bunker_binding`). The operator's signing key stays in the bunker. The legacy `VITE_ATM_PRIVATE_KEY` is a dev-only fallback.
|
||||
1. **Private keys** — Never log nsec. The ATM's `VITE_ATM_PRIVATE_KEY` lives in `/var/lib/bitspire/.env` with mode 0600, owned by `bitspire:bitspire`.
|
||||
2. **Payments** — Validate the bolt11 amount on cash-out before exposing the QR. Decode `payment_hash` from the bolt11 (cheap, avoids a roundtrip) and use it as the `subscribe_payments` filter.
|
||||
3. **Replay** — LNURL-withdraw links use `uses:1` and are deleted on session abort.
|
||||
4. **Encryption** — All RPC content is NIP-44 v2. NIP-04 is forbidden.
|
||||
|
|
@ -219,7 +196,6 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l
|
|||
## Useful invariants when debugging
|
||||
|
||||
- The renderer logs prefix every line with a tag: `[Lightning]`, `[ATM]`, `[ATM Service]`, `[LNURL Session]`, `[CLINK]`, `[StateStore]`. `journalctl -u bitspire | grep '\['` is your friend.
|
||||
- **Never pass an object as a console argument in the renderer.** Electron's console bridge stringifies each argument, so `console.log('msg:', { a, b })` reaches the journal as `msg: [object Object]` and every field is lost. Interpolate instead. Cost a debugging session on 2026-09-23, when a cassette publish that had worked looked like it had done nothing.
|
||||
- `bitspire.service` runs as the `lamassu` user; `/var/lib/bitspire` is its `dataDir` (ReadWritePaths). DB lives at `/var/lib/bitspire/state.db` (we previously had `/var/lib/lamassu-atm` — that path is gone on dev, see commit `9c455d6`).
|
||||
- The `lightning.lightningPub` field on `LightningServices` is a `LightningBackend` *adapter*, not a `LightningPubClient`. Don't try to call LP-only methods on it.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,15 +19,11 @@ VITE_LAMASSU_FIAT_CODE=USD
|
|||
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]'
|
||||
|
||||
# =============================================================================
|
||||
# LNbits Connection (dev override — normally seed-provided) — nostr-native-transport
|
||||
# LNbits Connection (Required) — nostr-native-transport
|
||||
# =============================================================================
|
||||
# On a real machine the pairing SEED (VITE_SPIRE_SEED) carries the relay AND the
|
||||
# server pubkey (aiolabs/bitspire#70), so leave both blank there. Set them here
|
||||
# only for browser dev without a seed/bunker — they WIN over the seed.
|
||||
|
||||
# Nostr relay WebSocket URL. Dev stack uses LNbits's bundled nostrrelay:
|
||||
# VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test
|
||||
VITE_RELAY_URL=
|
||||
# Nostr relay WebSocket URL — relay LNbits is subscribed to.
|
||||
VITE_RELAY_URL=ws://localhost:7777
|
||||
|
||||
# LNbits nostr-transport server pubkey (hex, 64 chars).
|
||||
# Printed by the LNbits server on startup:
|
||||
|
|
@ -40,23 +36,16 @@ VITE_LNBITS_SERVER_PUBKEY=
|
|||
# aiolabs/withdraw#1 / commit e9d911e.)
|
||||
|
||||
# =============================================================================
|
||||
# ATM Identity — spire pairing seed (NIP-46 bunker; aiolabs/bitspire#52)
|
||||
# ATM Identity
|
||||
# =============================================================================
|
||||
|
||||
# The spire pairing seed produced by the operator dashboard (spirekeeper):
|
||||
# spire-seed:v1:<base64url>
|
||||
# It carries a one-shot NIP-46 connect token + the spire's signing pubkey +
|
||||
# the bunker URL. On first boot the ATM redeems the token, generates its own
|
||||
# transport key, and persists the binding to state.db; thereafter it resumes
|
||||
# from the binding (the seed can stay set — it's matched by fingerprint).
|
||||
# A changed seed re-pairs (and re-publishes the cassette-state hello).
|
||||
VITE_SPIRE_SEED=
|
||||
|
||||
# pragma: allowlist secret
|
||||
# DEV ONLY fallback — a raw Nostr private key (hex, 64 chars) for running
|
||||
# without a bunker. Ignored when VITE_SPIRE_SEED or a stored binding exists.
|
||||
# ATM's Nostr private key (hex format, 64 characters). This signing
|
||||
# key IS the credential — LNbits derives the account from it on first
|
||||
# contact (issue aiolabs/lnbits#9 alignment).
|
||||
# Generate with: openssl rand -hex 32
|
||||
# VITE_ATM_PRIVATE_KEY=
|
||||
# If not set, generates ephemeral identity on each restart (dev only).
|
||||
VITE_ATM_PRIVATE_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# Operator Identity
|
||||
|
|
@ -73,18 +62,6 @@ VITE_SPIRE_SEED=
|
|||
# Show "Under Service" screen and block all transactions
|
||||
# VITE_MAINTENANCE_MODE=true
|
||||
|
||||
# =============================================================================
|
||||
# Public Web Demo
|
||||
# =============================================================================
|
||||
|
||||
# Set ONLY for the browser demo build (atm.demo.aiolabs.dev). Leave blank on
|
||||
# every real machine. When set it:
|
||||
# - keeps the mouse cursor visible (kiosk builds hide it)
|
||||
# - mints one extra, never-used LNbits wallet named with this exact string,
|
||||
# so the throwaway accounts the demo creates (one per page load, each with
|
||||
# its own ephemeral identity) can be swept by name instead of guessed at.
|
||||
# VITE_DEMO_TAG=bitspire-web-demo
|
||||
|
||||
# =============================================================================
|
||||
# Mock Fallback (Production Safety)
|
||||
# =============================================================================
|
||||
|
|
@ -93,31 +70,3 @@ VITE_SPIRE_SEED=
|
|||
# Set to 'true' for development/demo environments only
|
||||
# When false (production default), initialization failures show a maintenance screen
|
||||
# VITE_ALLOW_MOCK_FALLBACK=true
|
||||
|
||||
# =============================================================================
|
||||
# Access Control (ADR-003)
|
||||
# =============================================================================
|
||||
|
||||
# Tap-to-enter gate. When disabled (default), the machine boots straight to
|
||||
# idle exactly as before. When enabled, it boots into a locked screen and a
|
||||
# Bolt Card tap (read by the main-process NFC service over pcscd) unlocks it
|
||||
# and loads the card for the session, so buy/sell finish with one Complete.
|
||||
# ACCESS_CONTROL_ENABLED=true
|
||||
|
||||
# Admit ANY Bolt Card when the allow-list has no match. With this on the gate
|
||||
# only keeps casual users off the menu — any NDEF tag with a /scan/<id> URL
|
||||
# unlocks it; money still moves only on a valid SUN at Complete. Turn OFF once
|
||||
# a real allow-list (/var/lib/bitspire/access.json) is provisioned.
|
||||
# ACCESS_OPEN_ENROLLMENT=true
|
||||
|
||||
# Show the on-screen runtime dev/operator unlock button on the locked screen.
|
||||
# Default OFF — it bypasses the gate, so enable only on a bench/dev machine.
|
||||
# ACCESS_DEV_UNLOCK=true
|
||||
|
||||
# Per-machine salt for hashing credentials/PINs. Provision a real value in
|
||||
# production (or in access.json); a fixed default is used if unset.
|
||||
# ACCESS_SALT=change-me-per-machine
|
||||
|
||||
# Build/dev bypass — forces the gate OPEN even when enabled (browser dev / CI).
|
||||
# Renderer-side (Vite) flag, never set in a production image.
|
||||
# VITE_SKIP_ACCESS_GATE=true
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
/**
|
||||
* Tests for bunker-binding persistence in state-store (aiolabs/bitspire#52,
|
||||
* transport config added in #70).
|
||||
*
|
||||
* Validates the round-trip of the binding singleton, including the v11→v12
|
||||
* transport columns (relays JSON + lnbits_server_pubkey) and their absence on
|
||||
* a pre-#70 binding.
|
||||
*
|
||||
* Uses an in-memory SQLite database — fresh per test, no on-disk artifacts.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearBunkerBinding,
|
||||
closeDatabase,
|
||||
getBunkerBinding,
|
||||
initDatabase,
|
||||
saveBunkerBinding,
|
||||
type StoredBunkerBinding,
|
||||
} from '../state-store.js'
|
||||
|
||||
const BASE: StoredBunkerBinding = {
|
||||
clientSecretHex: 'aa'.repeat(32),
|
||||
spirePubkey: 'bb'.repeat(32),
|
||||
bunkerUrl: 'bunker://bb?relay=wss%3A%2F%2Fr%2F&secret=deadbeef',
|
||||
seedFingerprint: 'cc'.repeat(32),
|
||||
pairedAt: 1_780_000_000,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initDatabase(':memory:')
|
||||
})
|
||||
afterEach(() => {
|
||||
closeDatabase()
|
||||
})
|
||||
|
||||
describe('bunker binding persistence', () => {
|
||||
it('round-trips a binding carrying transport config (#70)', () => {
|
||||
const binding: StoredBunkerBinding = {
|
||||
...BASE,
|
||||
relays: ['wss://one.relay/', 'wss://two.relay/'],
|
||||
lnbitsServerPubkey: 'dd'.repeat(32),
|
||||
}
|
||||
saveBunkerBinding(binding)
|
||||
expect(getBunkerBinding()).toEqual(binding)
|
||||
})
|
||||
|
||||
it('round-trips a pre-#70 binding (no transport config) as undefined fields', () => {
|
||||
saveBunkerBinding(BASE)
|
||||
const got = getBunkerBinding()
|
||||
expect(got).toEqual(BASE)
|
||||
expect(got?.relays).toBeUndefined()
|
||||
expect(got?.lnbitsServerPubkey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('upserts transport config in place (re-pair overwrites)', () => {
|
||||
saveBunkerBinding({ ...BASE, relays: ['wss://old/'], lnbitsServerPubkey: 'ee'.repeat(32) })
|
||||
saveBunkerBinding({ ...BASE, relays: ['wss://new/'], lnbitsServerPubkey: 'ff'.repeat(32) })
|
||||
const got = getBunkerBinding()
|
||||
expect(got?.relays).toEqual(['wss://new/'])
|
||||
expect(got?.lnbitsServerPubkey).toBe('ff'.repeat(32))
|
||||
})
|
||||
|
||||
it('returns null after clear', () => {
|
||||
saveBunkerBinding(BASE)
|
||||
clearBunkerBinding()
|
||||
expect(getBunkerBinding()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,495 +0,0 @@
|
|||
/**
|
||||
* Tests for recordTransaction inventory accounting.
|
||||
*
|
||||
* Regression coverage for the position-vs-denomination decrement bug:
|
||||
* position is the cassettes PK (v9) and duplicate denominations across
|
||||
* bays are legal, so cash-out decrements MUST address bays by position.
|
||||
* A denomination-keyed UPDATE would drain every matching bay at once.
|
||||
*
|
||||
* Uses an in-memory SQLite database — fresh per test, no on-disk
|
||||
* artifacts, no parallel-test interference.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyOperatorCassetteOps,
|
||||
closeDatabase,
|
||||
getAppliedOpIds,
|
||||
getCassetteStateSeq,
|
||||
getCashbox,
|
||||
getCountsUncertainSince,
|
||||
getInventory,
|
||||
initDatabase,
|
||||
loadCassettes,
|
||||
markCountsUncertain,
|
||||
recordTransaction,
|
||||
setCassettes,
|
||||
} from '../state-store.js'
|
||||
|
||||
const TX_BASE = {
|
||||
fiatCents: 4000,
|
||||
sats: 100_000,
|
||||
feeSats: 5_000,
|
||||
feeFraction: 0.05,
|
||||
exchangeRate: 2500,
|
||||
currency: 'USD',
|
||||
}
|
||||
|
||||
/** Two $20 bays plus one $50 bay — the duplicate-denomination layout. */
|
||||
function seedDuplicateDenomBays() {
|
||||
setCassettes([
|
||||
{ position: 1, denomination: 20, count: 50 },
|
||||
{ position: 2, denomination: 20, count: 50 },
|
||||
{ position: 3, denomination: 50, count: 30 },
|
||||
])
|
||||
}
|
||||
|
||||
function countsByPosition(): Record<number, number> {
|
||||
const out: Record<number, number> = {}
|
||||
for (const row of loadCassettes()) out[row.position] = row.count
|
||||
return out
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initDatabase(':memory:')
|
||||
seedDuplicateDenomBays()
|
||||
})
|
||||
afterEach(() => {
|
||||
closeDatabase()
|
||||
})
|
||||
|
||||
describe('state-store: recordTransaction cash_out inventory', () => {
|
||||
it('decrements only the bay that actually dispensed (duplicate denominations)', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-single-bay',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 3 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette1',
|
||||
position: 1,
|
||||
denomination: 20,
|
||||
provisioned: 3,
|
||||
dispensed: 3,
|
||||
rejected: 0,
|
||||
},
|
||||
{
|
||||
name: 'cassette2',
|
||||
position: 2,
|
||||
denomination: 20,
|
||||
provisioned: 0,
|
||||
dispensed: 0,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(countsByPosition()).toEqual({ 1: 47, 2: 50, 3: 30 })
|
||||
})
|
||||
|
||||
it('decrements each bay by its own dispensed count on a split dispense', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-split-bays',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 60 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette1',
|
||||
position: 1,
|
||||
denomination: 20,
|
||||
provisioned: 50,
|
||||
dispensed: 50,
|
||||
rejected: 0,
|
||||
},
|
||||
{
|
||||
name: 'cassette2',
|
||||
position: 2,
|
||||
denomination: 20,
|
||||
provisioned: 10,
|
||||
dispensed: 10,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(countsByPosition()).toEqual({ 1: 0, 2: 40, 3: 30 })
|
||||
})
|
||||
|
||||
it('fallback without cassette results drains matching bays greedily by position', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-fallback',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 60 }],
|
||||
})
|
||||
|
||||
// Bay 1 (50 bills) drains fully, bay 2 covers the remaining 10.
|
||||
expect(countsByPosition()).toEqual({ 1: 0, 2: 40, 3: 30 })
|
||||
})
|
||||
|
||||
it('never drives a bay count below zero', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-overdispense',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 50, count: 35 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette3',
|
||||
position: 3,
|
||||
denomination: 50,
|
||||
provisioned: 35,
|
||||
dispensed: 35,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: recordTransaction cash_in cashbox', () => {
|
||||
it('adds inserted bills to the cashbox and leaves cassettes untouched', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-cash-in',
|
||||
type: 'cash_in',
|
||||
status: 'complete',
|
||||
bills: [
|
||||
{ denomination: 20, count: 2 },
|
||||
{ denomination: 50, count: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
const cashbox = getCashbox()
|
||||
expect(cashbox.totalBills).toBe(3)
|
||||
expect(cashbox.totalFiatCents).toBe(TX_BASE.fiatCents)
|
||||
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: recordTransaction manual_dispense inventory (#76)', () => {
|
||||
it('decrements the bays an operator remediation actually emptied', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-manual',
|
||||
type: 'manual_dispense',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 2 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette1',
|
||||
position: 1,
|
||||
denomination: 20,
|
||||
provisioned: 2,
|
||||
dispensed: 2,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
// Bills physically left bay 1; before #76 this row was untouched and the
|
||||
// inflated count became truth on the next boot.
|
||||
expect(countsByPosition()).toEqual({ 1: 48, 2: 50, 3: 30 })
|
||||
})
|
||||
|
||||
it('decrements again when remediating a partly-dispensed cash-out', () => {
|
||||
// Original cash-out managed 1 of the 2 notes it provisioned.
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-partial',
|
||||
type: 'cash_out',
|
||||
status: 'partial',
|
||||
bills: [{ denomination: 50, count: 1 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette3',
|
||||
position: 3,
|
||||
denomination: 50,
|
||||
provisioned: 2,
|
||||
dispensed: 1,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(countsByPosition()[3]).toBe(29)
|
||||
|
||||
// The operator dispenses the missing note by hand. That is a second lot of
|
||||
// bills leaving the bay, so it debits again — the original only ever
|
||||
// debited what physically left.
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-remediate',
|
||||
type: 'manual_dispense',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 50, count: 1 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette3',
|
||||
position: 3,
|
||||
denomination: 50,
|
||||
provisioned: 1,
|
||||
dispensed: 1,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(countsByPosition()[3]).toBe(28)
|
||||
})
|
||||
|
||||
it('leaves the cashbox alone (bills leave, they do not arrive)', () => {
|
||||
const before = getCashbox()
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-manual-cashbox',
|
||||
type: 'manual_dispense',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 1 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette2',
|
||||
position: 2,
|
||||
denomination: 20,
|
||||
provisioned: 1,
|
||||
dispensed: 1,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(getCashbox()).toEqual(before)
|
||||
expect(countsByPosition()[2]).toBe(49)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: getInventory represents a drained machine', () => {
|
||||
it('keeps configured bays at zero rather than dropping them', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-drain-50s',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 50, count: 30 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette3',
|
||||
position: 3,
|
||||
denomination: 50,
|
||||
provisioned: 30,
|
||||
dispensed: 30,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
// The $50 bay is empty but still configured. Dropping the key made this
|
||||
// look like "no inventory known", and callers then fell back to a stale
|
||||
// snapshot or to HAL.
|
||||
expect(getInventory()).toEqual({ 20: 100, 50: 0 })
|
||||
})
|
||||
|
||||
it('reports every bay at zero when the machine is fully drained', () => {
|
||||
for (const [txid, position, denomination, count] of [
|
||||
['d1', 1, 20, 50],
|
||||
['d2', 2, 20, 50],
|
||||
['d3', 3, 50, 30],
|
||||
] as const) {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid,
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination, count }],
|
||||
cassettes: [
|
||||
{
|
||||
name: `cassette${position}`,
|
||||
position,
|
||||
denomination,
|
||||
provisioned: count,
|
||||
dispensed: count,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
expect(getInventory()).toEqual({ 20: 0, 50: 0 })
|
||||
})
|
||||
|
||||
it('returns an empty map only when no cassettes are configured', () => {
|
||||
// A fresh DB with no bays at all — the one case that should read as
|
||||
// "nothing known", so callers may legitimately defer to the hardware.
|
||||
closeDatabase()
|
||||
initDatabase(':memory:')
|
||||
expect(getInventory()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: unverified counts after a silent dispense', () => {
|
||||
it('starts clear, latches the first time, and keeps the earliest time', () => {
|
||||
expect(getCountsUncertainSince()).toBeNull()
|
||||
markCountsUncertain(1000)
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
// A second failure does not move the clock forward — the question is how
|
||||
// long the numbers have been untrustworthy, not when we last noticed.
|
||||
markCountsUncertain(2000)
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
})
|
||||
|
||||
it('clears on a recount, because that is what a recount is', () => {
|
||||
markCountsUncertain(1000)
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'recount', position: 1, count: 40 },
|
||||
])
|
||||
expect(result.applied).toEqual(['op-1'])
|
||||
expect(getCountsUncertainSince()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear on a refill', () => {
|
||||
// A refill adds to a number still known to be wrong. Only someone
|
||||
// opening the bay and counting it resolves that.
|
||||
markCountsUncertain(1000)
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
||||
])
|
||||
expect(result.applied).toEqual(['op-1'])
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
})
|
||||
|
||||
it('leaves the flag alone when the op is rejected', () => {
|
||||
markCountsUncertain(1000)
|
||||
// Bay 9 does not exist — the layout is hardware-determined.
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'recount', position: 9, count: 40 },
|
||||
])
|
||||
expect(result.applied).toEqual([])
|
||||
expect(result.rejected).toHaveLength(1)
|
||||
expect(getCountsUncertainSince()).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: operator cassette operations (ADR-004)', () => {
|
||||
beforeEach(() => {
|
||||
seedDuplicateDenomBays()
|
||||
})
|
||||
|
||||
it('applies a refill as a delta, not a total', () => {
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 30 },
|
||||
])
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80)
|
||||
})
|
||||
|
||||
it('is a no-op on a re-delivered operation', () => {
|
||||
// Addressable events are re-delivered on every relay reconnect and the
|
||||
// operator republishes a WINDOW, so the same op arrives many times. A
|
||||
// delta applied twice is simply wrong, which is why every op carries an
|
||||
// id and this table records the ones already applied.
|
||||
const op = {
|
||||
id: 'op-1',
|
||||
at: 1_700_000_000,
|
||||
type: 'refill' as const,
|
||||
position: 1,
|
||||
bills: 30,
|
||||
}
|
||||
expect(applyOperatorCassetteOps([op]).applied).toEqual(['op-1'])
|
||||
expect(applyOperatorCassetteOps([op]).applied).toEqual([])
|
||||
expect(applyOperatorCassetteOps([op, op]).applied).toEqual([])
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80)
|
||||
})
|
||||
|
||||
it('applies only the unseen ops from a window that mixes both', () => {
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
||||
])
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
||||
{ id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 5 },
|
||||
])
|
||||
expect(result.applied).toEqual(['op-2'])
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(65)
|
||||
})
|
||||
|
||||
it('applies a window oldest-first regardless of arrival order', () => {
|
||||
// A recount then a refill is not the same as the reverse, so ordering is
|
||||
// load-bearing and cannot be left to however the array arrived.
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-b', at: 1_700_000_002, type: 'refill', position: 1, bills: 7 },
|
||||
{ id: 'op-a', at: 1_700_000_001, type: 'recount', position: 1, count: 3 },
|
||||
])
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(10)
|
||||
})
|
||||
|
||||
it('empties a bay and sets a denomination', () => {
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'empty', position: 2 },
|
||||
{ id: 'op-2', at: 1_700_000_001, type: 'set_denomination', position: 2, denomination: 10 },
|
||||
])
|
||||
const bay = loadCassettes().find((c) => c.position === 2)!
|
||||
expect(bay.count).toBe(0)
|
||||
expect(bay.denomination).toBe(10)
|
||||
})
|
||||
|
||||
it('rejects a malformed op without applying or recording it', () => {
|
||||
// Unrecorded on purpose: it stays pending on the operator's dashboard,
|
||||
// which is the honest outcome. Recording it as applied would silence the
|
||||
// noise by telling the operator their refill landed.
|
||||
const result = applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: -5 },
|
||||
])
|
||||
expect(result.applied).toEqual([])
|
||||
expect(result.rejected[0]!.id).toBe('op-1')
|
||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(50)
|
||||
expect(getAppliedOpIds()).not.toContain('op-1')
|
||||
})
|
||||
|
||||
it('echoes applied ids back, newest first', () => {
|
||||
applyOperatorCassetteOps([
|
||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 1 },
|
||||
{ id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 1 },
|
||||
])
|
||||
expect(getAppliedOpIds()).toContain('op-1')
|
||||
expect(getAppliedOpIds()).toContain('op-2')
|
||||
})
|
||||
|
||||
it('advances the sequence on an applied op but not on a duplicate', () => {
|
||||
const op = {
|
||||
id: 'op-1',
|
||||
at: 1_700_000_000,
|
||||
type: 'refill' as const,
|
||||
position: 1,
|
||||
bills: 1,
|
||||
}
|
||||
const before = getCassetteStateSeq()
|
||||
applyOperatorCassetteOps([op])
|
||||
const after = getCassetteStateSeq()
|
||||
expect(after).toBeGreaterThan(before)
|
||||
applyOperatorCassetteOps([op])
|
||||
expect(getCassetteStateSeq()).toBe(after)
|
||||
})
|
||||
|
||||
it('advances the sequence on a dispense', () => {
|
||||
const before = getCassetteStateSeq()
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-seq',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 1 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette1',
|
||||
position: 1,
|
||||
denomination: 20,
|
||||
provisioned: 1,
|
||||
dispensed: 1,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(getCassetteStateSeq()).toBeGreaterThan(before)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { openCardSession, scanUrlToSessionUrl } from './boltcard-session'
|
||||
|
||||
const LNURLW =
|
||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||
|
||||
/** Mock fetch returning the given JSON bodies per call, in order (status 200). */
|
||||
function mockFetch(bodies: unknown[], status = 200) {
|
||||
const calls: string[] = []
|
||||
const impl = vi.fn(async (url: string | URL) => {
|
||||
calls.push(url.toString())
|
||||
const body = bodies[calls.length - 1]
|
||||
return { status, json: async () => body } as Response
|
||||
})
|
||||
return { impl: impl as unknown as typeof fetch, calls }
|
||||
}
|
||||
|
||||
const SESSION = {
|
||||
authenticated: true,
|
||||
external_id: 'abc123',
|
||||
card_name: 'Alice',
|
||||
balance_msat: 123_456_789,
|
||||
currency: 'usd',
|
||||
fiat: 98.76,
|
||||
withdraw: {
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
|
||||
k1: 'hit1',
|
||||
minWithdrawable: 1000,
|
||||
maxWithdrawable: 50_000_000,
|
||||
},
|
||||
withdraw_blocked_reason: null,
|
||||
pay: {
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
|
||||
minSendable: 1000,
|
||||
maxSendable: 50_000_000,
|
||||
metadata: '[["text/plain","Bolt Card top-up"]]',
|
||||
},
|
||||
}
|
||||
|
||||
describe('scanUrlToSessionUrl', () => {
|
||||
it('rewrites /scan/ to /session/ and preserves p + c', () => {
|
||||
const u = scanUrlToSessionUrl(LNURLW)
|
||||
expect(u).toContain('https://lnbits.l484.com/boltcards/api/v1/session/abc123')
|
||||
expect(u).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF')
|
||||
expect(u).toContain('c=1122334455667788')
|
||||
})
|
||||
it('returns null for a non-scan URL', () => {
|
||||
expect(scanUrlToSessionUrl('lnurlw://host/somethingelse?p=1&c=2')).toBeNull()
|
||||
expect(scanUrlToSessionUrl('http://host/boltcards/api/v1/scan/x')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('openCardSession', () => {
|
||||
it('opens a session: balance in sats, upper-cased currency, both steps', async () => {
|
||||
const f = mockFetch([SESSION])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(f.calls).toHaveLength(1)
|
||||
expect(f.calls[0]).toContain('/session/abc123')
|
||||
expect(out).toEqual({
|
||||
ok: true,
|
||||
session: {
|
||||
externalId: 'abc123',
|
||||
cardName: 'Alice',
|
||||
balanceSats: 123_456,
|
||||
currency: 'USD',
|
||||
fiat: 98.76,
|
||||
withdraw: SESSION.withdraw,
|
||||
withdrawBlockedReason: null,
|
||||
pay: SESSION.pay,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('carries a withheld withdraw step with its reason', async () => {
|
||||
const f = mockFetch([
|
||||
{ ...SESSION, withdraw: null, withdraw_blocked_reason: 'Max daily limit spent.' },
|
||||
])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out.ok).toBe(true)
|
||||
if (!out.ok) return
|
||||
expect(out.session.withdraw).toBeNull()
|
||||
expect(out.session.withdrawBlockedReason).toBe('Max daily limit spent.')
|
||||
expect(out.session.pay.callback).toBe(SESSION.pay.callback)
|
||||
})
|
||||
|
||||
it('has no fiat when the server sent no currency', async () => {
|
||||
const f = mockFetch([{ ...SESSION, currency: null, fiat: null }])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out.ok && out.session.currency).toBeNull()
|
||||
expect(out.ok && out.session.fiat).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces the server reason on a rejected tap', async () => {
|
||||
const f = mockFetch([{ authenticated: false, reason: 'This link is already used.' }])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'This link is already used.' })
|
||||
})
|
||||
|
||||
it('rejects an incomplete session (no pay step)', async () => {
|
||||
const f = mockFetch([{ ...SESSION, pay: undefined }])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'card server returned an incomplete session' })
|
||||
})
|
||||
|
||||
it('names an old card server that has no /session', async () => {
|
||||
const f = mockFetch([{ detail: 'Not Found' }], 404)
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'card server does not support sessions' })
|
||||
})
|
||||
|
||||
it('rejects a non-card tag without a network call', async () => {
|
||||
const f = mockFetch([])
|
||||
const out = await openCardSession('https://host/not/a/card', { fetchImpl: f.impl })
|
||||
expect(out.ok).toBe(false)
|
||||
expect(f.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports an unreachable card server', async () => {
|
||||
const impl = vi.fn(async () => {
|
||||
throw new TypeError('fetch failed')
|
||||
}) as unknown as typeof fetch
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'could not reach the card: fetch failed' })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
/**
|
||||
* Bolt Card session (tap-to-enter) — one tap, one verified visit.
|
||||
*
|
||||
* A Bolt Card tap yields a single-use SUN `p`/`c`; anything that verifies it
|
||||
* spends it. The access gate (ADR-003) wants to verify the card at entry AND
|
||||
* let the holder finish a buy or sell later without tapping again, so the
|
||||
* aiolabs `boltcards` fork exposes `/session/<external_id>?p=&c=` — a sibling
|
||||
* of `/scan` and `/pay` that verifies once, records one hit, and returns:
|
||||
* - the card wallet's balance and fiat equivalent (display only),
|
||||
* - the LUD-03 second step (withdraw callback + k1 = hit) for cash-out,
|
||||
* - the LUD-06 second step (pay callback) for cash-in.
|
||||
* Both callbacks are keyed by the hit — the same single-use bearer `/scan`
|
||||
* and `/pay` hand out — so the ATM holds no p/c for the rest of the visit.
|
||||
* The withdraw step is withheld (with a reason) once the card's daily limit is
|
||||
* spent, exactly as `/scan` would refuse.
|
||||
*
|
||||
* Runs in the MAIN process (Node fetch) to avoid renderer CORS, like the other
|
||||
* LNURL modules. See docs/boltcard-session.md for the wire contract.
|
||||
*/
|
||||
|
||||
import { lnurlwToHttps, type WithdrawStep } from './lnurl-withdraw.js'
|
||||
import type { PayStep } from './lnurl-pay.js'
|
||||
|
||||
export interface CardSession {
|
||||
externalId: string
|
||||
cardName: string
|
||||
balanceSats: number
|
||||
/** ISO currency the card server priced the balance in; null → no fiat. */
|
||||
currency: string | null
|
||||
/** Balance in `currency` at the card server's rate; null when unknown. */
|
||||
fiat: number | null
|
||||
/** LUD-03 second step, or null when the card server withheld it. */
|
||||
withdraw: WithdrawStep | null
|
||||
/** Why `withdraw` is null (e.g. daily limit spent); safe to show on-screen. */
|
||||
withdrawBlockedReason: string | null
|
||||
/** LUD-06 second step for topping the card wallet up. */
|
||||
pay: PayStep
|
||||
}
|
||||
|
||||
export type OpenCardSessionResult =
|
||||
| { ok: true; session: CardSession }
|
||||
| { ok: false; reason: string }
|
||||
|
||||
type FetchLike = typeof fetch
|
||||
|
||||
export interface OpenCardSessionOptions {
|
||||
/** Injected for tests; defaults to global fetch. */
|
||||
fetchImpl?: FetchLike
|
||||
/** Per-request timeout (default 15s). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the session URL from a tapped card's `lnurlw`: the card presents
|
||||
* `…/boltcards/api/v1/scan/<id>?p=&c=`; the session endpoint is its sibling
|
||||
* `…/boltcards/api/v1/session/<id>?p=&c=` with the same SUN.
|
||||
*/
|
||||
export function scanUrlToSessionUrl(lnurlw: string): string | null {
|
||||
const https = lnurlwToHttps(lnurlw)
|
||||
if (!https) return null
|
||||
const u = new URL(https)
|
||||
if (!u.pathname.includes('/scan/')) return null
|
||||
u.pathname = u.pathname.replace('/scan/', '/session/')
|
||||
return u.toString()
|
||||
}
|
||||
|
||||
/** Wire shape of a `/session` reply (any of the fields may be missing/odd). */
|
||||
interface SessionWire {
|
||||
authenticated?: unknown
|
||||
reason?: unknown
|
||||
external_id?: unknown
|
||||
card_name?: unknown
|
||||
balance_msat?: unknown
|
||||
currency?: unknown
|
||||
fiat?: unknown
|
||||
withdraw?: unknown
|
||||
withdraw_blocked_reason?: unknown
|
||||
pay?: unknown
|
||||
}
|
||||
|
||||
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
|
||||
const optNum = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
const optStr = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
|
||||
function parseWithdraw(v: unknown): WithdrawStep | null {
|
||||
if (!isObj(v)) return null
|
||||
const callback = optStr(v.callback)
|
||||
const k1 = optStr(v.k1)
|
||||
if (!callback || !k1) return null
|
||||
return {
|
||||
callback,
|
||||
k1,
|
||||
minWithdrawable: optNum(v.minWithdrawable),
|
||||
maxWithdrawable: optNum(v.maxWithdrawable),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePay(v: unknown): PayStep | null {
|
||||
if (!isObj(v)) return null
|
||||
const callback = optStr(v.callback)
|
||||
if (!callback) return null
|
||||
return {
|
||||
callback,
|
||||
minSendable: optNum(v.minSendable),
|
||||
maxSendable: optNum(v.maxSendable),
|
||||
metadata: optStr(v.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
if (e instanceof Error)
|
||||
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a session for a tapped card. Spends the tap's SUN. Never throws —
|
||||
* every failure returns `{ ok: false, reason }` (reasons come from the card
|
||||
* server verbatim and are safe to show).
|
||||
*/
|
||||
export async function openCardSession(
|
||||
lnurlw: string,
|
||||
opts: OpenCardSessionOptions = {}
|
||||
): Promise<OpenCardSessionResult> {
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const timeoutMs = opts.timeoutMs ?? 15_000
|
||||
|
||||
const url = scanUrlToSessionUrl(lnurlw)
|
||||
if (!url) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
|
||||
|
||||
let wire: SessionWire
|
||||
try {
|
||||
const res = await doFetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
if (res.status === 404) {
|
||||
// Older fork without /session — say so rather than "card rejected".
|
||||
return { ok: false, reason: 'card server does not support sessions' }
|
||||
}
|
||||
wire = (await res.json()) as SessionWire
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
|
||||
}
|
||||
|
||||
if (wire.authenticated !== true) {
|
||||
return { ok: false, reason: optStr(wire.reason) || 'card rejected the tap' }
|
||||
}
|
||||
const externalId = optStr(wire.external_id)
|
||||
const pay = parsePay(wire.pay)
|
||||
if (!externalId || !pay) {
|
||||
return { ok: false, reason: 'card server returned an incomplete session' }
|
||||
}
|
||||
const balanceMsat = optNum(wire.balance_msat) ?? 0
|
||||
const currency = optStr(wire.currency)?.toUpperCase() ?? null
|
||||
const fiat = optNum(wire.fiat)
|
||||
return {
|
||||
ok: true,
|
||||
session: {
|
||||
externalId,
|
||||
cardName: optStr(wire.card_name) ?? '',
|
||||
balanceSats: Math.floor(balanceMsat / 1000),
|
||||
currency,
|
||||
fiat: currency && fiat !== undefined ? fiat : null,
|
||||
withdraw: parseWithdraw(wire.withdraw),
|
||||
withdrawBlockedReason: optStr(wire.withdraw_blocked_reason) ?? null,
|
||||
pay,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -13,15 +13,8 @@
|
|||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import {
|
||||
NostrClient,
|
||||
LocalSigner,
|
||||
loadIdentityFromHex,
|
||||
resumeFromBinding,
|
||||
type Signer,
|
||||
} from '@bitSpire/nostr-client'
|
||||
import { NostrClient, loadIdentityFromHex } from '@bitSpire/nostr-client'
|
||||
import { LnbitsClient } from '@bitSpire/lnbits'
|
||||
import { initDatabase, getBunkerBinding } from './state-store.js'
|
||||
|
||||
// @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed
|
||||
import QRCode from 'qrcode'
|
||||
|
|
@ -63,38 +56,19 @@ async function main() {
|
|||
const lnbitsServerPubkey = env['VITE_LNBITS_SERVER_PUBKEY']
|
||||
const atmPrivateKey = env['VITE_ATM_PRIVATE_KEY']
|
||||
|
||||
if (!relayUrl || !lnbitsServerPubkey) {
|
||||
if (!relayUrl || !lnbitsServerPubkey || !atmPrivateKey) {
|
||||
console.error('Missing required config in', envPath)
|
||||
console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY')
|
||||
console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY, VITE_ATM_PRIVATE_KEY')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.error(`Generating invoice for ${amountSats} sats...`)
|
||||
|
||||
// Resolve the signer. Prod: resume the bunker binding from state.db (the
|
||||
// ATM's transport key — the connect token was already redeemed by the main
|
||||
// app, so we can't re-pair here). Dev: a local nsec via VITE_ATM_PRIVATE_KEY.
|
||||
let signer: Signer
|
||||
if (atmPrivateKey) {
|
||||
signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey))
|
||||
} else {
|
||||
initDatabase()
|
||||
const binding = getBunkerBinding()
|
||||
if (!binding) {
|
||||
console.error('ATM is not paired (no bunker binding in state.db) and no')
|
||||
console.error('VITE_ATM_PRIVATE_KEY set. Pair the ATM via the main app first.')
|
||||
process.exit(1)
|
||||
}
|
||||
signer = await resumeFromBinding({
|
||||
clientSecretHex: binding.clientSecretHex,
|
||||
spirePubkey: binding.spirePubkey,
|
||||
bunkerUrl: binding.bunkerUrl,
|
||||
})
|
||||
}
|
||||
const identity = loadIdentityFromHex(atmPrivateKey)
|
||||
|
||||
const nostrClient = new NostrClient({
|
||||
relays: [{ url: relayUrl }],
|
||||
signer,
|
||||
identity,
|
||||
})
|
||||
await nostrClient.connect()
|
||||
|
||||
|
|
@ -102,7 +76,7 @@ async function main() {
|
|||
serverPubkey: lnbitsServerPubkey,
|
||||
relays: [relayUrl],
|
||||
})
|
||||
lnbits.initialize(nostrClient, signer)
|
||||
lnbits.initialize(nostrClient, identity)
|
||||
|
||||
const wallets = await lnbits.listWallets()
|
||||
const wallet = wallets[0]
|
||||
|
|
|
|||
|
|
@ -36,11 +36,6 @@ export interface HalConfig {
|
|||
export interface ValidatorCallbacks {
|
||||
shouldAcceptBill: (denomination: number) => boolean | 'hold'
|
||||
onBillRead?: (denomination: number) => void
|
||||
/**
|
||||
* Fires on the validator's stacked-confirmation (`billsValid`) — the
|
||||
* bill physically reached the stacker. This is the CREDIT event; it is
|
||||
* NOT emitted at stack-command time (a stack can still fail/return).
|
||||
*/
|
||||
onBillInserted: (denomination: number) => void
|
||||
onBillRejected: (reason: string) => void
|
||||
onError: (error: string) => void
|
||||
|
|
@ -147,14 +142,6 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
count: c.count ?? 0,
|
||||
}))
|
||||
|
||||
// Escrow / in-flight bookkeeping (legacy brain.js `billsRead` interlock):
|
||||
// `escrowDenomination` = bill held in escrow awaiting a stack/reject
|
||||
// decision; `inFlightDenomination` = stack commanded, awaiting the
|
||||
// validator's `billsValid` stacked-confirmation. onBillInserted (the
|
||||
// credit event) fires only on that confirmation.
|
||||
let escrowDenomination: number | null = null
|
||||
let inFlightDenomination: number | null = null
|
||||
|
||||
return {
|
||||
connectValidator: (callbacks: ValidatorCallbacks) => {
|
||||
if (!validator) {
|
||||
|
|
@ -166,11 +153,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
const decision = callbacks.shouldAcceptBill(data.denomination)
|
||||
if (decision === 'hold') {
|
||||
console.log('[HAL] Bill in escrow:', data.denomination)
|
||||
escrowDenomination = data.denomination
|
||||
callbacks.onBillRead?.(data.denomination)
|
||||
} else if (decision) {
|
||||
inFlightDenomination = data.denomination
|
||||
validator.stack()
|
||||
callbacks.onBillInserted(data.denomination)
|
||||
} else {
|
||||
console.log('[HAL] Bill rejected: insufficient balance for', data.denomination)
|
||||
validator.reject()
|
||||
|
|
@ -182,23 +168,7 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
}
|
||||
})
|
||||
|
||||
// Stacked-confirmation → the credit event.
|
||||
validator.on('billsValid', () => {
|
||||
if (inFlightDenomination === null) {
|
||||
console.warn('[HAL] billsValid with no bill in flight — ignoring')
|
||||
return
|
||||
}
|
||||
const denomination = inFlightDenomination
|
||||
inFlightDenomination = null
|
||||
console.log('[HAL] Bill stacked (confirmed):', denomination)
|
||||
callbacks.onBillInserted(denomination)
|
||||
})
|
||||
|
||||
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
|
||||
// Covers both an escrow refusal and a failed/returned stack —
|
||||
// either way nothing was credited and nothing is in flight.
|
||||
escrowDenomination = null
|
||||
inFlightDenomination = null
|
||||
callbacks.onBillRejected(data?.reason ?? 'unknown')
|
||||
})
|
||||
|
||||
|
|
@ -221,32 +191,12 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
},
|
||||
|
||||
disableValidator: () => {
|
||||
// If a note is sitting in escrow when we disable (inactivity timeout,
|
||||
// cancel, or leaving the insert screen), return it to the customer.
|
||||
// Disabling alone does NOT release an escrowed note on EBDS — it would
|
||||
// be stranded in the transport until the next power cycle.
|
||||
if (escrowDenomination !== null) {
|
||||
console.log('[HAL] Returning escrowed bill on disable:', escrowDenomination)
|
||||
escrowDenomination = null
|
||||
validator?.reject()
|
||||
}
|
||||
validator?.disable()
|
||||
validator?.lightOff()
|
||||
},
|
||||
|
||||
stackBill: () => {
|
||||
if (escrowDenomination === null) {
|
||||
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
|
||||
return
|
||||
}
|
||||
inFlightDenomination = escrowDenomination
|
||||
escrowDenomination = null
|
||||
validator?.stack()
|
||||
},
|
||||
rejectBill: () => {
|
||||
escrowDenomination = null
|
||||
validator?.reject()
|
||||
},
|
||||
stackBill: () => validator?.stack(),
|
||||
rejectBill: () => validator?.reject(),
|
||||
|
||||
dispenseCash: async (amounts): Promise<DispenseResult> => {
|
||||
console.log('[HAL] Dispensing:', amounts)
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
resolveCardInvoice,
|
||||
resolveInvoiceFromPayStep,
|
||||
scanUrlToResolver,
|
||||
lnAddressToLnurlp,
|
||||
} from './lnurl-pay'
|
||||
|
||||
const LNURLW =
|
||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||
const BOLT11 = 'lnbc10u1p3xyz...'
|
||||
|
||||
/** Mock fetch that returns the given JSON bodies per call, in order. */
|
||||
function mockFetch(bodies: unknown[]) {
|
||||
const calls: string[] = []
|
||||
const impl = vi.fn(async (url: string | URL) => {
|
||||
calls.push(url.toString())
|
||||
const body = bodies[calls.length - 1]
|
||||
return { json: async () => body } as Response
|
||||
})
|
||||
return { impl: impl as unknown as typeof fetch, calls }
|
||||
}
|
||||
|
||||
describe('scanUrlToResolver', () => {
|
||||
it('rewrites /scan/ to /pay/ and preserves p + c', () => {
|
||||
const r = scanUrlToResolver(LNURLW)
|
||||
expect(r).toContain('https://lnbits.l484.com/boltcards/api/v1/pay/abc123')
|
||||
expect(r).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF')
|
||||
expect(r).toContain('c=1122334455667788')
|
||||
})
|
||||
it('returns null for a non-scan URL', () => {
|
||||
expect(scanUrlToResolver('lnurlw://host/somethingelse?p=1&c=2')).toBeNull()
|
||||
expect(scanUrlToResolver('http://host/boltcards/api/v1/scan/x')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lnAddressToLnurlp', () => {
|
||||
it('maps name@host to the well-known lnurlp URL', () => {
|
||||
expect(lnAddressToLnurlp('cardname@l484.com')).toBe(
|
||||
'https://l484.com/.well-known/lnurlp/cardname'
|
||||
)
|
||||
})
|
||||
it('rejects non-addresses', () => {
|
||||
expect(lnAddressToLnurlp('not-an-address')).toBeNull()
|
||||
expect(lnAddressToLnurlp('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCardInvoice', () => {
|
||||
const payReq = {
|
||||
tag: 'payRequest',
|
||||
callback: 'https://lnbits.l484.com/lnurlp/api/v1/lnurl/cb',
|
||||
minSendable: 1000,
|
||||
maxSendable: 100_000_000,
|
||||
metadata: '[["text/plain","bolt card top-up"]]',
|
||||
}
|
||||
|
||||
it('resolver returns a payRequest inline → fetches the invoice', async () => {
|
||||
const { impl, calls } = mockFetch([payReq, { pr: BOLT11 }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toEqual({ ok: true, bolt11: BOLT11 })
|
||||
// 1st call = the /pay resolver; 2nd = the callback with amount in msat.
|
||||
expect(calls[0]).toContain('/boltcards/api/v1/pay/abc123')
|
||||
expect(calls[1]).toContain('amount=21000')
|
||||
})
|
||||
|
||||
it('resolver returns a Lightning Address → LUD-16 → invoice', async () => {
|
||||
const { impl, calls } = mockFetch([
|
||||
{ lightningAddress: 'cardname@l484.com' },
|
||||
payReq,
|
||||
{ pr: BOLT11 },
|
||||
])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toEqual({ ok: true, bolt11: BOLT11 })
|
||||
expect(calls[1]).toBe('https://l484.com/.well-known/lnurlp/cardname')
|
||||
expect(calls[2]).toContain('amount=21000')
|
||||
})
|
||||
|
||||
it('rejects a non-lnurlw tag', async () => {
|
||||
const { impl } = mockFetch([])
|
||||
const res = await resolveCardInvoice('http://nope', 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false })
|
||||
expect(res.reason).toMatch(/not a valid Bolt Card/i)
|
||||
})
|
||||
|
||||
it('rejects a zero amount', async () => {
|
||||
const { impl } = mockFetch([])
|
||||
const res = await resolveCardInvoice(LNURLW, 0, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'no amount to send' })
|
||||
})
|
||||
|
||||
it('surfaces an ERROR from the resolver (bad SUN)', async () => {
|
||||
const { impl } = mockFetch([{ status: 'ERROR', reason: 'invalid card' }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'invalid card' })
|
||||
})
|
||||
|
||||
it('rejects (without calling the callback) when the amount exceeds maxSendable', async () => {
|
||||
const { impl, calls } = mockFetch([{ ...payReq, maxSendable: 5000 }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'amount is above the card wallet maximum' })
|
||||
expect(calls).toHaveLength(1) // callback never hit
|
||||
})
|
||||
|
||||
it('surfaces an ERROR from the pay callback', async () => {
|
||||
const { impl } = mockFetch([payReq, { status: 'ERROR', reason: 'wallet frozen' }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'wallet frozen' })
|
||||
})
|
||||
|
||||
it('rejects when the card wallet has no receive address', async () => {
|
||||
const { impl } = mockFetch([{ foo: 'bar' }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'card wallet has no receive address' })
|
||||
})
|
||||
|
||||
it('handles a network failure gracefully', async () => {
|
||||
const impl = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
}) as unknown as typeof fetch
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toMatch(/could not reach the card/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveInvoiceFromPayStep (session second step, no tap)', () => {
|
||||
const step = {
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
|
||||
minSendable: 1000,
|
||||
maxSendable: 50_000_000,
|
||||
metadata: '[["text/plain","Bolt Card top-up"]]',
|
||||
}
|
||||
|
||||
it('fetches an invoice for the amount from the callback', async () => {
|
||||
const f = mockFetch([{ pr: BOLT11 }])
|
||||
const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: true, bolt11: BOLT11 })
|
||||
expect(f.calls).toHaveLength(1)
|
||||
expect(f.calls[0]).toContain('amount=25000')
|
||||
})
|
||||
|
||||
it('enforces the step bounds without calling out', async () => {
|
||||
const f = mockFetch([])
|
||||
expect(await resolveInvoiceFromPayStep(step, 500, { fetchImpl: f.impl })).toEqual({
|
||||
ok: false,
|
||||
reason: 'amount is below the card wallet minimum',
|
||||
})
|
||||
expect(await resolveInvoiceFromPayStep(step, 60_000_000, { fetchImpl: f.impl })).toEqual({
|
||||
ok: false,
|
||||
reason: 'amount is above the card wallet maximum',
|
||||
})
|
||||
expect(await resolveInvoiceFromPayStep(step, 0, { fetchImpl: f.impl })).toEqual({
|
||||
ok: false,
|
||||
reason: 'no amount to send',
|
||||
})
|
||||
expect(f.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('surfaces a callback decline', async () => {
|
||||
const f = mockFetch([{ status: 'ERROR', reason: 'Card is disabled.' }])
|
||||
const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'Card is disabled.' })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
/**
|
||||
* LNURL-pay resolver (LUD-06 / LUD-16) — the ATM as the *paying* party.
|
||||
*
|
||||
* Bolt Card tap-to-RECEIVE for the cash-in (buy) flow. A Bolt Card only ever
|
||||
* emits its `lnurlw://…?p=…&c=…` voucher — a *withdraw* (spend) credential — so
|
||||
* we can't push sats into it directly. Instead the tap is used as an
|
||||
* authenticated identity (external_id + SUN p/c) to look up the card wallet's
|
||||
* *pay* target, then the ATM fetches an invoice for the payout amount:
|
||||
* 1. resolveCardPayTarget — GET the boltcards `/pay/<id>?p=&c=` resolver
|
||||
* (a sibling of `/scan`); it verifies the same SUN and returns the card
|
||||
* wallet's Lightning Address / lnurlp (or a LUD-06 payRequest directly).
|
||||
* 2. toPayRequest → LUD-16 (Lightning Address) or LUD-06 fetch → payRequest.
|
||||
* 3. requestInvoice — GET `callback?amount=<msat>` → a BOLT11 for the amount.
|
||||
* The returned BOLT11 is handed back to the renderer, which pays it over the
|
||||
* ATM's existing LNbits/nostr transport (stores/atm.ts `payInvoice`), so
|
||||
* settlement + PAYMENT_RECEIVED reuse the tested cash-in completion path.
|
||||
*
|
||||
* Runs in the MAIN process (Node fetch) to avoid renderer CORS, exactly like
|
||||
* lnurl-withdraw.ts.
|
||||
*
|
||||
* Transport seam: `resolveCardPayTarget()` is the single HTTPS-today /
|
||||
* Nostr-tomorrow swap point. The rest is standard LNURL-pay against whatever
|
||||
* pay target it returns and is transport-independent.
|
||||
*/
|
||||
|
||||
import { lnurlwToHttps } from './lnurl-withdraw.js'
|
||||
|
||||
export interface ResolveCardInvoiceResult {
|
||||
ok: boolean
|
||||
/** BOLT11 to pay when ok; the renderer settles it over the nostr transport. */
|
||||
bolt11?: string
|
||||
/** Human-readable reason when ok is false (safe to surface on-screen). */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type FetchLike = typeof fetch
|
||||
|
||||
export interface ResolveCardInvoiceOptions {
|
||||
/** Injected for tests; defaults to global fetch. */
|
||||
fetchImpl?: FetchLike
|
||||
/** Per-request timeout (default 15s). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/** Resolver response — any of these shapes is accepted (see the spec doc). */
|
||||
interface CardPayTarget {
|
||||
status?: string
|
||||
reason?: string
|
||||
// (a) a LUD-06 payRequest, inline
|
||||
tag?: string
|
||||
callback?: string
|
||||
minSendable?: number
|
||||
maxSendable?: number
|
||||
metadata?: string
|
||||
// (b) a Lightning Address, e.g. "cardname@l484.com"
|
||||
lightningAddress?: string
|
||||
// (c) an lnurlp pointer (https or lnurl://)
|
||||
lnurlp?: string
|
||||
lnurl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The LUD-06 second step on its own: what a `payRequest` (or a Bolt Card
|
||||
* session, see boltcard-session.ts) hands us to fetch an invoice.
|
||||
*/
|
||||
export interface PayStep {
|
||||
callback: string
|
||||
minSendable?: number
|
||||
maxSendable?: number
|
||||
metadata?: string
|
||||
}
|
||||
|
||||
/** LUD-06 payRequest (subset) + error shape. */
|
||||
interface PayRequest {
|
||||
tag?: string
|
||||
callback?: string
|
||||
minSendable?: number
|
||||
maxSendable?: number
|
||||
metadata?: string
|
||||
status?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** LUD-06 second-response (the callback body). */
|
||||
interface PayValues {
|
||||
pr?: string
|
||||
status?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
doFetch: FetchLike
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
if (e instanceof Error)
|
||||
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
function appendQuery(url: string, params: Record<string, string>): string {
|
||||
const u = new URL(url)
|
||||
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v)
|
||||
return u.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the boltcards *pay* resolver URL from a tapped card's `lnurlw`.
|
||||
* The card presents `…/boltcards/api/v1/scan/<id>?p=&c=` (a withdraw voucher);
|
||||
* the receive resolver is its sibling `…/boltcards/api/v1/pay/<id>?p=&c=`,
|
||||
* carrying the same SUN p/c. This is the HTTPS transport seam — a future
|
||||
* nostr-native card would resolve the same identity over nostr instead.
|
||||
*/
|
||||
export function scanUrlToResolver(lnurlw: string): string | null {
|
||||
const https = lnurlwToHttps(lnurlw)
|
||||
if (!https) return null
|
||||
const u = new URL(https)
|
||||
if (!u.pathname.includes('/scan/')) return null
|
||||
u.pathname = u.pathname.replace('/scan/', '/pay/')
|
||||
return u.toString()
|
||||
}
|
||||
|
||||
/** LUD-16: map a Lightning Address `name@host` to its lnurlp URL. */
|
||||
export function lnAddressToLnurlp(addr: string): string | null {
|
||||
const m = addr.trim().match(/^([a-z0-9._%+-]+)@([a-z0-9.-]+)$/i)
|
||||
if (!m) return null
|
||||
return `https://${m[2]}/.well-known/lnurlp/${m[1]}`
|
||||
}
|
||||
|
||||
async function fetchPayRequest(
|
||||
url: string,
|
||||
ctx: Ctx
|
||||
): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> {
|
||||
let body: PayRequest
|
||||
try {
|
||||
const res = await ctx.doFetch(url, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
||||
body = (await res.json()) as PayRequest
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not reach the card wallet: ${errMsg(e)}` }
|
||||
}
|
||||
if (body.status === 'ERROR') {
|
||||
return { ok: false, reason: body.reason || 'card wallet rejected the request' }
|
||||
}
|
||||
if (body.tag !== 'payRequest' || !body.callback) {
|
||||
return { ok: false, reason: 'card wallet did not return a pay request' }
|
||||
}
|
||||
return { ok: true, payRequest: body }
|
||||
}
|
||||
|
||||
/** Turn a resolver response into a LUD-06 payRequest (fetching if needed). */
|
||||
async function toPayRequest(
|
||||
target: CardPayTarget,
|
||||
ctx: Ctx
|
||||
): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> {
|
||||
// (a) resolver returned a LUD-06 payRequest inline.
|
||||
if (target.tag === 'payRequest' && target.callback) {
|
||||
return { ok: true, payRequest: target }
|
||||
}
|
||||
// (b) resolver returned a Lightning Address (the common case here).
|
||||
if (typeof target.lightningAddress === 'string') {
|
||||
const url = lnAddressToLnurlp(target.lightningAddress)
|
||||
if (!url) return { ok: false, reason: 'card wallet address is invalid' }
|
||||
return fetchPayRequest(url, ctx)
|
||||
}
|
||||
// (c) resolver returned an lnurlp pointer.
|
||||
const pointer = target.lnurlp ?? target.lnurl
|
||||
if (typeof pointer === 'string') {
|
||||
const url = lnurlwToHttps(pointer)
|
||||
if (!url) return { ok: false, reason: 'card wallet lnurlp is invalid' }
|
||||
return fetchPayRequest(url, ctx)
|
||||
}
|
||||
return { ok: false, reason: 'card wallet has no receive address' }
|
||||
}
|
||||
|
||||
async function requestInvoice(
|
||||
pr: PayStep,
|
||||
amountMsat: number,
|
||||
ctx: Ctx
|
||||
): Promise<ResolveCardInvoiceResult> {
|
||||
if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
|
||||
if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) {
|
||||
return { ok: false, reason: 'amount is below the card wallet minimum' }
|
||||
}
|
||||
if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) {
|
||||
return { ok: false, reason: 'amount is above the card wallet maximum' }
|
||||
}
|
||||
const cbUrl = appendQuery(pr.callback, { amount: String(amountMsat) })
|
||||
let vals: PayValues
|
||||
try {
|
||||
const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
||||
vals = (await res.json()) as PayValues
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not fetch the invoice: ${errMsg(e)}` }
|
||||
}
|
||||
if (vals.status === 'ERROR') {
|
||||
return { ok: false, reason: vals.reason || 'card wallet declined' }
|
||||
}
|
||||
if (!vals.pr || !/^ln[a-z0-9]/i.test(vals.pr.trim())) {
|
||||
return { ok: false, reason: 'card wallet returned no invoice' }
|
||||
}
|
||||
return { ok: true, bolt11: vals.pr.trim() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tapped Bolt Card + a payout amount to a BOLT11 the ATM can pay.
|
||||
* Never throws — every failure returns `{ ok: false, reason }`.
|
||||
*/
|
||||
export async function resolveCardInvoice(
|
||||
lnurlw: string,
|
||||
amountMsat: number,
|
||||
opts: ResolveCardInvoiceOptions = {}
|
||||
): Promise<ResolveCardInvoiceResult> {
|
||||
const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
|
||||
|
||||
const resolverUrl = scanUrlToResolver(lnurlw)
|
||||
if (!resolverUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
|
||||
if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
|
||||
|
||||
// 1) Resolve card → pay target (the transport seam: HTTPS today).
|
||||
let target: CardPayTarget
|
||||
try {
|
||||
const res = await ctx.doFetch(resolverUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
||||
target = (await res.json()) as CardPayTarget
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
|
||||
}
|
||||
if (target.status === 'ERROR') {
|
||||
return { ok: false, reason: target.reason || 'card rejected the tap' }
|
||||
}
|
||||
|
||||
// 2) Normalize to a LUD-06 payRequest.
|
||||
const pr = await toPayRequest(target, ctx)
|
||||
if (!pr.ok) return pr
|
||||
|
||||
// 3) Ask for an invoice for the payout amount.
|
||||
return requestInvoice({ ...pr.payRequest, callback: pr.payRequest.callback! }, amountMsat, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* The LUD-06 second step alone: fetch a BOLT11 for `amountMsat` from an
|
||||
* already-obtained pay step (from a Bolt Card session opened at tap-to-enter).
|
||||
* Never throws — every failure returns `{ ok: false, reason }`.
|
||||
*/
|
||||
export async function resolveInvoiceFromPayStep(
|
||||
step: PayStep,
|
||||
amountMsat: number,
|
||||
opts: ResolveCardInvoiceOptions = {}
|
||||
): Promise<ResolveCardInvoiceResult> {
|
||||
const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
|
||||
return requestInvoice(step, amountMsat, ctx)
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { executeLnurlWithdraw, executeWithdrawCallback, lnurlwToHttps } from './lnurl-withdraw'
|
||||
|
||||
const BOLT11 = 'lnbc10u1p3xyz...'
|
||||
const LNURLW =
|
||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||
|
||||
/** Build a mock fetch that returns the given JSON bodies per call, in order. */
|
||||
function mockFetch(bodies: unknown[]) {
|
||||
const calls: string[] = []
|
||||
const impl = vi.fn(async (url: string | URL) => {
|
||||
calls.push(url.toString())
|
||||
const body = bodies[calls.length - 1]
|
||||
return { json: async () => body } as Response
|
||||
})
|
||||
return { impl: impl as unknown as typeof fetch, calls }
|
||||
}
|
||||
|
||||
describe('lnurlwToHttps', () => {
|
||||
it('maps lnurlw:// and lnurl:// to https://', () => {
|
||||
expect(lnurlwToHttps('lnurlw://host/p?x=1')).toBe('https://host/p?x=1')
|
||||
expect(lnurlwToHttps('lnurl://host/p')).toBe('https://host/p')
|
||||
})
|
||||
it('strips a lightning: prefix', () => {
|
||||
expect(lnurlwToHttps('lightning:lnurlw://host/p')).toBe('https://host/p')
|
||||
})
|
||||
it('passes https:// through and trims', () => {
|
||||
expect(lnurlwToHttps(' https://host/p ')).toBe('https://host/p')
|
||||
})
|
||||
it('rejects http://, bech32 lnurl1…, and empty', () => {
|
||||
expect(lnurlwToHttps('http://host/p')).toBeNull()
|
||||
expect(lnurlwToHttps('LNURL1DP68GURN8GHJ7')).toBeNull()
|
||||
expect(lnurlwToHttps('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeLnurlWithdraw', () => {
|
||||
const withdrawReq = {
|
||||
tag: 'withdrawRequest',
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/scan/cb',
|
||||
k1: 'K1TOKEN',
|
||||
minWithdrawable: 1000,
|
||||
maxWithdrawable: 5_000_000,
|
||||
}
|
||||
|
||||
it('completes the two-step withdraw and passes k1 + pr to the callback', async () => {
|
||||
const { impl, calls } = mockFetch([withdrawReq, { status: 'OK' }])
|
||||
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||
expect(res).toEqual({ ok: true })
|
||||
// First call = the lnurlw as https; second = callback with k1 + pr.
|
||||
expect(calls[0]).toContain('https://lnbits.l484.com/boltcards/api/v1/scan/abc123')
|
||||
expect(calls[1]).toContain('k1=K1TOKEN')
|
||||
expect(calls[1]).toContain(`pr=${encodeURIComponent(BOLT11)}`)
|
||||
})
|
||||
|
||||
it('rejects a non-lnurlw tag', async () => {
|
||||
const { impl } = mockFetch([])
|
||||
const res = await executeLnurlWithdraw('http://nope', BOLT11, { fetchImpl: impl })
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toMatch(/not a valid Bolt Card/i)
|
||||
})
|
||||
|
||||
it('rejects when there is no invoice', async () => {
|
||||
const { impl } = mockFetch([])
|
||||
const res = await executeLnurlWithdraw(LNURLW, '', { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'no invoice to charge' })
|
||||
})
|
||||
|
||||
it('surfaces an ERROR from the withdraw request', async () => {
|
||||
const { impl } = mockFetch([{ status: 'ERROR', reason: 'spent today limit' }])
|
||||
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'spent today limit' })
|
||||
})
|
||||
|
||||
it('rejects a response that is not a withdrawRequest', async () => {
|
||||
const { impl } = mockFetch([{ tag: 'payRequest', callback: 'x' }])
|
||||
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false })
|
||||
expect(res.reason).toMatch(/withdraw voucher/i)
|
||||
})
|
||||
|
||||
it('rejects (without calling the callback) when the amount exceeds the card limit', async () => {
|
||||
const { impl, calls } = mockFetch([{ ...withdrawReq, maxWithdrawable: 2000 }])
|
||||
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl, amountMsat: 5000 })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'card limit is below this amount' })
|
||||
expect(calls).toHaveLength(1) // callback never hit
|
||||
})
|
||||
|
||||
it('surfaces an ERROR from the callback (card declined)', async () => {
|
||||
const { impl } = mockFetch([withdrawReq, { status: 'ERROR', reason: 'insufficient funds' }])
|
||||
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'insufficient funds' })
|
||||
})
|
||||
|
||||
it('handles a network failure gracefully', async () => {
|
||||
const impl = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
}) as unknown as typeof fetch
|
||||
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toMatch(/could not reach the card/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeWithdrawCallback (session second step, no tap)', () => {
|
||||
const step = {
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
|
||||
k1: 'hit1',
|
||||
maxWithdrawable: 5_000_000,
|
||||
}
|
||||
|
||||
it('hands the invoice straight to the callback with k1', async () => {
|
||||
const f = mockFetch([{ status: 'OK' }])
|
||||
const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: true })
|
||||
expect(f.calls).toHaveLength(1)
|
||||
expect(f.calls[0]).toContain('k1=hit1')
|
||||
expect(f.calls[0]).toContain('pr=' + BOLT11)
|
||||
})
|
||||
|
||||
it('refuses an amount above the step limit without calling out', async () => {
|
||||
const f = mockFetch([])
|
||||
const out = await executeWithdrawCallback(step, BOLT11, {
|
||||
fetchImpl: f.impl,
|
||||
amountMsat: 6_000_000,
|
||||
})
|
||||
expect(out).toEqual({ ok: false, reason: 'card limit is below this amount' })
|
||||
expect(f.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('surfaces a callback decline', async () => {
|
||||
const f = mockFetch([{ status: 'ERROR', reason: 'Payment already claimed.' }])
|
||||
const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'Payment already claimed.' })
|
||||
})
|
||||
|
||||
it('rejects a missing invoice', async () => {
|
||||
const f = mockFetch([])
|
||||
expect(await executeWithdrawCallback(step, '', { fetchImpl: f.impl })).toEqual({
|
||||
ok: false,
|
||||
reason: 'no invoice to charge',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
/**
|
||||
* LNURL-withdraw executor (LUD-03) — the ATM as the *withdrawing* party.
|
||||
*
|
||||
* Bolt Card tap-to-pay for the cash-out flow: a Bolt Card presents an
|
||||
* `lnurlw://…?p=…&c=…` voucher (NTAG424 SUN — fresh p/c per tap). The ATM has
|
||||
* already generated its cash-out BOLT11; here it asks the card's wallet to pay
|
||||
* that invoice:
|
||||
* 1. GET the lnurlw URL → a `withdrawRequest` (callback, k1, max/min).
|
||||
* 2. GET `callback?k1=…&pr=<our bolt11>` → the card's wallet pays it.
|
||||
* Settlement itself is observed elsewhere (the existing invoice watcher over
|
||||
* nostr), so a returned `{ ok: true }` means "the card accepted the pull", not
|
||||
* "cash dispensed" — the state machine still waits for PAYMENT_RECEIVED.
|
||||
*
|
||||
* Runs in the MAIN process (Node fetch) to avoid renderer CORS: LNURL
|
||||
* endpoints don't send CORS headers, so a renderer fetch to the card's host
|
||||
* would be blocked.
|
||||
*/
|
||||
|
||||
export interface LnurlWithdrawResult {
|
||||
ok: boolean
|
||||
/** Human-readable reason when ok is false (safe to surface on-screen). */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** LUD-03 withdrawRequest (subset we consume) + LUD-06 error shape. */
|
||||
interface WithdrawRequest {
|
||||
tag?: string
|
||||
callback?: string
|
||||
k1?: string
|
||||
minWithdrawable?: number
|
||||
maxWithdrawable?: number
|
||||
defaultDescription?: string
|
||||
status?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type FetchLike = typeof fetch
|
||||
|
||||
/**
|
||||
* The LUD-03 second step on its own: what a `withdrawRequest` (or a Bolt Card
|
||||
* session, see boltcard-session.ts) hands us to actually pull a payment.
|
||||
*/
|
||||
export interface WithdrawStep {
|
||||
callback: string
|
||||
k1: string
|
||||
minWithdrawable?: number
|
||||
maxWithdrawable?: number
|
||||
}
|
||||
|
||||
export interface ExecuteLnurlWithdrawOptions {
|
||||
/** Injected for tests; defaults to global fetch. */
|
||||
fetchImpl?: FetchLike
|
||||
/**
|
||||
* Our invoice amount in millisats. When set, we reject early if it exceeds
|
||||
* the voucher's maxWithdrawable (defensive; the callback would reject anyway).
|
||||
*/
|
||||
amountMsat?: number
|
||||
/** Per-request timeout (default 15s). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a Bolt Card / LNURL-withdraw pointer to an https URL.
|
||||
* Bolt Cards emit `lnurlw://host/path?query`; we also accept `lnurl://` and a
|
||||
* bare `https://`. Bech32 `LNURL1…` is intentionally unsupported (Bolt Cards
|
||||
* never use it) and rejected with a clear reason.
|
||||
*/
|
||||
export function lnurlwToHttps(raw: string): string | null {
|
||||
let s = raw.trim()
|
||||
if (!s) return null
|
||||
if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length)
|
||||
const lower = s.toLowerCase()
|
||||
if (lower.startsWith('lnurlw://')) return 'https://' + s.slice('lnurlw://'.length)
|
||||
if (lower.startsWith('lnurl://')) return 'https://' + s.slice('lnurl://'.length)
|
||||
if (lower.startsWith('https://')) return s
|
||||
// Reject http:// (must be TLS) and bech32 lnurl1… (not a Bolt Card).
|
||||
return null
|
||||
}
|
||||
|
||||
function appendQuery(url: string, params: Record<string, string>): string {
|
||||
const u = new URL(url)
|
||||
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v)
|
||||
return u.toString()
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
if (e instanceof Error)
|
||||
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
export async function executeLnurlWithdraw(
|
||||
lnurlw: string,
|
||||
bolt11: string,
|
||||
opts: ExecuteLnurlWithdrawOptions = {}
|
||||
): Promise<LnurlWithdrawResult> {
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const timeoutMs = opts.timeoutMs ?? 15_000
|
||||
|
||||
const paramsUrl = lnurlwToHttps(lnurlw)
|
||||
if (!paramsUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
|
||||
if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) {
|
||||
return { ok: false, reason: 'no invoice to charge' }
|
||||
}
|
||||
|
||||
// 1) Fetch the withdraw request.
|
||||
let params: WithdrawRequest
|
||||
try {
|
||||
const res = await doFetch(paramsUrl, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
params = (await res.json()) as WithdrawRequest
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
|
||||
}
|
||||
if (params.status === 'ERROR') {
|
||||
return { ok: false, reason: params.reason || 'card rejected the tap' }
|
||||
}
|
||||
if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) {
|
||||
return { ok: false, reason: 'card did not return a withdraw voucher' }
|
||||
}
|
||||
|
||||
// 2) Hand our invoice to the callback — the card's wallet pays it.
|
||||
return executeWithdrawCallback(
|
||||
{
|
||||
callback: params.callback,
|
||||
k1: params.k1,
|
||||
minWithdrawable: params.minWithdrawable,
|
||||
maxWithdrawable: params.maxWithdrawable,
|
||||
},
|
||||
bolt11,
|
||||
opts
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The LUD-03 second step alone: hand our invoice to an already-obtained
|
||||
* withdraw step (from a `/scan` withdrawRequest, or from a Bolt Card session
|
||||
* opened at tap-to-enter) — the card's wallet pays it. `{ ok: true }` means the
|
||||
* card accepted the pull; settlement is observed by the invoice watcher.
|
||||
*/
|
||||
export async function executeWithdrawCallback(
|
||||
step: WithdrawStep,
|
||||
bolt11: string,
|
||||
opts: ExecuteLnurlWithdrawOptions = {}
|
||||
): Promise<LnurlWithdrawResult> {
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const timeoutMs = opts.timeoutMs ?? 15_000
|
||||
|
||||
if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) {
|
||||
return { ok: false, reason: 'no invoice to charge' }
|
||||
}
|
||||
if (
|
||||
opts.amountMsat != null &&
|
||||
typeof step.maxWithdrawable === 'number' &&
|
||||
opts.amountMsat > step.maxWithdrawable
|
||||
) {
|
||||
return { ok: false, reason: 'card limit is below this amount' }
|
||||
}
|
||||
|
||||
const cbUrl = appendQuery(step.callback, { k1: step.k1, pr: bolt11.trim() })
|
||||
let cb: { status?: string; reason?: string }
|
||||
try {
|
||||
const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
cb = (await res.json()) as { status?: string; reason?: string }
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `card payment failed: ${errMsg(e)}` }
|
||||
}
|
||||
if (cb.status === 'OK') return { ok: true }
|
||||
return { ok: false, reason: cb.reason || 'card declined the payment' }
|
||||
}
|
||||
|
|
@ -24,37 +24,18 @@ import {
|
|||
markCommandExecuting,
|
||||
completeCommand,
|
||||
getLastKnownConfigCreatedAt,
|
||||
getCountsUncertainSince,
|
||||
getLastStatePublishedAt,
|
||||
markCountsUncertain,
|
||||
markStatePublished,
|
||||
resetStatePublishWatermark,
|
||||
resetForRepair,
|
||||
applyOperatorCassetteOps,
|
||||
getAppliedOpIds,
|
||||
getCassetteStateSeq,
|
||||
getBootstrapPublishedAt,
|
||||
markBootstrapPublished,
|
||||
applyOperatorCassettesConfig,
|
||||
getFeeConfig,
|
||||
getLastKnownFeeConfigCreatedAt,
|
||||
applyFeeConfig,
|
||||
getBunkerBinding,
|
||||
saveBunkerBinding,
|
||||
clearBunkerBinding,
|
||||
type CassetteOp,
|
||||
type ApplyOpsResult,
|
||||
type OperatorCassettesPayload,
|
||||
type FeeConfigPayload,
|
||||
type FeeConfigRow,
|
||||
type ApplyResult,
|
||||
type StoredBunkerBinding,
|
||||
} from './state-store.js'
|
||||
import { initializeHal, type HalInstance } from './hal-service.js'
|
||||
import {
|
||||
executeLnurlWithdraw,
|
||||
executeWithdrawCallback,
|
||||
type WithdrawStep,
|
||||
} from './lnurl-withdraw.js'
|
||||
import { resolveCardInvoice, resolveInvoiceFromPayStep, type PayStep } from './lnurl-pay.js'
|
||||
import { openCardSession, type OpenCardSessionResult } from './boltcard-session.js'
|
||||
import { startNfcReader, type NfcStatus } from './nfc-service.js'
|
||||
|
||||
// ESM equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
|
|
@ -100,6 +81,16 @@ type BrandingConfig = {
|
|||
logoDarkDataUrl: string | null
|
||||
}
|
||||
|
||||
const VALID_THEMES = new Set([
|
||||
'gruvbox',
|
||||
'catppuccin',
|
||||
'cyberpunk',
|
||||
'dracula',
|
||||
'nord',
|
||||
'tokyo-night',
|
||||
'custom',
|
||||
])
|
||||
|
||||
function loadBranding(): BrandingConfig | null {
|
||||
const brandingDir = path.join(
|
||||
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
|
||||
|
|
@ -119,10 +110,7 @@ function loadBranding(): BrandingConfig | null {
|
|||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
||||
if (typeof raw.title === 'string') title = raw.title
|
||||
// No theme-name validation here: the renderer's `themes` list (plus its
|
||||
// 'custom' branch) is the single source of truth. Pass the string through
|
||||
// and let useTheme's applyBrandingTheme ignore anything it doesn't know.
|
||||
if (typeof raw.theme === 'string') theme = raw.theme
|
||||
if (typeof raw.theme === 'string' && VALID_THEMES.has(raw.theme)) theme = raw.theme
|
||||
if (raw.custom_colors && typeof raw.custom_colors === 'object') {
|
||||
const { dark, ...flat } = raw.custom_colors as Record<string, unknown>
|
||||
const colors = Object.fromEntries(
|
||||
|
|
@ -131,7 +119,9 @@ function loadBranding(): BrandingConfig | null {
|
|||
if (Object.keys(colors).length > 0) customColors = colors
|
||||
if (dark && typeof dark === 'object') {
|
||||
const darkColors = Object.fromEntries(
|
||||
Object.entries(dark as Record<string, unknown>).filter(([, v]) => typeof v === 'string')
|
||||
Object.entries(dark as Record<string, unknown>).filter(
|
||||
([, v]) => typeof v === 'string'
|
||||
)
|
||||
) as Record<string, string>
|
||||
if (Object.keys(darkColors).length > 0) customColorsDark = darkColors
|
||||
}
|
||||
|
|
@ -174,62 +164,6 @@ function loadBranding(): BrandingConfig | null {
|
|||
return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl }
|
||||
}
|
||||
|
||||
// Access-control config loader (ADR-003). Env toggles the gate; an optional
|
||||
// /var/lib/bitspire/access.json carries the salt + allow-list. Defaults OFF —
|
||||
// a machine with neither env nor file behaves as if there is no access layer.
|
||||
// The allow-list shape mirrors the renderer's AllowListEntry (authorize.ts);
|
||||
// duplicated here to avoid a cross-project (electron↔renderer) import.
|
||||
interface AccessAllowListEntry {
|
||||
idHash: string
|
||||
role: 'user' | 'operator'
|
||||
pinHash?: string
|
||||
label?: string
|
||||
}
|
||||
function loadAccessControl() {
|
||||
// Env provides defaults; access.json (writable, operator-provisioned — same
|
||||
// spirit as branding/) overrides them, so the gate can be toggled on a
|
||||
// deployed machine by dropping a file + restarting the service, with no image
|
||||
// rebuild. Defaults OFF.
|
||||
let enabled = process.env.ACCESS_CONTROL_ENABLED === 'true'
|
||||
// Dev unlock is OFF unless explicitly enabled: a gated machine must not ship
|
||||
// a visible bypass button by default.
|
||||
let devUnlock = process.env.ACCESS_DEV_UNLOCK === 'true'
|
||||
let openEnrollment = process.env.ACCESS_OPEN_ENROLLMENT === 'true'
|
||||
let salt = process.env.ACCESS_SALT || ''
|
||||
let allowList: AccessAllowListEntry[] = []
|
||||
|
||||
const jsonPath = path.join(
|
||||
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
|
||||
'access.json'
|
||||
)
|
||||
if (fs.existsSync(jsonPath)) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
||||
if (typeof raw.enabled === 'boolean') enabled = raw.enabled
|
||||
if (typeof raw.devUnlock === 'boolean') devUnlock = raw.devUnlock
|
||||
if (typeof raw.openEnrollment === 'boolean') openEnrollment = raw.openEnrollment
|
||||
if (typeof raw.salt === 'string' && raw.salt) salt = raw.salt
|
||||
if (Array.isArray(raw.allowList)) {
|
||||
allowList = (raw.allowList as unknown[]).filter(
|
||||
(e): e is AccessAllowListEntry =>
|
||||
!!e &&
|
||||
typeof (e as AccessAllowListEntry).idHash === 'string' &&
|
||||
((e as AccessAllowListEntry).role === 'user' ||
|
||||
(e as AccessAllowListEntry).role === 'operator')
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Electron] Failed to parse access.json:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// A gated machine needs a stable salt for deterministic hashing. Fall back to
|
||||
// a fixed default (prototype); production should provision a real salt.
|
||||
if (!salt) salt = 'bitspire-access-v1'
|
||||
|
||||
return { enabled, devUnlock, openEnrollment, salt, allowList }
|
||||
}
|
||||
|
||||
// Determine if we're in development
|
||||
const isDev =
|
||||
process.env.ELECTRON_FORCE_PROD !== '1' &&
|
||||
|
|
@ -346,11 +280,8 @@ ipcMain.handle('watchdog:pong', () => {
|
|||
// pragma: allowlist secret end
|
||||
ipcMain.handle('get-config', () => {
|
||||
return {
|
||||
// LNbits nostr-transport connection (public info only). Empty when
|
||||
// unprovisioned — the renderer then falls through to the pairing seed's
|
||||
// relay (aiolabs/bitspire#70). A non-empty default here would win via the
|
||||
// env-first precedence and override the seed.
|
||||
relayUrl: process.env.VITE_RELAY_URL || '',
|
||||
// LNbits nostr-transport connection (public info only)
|
||||
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777',
|
||||
lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
|
||||
appId: process.env.VITE_APP_ID || '',
|
||||
|
||||
|
|
@ -377,9 +308,6 @@ ipcMain.handle('get-config', () => {
|
|||
|
||||
// Operator branding (logo/title/theme) — null when no override
|
||||
branding: loadBranding(),
|
||||
|
||||
// Access-control gate (ADR-003) — `enabled` defaults false (no gate).
|
||||
accessControl: loadAccessControl(),
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -402,148 +330,14 @@ let secretsConsumed = false
|
|||
ipcMain.handle('get-atm-secrets', () => {
|
||||
if (secretsConsumed) {
|
||||
console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed')
|
||||
return { spireSeed: '', bunkerBinding: null }
|
||||
return { atmPrivateKey: '' }
|
||||
}
|
||||
secretsConsumed = true
|
||||
// The spire pairing seed (one-shot connect token inside) + the persisted
|
||||
// bunker binding (transport key). The renderer resolves these into a
|
||||
// BunkerSigner; see services/signer-resolver.ts (aiolabs/bitspire#52).
|
||||
return {
|
||||
spireSeed: process.env.VITE_SPIRE_SEED || '',
|
||||
bunkerBinding: getBunkerBinding(),
|
||||
atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '',
|
||||
}
|
||||
})
|
||||
|
||||
// Bunker binding persistence — the renderer writes the binding after a
|
||||
// successful pairing (connectNewSeed), and resets the publish watermark so the
|
||||
// new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56).
|
||||
ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => {
|
||||
saveBunkerBinding(binding)
|
||||
})
|
||||
ipcMain.handle('state:clear-bunker-binding', (): void => {
|
||||
clearBunkerBinding()
|
||||
})
|
||||
ipcMain.handle('state:reset-state-publish-watermark', (): void => {
|
||||
resetStatePublishWatermark()
|
||||
})
|
||||
ipcMain.handle('state:reset-for-repair', (): void => {
|
||||
resetForRepair()
|
||||
})
|
||||
|
||||
// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a
|
||||
// spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the
|
||||
// runtime .env so the next boot's signer-resolver redeems it (connectNewSeed)
|
||||
// exactly as if it had been provisioned. We deliberately do NOT pair here —
|
||||
// persisting + relaunching reuses the single, tested pairing path rather than
|
||||
// duplicating it in the renderer.
|
||||
function runtimeEnvPath(): string {
|
||||
const base = fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd()
|
||||
return path.join(base, '.env')
|
||||
}
|
||||
|
||||
ipcMain.handle('state:save-spire-seed', (_event, seed: string): void => {
|
||||
const trimmed = (seed || '').trim()
|
||||
if (!trimmed) throw new Error('save-spire-seed: empty seed')
|
||||
const envPath = runtimeEnvPath()
|
||||
const line = `VITE_SPIRE_SEED=${trimmed}`
|
||||
let lines: string[] = []
|
||||
if (fs.existsSync(envPath)) {
|
||||
lines = fs.readFileSync(envPath, 'utf8').split('\n')
|
||||
}
|
||||
const idx = lines.findIndex((l) => l.startsWith('VITE_SPIRE_SEED='))
|
||||
if (idx >= 0) {
|
||||
lines[idx] = line
|
||||
} else {
|
||||
// Drop a trailing empty element so we don't accumulate blank lines.
|
||||
if (lines.length && lines[lines.length - 1] === '') lines.pop()
|
||||
lines.push(line)
|
||||
}
|
||||
fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 })
|
||||
// Keep this process's view in sync so get-atm-secrets reflects the new seed
|
||||
// even before relaunch (belt-and-suspenders; relaunch re-reads from disk).
|
||||
process.env.VITE_SPIRE_SEED = trimmed
|
||||
console.log('[Pairing] Spire seed persisted to', envPath)
|
||||
})
|
||||
|
||||
// Relaunch the kiosk so the new seed is picked up by a clean boot. Under
|
||||
// systemd (bitspire.service) the exit triggers an automatic restart; in dev
|
||||
// Electron's relaunch re-spawns the process.
|
||||
ipcMain.handle('app:relaunch', (): void => {
|
||||
console.log('[Pairing] Relaunching to apply new pairing')
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
// Connectivity recovery: reload the renderer to re-run init from a clean slate
|
||||
// (fresh JS context → no leaked actors/subscriptions), while preserving HAL in
|
||||
// this main process (reloadRenderer resets secretsConsumed so get-atm-secrets
|
||||
// works again, and hal:init is idempotent). The renderer calls this when it's
|
||||
// stuck on a connectivity-type "ATM Unavailable" and the network returns, or
|
||||
// when the operator taps the on-screen Retry (ADR-002 amendment 2026-08-04).
|
||||
ipcMain.handle('app:recover', (): void => {
|
||||
console.log('[Recovery] Reloading renderer to re-attempt initialization')
|
||||
reloadRenderer()
|
||||
})
|
||||
|
||||
// Bolt Card cash-out: pull payment for the current invoice from a tapped card
|
||||
// via LNURL-withdraw. Runs in the main process (Node fetch) to dodge renderer
|
||||
// CORS. Returns once the card accepts; settlement arrives via the invoice
|
||||
// watcher. See lnurl-withdraw.ts.
|
||||
ipcMain.handle(
|
||||
'lnurl:withdraw',
|
||||
async (
|
||||
_event,
|
||||
args: { lnurlw: string; bolt11: string; amountMsat?: number }
|
||||
): Promise<{ ok: boolean; reason?: string }> => {
|
||||
return executeLnurlWithdraw(args.lnurlw, args.bolt11, { amountMsat: args.amountMsat })
|
||||
}
|
||||
)
|
||||
|
||||
// Bolt Card cash-in (receive): resolve a tapped card + payout amount to a
|
||||
// BOLT11 on the card wallet, which the renderer then pays over the nostr
|
||||
// transport (stores/atm.ts payInvoice). HTTPS to the card host runs here in the
|
||||
// main process to dodge renderer CORS. See lnurl-pay.ts.
|
||||
ipcMain.handle(
|
||||
'lnurl:pay-card',
|
||||
async (
|
||||
_event,
|
||||
args: { lnurlw: string; amountMsat: number }
|
||||
): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
|
||||
return resolveCardInvoice(args.lnurlw, args.amountMsat)
|
||||
}
|
||||
)
|
||||
|
||||
// Bolt Card tap-to-enter (ADR-003): open a verified session for a tapped card.
|
||||
// Spends the tap's SUN once and returns balance + fiat + the withdraw/pay
|
||||
// second steps the session reuses at Complete. See boltcard-session.ts.
|
||||
ipcMain.handle(
|
||||
'lnurl:open-card-session',
|
||||
async (_event, args: { lnurlw: string }): Promise<OpenCardSessionResult> => {
|
||||
return openCardSession(args.lnurlw)
|
||||
}
|
||||
)
|
||||
|
||||
// Session variants of the two Complete paths: no tap, no p/c — just the
|
||||
// hit-keyed second step the session already holds.
|
||||
ipcMain.handle(
|
||||
'lnurl:withdraw-session',
|
||||
async (
|
||||
_event,
|
||||
args: { withdraw: WithdrawStep; bolt11: string; amountMsat?: number }
|
||||
): Promise<{ ok: boolean; reason?: string }> => {
|
||||
return executeWithdrawCallback(args.withdraw, args.bolt11, { amountMsat: args.amountMsat })
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'lnurl:pay-session',
|
||||
async (
|
||||
_event,
|
||||
args: { pay: PayStep; amountMsat: number }
|
||||
): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
|
||||
return resolveInvoiceFromPayStep(args.pay, args.amountMsat)
|
||||
}
|
||||
)
|
||||
|
||||
// State persistence IPC handlers
|
||||
ipcMain.handle('state:load-cassettes', () => loadCassettes())
|
||||
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
|
||||
|
|
@ -560,22 +354,17 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
|
|||
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
|
||||
getLastKnownConfigCreatedAt()
|
||||
)
|
||||
ipcMain.handle('state:get-last-state-published-at', (): number | null => getLastStatePublishedAt())
|
||||
ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCountsUncertainSince())
|
||||
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
|
||||
markCountsUncertain(unixTimestamp)
|
||||
})
|
||||
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
|
||||
markStatePublished(unixTimestamp)
|
||||
ipcMain.handle('state:get-bootstrap-published-at', (): number | null =>
|
||||
getBootstrapPublishedAt()
|
||||
)
|
||||
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
|
||||
markBootstrapPublished(unixTimestamp)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'state:apply-operator-cassette-ops',
|
||||
(_event, ops: CassetteOp[]): ApplyOpsResult => applyOperatorCassetteOps(ops)
|
||||
'state:apply-operator-cassettes-config',
|
||||
(_event, payload: OperatorCassettesPayload, eventCreatedAt: number): ApplyResult =>
|
||||
applyOperatorCassettesConfig(payload, eventCreatedAt)
|
||||
)
|
||||
ipcMain.handle('state:get-applied-op-ids', (_event, limit?: number): string[] =>
|
||||
getAppliedOpIds(limit)
|
||||
)
|
||||
ipcMain.handle('state:get-cassette-state-seq', (): number => getCassetteStateSeq())
|
||||
|
||||
// Operator-fees consumer (aiolabs/lamassu-next#57) — persisted singleton
|
||||
// fee config + per-d-tag replay watermark + atomic apply for kind-30078
|
||||
|
|
@ -625,17 +414,6 @@ let pendingBillDenomination: number | null = null
|
|||
|
||||
ipcMain.handle('hal:init', async (_event, config) => {
|
||||
try {
|
||||
// Idempotent: HAL lives in this (long-lived) main process, but the renderer
|
||||
// re-runs full init on every reload — the watchdog's crash-recovery reload
|
||||
// and the connectivity-recovery reload (app:recover) both re-invoke this.
|
||||
// initializeHal opens serial ports without closing prior handles, so
|
||||
// re-entering it would double-open the validator/dispenser. Reuse the
|
||||
// existing instance instead; its validator event wiring already targets the
|
||||
// (reloaded) mainWindow, so the reloaded renderer keeps receiving bill events.
|
||||
if (halInstance) {
|
||||
console.log('[Electron] HAL already initialized — reusing existing instance')
|
||||
return { success: true }
|
||||
}
|
||||
// Override cassette config with DB values (operator may have changed them via atm-tui
|
||||
// or via an operator-config publish from satmachineadmin). Pass per-position so the
|
||||
// HAL knows about every bay including duplicates of the same denomination — real
|
||||
|
|
@ -741,12 +519,10 @@ ipcMain.handle('hal:stack-bill', () => {
|
|||
console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring')
|
||||
return
|
||||
}
|
||||
const denomination = pendingBillDenomination
|
||||
pendingBillDenomination = null
|
||||
// Credit is NOT sent here. hal-service fires onBillInserted (forwarded
|
||||
// as 'hal:bill-inserted') only on the validator's `billsValid`
|
||||
// stacked-confirmation — a stack command can still fail or return the
|
||||
// bill (aiolabs/bitspire#58).
|
||||
halInstance.stackBill()
|
||||
mainWindow?.webContents.send('hal:bill-inserted', denomination)
|
||||
})
|
||||
|
||||
ipcMain.handle('hal:reject-bill', () => {
|
||||
|
|
@ -853,11 +629,6 @@ function startCommandPoller(): void {
|
|||
error: result.error,
|
||||
})
|
||||
|
||||
// This dispense happened entirely in the main process, so the renderer
|
||||
// has no idea the bays moved — it would keep serving a stale inventory
|
||||
// and would never republish the operator's view. Tell it.
|
||||
mainWindow?.webContents.send('cassettes:changed')
|
||||
|
||||
// Only remediate the original tx if ALL requested bills were dispensed
|
||||
let refRemediated = false
|
||||
if (parsed.ref_txid && result.dispensed) {
|
||||
|
|
@ -952,23 +723,6 @@ app.whenReady().then(() => {
|
|||
startWatchdog()
|
||||
startCommandPoller()
|
||||
|
||||
// Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Fully
|
||||
// best-effort: if the reader/pcscd is absent it just reports 'unavailable'
|
||||
// and the cash-out QR path is unaffected.
|
||||
void startNfcReader(
|
||||
(lnurlw) => {
|
||||
// Don't log the value — it carries the card's single-use SUN p/c.
|
||||
console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`)
|
||||
mainWindow?.webContents.send('nfc:card-tapped', lnurlw)
|
||||
},
|
||||
(status: NfcStatus) => {
|
||||
console.log(
|
||||
`[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}`
|
||||
)
|
||||
mainWindow?.webContents.send('nfc:status', status)
|
||||
}
|
||||
)
|
||||
|
||||
app.on('activate', () => {
|
||||
// macOS: re-create window when dock icon clicked
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { extractLnurlw, readNdefLnurlw } from './nfc-service'
|
||||
|
||||
const LNURLW =
|
||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||
|
||||
/** Build a Type-4 NDEF message with a single URI record carrying `uri`. */
|
||||
function ndefUriMessage(uri: string): Buffer {
|
||||
const uriBytes = Buffer.from(uri, 'ascii')
|
||||
const payload = Buffer.concat([Buffer.from([0x00]), uriBytes]) // 0x00 = no prefix
|
||||
// D1 = MB|ME|SR, TNF=well-known; type length 1; payload length; 'U'
|
||||
return Buffer.concat([Buffer.from([0xd1, 0x01, payload.length, 0x55]), payload])
|
||||
}
|
||||
|
||||
describe('extractLnurlw', () => {
|
||||
it('pulls an lnurlw:// URI out of an NDEF record', () => {
|
||||
expect(extractLnurlw(ndefUriMessage(LNURLW))).toBe(LNURLW)
|
||||
})
|
||||
it('pulls a boltcards https scan URL', () => {
|
||||
const https = 'https://lnbits.l484.com/boltcards/api/v1/scan/x?p=aa&c=bb'
|
||||
expect(extractLnurlw(ndefUriMessage(https))).toBe(https)
|
||||
})
|
||||
it('stops at the record boundary (no trailing binary)', () => {
|
||||
const msg = Buffer.concat([ndefUriMessage(LNURLW), Buffer.from([0x00, 0xfe, 0x01])])
|
||||
expect(extractLnurlw(msg)).toBe(LNURLW)
|
||||
})
|
||||
it('returns null when there is no lnurl', () => {
|
||||
expect(extractLnurlw(Buffer.from('just some text', 'ascii'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readNdefLnurlw', () => {
|
||||
const SW_OK = Buffer.from([0x90, 0x00])
|
||||
const SW_NOTFOUND = Buffer.from([0x6a, 0x82])
|
||||
// Capability Container advertising the NDEF file id E104 (TLV 04 06 at [7,8]).
|
||||
const CC = Buffer.from([
|
||||
0x00, 0x0f, 0x20, 0x00, 0x3b, 0x00, 0x34, 0x04, 0x06, 0xe1, 0x04, 0x00, 0xff, 0x00, 0xff,
|
||||
])
|
||||
|
||||
/** Route APDUs by content so the CC-read + fallback loop is exercised. */
|
||||
function cardMock(opts: { noApp?: boolean; nlen0?: boolean; uri?: string } = {}) {
|
||||
const msg = ndefUriMessage(opts.uri ?? LNURLW)
|
||||
const nlen = msg.length
|
||||
return vi.fn(async (apdu: Buffer) => {
|
||||
const hex = apdu.toString('hex')
|
||||
if (hex.includes('d2760000850101')) return opts.noApp ? SW_NOTFOUND : SW_OK // select app
|
||||
if (hex.startsWith('00a4000c02e103')) return SW_OK // select CC
|
||||
if (hex.startsWith('00b000000f')) return Buffer.concat([CC, SW_OK]) // read CC
|
||||
if (hex.startsWith('00a4000c02e104')) return SW_OK // select NDEF file (E104)
|
||||
if (hex.startsWith('00a4000c020004')) return SW_NOTFOUND // fallback file id: absent
|
||||
if (hex.startsWith('00b0000002'))
|
||||
return opts.nlen0
|
||||
? Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK])
|
||||
: Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK]) // NLEN
|
||||
if (hex.startsWith('00b0')) return Buffer.concat([msg, SW_OK]) // read message
|
||||
return SW_NOTFOUND
|
||||
})
|
||||
}
|
||||
|
||||
it('reads CC → NDEF file (E104) and returns the lnurlw', async () => {
|
||||
const transmit = cardMock()
|
||||
expect(await readNdefLnurlw(transmit)).toBe(LNURLW)
|
||||
// First APDU selects the NDEF application (AID D2760000850101).
|
||||
expect((transmit.mock.calls[0][0] as Buffer).toString('hex')).toContain('d2760000850101')
|
||||
})
|
||||
|
||||
it('returns null if selecting the NDEF app fails', async () => {
|
||||
expect(await readNdefLnurlw(cardMock({ noApp: true }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null on an empty NDEF file', async () => {
|
||||
expect(await readNdefLnurlw(cardMock({ nlen0: true }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
/**
|
||||
* NFC reader driver (main process) for Bolt Card tap-to-pay.
|
||||
*
|
||||
* Wraps `nfc-pcsc` (PC/SC via the Feitian KP382 CCID reader). On each card
|
||||
* tap it reads the NTAG424 Type-4 NDEF file over ISO7816 APDUs and extracts
|
||||
* the `lnurlw://…?p=…&c=…` voucher (the card computes fresh SUN p/c per tap),
|
||||
* then hands it to the renderer over IPC. The renderer, when showing a
|
||||
* cash-out invoice, pays it via LNURL-withdraw (see lnurl-withdraw.ts).
|
||||
*
|
||||
* Everything here is best-effort and lazy: `nfc-pcsc` is a native addon, so it
|
||||
* is dynamically imported and every failure is swallowed into a status
|
||||
* callback. If the reader/library is absent, NFC is simply unavailable and the
|
||||
* QR path keeps working — cash-out never depends on this.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
|
||||
export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable'
|
||||
export interface NfcStatus {
|
||||
state: NfcState
|
||||
reader?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
type CardHandler = (lnurlw: string) => void
|
||||
type StatusHandler = (status: NfcStatus) => void
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
|
||||
/** Pull the lnurlw (or a boltcards https scan URL) out of a Type-4 NDEF blob. */
|
||||
export function extractLnurlw(ndef: Buffer): string | null {
|
||||
// Robust to record framing: the URI record embeds the literal string; grab
|
||||
// it directly, bounded to URL-safe characters so we stop at the record end.
|
||||
const text = ndef.toString('latin1')
|
||||
const urlChars = "[A-Za-z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+"
|
||||
const m =
|
||||
text.match(new RegExp('lnurlw://' + urlChars, 'i')) ||
|
||||
text.match(new RegExp('https://' + urlChars + '/boltcards/' + urlChars, 'i'))
|
||||
return m ? m[0] : null
|
||||
}
|
||||
|
||||
const swOk = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00
|
||||
|
||||
/** Select an EF by its 2-byte file id and read + parse its NDEF message. */
|
||||
async function readNdefFile(
|
||||
send: (bytes: number[]) => Promise<Buffer>,
|
||||
fid: [number, number]
|
||||
): Promise<string | null> {
|
||||
if (!swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, fid[0], fid[1]]))) return null
|
||||
// 2-byte NLEN header at offset 0.
|
||||
const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02])
|
||||
if (!swOk(lenResp)) return null
|
||||
const nlen = (lenResp[0] << 8) | lenResp[1]
|
||||
if (nlen <= 0 || nlen > 0x2000) return null
|
||||
// NDEF message starts at offset 2; read in <=250-byte chunks.
|
||||
const chunks: Buffer[] = []
|
||||
let offset = 2
|
||||
let remaining = nlen
|
||||
while (remaining > 0) {
|
||||
const toRead = Math.min(remaining, 0xfa)
|
||||
const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead])
|
||||
if (!swOk(resp)) break
|
||||
const data = resp.subarray(0, resp.length - 2)
|
||||
if (data.length === 0) break
|
||||
chunks.push(data)
|
||||
offset += data.length
|
||||
remaining -= data.length
|
||||
}
|
||||
return extractLnurlw(Buffer.concat(chunks))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the NDEF of a Type-4 tag and return the extracted lnurlw, or null.
|
||||
* `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2.
|
||||
*
|
||||
* Select the NDEF Tag Application, read the Capability Container to learn the
|
||||
* real NDEF FileID (NTAG424 Bolt Cards use E104, not the 0004 some tags use),
|
||||
* then read that file. Falls back to E104/0004 if the CC read is unavailable.
|
||||
*/
|
||||
export async function readNdefLnurlw(
|
||||
transmit: (apdu: Buffer, maxLen: number) => Promise<Buffer>
|
||||
): Promise<string | null> {
|
||||
const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256)
|
||||
|
||||
// Select the NDEF Tag Application (AID D2760000850101).
|
||||
if (
|
||||
!swOk(
|
||||
await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00])
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// NTAG424 Bolt Cards use NDEF FileID E104. Try it (and 0004) directly to
|
||||
// minimise APDU round-trips over a flaky RF link; only fall back to reading
|
||||
// the Capability Container to discover the id if both direct reads fail.
|
||||
for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) {
|
||||
const found = await readNdefFile(send, fid)
|
||||
if (found) return found
|
||||
}
|
||||
if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) {
|
||||
const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f])
|
||||
// CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID.
|
||||
if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) {
|
||||
const found = await readNdefFile(send, [cc[9], cc[10]])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let stopFn: (() => void) | null = null
|
||||
|
||||
// ── Wedge auto-recovery ───────────────────────────────────────────────────
|
||||
// Cheap CCID readers (the Feitian R502-CL especially) occasionally wedge: they
|
||||
// keep detecting a card but every APDU returns "card absent or mute", and ONLY
|
||||
// a USB power-cycle clears it — pcscd/app restarts do NOT. When we see a run of
|
||||
// consecutive read failures we trigger nfc-reader-reset.service (a root oneshot
|
||||
// that re-binds the reader's USB device = a software replug); nfc-pcsc then
|
||||
// re-detects the reader on hotplug with no app restart. The trigger is gated by
|
||||
// a cooldown so a still-wedged reader can't reset-loop. A quality reader (e.g.
|
||||
// ACR1252U) wedges far less; this is belt-and-suspenders for any reader.
|
||||
const WEDGE_FAILURE_THRESHOLD = 3
|
||||
const RESET_COOLDOWN_MS = 30_000
|
||||
// Persist across reader re-enumerations (a reset spawns a fresh reader closure).
|
||||
let lastReaderResetAt = 0
|
||||
|
||||
/** Trigger the privileged USB power-cycle of the reader. Best-effort. */
|
||||
function resetWedgedReader(): void {
|
||||
// NixOS: the app runs unprivileged as `bitspire`; a polkit rule authorises it
|
||||
// to start this one unit. systemctl lives at a stable path on the device.
|
||||
execFile('/run/current-system/sw/bin/systemctl', ['start', 'nfc-reader-reset.service'], () => {
|
||||
/* best-effort — if it fails the reader stays wedged until a manual reset */
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for Bolt Card taps. Idempotent. Returns a stop function.
|
||||
* Never throws — failures surface via onStatus.
|
||||
*/
|
||||
export async function startNfcReader(
|
||||
onCard: CardHandler,
|
||||
onStatus: StatusHandler
|
||||
): Promise<() => void> {
|
||||
if (stopFn) return stopFn
|
||||
|
||||
let mod: unknown
|
||||
try {
|
||||
// Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc
|
||||
// while resolving normally at runtime.
|
||||
const pkg = 'nfc-pcsc'
|
||||
mod = (await import(pkg)) as unknown
|
||||
} catch (e) {
|
||||
onStatus({ state: 'unavailable', message: `NFC library unavailable: ${errMsg(e)}` })
|
||||
return () => {}
|
||||
}
|
||||
const NFC =
|
||||
(mod as { NFC?: unknown }).NFC ?? (mod as { default?: { NFC?: unknown } }).default?.NFC
|
||||
if (typeof NFC !== 'function') {
|
||||
onStatus({ state: 'unavailable', message: 'NFC library has no NFC export' })
|
||||
return () => {}
|
||||
}
|
||||
|
||||
let nfc: { on: (e: string, cb: (...a: unknown[]) => void) => void; close?: () => void }
|
||||
try {
|
||||
nfc = new (NFC as new () => typeof nfc)()
|
||||
} catch (e) {
|
||||
onStatus({ state: 'unavailable', message: `NFC init failed: ${errMsg(e)}` })
|
||||
return () => {}
|
||||
}
|
||||
|
||||
nfc.on('reader', (reader: unknown) => {
|
||||
const r = reader as {
|
||||
name?: string
|
||||
reader?: { name?: string }
|
||||
autoProcessing?: boolean
|
||||
on: (e: string, cb: (...a: unknown[]) => void) => void
|
||||
transmit: (data: Buffer, maxLen: number) => Promise<Buffer>
|
||||
}
|
||||
const name = r.name ?? r.reader?.name ?? 'reader'
|
||||
// We do our own NDEF APDU read, not nfc-pcsc's UID auto-processing.
|
||||
r.autoProcessing = false
|
||||
onStatus({ state: 'ready', reader: name })
|
||||
|
||||
// Cooldown after a failed read: these cheap CCID readers can get wedged into
|
||||
// a present↔empty storm when hammered, so ignore re-detections for a beat
|
||||
// after a failure. Successful reads don't cool down.
|
||||
let cooldownUntil = 0
|
||||
// Consecutive failed reads → wedge detection (see resetWedgedReader above).
|
||||
// A completed read (Bolt Card or not) proves the reader is healthy and
|
||||
// clears the count; only a run of thrown transmits trips the reset.
|
||||
let consecutiveFailures = 0
|
||||
r.on('card', async () => {
|
||||
if (Date.now() < cooldownUntil) return
|
||||
onStatus({ state: 'reading', reader: name })
|
||||
// Single attempt: retrying hammers a flaky RF link. A read is a few APDU
|
||||
// round-trips; if the card shifts mid-read the transmit fails and the
|
||||
// user simply re-taps.
|
||||
try {
|
||||
const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen))
|
||||
consecutiveFailures = 0
|
||||
if (lnurlw) {
|
||||
onCard(lnurlw)
|
||||
return
|
||||
}
|
||||
onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' })
|
||||
} catch (e) {
|
||||
consecutiveFailures++
|
||||
if (
|
||||
consecutiveFailures >= WEDGE_FAILURE_THRESHOLD &&
|
||||
Date.now() - lastReaderResetAt > RESET_COOLDOWN_MS
|
||||
) {
|
||||
// Reader looks wedged — auto power-cycle it (only fix that works).
|
||||
lastReaderResetAt = Date.now()
|
||||
consecutiveFailures = 0
|
||||
onStatus({ state: 'error', reader: name, message: 'reader stuck — auto-resetting…' })
|
||||
resetWedgedReader()
|
||||
} else {
|
||||
onStatus({
|
||||
state: 'error',
|
||||
reader: name,
|
||||
message: 'card read failed — hold steady & retap',
|
||||
})
|
||||
}
|
||||
void e
|
||||
}
|
||||
cooldownUntil = Date.now() + 1500
|
||||
})
|
||||
r.on('card.off', () => onStatus({ state: 'card-removed', reader: name }))
|
||||
r.on('error', (err: unknown) =>
|
||||
onStatus({ state: 'error', reader: name, message: errMsg(err) })
|
||||
)
|
||||
r.on('end', () =>
|
||||
onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })
|
||||
)
|
||||
})
|
||||
nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) }))
|
||||
|
||||
stopFn = () => {
|
||||
try {
|
||||
nfc.close?.()
|
||||
} catch {
|
||||
/* idempotent */
|
||||
}
|
||||
stopFn = null
|
||||
}
|
||||
return stopFn
|
||||
}
|
||||
|
|
@ -17,6 +17,10 @@ export interface RuntimeConfig {
|
|||
relayUrl: string
|
||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||
lnbitsServerPubkey: string
|
||||
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
|
||||
lightningPubPubkey?: string
|
||||
lightningPubApiUrl?: string
|
||||
extensionApiUrl?: string
|
||||
appId: string
|
||||
machineModel: string
|
||||
fiatCode: string
|
||||
|
|
@ -38,29 +42,13 @@ export interface BrandingConfig {
|
|||
logoDarkDataUrl: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding).
|
||||
*/
|
||||
export interface BunkerBindingRecord {
|
||||
clientSecretHex: string
|
||||
spirePubkey: string
|
||||
bunkerUrl: string
|
||||
seedFingerprint: string
|
||||
pairedAt: number
|
||||
/** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */
|
||||
relays?: string[]
|
||||
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
|
||||
lnbitsServerPubkey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* ATM secrets — returned once by getAtmSecrets(), then empty on subsequent calls.
|
||||
* The spire pairing seed (carries the one-shot connect token) plus the persisted
|
||||
* bunker binding; the renderer resolves these into a signer.
|
||||
*/
|
||||
export interface AtmSecrets {
|
||||
spireSeed: string
|
||||
bunkerBinding: BunkerBindingRecord | null
|
||||
atmPrivateKey: string
|
||||
/** Legacy LP admin token — retained until 3d removes the LP backend. */
|
||||
adminToken?: string
|
||||
}
|
||||
|
||||
// Expose protected methods to renderer
|
||||
|
|
@ -108,78 +96,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
// Operator-config consumer (aiolabs/lamassu-next#56)
|
||||
getLastKnownConfigCreatedAt: (): Promise<number> =>
|
||||
ipcRenderer.invoke('state:get-last-known-config-created-at'),
|
||||
getLastStatePublishedAt: (): Promise<number | null> =>
|
||||
ipcRenderer.invoke('state:get-last-state-published-at'),
|
||||
getCountsUncertainSince: (): Promise<number | null> =>
|
||||
ipcRenderer.invoke('state:get-counts-uncertain-since'),
|
||||
markCountsUncertain: (unixTimestamp: number): Promise<void> =>
|
||||
ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp),
|
||||
markStatePublished: (unixTimestamp: number): Promise<void> =>
|
||||
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
|
||||
|
||||
// Bunker binding persistence (aiolabs/bitspire#52)
|
||||
saveBunkerBinding: (binding: BunkerBindingRecord): Promise<void> =>
|
||||
ipcRenderer.invoke('state:save-bunker-binding', binding),
|
||||
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
|
||||
resetStatePublishWatermark: (): Promise<void> =>
|
||||
ipcRenderer.invoke('state:reset-state-publish-watermark'),
|
||||
resetForRepair: (): Promise<void> => ipcRenderer.invoke('state:reset-for-repair'),
|
||||
|
||||
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
|
||||
// then relaunch so the normal boot flow pairs it.
|
||||
saveSpireSeed: (seed: string): Promise<void> => ipcRenderer.invoke('state:save-spire-seed', seed),
|
||||
relaunchApp: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
|
||||
// Reload the renderer to re-attempt initialization (connectivity recovery).
|
||||
recoverApp: (): Promise<void> => ipcRenderer.invoke('app:recover'),
|
||||
|
||||
// Bolt Card cash-out: pull payment for the current invoice from a tapped card.
|
||||
lnurlWithdraw: (args: {
|
||||
lnurlw: string
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('lnurl:withdraw', args),
|
||||
|
||||
// Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay.
|
||||
resolveCardInvoice: (args: {
|
||||
lnurlw: string
|
||||
amountMsat: number
|
||||
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
|
||||
ipcRenderer.invoke('lnurl:pay-card', args),
|
||||
|
||||
// Bolt Card tap-to-enter: one verified session per tap (balance + fiat +
|
||||
// the withdraw/pay second steps reused at Complete). Payload shapes are
|
||||
// declared in src/types/electron.d.ts (CardSession).
|
||||
openCardSession: (args: { lnurlw: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('lnurl:open-card-session', args),
|
||||
withdrawWithSession: (args: {
|
||||
withdraw: { callback: string; k1: string; minWithdrawable?: number; maxWithdrawable?: number }
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}): Promise<{ ok: boolean; reason?: string }> =>
|
||||
ipcRenderer.invoke('lnurl:withdraw-session', args),
|
||||
resolveSessionInvoice: (args: {
|
||||
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
|
||||
amountMsat: number
|
||||
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
|
||||
ipcRenderer.invoke('lnurl:pay-session', args),
|
||||
|
||||
applyOperatorCassetteOps: (
|
||||
ops: {
|
||||
id: string
|
||||
at: number
|
||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||
position: number
|
||||
bills?: number
|
||||
count?: number
|
||||
denomination?: number
|
||||
}[]
|
||||
): Promise<{
|
||||
applied: string[]
|
||||
rejected: { id: string; reason: string }[]
|
||||
}> => ipcRenderer.invoke('state:apply-operator-cassette-ops', ops),
|
||||
getAppliedOpIds: (limit?: number): Promise<string[]> =>
|
||||
ipcRenderer.invoke('state:get-applied-op-ids', limit),
|
||||
getCassetteStateSeq: (): Promise<number> => ipcRenderer.invoke('state:get-cassette-state-seq'),
|
||||
getBootstrapPublishedAt: (): Promise<number | null> =>
|
||||
ipcRenderer.invoke('state:get-bootstrap-published-at'),
|
||||
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
|
||||
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: {
|
||||
positions: Record<string, { denomination: number; count: number }>
|
||||
},
|
||||
eventCreatedAt: number
|
||||
): Promise<{ applied: true } | { applied: false; reason: string }> =>
|
||||
ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt),
|
||||
|
||||
// Operator-fees consumer (aiolabs/lamassu-next#57)
|
||||
getFeeConfig: (): Promise<{
|
||||
|
|
@ -234,27 +161,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
ipcRenderer.on('hal:error', (_event, error) => callback(error))
|
||||
},
|
||||
|
||||
// Bolt Card reader (main process → renderer). removeAllListeners first: a
|
||||
// renderer reload re-runs this, and a duplicated card-tap listener would
|
||||
// trigger the LNURL-withdraw twice.
|
||||
// The main process changed the cassettes table (an operator-command dispense,
|
||||
// boot seeding). The renderer reloads its inventory and republishes state.
|
||||
onCassettesChanged: (callback: () => void) => {
|
||||
ipcRenderer.removeAllListeners('cassettes:changed')
|
||||
ipcRenderer.on('cassettes:changed', () => callback())
|
||||
},
|
||||
|
||||
onNfcCardTapped: (callback: (lnurlw: string) => void) => {
|
||||
ipcRenderer.removeAllListeners('nfc:card-tapped')
|
||||
ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw))
|
||||
},
|
||||
onNfcStatus: (
|
||||
callback: (status: { state: string; reader?: string; message?: string }) => void
|
||||
) => {
|
||||
ipcRenderer.removeAllListeners('nfc:status')
|
||||
ipcRenderer.on('nfc:status', (_event, status) => callback(status))
|
||||
},
|
||||
|
||||
// Watchdog heartbeat (main process → renderer → main process)
|
||||
onWatchdogPing: (callback: () => void) => {
|
||||
ipcRenderer.on('watchdog:ping', () => callback())
|
||||
|
|
@ -304,42 +210,12 @@ declare global {
|
|||
emptyCashbox: () => Promise<void>
|
||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||
getLastStatePublishedAt: () => Promise<number | null>
|
||||
getCountsUncertainSince: () => Promise<number | null>
|
||||
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
resetStatePublishWatermark: () => Promise<void>
|
||||
resetForRepair: () => Promise<void>
|
||||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
recoverApp: () => Promise<void>
|
||||
lnurlWithdraw: (args: {
|
||||
lnurlw: string
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}) => Promise<{ ok: boolean; reason?: string }>
|
||||
resolveCardInvoice: (args: {
|
||||
lnurlw: string
|
||||
amountMsat: number
|
||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||
applyOperatorCassetteOps: (
|
||||
ops: {
|
||||
id: string
|
||||
at: number
|
||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||
position: number
|
||||
bills?: number
|
||||
count?: number
|
||||
denomination?: number
|
||||
}[]
|
||||
) => Promise<{
|
||||
applied: string[]
|
||||
rejected: { id: string; reason: string }[]
|
||||
}>
|
||||
getAppliedOpIds: (limit?: number) => Promise<string[]>
|
||||
getCassetteStateSeq: () => Promise<number>
|
||||
getBootstrapPublishedAt: () => Promise<number | null>
|
||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||
eventCreatedAt: number
|
||||
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
||||
getFeeConfig: () => Promise<{
|
||||
cashInFeeFraction: number
|
||||
cashOutFeeFraction: number
|
||||
|
|
@ -373,10 +249,6 @@ declare global {
|
|||
onHalBillInserted: (callback: (denomination: number) => void) => void
|
||||
onHalBillRejected: (callback: (reason: string) => void) => void
|
||||
onHalError: (callback: (error: string) => void) => void
|
||||
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
|
||||
onNfcStatus: (
|
||||
callback: (status: { state: string; reader?: string; message?: string }) => void
|
||||
) => void
|
||||
onWatchdogPing: (callback: () => void) => void
|
||||
watchdogPong: () => Promise<void>
|
||||
platform: NodeJS.Platform
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
|||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
const SCHEMA_VERSION = '13'
|
||||
const SCHEMA_VERSION = '10'
|
||||
|
||||
function getDbPath(): string {
|
||||
const prodDir = '/var/lib/bitspire'
|
||||
|
|
@ -57,17 +57,6 @@ export function initDatabase(dbPath?: string): void {
|
|||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cassette_ops (
|
||||
id TEXT PRIMARY KEY,
|
||||
position INTEGER NOT NULL,
|
||||
op_type TEXT NOT NULL,
|
||||
bills INTEGER,
|
||||
count INTEGER,
|
||||
denomination INTEGER,
|
||||
op_at INTEGER NOT NULL,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cashbox (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total_bills INTEGER NOT NULL DEFAULT 0,
|
||||
|
|
@ -125,17 +114,6 @@ export function initDatabase(dbPath?: string): void {
|
|||
event_created_at INTEGER NOT NULL,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bunker_binding (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
client_secret_hex TEXT NOT NULL,
|
||||
spire_pubkey TEXT NOT NULL,
|
||||
bunker_url TEXT NOT NULL,
|
||||
seed_fingerprint TEXT NOT NULL,
|
||||
paired_at INTEGER NOT NULL,
|
||||
relays TEXT,
|
||||
lnbits_server_pubkey TEXT
|
||||
);
|
||||
`)
|
||||
|
||||
// Seed meta + cashbox if first run, or run migrations
|
||||
|
|
@ -306,9 +284,7 @@ export function initDatabase(dbPath?: string): void {
|
|||
`)
|
||||
db.pragma('foreign_keys = ON')
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version')
|
||||
console.log(
|
||||
'[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)'
|
||||
)
|
||||
console.log('[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)')
|
||||
existing.value = '9'
|
||||
}
|
||||
|
||||
|
|
@ -344,78 +320,6 @@ export function initDatabase(dbPath?: string): void {
|
|||
)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('10', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v9 → v10 (added fee_config + watermark)')
|
||||
existing.value = '10'
|
||||
}
|
||||
|
||||
if (existing && existing.value === '10') {
|
||||
// Migration v10 → v11: NIP-46 bunker binding (aiolabs/bitspire#52).
|
||||
// - bunker_binding singleton — the ATM's own NIP-46 transport key
|
||||
// (client_nsec) plus the spire signing identity, bunker URL, and a
|
||||
// fingerprint of the seed it was paired from. Persisted so a restart
|
||||
// resumes the bunker session without re-redeeming the one-shot connect
|
||||
// secret. A new/changed seed_fingerprint signals a re-pair (which also
|
||||
// resets bootstrapPublishedAt — see lightning.ts / bitspire#56).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bunker_binding (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
client_secret_hex TEXT NOT NULL,
|
||||
spire_pubkey TEXT NOT NULL,
|
||||
bunker_url TEXT NOT NULL,
|
||||
seed_fingerprint TEXT NOT NULL,
|
||||
paired_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)')
|
||||
existing.value = '11'
|
||||
}
|
||||
|
||||
if (existing && existing.value === '11') {
|
||||
// Migration v11 → v12: carry the LNbits transport config in the binding
|
||||
// (aiolabs/bitspire#70). relays (JSON array) + lnbits_server_pubkey let a
|
||||
// paired machine reach the backend from the pairing alone — no VITE_RELAY_URL
|
||||
// / VITE_LNBITS_SERVER_PUBKEY provisioning. Nullable: bindings written before
|
||||
// this (the seed didn't carry them) resume fine and fall back to env.
|
||||
db.exec(`
|
||||
ALTER TABLE bunker_binding ADD COLUMN relays TEXT;
|
||||
ALTER TABLE bunker_binding ADD COLUMN lnbits_server_pubkey TEXT;
|
||||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('12', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)')
|
||||
}
|
||||
|
||||
if (existing && existing.value === '12') {
|
||||
// Migration v12 → v13: operator OPERATIONS replace operator counts
|
||||
// (aiolabs/bitspire ADR-004).
|
||||
//
|
||||
// The operator used to publish absolute counts and this machine applied
|
||||
// them outright. Both sides wrote the same value over a transport that
|
||||
// never tells a writer it lost, so a dashboard form loaded before a
|
||||
// dispense silently discarded that dispense — and nothing on either side
|
||||
// could detect it afterwards. The operator now publishes what it DID and
|
||||
// this machine, which holds the notes, owns the running total.
|
||||
//
|
||||
// `cassette_ops` is the dedup ledger. A delta applied twice is wrong, and
|
||||
// addressable events are re-delivered on every reconnect, so the operator
|
||||
// mints an id per operation and we record the ones we have applied. The
|
||||
// operator's window is a slice of recent operations rather than just the
|
||||
// newest, so one we missed arrives with the next publish; dedup is what
|
||||
// makes re-delivery free instead of dangerous.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cassette_ops (
|
||||
id TEXT PRIMARY KEY,
|
||||
position INTEGER NOT NULL,
|
||||
op_type TEXT NOT NULL,
|
||||
bills INTEGER,
|
||||
count INTEGER,
|
||||
denomination INTEGER,
|
||||
op_at INTEGER NOT NULL,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('13', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v12 → v13 (added cassette_ops)')
|
||||
existing.value = '13'
|
||||
}
|
||||
|
||||
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||||
|
|
@ -424,7 +328,6 @@ export function initDatabase(dbPath?: string): void {
|
|||
seedMeta.run('lastKnownConfigCreatedAt', '0')
|
||||
seedMeta.run('bootstrapPublishedAt', '')
|
||||
seedMeta.run('lastKnownFeeConfigCreatedAt', '0')
|
||||
seedMeta.run('cassetteStateSeq', '0')
|
||||
|
||||
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
||||
if (!cashboxRow) {
|
||||
|
|
@ -445,110 +348,32 @@ export function initDatabase(dbPath?: string): void {
|
|||
*/
|
||||
export function getLastKnownConfigCreatedAt(): number {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('lastKnownConfigCreatedAt') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
const row = db
|
||||
.prepare('SELECT value FROM meta WHERE key = ?')
|
||||
.get('lastKnownConfigCreatedAt') as { value: string } | undefined
|
||||
return row ? Number(row.value) || 0 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The `created_at` of the last `bitspire-cassettes-state` event this machine
|
||||
* published, or null if it has never published one.
|
||||
*
|
||||
* This used to be a one-shot gate ("have we said hello yet"), which meant a
|
||||
* layout change after first boot was never announced (#94). It is now a
|
||||
* high-water mark: every publish records its stamp, and the next one is forced
|
||||
* strictly above it. Addressable events are ordered by `created_at` at second
|
||||
* granularity, and a relay silently keeps the higher one, so a clock that steps
|
||||
* backwards would otherwise make this machine's reports vanish with an `OK`.
|
||||
*
|
||||
* Stored under the original `bootstrapPublishedAt` meta key so no migration is
|
||||
* needed; the name is historical, the meaning is not.
|
||||
* Read the one-shot bootstrap-publish gate. Returns null if the ATM has
|
||||
* not yet published its `bitspire-cassettes-state:<machine_id>` hello-event.
|
||||
*/
|
||||
export function getLastStatePublishedAt(): number | null {
|
||||
export function getBootstrapPublishedAt(): number | null {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
const row = db
|
||||
.prepare('SELECT value FROM meta WHERE key = ?')
|
||||
.get('bootstrapPublishedAt') as { value: string } | undefined
|
||||
if (!row || row.value === '') return null
|
||||
const n = Number(row.value)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bay counts are known to be unverified, and since when.
|
||||
*
|
||||
* Set when a dispense ends without the dispenser reporting what it moved — a
|
||||
* driver throw, or the dispense timeout. Bills may well have reached the
|
||||
* customer, but nothing knows how many, so neither the rows here nor HAL's
|
||||
* bays were debited and both now read high. Reporting that number as fact is
|
||||
* the worst option available; saying the number is unverified is honest and
|
||||
* tells the operator to open the machine and recount.
|
||||
*
|
||||
* Cleared when an operator asserts authoritative counts (a config apply),
|
||||
* which is precisely what a recount is. Uses an upsert so no migration is
|
||||
* needed for machines whose meta table predates the key.
|
||||
* Mark the bootstrap hello-event as published. Idempotent — only takes
|
||||
* effect the first time it's set. Subsequent calls overwrite the
|
||||
* timestamp (harmless; the gate just needs to be non-null).
|
||||
*/
|
||||
export function getCountsUncertainSince(): number | null {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('countsUncertainSince') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
if (!row || row.value === '') return null
|
||||
const n = Number(row.value)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/** Flag the counts as unverified. Keeps the earliest time it went bad. */
|
||||
export function markCountsUncertain(unixTimestamp: number): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
if (getCountsUncertainSince() !== null) return
|
||||
db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run('countsUncertainSince', String(unixTimestamp))
|
||||
console.warn('[StateStore] Cassette counts flagged unverified at', unixTimestamp)
|
||||
}
|
||||
|
||||
/** Clear the flag — an operator has asserted real counts. */
|
||||
export function clearCountsUncertain(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run('countsUncertainSince', '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A counter bumped on every local change to a bay count, from any cause.
|
||||
*
|
||||
* It rides along in the state document so a reader can reject a regression
|
||||
* without trusting a clock. `created_at` cannot carry that: it has
|
||||
* second granularity, so two publishes in the same second are ordered by
|
||||
* whichever event id hashes lower — and a machine whose clock stepped
|
||||
* backwards would otherwise have every later report look older than the one
|
||||
* already on the relay.
|
||||
*/
|
||||
export function getCassetteStateSeq(): number {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cassetteStateSeq') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
return row ? Number(row.value) || 0 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump the counter. Safe to call inside an open transaction — every caller
|
||||
* that mutates a count does, so the bump commits or rolls back with it.
|
||||
*/
|
||||
export function bumpCassetteStateSeq(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ' +
|
||||
'ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(meta.value AS INTEGER) + 1 AS TEXT)'
|
||||
).run('cassetteStateSeq', '1')
|
||||
}
|
||||
|
||||
/** Record the `created_at` just published, as the next publish's floor. */
|
||||
export function markStatePublished(unixTimestamp: number): void {
|
||||
export function markBootstrapPublished(unixTimestamp: number): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run(
|
||||
String(unixTimestamp),
|
||||
|
|
@ -556,327 +381,108 @@ export function markStatePublished(unixTimestamp: number): void {
|
|||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bunker binding — NIP-46 transport key + spire identity (aiolabs/bitspire#52)
|
||||
// ---------------------------------------------------------------------------
|
||||
export type OperatorCassettesPayload = {
|
||||
positions: Record<string, { denomination: number; count: number }>
|
||||
}
|
||||
|
||||
export type ApplyResult =
|
||||
| { applied: true }
|
||||
| { applied: false; reason: string }
|
||||
|
||||
export interface StoredBunkerBinding {
|
||||
/** The ATM's own NIP-46 transport secret key (`client_nsec`), hex. */
|
||||
clientSecretHex: string
|
||||
/** The spire's signing pubkey (hex) — the identity events are signed as. */
|
||||
spirePubkey: string
|
||||
/** `bunker://…` URL, re-parsed into a pointer on resume. */
|
||||
bunkerUrl: string
|
||||
/** Fingerprint of the seed this binding was paired from (re-pair detection). */
|
||||
seedFingerprint: string
|
||||
/** Unix seconds when the pairing was redeemed. */
|
||||
pairedAt: number
|
||||
/**
|
||||
* LNbits transport relays from the pairing seed (aiolabs/bitspire#70). Lets a
|
||||
* resumed (seedless) boot reach the backend without env provisioning.
|
||||
* Undefined for bindings written before the seed carried them.
|
||||
* Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56).
|
||||
*
|
||||
* Caller has already verified the event signature and decrypted the
|
||||
* content. This function:
|
||||
*
|
||||
* 1. Rechecks replay-protection against `meta.lastKnownConfigCreatedAt`
|
||||
* (defense-in-depth — caller should have done this too).
|
||||
* 2. Validates the payload's `positions` key set is *exactly* the set of
|
||||
* positions currently in the `cassettes` table. The bay count is
|
||||
* hardware-determined and can't be added to or removed from via this
|
||||
* path; only the per-bay denomination and count are operator-mutable.
|
||||
* 3. Validates per-entry `denomination` is a positive int, `count` is a
|
||||
* non-negative int. **Duplicate denominations across positions are
|
||||
* intentionally permitted** — real machines load multiple cassettes
|
||||
* with the same denomination for cash-out throughput.
|
||||
* 4. In a single SQLite transaction: updates `cassettes` rows by position
|
||||
* (denomination + count both mutable per row) AND advances
|
||||
* `meta.lastKnownConfigCreatedAt` to `eventCreatedAt`.
|
||||
*
|
||||
* Mid-write crashes roll back cleanly; on restart the same event is
|
||||
* re-delivered by the relay and the watermark check drops it as already
|
||||
* consumed (or the watermark is pre-event because the tx rolled back,
|
||||
* and the apply runs again from scratch).
|
||||
*/
|
||||
relays?: string[]
|
||||
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
|
||||
lnbitsServerPubkey?: string
|
||||
}
|
||||
|
||||
/** Read the persisted bunker binding, or null if the ATM is unpaired. */
|
||||
export function getBunkerBinding(): StoredBunkerBinding | null {
|
||||
export function applyOperatorCassettesConfig(
|
||||
payload: OperatorCassettesPayload,
|
||||
eventCreatedAt: number
|
||||
): ApplyResult {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey FROM bunker_binding WHERE id = 1'
|
||||
)
|
||||
.get() as
|
||||
| {
|
||||
client_secret_hex: string
|
||||
spire_pubkey: string
|
||||
bunker_url: string
|
||||
seed_fingerprint: string
|
||||
paired_at: number
|
||||
relays: string | null
|
||||
lnbits_server_pubkey: string | null
|
||||
}
|
||||
| undefined
|
||||
if (!row) return null
|
||||
|
||||
const watermark = getLastKnownConfigCreatedAt()
|
||||
if (eventCreatedAt <= watermark) {
|
||||
return {
|
||||
clientSecretHex: row.client_secret_hex,
|
||||
spirePubkey: row.spire_pubkey,
|
||||
bunkerUrl: row.bunker_url,
|
||||
seedFingerprint: row.seed_fingerprint,
|
||||
pairedAt: row.paired_at,
|
||||
relays: parseRelaysColumn(row.relays),
|
||||
lnbitsServerPubkey: row.lnbits_server_pubkey ?? undefined,
|
||||
applied: false,
|
||||
reason: `event.created_at (${eventCreatedAt}) <= lastKnownConfigCreatedAt (${watermark})`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode the JSON-array `relays` column, tolerating null/legacy/garbage. */
|
||||
function parseRelaysColumn(value: string | null): string[] | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed) && parsed.every((r) => typeof r === 'string')) {
|
||||
return parsed as string[]
|
||||
const currentRows = db
|
||||
.prepare('SELECT position FROM cassettes')
|
||||
.all() as { position: number }[]
|
||||
const currentPositions = new Set(currentRows.map((r) => r.position))
|
||||
const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k)))
|
||||
|
||||
if (currentPositions.size !== payloadPositions.size) {
|
||||
return {
|
||||
applied: false,
|
||||
reason: `position count mismatch: state.db has ${currentPositions.size}, payload has ${payloadPositions.size}`,
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return undefined
|
||||
for (const p of currentPositions) {
|
||||
if (!payloadPositions.has(p)) {
|
||||
return { applied: false, reason: `payload missing position ${p}` }
|
||||
}
|
||||
}
|
||||
for (const p of payloadPositions) {
|
||||
if (!currentPositions.has(p)) {
|
||||
return { applied: false, reason: `payload includes unknown position ${p}` }
|
||||
}
|
||||
}
|
||||
|
||||
/** Upsert the bunker binding after a successful (re-)pairing. */
|
||||
export function saveBunkerBinding(binding: StoredBunkerBinding): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare(
|
||||
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
client_secret_hex = excluded.client_secret_hex,
|
||||
spire_pubkey = excluded.spire_pubkey,
|
||||
bunker_url = excluded.bunker_url,
|
||||
seed_fingerprint = excluded.seed_fingerprint,
|
||||
paired_at = excluded.paired_at,
|
||||
relays = excluded.relays,
|
||||
lnbits_server_pubkey = excluded.lnbits_server_pubkey`
|
||||
).run(
|
||||
binding.clientSecretHex,
|
||||
binding.spirePubkey,
|
||||
binding.bunkerUrl,
|
||||
binding.seedFingerprint,
|
||||
binding.pairedAt,
|
||||
binding.relays ? JSON.stringify(binding.relays) : null,
|
||||
binding.lnbitsServerPubkey ?? null
|
||||
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
||||
if (!Number.isInteger(entry.denomination) || entry.denomination <= 0) {
|
||||
return {
|
||||
applied: false,
|
||||
reason: `denomination must be positive int (position ${posKey}, got ${entry.denomination})`,
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(entry.count) || entry.count < 0) {
|
||||
return {
|
||||
applied: false,
|
||||
reason: `count must be non-negative int (position ${posKey}, got ${entry.count})`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateCassette = db.prepare(
|
||||
'UPDATE cassettes SET denomination = ?, count = ? WHERE position = ?'
|
||||
)
|
||||
const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?')
|
||||
|
||||
const run = db.transaction(() => {
|
||||
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
||||
updateCassette.run(entry.denomination, entry.count, Number(posKey))
|
||||
}
|
||||
setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt')
|
||||
})
|
||||
|
||||
/** Drop the bunker binding (e.g. after an operator revoke → force re-pair). */
|
||||
export function clearBunkerBinding(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare('DELETE FROM bunker_binding WHERE id = 1').run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the publish high-water mark. Called on a re-pair (new seed): the
|
||||
* next publish is then free to use the wall clock, which is what a fresh
|
||||
* operator relationship wants. The state itself is republished on startup
|
||||
* regardless, so the new operator always receives current counts.
|
||||
*/
|
||||
export function resetStatePublishWatermark(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe operator-scoped CONFIG/TRUST state on a re-pair to a new operator/backend,
|
||||
* so stale policy from the previous pairing can't linger or silently reject the
|
||||
* new operator's config.
|
||||
*
|
||||
* Clears the fee config and resets BOTH replay watermarks to 0. The watermark
|
||||
* reset is the load-bearing part: without it, a new backend whose first config
|
||||
* event has a lower `created_at` than the old operator's last event is silently
|
||||
* dropped as a replay — the exact remnant trap where re-pairing a long-lived
|
||||
* install to a fresh backend appears to "work" but never picks up new config.
|
||||
*
|
||||
* Deliberately does NOT touch cassettes / cashbox / transactions: those track
|
||||
* PHYSICAL cash, which survives an operator handover. A full wipe (decommission
|
||||
* or a truly-fresh test) is the factory-reset path, not this.
|
||||
*/
|
||||
export function resetForRepair(): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const database = db
|
||||
database.transaction(() => {
|
||||
database.prepare('DELETE FROM fee_config').run()
|
||||
const setWatermark = database.prepare('UPDATE meta SET value = ? WHERE key = ?')
|
||||
setWatermark.run('0', 'lastKnownFeeConfigCreatedAt')
|
||||
setWatermark.run('0', 'lastKnownConfigCreatedAt')
|
||||
})()
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of applying an operator-authored absolute config. Still the right
|
||||
* shape for fee config, where the operator is the only writer of the value
|
||||
* and a later event simply supersedes an earlier one. Cassette counts left
|
||||
* this model in ADR-004 precisely because they had two writers.
|
||||
*/
|
||||
export type ApplyResult = { applied: true } | { applied: false; reason: string }
|
||||
|
||||
/** One operator-authored operation, as it arrives on the wire. */
|
||||
export type CassetteOp = {
|
||||
id: string
|
||||
at: number
|
||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||
position: number
|
||||
bills?: number
|
||||
count?: number
|
||||
denomination?: number
|
||||
}
|
||||
|
||||
export type ApplyOpsResult = {
|
||||
/** Ids applied by this call. Empty when every op was already on file. */
|
||||
applied: string[]
|
||||
/** Ids rejected, with why. These stay unapplied and unrecorded. */
|
||||
rejected: { id: string; reason: string }[]
|
||||
}
|
||||
|
||||
const CASSETTE_OP_TYPES = new Set(['refill', 'empty', 'recount', 'set_denomination'])
|
||||
|
||||
/**
|
||||
* Validate one operation in isolation. Returns null when it is well-formed.
|
||||
*
|
||||
* Shape errors and unknown positions are treated the same way by the caller:
|
||||
* the op is neither applied nor recorded, so it stays pending on the
|
||||
* operator's dashboard. That is the honest outcome — it did not happen — and
|
||||
* it beats recording it as applied to stop the noise, which would tell the
|
||||
* operator their refill landed when the notes are unaccounted for.
|
||||
*/
|
||||
function validateCassetteOp(op: CassetteOp, knownPositions: Set<number>): string | null {
|
||||
if (typeof op.id !== 'string' || op.id.length === 0) return 'missing id'
|
||||
if (!CASSETTE_OP_TYPES.has(op.type)) return `unknown type ${String(op.type)}`
|
||||
if (!Number.isInteger(op.position)) return `position must be an integer (got ${op.position})`
|
||||
if (!knownPositions.has(op.position)) return `unknown position ${op.position}`
|
||||
if (!Number.isFinite(op.at)) return 'missing at'
|
||||
|
||||
if (op.type === 'refill') {
|
||||
if (!Number.isInteger(op.bills) || (op.bills as number) <= 0) {
|
||||
return `refill needs a positive integer bills (got ${op.bills})`
|
||||
}
|
||||
}
|
||||
if (op.type === 'recount') {
|
||||
if (!Number.isInteger(op.count) || (op.count as number) < 0) {
|
||||
return `recount needs a non-negative integer count (got ${op.count})`
|
||||
}
|
||||
}
|
||||
if (op.type === 'set_denomination') {
|
||||
if (!Number.isInteger(op.denomination) || (op.denomination as number) <= 0) {
|
||||
return `set_denomination needs a positive integer denomination (got ${op.denomination})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an operator's cassette operations, skipping any already on file.
|
||||
*
|
||||
* This replaces applying absolute counts. The operator authors what it DID —
|
||||
* a refill in notes added, an empty, a recount, a denomination change — and
|
||||
* this machine, which holds the physical notes, keeps the running total.
|
||||
* Nobody but this process writes a count any more, so there is no second
|
||||
* writer to lose a race to.
|
||||
*
|
||||
* Deltas are not idempotent and addressable events ARE re-delivered on every
|
||||
* relay reconnect, so idempotency is carried explicitly: the operator mints an
|
||||
* id per operation, `cassette_ops` records the ones applied, and a repeat is a
|
||||
* no-op. That is also why there is no `created_at` watermark here any more.
|
||||
* Under absolute counts the watermark was the only replay defence; with
|
||||
* per-op ids it is strictly weaker than the dedup and would do active harm,
|
||||
* because an event that arrives out of order may still carry an operation this
|
||||
* machine has never seen.
|
||||
*
|
||||
* Applied oldest-first by `at`, ties broken by id so two operations stamped in
|
||||
* the same second still order the same way on every machine. Ordering matters
|
||||
* because a recount followed by a refill is not the same as the reverse.
|
||||
*
|
||||
* The whole batch runs in one SQLite transaction with the sequence bump, so a
|
||||
* crash mid-apply rolls back to a coherent count and the next publish re-offers
|
||||
* every op in the window.
|
||||
*/
|
||||
export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const database = db
|
||||
const result: ApplyOpsResult = { applied: [], rejected: [] }
|
||||
if (ops.length === 0) return result
|
||||
|
||||
const knownPositions = new Set(
|
||||
(database.prepare('SELECT position FROM cassettes').all() as { position: number }[]).map(
|
||||
(r) => r.position
|
||||
)
|
||||
)
|
||||
const seen = database.prepare('SELECT 1 FROM cassette_ops WHERE id = ?')
|
||||
|
||||
const pending: CassetteOp[] = []
|
||||
for (const op of ops) {
|
||||
if (op && typeof op.id === 'string' && seen.get(op.id)) continue
|
||||
const reason = validateCassetteOp(op, knownPositions)
|
||||
if (reason) {
|
||||
result.rejected.push({ id: op?.id ?? '<no id>', reason })
|
||||
continue
|
||||
}
|
||||
pending.push(op)
|
||||
}
|
||||
if (pending.length === 0) return result
|
||||
|
||||
pending.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
|
||||
const addBills = database.prepare(
|
||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
|
||||
)
|
||||
const setCount = database.prepare('UPDATE cassettes SET count = ? WHERE position = ?')
|
||||
const setDenomination = database.prepare(
|
||||
'UPDATE cassettes SET denomination = ? WHERE position = ?'
|
||||
)
|
||||
const recordOp = database.prepare(
|
||||
'INSERT INTO cassette_ops (id, position, op_type, bills, count, denomination, op_at, applied_at) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
const upsertMeta = database.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
)
|
||||
|
||||
const appliedAt = Math.floor(Date.now() / 1000)
|
||||
let sawRecount = false
|
||||
|
||||
database.transaction(() => {
|
||||
for (const op of pending) {
|
||||
if (op.type === 'refill') addBills.run(op.bills, op.position)
|
||||
else if (op.type === 'empty') setCount.run(0, op.position)
|
||||
else if (op.type === 'recount') {
|
||||
setCount.run(op.count, op.position)
|
||||
sawRecount = true
|
||||
} else setDenomination.run(op.denomination, op.position)
|
||||
|
||||
recordOp.run(
|
||||
op.id,
|
||||
op.position,
|
||||
op.type,
|
||||
op.bills ?? null,
|
||||
op.count ?? null,
|
||||
op.denomination ?? null,
|
||||
Math.floor(op.at),
|
||||
appliedAt
|
||||
)
|
||||
result.applied.push(op.id)
|
||||
}
|
||||
bumpCassetteStateSeq()
|
||||
// A recount is an operator opening the bay and counting it, which is
|
||||
// exactly what resolves an unverified count. Nothing else does: a refill
|
||||
// adds to a number still known to be wrong.
|
||||
if (sawRecount) upsertMeta.run('countsUncertainSince', '')
|
||||
})()
|
||||
|
||||
run()
|
||||
console.log(
|
||||
`[StateStore] Applied ${result.applied.length} cassette op(s)` +
|
||||
(result.rejected.length ? `, rejected ${result.rejected.length}` : '')
|
||||
`[StateStore] Applied operator cassettes config @ created_at=${eventCreatedAt} (${Object.keys(payload.positions).length} positions)`
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids most recently applied, newest first — the acknowledgement leg of
|
||||
* the protocol.
|
||||
*
|
||||
* An addressable event gives its publisher no failure signal at all: the relay
|
||||
* returns OK for an event it then discards, and a losing writer is never told.
|
||||
* Echoing the ids back in this machine's own state document is the only way
|
||||
* the operator can distinguish an operation that landed from one that was
|
||||
* merely sent.
|
||||
*/
|
||||
export function getAppliedOpIds(limit = 50): string[] {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const rows = db
|
||||
.prepare('SELECT id FROM cassette_ops ORDER BY applied_at DESC, rowid DESC LIMIT ?')
|
||||
.all(limit) as { id: string }[]
|
||||
return rows.map((r) => r.id)
|
||||
return { applied: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -961,7 +567,10 @@ export interface FeeConfigPayload {
|
|||
*/
|
||||
const FEE_CAP_PER_DIRECTION = 0.15
|
||||
|
||||
export function applyFeeConfig(payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult {
|
||||
export function applyFeeConfig(
|
||||
payload: FeeConfigPayload,
|
||||
eventCreatedAt: number
|
||||
): ApplyResult {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
|
||||
const watermark = getLastKnownFeeConfigCreatedAt()
|
||||
|
|
@ -1054,7 +663,6 @@ export function setCassettes(
|
|||
const row = rows[i]!
|
||||
upsert.run(row.position ?? i + 1, row.denomination, row.count)
|
||||
}
|
||||
bumpCassetteStateSeq()
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1069,14 +677,11 @@ export function setCassettes(
|
|||
*/
|
||||
export function updateCassetteCountByPosition(position: number, delta: number): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const database = db
|
||||
|
||||
database.transaction(() => {
|
||||
database
|
||||
.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?')
|
||||
.run(delta, position)
|
||||
bumpCassetteStateSeq()
|
||||
})()
|
||||
db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?').run(
|
||||
delta,
|
||||
position
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1089,15 +694,10 @@ export function getInventory(): Record<number, number> {
|
|||
const rows = loadCassettes()
|
||||
const inv: Record<number, number> = {}
|
||||
for (const row of rows) {
|
||||
// Zero-count bays are KEPT. Dropping them made a drained machine
|
||||
// indistinguishable from an unconfigured one, and every caller reads an
|
||||
// empty map as "I don't know, ask the hardware" — so the last non-empty
|
||||
// snapshot stuck and the availability beacon went on advertising bills
|
||||
// that had already been dispensed. An empty map now means exactly one
|
||||
// thing: no cassettes are configured. Consumers already filter for
|
||||
// `> 0` before offering a denomination (CashOutView, machine.ts).
|
||||
if (row.count > 0) {
|
||||
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
|
||||
}
|
||||
}
|
||||
return inv
|
||||
}
|
||||
|
||||
|
|
@ -1197,11 +797,8 @@ export function recordTransaction(tx: TransactionInput): void {
|
|||
const insertCassetteBill = db.prepare(
|
||||
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
const updateCassetteByPosition = db.prepare(
|
||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
|
||||
)
|
||||
const selectBaysByDenom = db.prepare(
|
||||
'SELECT position, count FROM cassettes WHERE denomination = ? ORDER BY position'
|
||||
const updateCassette = db.prepare(
|
||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?'
|
||||
)
|
||||
const updateCashboxStmt = db.prepare(
|
||||
'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1'
|
||||
|
|
@ -1241,49 +838,21 @@ export function recordTransaction(tx: TransactionInput): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Any dispense empties bays, whoever asked for it. `manual_dispense`
|
||||
// (operator remediation, via the command poller or a kind-21003 command)
|
||||
// used to fall outside this branch: HAL decremented its in-memory bays but
|
||||
// the rows here did not move, and on the next boot HAL re-seeds from these
|
||||
// rows — so the machine came back believing it still held bills a customer
|
||||
// had already been handed (#76). A remediation against a partly-dispensed
|
||||
// original decrements again on purpose: the original only ever debited what
|
||||
// physically left, and this is a second lot of bills leaving.
|
||||
if (t.type === 'cash_out' || t.type === 'manual_dispense') {
|
||||
// Decrement cassettes by ACTUALLY dispensed count (not requested).
|
||||
// Position is the addressable unit (v9): duplicate denominations
|
||||
// across bays are legal, so a denomination-keyed UPDATE would
|
||||
// decrement every matching bay.
|
||||
if (t.type === 'cash_out') {
|
||||
// Decrement cassettes by ACTUALLY dispensed count (not requested)
|
||||
if (t.cassettes) {
|
||||
for (const c of t.cassettes) {
|
||||
if (c.dispensed > 0) {
|
||||
updateCassetteByPosition.run(-c.dispensed, c.position)
|
||||
updateCassette.run(-c.dispensed, c.denomination)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: per-denomination bill counts (mocks without per-bay
|
||||
// results). Drain matching bays greedily in position order —
|
||||
// the dispenser's own fill order.
|
||||
// Fallback: use bill counts (backward compat for mocks without cassette data)
|
||||
for (const bill of t.bills) {
|
||||
let remaining = bill.count
|
||||
const bays = selectBaysByDenom.all(bill.denomination) as {
|
||||
position: number
|
||||
count: number
|
||||
}[]
|
||||
for (const bay of bays) {
|
||||
if (remaining <= 0) break
|
||||
const take = Math.min(remaining, bay.count)
|
||||
if (take <= 0) continue
|
||||
updateCassetteByPosition.run(-take, bay.position)
|
||||
remaining -= take
|
||||
updateCassette.run(-bill.count, bill.denomination)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The counts moved, so the sequence must move with them, inside this
|
||||
// same transaction. It rides in the state document as the operator's
|
||||
// way to reject a regression without trusting either clock.
|
||||
bumpCassetteStateSeq()
|
||||
}
|
||||
|
||||
if (t.type === 'cash_in') {
|
||||
// Bills inserted by customer go into cashbox
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
<!--
|
||||
Content Security Policy:
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'"
|
||||
/>
|
||||
<title>bitSpire ATM</title>
|
||||
<title>Lamassu ATM</title>
|
||||
<style>
|
||||
/* Prevent text selection and context menu on kiosk */
|
||||
* {
|
||||
|
|
@ -33,17 +33,12 @@
|
|||
padding: 0;
|
||||
background: #000;
|
||||
}
|
||||
/* Kiosk-only: lock overflow. The cursor is NOT hidden here — that is
|
||||
src/style.css's `.kiosk` rule, which main.ts applies at runtime unless
|
||||
VITE_DEMO_TAG is set. This block can't make that distinction (static
|
||||
HTML, and the CSP forbids an inline script to read the env), so a
|
||||
`cursor: none` here would also blank the pointer on the public web
|
||||
demo, where people drive the kiosk with a mouse. One owner for cursor
|
||||
hiding, and it is the one that knows whether this is a real machine. */
|
||||
/* Kiosk-only: lock overflow and hide cursor */
|
||||
@media (min-width: 1024px) {
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
cursor: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -14,8 +14,7 @@
|
|||
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
|
||||
"dev:vite": "vite",
|
||||
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
|
||||
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --external:better-sqlite3 --outfile=dist-electron/fund-atm.bundle.cjs",
|
||||
"build:web": "vite build",
|
||||
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --outfile=dist-electron/fund-atm.bundle.cjs",
|
||||
"build:electron": "pnpm build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
|
|
@ -36,10 +35,8 @@
|
|||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.563.0",
|
||||
"marked": "^17.0.5",
|
||||
"nfc-pcsc": "^0.8.1",
|
||||
"nostr-tools": "^2.10.0",
|
||||
"pinia": "^2.2.0",
|
||||
"qr": "^0.6.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
|
|||
|
|
@ -1,39 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useSessionSecurity } from '@/composables/useSessionSecurity'
|
||||
import { setBranding } from '@/composables/useBranding'
|
||||
import { classifyInitError } from '@/services/init-error'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import PairingWizard from '@/components/PairingWizard.vue'
|
||||
import LockedView from '@/views/LockedView.vue'
|
||||
import ColorModeToggle from '@/components/ColorModeToggle.vue'
|
||||
import { Sun, Moon } from 'lucide-vue-next'
|
||||
|
||||
const atmStore = useAtmStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { current: currentTheme, themes, colorMode } = useTheme()
|
||||
|
||||
// ADR-003 session security: idle-inactivity re-lock (resets on touch) + an
|
||||
// absolute hard session cap. Enforced here at the always-mounted shell so it
|
||||
// spans the whole unlocked session, not just the idle view.
|
||||
useSessionSecurity()
|
||||
|
||||
// When the machine re-locks after a transaction (access gate enabled), the
|
||||
// router is still on /cash-in or /cash-out under the LockedView overlay. Reset
|
||||
// it to home so that when the gate reopens to `idle`, IdleView shows — not the
|
||||
// stale transaction view (e.g. a completed cash-out's collect screen). Runs
|
||||
// while locked, so the router-view is hidden; no flicker. The disabled-gate path
|
||||
// (never dwells in `locked`) still returns home via each view's isIdle watch.
|
||||
watch(
|
||||
() => atmStore.isLocked,
|
||||
(locked) => {
|
||||
if (locked && route.path !== '/') void router.push('/')
|
||||
}
|
||||
)
|
||||
const debugExpanded = ref(false)
|
||||
const isSupport = computed(() => route.path === '/support')
|
||||
// Network detected dynamically from Lightning invoice prefix
|
||||
|
|
@ -43,54 +20,6 @@ function formatSats(sats: number): string {
|
|||
return sats.toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintenance-screen copy keyed by the `initError` sentinel. Falls back to a
|
||||
* generic out-of-service message (the raw error text shows under debug only).
|
||||
*/
|
||||
const MAINTENANCE_SCREENS: Record<string, { title: string; message: string }> = {
|
||||
maintenance: {
|
||||
title: 'Under Service',
|
||||
message: 'This machine is currently being serviced. We will be back shortly.',
|
||||
},
|
||||
'awaiting-fees': {
|
||||
title: 'Awaiting Configuration',
|
||||
message:
|
||||
'Awaiting fee configuration from operator. Contact operator to publish initial fee config.',
|
||||
},
|
||||
unpaired: {
|
||||
title: 'Pairing Required',
|
||||
message:
|
||||
'This machine needs to be re-paired by the operator before it can accept transactions.',
|
||||
},
|
||||
'signer-unreachable': {
|
||||
title: 'Signer Unreachable',
|
||||
message: 'Cannot reach the signing service right now. This usually resolves shortly.',
|
||||
},
|
||||
}
|
||||
|
||||
const GENERIC_SCREEN = {
|
||||
title: 'ATM Unavailable',
|
||||
message:
|
||||
'This machine is temporarily out of service. Please try again later or use another machine.',
|
||||
}
|
||||
|
||||
const maintenanceScreen = computed(() =>
|
||||
atmStore.initError ? (MAINTENANCE_SCREENS[atmStore.initError] ?? GENERIC_SCREEN) : GENERIC_SCREEN
|
||||
)
|
||||
|
||||
/** True when the screen is a known sentinel (hide the raw debug error line). */
|
||||
const isKnownMaintenanceScreen = computed(
|
||||
() => !!atmStore.initError && atmStore.initError in MAINTENANCE_SCREENS
|
||||
)
|
||||
|
||||
/**
|
||||
* `unpaired` is interactive, not a dead-end: render the QR-pairing wizard so
|
||||
* the operator can scan a spire-seed on-machine (aiolabs/bitspire#52). The
|
||||
* wizard only works under Electron (needs the seed-persist + relaunch bridge);
|
||||
* in browser dev it falls back to the static card.
|
||||
*/
|
||||
const showPairingWizard = computed(() => atmStore.initError === 'unpaired' && isElectron)
|
||||
|
||||
const formattedBtcPrice = computed(() => {
|
||||
if (atmStore.btcPrice === null) return null
|
||||
const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}`
|
||||
|
|
@ -122,21 +51,18 @@ onMounted(async () => {
|
|||
atmStore.initError = 'maintenance'
|
||||
// Publish maintenance beacon — minimal Nostr connection only (no Lightning.Pub)
|
||||
try {
|
||||
const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client')
|
||||
const { resolveSigner } = await import('@/services/signer-resolver')
|
||||
// Best-effort: resolve a signer (bunker resume / pairing, or dev nsec).
|
||||
// If the ATM isn't paired yet, skip the beacon rather than fail the screen.
|
||||
const resolved = await resolveSigner({ allowEphemeral: true }).catch(() => null)
|
||||
const signer = resolved?.signer ?? null
|
||||
// Same env → pairing-seed precedence as lightning.ts: on a blank-.env
|
||||
// seed-driven machine the relay comes from the pairing transport, not env.
|
||||
const relayUrl =
|
||||
config?.relayUrl || import.meta.env.VITE_RELAY_URL || resolved?.transport?.relays?.[0]
|
||||
if (signer && relayUrl) {
|
||||
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
||||
const { NostrClient, loadIdentityFromHex, createSignedEvent } = await import(
|
||||
'@bitSpire/nostr-client'
|
||||
)
|
||||
const secrets = isElectron ? await window.electronAPI?.getAtmSecrets() : null
|
||||
const privKey = secrets?.atmPrivateKey || import.meta.env.VITE_ATM_PRIVATE_KEY
|
||||
const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL
|
||||
if (privKey && relayUrl) {
|
||||
const identity = loadIdentityFromHex(privKey)
|
||||
const client = new NostrClient({ relays: [{ url: relayUrl }], identity })
|
||||
await client.connect()
|
||||
const publishBeacon = async () => {
|
||||
const event = await createSignedEvent(signer, {
|
||||
const publishBeacon = () => {
|
||||
const event = createSignedEvent(identity, {
|
||||
kind: 30078,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['d', 'atm-availability']],
|
||||
|
|
@ -151,8 +77,8 @@ onMounted(async () => {
|
|||
})
|
||||
client.publish(event).catch(() => {})
|
||||
}
|
||||
void publishBeacon()
|
||||
setInterval(() => void publishBeacon(), 5 * 60 * 1000)
|
||||
publishBeacon()
|
||||
setInterval(publishBeacon, 5 * 60 * 1000)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[App] Failed to start maintenance beacon:', e)
|
||||
|
|
@ -171,77 +97,14 @@ onMounted(async () => {
|
|||
atmStore.startPricePolling()
|
||||
} catch (error) {
|
||||
console.error('[App] Initialization failed:', error)
|
||||
atmStore.initError = classifyInitError(error)
|
||||
atmStore.initError = error instanceof Error ? error.message : 'Initialization failed'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
atmStore.stopPricePolling()
|
||||
stopRecoveryWatch()
|
||||
})
|
||||
|
||||
// ── Connectivity recovery (ADR-002 amendment 2026-08-04) ──────────────────
|
||||
// A connectivity-type init failure lands on "ATM Unavailable" and, without
|
||||
// this, stays there forever (init is one-shot; the nostr reconnect only helps
|
||||
// AFTER a first successful connect). We recover by reloading the renderer —
|
||||
// which re-runs this whole init from a clean JS context while the main process
|
||||
// keeps HAL (see main.ts app:recover). Not for the operator/self-clearing
|
||||
// states: `unpaired` shows the pairing wizard, `awaiting-fees` clears itself on
|
||||
// the operator's fee-config event, `maintenance` is operator-set.
|
||||
const NON_RECOVERABLE = new Set(['maintenance', 'awaiting-fees', 'unpaired'])
|
||||
const isRecoverable = computed(
|
||||
() => !!atmStore.initError && !NON_RECOVERABLE.has(atmStore.initError)
|
||||
)
|
||||
const recovering = ref(false)
|
||||
const RECOVERY_RETRY_MS = 45_000
|
||||
let recoveryTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function triggerRecovery() {
|
||||
if (recovering.value) return
|
||||
recovering.value = true
|
||||
console.log('[App] Attempting connectivity recovery (renderer reload)')
|
||||
if (window.electronAPI?.recoverApp) {
|
||||
void window.electronAPI.recoverApp() // main reloads renderer → fresh init
|
||||
} else {
|
||||
location.reload() // browser-dev fallback
|
||||
}
|
||||
}
|
||||
|
||||
function onOnline() {
|
||||
// Network came back — recover immediately rather than waiting for the timer.
|
||||
triggerRecovery()
|
||||
}
|
||||
|
||||
function startRecoveryWatch() {
|
||||
stopRecoveryWatch()
|
||||
window.addEventListener('online', onOnline)
|
||||
// Safety net for the online-but-relay-unreachable case (navigator.onLine only
|
||||
// reflects a local route, not relay reachability).
|
||||
recoveryTimer = setInterval(triggerRecovery, RECOVERY_RETRY_MS)
|
||||
}
|
||||
|
||||
function stopRecoveryWatch() {
|
||||
window.removeEventListener('online', onOnline)
|
||||
if (recoveryTimer !== null) {
|
||||
clearInterval(recoveryTimer)
|
||||
recoveryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Operator-facing "Retry" button on the maintenance screen. */
|
||||
function retryNow() {
|
||||
triggerRecovery()
|
||||
}
|
||||
|
||||
watch(
|
||||
isRecoverable,
|
||||
(recoverable) => {
|
||||
if (recoverable) startRecoveryWatch()
|
||||
else stopRecoveryWatch()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function toggleLiveServices() {
|
||||
if (atmStore.useLiveServices) {
|
||||
// Switch to mock
|
||||
|
|
@ -257,12 +120,9 @@ function toggleLiveServices() {
|
|||
<div
|
||||
class="flex min-h-dvh lg:h-dvh w-screen flex-col overflow-y-auto lg:overflow-hidden bg-background font-sans text-foreground"
|
||||
>
|
||||
<!-- Unpaired: interactive QR-pairing wizard (aiolabs/bitspire#52) -->
|
||||
<PairingWizard v-if="showPairingWizard" />
|
||||
|
||||
<!-- Maintenance screen: shown when initialization fails in production -->
|
||||
<div
|
||||
v-else-if="atmStore.initError"
|
||||
v-if="atmStore.initError"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -282,37 +142,35 @@ function toggleLiveServices() {
|
|||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<h1 class="text-2xl lg:text-[3.5rem] font-bold">
|
||||
{{ maintenanceScreen.title }}
|
||||
{{
|
||||
atmStore.initError === 'maintenance'
|
||||
? 'Under Service'
|
||||
: atmStore.initError === 'awaiting-fees'
|
||||
? 'Awaiting Configuration'
|
||||
: 'ATM Unavailable'
|
||||
}}
|
||||
</h1>
|
||||
<p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground">
|
||||
{{ maintenanceScreen.message }}
|
||||
{{
|
||||
atmStore.initError === 'maintenance'
|
||||
? 'This machine is currently being serviced. We will be back shortly.'
|
||||
: atmStore.initError === 'awaiting-fees'
|
||||
? 'Awaiting fee configuration from operator. Contact operator to publish initial fee config.'
|
||||
: 'This machine is temporarily out of service. Please try again later or use another machine.'
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="atmStore.debugMode && !isKnownMaintenanceScreen"
|
||||
v-if="
|
||||
atmStore.debugMode &&
|
||||
atmStore.initError !== 'maintenance' &&
|
||||
atmStore.initError !== 'awaiting-fees'
|
||||
"
|
||||
class="max-w-lg text-center font-mono text-sm text-destructive"
|
||||
>
|
||||
{{ atmStore.initError }}
|
||||
</p>
|
||||
|
||||
<!-- Manual recovery for a connectivity failure; auto-recovery also runs
|
||||
in the background (online event + backoff). Not shown for operator/
|
||||
self-clearing states (maintenance / awaiting-fees / unpaired). -->
|
||||
<Button
|
||||
v-if="isRecoverable"
|
||||
size="kiosk"
|
||||
:disabled="recovering"
|
||||
class="mt-4"
|
||||
@click="retryNow"
|
||||
>
|
||||
{{ recovering ? 'Retrying…' : 'Retry' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Access gate (ADR-003): shown when the machine is healthy but locked,
|
||||
below the init/maintenance gates above. Never renders when access
|
||||
control is disabled (the machine never dwells in `locked`). -->
|
||||
<LockedView v-else-if="atmStore.isLocked" />
|
||||
|
||||
<template v-else>
|
||||
<router-view />
|
||||
|
||||
|
|
@ -364,10 +222,16 @@ function toggleLiveServices() {
|
|||
</div>
|
||||
|
||||
<!-- Light/dark toggle (production only — debug panel has this in dev) -->
|
||||
<ColorModeToggle
|
||||
<Button
|
||||
v-if="!atmStore.allowMockFallback"
|
||||
class="fixed bottom-3 right-3 z-50 lg:bottom-6 lg:right-6"
|
||||
/>
|
||||
variant="outline"
|
||||
class="fixed bottom-3 right-3 lg:bottom-6 lg:right-6 z-50 h-10 px-3 py-1 text-sm rounded-lg lg:h-[7vh] lg:min-h-[70px] lg:px-8 lg:py-3 lg:text-2xl lg:rounded-xl gap-2 lg:gap-3"
|
||||
@click="colorMode = colorMode === 'dark' ? 'light' : 'dark'"
|
||||
>
|
||||
<Sun v-if="colorMode === 'dark'" class="w-5 h-5 lg:w-7 lg:h-7" />
|
||||
<Moon v-else class="w-5 h-5 lg:w-7 lg:h-7" />
|
||||
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
|
||||
</Button>
|
||||
|
||||
<!-- Debug overlay (dev only) -->
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* The Bolt Card loaded for this session (ADR-003 tap-to-enter): card label,
|
||||
* and the card wallet's balance HIDDEN BY DEFAULT behind an eye toggle — a
|
||||
* kiosk in a public space must not show a stranger's balance unasked. The
|
||||
* revealed line mirrors the LNbits wallet page: sats, then the fiat
|
||||
* equivalent in the wallet's own currency (Intl currency formatting), falling
|
||||
* back to the ATM's fiat at its display rate when the card server priced
|
||||
* nothing. Reveal state lives in the store and resets on re-lock.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Nfc, Eye, EyeOff } from 'lucide-vue-next'
|
||||
|
||||
const atmStore = useAtmStore()
|
||||
const card = computed(() => atmStore.loadedBoltCard)
|
||||
|
||||
const label = computed(() => {
|
||||
const c = card.value
|
||||
if (!c) return ''
|
||||
return c.cardName
|
||||
? `${c.cardName} · ••${c.externalId.slice(-4)}`
|
||||
: `Card ••${c.externalId.slice(-4)}`
|
||||
})
|
||||
const sats = computed(() =>
|
||||
card.value ? new Intl.NumberFormat().format(card.value.balanceSats) : ''
|
||||
)
|
||||
const fiat = computed(() => {
|
||||
const f = atmStore.loadedCardFiat
|
||||
if (!f) return null
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: f.currency }).format(
|
||||
f.amount
|
||||
)
|
||||
} catch {
|
||||
return `${f.amount.toFixed(2)} ${f.currency}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="card"
|
||||
class="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-2 text-left lg:gap-4 lg:px-6 lg:py-3"
|
||||
>
|
||||
<Nfc class="size-6 shrink-0 text-primary lg:size-8" />
|
||||
<div class="flex min-w-0 flex-col leading-tight">
|
||||
<span class="truncate text-xs uppercase tracking-wide text-muted-foreground lg:text-sm">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="atmStore.cardBalanceRevealed"
|
||||
class="text-base font-semibold text-foreground lg:text-2xl"
|
||||
>
|
||||
{{ sats }} sats
|
||||
<span v-if="fiat" class="ml-2 font-normal text-muted-foreground">≈ {{ fiat }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-base font-semibold tracking-widest text-muted-foreground lg:text-2xl"
|
||||
aria-label="Balance hidden"
|
||||
>
|
||||
••••••
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="ml-auto h-10 w-10 shrink-0 rounded-full lg:h-14 lg:w-14"
|
||||
:aria-label="atmStore.cardBalanceRevealed ? 'Hide balance' : 'Show balance'"
|
||||
@click="atmStore.toggleCardBalance()"
|
||||
>
|
||||
<EyeOff v-if="atmStore.cardBalanceRevealed" class="size-5 lg:size-7" />
|
||||
<Eye v-else class="size-5 lg:size-7" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Light/dark toggle — the single reusable control for switching color mode.
|
||||
*
|
||||
* Kiosk-sized by default (large touch target for a public display). colorMode
|
||||
* is global + persisted (toggles `.dark` on <html>, and dark mode pulls
|
||||
* branding.json's dark palette + logo-dark.png), so this stays in sync
|
||||
* wherever it's used. Position it via a fallthrough `class` on the consumer,
|
||||
* e.g. `<ColorModeToggle class="fixed bottom-6 right-6" />`.
|
||||
*/
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sun, Moon } from 'lucide-vue-next'
|
||||
|
||||
const { colorMode } = useTheme()
|
||||
|
||||
function toggle() {
|
||||
colorMode.value = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-10 gap-2 rounded-lg px-3 py-1 text-sm lg:h-[7vh] lg:min-h-[70px] lg:gap-3 lg:rounded-xl lg:px-8 lg:py-3 lg:text-2xl"
|
||||
:aria-label="colorMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="toggle"
|
||||
>
|
||||
<Sun v-if="colorMode === 'dark'" class="h-5 w-5 lg:h-7 lg:w-7" />
|
||||
<Moon v-else class="h-5 w-5 lg:h-7 lg:w-7" />
|
||||
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
|
||||
</Button>
|
||||
</template>
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* QR-pairing wizard (aiolabs/bitspire#52).
|
||||
*
|
||||
* Shown in place of the "Pairing Required" maintenance screen when the machine
|
||||
* is unpaired. The operator displays the spire-seed QR (minted by spirekeeper)
|
||||
* to the machine's camera; we decode it, persist it as VITE_SPIRE_SEED, and
|
||||
* relaunch so the normal boot path performs the bunker pairing.
|
||||
*
|
||||
* Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be
|
||||
* offered later without changing this view.
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import {
|
||||
availablePairingSources,
|
||||
ingestScannedSeed,
|
||||
parseScannedSeed,
|
||||
testRelay,
|
||||
type PairingSource,
|
||||
type RelayTestResult,
|
||||
type StopCapture,
|
||||
} from '@/services/pairing'
|
||||
|
||||
type Phase = 'probing' | 'scanning' | 'review' | 'no-source' | 'pairing' | 'error'
|
||||
|
||||
const phase = ref<Phase>('probing')
|
||||
const errorMessage = ref('')
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
const sources = shallowRef<PairingSource[]>([])
|
||||
const activeSource = shallowRef<PairingSource | null>(null)
|
||||
let stopCapture: StopCapture | null = null
|
||||
|
||||
// Review-step state: the scanned-but-not-yet-committed seed + relay tests.
|
||||
const scannedRaw = ref('')
|
||||
const previewSpire = ref('')
|
||||
const previewRelays = ref<string[]>([])
|
||||
type RelayState = { status: 'idle' | 'testing' | 'done'; result?: RelayTestResult }
|
||||
const relayTests = ref<Record<string, RelayState>>({})
|
||||
const testingRelays = ref(false)
|
||||
const committing = ref(false)
|
||||
|
||||
const anyRelayFailed = computed(() =>
|
||||
Object.values(relayTests.value).some((s) => s.status === 'done' && s.result != null && !s.result.ok),
|
||||
)
|
||||
|
||||
async function startWith(source: PairingSource) {
|
||||
await teardown()
|
||||
activeSource.value = source
|
||||
errorMessage.value = ''
|
||||
phase.value = 'scanning'
|
||||
try {
|
||||
stopCapture = await source.start({
|
||||
video: source.kind === 'qr' ? (videoEl.value ?? undefined) : undefined,
|
||||
onScan: handleScan,
|
||||
onError: (e) => console.warn('[Pairing] capture glitch:', e),
|
||||
})
|
||||
} catch (e) {
|
||||
phase.value = 'error'
|
||||
errorMessage.value =
|
||||
e instanceof Error ? e.message : 'Could not start the camera. Check permissions.'
|
||||
}
|
||||
}
|
||||
|
||||
let handling = false
|
||||
async function handleScan(raw: string) {
|
||||
if (handling) return
|
||||
handling = true
|
||||
// Validate only — don't commit yet. Show a review step with the decoded
|
||||
// relay + a "test relay" button so a well-formed but unreachable relay is
|
||||
// caught before we relaunch into a pairing crash-loop (aiolabs/bitspire#70).
|
||||
const preview = parseScannedSeed(raw)
|
||||
if (preview.ok) {
|
||||
await teardown() // camera off during review
|
||||
scannedRaw.value = raw.trim()
|
||||
previewSpire.value = preview.spirePubkey
|
||||
previewRelays.value = preview.relays
|
||||
relayTests.value = Object.fromEntries(preview.relays.map((r) => [r, { status: 'idle' }]))
|
||||
errorMessage.value = ''
|
||||
phase.value = 'review'
|
||||
return
|
||||
}
|
||||
// Reject non-seed / malformed scans (a stray QR, a corrupted relay) and resume.
|
||||
console.warn('[Pairing] rejected scan:', preview.reason, preview.message)
|
||||
errorMessage.value = 'That code is not a valid pairing code. Show the operator pairing QR.'
|
||||
handling = false
|
||||
if (activeSource.value) await startWith(activeSource.value)
|
||||
}
|
||||
|
||||
/** Probe every relay in the scanned seed and record reachability. */
|
||||
async function testRelays() {
|
||||
testingRelays.value = true
|
||||
await Promise.all(
|
||||
previewRelays.value.map(async (url) => {
|
||||
relayTests.value[url] = { status: 'testing' }
|
||||
const result = await testRelay(url)
|
||||
relayTests.value[url] = { status: 'done', result }
|
||||
}),
|
||||
)
|
||||
testingRelays.value = false
|
||||
}
|
||||
|
||||
/** Commit the reviewed seed: persist + relaunch into the real pairing path. */
|
||||
async function confirmPair() {
|
||||
committing.value = true
|
||||
const result = await ingestScannedSeed(scannedRaw.value)
|
||||
if (result.ok) {
|
||||
phase.value = 'pairing' // relaunch in flight
|
||||
return
|
||||
}
|
||||
committing.value = false
|
||||
errorMessage.value = result.message
|
||||
phase.value = 'error'
|
||||
}
|
||||
|
||||
/** Discard the scan and go back to scanning. */
|
||||
async function rescan() {
|
||||
scannedRaw.value = ''
|
||||
previewRelays.value = []
|
||||
relayTests.value = {}
|
||||
handling = false
|
||||
if (activeSource.value) await startWith(activeSource.value)
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (stopCapture) {
|
||||
try {
|
||||
stopCapture()
|
||||
} catch {
|
||||
/* idempotent */
|
||||
}
|
||||
stopCapture = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const available = await availablePairingSources()
|
||||
sources.value = available
|
||||
const first = available[0]
|
||||
if (!first) {
|
||||
phase.value = 'no-source'
|
||||
return
|
||||
}
|
||||
await startWith(first)
|
||||
})
|
||||
|
||||
onUnmounted(teardown)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-8">
|
||||
<h1 class="text-2xl lg:text-[3.5rem] font-bold">Pair This Machine</h1>
|
||||
|
||||
<!-- Camera viewfinder -->
|
||||
<div
|
||||
v-show="phase === 'scanning' && activeSource?.kind === 'qr'"
|
||||
class="relative overflow-hidden rounded-2xl border-4 border-primary/40 bg-black"
|
||||
style="width: min(80vw, 28rem); aspect-ratio: 1 / 1"
|
||||
>
|
||||
<!-- The Sintra's camera is mounted rotated, so rotate the preview 90° CCW
|
||||
for an upright image. Preview-only: qr-source decodes the raw (un-
|
||||
rotated) frame and QR decoding is rotation-invariant. The container is
|
||||
square + overflow-hidden, so the rotated square stays in the box. -->
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="h-full w-full -rotate-90 object-cover"
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
></video>
|
||||
<!-- Reticle -->
|
||||
<div class="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70"></div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="phase === 'scanning'"
|
||||
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
|
||||
>
|
||||
Hold the operator's pairing QR up to the camera.
|
||||
</p>
|
||||
|
||||
<p v-if="phase === 'probing'" class="text-base lg:text-2xl text-muted-foreground">
|
||||
Starting camera…
|
||||
</p>
|
||||
|
||||
<div v-if="phase === 'pairing'" class="flex flex-col items-center gap-4">
|
||||
<p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p>
|
||||
</div>
|
||||
|
||||
<!-- Review: confirm the scanned relay is reachable before committing -->
|
||||
<div v-if="phase === 'review'" class="flex w-full max-w-md flex-col items-center gap-5">
|
||||
<p class="text-base lg:text-2xl text-muted-foreground">
|
||||
Pairing code scanned. Test the relay, then pair.
|
||||
</p>
|
||||
<div class="w-full rounded-xl border border-border p-4 text-left">
|
||||
<p class="text-xs uppercase text-muted-foreground">Spire</p>
|
||||
<p class="mb-3 break-all font-mono text-sm">{{ previewSpire.slice(0, 16) }}…</p>
|
||||
<p class="text-xs uppercase text-muted-foreground">Relay(s)</p>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li
|
||||
v-for="url in previewRelays"
|
||||
:key="url"
|
||||
class="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span class="break-all font-mono text-xs">{{ url }}</span>
|
||||
<span class="shrink-0 text-sm">
|
||||
<template v-if="relayTests[url]?.status === 'testing'">
|
||||
<span class="text-muted-foreground">testing…</span>
|
||||
</template>
|
||||
<template v-else-if="relayTests[url]?.status === 'done'">
|
||||
<span v-if="relayTests[url]?.result?.ok" class="text-green-500"
|
||||
>✓ {{ relayTests[url]?.result?.ms }}ms</span
|
||||
>
|
||||
<span v-else class="text-destructive">✗ unreachable</span>
|
||||
</template>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
|
||||
:disabled="testingRelays || committing"
|
||||
@click="testRelays"
|
||||
>
|
||||
{{ testingRelays ? 'Testing…' : 'Test relay' }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
|
||||
:disabled="committing"
|
||||
@click="rescan"
|
||||
>
|
||||
Rescan
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
|
||||
:disabled="committing"
|
||||
@click="confirmPair"
|
||||
>
|
||||
{{ committing ? 'Pairing…' : 'Pair this machine' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="anyRelayFailed" class="max-w-md text-center text-sm text-warning">
|
||||
A relay looks unreachable from this machine — pairing will fail unless it can reach the
|
||||
relay. Check the URL/network, or rescan a corrected code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="phase === 'no-source'"
|
||||
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
|
||||
>
|
||||
No camera or NFC reader is available on this machine. Pair by provisioning
|
||||
<span class="font-mono">VITE_SPIRE_SEED</span> instead.
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="phase === 'error'"
|
||||
class="max-w-md text-center text-base lg:text-xl text-destructive"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<!-- Transient rejected-scan hint while still scanning -->
|
||||
<p
|
||||
v-if="phase === 'scanning' && errorMessage"
|
||||
class="max-w-md text-center text-sm lg:text-base text-warning"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<!-- Alternate sources (e.g. NFC) when more than one is available -->
|
||||
<div v-if="sources.length > 1" class="flex gap-3">
|
||||
<button
|
||||
v-for="source in sources"
|
||||
:key="source.kind"
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm"
|
||||
:class="activeSource?.kind === source.kind ? 'bg-primary text-primary-foreground' : ''"
|
||||
@click="startWith(source)"
|
||||
>
|
||||
{{ source.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
import { watch, type Ref } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import type { NostrClient, Signer } from '@bitSpire/nostr-client'
|
||||
import type { NostrClient, MachineIdentity } from '@bitSpire/nostr-client'
|
||||
import { createSignedEvent } from '@bitSpire/nostr-client'
|
||||
|
||||
type CashLevel = 'none' | 'low' | 'good' | 'full'
|
||||
|
|
@ -26,7 +26,7 @@ interface AvailabilitySnapshot {
|
|||
|
||||
interface UseAvailabilityBroadcastOptions {
|
||||
nostrClient: NostrClient
|
||||
signer: Signer
|
||||
identity: MachineIdentity
|
||||
/** Reactive inventory: denomination -> count */
|
||||
inventory: Ref<Record<number, number>>
|
||||
/** Reactive Lightning.Pub balance in sats (null = unknown) */
|
||||
|
|
@ -38,7 +38,7 @@ interface UseAvailabilityBroadcastOptions {
|
|||
}
|
||||
|
||||
export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) {
|
||||
const { nostrClient, signer, inventory, balanceSats, fiatCode, model } = options
|
||||
const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options
|
||||
|
||||
let lastSnapshot: AvailabilitySnapshot | null = null
|
||||
|
||||
|
|
@ -73,22 +73,19 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
|
|||
model,
|
||||
})
|
||||
|
||||
// Signing goes through the bunker, so it can throw BunkerTimeoutError /
|
||||
// BunkerRejectedError — keep it INSIDE the try so a transient signer blip
|
||||
// is swallowed (the beacon re-publishes every interval) rather than
|
||||
// surfacing as an uncaught rejection. `publish()` is fire-and-forget.
|
||||
try {
|
||||
const event = await createSignedEvent(signer, {
|
||||
const event = createSignedEvent(identity, {
|
||||
kind: 30078,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['d', 'atm-availability']],
|
||||
content,
|
||||
})
|
||||
|
||||
try {
|
||||
await nostrClient.publish(event)
|
||||
lastSnapshot = snap
|
||||
console.log('[Availability] Published:', content)
|
||||
} catch (e) {
|
||||
console.warn('[Availability] Publish failed (sign or relay):', e)
|
||||
console.warn('[Availability] Failed to publish:', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
import { watch } from 'vue'
|
||||
import { useEventListener, useIntervalFn } from '@vueuse/core'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
|
||||
/**
|
||||
* Session security timeouts for the ADR-003 access gate.
|
||||
*
|
||||
* Enforced at the DOM layer on purpose: the state machine can't observe raw
|
||||
* pointer events, so an XState `after` delay can only count from state entry —
|
||||
* it never resets on a screen touch and therefore can't measure *inactivity*.
|
||||
* Two independent, fail-closed limits, both of which re-lock via the machine's
|
||||
* root-level END_SESSION transition:
|
||||
*
|
||||
* - SOFT idle (SOFT_IDLE_MS): re-lock after this long with no trusted user
|
||||
* input while on the idle menu. Resets on every genuine pointer/touch/key
|
||||
* event. Scoped to `idle` so it never interrupts an in-flight cash-in/out
|
||||
* (those carry their own, longer machine timeouts).
|
||||
* - HARD cap (HARD_CAP_MS): re-lock this long after the session began,
|
||||
* regardless of activity. Anchored to unlock time and never reset — an
|
||||
* absolute ceiling a forgotten or relayed card can't hold open. Like the
|
||||
* soft limit it only fires while on the idle menu: locking mid-transaction
|
||||
* would strand stacked bills or an in-flight dispense, and every
|
||||
* transaction already returns to `locked` on its own, so the cap simply
|
||||
* takes effect the moment the machine is back at idle.
|
||||
*
|
||||
* Security properties:
|
||||
* - Only `event.isTrusted` input resets the soft timer, so synthetic/scripted
|
||||
* events in the renderer can't keep a session alive.
|
||||
* - Limits are wall-clock deadline comparisons, not chained setTimeouts: a
|
||||
* suspended/resumed renderer re-locks on the very next tick instead of
|
||||
* silently extending the session past its deadline.
|
||||
* - Both limits only ever *lock*. The machine's accessGateActive guard makes
|
||||
* END_SESSION a no-op when the gate is off, so this is inert on a
|
||||
* gate-disabled machine.
|
||||
* - One-shot per session: after firing, it disarms until the next unlock, so
|
||||
* a lock that (under dev bypass) doesn't take can't spin.
|
||||
*
|
||||
* Call once from the always-mounted App shell.
|
||||
*/
|
||||
const SOFT_IDLE_MS = 60_000 // 60s of no interaction on the idle menu
|
||||
const HARD_CAP_MS = 600_000 // 10min absolute session ceiling
|
||||
|
||||
export function useSessionSecurity() {
|
||||
const atm = useAtmStore()
|
||||
|
||||
// Wall-clock anchors. `null` sessionStartedAt == disarmed (no live session).
|
||||
let sessionStartedAt: number | null = null
|
||||
let lastActivityAt = 0
|
||||
|
||||
function arm() {
|
||||
const now = Date.now()
|
||||
sessionStartedAt = now
|
||||
lastActivityAt = now
|
||||
}
|
||||
function disarm() {
|
||||
sessionStartedAt = null
|
||||
}
|
||||
|
||||
// Arm on each locked → unlocked edge; disarm on lock. Anchored to the
|
||||
// isLocked transition so the hard cap starts at unlock and does NOT restart
|
||||
// when moving idle → cashIn → idle within a single session.
|
||||
watch(
|
||||
() => atm.isLocked,
|
||||
(locked, wasLocked) => {
|
||||
if (wasLocked && !locked && atm.accessControl.enabled) arm()
|
||||
else if (locked) disarm()
|
||||
}
|
||||
)
|
||||
|
||||
// Only genuine hardware input counts as activity. Passive + capture so it
|
||||
// observes every touch without interfering with handling. useEventListener
|
||||
// auto-detaches on unmount.
|
||||
const onActivity = (e: Event) => {
|
||||
if (e.isTrusted) lastActivityAt = Date.now()
|
||||
}
|
||||
for (const type of ['pointerdown', 'touchstart', 'keydown', 'wheel'] as const) {
|
||||
useEventListener(document, type, onActivity, { passive: true, capture: true })
|
||||
}
|
||||
|
||||
// Single 1s evaluator — cheap, and coarse enough that timer drift/suspend
|
||||
// can only ever make it fire late-then-immediately, never early.
|
||||
useIntervalFn(() => {
|
||||
if (sessionStartedAt === null || atm.isLocked || !atm.accessControl.enabled) return
|
||||
// Never lock out from under a transaction (the machine only accepts
|
||||
// END_SESSION from idle anyway); the deadlines keep counting meanwhile, so
|
||||
// an expired session re-locks on the first tick back at the menu.
|
||||
if (atm.currentState !== 'idle') return
|
||||
const now = Date.now()
|
||||
|
||||
// Hard cap first — absolute, activity-independent.
|
||||
if (now - sessionStartedAt >= HARD_CAP_MS) {
|
||||
disarm() // one-shot; re-arms on next unlock
|
||||
atm.endSession('session-cap')
|
||||
return
|
||||
}
|
||||
|
||||
// Soft inactivity — resets on trusted input.
|
||||
if (now - lastActivityAt >= SOFT_IDLE_MS) {
|
||||
disarm()
|
||||
atm.endSession('inactivity')
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
|
@ -139,21 +139,11 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
|
|||
model: 'batm3',
|
||||
validator: {
|
||||
type: 'ebds',
|
||||
// MEI bill acceptor (EBDS) on a USB-serial bridge, exposed via the
|
||||
// stable udev symlink /dev/ttyMEI (batm3.nix, serial A9YW78OC). The old
|
||||
// /dev/ttyACM0 default assumed a CDC-ACM BNR; this hardware enumerates as
|
||||
// ttyUSB* instead, so ACM0 never existed and cash-in was silently
|
||||
// disabled ("[HAL] No validator"). Per-box override: VITE_LAMASSU_VALIDATOR_DEVICE.
|
||||
device: '/dev/ttyMEI',
|
||||
device: '/dev/ttyACM0',
|
||||
},
|
||||
dispenser: {
|
||||
type: 'f56',
|
||||
// Fujitsu F56 on a USB-serial bridge, via the stable udev symlink
|
||||
// /dev/ttyF56 (batm3.nix, serial DDDLb103Y23). Avoids the raw
|
||||
// /dev/ttyUSB0, which is enumeration-order dependent and could point at
|
||||
// the wrong adapter after a re-plug/reboot. Per-box override:
|
||||
// VITE_LAMASSU_DISPENSER_DEVICE.
|
||||
device: '/dev/ttyF56',
|
||||
device: '/dev/ttyUSB0',
|
||||
cassettes: [
|
||||
{ denomination: 20, count: 400 },
|
||||
{ denomination: 1, count: 400 },
|
||||
|
|
|
|||
|
|
@ -24,13 +24,6 @@ const router = createRouter({
|
|||
],
|
||||
})
|
||||
|
||||
// Kiosk chrome (hidden cursor) is the default — every real machine is a
|
||||
// touchscreen. The public web demo (VITE_DEMO_TAG) runs in a normal browser,
|
||||
// where an invisible pointer just reads as broken.
|
||||
if (!import.meta.env.VITE_DEMO_TAG) {
|
||||
document.documentElement.classList.add('kiosk')
|
||||
}
|
||||
|
||||
// Create Pinia store
|
||||
const pinia = createPinia()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { BunkerRejectedError, BunkerTimeoutError } from '@bitSpire/nostr-client'
|
||||
import { classifyInitError } from '../init-error.js'
|
||||
|
||||
describe('classifyInitError', () => {
|
||||
it('maps a bunker rejection (revoke / TTL / off-policy) to "unpaired"', () => {
|
||||
expect(classifyInitError(new BunkerRejectedError('revoked'))).toBe('unpaired')
|
||||
})
|
||||
|
||||
it('maps a bunker timeout to "signer-unreachable"', () => {
|
||||
expect(classifyInitError(new BunkerTimeoutError('no response'))).toBe('signer-unreachable')
|
||||
})
|
||||
|
||||
it('classifies by error name across bundle boundaries (no instanceof)', () => {
|
||||
// A structurally-equivalent error from a different module copy still maps.
|
||||
const lookalike = Object.assign(new Error('x'), { name: 'BunkerRejectedError' })
|
||||
expect(classifyInitError(lookalike)).toBe('unpaired')
|
||||
})
|
||||
|
||||
it('surfaces a generic error message unchanged', () => {
|
||||
expect(classifyInitError(new Error('relay down'))).toBe('relay down')
|
||||
})
|
||||
|
||||
it('uses the fallback for non-Error throws', () => {
|
||||
expect(classifyInitError('boom', 'Lightning initialization failed')).toBe(
|
||||
'Lightning initialization failed'
|
||||
)
|
||||
expect(classifyInitError(undefined)).toBe('Initialization failed')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { initialContext, type ATMContext } from '@bitSpire/state-machine'
|
||||
import type { LnbitsClient, LnbitsPayment } from '@bitSpire/lnbits'
|
||||
import { createATMServices } from '../lightning'
|
||||
|
||||
/**
|
||||
* The cash-out settlement watch (2026-09-22 regression).
|
||||
*
|
||||
* A one-tap Bolt Card Complete settles in about a second; subscribing over
|
||||
* nostr takes several. When the watch was armed at display time the push —
|
||||
* an ephemeral event with no replay — fired before anything listened, and the
|
||||
* machine sat on a paid invoice until it timed out, taking the sats without
|
||||
* dispensing. These pin the three defences: arm before the invoice is handed
|
||||
* out, latch a settlement that still beats the consumer, and poll so a push
|
||||
* that never arrives cannot strand a payment.
|
||||
*/
|
||||
|
||||
const BOLT11 = 'lnbc265u1p4t9gthpp5td44vd9a0s5er'
|
||||
const HASH = 'aa'.repeat(32)
|
||||
|
||||
const paid = (preimage = 'PREIMAGE'): LnbitsPayment =>
|
||||
({ payment_hash: HASH, status: 'success', preimage }) as LnbitsPayment
|
||||
|
||||
function makeLnbits(over: Partial<Record<string, unknown>> = {}) {
|
||||
let pushTo: ((p: LnbitsPayment) => void) | null = null
|
||||
const api = {
|
||||
createInvoice: vi.fn(async () => ({ payment_request: BOLT11, payment_hash: HASH })),
|
||||
subscribePayments: vi.fn(
|
||||
async (_w: unknown, _f: unknown, onPush: (p: LnbitsPayment) => void) => {
|
||||
pushTo = onPush
|
||||
return 'sub-1'
|
||||
}
|
||||
),
|
||||
getPayment: vi.fn(async (): Promise<LnbitsPayment | null> => null),
|
||||
unsubscribe: vi.fn(async () => true),
|
||||
decodePayment: vi.fn(async () => ({ payment_hash: HASH })),
|
||||
...over,
|
||||
}
|
||||
return { api, push: (p: LnbitsPayment) => pushTo?.(p) }
|
||||
}
|
||||
|
||||
const ctx = (): ATMContext => ({ ...initialContext, satsAmount: 26_500, exchangeRate: 1325 })
|
||||
|
||||
const services = (l: { api: Record<string, unknown> }) =>
|
||||
createATMServices(vi.fn(), l.api as unknown as LnbitsClient, 'wallet-1')
|
||||
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('cash-out settlement watch', () => {
|
||||
it('is armed before the invoice is handed out, without decoding it back', async () => {
|
||||
const l = makeLnbits()
|
||||
const invoice = await services(l).generateInvoice(ctx())
|
||||
|
||||
expect(invoice).toBe(BOLT11)
|
||||
// Armed during generateInvoice, not later at display time.
|
||||
expect(l.api.subscribePayments).toHaveBeenCalledTimes(1)
|
||||
expect(l.api.subscribePayments.mock.calls[0]![1]).toMatchObject({ payment_hash: HASH })
|
||||
// The hash came from the creation response, so no round trip to recover it.
|
||||
expect(l.api.decodePayment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replays a settlement that beat the consumer (the race that lost payments)', async () => {
|
||||
const l = makeLnbits()
|
||||
const svc = services(l)
|
||||
const invoice = await svc.generateInvoice(ctx())
|
||||
|
||||
// Card pays before the machine reaches displayingInvoice.
|
||||
l.push(paid())
|
||||
|
||||
const onPaid = vi.fn()
|
||||
svc.watchInvoice(invoice, onPaid)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(onPaid).toHaveBeenCalledWith('PREIMAGE')
|
||||
})
|
||||
|
||||
it('delivers a push that arrives while the consumer is attached', async () => {
|
||||
const l = makeLnbits()
|
||||
const svc = services(l)
|
||||
const invoice = await svc.generateInvoice(ctx())
|
||||
|
||||
const onPaid = vi.fn()
|
||||
svc.watchInvoice(invoice, onPaid)
|
||||
l.push(paid('LATER'))
|
||||
|
||||
expect(onPaid).toHaveBeenCalledWith('LATER')
|
||||
})
|
||||
|
||||
it('settles from the poll when no push ever arrives', async () => {
|
||||
const l = makeLnbits()
|
||||
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
|
||||
const svc = services(l)
|
||||
const invoice = await svc.generateInvoice(ctx())
|
||||
|
||||
const onPaid = vi.fn()
|
||||
svc.watchInvoice(invoice, onPaid)
|
||||
expect(onPaid).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(7_000)
|
||||
expect(onPaid).toHaveBeenCalledWith('VIA-POLL')
|
||||
})
|
||||
|
||||
it('polls even when arming the subscription fails', async () => {
|
||||
const l = makeLnbits()
|
||||
l.api.subscribePayments = vi.fn(async () => {
|
||||
throw new Error('relay down')
|
||||
})
|
||||
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
|
||||
const svc = services(l)
|
||||
const invoice = await svc.generateInvoice(ctx())
|
||||
|
||||
const onPaid = vi.fn()
|
||||
svc.watchInvoice(invoice, onPaid)
|
||||
await vi.advanceTimersByTimeAsync(7_000)
|
||||
|
||||
expect(onPaid).toHaveBeenCalledWith('VIA-POLL')
|
||||
})
|
||||
|
||||
it('reports each settlement once, whichever path saw it first', async () => {
|
||||
const l = makeLnbits()
|
||||
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
|
||||
const svc = services(l)
|
||||
const invoice = await svc.generateInvoice(ctx())
|
||||
|
||||
const onPaid = vi.fn()
|
||||
svc.watchInvoice(invoice, onPaid)
|
||||
l.push(paid('VIA-PUSH'))
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
|
||||
expect(onPaid).toHaveBeenCalledTimes(1)
|
||||
expect(onPaid).toHaveBeenCalledWith('VIA-PUSH')
|
||||
})
|
||||
|
||||
it('stops polling and unsubscribes when the transaction ends', async () => {
|
||||
const l = makeLnbits()
|
||||
const svc = services(l)
|
||||
const invoice = await svc.generateInvoice(ctx())
|
||||
const stop = svc.watchInvoice(invoice, vi.fn())
|
||||
|
||||
stop()
|
||||
expect(l.api.unsubscribe).toHaveBeenCalledWith(undefined, 'sub-1')
|
||||
|
||||
const pollsAfterStop = (l.api.getPayment as ReturnType<typeof vi.fn>).mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect((l.api.getPayment as ReturnType<typeof vi.fn>).mock.calls.length).toBe(pollsAfterStop)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { npubEncode, nprofileEncode } from 'nostr-tools/nip19'
|
||||
import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize'
|
||||
import { parseBoltcardLnurlw } from '../boltcard'
|
||||
|
||||
const SALT = 'test-salt'
|
||||
const HEX_A = 'aa'.repeat(32)
|
||||
const HEX_B = 'bb'.repeat(32)
|
||||
const NPUB_A = npubEncode(HEX_A)
|
||||
const NPUB_B = npubEncode(HEX_B)
|
||||
|
||||
describe('access authorize (ADR-003)', () => {
|
||||
describe('open enrollment (prototype)', () => {
|
||||
it('grants any valid npub as user', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out.status).toBe('granted')
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||
})
|
||||
|
||||
it('rejects a stray / non-npub QR', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: 'https://example.com/not-an-npub' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out.status).toBe('denied')
|
||||
expect(out).toMatchObject({ reason: 'not a valid npub' })
|
||||
})
|
||||
|
||||
it('accepts a `nostr:` URI prefix (with surrounding whitespace)', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: ` nostr:${NPUB_A}\n` }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out.status).toBe('granted')
|
||||
})
|
||||
|
||||
it('accepts an nprofile and resolves to the same identity as its npub', async () => {
|
||||
const nprofile = nprofileEncode({ pubkey: HEX_A, relays: ['wss://relay.example'] })
|
||||
const viaNprofile = await authorize({ kind: 'npub', npub: nprofile }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
const viaNpub = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(viaNprofile.status).toBe('granted')
|
||||
// Same underlying pubkey → same credential hash.
|
||||
expect(viaNprofile.credentialIdHash).toBe(viaNpub.credentialIdHash)
|
||||
})
|
||||
})
|
||||
|
||||
describe('allow-list (closed)', () => {
|
||||
it('denies an unlisted npub when not open-enrollment', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], { salt: SALT })
|
||||
expect(out).toMatchObject({ status: 'denied', reason: 'not authorized' })
|
||||
})
|
||||
|
||||
it('grants a listed npub with its role, no PIN', async () => {
|
||||
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'operator' }
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [entry], { salt: SALT })
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
|
||||
})
|
||||
|
||||
it('does not match npub B against npub A entry', async () => {
|
||||
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'user' }
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_B }, [entry], { salt: SALT })
|
||||
expect(out.status).toBe('denied')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PIN second factor', () => {
|
||||
const makeEntry = async (): Promise<AllowListEntry> => ({
|
||||
idHash: await hashId(HEX_A, SALT),
|
||||
role: 'user',
|
||||
pinHash: await hashPin('1234', SALT),
|
||||
})
|
||||
|
||||
it('asks for a PIN when one is configured and none supplied', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
||||
salt: SALT,
|
||||
})
|
||||
expect(out.status).toBe('pin-required')
|
||||
})
|
||||
|
||||
it('grants on correct PIN', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
||||
salt: SALT,
|
||||
pin: '1234',
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||
})
|
||||
|
||||
it('denies on wrong PIN', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
||||
salt: SALT,
|
||||
pin: '9999',
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'denied', reason: 'incorrect PIN' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('challenge credential (v2 seam)', () => {
|
||||
it('is not yet authorized', async () => {
|
||||
const out = await authorize({ kind: 'challenge', pubkey: HEX_A, nonce: 'n', sig: 's' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out.status).toBe('denied')
|
||||
})
|
||||
})
|
||||
|
||||
describe('boltcard credential (tap-to-enter)', () => {
|
||||
it('open-enrollment grants any card as user', async () => {
|
||||
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||
})
|
||||
|
||||
it('rejects a card with no external_id', async () => {
|
||||
const out = await authorize({ kind: 'boltcard', externalId: '' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'denied', reason: 'not a valid card' })
|
||||
})
|
||||
|
||||
it('allow-list matches by external_id hash', async () => {
|
||||
const idHash = await hashId('abc123', SALT)
|
||||
const list: AllowListEntry[] = [{ idHash, role: 'operator' }]
|
||||
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, list, {
|
||||
salt: SALT,
|
||||
openEnrollment: false,
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBoltcardLnurlw', () => {
|
||||
it('extracts external_id from a tapped lnurlw', () => {
|
||||
expect(
|
||||
parseBoltcardLnurlw('lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEAD&c=BEEF')
|
||||
).toEqual({ externalId: 'abc123' })
|
||||
})
|
||||
it('strips a lightning: prefix and accepts https', () => {
|
||||
expect(parseBoltcardLnurlw('lightning:lnurlw://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({
|
||||
externalId: 'xyz',
|
||||
})
|
||||
expect(parseBoltcardLnurlw('https://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({
|
||||
externalId: 'xyz',
|
||||
})
|
||||
})
|
||||
it('returns null for non-card / malformed input', () => {
|
||||
expect(parseBoltcardLnurlw('https://h/something/else')).toBeNull()
|
||||
expect(parseBoltcardLnurlw('not a url')).toBeNull()
|
||||
expect(parseBoltcardLnurlw('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
/**
|
||||
* Credential authorization (ADR-003).
|
||||
*
|
||||
* Decides whether a presented credential may unlock the terminal. Matching is
|
||||
* against a local allow-list of salted identity hashes, optionally behind a
|
||||
* PIN second factor; `openEnrollment` admits any well-formed credential when
|
||||
* the allow-list has no match (the current posture — see the ADR amendment:
|
||||
* with it on, the gate is a convenience, not a security boundary). Only
|
||||
* salted hashes are compared, stored or logged — never the raw id (KYC-free).
|
||||
*
|
||||
* Identity id per scan kind:
|
||||
* - boltcard → the card's boltcards `external_id` (parsed locally from the
|
||||
* lnurlw; the SUN p/c are NOT verified here — that happens at
|
||||
* payment time, where the voucher is actually spent)
|
||||
* - npub → hex pubkey (decoded, canonical)
|
||||
* - challenge → v2 seam, not yet authorized
|
||||
*/
|
||||
|
||||
import { decode as nip19Decode } from 'nostr-tools/nip19'
|
||||
import type { AccessRole } from '@bitSpire/state-machine'
|
||||
import type { AccessScan } from './types'
|
||||
|
||||
/** One authorized identity. `idHash` = hashId(<canonical id>, salt). */
|
||||
export interface AllowListEntry {
|
||||
idHash: string
|
||||
role: AccessRole
|
||||
/** When set, access requires this PIN (hashPin(pin, salt)) as a 2nd factor. */
|
||||
pinHash?: string
|
||||
/** Optional operator-facing label (never a person's real identity). */
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface AuthorizeOptions {
|
||||
/** Per-machine salt for all hashing. */
|
||||
salt: string
|
||||
/** Admit any valid credential when the allow-list has no match (prototype). */
|
||||
openEnrollment?: boolean
|
||||
/** PIN supplied on the follow-up call after a `pin-required` outcome. */
|
||||
pin?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Three outcomes, so the caller can drive a two-step flow:
|
||||
* - `granted` → send ACCESS_GRANTED
|
||||
* - `pin-required` → prompt for a PIN, then call authorize() again with `pin`
|
||||
* - `denied` → send ACCESS_DENIED(reason)
|
||||
*/
|
||||
export type AuthorizeOutcome =
|
||||
| { status: 'granted'; role: AccessRole; credentialIdHash: string }
|
||||
| { status: 'pin-required'; credentialIdHash: string }
|
||||
| { status: 'denied'; credentialIdHash: string; reason: string }
|
||||
|
||||
/** Salted SHA-256, hex-encoded. */
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(input)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export const hashId = (id: string, salt: string): Promise<string> => sha256Hex(`id:${salt}:${id}`)
|
||||
export const hashPin = (pin: string, salt: string): Promise<string> =>
|
||||
sha256Hex(`pin:${salt}:${pin}`)
|
||||
|
||||
/**
|
||||
* Resolve a scan to a canonical identity string, or `null` if malformed.
|
||||
* npub is decoded to its hex pubkey so npub/hex forms compare equal and a
|
||||
* stray (non-npub) string is rejected.
|
||||
*/
|
||||
function canonicalId(scan: AccessScan): string | null {
|
||||
if (scan.kind === 'boltcard') return scan.externalId || null
|
||||
if (scan.kind === 'challenge') return null // v2 — handled separately
|
||||
// Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI
|
||||
// prefix, and `nprofile1…` (npub + relay hints, what many clients export).
|
||||
const raw = scan.npub.trim().replace(/^nostr:/i, '')
|
||||
try {
|
||||
const decoded = nip19Decode(raw)
|
||||
if (decoded.type === 'npub' && typeof decoded.data === 'string') {
|
||||
return decoded.data
|
||||
}
|
||||
if (
|
||||
decoded.type === 'nprofile' &&
|
||||
decoded.data &&
|
||||
typeof (decoded.data as { pubkey?: unknown }).pubkey === 'string'
|
||||
) {
|
||||
return (decoded.data as { pubkey: string }).pubkey
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a scanned credential is authorized.
|
||||
* Always resolves (never throws) so the caller can uniformly react.
|
||||
*/
|
||||
export async function authorize(
|
||||
scan: AccessScan,
|
||||
allowList: AllowListEntry[],
|
||||
opts: AuthorizeOptions
|
||||
): Promise<AuthorizeOutcome> {
|
||||
if (scan.kind === 'challenge') {
|
||||
return {
|
||||
status: 'denied',
|
||||
credentialIdHash: '',
|
||||
reason: 'challenge-response credentials not yet supported',
|
||||
}
|
||||
}
|
||||
|
||||
const id = canonicalId(scan)
|
||||
if (!id) {
|
||||
return {
|
||||
status: 'denied',
|
||||
credentialIdHash: '',
|
||||
reason:
|
||||
scan.kind === 'npub'
|
||||
? 'not a valid npub'
|
||||
: scan.kind === 'boltcard'
|
||||
? 'not a valid card'
|
||||
: 'invalid credential',
|
||||
}
|
||||
}
|
||||
|
||||
const credentialIdHash = await hashId(id, opts.salt)
|
||||
const entry = allowList.find((e) => e.idHash === credentialIdHash)
|
||||
|
||||
if (!entry) {
|
||||
if (opts.openEnrollment) {
|
||||
return { status: 'granted', role: 'user', credentialIdHash }
|
||||
}
|
||||
return { status: 'denied', credentialIdHash, reason: 'not authorized' }
|
||||
}
|
||||
|
||||
// No PIN configured → single-factor grant.
|
||||
if (!entry.pinHash) {
|
||||
return { status: 'granted', role: entry.role, credentialIdHash }
|
||||
}
|
||||
|
||||
// PIN configured but not yet supplied → ask for it.
|
||||
if (opts.pin === undefined) {
|
||||
return { status: 'pin-required', credentialIdHash }
|
||||
}
|
||||
|
||||
// PIN supplied → verify.
|
||||
const pinHash = await hashPin(opts.pin, opts.salt)
|
||||
if (pinHash !== entry.pinHash) {
|
||||
return { status: 'denied', credentialIdHash, reason: 'incorrect PIN' }
|
||||
}
|
||||
return { status: 'granted', role: entry.role, credentialIdHash }
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
/**
|
||||
* Bolt Card lnurlw parsing for the access gate (ADR-003).
|
||||
*
|
||||
* The tap-to-enter flow reads a Bolt Card's `lnurlw://…/scan/<external_id>?p=&c=`
|
||||
* voucher and needs the `external_id` for the session identity — WITHOUT hitting
|
||||
* the server (that would burn the single-use SUN p/c we want to reuse at
|
||||
* Complete). So this is a purely local parse: extract the id from the URL path;
|
||||
* the p/c ride along in the stored lnurlw and are only spent at payment time.
|
||||
*/
|
||||
|
||||
/** Extract a Bolt Card's `external_id` from its tapped lnurlw. Null if not one. */
|
||||
export function parseBoltcardLnurlw(lnurlw: string): { externalId: string } | null {
|
||||
let s = lnurlw.trim()
|
||||
if (!s) return null
|
||||
if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length)
|
||||
const https = s.replace(/^lnurlw:\/\//i, 'https://').replace(/^lnurl:\/\//i, 'https://')
|
||||
if (!/^https:\/\//i.test(https)) return null
|
||||
try {
|
||||
const u = new URL(https)
|
||||
// …/boltcards/api/v1/scan/<external_id>
|
||||
const m = u.pathname.match(/\/scan\/([^/?#]+)/)
|
||||
if (!m || !m[1]) return null
|
||||
return { externalId: decodeURIComponent(m[1]) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
/**
|
||||
* Access-control module surface (ADR-003).
|
||||
*
|
||||
* Credential capture is NOT here: the reader is the main-process NFC service
|
||||
* (`electron/nfc-service.ts`, over the `nfc:card-tapped` IPC), and the store
|
||||
* turns a tapped lnurlw into a `boltcard` scan. This module only decides —
|
||||
* parse the card, hash the identity, match the allow-list.
|
||||
*/
|
||||
|
||||
export type { AccessScan, AccessRole } from './types'
|
||||
export { authorize, hashId, hashPin } from './authorize'
|
||||
export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize'
|
||||
export { parseBoltcardLnurlw } from './boltcard'
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/**
|
||||
* Access-control credential types (ADR-003).
|
||||
*
|
||||
* A credential is captured elsewhere — for Bolt Cards by the main-process NFC
|
||||
* reader (`electron/nfc-service.ts`), which hands the tapped lnurlw to the
|
||||
* store over IPC — and arrives here as a RAW `AccessScan`. Hashing and
|
||||
* authorization (and the optional PIN second factor) happen in `authorize.ts`,
|
||||
* so raw ids never leave this layer (KYC-free).
|
||||
*/
|
||||
|
||||
import type { AccessRole } from '@bitSpire/state-machine'
|
||||
|
||||
export type { AccessRole }
|
||||
|
||||
/**
|
||||
* A raw credential. Discriminated union so new factors are additive:
|
||||
* - `boltcard` — what ships: a tapped Bolt Card, already verified by the
|
||||
* card server's `/session` (the tap's SUN was spent there).
|
||||
* `externalId` is the identity as the server returned it;
|
||||
* it is the only thing hashed/authorized. The session's
|
||||
* payment steps stay in the store, never in this layer.
|
||||
* - `npub` — a Nostr pubkey (bare npub, `nostr:` URI or nprofile).
|
||||
* No reader emits it today; kept, with the PIN second
|
||||
* factor, for a future non-card credential.
|
||||
* - `challenge` — card-signed nonce, challenge-response. v2 seam; not yet
|
||||
* authorized.
|
||||
*/
|
||||
export type AccessScan =
|
||||
| { kind: 'boltcard'; externalId: string }
|
||||
| { kind: 'npub'; npub: string }
|
||||
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string }
|
||||
|
|
@ -109,12 +109,6 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
})
|
||||
console.log('[HAL] Validator started')
|
||||
|
||||
// Escrow / in-flight bookkeeping: credit (onBillInserted) fires only on
|
||||
// the validator's `billsValid` stacked-confirmation, never at
|
||||
// stack-command time (mirrors electron/hal-service.ts).
|
||||
let escrowDenomination: number | null = null
|
||||
let inFlightDenomination: number | null = null
|
||||
|
||||
// Track inventory (decremented on dispense)
|
||||
const inventory: Record<number, number> = {}
|
||||
for (const cassette of dispConfig.cassettes) {
|
||||
|
|
@ -212,13 +206,10 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
if (decision === 'hold') {
|
||||
// Hold in escrow — caller will call stackBill() or rejectBill()
|
||||
console.log('[HAL] Bill in escrow:', data.denomination)
|
||||
escrowDenomination = data.denomination
|
||||
callbacks.onBillRead?.(data.denomination)
|
||||
} else if (decision) {
|
||||
// Credit waits for the validator's stacked-confirmation
|
||||
// (`billsValid`) — see the handler below.
|
||||
inFlightDenomination = data.denomination
|
||||
validator.stack()
|
||||
callbacks.onBillInserted(data.denomination)
|
||||
} else {
|
||||
console.log('[HAL] Bill rejected: insufficient ATM balance for', data.denomination)
|
||||
validator.reject()
|
||||
|
|
@ -230,21 +221,7 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
}
|
||||
})
|
||||
|
||||
// Stacked-confirmation → the credit event.
|
||||
validator.on('billsValid', () => {
|
||||
if (inFlightDenomination === null) {
|
||||
console.warn('[HAL] billsValid with no bill in flight — ignoring')
|
||||
return
|
||||
}
|
||||
const denomination = inFlightDenomination
|
||||
inFlightDenomination = null
|
||||
console.log('[HAL] Bill stacked (confirmed):', denomination)
|
||||
callbacks.onBillInserted(denomination)
|
||||
})
|
||||
|
||||
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
|
||||
escrowDenomination = null
|
||||
inFlightDenomination = null
|
||||
callbacks.onBillRejected(data?.reason ?? 'unknown')
|
||||
})
|
||||
|
||||
|
|
@ -271,19 +248,8 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
validator.lightOff()
|
||||
},
|
||||
|
||||
stackBill: () => {
|
||||
if (escrowDenomination === null) {
|
||||
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
|
||||
return
|
||||
}
|
||||
inFlightDenomination = escrowDenomination
|
||||
escrowDenomination = null
|
||||
validator.stack()
|
||||
},
|
||||
rejectBill: () => {
|
||||
escrowDenomination = null
|
||||
validator.reject()
|
||||
},
|
||||
stackBill: () => validator.stack(),
|
||||
rejectBill: () => validator.reject(),
|
||||
|
||||
cleanup: async () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
/**
|
||||
* Classify an initialization failure into a maintenance-screen sentinel
|
||||
* (see App.vue's MAINTENANCE_SCREENS).
|
||||
*
|
||||
* Bunker failures (aiolabs/bitspire#52) get dedicated screens:
|
||||
* - `NoPairingError` (fresh machine, never paired) → `unpaired` — render the
|
||||
* interactive QR-pairing wizard so the operator can scan a spire-seed.
|
||||
* - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) →
|
||||
* `unpaired` too — re-pairing is the same scan-a-fresh-seed flow.
|
||||
* - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`,
|
||||
* a transient condition.
|
||||
* Everything else surfaces its raw message (or the caller's fallback).
|
||||
*/
|
||||
export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string {
|
||||
const name = (error as { name?: string } | null)?.name
|
||||
if (name === 'NoPairingError') return 'unpaired'
|
||||
if (name === 'BunkerRejectedError') return 'unpaired'
|
||||
if (name === 'BunkerTimeoutError') return 'signer-unreachable'
|
||||
return error instanceof Error ? error.message : fallback
|
||||
}
|
||||
|
|
@ -12,8 +12,12 @@
|
|||
* the customer's invoice.
|
||||
*/
|
||||
|
||||
import { NostrClient, type Signer } from '@bitSpire/nostr-client'
|
||||
import { resolveSigner } from './signer-resolver.js'
|
||||
import {
|
||||
NostrClient,
|
||||
generateIdentity,
|
||||
loadIdentityFromHex,
|
||||
type MachineIdentity,
|
||||
} from '@bitSpire/nostr-client'
|
||||
import { LnbitsClient } from '@bitSpire/lnbits'
|
||||
import { CLINKClient } from '@bitSpire/clink'
|
||||
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
|
||||
|
|
@ -33,12 +37,14 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
|||
*
|
||||
* Environment variables:
|
||||
* - VITE_RELAY_URL: Nostr relay WebSocket URL
|
||||
* - VITE_LNBITS_SERVER_PUBKEY: LNbits nostr-transport server pubkey (hex)
|
||||
* - VITE_SPIRE_SEED: spire pairing seed (NIP-46 bunker); see signer-resolver.ts
|
||||
* - VITE_OPERATOR_PUBKEYS: comma-separated operator pubkeys (hex)
|
||||
* - VITE_LIGHTNING_PUB_PUBKEY: Lightning.Pub's Nostr pubkey (hex or npub)
|
||||
* - VITE_LIGHTNING_PUB_API_URL: Lightning.Pub HTTP API URL
|
||||
* - VITE_ATM_PRIVATE_KEY: ATM's Nostr private key (hex or nsec) // pragma: allowlist secret
|
||||
* - VITE_ADMIN_TOKEN: Lightning.Pub admin token (dev only)
|
||||
*/
|
||||
interface LightningConfig {
|
||||
relayUrl: string
|
||||
atmPrivateKey: string
|
||||
appId: string
|
||||
operatorPubkeys: string[]
|
||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||
|
|
@ -54,10 +60,8 @@ interface LightningConfig {
|
|||
*/
|
||||
async function loadLightningConfig(): Promise<LightningConfig> {
|
||||
const defaults: LightningConfig = {
|
||||
// Empty when unset (not the dev relay) so initializeLightningServices can
|
||||
// tell "operator gave us a relay" from "fall back to the pairing seed". See
|
||||
// aiolabs/bitspire#70 and DEV_DEFAULT_RELAY.
|
||||
relayUrl: '',
|
||||
relayUrl: 'ws://localhost:7777',
|
||||
atmPrivateKey: '',
|
||||
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
|
||||
operatorPubkeys: [],
|
||||
lnbitsServerPubkey: '',
|
||||
|
|
@ -66,8 +70,10 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
if (isElectron && window.electronAPI) {
|
||||
try {
|
||||
const rc = await window.electronAPI.getConfig()
|
||||
const sec = await window.electronAPI.getAtmSecrets()
|
||||
return {
|
||||
relayUrl: rc.relayUrl || defaults.relayUrl,
|
||||
atmPrivateKey: sec.atmPrivateKey || defaults.atmPrivateKey,
|
||||
appId: rc.appId || defaults.appId,
|
||||
operatorPubkeys: rc.operatorPubkeys
|
||||
? rc.operatorPubkeys
|
||||
|
|
@ -84,6 +90,7 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
|
||||
return {
|
||||
relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl,
|
||||
atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
|
||||
appId: import.meta.env.VITE_APP_ID || defaults.appId,
|
||||
lnbitsServerPubkey:
|
||||
(import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) ||
|
||||
|
|
@ -100,10 +107,6 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
// Config is loaded async now - will be set in initializeLightningServices
|
||||
let CONFIG: LightningConfig
|
||||
|
||||
/** Dev-only relay used when neither env nor the pairing supplies one. Matches
|
||||
* the dev stack — LNbits's bundled nostrrelay (no separate strfry container). */
|
||||
const DEV_DEFAULT_RELAY = 'ws://localhost:5001/nostrrelay/test'
|
||||
|
||||
/** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime.
|
||||
* Sessions are normally cleaned up by the state machine on idle transition.
|
||||
* This is a safety net in case the state machine doesn't clean up properly. */
|
||||
|
|
@ -116,27 +119,33 @@ const SESSION_SAFETY_TIMEOUT_MS = 15 * 60 * 1000
|
|||
/** Active LNURL-withdraw session */
|
||||
interface LnurlSession {
|
||||
sessionId: string
|
||||
/** Link ID — the management + settlement-watch key (delete/subscribe). */
|
||||
/** Link ID for management operations (delete/update) */
|
||||
linkId: string
|
||||
uniqueHash: string
|
||||
satsAmount: number
|
||||
status: 'active' | 'claimed' | 'expired'
|
||||
createdAt: number
|
||||
cleanup?: () => void
|
||||
}
|
||||
|
||||
/** Map of linkId -> LNURL session data. Keyed on link_id since the secure
|
||||
* `create_withdraw` response (spirekeeper#31) carries no `unique_hash`. */
|
||||
/** Map of uniqueHash -> LNURL session data */
|
||||
const lnurlSessions = new Map<string, LnurlSession>()
|
||||
|
||||
/**
|
||||
* Register a new LNURL-withdraw session, keyed by linkId.
|
||||
* Register a new LNURL-withdraw session
|
||||
*/
|
||||
function registerLnurlSession(sessionId: string, linkId: string, satsAmount: number): void {
|
||||
console.log('[LNURL Session] Registering:', linkId, 'for', satsAmount, 'sats')
|
||||
function registerLnurlSession(
|
||||
sessionId: string,
|
||||
linkId: string,
|
||||
uniqueHash: string,
|
||||
satsAmount: number,
|
||||
): void {
|
||||
console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats')
|
||||
|
||||
lnurlSessions.set(linkId, {
|
||||
lnurlSessions.set(uniqueHash, {
|
||||
sessionId,
|
||||
linkId,
|
||||
uniqueHash,
|
||||
satsAmount,
|
||||
status: 'active',
|
||||
createdAt: Date.now(),
|
||||
|
|
@ -144,10 +153,10 @@ function registerLnurlSession(sessionId: string, linkId: string, satsAmount: num
|
|||
|
||||
// Safety timeout — normally cleaned up by state machine on idle transition.
|
||||
setTimeout(() => {
|
||||
const session = lnurlSessions.get(linkId)
|
||||
const session = lnurlSessions.get(uniqueHash)
|
||||
if (session && session.status === 'active') {
|
||||
console.warn('[LNURL Session] Safety timeout reached, expiring:', linkId)
|
||||
expireLnurlSession(linkId)
|
||||
console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash)
|
||||
expireLnurlSession(uniqueHash)
|
||||
}
|
||||
}, SESSION_SAFETY_TIMEOUT_MS)
|
||||
}
|
||||
|
|
@ -155,23 +164,23 @@ function registerLnurlSession(sessionId: string, linkId: string, satsAmount: num
|
|||
/** Invalidate an active LNURL session by cash-in sessionId. The session's
|
||||
* cleanup closure unsubscribes from LNbits and deletes the link. */
|
||||
function invalidateLnurlSessionBySessionId(sessionId: string): void {
|
||||
for (const [linkId, session] of lnurlSessions.entries()) {
|
||||
for (const [hash, session] of lnurlSessions.entries()) {
|
||||
if (session.sessionId === sessionId && session.status === 'active') {
|
||||
console.log('[LNURL Session] Invalidating previous session:', linkId)
|
||||
expireLnurlSession(linkId)
|
||||
console.log('[LNURL Session] Invalidating previous session:', hash)
|
||||
expireLnurlSession(hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Expire a single LNURL session via its cleanup closure. */
|
||||
function expireLnurlSession(linkId: string): void {
|
||||
const session = lnurlSessions.get(linkId)
|
||||
function expireLnurlSession(uniqueHash: string): void {
|
||||
const session = lnurlSessions.get(uniqueHash)
|
||||
if (!session || session.status !== 'active') return
|
||||
|
||||
console.log('[LNURL Session] Expiring:', linkId)
|
||||
console.log('[LNURL Session] Expiring:', uniqueHash)
|
||||
session.status = 'expired'
|
||||
if (session.cleanup) session.cleanup()
|
||||
setTimeout(() => lnurlSessions.delete(linkId), 60000)
|
||||
setTimeout(() => lnurlSessions.delete(uniqueHash), 60000)
|
||||
}
|
||||
|
||||
let _lnbitsRef: LnbitsClient | null = null
|
||||
|
|
@ -217,7 +226,7 @@ export interface LightningBackend {
|
|||
}): Promise<{ paymentRequest: string; paymentHash?: string }>
|
||||
payInvoice(
|
||||
bolt11: string,
|
||||
amountSats: number
|
||||
amountSats: number,
|
||||
): Promise<{ success: boolean; preimage?: string; error?: string }>
|
||||
}
|
||||
|
||||
|
|
@ -225,7 +234,7 @@ interface LightningServices {
|
|||
nostrClient: NostrClient
|
||||
lightningPub: LightningBackend
|
||||
clink: CLINKClient
|
||||
signer: Signer
|
||||
identity: MachineIdentity
|
||||
/** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */
|
||||
operatorPubkeys: string[]
|
||||
atmServices: ATMServices
|
||||
|
|
@ -402,85 +411,50 @@ export async function initializeLightningServices(options?: {
|
|||
// Load configuration (async for Electron runtime config)
|
||||
CONFIG = await loadLightningConfig()
|
||||
|
||||
// Resolve the signing identity BEFORE validating the LNbits transport
|
||||
// config. An unpaired machine must reach the QR-pairing wizard regardless
|
||||
// of relay/server-pubkey provisioning — pairing is what provides those — so
|
||||
// resolveSigner (which throws NoPairingError → 'unpaired' → wizard for a
|
||||
// machine with no seed and no binding) has to run ahead of the config
|
||||
// checks below. The relay/pubkey validation then only gates a *paired*
|
||||
// machine that's actually trying to talk to LNbits. See aiolabs/bitspire#70.
|
||||
//
|
||||
// In production this is a BunkerSigner over NIP-46 (the ATM holds only a
|
||||
// transport key; the operator's nsecbunkerd holds the signing key); in dev
|
||||
// it falls back to an in-process LocalSigner. The Phase-A Signer seam means
|
||||
// nothing downstream changes. See aiolabs/bitspire#52.
|
||||
const { signer, transport } = await resolveSigner({ allowEphemeral: !options?.strict })
|
||||
console.log('[Lightning] ATM pubkey:', signer.pubkey)
|
||||
console.log('[Lightning] Relay URL:', CONFIG.relayUrl)
|
||||
console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)')
|
||||
|
||||
// Resolve the effective LNbits transport. Precedence: explicit env wins (dev
|
||||
// + operator override), else the pairing (seed/binding) supplies it (#70) so
|
||||
// a blank-.env paired machine reaches the backend from the seed alone, else a
|
||||
// dev-only localhost fallback. CONFIG is mutated to the resolved values so
|
||||
// downstream (and the exported CONFIG) see a single source of truth.
|
||||
const envRelay = CONFIG.relayUrl
|
||||
const envPubkey = CONFIG.lnbitsServerPubkey
|
||||
const relays: string[] = envRelay
|
||||
? [envRelay]
|
||||
: transport && transport.relays.length > 0
|
||||
? transport.relays
|
||||
: [DEV_DEFAULT_RELAY]
|
||||
CONFIG.relayUrl = relays[0]!
|
||||
CONFIG.lnbitsServerPubkey = envPubkey || transport?.lnbitsServerPubkey || ''
|
||||
console.log(
|
||||
'[Lightning] Relay(s):',
|
||||
relays.join(', '),
|
||||
envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)'
|
||||
)
|
||||
console.log(
|
||||
'[Lightning] LNbits server pubkey:',
|
||||
CONFIG.lnbitsServerPubkey || '(not configured)',
|
||||
envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : ''
|
||||
)
|
||||
// Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS
|
||||
// (env). An empty set disables the fees/operator-config services → the machine
|
||||
// sits at "awaiting configuration" — so log it loudly rather than fail silent.
|
||||
// (aiolabs/bitspire#70 P1 will source this from LNbits over the transport.)
|
||||
console.log(
|
||||
'[Lightning] Operator pubkey(s):',
|
||||
CONFIG.operatorPubkeys.length
|
||||
? CONFIG.operatorPubkeys.join(', ') + ' (env)'
|
||||
: '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)'
|
||||
)
|
||||
|
||||
// Strict mode: validate the RESOLVED config is production-ready (no
|
||||
// localhost). Values may come from env or the pairing seed (#70).
|
||||
// Strict mode: validate config is production-ready (no localhost, no ephemeral identity)
|
||||
if (options?.strict) {
|
||||
const errors: string[] = []
|
||||
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
|
||||
errors.push('relay resolves to localhost (VITE_RELAY_URL / seed relays)')
|
||||
errors.push('VITE_RELAY_URL contains localhost')
|
||||
}
|
||||
if (!CONFIG.atmPrivateKey) {
|
||||
errors.push('VITE_ATM_PRIVATE_KEY is not set (ephemeral identity not allowed in production)')
|
||||
}
|
||||
if (!CONFIG.lnbitsServerPubkey) {
|
||||
errors.push('no LNbits server pubkey (VITE_LNBITS_SERVER_PUBKEY / seed lnbits_npub)')
|
||||
errors.push('VITE_LNBITS_SERVER_PUBKEY is not set')
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required configuration. Reached only for a paired machine (an
|
||||
// unpaired one threw NoPairingError above) — it needs the LNbits server
|
||||
// pubkey to talk to the transport, from either env or the pairing seed.
|
||||
// Validate required configuration
|
||||
if (!CONFIG.lnbitsServerPubkey) {
|
||||
throw new Error(
|
||||
'[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' +
|
||||
'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).'
|
||||
'[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' +
|
||||
'Get it from: docker logs lnbits | grep nostr_transport pubkey',
|
||||
)
|
||||
}
|
||||
|
||||
// Load or generate ATM identity
|
||||
let identity: MachineIdentity
|
||||
if (CONFIG.atmPrivateKey) {
|
||||
identity = loadIdentityFromHex(CONFIG.atmPrivateKey)
|
||||
console.log('[Lightning] Loaded ATM identity from config')
|
||||
} else {
|
||||
identity = generateIdentity()
|
||||
console.warn('[Lightning] No VITE_ATM_PRIVATE_KEY configured - generated ephemeral identity')
|
||||
console.warn('[Lightning] Set VITE_ATM_PRIVATE_KEY for persistent identity across restarts')
|
||||
}
|
||||
console.log('[Lightning] ATM pubkey:', identity.publicKey)
|
||||
|
||||
// Create Nostr client
|
||||
const nostrClient = new NostrClient({
|
||||
relays: relays.map((url) => ({ url })),
|
||||
signer,
|
||||
relays: [{ url: CONFIG.relayUrl }],
|
||||
identity,
|
||||
})
|
||||
|
||||
await nostrClient.connect()
|
||||
|
|
@ -489,9 +463,9 @@ export async function initializeLightningServices(options?: {
|
|||
// LNbits nostr-transport client.
|
||||
const lnbits = new LnbitsClient({
|
||||
serverPubkey: CONFIG.lnbitsServerPubkey,
|
||||
relays,
|
||||
relays: [CONFIG.relayUrl],
|
||||
})
|
||||
lnbits.initialize(nostrClient, signer)
|
||||
lnbits.initialize(nostrClient, identity)
|
||||
_lnbitsRef = lnbits
|
||||
console.log('[Lightning] LNbits client initialized')
|
||||
|
||||
|
|
@ -505,83 +479,14 @@ export async function initializeLightningServices(options?: {
|
|||
}
|
||||
console.log('[Lightning] LNbits wallet:', lnbitsWalletId)
|
||||
|
||||
// ── Public web demo: stamp the throwaway account so it can be swept ──────
|
||||
// The browser demo (atm.demo.aiolabs.dev) runs with an EPHEMERAL identity —
|
||||
// a fresh keypair per page load — so LNbits mints a new account + a fresh
|
||||
// auto-credited wallet for every visitor. That isolation is the point (a
|
||||
// single baked-in key would be credited exactly once and then drain), but it
|
||||
// leaves throwaway accounts behind, and nothing in an auto-created row says
|
||||
// "demo": pubkey-set/prvkey-NULL also describes a real ATM.
|
||||
//
|
||||
// A nostr pubkey can't carry a marker (you'd have to grind a vanity prefix,
|
||||
// far too slow to do on page load), and the account/wallet the server
|
||||
// auto-creates isn't nameable by the client. So we mint one extra,
|
||||
// never-used wallet whose NAME is the tag: sweeping is then an exact string
|
||||
// match on wallet name rather than a heuristic about what looks disposable.
|
||||
//
|
||||
// Unset on every real machine, so this is inert outside the demo build. The
|
||||
// call is fire-and-forget: losing the marker degrades cleanup, not the demo.
|
||||
const demoTag = (import.meta.env.VITE_DEMO_TAG as string | undefined)?.trim()
|
||||
if (demoTag) {
|
||||
void lnbits
|
||||
.createWallet(demoTag)
|
||||
// Never log the reply — create_wallet returns adminkey/inkey.
|
||||
.then(() => console.log('[Lightning] Demo marker wallet created:', demoTag))
|
||||
.catch((e) => console.warn('[Lightning] Demo marker wallet failed:', e))
|
||||
}
|
||||
|
||||
// #70 P1: pull operator pubkey + fee config from LNbits over the authenticated
|
||||
// transport (spirekeeper#41 `get_machine_config`). A seed-only machine has no
|
||||
// VITE_OPERATOR_PUBKEYS, so without this it can't trust its fee config and sits
|
||||
// at "awaiting configuration". Only for the seed-only case — an explicit
|
||||
// VITE_OPERATOR_PUBKEYS override keeps the env/kind-30078 path untouched.
|
||||
// Soft-fail: an older spirekeeper (no RPC) or a transport error falls back to
|
||||
// whatever the operator services can pull from kind-30078.
|
||||
if (CONFIG.operatorPubkeys.length === 0) {
|
||||
try {
|
||||
const mc = await lnbits.getMachineConfig()
|
||||
if (mc.operator_pubkey) {
|
||||
CONFIG.operatorPubkeys = [mc.operator_pubkey]
|
||||
console.log(
|
||||
'[Lightning] Operator pubkey(s):',
|
||||
mc.operator_pubkey,
|
||||
'(server-delivered, #70 P1)'
|
||||
)
|
||||
}
|
||||
if (mc.fee_config && isElectron && window.electronAPI) {
|
||||
// Persist the server-delivered fee config so atm.ts's awaiting-fees gate
|
||||
// (getFeeConfig) clears immediately — robust to the replaceable kind-30078
|
||||
// event not being fetchable from the relay. The live kind-30078
|
||||
// subscription still handles mid-run fee updates.
|
||||
const applied = await window.electronAPI.applyFeeConfig(
|
||||
{
|
||||
cashInFeeFraction: mc.fee_config.cash_in_fee_fraction,
|
||||
cashOutFeeFraction: mc.fee_config.cash_out_fee_fraction,
|
||||
schemaVersion: mc.fee_config.schema_version,
|
||||
},
|
||||
mc.created_at
|
||||
)
|
||||
console.log(
|
||||
'[Lightning] Server-delivered fee config:',
|
||||
applied.applied ? 'applied' : `skipped (${applied.reason})`
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[Lightning] get_machine_config unavailable; falling back to env/kind-30078 for operator config:',
|
||||
(e as Error).message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// CLINK client — kept in tree but not actively wired into LNbits flows.
|
||||
// operatorPubkey is the operator allowlist for kind-21003 management
|
||||
// commands; it has no Lightning.Pub dependency.
|
||||
const clink = new CLINKClient({
|
||||
nostrClient,
|
||||
signer,
|
||||
identity,
|
||||
operatorPubkey: CONFIG.operatorPubkeys,
|
||||
relays,
|
||||
relays: [CONFIG.relayUrl],
|
||||
})
|
||||
|
||||
// Callbacks for events
|
||||
|
|
@ -669,20 +574,21 @@ export async function initializeLightningServices(options?: {
|
|||
}
|
||||
|
||||
const atmServices = createATMServices(
|
||||
identity,
|
||||
(preimage) => {
|
||||
if (paymentReceivedCallback) {
|
||||
paymentReceivedCallback(preimage)
|
||||
}
|
||||
},
|
||||
lnbits,
|
||||
lnbitsWalletId
|
||||
lnbitsWalletId,
|
||||
)
|
||||
|
||||
return {
|
||||
nostrClient,
|
||||
lightningPub,
|
||||
clink,
|
||||
signer,
|
||||
identity,
|
||||
operatorPubkeys: CONFIG.operatorPubkeys,
|
||||
atmServices,
|
||||
onOfferRequest: (callback: OfferRequestCallback) => {
|
||||
|
|
@ -710,142 +616,14 @@ export async function initializeLightningServices(options?: {
|
|||
/**
|
||||
* Create ATMServices implementation using the LNbits nostr-transport.
|
||||
*/
|
||||
export function createATMServices(
|
||||
function createATMServices(
|
||||
_identity: MachineIdentity,
|
||||
onPaymentSuccess: (preimage: string) => void,
|
||||
lnbits: LnbitsClient,
|
||||
lnbitsWalletId: string
|
||||
lnbitsWalletId: string,
|
||||
): ATMServices {
|
||||
const onPaymentCallback = onPaymentSuccess
|
||||
|
||||
// ── Cash-out settlement watch ───────────────────────────────────────────
|
||||
/**
|
||||
* A watch on one cash-out invoice, armed the moment the invoice exists and
|
||||
* consumed later by the state machine's `displayingInvoice` actor.
|
||||
*
|
||||
* Arming at creation rather than at display closes a race that swallowed
|
||||
* real payments on 2026-09-22 (sintra). Subscribing costs a nostr round
|
||||
* trip — about four seconds against a remote relay — while a one-tap Bolt
|
||||
* Card Complete settles in roughly one. The settlement push is an ephemeral
|
||||
* event with no replay, so it fired before anything was listening and the
|
||||
* machine sat on a paid invoice until it timed out, taking the sats without
|
||||
* dispensing. The old two-tap flow only worked because fumbling with the
|
||||
* card covered the gap.
|
||||
*
|
||||
* Three defences, in order: the invoice is not returned until its watch is
|
||||
* armed; a settlement that still beats the UI is latched and replayed when
|
||||
* the consumer attaches; and a poll runs alongside the subscription so a
|
||||
* lost or unsent push cannot strand a payment either way.
|
||||
*/
|
||||
interface InvoiceWatch {
|
||||
paymentHash: string
|
||||
subId: string | null
|
||||
/** Preimage seen before a consumer attached; replayed on attach. */
|
||||
settled: string | null
|
||||
consumer: ((preimage: string) => void) | null
|
||||
poll: ReturnType<typeof setInterval> | null
|
||||
released: boolean
|
||||
}
|
||||
const invoiceWatches = new Map<string, InvoiceWatch>()
|
||||
|
||||
// Backstop cadence. One cheap RPC; on the money path a little extra relay
|
||||
// traffic is worth far more than a payment that lands with no cash.
|
||||
const SETTLEMENT_POLL_MS = 6_000
|
||||
|
||||
function stopInvoiceWatchPoll(watch: InvoiceWatch): void {
|
||||
if (watch.poll) {
|
||||
clearInterval(watch.poll)
|
||||
watch.poll = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver a settlement exactly once, to the consumer or into the latch. */
|
||||
function settleInvoiceWatch(watch: InvoiceWatch, preimage: string, via: string): void {
|
||||
if (watch.released || watch.settled) return
|
||||
watch.settled = preimage
|
||||
stopInvoiceWatchPoll(watch)
|
||||
console.log(`[ATM Service] Invoice paid (${via})!`)
|
||||
watch.consumer?.(preimage)
|
||||
}
|
||||
|
||||
function startInvoiceWatchPoll(watch: InvoiceWatch): void {
|
||||
let inFlight = false
|
||||
watch.poll = setInterval(() => {
|
||||
if (inFlight || watch.settled || watch.released) return
|
||||
inFlight = true
|
||||
void lnbits
|
||||
.getPayment(watch.paymentHash)
|
||||
.then((payment) => {
|
||||
if (payment?.status === 'success') {
|
||||
settleInvoiceWatch(watch, payment.preimage ?? 'payment-confirmed', 'poll')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* transport blip — the next tick retries */
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false
|
||||
})
|
||||
}, SETTLEMENT_POLL_MS)
|
||||
}
|
||||
|
||||
/** Arm the watch for a freshly created invoice. Resolves once it is live. */
|
||||
async function armInvoiceWatch(bolt11: string, paymentHash: string): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
const watch: InvoiceWatch = {
|
||||
paymentHash,
|
||||
subId: null,
|
||||
settled: null,
|
||||
consumer: null,
|
||||
poll: null,
|
||||
released: false,
|
||||
}
|
||||
invoiceWatches.set(bolt11, watch)
|
||||
try {
|
||||
// walletId omitted: payment_hash is the natural primary key for "wait
|
||||
// for THIS invoice to settle." Under path B
|
||||
// (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment to
|
||||
// the operator's wallet, so a subscription scoped to the ATM's
|
||||
// pre-override wallet_id would AND-filter the settlement out and never
|
||||
// fire. With wallet_id omitted, lnbits resolves the wallet from
|
||||
// get_standalone_payment(payment_hash) and ownership-checks against the
|
||||
// auth'd account — works on both pre/post-override wallets.
|
||||
// Coordination log 2026-05-31T18:50Z (lnbits) for the confirmation,
|
||||
// §18:35Z for the joint smoke that surfaced the bug.
|
||||
watch.subId = await lnbits.subscribePayments(
|
||||
undefined,
|
||||
{ payment_hash: paymentHash, max_seconds: 600 },
|
||||
(push) => {
|
||||
if (push.payment_hash !== paymentHash || push.status !== 'success') return
|
||||
settleInvoiceWatch(watch, push.preimage ?? 'payment-confirmed', 'LNbits push')
|
||||
},
|
||||
(reason) => console.log(`[ATM Service] Settlement subscription closed (${reason})`)
|
||||
)
|
||||
console.log(
|
||||
`[ATM Service] Settlement watch armed in ${Date.now() - startedAt} ms ` +
|
||||
`(hash ${paymentHash.slice(0, 12)}…)`
|
||||
)
|
||||
} catch (e) {
|
||||
// The poll below then carries settlement on its own, which is exactly
|
||||
// why it runs whether or not the subscription came up.
|
||||
console.error('[ATM Service] Settlement subscribe failed — polling only:', e)
|
||||
}
|
||||
startInvoiceWatchPoll(watch)
|
||||
}
|
||||
|
||||
/** Tear a watch down: the transaction ended, one way or another. */
|
||||
function releaseInvoiceWatch(bolt11: string): void {
|
||||
const watch = invoiceWatches.get(bolt11)
|
||||
if (!watch) return
|
||||
watch.released = true
|
||||
watch.consumer = null
|
||||
stopInvoiceWatchPoll(watch)
|
||||
invoiceWatches.delete(bolt11)
|
||||
if (watch.subId) {
|
||||
// wallet_id omitted to match the subscribePayments call above.
|
||||
void lnbits.unsubscribe(undefined, watch.subId).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* 3b.4: ndebit cash-in path removed. CashInView.vue ignores this
|
||||
|
|
@ -883,70 +661,57 @@ export function createATMServices(
|
|||
* over nostr, we trigger dispense.
|
||||
*/
|
||||
generateLnurlWithdraw: async (context: ATMContext): Promise<string> => {
|
||||
// GROSS principal (fiat × rate, BEFORE commission). The server derives
|
||||
// fee + NET from this, so we must NOT send the already-fee'd
|
||||
// context.satsAmount — doing so double-applies the commission (client
|
||||
// subtracts it in calculateSats, then the server subtracts it again,
|
||||
// e.g. 12% → 22.6% effective; the customer is short-changed while the
|
||||
// quote/receipt still read 12%). Mirror calculateSats's principal.
|
||||
const grossPrincipalSats = Math.floor((context.fiatCents / 100) * context.exchangeRate)
|
||||
console.log(
|
||||
`[ATM Service] Generating LNURL-withdraw: gross principal=${grossPrincipalSats} sats ` +
|
||||
`(net after ${(context.feeFraction * 100).toFixed(2)}% ≈ ${context.satsAmount})`
|
||||
)
|
||||
console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats')
|
||||
|
||||
try {
|
||||
if (context.cashInSessionId) {
|
||||
invalidateLnurlSessionBySessionId(context.cashInSessionId)
|
||||
}
|
||||
|
||||
// Secure cash-in: the ATM sends only the hardware-attested gross
|
||||
// principal; the operator side verifies the signer, derives fee + NET,
|
||||
// and stamps attribution (spirekeeper#31/#32). The ATM no longer sets
|
||||
// the amount or extra. We display the returned LNURL (for NET) and
|
||||
// watch link_id for settlement.
|
||||
const link = await lnbits.createWithdraw(lnbitsWalletId, {
|
||||
principal_sats: grossPrincipalSats,
|
||||
fiat_amount: context.fiatCents / 100,
|
||||
fiat_code: context.currency,
|
||||
const link = await lnbits.createWithdrawLink(lnbitsWalletId, {
|
||||
title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
|
||||
client_ref: context.txid ?? context.cashInSessionId ?? undefined,
|
||||
min_withdrawable: context.satsAmount,
|
||||
max_withdrawable: context.satsAmount,
|
||||
uses: 1,
|
||||
wait_time: 1,
|
||||
is_unique: false,
|
||||
})
|
||||
|
||||
if (!link.lnurl) {
|
||||
throw new Error(
|
||||
'[ATM Service] create_withdraw returned no lnurl — check withdraw#3 / LNBITS_BASEURL on the server'
|
||||
'[ATM Service] LNbits returned link.lnurl=null — check LNBITS_BASEURL on the server (aiolabs/withdraw#1)'
|
||||
)
|
||||
}
|
||||
const lnurl = link.lnurl.toUpperCase()
|
||||
console.log(
|
||||
`[ATM Service] create_withdraw: principal=${link.principal_sats} fee=${link.fee_sats} net=${link.net_sats} link=${link.link_id}`
|
||||
)
|
||||
|
||||
if (context.cashInSessionId) {
|
||||
// Track the NET (what the customer withdraws); keyed by link_id.
|
||||
registerLnurlSession(context.cashInSessionId, link.link_id, link.net_sats)
|
||||
registerLnurlSession(
|
||||
context.cashInSessionId,
|
||||
link.id,
|
||||
link.unique_hash,
|
||||
context.satsAmount,
|
||||
)
|
||||
const subId = await lnbits.subscribePayments(
|
||||
lnbitsWalletId,
|
||||
{ tag: 'withdraw', link_id: link.link_id, max_seconds: 600 },
|
||||
{ tag: 'withdraw', link_id: link.id, max_seconds: 600 },
|
||||
(push) => {
|
||||
console.log('[ATM Service] LNURL-withdraw claimed (LNbits push)!')
|
||||
const session = lnurlSessions.get(link.link_id)
|
||||
const session = lnurlSessions.get(link.unique_hash)
|
||||
if (session) {
|
||||
session.status = 'claimed'
|
||||
lnurlSessions.delete(link.link_id)
|
||||
lnurlSessions.delete(link.unique_hash)
|
||||
}
|
||||
if (onPaymentCallback) {
|
||||
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`)
|
||||
}
|
||||
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.unique_hash}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
// Wire per-session cleanup so abort/expiry tears it down cleanly.
|
||||
const session = lnurlSessions.get(link.link_id)
|
||||
const session = lnurlSessions.get(link.unique_hash)
|
||||
if (session) {
|
||||
session.cleanup = () => {
|
||||
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {})
|
||||
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.link_id).catch(() => {})
|
||||
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.id).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -982,14 +747,15 @@ export function createATMServices(
|
|||
* matches machine fiat_code
|
||||
* - `type: "cash_out"` / `source: "bitspire"` — discriminators
|
||||
*/
|
||||
// (see armInvoiceWatch below — the watch is armed before this resolves)
|
||||
generateInvoice: async (context: ATMContext): Promise<string> => {
|
||||
const amountSats = context.satsAmount
|
||||
// Cash-out: satsAmount = principal + commission. principal is
|
||||
// derived from the raw market rate (no commission baked in) so a
|
||||
// consumer can independently audit the split.
|
||||
const principalSats =
|
||||
context.exchangeRate > 0 ? Math.floor((context.fiatCents / 100) * context.exchangeRate) : 0
|
||||
context.exchangeRate > 0
|
||||
? Math.floor((context.fiatCents / 100) * context.exchangeRate)
|
||||
: 0
|
||||
const feeSats = Math.max(0, amountSats - principalSats)
|
||||
console.log(
|
||||
'[ATM Service] Generating invoice — gross',
|
||||
|
|
@ -1029,17 +795,6 @@ export function createATMServices(
|
|||
if (!payment.payment_request) {
|
||||
throw new Error('LNbits createInvoice returned empty payment_request')
|
||||
}
|
||||
// Arm the settlement watch BEFORE the invoice reaches the screen, and
|
||||
// take the payment hash from the response we already have rather than
|
||||
// spending a round trip decoding it back off the bolt11. See
|
||||
// armInvoiceWatch for why the timing matters.
|
||||
if (payment.payment_hash) {
|
||||
await armInvoiceWatch(payment.payment_request, payment.payment_hash)
|
||||
} else {
|
||||
console.error(
|
||||
'[ATM Service] createInvoice returned no payment_hash — settlement watch will arm late'
|
||||
)
|
||||
}
|
||||
return payment.payment_request
|
||||
},
|
||||
|
||||
|
|
@ -1206,30 +961,15 @@ export function createATMServices(
|
|||
* push, filtered by payment_hash. Returns a cleanup function.
|
||||
*/
|
||||
watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => {
|
||||
console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...')
|
||||
|
||||
if (!invoice.toLowerCase().startsWith('ln')) {
|
||||
console.error('[ATM Service] Invalid invoice format - expected BOLT11')
|
||||
return () => {}
|
||||
}
|
||||
console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...')
|
||||
|
||||
const armed = invoiceWatches.get(invoice)
|
||||
if (armed) {
|
||||
armed.consumer = callback
|
||||
// Settled between arming and display (a one-tap card pull can beat the
|
||||
// state transition): replay the latched settlement instead of waiting
|
||||
// on a push that has already come and gone.
|
||||
if (armed.settled) {
|
||||
const preimage = armed.settled
|
||||
queueMicrotask(() => callback(preimage))
|
||||
}
|
||||
return () => releaseInvoiceWatch(invoice)
|
||||
}
|
||||
|
||||
// No armed watch: an invoice this service didn't create. Recover the
|
||||
// hash over the wire and arm now. This is the pre-2026-09-22 behaviour
|
||||
// and carries the race that arming-at-creation fixes, so say so.
|
||||
console.warn('[ATM Service] No armed settlement watch for this invoice — arming late')
|
||||
let cancelled = false
|
||||
let subId: string | null = null
|
||||
;(async () => {
|
||||
try {
|
||||
const decoded = await lnbits.decodePayment(invoice)
|
||||
|
|
@ -1239,18 +979,36 @@ export function createATMServices(
|
|||
return
|
||||
}
|
||||
if (cancelled) return
|
||||
await armInvoiceWatch(invoice, paymentHash)
|
||||
const late = invoiceWatches.get(invoice)
|
||||
if (!late || cancelled) return
|
||||
late.consumer = callback
|
||||
if (late.settled) callback(late.settled)
|
||||
// walletId omitted: payment_hash is the natural primary key for
|
||||
// "wait for THIS invoice to settle." Under path B
|
||||
// (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment
|
||||
// to the operator's wallet, so a subscription scoped to the ATM's
|
||||
// pre-override wallet_id would AND-filter the settlement out and
|
||||
// never fire. With wallet_id omitted, lnbits resolves the wallet
|
||||
// from get_standalone_payment(payment_hash) and ownership-checks
|
||||
// against the auth'd account — works on both pre/post-override
|
||||
// wallets. Coordination log 2026-05-31T18:50Z (lnbits) for the
|
||||
// confirmation, §18:35Z for the joint smoke that surfaced the bug.
|
||||
subId = await lnbits.subscribePayments(
|
||||
undefined,
|
||||
{ payment_hash: paymentHash, max_seconds: 600 },
|
||||
(push) => {
|
||||
if (push.payment_hash !== paymentHash) return
|
||||
if (push.status !== 'success') return
|
||||
console.log('[ATM Service] Invoice paid (LNbits push)!')
|
||||
callback(push.preimage ?? 'payment-confirmed')
|
||||
},
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('[ATM Service] LNbits watchInvoice failed:', e)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
releaseInvoiceWatch(invoice)
|
||||
if (subId) {
|
||||
// wallet_id omitted to match the subscribePayments call above.
|
||||
void lnbits.unsubscribe(undefined, subId).catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,34 @@
|
|||
/**
|
||||
* Operator-config consumer (aiolabs/lamassu-next#56, v2 per bitspire ADR-004).
|
||||
* Operator-config consumer (aiolabs/lamassu-next#56).
|
||||
*
|
||||
* Subscribes to operator-published kind-30078 events carrying cassette
|
||||
* OPERATIONS — a refill, an empty, a recount, a denomination change —
|
||||
* applies the ones it has not already seen, and hot-reloads the HAL
|
||||
* dispenser. It also publishes this machine's cassette state, which is
|
||||
* what populates the operator dashboard's bay rows.
|
||||
*
|
||||
* The operator used to publish absolute counts and this machine applied them
|
||||
* outright. Both sides wrote the same value over a transport that never tells
|
||||
* a writer it lost: a dashboard form loaded before a dispense silently
|
||||
* discarded that dispense, and neither side could detect it. This machine now
|
||||
* owns the count — it holds the notes — and the operator says what it did.
|
||||
* config updates, validates + applies them to state.db, and hot-reloads
|
||||
* the HAL dispenser. Also publishes a one-shot ATM-state hello-event on
|
||||
* first boot so the operator dashboard (satmachineadmin) can auto-populate
|
||||
* `cassette_configs` rows for this machine.
|
||||
*
|
||||
* Architecture (see ~/dev/coordination/log.md entries on 2026-05-30):
|
||||
*
|
||||
* - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:<machine_id>"]`,
|
||||
* `["p", <atm_npub>]`, NIP-44 v2 encrypted content, author = operator pubkey
|
||||
* - ATM state: `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`,
|
||||
* - ATM bootstrap: `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`,
|
||||
* `["p", <operator_pubkey>]`, NIP-44 v2 encrypted content, author = ATM pubkey
|
||||
*
|
||||
* The ATM's hex pubkey serves as `<machine_id>` — globally unique, no
|
||||
* extra provisioning step required.
|
||||
*
|
||||
* The ATM publishes its state on startup, after every change to the bays, and
|
||||
* on a heartbeat. It was once a single hello-event gated on a one-shot flag,
|
||||
* which left the operator validating against a layout the machine no longer
|
||||
* had (#94), and left a dispense published during a relay outage lost for good.
|
||||
* v1 only publishes the one-shot bootstrap hello-event. The continuous
|
||||
* ATM-state reverse channel (publish on every count change + heartbeat)
|
||||
* is v2 territory.
|
||||
*/
|
||||
|
||||
import {
|
||||
type Signer,
|
||||
type MachineIdentity,
|
||||
type NostrClient,
|
||||
type Event,
|
||||
createSignedEvent,
|
||||
decryptContentV2,
|
||||
encryptContentV2,
|
||||
validateEvent,
|
||||
} from '@bitSpire/nostr-client'
|
||||
|
||||
|
|
@ -41,36 +36,9 @@ import type {} from '@/types/electron'
|
|||
|
||||
const KIND_NIP78 = 30078
|
||||
|
||||
/** The wire schema this machine speaks. Operations, not counts (ADR-004). */
|
||||
const CASSETTE_SCHEMA_VERSION = 2
|
||||
|
||||
/** One operator-authored operation, as it arrives on the wire. */
|
||||
type CassetteOp = {
|
||||
id: string
|
||||
at: number
|
||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||
position: number
|
||||
bills?: number
|
||||
count?: number
|
||||
denomination?: number
|
||||
}
|
||||
|
||||
/** Accept operator events stamped up to this many seconds in the future. */
|
||||
const MAX_FUTURE_SKEW_S = 60
|
||||
|
||||
/**
|
||||
* Republish the cassette state on this interval even when nothing changed.
|
||||
*
|
||||
* A publish is a single fire-and-forget event with no retry: if the relay is
|
||||
* unreachable at the moment of a dispense, that update is simply gone and the
|
||||
* operator's view stays wrong until the next customer happens to buy cash. A
|
||||
* relay also acknowledges an event it then discards, so a publish that returns
|
||||
* cleanly is not proof of anything. The heartbeat is what makes the channel
|
||||
* self-healing, and it is also the only way an out-of-band edit to the table
|
||||
* (atm-tui, direct SQL) ever reaches the operator.
|
||||
*/
|
||||
const STATE_HEARTBEAT_MS = 5 * 60 * 1000
|
||||
|
||||
const operatorConfigDTag = (machineId: string) => `bitspire-cassettes:${machineId}`
|
||||
const atmStateDTag = (machineId: string) => `bitspire-cassettes-state:${machineId}`
|
||||
|
||||
|
|
@ -79,28 +47,17 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
|||
export interface OperatorConfigServiceConfig {
|
||||
/** Connected NostrClient — shared with the Lightning service. */
|
||||
nostrClient: NostrClient
|
||||
/** Signer for the ATM identity. Decrypts operator events + signs our state. */
|
||||
signer: Signer
|
||||
/** ATM's nostr identity. Used to decrypt operator events + sign the bootstrap. */
|
||||
identity: MachineIdentity
|
||||
/** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */
|
||||
operatorPubkeys: string[]
|
||||
/** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
|
||||
/** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */
|
||||
machineId?: string
|
||||
}
|
||||
|
||||
export interface OperatorConfigService {
|
||||
/** Unsubscribe from operator events and free resources. */
|
||||
stop(): void
|
||||
/**
|
||||
* Republish the current cassette state (kind-30078, replaceable). Call after
|
||||
* a dispense and on a cassette reload so the operator's view tracks reality.
|
||||
* Best-effort — logs and swallows errors.
|
||||
*/
|
||||
publishCassettesState(): Promise<void>
|
||||
}
|
||||
|
||||
const NOOP_SERVICE: OperatorConfigService = {
|
||||
stop: () => {},
|
||||
publishCassettesState: async () => {},
|
||||
}
|
||||
|
||||
export async function startOperatorConfigService(
|
||||
|
|
@ -108,24 +65,21 @@ export async function startOperatorConfigService(
|
|||
): Promise<OperatorConfigService> {
|
||||
if (cfg.operatorPubkeys.length === 0) {
|
||||
console.log('[OperatorConfig] No operator pubkeys configured — service disabled')
|
||||
return NOOP_SERVICE
|
||||
return { stop: () => {} }
|
||||
}
|
||||
if (!isElectron || !window.electronAPI) {
|
||||
console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)')
|
||||
return NOOP_SERVICE
|
||||
return { stop: () => {} }
|
||||
}
|
||||
const api = window.electronAPI
|
||||
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
||||
const machineId = cfg.machineId ?? cfg.identity.publicKey
|
||||
|
||||
// Announce current state on every start. This used to be gated on a
|
||||
// one-shot "have we said hello" flag, so any later change to the layout —
|
||||
// a reseed, an atm-tui edit, direct SQL — was never published and the
|
||||
// operator's dashboard kept validating against a bay set that no longer
|
||||
// existed (#94). Best-effort; the heartbeat below is the safety net.
|
||||
// Bootstrap hello-event on first boot (best-effort — failure leaves the
|
||||
// gate null so the next boot retries).
|
||||
try {
|
||||
await publishCassettesState(cfg, api, machineId)
|
||||
await maybePublishBootstrap(cfg, api, machineId)
|
||||
} catch (err) {
|
||||
console.warn('[OperatorConfig] Startup cassettes-state publish failed:', err)
|
||||
console.warn('[OperatorConfig] Bootstrap publish failed (will retry next boot):', err)
|
||||
}
|
||||
|
||||
// Subscribe to operator-published cassette config events.
|
||||
|
|
@ -134,7 +88,7 @@ export async function startOperatorConfigService(
|
|||
[
|
||||
{
|
||||
kinds: [KIND_NIP78],
|
||||
'#p': [cfg.signer.pubkey],
|
||||
'#p': [cfg.identity.publicKey],
|
||||
'#d': [dTag],
|
||||
authors: cfg.operatorPubkeys,
|
||||
},
|
||||
|
|
@ -147,25 +101,10 @@ export async function startOperatorConfigService(
|
|||
},
|
||||
}
|
||||
)
|
||||
console.log(`[OperatorConfig] Subscribed: d=${dTag} sub=${subscriptionId}`)
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
publishCassettesState(cfg, api, machineId).catch((err) =>
|
||||
console.warn('[OperatorConfig] cassettes-state heartbeat failed:', err)
|
||||
)
|
||||
}, STATE_HEARTBEAT_MS)
|
||||
console.log('[OperatorConfig] Subscribed:', { dTag, subscriptionId })
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
clearInterval(heartbeat)
|
||||
cfg.nostrClient.unsubscribe(subscriptionId)
|
||||
},
|
||||
publishCassettesState: () =>
|
||||
publishCassettesState(cfg, api, machineId)
|
||||
.then(() => {})
|
||||
.catch((err) => {
|
||||
console.warn('[OperatorConfig] cassettes-state republish failed:', err)
|
||||
}),
|
||||
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -185,13 +124,17 @@ async function handleOperatorConfigEvent(
|
|||
return
|
||||
}
|
||||
|
||||
// 2. There is deliberately no `created_at` watermark here any more.
|
||||
// Under absolute counts it was the only replay defence, and it cost us:
|
||||
// an event re-delivered out of order was dropped whole, operations
|
||||
// included. Idempotency now rides on the operations themselves — the
|
||||
// operator mints an id per op and this machine records the ones it
|
||||
// applied — which is strictly stronger, because it survives an event
|
||||
// that mixes operations we have seen with ones we have not.
|
||||
// 2. Replay protection — drop stale events. NIP-78 replaceable events
|
||||
// DO get re-delivered on reconnect/restart; without this check, the
|
||||
// ATM would re-apply the same payload on every boot and clobber any
|
||||
// cash-out decrements that landed between operator publishes.
|
||||
const watermark = await api.getLastKnownConfigCreatedAt()
|
||||
if (event.created_at <= watermark) {
|
||||
console.log(
|
||||
`[OperatorConfig] Stale event dropped (created_at=${event.created_at} <= watermark=${watermark})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Clock-skew defense — reject events stamped too far in the future.
|
||||
// Limits damage from a leaked operator nsec future-stamping a fake
|
||||
|
|
@ -205,44 +148,30 @@ async function handleOperatorConfigEvent(
|
|||
}
|
||||
|
||||
// 4. Decrypt content (NIP-44 v2).
|
||||
let parsed: { schema_version?: number; ops?: unknown }
|
||||
let parsed: { positions: Record<string, { denomination: number; count: number }> }
|
||||
try {
|
||||
const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
|
||||
const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content)
|
||||
parsed = JSON.parse(plaintext) as typeof parsed
|
||||
} catch (err) {
|
||||
console.error('[OperatorConfig] Decrypt/parse failed:', err)
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.ops)) {
|
||||
// A v1 operator publishing absolute counts lands here and is ignored.
|
||||
// That direction fails safe: the machine keeps its own counts, which it
|
||||
// is now the only writer of, and simply will not dispense notes it
|
||||
// believes it lacks. The opposite — applying a count from a form loaded
|
||||
// before a dispense — is what ADR-004 exists to stop.
|
||||
console.error('[OperatorConfig] Payload missing `ops` array — dropped')
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.positions) {
|
||||
console.error('[OperatorConfig] Payload missing `positions` field')
|
||||
return
|
||||
}
|
||||
const ops = parsed.ops as CassetteOp[]
|
||||
|
||||
// 5. Apply the ones we have not seen, in one transaction with the sequence
|
||||
// bump. No `created_at` watermark: each op carries an operator-minted id
|
||||
// and the machine records what it applied, so a re-delivered event is a
|
||||
// no-op on its own merits. The watermark would be strictly weaker and
|
||||
// actively harmful — an event arriving out of order can still carry an
|
||||
// operation this machine has never seen.
|
||||
const result = await api.applyOperatorCassetteOps(ops)
|
||||
for (const bad of result.rejected) {
|
||||
console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`)
|
||||
}
|
||||
if (result.applied.length === 0) {
|
||||
console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`)
|
||||
// Still republish: the operator learns from our applied_ops echo that
|
||||
// earlier operations landed, and an event carrying nothing new can be the
|
||||
// first one we successfully answer after a relay outage.
|
||||
const machineIdNoop = cfg.machineId ?? cfg.signer.pubkey
|
||||
await publishCassettesState(cfg, api, machineIdNoop).catch((err) =>
|
||||
console.warn('[OperatorConfig] post-apply cassettes-state republish failed:', err)
|
||||
// 5. Atomic apply (cassettes + meta watermark) via IPC. The state-store
|
||||
// function re-validates watermark + position key-set equality +
|
||||
// per-entry types inside the SQLite transaction. Duplicate
|
||||
// denominations across positions are allowed — real machines load
|
||||
// N cassettes of the same denomination for cash-out throughput.
|
||||
const result = await api.applyOperatorCassettesConfig(
|
||||
{ positions: parsed.positions },
|
||||
event.created_at
|
||||
)
|
||||
if (!result.applied) {
|
||||
console.warn('[OperatorConfig] Apply rejected:', result.reason)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -250,8 +179,8 @@ async function handleOperatorConfigEvent(
|
|||
// picks up the new per-position mapping. state.db is already updated;
|
||||
// HAL re-init failure means the renderer's persistedInventory may be
|
||||
// ahead of the HAL until next service restart — log loudly but don't
|
||||
// unwind the state.db apply (the operation happened physically; HAL
|
||||
// can catch up).
|
||||
// unwind the state.db apply (the operator wants their config landed;
|
||||
// HAL can catch up).
|
||||
const cassettesAfter = await api.loadCassettes()
|
||||
const halResult = await api.halReloadCassettes(
|
||||
cassettesAfter.map((c) => ({
|
||||
|
|
@ -263,88 +192,51 @@ async function handleOperatorConfigEvent(
|
|||
if (!halResult.ok) {
|
||||
console.error('[OperatorConfig] HAL reload failed:', halResult.error)
|
||||
}
|
||||
console.log(`[OperatorConfig] Applied ops: ${result.applied.join(', ')}`)
|
||||
|
||||
// Republish our resulting cassette state so the operator's view reflects the
|
||||
// applied config (the "on cassette reload" case). Different d-tag from the
|
||||
// operator's config event, so no echo loop. Best-effort.
|
||||
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
||||
await publishCassettesState(cfg, api, machineId).catch((err) =>
|
||||
console.warn('[OperatorConfig] post-apply cassettes-state republish failed:', err)
|
||||
console.log(
|
||||
`[OperatorConfig] Applied — created_at=${event.created_at}, positions=${Object.keys(parsed.positions).join(',')}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the ATM's current cassette state as a replaceable kind-30078 event
|
||||
* (`bitspire-cassettes-state:<machineId>`), NIP-44-encrypted to the operator.
|
||||
* Replaceable → latest wins; the operator consumes every update. Call after a
|
||||
* dispense, on a cassette reload, at startup and on a heartbeat, so the
|
||||
* operator view tracks reality (coord 2026-06-21 / lamassu-next#56).
|
||||
*
|
||||
* Returns whether an event was published (false when there are no cassettes /
|
||||
* no operator).
|
||||
*/
|
||||
async function publishCassettesState(
|
||||
async function maybePublishBootstrap(
|
||||
cfg: OperatorConfigServiceConfig,
|
||||
api: NonNullable<typeof window.electronAPI>,
|
||||
machineId: string
|
||||
): Promise<boolean> {
|
||||
): Promise<void> {
|
||||
const already = await api.getBootstrapPublishedAt()
|
||||
if (already !== null) {
|
||||
console.log('[OperatorConfig] Bootstrap already published at unix', already)
|
||||
return
|
||||
}
|
||||
const cassettes = await api.loadCassettes()
|
||||
if (cassettes.length === 0) return false
|
||||
if (cassettes.length === 0) {
|
||||
console.log('[OperatorConfig] state.db.cassettes empty — skipping bootstrap')
|
||||
return
|
||||
}
|
||||
|
||||
const operatorPubkey = cfg.operatorPubkeys[0]
|
||||
if (!operatorPubkey) return false
|
||||
if (!operatorPubkey) {
|
||||
console.log('[OperatorConfig] No operator pubkey — skipping bootstrap')
|
||||
return
|
||||
}
|
||||
|
||||
const positions: Record<string, { denomination: number; count: number }> = {}
|
||||
for (const c of cassettes) {
|
||||
positions[String(c.position)] = { denomination: c.denomination, count: c.count }
|
||||
}
|
||||
// Additive field: an operator on the old consumer reads `positions` and
|
||||
// ignores this, so it needs no coordinated release. When set, the counts
|
||||
// above are the machine's best guess, not a measurement.
|
||||
const countsUncertainSince = await api.getCountsUncertainSince()
|
||||
|
||||
// `applied_ops` is the acknowledgement leg. An addressable event gives its
|
||||
// publisher no failure signal — the relay returns OK for an event it then
|
||||
// discards — so echoing the ids back is the only way the operator can tell
|
||||
// an operation that landed from one that was merely sent. `seq` lets a
|
||||
// reader reject a regression without trusting a clock: `created_at` has
|
||||
// second granularity and ties break on event id, so it cannot order two
|
||||
// reports from the same second.
|
||||
const [appliedOps, seq] = await Promise.all([api.getAppliedOpIds(), api.getCassetteStateSeq()])
|
||||
const payload: Record<string, unknown> = {
|
||||
schema_version: CASSETTE_SCHEMA_VERSION,
|
||||
positions,
|
||||
seq,
|
||||
applied_ops: appliedOps,
|
||||
}
|
||||
if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince
|
||||
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
|
||||
|
||||
// Force the stamp strictly above our last one. Addressable events are ordered
|
||||
// by `created_at` at second granularity, ties broken by lowest event id, and
|
||||
// the relay keeps one and silently drops the other while acknowledging both.
|
||||
// So two publishes inside one second would leave the winner decided by a hash,
|
||||
// permanently — and a clock that stepped backwards would make every report
|
||||
// from this machine disappear. Neither failure is visible from here.
|
||||
const lastPublished = (await api.getLastStatePublishedAt()) ?? 0
|
||||
const createdAt = Math.max(Math.floor(Date.now() / 1000), lastPublished + 1)
|
||||
const ciphertext = encryptContentV2(cfg.identity, operatorPubkey, { positions })
|
||||
|
||||
const dTag = atmStateDTag(machineId)
|
||||
const event = await createSignedEvent(cfg.signer, {
|
||||
const event = createSignedEvent(cfg.identity, {
|
||||
kind: KIND_NIP78,
|
||||
content: ciphertext,
|
||||
tags: [
|
||||
['d', dTag],
|
||||
['p', operatorPubkey],
|
||||
],
|
||||
created_at: createdAt,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await cfg.nostrClient.publish(event)
|
||||
await api.markStatePublished(createdAt)
|
||||
console.log(
|
||||
`[OperatorConfig] cassettes-state published: id=${event.id.slice(0, 12)}… ` +
|
||||
`created_at=${createdAt} seq=${seq} applied_ops=${appliedOps.length} d=${dTag}`
|
||||
)
|
||||
return true
|
||||
await api.markBootstrapPublished(Math.floor(Date.now() / 1000))
|
||||
console.log('[OperatorConfig] Bootstrap hello-event published:', { dTag, eventId: event.id })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,9 +56,10 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
type Signer,
|
||||
type MachineIdentity,
|
||||
type NostrClient,
|
||||
type Event,
|
||||
decryptContentV2,
|
||||
validateEvent,
|
||||
} from '@bitSpire/nostr-client'
|
||||
|
||||
|
|
@ -79,11 +80,11 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
|||
export interface OperatorFeesServiceConfig {
|
||||
/** Connected NostrClient — shared with the Lightning service. */
|
||||
nostrClient: NostrClient
|
||||
/** Signer for the ATM identity. Decrypts operator events. */
|
||||
signer: Signer
|
||||
/** ATM's nostr identity. Used to decrypt operator events. */
|
||||
identity: MachineIdentity
|
||||
/** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */
|
||||
operatorPubkeys: string[]
|
||||
/** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
|
||||
/** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */
|
||||
machineId?: string
|
||||
/**
|
||||
* Called when a valid fee-config event is applied. Renderer should
|
||||
|
|
@ -111,7 +112,7 @@ export async function startOperatorFeesService(
|
|||
return { stop: () => {} }
|
||||
}
|
||||
const api = window.electronAPI
|
||||
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
||||
const machineId = cfg.machineId ?? cfg.identity.publicKey
|
||||
|
||||
// Subscribe to operator-published fee config events.
|
||||
const dTag = feeConfigDTag(machineId)
|
||||
|
|
@ -119,7 +120,7 @@ export async function startOperatorFeesService(
|
|||
[
|
||||
{
|
||||
kinds: [KIND_NIP78],
|
||||
'#p': [cfg.signer.pubkey],
|
||||
'#p': [cfg.identity.publicKey],
|
||||
'#d': [dTag],
|
||||
authors: cfg.operatorPubkeys,
|
||||
},
|
||||
|
|
@ -132,7 +133,7 @@ export async function startOperatorFeesService(
|
|||
},
|
||||
}
|
||||
)
|
||||
console.log(`[Fees] Subscribed: d=${dTag} sub=${subscriptionId}`)
|
||||
console.log('[Fees] Subscribed:', { dTag, subscriptionId })
|
||||
|
||||
return {
|
||||
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
||||
|
|
@ -188,7 +189,7 @@ async function handleFeeConfigEvent(
|
|||
// fields (v2 forward-compat — future promo payloads).
|
||||
let parsed: ParsedFeePayload
|
||||
try {
|
||||
const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
|
||||
const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content)
|
||||
const raw = JSON.parse(plaintext) as Record<string, unknown>
|
||||
parsed = parseV1Payload(raw)
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { ingestScannedSeed } from '../ingest'
|
||||
import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
|
||||
import { npubEncode } from 'nostr-tools/nip19'
|
||||
|
||||
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
||||
function makeSeed(json: unknown): string {
|
||||
const b64 = Buffer.from(JSON.stringify(json), 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
return SPIRE_SEED_SCHEME + b64
|
||||
}
|
||||
|
||||
const SPIRE_PUBKEY = 'a'.repeat(64)
|
||||
const VALID_SEED = makeSeed({
|
||||
v: 1,
|
||||
spire_npub: npubEncode(SPIRE_PUBKEY),
|
||||
lnbits_npub: npubEncode('b'.repeat(64)),
|
||||
bunker_secret: 'deadbeef',
|
||||
relays: ['wss://events.relay/'],
|
||||
})
|
||||
|
||||
describe('ingestScannedSeed', () => {
|
||||
const originalWindow = globalThis.window
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.window = originalWindow
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('rejects a non-seed scan without touching the bridge', async () => {
|
||||
const saveSpireSeed = vi.fn()
|
||||
globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
|
||||
|
||||
const result = await ingestScannedSeed('https://example.com/not-a-seed')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('invalid-seed')
|
||||
expect(saveSpireSeed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports no-bridge when Electron is absent', async () => {
|
||||
globalThis.window = {} as unknown as Window & typeof globalThis
|
||||
const result = await ingestScannedSeed(VALID_SEED)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('no-bridge')
|
||||
})
|
||||
|
||||
it('persists the seed and relaunches on a valid scan', async () => {
|
||||
const saveSpireSeed = vi.fn().mockResolvedValue(undefined)
|
||||
const relaunchApp = vi.fn().mockResolvedValue(undefined)
|
||||
globalThis.window = {
|
||||
electronAPI: { saveSpireSeed, relaunchApp },
|
||||
} as unknown as Window & typeof globalThis
|
||||
|
||||
const result = await ingestScannedSeed(` ${VALID_SEED} `) // tolerate whitespace
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.spirePubkey).toBe(SPIRE_PUBKEY)
|
||||
expect(saveSpireSeed).toHaveBeenCalledWith(VALID_SEED)
|
||||
expect(relaunchApp).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('surfaces persist-failed when saveSpireSeed throws', async () => {
|
||||
const saveSpireSeed = vi.fn().mockRejectedValue(new Error('EACCES'))
|
||||
globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
|
||||
|
||||
const result = await ingestScannedSeed(VALID_SEED)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('persist-failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ingest does not pair in-renderer', () => {
|
||||
it('never imports connect logic — persistence + relaunch only', () => {
|
||||
// Guard: the design intentionally reuses the boot-time pairing path.
|
||||
// If someone wires connectNewSeed here, this comment + the ingest source
|
||||
// should be revisited together.
|
||||
expect(ingestScannedSeed).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
/**
|
||||
* Pairing module surface (aiolabs/bitspire#52).
|
||||
*
|
||||
* `availablePairingSources()` probes each known source and returns those the
|
||||
* current device can actually run, in preference order (camera first, NFC if
|
||||
* present). The wizard renders the first available source and offers the rest
|
||||
* as alternates.
|
||||
*/
|
||||
|
||||
import { QrPairingSource } from './qr-source'
|
||||
import { NfcPairingSource } from './nfc-source'
|
||||
import type { PairingSource } from './types'
|
||||
|
||||
export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types'
|
||||
export { QrPairingSource } from './qr-source'
|
||||
export { NfcPairingSource } from './nfc-source'
|
||||
export { ingestScannedSeed, parseScannedSeed } from './ingest'
|
||||
export type { IngestResult, SeedPreview } from './ingest'
|
||||
export { testRelay } from './relay-test'
|
||||
export type { RelayTestResult } from './relay-test'
|
||||
|
||||
/** All sources in preference order, regardless of availability. */
|
||||
export function allPairingSources(): PairingSource[] {
|
||||
return [new QrPairingSource(), new NfcPairingSource()]
|
||||
}
|
||||
|
||||
/** Only the sources this device can run, in preference order. */
|
||||
export async function availablePairingSources(): Promise<PairingSource[]> {
|
||||
const sources = allPairingSources()
|
||||
const flags = await Promise.all(sources.map((s) => s.isAvailable()))
|
||||
return sources.filter((_, i) => flags[i])
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
/**
|
||||
* Seed ingest pipeline (aiolabs/bitspire#52).
|
||||
*
|
||||
* Turns a raw scanned payload into a paired machine. The wizard captures a
|
||||
* string off some PairingSource and hands it here; we:
|
||||
* 1. validate it parses as a spire-seed (reject anything else — a QR on the
|
||||
* counter, a URL, a different protocol),
|
||||
* 2. persist it as VITE_SPIRE_SEED via the Electron bridge,
|
||||
* 3. relaunch so the normal boot path (signer-resolver → connectNewSeed)
|
||||
* performs the actual bunker pairing.
|
||||
*
|
||||
* We do NOT pair in-renderer here: persisting + relaunching reuses the single,
|
||||
* hardware-tested pairing path rather than duplicating connect/redeem logic in
|
||||
* the wizard. The trade-off is a ~kiosk-restart of latency, which is fine for a
|
||||
* one-time provisioning step.
|
||||
*/
|
||||
|
||||
import { parseSpireSeed, seedFingerprint } from '@bitSpire/nostr-client'
|
||||
|
||||
export type IngestResult =
|
||||
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
|
||||
| { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string }
|
||||
|
||||
export type SeedPreview =
|
||||
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
|
||||
| { ok: false; reason: 'invalid-seed'; message: string }
|
||||
|
||||
/**
|
||||
* Validate-only: parse a scanned payload as a spire-seed WITHOUT persisting or
|
||||
* relaunching. The wizard uses this to show a review step (decoded relay + a
|
||||
* "test relay" button) before committing, so a well-formed but unreachable
|
||||
* relay is caught before the machine relaunches into a pairing crash-loop.
|
||||
* `parseSpireSeed` already rejects a malformed relay (e.g. a QR misread of
|
||||
* `ws://` → `As://`); this surfaces that as an invalid-seed rejection.
|
||||
*/
|
||||
export function parseScannedSeed(raw: string): SeedPreview {
|
||||
const trimmed = (raw || '').trim()
|
||||
try {
|
||||
const seed = parseSpireSeed(trimmed)
|
||||
return {
|
||||
ok: true,
|
||||
spirePubkey: seed.spirePubkey,
|
||||
fingerprint: seedFingerprint(trimmed),
|
||||
relays: seed.relays,
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid-seed',
|
||||
message: e instanceof Error ? e.message : 'Not a valid pairing code',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ingestScannedSeed(raw: string): Promise<IngestResult> {
|
||||
const trimmed = (raw || '').trim()
|
||||
|
||||
let spirePubkey: string
|
||||
let relays: string[]
|
||||
try {
|
||||
const seed = parseSpireSeed(trimmed)
|
||||
spirePubkey = seed.spirePubkey
|
||||
relays = seed.relays
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid-seed',
|
||||
message: e instanceof Error ? e.message : 'Not a valid pairing code',
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || !window.electronAPI) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'no-bridge',
|
||||
message: 'Pairing must run on the machine (no kiosk bridge available).',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI.saveSpireSeed(trimmed)
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'persist-failed',
|
||||
message: e instanceof Error ? e.message : 'Could not save the pairing.',
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: the relaunch tears this process down.
|
||||
void window.electronAPI.relaunchApp()
|
||||
|
||||
return { ok: true, spirePubkey, fingerprint: seedFingerprint(trimmed), relays }
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
/**
|
||||
* NFC pairing source — SCAFFOLD (aiolabs/bitspire#52).
|
||||
*
|
||||
* The user flagged NFC as a plausible future pairing method (tap a tag/phone
|
||||
* carrying the spire-seed). This wires the seam against the Web NFC API
|
||||
* (`NDEFReader`) so a future build can light it up without reworking the
|
||||
* wizard. It is NOT active on current hardware: Web NFC ships only on Chrome
|
||||
* for Android, so `isAvailable()` returns false on the Sintra's Linux Electron
|
||||
* and the wizard simply won't offer it.
|
||||
*
|
||||
* When real NFC hardware lands (likely a HAL peripheral rather than Web NFC),
|
||||
* replace the body of `start()` with that driver — the PairingSource contract
|
||||
* stays the same.
|
||||
*/
|
||||
|
||||
import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
|
||||
|
||||
// Minimal structural type for the Web NFC API (not in lib.dom for Electron).
|
||||
interface NDEFReaderLike {
|
||||
scan(): Promise<void>
|
||||
addEventListener(
|
||||
type: 'reading',
|
||||
listener: (event: { message: { records: Array<{ recordType: string; data?: BufferSource }> } }) => void
|
||||
): void
|
||||
addEventListener(type: 'readingerror', listener: (event: unknown) => void): void
|
||||
}
|
||||
|
||||
function getNDEFReaderCtor(): (new () => NDEFReaderLike) | null {
|
||||
const ctor = (globalThis as { NDEFReader?: new () => NDEFReaderLike }).NDEFReader
|
||||
return ctor ?? null
|
||||
}
|
||||
|
||||
export class NfcPairingSource implements PairingSource {
|
||||
readonly kind = 'nfc' as const
|
||||
readonly label = 'NFC tap'
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return getNDEFReaderCtor() !== null
|
||||
}
|
||||
|
||||
async start(opts: PairingSourceStartOptions): Promise<StopCapture> {
|
||||
const Ctor = getNDEFReaderCtor()
|
||||
if (!Ctor) throw new Error('Web NFC unavailable on this device')
|
||||
|
||||
const reader = new Ctor()
|
||||
const decoder = new TextDecoder()
|
||||
let stopped = false
|
||||
|
||||
reader.addEventListener('reading', (event) => {
|
||||
if (stopped) return
|
||||
for (const record of event.message.records) {
|
||||
if (record.recordType === 'text' && record.data) {
|
||||
const raw = decoder.decode(record.data).trim()
|
||||
if (raw) opts.onScan(raw)
|
||||
}
|
||||
}
|
||||
})
|
||||
reader.addEventListener('readingerror', (e) => opts.onError?.(e))
|
||||
|
||||
await reader.scan()
|
||||
// Web NFC has no explicit stop; the AbortController form would, but the
|
||||
// scaffold just flips a guard so late events are ignored after teardown.
|
||||
return () => {
|
||||
stopped = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
/**
|
||||
* Camera-based QR pairing source (aiolabs/bitspire#52).
|
||||
*
|
||||
* Decodes with `qr` (paulmillr) — a zero-dependency, auditable, dual
|
||||
* MIT/Apache library from the same author as the `@noble`/`@scure` crypto our
|
||||
* nostr stack already trusts (chosen over the dormant `jsqr` for that ethos +
|
||||
* active maintenance). Its `qr/dom.js` browser helper wraps getUserMedia and
|
||||
* the per-frame decode loop, so this source is a thin adapter onto the
|
||||
* PairingSource contract.
|
||||
*
|
||||
* The first successful decode wins; the loop then stops itself so a single
|
||||
* seed isn't ingested repeatedly.
|
||||
*/
|
||||
|
||||
import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js'
|
||||
import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
|
||||
|
||||
export class QrPairingSource implements PairingSource {
|
||||
readonly kind = 'qr' as const
|
||||
readonly label = 'Camera'
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return (
|
||||
typeof navigator !== 'undefined' &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.getUserMedia === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
async start(opts: PairingSourceStartOptions): Promise<StopCapture> {
|
||||
const { onScan, onError, video } = opts
|
||||
if (!video) throw new Error('QrPairingSource requires a <video> element')
|
||||
|
||||
const camera = await frontalCamera(video)
|
||||
|
||||
// `frontalCamera` requests `ideal: screen.{width,height}`, so on the kiosk's
|
||||
// 1280x800 panel it otherwise streams at ~720p — and `decodeQR` then
|
||||
// center-crops to a square, leaving too few pixels-per-module for a dense
|
||||
// spire-seed QR on this fixed-focus lens. Pin a deliberate 1280x960 capture
|
||||
// instead: lamassu-machine caps QR scanning at 640x480 for decode speed
|
||||
// (megapixels just slow the per-frame decode), but our seed QR is denser
|
||||
// than a lightning invoice, so 1280x960 is the balance — ~14-18px/module at
|
||||
// frame-fill, still fast, and it meters exposure better than maxing the
|
||||
// sensor (a frame-filling QR keeps auto-exposure from blowing out on a
|
||||
// bright phone screen). The stream lives on the <video>'s srcObject
|
||||
// (QRCamera.stream is private); soft `ideal` so a camera that can't honor
|
||||
// it degrades to its closest mode instead of throwing.
|
||||
try {
|
||||
const stream = video.srcObject
|
||||
if (stream instanceof MediaStream) {
|
||||
await stream.getVideoTracks()[0]?.applyConstraints({
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 960 },
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.(e)
|
||||
}
|
||||
|
||||
const canvas = new QRCanvas() // decode-only; no overlay canvases needed
|
||||
|
||||
let stopped = false
|
||||
let cancel: (() => void) | null = null
|
||||
const stop: StopCapture = () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
cancel?.()
|
||||
camera.stop()
|
||||
}
|
||||
|
||||
cancel = frameLoop(() => {
|
||||
if (stopped) return
|
||||
try {
|
||||
// `fullSize: true` decodes the camera's intrinsic frame (videoWidth ×
|
||||
// videoHeight) rather than the <video> element's CSS box — the default
|
||||
// (`false`) was decoding the few-hundred-px on-screen preview, which
|
||||
// (compounded by `object-cover` cropping) starved the decoder.
|
||||
const result = camera.readFrame(canvas, true)
|
||||
if (result) {
|
||||
stop()
|
||||
onScan(result)
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.(e)
|
||||
}
|
||||
})
|
||||
|
||||
return stop
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/**
|
||||
* Relay reachability probe for the pairing wizard (aiolabs/bitspire#70).
|
||||
*
|
||||
* `parseSpireSeed` catches a MALFORMED relay (e.g. a QR misread of `ws://` into
|
||||
* `As://`), but a well-formed-yet-unreachable relay — `ws://localhost:…` baked
|
||||
* into a seed for a remote machine, a wrong LAN IP, or a relay that's simply
|
||||
* down — still parses fine and would only fail later as a NIP-46 connect
|
||||
* crash-loop. This opens a WebSocket to the relay (and sends a NIP-01 REQ so a
|
||||
* real relay answers) so the operator can confirm reachability on-machine,
|
||||
* before committing the pairing.
|
||||
*/
|
||||
|
||||
export interface RelayTestResult {
|
||||
url: string
|
||||
ok: boolean
|
||||
/** Round-trip time to open (ms), when reachable. */
|
||||
ms?: number
|
||||
/** True when the relay answered our REQ — i.e. it's actually a nostr relay. */
|
||||
answered?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Open a WebSocket to `url` and report whether it connects within `timeoutMs`. */
|
||||
export function testRelay(url: string, timeoutMs = 6000): Promise<RelayTestResult> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now()
|
||||
let ws: WebSocket | null = null
|
||||
let settled = false
|
||||
|
||||
const finish = (r: Omit<RelayTestResult, 'url'>): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
try {
|
||||
ws?.close()
|
||||
} catch {
|
||||
/* already closing */
|
||||
}
|
||||
resolve({ url, ...r })
|
||||
}
|
||||
|
||||
const timer = setTimeout(
|
||||
() => finish({ ok: false, error: `timed out after ${timeoutMs}ms` }),
|
||||
timeoutMs,
|
||||
)
|
||||
|
||||
try {
|
||||
ws = new WebSocket(url)
|
||||
} catch (e) {
|
||||
finish({ ok: false, error: e instanceof Error ? e.message : 'invalid relay URL' })
|
||||
return
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
// Connected. Probe it as a nostr relay; a genuine relay replies (EOSE /
|
||||
// notice). If it stays silent we still count the open as reachable.
|
||||
try {
|
||||
ws?.send(JSON.stringify(['REQ', 'bitspire-relay-test', { limit: 0 }]))
|
||||
} catch {
|
||||
/* send failed, but the socket opened → still reachable */
|
||||
}
|
||||
const graceMs = Math.min(600, timeoutMs)
|
||||
setTimeout(() => finish({ ok: true, ms: Date.now() - start, answered: false }), graceMs)
|
||||
}
|
||||
ws.onmessage = () => finish({ ok: true, ms: Date.now() - start, answered: true })
|
||||
ws.onerror = () =>
|
||||
finish({ ok: false, error: 'connection failed (unreachable or not a relay)' })
|
||||
})
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* Pairing-source abstraction (aiolabs/bitspire#52).
|
||||
*
|
||||
* A fresh ATM is paired by getting a `spire-seed:v1:…` onto the device. The
|
||||
* operator's spirekeeper mints that seed and renders it as a QR (and, later,
|
||||
* possibly an NFC tag). The machine ingests it via whatever capture hardware
|
||||
* it has — today a camera, tomorrow maybe an NFC reader or a HAL barcode
|
||||
* scanner. `PairingSource` is the seam that keeps the wizard UI and the
|
||||
* ingest pipeline agnostic to *how* the seed arrived.
|
||||
*
|
||||
* Implementations live next to this file: `qr-source.ts` (camera + jsQR),
|
||||
* `nfc-source.ts` (Web NFC scaffold). A HAL-scanner source can be added the
|
||||
* same way without touching the wizard.
|
||||
*/
|
||||
|
||||
export type PairingSourceKind = 'qr' | 'nfc'
|
||||
|
||||
export interface PairingSourceStartOptions {
|
||||
/** Invoked with each decoded payload (the raw seed string). */
|
||||
onScan: (raw: string) => void
|
||||
/** Invoked on a non-fatal capture error (e.g. a frame decode glitch). */
|
||||
onError?: (error: unknown) => void
|
||||
/**
|
||||
* The <video> element the camera preview renders into. Required by
|
||||
* camera-based sources; ignored by sources that don't show a viewfinder
|
||||
* (e.g. NFC).
|
||||
*/
|
||||
video?: HTMLVideoElement
|
||||
}
|
||||
|
||||
/** Releases capture hardware (camera stream, NFC reader). Idempotent. */
|
||||
export type StopCapture = () => void
|
||||
|
||||
export interface PairingSource {
|
||||
readonly kind: PairingSourceKind
|
||||
/** Short label for the wizard's source picker (e.g. "Camera", "NFC tap"). */
|
||||
readonly label: string
|
||||
/** Whether this source can run in the current environment. */
|
||||
isAvailable(): Promise<boolean>
|
||||
/** Begin capturing; resolves once hardware is live. */
|
||||
start(opts: PairingSourceStartOptions): Promise<StopCapture>
|
||||
}
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
/**
|
||||
* Signer resolution — turns the ATM's pairing state into a live `Signer`.
|
||||
*
|
||||
* Three outcomes, in priority order (aiolabs/bitspire#52, model A1):
|
||||
* 1. A seed is present whose fingerprint differs from the stored binding
|
||||
* (first pair or re-pair) → generate a fresh NIP-46 transport key, redeem
|
||||
* the one-shot connect secret, persist the binding, and reset the
|
||||
* publish watermark so the (possibly new) operator gets current state (#56).
|
||||
* 2. A seed is present matching the stored binding, OR no seed but a stored
|
||||
* binding exists → resume the bunker session with the persisted transport
|
||||
* key (no re-redeem — the binding is server-persistent).
|
||||
* 3. Neither → ephemeral LocalSigner, dev only. In strict (production) mode
|
||||
* this throws instead: no pairing means no signing identity.
|
||||
*
|
||||
* Runs in the renderer (where the relay I/O lives); state.db reads/writes go
|
||||
* through the one-shot get-atm-secrets channel + the binding IPC handlers.
|
||||
*/
|
||||
|
||||
import {
|
||||
LocalSigner,
|
||||
connectNewSeed,
|
||||
resumeFromBinding,
|
||||
generateClientTransportKey,
|
||||
generateIdentity,
|
||||
loadIdentityFromHex,
|
||||
parseSpireSeed,
|
||||
seedFingerprint,
|
||||
type Signer,
|
||||
type SpireSeed,
|
||||
} from '@bitSpire/nostr-client'
|
||||
import type { BunkerBindingRecord } from '@/types/electron'
|
||||
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||
|
||||
/**
|
||||
* Thrown in strict mode when the machine has no seed and no binding — it is
|
||||
* genuinely unpaired, not misconfigured. The renderer catches this to show the
|
||||
* QR-pairing wizard (camera scan of a spire-seed) rather than a fault screen.
|
||||
* Distinct `.name` so it survives the bundle boundary (instanceof is fragile
|
||||
* across the electron/renderer split). See services/init-error.ts.
|
||||
*/
|
||||
export class NoPairingError extends Error {
|
||||
override readonly name = 'NoPairingError'
|
||||
constructor() {
|
||||
super('[Signer] Machine is unpaired — no spire seed and no bunker binding.')
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolveSignerOptions {
|
||||
/** Allow an ephemeral LocalSigner when no seed/binding exists (dev only). */
|
||||
allowEphemeral: boolean
|
||||
}
|
||||
|
||||
/** LNbits transport config carried by the pairing (aiolabs/bitspire#70). */
|
||||
export interface TransportConfig {
|
||||
/** LNbits transport relays (kind-21000 / 30078). */
|
||||
relays: string[]
|
||||
/** LNbits nostr-transport server pubkey (hex). */
|
||||
lnbitsServerPubkey: string
|
||||
}
|
||||
|
||||
export interface ResolvedSigner {
|
||||
signer: Signer
|
||||
/**
|
||||
* Transport config sourced from the pairing — the seed on a fresh pair /
|
||||
* seeded resume, the binding on a seedless resume. Null when unavailable (an
|
||||
* ephemeral dev signer, or a pre-#70 binding that never stored it); the
|
||||
* caller then falls back to env provisioning.
|
||||
*/
|
||||
transport: TransportConfig | null
|
||||
}
|
||||
|
||||
interface PairingState {
|
||||
spireSeed: string
|
||||
binding: BunkerBindingRecord | null
|
||||
}
|
||||
|
||||
/** Gather the seed + persisted binding from Electron, or env in browser dev. */
|
||||
async function loadPairingState(): Promise<PairingState> {
|
||||
if (isElectron && window.electronAPI) {
|
||||
const secrets = await window.electronAPI.getAtmSecrets()
|
||||
return { spireSeed: secrets.spireSeed || '', binding: secrets.bunkerBinding ?? null }
|
||||
}
|
||||
return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null }
|
||||
}
|
||||
|
||||
export async function resolveSigner(opts: ResolveSignerOptions): Promise<ResolvedSigner> {
|
||||
const { spireSeed, binding } = await loadPairingState()
|
||||
|
||||
const resume = (b: BunkerBindingRecord): Promise<Signer> =>
|
||||
resumeFromBinding({
|
||||
clientSecretHex: b.clientSecretHex,
|
||||
spirePubkey: b.spirePubkey,
|
||||
bunkerUrl: b.bunkerUrl,
|
||||
})
|
||||
|
||||
// Transport config from a binding — present only when the pairing seed
|
||||
// carried it (post-#70) and it was persisted. Null on pre-#70 bindings.
|
||||
const transportFromBinding = (b: BunkerBindingRecord): TransportConfig | null =>
|
||||
b.relays && b.relays.length > 0 && b.lnbitsServerPubkey
|
||||
? { relays: b.relays, lnbitsServerPubkey: b.lnbitsServerPubkey }
|
||||
: null
|
||||
|
||||
const transportFromSeed = (s: SpireSeed): TransportConfig => ({
|
||||
relays: s.relays,
|
||||
lnbitsServerPubkey: s.lnbitsServerPubkey,
|
||||
})
|
||||
|
||||
if (spireSeed) {
|
||||
let seed: SpireSeed
|
||||
let fingerprint: string
|
||||
try {
|
||||
seed = parseSpireSeed(spireSeed)
|
||||
fingerprint = seedFingerprint(spireSeed)
|
||||
} catch (err) {
|
||||
// A stored seed we can't parse — e.g. a legacy-shape seed left in .env
|
||||
// after the seed format changed (bitspire-#70). If we already hold a
|
||||
// binding it's authoritative (server-persistent), so resume from it
|
||||
// rather than bricking a paired machine on the next boot. With no
|
||||
// binding the seed is our only pairing input, so fail closed.
|
||||
if (binding) {
|
||||
console.warn(
|
||||
'[Signer] Stored spire seed is unparseable; resuming from existing binding:',
|
||||
(err as Error).message
|
||||
)
|
||||
return { signer: await resume(binding), transport: transportFromBinding(binding) }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (binding && binding.seedFingerprint === fingerprint) {
|
||||
console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey)
|
||||
// Seed present + parsed → prefer its (fresh) transport config over the
|
||||
// binding's, which may predate the seed carrying transport (pre-#70).
|
||||
return { signer: await resume(binding), transport: transportFromSeed(seed) }
|
||||
}
|
||||
|
||||
// First pair or re-pair: redeem the one-shot connect secret.
|
||||
console.log('[Signer] Pairing to bunker for spire', seed.spirePubkey)
|
||||
const transport = generateClientTransportKey()
|
||||
const signer = await connectNewSeed({
|
||||
spirePubkey: seed.spirePubkey,
|
||||
bunkerUrl: seed.bunkerUrl,
|
||||
clientSecretHex: transport.secretHex,
|
||||
})
|
||||
if (isElectron && window.electronAPI) {
|
||||
// Re-pair (a NEW seed replacing a prior binding) → wipe the previous
|
||||
// operator's config/trust state (fee config + replay watermarks) so it
|
||||
// can't linger or silently replay-block the new operator's config. A
|
||||
// first pair (no prior binding) has nothing to reset. Cash accounting is
|
||||
// preserved — see resetForRepair; a full wipe is the factory-reset path.
|
||||
if (binding) {
|
||||
console.log(
|
||||
'[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state'
|
||||
)
|
||||
await window.electronAPI.resetForRepair()
|
||||
}
|
||||
// Persist the seed's transport config alongside the binding so a later
|
||||
// seedless resume still reaches the backend without env provisioning.
|
||||
await window.electronAPI.saveBunkerBinding({
|
||||
clientSecretHex: transport.secretHex,
|
||||
spirePubkey: seed.spirePubkey,
|
||||
bunkerUrl: seed.bunkerUrl,
|
||||
seedFingerprint: fingerprint,
|
||||
pairedAt: Math.floor(Date.now() / 1000),
|
||||
relays: seed.relays,
|
||||
lnbitsServerPubkey: seed.lnbitsServerPubkey,
|
||||
})
|
||||
// Re-pair → re-publish the cassette-state hello to the new operator (#56).
|
||||
await window.electronAPI.resetStatePublishWatermark()
|
||||
}
|
||||
return { signer, transport: transportFromSeed(seed) }
|
||||
}
|
||||
|
||||
// No seed in this boot but a binding survives → resume.
|
||||
if (binding) {
|
||||
console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)')
|
||||
return { signer: await resume(binding), transport: transportFromBinding(binding) }
|
||||
}
|
||||
|
||||
if (opts.allowEphemeral) {
|
||||
// Dev-only: a hex key gives a stable dev identity; otherwise ephemeral.
|
||||
const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : ''
|
||||
if (devKey) {
|
||||
console.warn('[Signer] No bunker pairing — using LocalSigner from VITE_ATM_PRIVATE_KEY (dev)')
|
||||
return { signer: new LocalSigner(loadIdentityFromHex(devKey)), transport: null }
|
||||
}
|
||||
console.warn('[Signer] No bunker pairing — generated ephemeral LocalSigner (dev only)')
|
||||
return { signer: new LocalSigner(generateIdentity()), transport: null }
|
||||
}
|
||||
|
||||
throw new NoPairingError()
|
||||
}
|
||||
|
|
@ -8,13 +8,16 @@ import {
|
|||
type ActorRefFrom,
|
||||
type SnapshotFrom,
|
||||
type ATMMachine,
|
||||
type AccessRole,
|
||||
} from '@bitSpire/state-machine'
|
||||
import type { AccessControlConfig, CardSession } from '@/types/electron'
|
||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||
import { classifyInitError } from '@/services/init-error'
|
||||
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
|
||||
import { startOperatorFeesService, type OperatorFeesService } from '@/services/operator-fees'
|
||||
import {
|
||||
startOperatorConfigService,
|
||||
type OperatorConfigService,
|
||||
} from '@/services/operator-config'
|
||||
import {
|
||||
startOperatorFeesService,
|
||||
type OperatorFeesService,
|
||||
} from '@/services/operator-fees'
|
||||
import type { HalConfig, HalServices } from '@/services/hal'
|
||||
import type { MachineModel } from '@/config'
|
||||
import type { LightningBackend } from '@/services/lightning'
|
||||
|
|
@ -27,7 +30,6 @@ import {
|
|||
} from '@bitSpire/clink'
|
||||
import type { TransactionRecord } from '@/types/state'
|
||||
import { useAvailabilityBroadcast } from '@/composables/useAvailabilityBroadcast'
|
||||
import { authorize, parseBoltcardLnurlw, type AccessScan } from '@/services/access'
|
||||
|
||||
// Check if we're running in Electron
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||
|
|
@ -49,8 +51,7 @@ function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number {
|
|||
`Unit fraction expected (0.05 = 5%), not a percentage.`
|
||||
)
|
||||
}
|
||||
const principalSats =
|
||||
ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
|
||||
const principalSats = ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
|
||||
const feeSats = isCashIn
|
||||
? principalSats - ctx.satsAmount // cash-in: customer receives less than principal
|
||||
: ctx.satsAmount - principalSats // cash-out: customer pays more than principal
|
||||
|
|
@ -72,13 +73,7 @@ async function handleManagementCommand(
|
|||
request: ManagementRequest,
|
||||
dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise<any>,
|
||||
machineIdle: boolean,
|
||||
currency: string,
|
||||
/**
|
||||
* Called once the dispense has been persisted. Bills left the bays whether or
|
||||
* not the dispense completed, so the caller refreshes its inventory and
|
||||
* republishes the operator's view — this path used to do neither.
|
||||
*/
|
||||
onCassettesChanged?: () => Promise<void>
|
||||
currency: string
|
||||
): Promise<ManagementResponse | null> {
|
||||
if (!isMachineDispenseRequest(request)) return null
|
||||
|
||||
|
|
@ -136,7 +131,6 @@ async function handleManagementCommand(
|
|||
cassettes: result.cassettes,
|
||||
error: result.error,
|
||||
})
|
||||
await onCassettesChanged?.()
|
||||
|
||||
// Only remediate the original tx if ALL requested bills were dispensed
|
||||
let refRemediated = false
|
||||
|
|
@ -168,14 +162,10 @@ async function handleManagementCommand(
|
|||
}
|
||||
|
||||
/**
|
||||
* Load inventory from SQLite via IPC.
|
||||
*
|
||||
* Returns `null` when the DB could not be asked at all — browser dev mode, or
|
||||
* a failed IPC call — so callers can tell "no answer" from an answer of "the
|
||||
* bays are empty". An empty map is a real, actionable reading: cassettes are
|
||||
* configured and drained, or none are configured.
|
||||
* Load inventory from SQLite via IPC (Electron only).
|
||||
* Returns empty object in browser dev mode.
|
||||
*/
|
||||
async function loadInventoryFromDb(): Promise<Record<number, number> | null> {
|
||||
async function loadInventoryFromDb(): Promise<Record<number, number>> {
|
||||
if (isElectron && window.electronAPI) {
|
||||
try {
|
||||
const inv = await window.electronAPI.getInventory()
|
||||
|
|
@ -185,7 +175,7 @@ async function loadInventoryFromDb(): Promise<Record<number, number> | null> {
|
|||
console.warn('[ATM] Failed to load inventory from DB:', e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -300,74 +290,6 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
const debugMode = ref(true)
|
||||
const allowMockFallback = ref(true) // default true for browser dev
|
||||
const initError = ref<string | null>(null) // fatal error → maintenance screen
|
||||
// Bolt Card cash-out (NFC tap-to-pay). nfcStatus surfaces reader state on the
|
||||
// invoice screen; boltCardProcessing gates against double-taps while a pull
|
||||
// is in flight (settlement still arrives via the normal invoice watcher).
|
||||
const nfcStatus = ref<{ state: string; message?: string } | null>(null)
|
||||
const boltCardProcessing = ref(false)
|
||||
// What a tap handler did with the voucher it was given:
|
||||
// 'skipped' — a guard bounced it before any server call; the SUN p/c are
|
||||
// untouched and the lnurlw can still be presented later.
|
||||
// 'accepted' — the card's server took the voucher (payment in flight).
|
||||
// 'declined' — presented and refused, or failed after presentation. The
|
||||
// boltcards server bumps the SUN counter on the first GET, so
|
||||
// treat the voucher as spent even when the failure was ours.
|
||||
// 'blocked' — refused BEFORE anything was presented, because the card
|
||||
// server withheld that step when the session opened (e.g. the
|
||||
// card's daily limit is already spent). Nothing was consumed
|
||||
// and no re-tap can lift it, so the session stays loaded.
|
||||
type BoltCardOutcome = 'skipped' | 'accepted' | 'declined' | 'blocked'
|
||||
// Where a Complete gets its voucher: a card tapped right now on the cash
|
||||
// screen (raw lnurlw, spent by this call) or the session opened at entry
|
||||
// (hit-keyed steps, no p/c).
|
||||
type BoltCardSource = { lnurlw: string } | { session: CardSession }
|
||||
// Electron IPC structured-clones its arguments and rejects Vue reactive
|
||||
// proxies with "An object could not be cloned". `loadedBoltCard` is a ref,
|
||||
// so anything reached through it is a proxy — copy the steps field by field
|
||||
// into plain objects before they cross the bridge.
|
||||
const plainWithdrawStep = (w: NonNullable<CardSession['withdraw']>) => ({
|
||||
callback: w.callback,
|
||||
k1: w.k1,
|
||||
minWithdrawable: w.minWithdrawable,
|
||||
maxWithdrawable: w.maxWithdrawable,
|
||||
})
|
||||
const plainPayStep = (p: CardSession['pay']) => ({
|
||||
callback: p.callback,
|
||||
minSendable: p.minSendable,
|
||||
maxSendable: p.maxSendable,
|
||||
metadata: p.metadata,
|
||||
})
|
||||
// Access-control gate config (ADR-003). Defaults disabled → the machine's
|
||||
// `locked` state bypasses straight to `idle` (behaviour identical to no gate).
|
||||
// Populated from RuntimeConfig.accessControl in initializeForProduction.
|
||||
const accessControl = ref<AccessControlConfig>({
|
||||
enabled: false,
|
||||
devUnlock: false,
|
||||
openEnrollment: false,
|
||||
salt: 'bitspire-access-v1',
|
||||
allowList: [],
|
||||
})
|
||||
// Build/dev bypass — opens the gate even when enabled (browser dev / CI).
|
||||
const accessBypassFlag = import.meta.env.VITE_SKIP_ACCESS_GATE === 'true'
|
||||
// Tap-to-enter (ADR-003): the Bolt Card session opened at the locked screen
|
||||
// is held here for the whole visit so buy/sell just need "Complete" — no
|
||||
// second tap. The tap's SUN was spent opening it; what we hold are the
|
||||
// hit-keyed withdraw/pay steps plus balance + fiat for display. Cleared when
|
||||
// the machine re-locks. Never logged.
|
||||
const loadedBoltCard = ref<CardSession | null>(null)
|
||||
// The card balance is hidden by default on the public screen; the holder
|
||||
// reveals it with the eye toggle. Resets on re-lock.
|
||||
const cardBalanceRevealed = ref(false)
|
||||
// Set when a card accepted a cash-out pull and the machine then left the
|
||||
// invoice screen without ever seeing the payment settle. The customer's
|
||||
// wallet paid and no cash came out, so this must be visible and carry the
|
||||
// reference an operator can reconcile against — never a silent return to
|
||||
// the amount screen. Cleared when the next transaction starts.
|
||||
const settlementError = ref<{ txid: string | null; message: string } | null>(null)
|
||||
// When the card server names a currency but didn't price the balance (its
|
||||
// rate cache was cold — it never blocks the unlock on a rate lookup), price
|
||||
// it here from the ATM's own rate source, in that currency.
|
||||
const cardFiatRate = ref<{ currency: string; btcPrice: number } | null>(null)
|
||||
const fiatCode = ref('USD')
|
||||
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
||||
|
|
@ -457,25 +379,13 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
*/
|
||||
const persistedInventory = ref<Record<number, number>>({})
|
||||
|
||||
/**
|
||||
* The bays moved outside the normal cash-out flow — an operator-command
|
||||
* dispense, a main-process seed. Refresh the renderer's view and push the
|
||||
* operator's. Best-effort: a publish failure must not fail the dispense.
|
||||
*/
|
||||
async function refreshAndPublishCassettes() {
|
||||
await reloadPersistedInventory()
|
||||
await operatorConfigSvc?.publishCassettesState()
|
||||
}
|
||||
|
||||
async function reloadPersistedInventory() {
|
||||
const inv = await loadInventoryFromDb()
|
||||
// Only a failed read is ignored. An empty map used to be skipped too,
|
||||
// which meant the last bill out of the machine never updated anything and
|
||||
// the availability beacon kept advertising a full cassette.
|
||||
if (inv === null) return
|
||||
if (Object.keys(inv).length > 0) {
|
||||
persistedInventory.value = inv
|
||||
console.log('[ATM] Persisted inventory updated:', inv)
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */
|
||||
function detectNetworkFromInvoice(invoice: string) {
|
||||
|
|
@ -510,34 +420,6 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
|
||||
const isIdle = computed(() => currentState.value === 'idle')
|
||||
|
||||
// ADR-003: the machine is sitting at the access gate. When the gate is
|
||||
// disabled this is never true (the `locked` state bypasses to `idle` on
|
||||
// start), so App.vue's LockedView branch never renders on a non-access machine.
|
||||
const isLocked = computed(() => currentState.value === 'locked')
|
||||
|
||||
/**
|
||||
* Fiat view of the loaded card's balance, the way the LNbits wallet page
|
||||
* prices it: the card server's own currency and rate first (the wallet's
|
||||
* currency, else the instance default); when it priced nothing, the ATM's
|
||||
* fiat at its display rate. Null when neither is available.
|
||||
*/
|
||||
const loadedCardFiat = computed<{ amount: number; currency: string } | null>(() => {
|
||||
const card = loadedBoltCard.value
|
||||
if (!card) return null
|
||||
if (card.currency && card.fiat !== null) return { amount: card.fiat, currency: card.currency }
|
||||
const rate = cardFiatRate.value
|
||||
if (card.currency && rate && rate.currency === card.currency) {
|
||||
return { amount: (card.balanceSats / 1e8) * rate.btcPrice, currency: card.currency }
|
||||
}
|
||||
if (btcPrice.value && btcPrice.value > 0) {
|
||||
return { amount: (card.balanceSats / 1e8) * btcPrice.value, currency: fiatCode.value }
|
||||
}
|
||||
return null
|
||||
})
|
||||
function toggleCardBalance() {
|
||||
cardBalanceRevealed.value = !cardBalanceRevealed.value
|
||||
}
|
||||
|
||||
const isCashIn = computed(() => {
|
||||
const state = snapshot.value?.value
|
||||
return typeof state === 'object' && 'cashIn' in state
|
||||
|
|
@ -565,8 +447,6 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
currency: fiatCode.value,
|
||||
cashInFeeFraction: cashInFeeFraction.value,
|
||||
cashOutFeeFraction: cashOutFeeFraction.value,
|
||||
accessControlEnabled: accessControl.value.enabled,
|
||||
accessBypassFlag,
|
||||
})
|
||||
actor.value = createActor(machine)
|
||||
|
||||
|
|
@ -577,7 +457,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
actor.value.subscribe((newSnapshot: SnapshotFrom<ATMMachine>) => {
|
||||
const prevSnapshot = snapshot.value
|
||||
snapshot.value = newSnapshot
|
||||
console.log('[ATM] State:', JSON.stringify(newSnapshot.value))
|
||||
console.log('[ATM] State:', newSnapshot.value)
|
||||
|
||||
// Detect transition into a complete state
|
||||
const state = newSnapshot.value
|
||||
|
|
@ -592,14 +472,6 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
lnurlCleanupFn()
|
||||
}
|
||||
|
||||
// Drop the tapped-at-entry Bolt Card when the session ends (machine
|
||||
// re-locks) so the next customer starts fresh — never carry a card over.
|
||||
if (state === 'locked' && loadedBoltCard.value) {
|
||||
loadedBoltCard.value = null
|
||||
cardBalanceRevealed.value = false
|
||||
cardFiatRate.value = null
|
||||
}
|
||||
|
||||
// Detect network from first invoice we see
|
||||
if (newSnapshot.context.invoice) {
|
||||
detectNetworkFromInvoice(newSnapshot.context.invoice)
|
||||
|
|
@ -633,19 +505,6 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
bills = dr.bills
|
||||
.filter((b) => b.dispensed > 0)
|
||||
.map((b) => ({ denomination: b.denomination, count: b.dispensed }))
|
||||
} else {
|
||||
// The dispenser threw, or the dispense timed out, so there is no
|
||||
// per-bay report. Bills may well have reached the customer, and
|
||||
// nothing knows how many: neither the cassette rows nor HAL's bays
|
||||
// were debited, so both now read high. Record that the counts are
|
||||
// unverified instead of letting a number we know may be wrong go
|
||||
// on being treated as fact.
|
||||
console.error(
|
||||
`[ATM] Dispense ended with no report — bay counts are unverified (txid=${ctx.txid})`
|
||||
)
|
||||
void window.electronAPI
|
||||
?.markCountsUncertain(Math.floor(Date.now() / 1000))
|
||||
.catch((e) => console.warn('[ATM] Could not flag counts unverified:', e))
|
||||
}
|
||||
|
||||
persistTransaction({
|
||||
|
|
@ -661,10 +520,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
bills,
|
||||
cassettes: dr?.cassettes,
|
||||
error: dr?.error ?? ctx.error,
|
||||
})
|
||||
.then(() => reloadPersistedInventory())
|
||||
// Republish cassette state — a partial dispense changed counts.
|
||||
.then(() => operatorConfigSvc?.publishCassettesState())
|
||||
}).then(() => reloadPersistedInventory())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -694,322 +550,18 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
bills,
|
||||
cassettes: dr?.cassettes,
|
||||
error: dr?.error,
|
||||
})
|
||||
.then(() => reloadPersistedInventory())
|
||||
// Republish cassette state after a cash-out dispense (counts
|
||||
// decremented); harmless no-op echo for a cash-in complete.
|
||||
.then(() => (isCashInTx ? undefined : operatorConfigSvc?.publishCassettesState()))
|
||||
}).then(() => reloadPersistedInventory())
|
||||
}
|
||||
}
|
||||
|
||||
// Clear Bolt Card state whenever we leave a tap screen — the cash-out
|
||||
// invoice ('displayingInvoice') or the cash-in QR ('displayingQR') — so a
|
||||
// stale "processing"/error can't linger into the next flow.
|
||||
const leftBoltCardScreen =
|
||||
(prevNestedState === 'displayingInvoice' && currentNested !== 'displayingInvoice') ||
|
||||
(prevNestedState === 'displayingQR' && currentNested !== 'displayingQR')
|
||||
if (leftBoltCardScreen) {
|
||||
// The card accepted the pull (that's what leaves processing latched
|
||||
// with an 'accepted' status) but we left the invoice screen for
|
||||
// somewhere other than the dispenser: the payment was taken and no
|
||||
// cash followed. Surface it with the txid instead of dropping the
|
||||
// customer back on the amount screen as if nothing had happened.
|
||||
if (
|
||||
prevNestedState === 'displayingInvoice' &&
|
||||
currentNested !== 'dispensingCash' &&
|
||||
boltCardProcessing.value &&
|
||||
nfcStatus.value?.state === 'accepted'
|
||||
) {
|
||||
const txid = context.value?.txid ?? null
|
||||
console.error(
|
||||
`[ATM] Settlement never confirmed after the card accepted the pull — txid=${txid}`
|
||||
)
|
||||
settlementError.value = {
|
||||
txid,
|
||||
message: 'Your payment was accepted but the cash was not dispensed.',
|
||||
}
|
||||
}
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = null
|
||||
}
|
||||
|
||||
prevNestedState = currentNested
|
||||
})
|
||||
|
||||
// Start the machine
|
||||
actor.value.start()
|
||||
setupNfcListener()
|
||||
setupCassettesChangedListener()
|
||||
console.log('[ATM] State machine initialized')
|
||||
}
|
||||
|
||||
// ── Bolt Card cash-out (NFC tap-to-pay) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* A tapped Bolt Card during the cash-out invoice screen: pull payment for
|
||||
* the shown invoice via LNURL-withdraw (main process). Settlement still
|
||||
* arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash;
|
||||
* ok here only means the card accepted the pull.
|
||||
*/
|
||||
async function handleBoltCardTap(source: BoltCardSource): Promise<BoltCardOutcome> {
|
||||
if (nestedState.value !== 'displayingInvoice') return 'skipped'
|
||||
const invoice = context.value?.invoice
|
||||
if (!invoice) return 'skipped'
|
||||
if (boltCardProcessing.value) return 'skipped' // one pull at a time
|
||||
// The card server withheld the withdraw step when this session opened, so
|
||||
// there is nothing to present. Say why instead of attempting a payment.
|
||||
if ('session' in source && !source.session.withdraw) {
|
||||
nfcStatus.value = {
|
||||
state: 'declined',
|
||||
message: source.session.withdrawBlockedReason ?? 'This card cannot sell right now',
|
||||
}
|
||||
return 'blocked'
|
||||
}
|
||||
boltCardProcessing.value = true
|
||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||
try {
|
||||
const amountMsat = (context.value?.satsAmount ?? 0) * 1000
|
||||
const api = window.electronAPI!
|
||||
const res =
|
||||
'session' in source
|
||||
? await api.withdrawWithSession({
|
||||
// Non-null: a withheld step is pre-flighted above.
|
||||
withdraw: plainWithdrawStep(source.session.withdraw!),
|
||||
bolt11: invoice,
|
||||
amountMsat,
|
||||
})
|
||||
: await api.lnurlWithdraw({ lnurlw: source.lnurlw, bolt11: invoice, amountMsat })
|
||||
if (res.ok) {
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
|
||||
return 'accepted'
|
||||
}
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' }
|
||||
return 'declined'
|
||||
} catch (e) {
|
||||
console.warn('[ATM] Bolt Card withdraw failed:', e)
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
||||
return 'declined'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tapped Bolt Card during the cash-in QR screen: RECEIVE sats to the card.
|
||||
* The card's lnurlw is only a spend voucher, so we resolve it to the card
|
||||
* wallet's lnurlp (main process), fetch an invoice for the payout, and pay it
|
||||
* over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED
|
||||
* path. Settlement + completion reuse the tested cash-in flow.
|
||||
*/
|
||||
async function handleBoltCardReceive(source: BoltCardSource): Promise<BoltCardOutcome> {
|
||||
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return 'skipped'
|
||||
const amountSats = context.value?.satsAmount ?? 0
|
||||
if (amountSats <= 0) return 'skipped'
|
||||
if (boltCardProcessing.value) return 'skipped' // one at a time
|
||||
boltCardProcessing.value = true
|
||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||
try {
|
||||
const amountMsat = amountSats * 1000
|
||||
const api = window.electronAPI!
|
||||
const res =
|
||||
'session' in source
|
||||
? await api.resolveSessionInvoice({ pay: plainPayStep(source.session.pay), amountMsat })
|
||||
: await api.resolveCardInvoice({ lnurlw: source.lnurlw, amountMsat })
|
||||
if (!res.ok || !res.bolt11) {
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
|
||||
return 'declined'
|
||||
}
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card found — sending sats…' }
|
||||
const paid = await payInvoice(res.bolt11)
|
||||
if (!paid) {
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'error', message: paymentError.value ?? 'Payment failed' }
|
||||
return 'declined'
|
||||
}
|
||||
// On success payInvoice fires PAYMENT_RECEIVED; state leaves displayingQR
|
||||
// and the subscribe-cleanup above resets nfcStatus/boltCardProcessing.
|
||||
return 'accepted'
|
||||
} catch (e) {
|
||||
console.warn('[ATM] Bolt Card receive failed:', e)
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
||||
return 'declined'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Verified
|
||||
* entry: the tap's single-use SUN is spent ONCE, on the card server's
|
||||
* `/session` (main process), which proves a genuine, non-replayed card and
|
||||
* returns the wallet balance plus the hit-keyed withdraw/pay steps. Then the
|
||||
* card's external_id is authorized locally (open-enrollment or allow-list)
|
||||
* and the session is held for the visit — Complete needs no second tap.
|
||||
*/
|
||||
async function handleBoltCardEntry(lnurlw: string) {
|
||||
if (!isLocked.value) return
|
||||
if (boltCardProcessing.value) return
|
||||
// Reject a non-card tag before spending anything or calling anyone.
|
||||
if (!parseBoltcardLnurlw(lnurlw)) {
|
||||
nfcStatus.value = { state: 'declined', message: 'Not a Bolt Card' }
|
||||
denyAccess('not a Bolt Card')
|
||||
return
|
||||
}
|
||||
if (!isElectron || !window.electronAPI?.openCardSession) {
|
||||
nfcStatus.value = { state: 'declined', message: 'Card sessions need the machine build' }
|
||||
denyAccess('card sessions need the machine build')
|
||||
return
|
||||
}
|
||||
boltCardProcessing.value = true
|
||||
nfcStatus.value = { state: 'processing', message: 'Verifying card…' }
|
||||
try {
|
||||
const t0 = Date.now()
|
||||
const opened = await window.electronAPI.openCardSession({ lnurlw })
|
||||
console.info(
|
||||
`[ATM] Card session ${opened.ok ? 'opened' : 'refused'} in ${Date.now() - t0} ms` +
|
||||
(opened.ok
|
||||
? ` (fiat ${opened.session.fiat === null ? 'not ' : ''}priced by card server; sell ` +
|
||||
(opened.session.withdraw
|
||||
? 'available)'
|
||||
: `BLOCKED: ${opened.session.withdrawBlockedReason ?? 'withdraw step withheld'})`)
|
||||
: '')
|
||||
)
|
||||
if (!opened.ok) {
|
||||
nfcStatus.value = { state: 'declined', message: opened.reason }
|
||||
denyAccess(opened.reason)
|
||||
return
|
||||
}
|
||||
const scan: AccessScan = { kind: 'boltcard', externalId: opened.session.externalId }
|
||||
const outcome = await authorize(scan, accessControl.value.allowList, {
|
||||
salt: accessControl.value.salt,
|
||||
openEnrollment: accessControl.value.openEnrollment,
|
||||
})
|
||||
if (outcome.status === 'granted') {
|
||||
loadedBoltCard.value = opened.session
|
||||
cardBalanceRevealed.value = false
|
||||
cardFiatRate.value = null
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
|
||||
grantAccess(outcome.role, outcome.credentialIdHash)
|
||||
// Price the balance in the card's currency off the unlock path.
|
||||
const { currency, fiat, externalId } = opened.session
|
||||
if (currency && fiat === null) {
|
||||
void fetchBtcPrice(currency).then((price) => {
|
||||
if (price && loadedBoltCard.value?.externalId === externalId) {
|
||||
cardFiatRate.value = { currency, btcPrice: price }
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// pin-required can't occur for card-only open-enrollment; treat as denied.
|
||||
const reason = outcome.status === 'denied' ? outcome.reason : 'card not authorized'
|
||||
nfcStatus.value = { state: 'declined', message: reason }
|
||||
denyAccess(reason, outcome.credentialIdHash)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ATM] Bolt Card session failed:', e)
|
||||
nfcStatus.value = { state: 'error', message: 'Card verification failed' }
|
||||
denyAccess('card verification failed')
|
||||
} finally {
|
||||
boltCardProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a buy/sell using the Bolt Card loaded at entry — no second tap.
|
||||
* Cash-out pulls via the stored lnurlw; cash-in resolves it to the card
|
||||
* wallet's lnurlp and pays. Reuses the tap handlers verbatim.
|
||||
*
|
||||
* The loaded session is SINGLE-SHOT: its withdraw/pay steps are keyed by one
|
||||
* server-side hit that the first use spends, so once a step has been
|
||||
* presented — accepted or declined — it can never succeed again. Drop the
|
||||
* session after the first real attempt and tell the customer to re-tap; a
|
||||
* fresh tap on the cash screen goes straight through the normal
|
||||
* tap-to-pay/receive path. A 'skipped' outcome (guard bounced it, no server
|
||||
* call) keeps the session.
|
||||
*/
|
||||
async function completeWithCard() {
|
||||
const card = loadedBoltCard.value
|
||||
if (!card) return
|
||||
const t0 = Date.now()
|
||||
let outcome: BoltCardOutcome = 'skipped'
|
||||
if (isCashOut.value) outcome = await handleBoltCardTap({ session: card })
|
||||
else if (isCashIn.value) outcome = await handleBoltCardReceive({ session: card })
|
||||
console.info(
|
||||
`[ATM] Complete with card: ${outcome} in ${Date.now() - t0} ms` +
|
||||
(outcome === 'accepted' ? '' : ` — ${nfcStatus.value?.message ?? 'no reason given'}`)
|
||||
)
|
||||
if (outcome === 'skipped') return
|
||||
// Blocked is the card server's standing answer for this visit: nothing was
|
||||
// consumed, and re-tapping cannot lift it. Keep the session loaded so the
|
||||
// chip still shows whose card it is, and skip the re-tap advice below.
|
||||
if (outcome === 'blocked') return
|
||||
loadedBoltCard.value = null
|
||||
if (outcome === 'declined') {
|
||||
const reason = nfcStatus.value?.message ?? 'Card declined'
|
||||
nfcStatus.value = {
|
||||
state: nfcStatus.value?.state ?? 'declined',
|
||||
message: `${reason} — tap your card to try again`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main process can move the bays without the renderer knowing — an
|
||||
* operator-command dispense runs entirely there, and boot seeding writes the
|
||||
* table before the store exists. Listen for that and catch up, otherwise the
|
||||
* renderer serves a stale inventory and the operator's view never updates.
|
||||
* Idempotent via preload removeAllListeners.
|
||||
*/
|
||||
function setupCassettesChangedListener() {
|
||||
if (!isElectron || !window.electronAPI?.onCassettesChanged) return
|
||||
window.electronAPI.onCassettesChanged(() => {
|
||||
console.log('[ATM] Cassettes changed in the main process — refreshing')
|
||||
void refreshAndPublishCassettes()
|
||||
})
|
||||
}
|
||||
|
||||
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
||||
function setupNfcListener() {
|
||||
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
||||
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
||||
// Route the tap by state: locked → enter + load the card; then cash-out
|
||||
// pulls, cash-in receives (fallback if no card was loaded at entry).
|
||||
if (isLocked.value) {
|
||||
void handleBoltCardEntry(lnurlw)
|
||||
} else if (isCashOut.value && nestedState.value === 'displayingInvoice') {
|
||||
void handleBoltCardTap({ lnurlw })
|
||||
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
|
||||
void handleBoltCardReceive({ lnurlw })
|
||||
}
|
||||
})
|
||||
window.electronAPI.onNfcStatus?.((status) => {
|
||||
// Surface reader status on a tap screen (locked / invoice / QR); don't
|
||||
// clobber an in-flight tap's message.
|
||||
const onTapScreen =
|
||||
isLocked.value ||
|
||||
(isCashOut.value && nestedState.value === 'displayingInvoice') ||
|
||||
(isCashIn.value && nestedState.value === 'displayingQR')
|
||||
if (onTapScreen && !boltCardProcessing.value) {
|
||||
nfcStatus.value = status
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Dev/mock: simulate a cash-out tap with a pasted lnurlw (test without a card). */
|
||||
function simulateBoltCardTap(lnurlw: string) {
|
||||
void handleBoltCardTap({ lnurlw })
|
||||
}
|
||||
|
||||
/** Dev/mock: simulate a cash-in (receive) tap with a pasted lnurlw. */
|
||||
function simulateBoltCardReceive(lnurlw: string) {
|
||||
void handleBoltCardReceive({ lnurlw })
|
||||
}
|
||||
|
||||
/** Dev/mock: simulate tapping a card at the locked screen (tap-to-enter). */
|
||||
function simulateBoltCardEntry(lnurlw: string) {
|
||||
void handleBoltCardEntry(lnurlw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Group an array of inserted bill denominations into { denomination, count } pairs.
|
||||
*/
|
||||
|
|
@ -1116,8 +668,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
}),
|
||||
getInventory: async () => {
|
||||
const fresh = await loadInventoryFromDb()
|
||||
// null == the DB could not be asked; an empty map is a real reading.
|
||||
return fresh ?? services.atmServices.getInventory()
|
||||
return Object.keys(fresh).length > 0 ? fresh : services.atmServices.getInventory()
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1125,14 +676,14 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
initialize(servicesWithInventory)
|
||||
|
||||
// Start broadcasting availability (Kind 30078) with 5-minute heartbeat
|
||||
startAvailabilityBroadcast(services.nostrClient, services.signer, machineModel.value)
|
||||
startAvailabilityBroadcast(services.nostrClient, services.identity, machineModel.value)
|
||||
|
||||
// Start operator-config consumer (aiolabs/lamassu-next#56) — subscribes
|
||||
// to kind-30078 cassette config events + publishes one-shot bootstrap
|
||||
operatorConfigSvc?.stop()
|
||||
operatorConfigSvc = await startOperatorConfigService({
|
||||
nostrClient: services.nostrClient,
|
||||
signer: services.signer,
|
||||
identity: services.identity,
|
||||
operatorPubkeys: services.operatorPubkeys,
|
||||
})
|
||||
|
||||
|
|
@ -1141,7 +692,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
operatorFeesSvc?.stop()
|
||||
operatorFeesSvc = await startOperatorFeesService({
|
||||
nostrClient: services.nostrClient,
|
||||
signer: services.signer,
|
||||
identity: services.identity,
|
||||
operatorPubkeys: services.operatorPubkeys,
|
||||
onApply: applyFeeConfig,
|
||||
})
|
||||
|
|
@ -1155,7 +706,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
useLiveServices.value = false
|
||||
initialize(mockServices)
|
||||
} else {
|
||||
initError.value = classifyInitError(error, 'Lightning initialization failed')
|
||||
initError.value = error instanceof Error ? error.message : 'Lightning initialization failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1365,8 +916,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
request,
|
||||
(amounts) => hal.atmServices.dispenseCash(amounts),
|
||||
isIdle.value,
|
||||
fiatCode.value,
|
||||
refreshAndPublishCassettes
|
||||
fiatCode.value
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -1381,8 +931,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// If DB has inventory, use it; otherwise fall back to HAL
|
||||
getInventory: async () => {
|
||||
const fresh = await loadInventoryFromDb()
|
||||
// null == the DB could not be asked; an empty map is a real reading.
|
||||
return fresh ?? hal.atmServices.getInventory()
|
||||
return Object.keys(fresh).length > 0 ? fresh : hal.atmServices.getInventory()
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1392,20 +941,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// Wire validator events to state machine
|
||||
hal.connectValidator({
|
||||
shouldAcceptBill: (denomination) => {
|
||||
// Check if accepting this bill would exceed available balance
|
||||
const ctx = context.value
|
||||
|
||||
// Fail closed: no rate/balance, or not in the accepting state →
|
||||
// return the bill (legacy _billsRead parity).
|
||||
if (
|
||||
nestedState.value !== 'insertingBills' ||
|
||||
!ctx ||
|
||||
ctx.exchangeRate <= 0 ||
|
||||
ctx.availableBalance <= 0
|
||||
) {
|
||||
console.log(
|
||||
`[ATM] Rejecting $${denomination} bill: not accepting (state/rate/balance unknown)`
|
||||
)
|
||||
return false
|
||||
if (!ctx || ctx.exchangeRate === 0) {
|
||||
console.warn('[ATM] Cannot check balance: no exchange rate')
|
||||
return true // Allow if we don't have rate yet (shouldn't happen)
|
||||
}
|
||||
|
||||
// Calculate what the new sats amount would be
|
||||
|
|
@ -1423,9 +963,6 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
return false
|
||||
}
|
||||
|
||||
// Accepting: mark the bill in flight. The HAL service issues the
|
||||
// stack command; BILL_INSERTED follows on stacked-confirmation.
|
||||
send({ type: 'BILL_PENDING', denomination })
|
||||
return true
|
||||
},
|
||||
onBillInserted: (denomination) => {
|
||||
|
|
@ -1452,13 +989,13 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
})
|
||||
|
||||
// Start broadcasting availability (Kind 30078)
|
||||
startAvailabilityBroadcast(lightning.nostrClient, lightning.signer, machineModel.value)
|
||||
startAvailabilityBroadcast(lightning.nostrClient, lightning.identity, machineModel.value)
|
||||
|
||||
// Operator-config consumer (aiolabs/lamassu-next#56)
|
||||
operatorConfigSvc?.stop()
|
||||
operatorConfigSvc = await startOperatorConfigService({
|
||||
nostrClient: lightning.nostrClient,
|
||||
signer: lightning.signer,
|
||||
identity: lightning.identity,
|
||||
operatorPubkeys: lightning.operatorPubkeys,
|
||||
})
|
||||
|
||||
|
|
@ -1466,7 +1003,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
operatorFeesSvc?.stop()
|
||||
operatorFeesSvc = await startOperatorFeesService({
|
||||
nostrClient: lightning.nostrClient,
|
||||
signer: lightning.signer,
|
||||
identity: lightning.identity,
|
||||
operatorPubkeys: lightning.operatorPubkeys,
|
||||
onApply: applyFeeConfig,
|
||||
})
|
||||
|
|
@ -1482,7 +1019,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
useLiveServices.value = false
|
||||
initialize(mockServices)
|
||||
} else {
|
||||
initError.value = classifyInitError(error, 'HAL initialization failed')
|
||||
initError.value = error instanceof Error ? error.message : 'HAL initialization failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1513,19 +1050,6 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
|
||||
fiatCode.value = runtimeFiatCode
|
||||
|
||||
// Access-control gate (ADR-003). Must be set BEFORE the machine is built
|
||||
// (initialize() reads accessControl.value to seed the `locked` state).
|
||||
if (runtimeConfig.accessControl) {
|
||||
accessControl.value = runtimeConfig.accessControl
|
||||
if (runtimeConfig.accessControl.enabled) {
|
||||
console.log(
|
||||
`[ATM] Access control ENABLED (openEnrollment=${runtimeConfig.accessControl.openEnrollment}, ` +
|
||||
`allowList=${runtimeConfig.accessControl.allowList.length} entries, ` +
|
||||
`devUnlock=${runtimeConfig.accessControl.devUnlock})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Load persisted operator fee config (aiolabs/lamassu-next#57). If no
|
||||
// config has ever been applied (fresh ATM, pre-operator-publish),
|
||||
// enter the 'awaiting-fees' maintenance state — UI shows the operator
|
||||
|
|
@ -1666,11 +1190,9 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
return await api.halDispense(amounts)
|
||||
},
|
||||
getInventory: async () => {
|
||||
// Priority: DB inventory > HAL hardware inventory > empty. Only a
|
||||
// null (unreadable) DB defers to HAL — a drained machine reports
|
||||
// drained rather than borrowing the hardware's view.
|
||||
// Priority: DB inventory > HAL hardware inventory > empty
|
||||
const fresh = await loadInventoryFromDb()
|
||||
if (fresh !== null) return fresh
|
||||
if (Object.keys(fresh).length > 0) return fresh
|
||||
// Fall back to HAL's cassette-based inventory
|
||||
try {
|
||||
const halInv = await api.halGetInventory()
|
||||
|
|
@ -1698,8 +1220,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
request,
|
||||
(amounts) => api.halDispense(amounts),
|
||||
isIdle.value,
|
||||
fiatCode.value,
|
||||
refreshAndPublishCassettes
|
||||
fiatCode.value
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -1720,22 +1241,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// Wire validator events from main process via IPC
|
||||
api.onHalBillRead((denomination) => {
|
||||
console.log('[ATM] Bill in escrow:', denomination)
|
||||
// Check if we should accept this bill
|
||||
const ctx = context.value
|
||||
|
||||
// Fail closed (legacy _billsRead parity): only stack while the
|
||||
// machine is accepting bills AND rate + balance are known.
|
||||
// Anything else returns the bill to the customer — stacking here
|
||||
// would swallow cash the machine can't (or won't) credit.
|
||||
if (
|
||||
nestedState.value !== 'insertingBills' ||
|
||||
!ctx ||
|
||||
ctx.exchangeRate <= 0 ||
|
||||
ctx.availableBalance <= 0
|
||||
) {
|
||||
console.log(
|
||||
`[ATM] Rejecting $${denomination} bill: not accepting (state=${nestedState.value}, rate=${ctx?.exchangeRate ?? 'n/a'}, balance=${ctx?.availableBalance ?? 'n/a'})`
|
||||
)
|
||||
api.halRejectBill()
|
||||
if (!ctx || ctx.exchangeRate === 0) {
|
||||
// No rate yet, accept anyway
|
||||
api.halStackBill()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1754,10 +1264,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
return
|
||||
}
|
||||
|
||||
// Accept the bill: mark it in flight, then command the stack.
|
||||
// Credit (BILL_INSERTED) arrives via onHalBillInserted once the
|
||||
// validator confirms the bill reached the stacker.
|
||||
send({ type: 'BILL_PENDING', denomination })
|
||||
// Accept the bill
|
||||
api.halStackBill()
|
||||
})
|
||||
|
||||
|
|
@ -1788,13 +1295,13 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// Real hardware connected — disable mock bill simulator
|
||||
debugMode.value = false
|
||||
// Start broadcasting availability (Kind 30078)
|
||||
startAvailabilityBroadcast(lightning.nostrClient, lightning.signer, machineModel.value)
|
||||
startAvailabilityBroadcast(lightning.nostrClient, lightning.identity, machineModel.value)
|
||||
|
||||
// Operator-config consumer (aiolabs/lamassu-next#56)
|
||||
operatorConfigSvc?.stop()
|
||||
operatorConfigSvc = await startOperatorConfigService({
|
||||
nostrClient: lightning.nostrClient,
|
||||
signer: lightning.signer,
|
||||
identity: lightning.identity,
|
||||
operatorPubkeys: lightning.operatorPubkeys,
|
||||
})
|
||||
|
||||
|
|
@ -1802,7 +1309,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
operatorFeesSvc?.stop()
|
||||
operatorFeesSvc = await startOperatorFeesService({
|
||||
nostrClient: lightning.nostrClient,
|
||||
signer: lightning.signer,
|
||||
identity: lightning.identity,
|
||||
operatorPubkeys: lightning.operatorPubkeys,
|
||||
onApply: applyFeeConfig,
|
||||
})
|
||||
|
|
@ -1823,7 +1330,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
initialize(mockServices)
|
||||
}
|
||||
} else {
|
||||
initError.value = classifyInitError(error, 'Hardware initialization failed')
|
||||
initError.value = error instanceof Error ? error.message : 'Hardware initialization failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1833,73 +1340,16 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
console.error('[ATM] Cannot send event: machine not initialized')
|
||||
return
|
||||
}
|
||||
console.log('[ATM] Sending event:', event.type, JSON.stringify(event))
|
||||
console.log('[ATM] Sending event:', event)
|
||||
actor.value.send(event)
|
||||
}
|
||||
|
||||
// === Access control (ADR-003) ===
|
||||
|
||||
/**
|
||||
* Audit stub — records an access decision. PR1 logs only (hashed id, never a
|
||||
* raw credential); a fast-follow persists to state.db and optionally a Nostr
|
||||
* event (see ADR-003).
|
||||
*/
|
||||
function recordAccessAudit(outcome: {
|
||||
result: 'granted' | 'denied'
|
||||
role?: AccessRole
|
||||
credentialIdHash: string
|
||||
reason?: string
|
||||
}) {
|
||||
console.info('[Access] audit', {
|
||||
result: outcome.result,
|
||||
role: outcome.role ?? null,
|
||||
// Truncate the hash in logs — it's already non-reversible, but no need to
|
||||
// splash the full value across the journal.
|
||||
credentialIdHash: outcome.credentialIdHash.slice(0, 12),
|
||||
reason: outcome.reason ?? null,
|
||||
at: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
/** Grant terminal access after a credential (and any PIN) is authorized. */
|
||||
function grantAccess(role: AccessRole, credentialIdHash: string) {
|
||||
recordAccessAudit({ result: 'granted', role, credentialIdHash })
|
||||
send({ type: 'ACCESS_GRANTED', role, credentialIdHash })
|
||||
}
|
||||
|
||||
/** Reject an access attempt; the machine stays locked and shows the reason. */
|
||||
function denyAccess(reason: string, credentialIdHash = '') {
|
||||
recordAccessAudit({ result: 'denied', credentialIdHash, reason })
|
||||
send({ type: 'ACCESS_DENIED', reason })
|
||||
}
|
||||
|
||||
/** Runtime dev/operator unlock (gated by the machine's devUnlockAllowed guard). */
|
||||
function devUnlock() {
|
||||
recordAccessAudit({ result: 'granted', role: 'operator', credentialIdHash: 'dev-unlock' })
|
||||
send({ type: 'DEV_UNLOCK' })
|
||||
}
|
||||
|
||||
/**
|
||||
* End the current tap-in session and re-lock immediately (drops the loaded
|
||||
* Bolt Card via `locked`'s entry). Routed through the machine's root-level
|
||||
* END_SESSION so it locks from any unlocked state. Callers: the "End session"
|
||||
* button, the idle-inactivity timer, and the absolute session cap. `reason`
|
||||
* is recorded for the access audit trail — never a raw credential. No-op
|
||||
* unless the gate is active (guarded in the machine).
|
||||
*/
|
||||
function endSession(reason: 'user' | 'inactivity' | 'session-cap' = 'user') {
|
||||
console.info(`[ATM] Ending session — reason=${reason}`)
|
||||
send({ type: 'END_SESSION' })
|
||||
}
|
||||
|
||||
// Convenience methods for common events
|
||||
function selectCashIn() {
|
||||
settlementError.value = null
|
||||
send({ type: 'SELECT_CASH_IN' })
|
||||
}
|
||||
|
||||
function selectCashOut() {
|
||||
settlementError.value = null
|
||||
send({ type: 'SELECT_CASH_OUT' })
|
||||
}
|
||||
|
||||
|
|
@ -1908,11 +1358,6 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
}
|
||||
|
||||
function insertBill(denomination: number) {
|
||||
// Dev simulator: a real validator goes escrow → stack command →
|
||||
// stacked-confirmation. Emit both halves so the simulated bill runs
|
||||
// the same guarded path (BILL_PENDING is balance-gated; a refused
|
||||
// pending drops the credit too).
|
||||
send({ type: 'BILL_PENDING', denomination })
|
||||
send({ type: 'BILL_INSERTED', denomination })
|
||||
}
|
||||
|
||||
|
|
@ -1992,7 +1437,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
/** Start broadcasting ATM availability (Kind 30078) with 5-minute heartbeat */
|
||||
let stopAvailabilityBroadcast: (() => void) | null = null
|
||||
|
||||
async function startAvailabilityBroadcast(nostrClient: any, signer: any, model: string) {
|
||||
async function startAvailabilityBroadcast(nostrClient: any, identity: any, model: string) {
|
||||
if (stopAvailabilityBroadcast) return
|
||||
|
||||
// Ensure persisted inventory is loaded before first broadcast
|
||||
|
|
@ -2000,7 +1445,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
|
||||
const { stop } = useAvailabilityBroadcast({
|
||||
nostrClient,
|
||||
signer,
|
||||
identity,
|
||||
inventory: persistedInventory,
|
||||
balanceSats,
|
||||
fiatCode: fiatCode.value,
|
||||
|
|
@ -2038,28 +1483,6 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
isCashOut,
|
||||
nestedState,
|
||||
|
||||
// Bolt Card (NFC): cash-out pulls, cash-in receives
|
||||
nfcStatus,
|
||||
boltCardProcessing,
|
||||
simulateBoltCardTap,
|
||||
simulateBoltCardReceive,
|
||||
// Tap-to-enter: card session opened at the locked screen, reused at Complete
|
||||
loadedBoltCard,
|
||||
cardBalanceRevealed,
|
||||
loadedCardFiat,
|
||||
toggleCardBalance,
|
||||
completeWithCard,
|
||||
simulateBoltCardEntry,
|
||||
|
||||
// Access control (ADR-003)
|
||||
settlementError,
|
||||
accessControl,
|
||||
isLocked,
|
||||
grantAccess,
|
||||
denyAccess,
|
||||
devUnlock,
|
||||
endSession,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
initializeWithLightning,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
/* Hide cursor completely on touchscreen kiosk.
|
||||
Scoped to .kiosk (set on <html> by main.ts) so the public web demo, which
|
||||
runs in an ordinary browser with a mouse, keeps a visible pointer. */
|
||||
.kiosk,
|
||||
.kiosk *,
|
||||
.kiosk *::before,
|
||||
.kiosk *::after {
|
||||
/* Hide cursor completely on touchscreen kiosk */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
|
|
|
|||
144
apps/machine/src/types/electron.d.ts
vendored
144
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -2,61 +2,14 @@
|
|||
* Type declarations for Electron API exposed via preload
|
||||
*/
|
||||
|
||||
import type { AllowListEntry } from '../services/access/authorize'
|
||||
|
||||
/**
|
||||
* Access-control config (ADR-003). Loaded by the main process from env +
|
||||
* an optional /var/lib/bitspire/access.json. `enabled` defaults false, so a
|
||||
* machine with no access config behaves exactly as before.
|
||||
*/
|
||||
export interface AccessControlConfig {
|
||||
/** Master switch for the badge-to-enter gate. */
|
||||
enabled: boolean
|
||||
/** Allow the runtime dev/operator unlock gesture on the locked screen. */
|
||||
devUnlock: boolean
|
||||
/** Prototype: admit any valid npub when the allow-list has no match. */
|
||||
openEnrollment: boolean
|
||||
/** Per-machine salt for hashing credentials/PINs. */
|
||||
salt: string
|
||||
/** Authorized identities (hashed). Empty in open-enrollment prototype mode. */
|
||||
allowList: AllowListEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A Bolt Card session opened at tap-to-enter (ADR-003). Wire payload from the
|
||||
* main process (electron/boltcard-session.ts) — mirrored here rather than
|
||||
* imported to avoid a cross-project electron↔renderer import. The withdraw and
|
||||
* pay steps are keyed by a single-use server-side hit; no card secret is held.
|
||||
*/
|
||||
export interface CardSession {
|
||||
externalId: string
|
||||
cardName: string
|
||||
balanceSats: number
|
||||
/** ISO currency the card server priced the balance in; null → no fiat. */
|
||||
currency: string | null
|
||||
/** Balance in `currency` at the card server's rate; null when unknown. */
|
||||
fiat: number | null
|
||||
/** LUD-03 second step, or null when the card server withheld it. */
|
||||
withdraw: {
|
||||
callback: string
|
||||
k1: string
|
||||
minWithdrawable?: number
|
||||
maxWithdrawable?: number
|
||||
} | null
|
||||
/** Why `withdraw` is null (e.g. daily limit spent); safe to show on-screen. */
|
||||
withdrawBlockedReason: string | null
|
||||
/** LUD-06 second step for topping the card wallet up. */
|
||||
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
|
||||
}
|
||||
|
||||
export type OpenCardSessionResult =
|
||||
| { ok: true; session: CardSession }
|
||||
| { ok: false; reason: string }
|
||||
|
||||
export interface RuntimeConfig {
|
||||
relayUrl: string
|
||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||
lnbitsServerPubkey: string
|
||||
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
|
||||
lightningPubPubkey?: string
|
||||
lightningPubApiUrl?: string
|
||||
extensionApiUrl?: string
|
||||
appId: string
|
||||
machineModel: string
|
||||
fiatCode: string
|
||||
|
|
@ -68,8 +21,6 @@ export interface RuntimeConfig {
|
|||
maintenanceMode: boolean
|
||||
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
|
||||
branding: BrandingConfig | null
|
||||
/** Access-control gate config (ADR-003). Always present; `enabled` defaults false. */
|
||||
accessControl: AccessControlConfig
|
||||
}
|
||||
|
||||
/** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */
|
||||
|
|
@ -88,24 +39,10 @@ export interface BrandingConfig {
|
|||
logoDarkDataUrl: string | null
|
||||
}
|
||||
|
||||
/** Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding). */
|
||||
export interface BunkerBindingRecord {
|
||||
clientSecretHex: string
|
||||
spirePubkey: string
|
||||
bunkerUrl: string
|
||||
seedFingerprint: string
|
||||
pairedAt: number
|
||||
/** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */
|
||||
relays?: string[]
|
||||
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
|
||||
lnbitsServerPubkey?: string
|
||||
}
|
||||
|
||||
export interface AtmSecrets {
|
||||
/** Spire pairing seed URL (`spire-seed:v1:…`); carries the one-shot connect token. */
|
||||
spireSeed: string
|
||||
/** Persisted bunker binding, or null when the ATM is unpaired. */
|
||||
bunkerBinding: BunkerBindingRecord | null
|
||||
atmPrivateKey: string
|
||||
/** Legacy LP admin token — retained until 3d removes the LP backend. */
|
||||
adminToken?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
@ -146,59 +83,12 @@ declare global {
|
|||
emptyCashbox: () => Promise<void>
|
||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||
getLastStatePublishedAt: () => Promise<number | null>
|
||||
/** When the bay counts became unverified (a dispense that reported nothing), or null. */
|
||||
getCountsUncertainSince: () => Promise<number | null>
|
||||
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
resetStatePublishWatermark: () => Promise<void>
|
||||
resetForRepair: () => Promise<void>
|
||||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
/** Reload the renderer to re-attempt initialization (connectivity recovery). */
|
||||
recoverApp: () => Promise<void>
|
||||
/** Bolt Card cash-out: pull payment for the current invoice from a tapped card. */
|
||||
lnurlWithdraw: (args: {
|
||||
lnurlw: string
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}) => Promise<{ ok: boolean; reason?: string }>
|
||||
/** Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay. */
|
||||
resolveCardInvoice: (args: {
|
||||
lnurlw: string
|
||||
amountMsat: number
|
||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||
/** Bolt Card tap-to-enter: open one verified session for a tapped card (spends the SUN). */
|
||||
openCardSession: (args: { lnurlw: string }) => Promise<OpenCardSessionResult>
|
||||
/** Cash-out via a session's withdraw step (no tap). */
|
||||
withdrawWithSession: (args: {
|
||||
withdraw: NonNullable<CardSession['withdraw']>
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}) => Promise<{ ok: boolean; reason?: string }>
|
||||
/** Cash-in via a session's pay step (no tap): a BOLT11 to pay. */
|
||||
resolveSessionInvoice: (args: {
|
||||
pay: CardSession['pay']
|
||||
amountMsat: number
|
||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||
applyOperatorCassetteOps: (
|
||||
ops: {
|
||||
id: string
|
||||
at: number
|
||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||
position: number
|
||||
bills?: number
|
||||
count?: number
|
||||
denomination?: number
|
||||
}[]
|
||||
) => Promise<{
|
||||
applied: string[]
|
||||
rejected: { id: string; reason: string }[]
|
||||
}>
|
||||
getAppliedOpIds: (limit?: number) => Promise<string[]>
|
||||
getCassetteStateSeq: () => Promise<number>
|
||||
getBootstrapPublishedAt: () => Promise<number | null>
|
||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||
eventCreatedAt: number
|
||||
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
||||
getFeeConfig: () => Promise<{
|
||||
cashInFeeFraction: number
|
||||
cashOutFeeFraction: number
|
||||
|
|
@ -232,14 +122,6 @@ declare global {
|
|||
onHalBillInserted: (callback: (denomination: number) => void) => void
|
||||
onHalBillRejected: (callback: (reason: string) => void) => void
|
||||
onHalError: (callback: (error: string) => void) => void
|
||||
/** The main process mutated the cassettes table; reload + republish. */
|
||||
onCassettesChanged: (callback: () => void) => void
|
||||
/** Bolt Card reader: a tapped card's lnurlw voucher. */
|
||||
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
|
||||
/** Bolt Card reader status (ready / reading / error / unavailable). */
|
||||
onNfcStatus: (
|
||||
callback: (status: { state: string; reader?: string; message?: string }) => void
|
||||
) => void
|
||||
onWatchdogPing: (callback: () => void) => void
|
||||
watchdogPong: () => Promise<void>
|
||||
platform: NodeJS.Platform
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { watch, computed, ref } from 'vue'
|
|||
import { useRouter } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CardChip from '@/components/CardChip.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
|
@ -49,9 +48,6 @@ const showCancelButton = computed(() => {
|
|||
// Invoice input for manual payment
|
||||
const invoiceInput = ref('')
|
||||
|
||||
// Dev: paste an lnurlw to simulate a Bolt Card tap-to-receive
|
||||
const mockLnurlw = ref('')
|
||||
|
||||
// Copy state for ndebit URI
|
||||
const copied = ref(false)
|
||||
|
||||
|
|
@ -246,10 +242,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
</p>
|
||||
|
||||
<!-- Status -->
|
||||
<p v-if="context?.billPending" class="text-sm lg:text-xl text-muted-foreground">
|
||||
⏳ Processing bill…
|
||||
</p>
|
||||
<p v-else-if="balanceLimitReached" class="text-sm lg:text-xl text-muted-foreground">
|
||||
<p v-if="balanceLimitReached" class="text-sm lg:text-xl text-muted-foreground">
|
||||
Maximum amount reached — press Done to continue
|
||||
</p>
|
||||
<p v-else class="text-sm lg:text-xl text-muted-foreground">
|
||||
|
|
@ -276,16 +269,11 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Done button — also blocked while a bill is between the
|
||||
stack command and the validator's stacked-confirmation
|
||||
(the machine guard drops FINISH_INSERTING regardless;
|
||||
this keeps the UI honest about it) -->
|
||||
<!-- Done button -->
|
||||
<Button
|
||||
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
||||
size="kiosk-lg"
|
||||
:disabled="
|
||||
!context || context.billsInserted.length === 0 || context.billPending !== null
|
||||
"
|
||||
:disabled="!context || context.billsInserted.length === 0"
|
||||
@click="finishInserting"
|
||||
>
|
||||
Done Inserting
|
||||
|
|
@ -337,47 +325,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents || 0) / 100).toFixed(2) }}
|
||||
</p>
|
||||
|
||||
<!-- Waiting indicator + Bolt Card tap-to-receive status -->
|
||||
<div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Waiting indicator -->
|
||||
<div class="flex items-center gap-3 pt-2 lg:pt-4">
|
||||
<PickaxeIcon :size="32" />
|
||||
<p class="text-sm lg:text-xl text-muted-foreground">
|
||||
{{
|
||||
atmStore.boltCardProcessing
|
||||
? 'Processing card…'
|
||||
: isElectron
|
||||
? 'Tap your card or scan to receive'
|
||||
: 'Waiting for wallet scan...'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="atmStore.nfcStatus?.message"
|
||||
class="text-sm lg:text-lg"
|
||||
:class="
|
||||
atmStore.nfcStatus.state === 'declined' || atmStore.nfcStatus.state === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
>
|
||||
{{ atmStore.nfcStatus.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tap-to-enter: card already loaded → one-press Complete (no re-tap) -->
|
||||
<div
|
||||
v-if="atmStore.loadedBoltCard"
|
||||
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
|
||||
>
|
||||
<CardChip class="w-full" />
|
||||
<Button
|
||||
class="w-full bg-success text-success-foreground"
|
||||
size="kiosk-lg"
|
||||
:disabled="atmStore.boltCardProcessing"
|
||||
@click="atmStore.completeWithCard()"
|
||||
>
|
||||
{{ atmStore.boltCardProcessing ? 'Completing…' : 'Complete Purchase' }}
|
||||
</Button>
|
||||
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for wallet scan...</p>
|
||||
</div>
|
||||
|
||||
<!-- LNURL URI (web-ui only) -->
|
||||
|
|
@ -396,8 +347,8 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug: simulate payment / Bolt Card tap-to-receive -->
|
||||
<div v-if="atmStore.debugMode" class="pt-2 flex flex-col items-center gap-2">
|
||||
<!-- Debug: Simulate payment button -->
|
||||
<div v-if="atmStore.debugMode" class="pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -406,21 +357,6 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
>
|
||||
Dev: Skip to Success
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="mockLnurlw"
|
||||
placeholder="lnurlw://… (paste to simulate a card tap)"
|
||||
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!mockLnurlw"
|
||||
@click="atmStore.simulateBoltCardReceive(mockLnurlw)"
|
||||
>
|
||||
Tap
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { watch, computed, ref } from 'vue'
|
|||
import { useRouter } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CardChip from '@/components/CardChip.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
|
@ -16,10 +15,6 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
|||
|
||||
const cashOutSteps = ['Select', 'Pay', 'Collect']
|
||||
|
||||
// Dev-only: paste a real card's lnurlw to exercise the Bolt Card pull without
|
||||
// the reader (single-use, so a live tap each time).
|
||||
const mockLnurlw = ref('')
|
||||
|
||||
const currentStepIndex = computed(() => {
|
||||
switch (nestedState.value) {
|
||||
case 'fetchingRate':
|
||||
|
|
@ -178,24 +173,6 @@ function formatFiat(cents: number): string {
|
|||
key="selectingAmount"
|
||||
class="flex flex-1 flex-col justify-center px-4 lg:px-[8vw] py-4 lg:py-6 gap-4 lg:gap-6"
|
||||
>
|
||||
<!-- A payment was taken and no cash came out. Say so plainly, with
|
||||
the reference an operator needs; do not let the customer wander
|
||||
back into the amount grid believing nothing happened. -->
|
||||
<div
|
||||
v-if="atmStore.settlementError"
|
||||
class="rounded-lg border-2 border-destructive bg-destructive/10 px-4 py-3 text-center lg:px-6 lg:py-4"
|
||||
>
|
||||
<p class="text-base font-bold text-destructive lg:text-2xl">
|
||||
{{ atmStore.settlementError.message }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground lg:text-lg">
|
||||
Please contact the operator<template v-if="atmStore.settlementError.txid">
|
||||
and quote
|
||||
<span class="font-mono-code">{{ atmStore.settlementError.txid }}</span> </template
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Denomination grid -->
|
||||
<div class="grid grid-cols-2 gap-3 lg:gap-6">
|
||||
<div
|
||||
|
|
@ -307,9 +284,7 @@ function formatFiat(cents: number): string {
|
|||
<div
|
||||
class="flex w-full lg:w-[52%] flex-col items-center justify-center gap-3 lg:gap-5 px-4 lg:px-[4vw] py-4 lg:py-0"
|
||||
>
|
||||
<p class="text-lg lg:text-[2rem] font-semibold text-warning">
|
||||
{{ isElectron ? 'Tap Card or Scan to Pay' : 'Scan to Pay' }}
|
||||
</p>
|
||||
<p class="text-lg lg:text-[2rem] font-semibold text-warning">Scan to Pay</p>
|
||||
<p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight">
|
||||
{{ context ? formatSats(context.satsAmount) : 0 }} sats
|
||||
</p>
|
||||
|
|
@ -320,60 +295,10 @@ function formatFiat(cents: number): string {
|
|||
</Badge>
|
||||
</p>
|
||||
|
||||
<!-- Waiting indicator + Bolt Card status -->
|
||||
<div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Waiting indicator -->
|
||||
<div class="flex items-center gap-3 pt-2 lg:pt-4">
|
||||
<PickaxeIcon :size="32" />
|
||||
<p class="text-sm lg:text-xl text-muted-foreground">
|
||||
{{
|
||||
atmStore.boltCardProcessing
|
||||
? 'Processing card…'
|
||||
: isElectron
|
||||
? 'Tap your card or scan the QR'
|
||||
: 'Waiting for payment...'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="atmStore.nfcStatus?.message"
|
||||
class="text-sm lg:text-lg"
|
||||
:class="
|
||||
atmStore.nfcStatus.state === 'declined' || atmStore.nfcStatus.state === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
>
|
||||
{{ atmStore.nfcStatus.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tap-to-enter: card already loaded → one-press Complete (no re-tap) -->
|
||||
<div
|
||||
v-if="atmStore.loadedBoltCard"
|
||||
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
|
||||
>
|
||||
<CardChip class="w-full" />
|
||||
<!-- The card server withheld this session's withdraw step (e.g.
|
||||
the card's daily limit is spent). Nothing the customer does
|
||||
here can lift it, so say so rather than offering a button
|
||||
that always fails. The QR remains as the way to get paid. -->
|
||||
<p
|
||||
v-if="!atmStore.loadedBoltCard.withdraw"
|
||||
class="w-full text-center text-sm font-medium text-destructive lg:text-lg"
|
||||
>
|
||||
{{
|
||||
atmStore.loadedBoltCard.withdrawBlockedReason ?? 'This card cannot sell right now'
|
||||
}}
|
||||
</p>
|
||||
<Button
|
||||
v-else
|
||||
class="w-full bg-success text-success-foreground"
|
||||
size="kiosk-lg"
|
||||
:disabled="atmStore.boltCardProcessing"
|
||||
@click="atmStore.completeWithCard()"
|
||||
>
|
||||
{{ atmStore.boltCardProcessing ? 'Completing…' : 'Complete Sale' }}
|
||||
</Button>
|
||||
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for payment...</p>
|
||||
</div>
|
||||
|
||||
<!-- Invoice info with copy button (web-ui only) -->
|
||||
|
|
@ -397,21 +322,6 @@ function formatFiat(cents: number): string {
|
|||
>
|
||||
Simulate Payment
|
||||
</Button>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
v-model="mockLnurlw"
|
||||
placeholder="lnurlw://… (paste to simulate a card tap)"
|
||||
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!mockLnurlw"
|
||||
@click="atmStore.simulateBoltCardTap(mockLnurlw)"
|
||||
>
|
||||
Tap
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { useBranding } from '@/composables/useBranding'
|
||||
import { initialContext } from '@bitSpire/state-machine'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CardChip from '@/components/CardChip.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import BitcoinIcon from '@/components/BitcoinIcon.vue'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
|
@ -15,8 +15,18 @@ const atmStore = useAtmStore()
|
|||
const { logoUrl, title: brandTitle } = useBranding()
|
||||
const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || ''
|
||||
const showZeusQR = ref(false)
|
||||
const showLpQR = ref(false)
|
||||
const copied = ref(false)
|
||||
|
||||
// Build nprofile for Lightning.Pub (pubkey + relay hint)
|
||||
const lpNprofile = computed(() => {
|
||||
const pubkey = import.meta.env.VITE_LIGHTNING_PUB_PUBKEY
|
||||
if (!pubkey) return ''
|
||||
const relayUrl = import.meta.env.VITE_RELAY_URL
|
||||
const relays = relayUrl ? [relayUrl.replace('ws://', 'wss://')] : []
|
||||
return nip19.nprofileEncode({ pubkey, relays })
|
||||
})
|
||||
|
||||
async function copyToClipboard(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
|
|
@ -74,12 +84,16 @@ function handleCashOut() {
|
|||
>Just Bitcoin</Badge
|
||||
>
|
||||
</div>
|
||||
<!-- Commission rates. The machine's own balance is NOT repeated here: App.vue
|
||||
already shows it in the top-right status chip, and next to the holder's
|
||||
card chip an "Available: N sats" line reads as *their* balance. -->
|
||||
<!-- Balance + Commission rates -->
|
||||
<div
|
||||
class="mt-1 flex flex-wrap items-center justify-center gap-2 lg:gap-4 text-[11px] lg:text-[2vh] text-muted-foreground"
|
||||
>
|
||||
<span v-if="atmStore.balanceSats !== null">
|
||||
Available:
|
||||
<span class="font-bold text-primary"
|
||||
>{{ atmStore.balanceSats.toLocaleString() }} sats</span
|
||||
>
|
||||
</span>
|
||||
<span>
|
||||
Buy:
|
||||
<span class="font-bold text-bitcoin"
|
||||
|
|
@ -101,8 +115,6 @@ function handleCashOut() {
|
|||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Tap-to-enter: the holder's card session, balance hidden until revealed -->
|
||||
<CardChip v-if="atmStore.loadedBoltCard" class="mt-1 w-full max-w-md" />
|
||||
</div>
|
||||
|
||||
<!-- Touch Zones -->
|
||||
|
|
@ -145,6 +157,15 @@ function handleCashOut() {
|
|||
>
|
||||
Zeus QR (lnd-alice)
|
||||
</Button>
|
||||
<Button
|
||||
v-if="lpNprofile"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-xs text-muted-foreground"
|
||||
@click="showLpQR = true"
|
||||
>
|
||||
Lightning.Pub nprofile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Zeus QR fullscreen overlay -->
|
||||
|
|
@ -172,37 +193,36 @@ function handleCashOut() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top-left utility buttons: Help, plus an End-session "✕" while the access
|
||||
gate is engaged. Kept in the left corner (not top-right) so they never
|
||||
collide with the centered balance/commission chips, which wrap into the
|
||||
top-right on narrower screens (e.g. sintra). A tap-in loads the holder's
|
||||
Bolt Card for the whole session, so the ✕ gives them an explicit way to
|
||||
re-lock the moment they're done rather than waiting out the idle timeout
|
||||
(which would leave the card usable by the next person meanwhile). -->
|
||||
<div class="absolute top-4 left-4 lg:top-8 lg:left-8 flex items-center gap-2 lg:gap-3">
|
||||
<!-- End session first (leftmost): a solid `destructive` swatch so the exit
|
||||
reads as red in every theme (--destructive is theme-scoped). Shown
|
||||
only while the access gate is engaged. -->
|
||||
<Button
|
||||
v-if="atmStore.accessControl.enabled"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full text-2xl lg:text-4xl font-bold"
|
||||
aria-label="End session"
|
||||
@click="atmStore.endSession()"
|
||||
<!-- Lightning.Pub nprofile QR fullscreen overlay -->
|
||||
<div
|
||||
v-if="showLpQR"
|
||||
class="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-black/90 p-4"
|
||||
@click.self="showLpQR = false"
|
||||
>
|
||||
✕
|
||||
<p class="text-sm text-white/70">Lightning.Pub nprofile</p>
|
||||
<div class="rounded-2xl">
|
||||
<QRCode :value="lpNprofile" :size="400" />
|
||||
</div>
|
||||
<code class="max-w-[90vw] truncate text-xs text-white/50">{{ lpNprofile }}</code>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" class="text-white" @click="copyToClipboard(lpNprofile)">
|
||||
{{ copied ? 'Copied!' : 'Copy' }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="text-white" @click="showLpQR = false">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help button (top-left) -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
|
||||
aria-label="Help"
|
||||
class="absolute top-4 left-4 lg:top-8 lg:left-8 h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
|
||||
@click="$router.push('/support')"
|
||||
>
|
||||
?
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Debug toggle (only visible when debug bar is hidden, never in production) -->
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Access gate — "tap your Bolt Card" screen (ADR-003, tap-to-enter).
|
||||
*
|
||||
* Shown when the machine is healthy but locked (App.vue's `isLocked` branch).
|
||||
* Entry is a single Bolt Card tap: the card is read by the main-process NFC
|
||||
* reader and routed to the store (`handleBoltCardEntry`) which authorizes it
|
||||
* (open-enrollment) and, on grant, loads the card into the session so buy/sell
|
||||
* only need "Complete". This view is presentation-only — it shows the prompt
|
||||
* and live reader status; the store owns the tap handling and machine events.
|
||||
*
|
||||
* Card-only by design: no camera/npub-QR, no PIN. A dev paste-box (debug builds)
|
||||
* and a dev-unlock button remain for testing without hardware.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { useBranding } from '@/composables/useBranding'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import ColorModeToggle from '@/components/ColorModeToggle.vue'
|
||||
import { Nfc } from 'lucide-vue-next'
|
||||
|
||||
const atmStore = useAtmStore()
|
||||
const { logoUrl, title } = useBranding()
|
||||
|
||||
const denyReason = computed(() => atmStore.snapshot?.context.accessDenyReason ?? null)
|
||||
const showDevUnlock = computed(() => atmStore.accessControl.devUnlock)
|
||||
const nfc = computed(() => atmStore.nfcStatus)
|
||||
const reading = computed(() => atmStore.boltCardProcessing)
|
||||
|
||||
// Dev: paste an lnurlw to simulate a tap-to-enter without a card.
|
||||
const mockLnurlw = ref('')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-1 flex-col items-center justify-center gap-10 bg-background p-8 text-foreground"
|
||||
>
|
||||
<!-- Light/dark toggle — shared kiosk-sized component -->
|
||||
<ColorModeToggle class="absolute right-4 top-4 z-10" />
|
||||
|
||||
<!-- Brand: logo + title only, colours from the active theme (branding.json) -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<img v-if="logoUrl" :src="logoUrl" alt="" class="h-[16vh] max-h-44 w-auto object-contain" />
|
||||
<h1 class="text-3xl font-bold tracking-tight lg:text-5xl">{{ title }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Tap target -->
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full border-4 border-primary bg-card shadow-xl"
|
||||
:class="reading ? 'animate-pulse' : ''"
|
||||
style="width: min(48vw, 15rem); aspect-ratio: 1 / 1"
|
||||
>
|
||||
<Nfc class="size-24 text-primary lg:size-28" />
|
||||
</div>
|
||||
|
||||
<p class="text-2xl font-semibold text-foreground lg:text-4xl">
|
||||
{{ reading ? 'Reading card…' : 'Tap your Bolt Card to begin' }}
|
||||
</p>
|
||||
|
||||
<!-- Reader status / denial reason -->
|
||||
<p v-if="denyReason" class="text-lg font-medium text-destructive lg:text-2xl">
|
||||
{{ denyReason }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="nfc?.message"
|
||||
class="text-base lg:text-xl"
|
||||
:class="
|
||||
nfc.state === 'declined' || nfc.state === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
>
|
||||
{{ nfc.message }}
|
||||
</p>
|
||||
<p v-else class="max-w-md text-center text-base text-muted-foreground lg:text-xl">
|
||||
Hold your card flat against the reader
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dev affordances -->
|
||||
<div class="mt-2 flex flex-col items-center gap-2">
|
||||
<Button
|
||||
v-if="showDevUnlock"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
|
||||
@click="atmStore.devUnlock()"
|
||||
>
|
||||
Dev unlock
|
||||
</Button>
|
||||
<div v-if="atmStore.debugMode" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="mockLnurlw"
|
||||
placeholder="lnurlw://… (paste to simulate a tap)"
|
||||
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!mockLnurlw"
|
||||
@click="atmStore.simulateBoltCardEntry(mockLnurlw)"
|
||||
>
|
||||
Tap
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { marked } from 'marked'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
|
@ -22,6 +23,35 @@ import { QrCode, ExternalLink } from 'lucide-vue-next'
|
|||
const router = useRouter()
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||
|
||||
// Lightning.Pub config loaded at runtime from Electron main process
|
||||
const lpPubkey = ref('')
|
||||
const relayUrl = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (isElectron && window.electronAPI) {
|
||||
const config = await window.electronAPI.getConfig()
|
||||
lpPubkey.value = config.lightningPubPubkey || ''
|
||||
relayUrl.value = config.relayUrl || ''
|
||||
} else {
|
||||
// Dev fallback: use Vite env vars
|
||||
lpPubkey.value = import.meta.env.VITE_LIGHTNING_PUB_PUBKEY || ''
|
||||
relayUrl.value = import.meta.env.VITE_RELAY_URL || ''
|
||||
}
|
||||
})
|
||||
|
||||
// Build nprofile for Lightning.Pub (pubkey + relay hint)
|
||||
const lpNprofile = computed(() => {
|
||||
if (!lpPubkey.value) return ''
|
||||
const relays = relayUrl.value ? [relayUrl.value.replace('ws://', 'wss://')] : []
|
||||
return nip19.nprofileEncode({ pubkey: lpPubkey.value, relays })
|
||||
})
|
||||
|
||||
// Deep link URL: opens ShockWallet with this ATM's Lightning.Pub pre-filled
|
||||
const shockwalletDeepLink = computed(() => {
|
||||
if (!lpNprofile.value) return ''
|
||||
return `https://wallet.aiolabs.dev/sources/add?nprofile=${encodeURIComponent(lpNprofile.value)}`
|
||||
})
|
||||
|
||||
interface SupportPage {
|
||||
id: string
|
||||
title: string
|
||||
|
|
@ -44,11 +74,17 @@ const defaultPages: SupportPage[] = [
|
|||
| Blink | No | Partial | Yes | Yes | https://www.blink.sv |
|
||||
| Zeus | Yes | Yes | Yes | Yes | https://zeusln.com |
|
||||
| Breez | Yes | Yes | Yes | Yes | https://breez.technology |
|
||||
| ShockWallet | No | Yes | Yes | Yes | https://shockwallet.app |
|
||||
| ShockWallet | No | Yes | Yes | Yes | [shockwallet-deep-link] |
|
||||
|
||||
Tap a QR icon to scan and download a wallet.
|
||||
|
||||
**Non-custodial** means you hold your own keys and have full control of your Bitcoin. **KYC-free** means no identity verification is required. Partial (~) means limits apply without verification.`,
|
||||
**Non-custodial** means you hold your own keys and have full control of your Bitcoin. **KYC-free** means no identity verification is required. Partial (~) means limits apply without verification.
|
||||
|
||||
## Using ShockWallet with this ATM
|
||||
|
||||
Scan the QR code below to add this ATM's Lightning node to your ShockWallet. This lets you send and receive sats directly through the ATM's payment system.
|
||||
|
||||
[lp-nprofile]`,
|
||||
},
|
||||
{
|
||||
id: 'faq',
|
||||
|
|
@ -117,6 +153,7 @@ type Segment =
|
|||
| { type: 'qr'; content: string }
|
||||
| { type: 'table'; table: ParsedTable }
|
||||
| { type: 'qr-placeholder' }
|
||||
| { type: 'lp-nprofile' }
|
||||
|
||||
/** Parse markdown table into structured data */
|
||||
function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
||||
|
|
@ -139,8 +176,17 @@ function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
|||
return { headers, rows }
|
||||
}
|
||||
|
||||
/** Resolve dynamic placeholders in markdown content */
|
||||
function resolvePlaceholders(md: string): string {
|
||||
return md.replace(
|
||||
'[shockwallet-deep-link]',
|
||||
shockwalletDeepLink.value || 'https://wallet.aiolabs.dev'
|
||||
)
|
||||
}
|
||||
|
||||
/** Parse content into segments: html, qr, or table */
|
||||
function parseContent(md: string): Segment[] {
|
||||
md = resolvePlaceholders(md)
|
||||
const segments: Segment[] = []
|
||||
const lines = md.split('\n')
|
||||
let htmlBlock = ''
|
||||
|
|
@ -189,6 +235,9 @@ function parseContent(md: string): Segment[] {
|
|||
} else if (trimmed === '[operator-qr-placeholder]') {
|
||||
flushHtml()
|
||||
segments.push({ type: 'qr-placeholder' })
|
||||
} else if (trimmed === '[lp-nprofile]') {
|
||||
flushHtml()
|
||||
segments.push({ type: 'lp-nprofile' })
|
||||
} else {
|
||||
htmlBlock += line + '\n'
|
||||
}
|
||||
|
|
@ -326,6 +375,33 @@ onUnmounted(() => {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Lightning.Pub nprofile QR (scannable by ShockWallet) -->
|
||||
<Card v-else-if="seg.type === 'lp-nprofile'" class="my-6 mx-auto max-w-xs">
|
||||
<CardContent class="flex flex-col items-center gap-3 p-6">
|
||||
<template v-if="lpNprofile">
|
||||
<div class="rounded-xl bg-white p-3">
|
||||
<QrcodeVue
|
||||
:value="lpNprofile"
|
||||
:size="180"
|
||||
level="L"
|
||||
render-as="svg"
|
||||
background="#ffffff"
|
||||
foreground="#000000"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground text-center px-2">
|
||||
Scan with ShockWallet to connect
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<QrCode class="h-16 w-16 text-muted-foreground/30" />
|
||||
<span class="text-sm text-muted-foreground/50 text-center">
|
||||
Lightning.Pub not configured
|
||||
</span>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Table with inline QR codes -->
|
||||
<div v-else-if="seg.type === 'table'" class="mb-8">
|
||||
<Table class="text-sm sm:text-base lg:text-xl w-full">
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ scp bitspire@<sintra-ip>:/var/lib/bitspire/.env ~/sintra-backup-$(date +%Y
|
|||
scp bitspire@<sintra-ip>:/var/lib/bitspire/state.db ~/sintra-backup-$(date +%Y%m%d)/
|
||||
```
|
||||
|
||||
The `.env` is the load-bearing one — it contains `VITE_SPIRE_SEED` (the NIP-46 bunker pairing seed; or the dev-only `VITE_ATM_PRIVATE_KEY` fallback) plus the LNbits / relay URLs. Note the persisted bunker binding (the ATM's transport key) lives in `state.db` once paired — so on a bunker-backed unit, keep `state.db` too or you'll need to re-pair. `state.db` also holds transaction history. Reuse these in step 7 instead of regenerating.
|
||||
The `.env` is the load-bearing one — it contains `VITE_ATM_PRIVATE_KEY` plus the LNbits / relay URLs. `state.db` is transaction history (cheap to keep, fine to drop on dev units). Reuse these in step 7 instead of regenerating.
|
||||
|
||||
Also before powering off the Sintra: make sure any unpushed commits on `dev` have been pushed AND `./deploy/push-cache.sh sintra` has run. Otherwise the next 04:00 auto-upgrade on the freshly-flashed unit will fail to substitute the new closure (or silently downgrade to whatever `origin/dev` HEAD points at).
|
||||
|
||||
|
|
@ -187,7 +187,7 @@ The `dev`-branch `flake.nix` pins the auto-upgrade source to `?ref=dev` so any A
|
|||
```nix
|
||||
system.autoUpgrade = {
|
||||
enable = true;
|
||||
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#${machineModel}-installed";
|
||||
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#${machineModel}-installed";
|
||||
dates = "04:00";
|
||||
allowReboot = false;
|
||||
};
|
||||
|
|
@ -202,7 +202,7 @@ Production ATMs on `main` continue to read `main`'s flake (no `?ref=` pin → re
|
|||
| Path | Owner | Purpose |
|
||||
|------|-------|---------|
|
||||
| `/var/lib/bitspire/` | bitspire:bitspire, 0750 | Service data directory |
|
||||
| `/var/lib/bitspire/.env` | bitspire:bitspire, 0600 | Runtime config — `VITE_RELAY_URL`, `VITE_LNBITS_SERVER_PUBKEY`, `VITE_SPIRE_SEED` (or dev `VITE_ATM_PRIVATE_KEY`), … |
|
||||
| `/var/lib/bitspire/.env` | bitspire:bitspire, 0600 | Runtime config — `VITE_RELAY_URL`, `VITE_LNBITS_SERVER_PUBKEY`, `VITE_ATM_PRIVATE_KEY`, … |
|
||||
| `/var/lib/bitspire/state.db` | bitspire:bitspire | SQLite — cassette inventory, cashbox state, transaction history |
|
||||
| `/var/lib/bitspire/logs/` | bitspire:bitspire, 0750 | Service logs (if app writes them) |
|
||||
| `/var/lib/bitspire/branding/` | bitspire:bitspire, 0755 | Operator branding override (logo.png + branding.json) — see issue #47 |
|
||||
|
|
@ -262,8 +262,8 @@ ls -la /dev/serial/by-id/
|
|||
{
|
||||
services.bitspire = {
|
||||
enable = true;
|
||||
relayUrl = ""; # seed-provided (#70); set to PIN a relay
|
||||
lnbitsServerPubkey = ""; # seed-provided (#70); set to PIN a pubkey
|
||||
relayUrl = "wss://relay.aiolabs.dev"; # ATM ↔ LNbits relay
|
||||
lnbitsServerPubkey = "<64-hex>"; # LNbits transport pubkey
|
||||
appDir = "/opt/bitspire"; # rarely overridden — defaults via flake
|
||||
dataDir = "/var/lib/bitspire"; # rarely overridden
|
||||
logLevel = "info"; # error | warn | info | debug
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"enabled": true,
|
||||
"openEnrollment": true,
|
||||
"devUnlock": false
|
||||
}
|
||||
|
|
@ -20,17 +20,18 @@ in
|
|||
|
||||
relayUrl = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
default = "wss://relay.aiolabs.dev";
|
||||
description = ''
|
||||
Optional override for the Nostr relay the ATM uses. Empty by
|
||||
default (aiolabs/bitspire#70): the relay comes from the pairing
|
||||
SEED, not from provisioning — a fresh machine boots blank, scans a
|
||||
spire-seed, and the seed's relay drives the connection. A non-empty
|
||||
value here is seeded into `/var/lib/bitspire/.env` as
|
||||
`VITE_RELAY_URL=…` and WINS over the seed (env-first precedence), so
|
||||
only set it to pin a machine to a specific relay. The renderer's
|
||||
resolution order is: `VITE_RELAY_URL` (this / .env) → the pairing
|
||||
seed's relay → a dev-only `ws://localhost:7777` fallback.
|
||||
Nostr relay URL the ATM and LNbits both subscribe to.
|
||||
|
||||
On a fresh-boot disk image this value is seeded into
|
||||
`/var/lib/bitspire/.env` as `VITE_RELAY_URL=…` (see flake.nix
|
||||
`bitspire-env` activation script). The operator can override
|
||||
the seeded value at runtime by editing `.env` directly or by
|
||||
re-running `deploy/nixos/provision-atm.sh` with a different
|
||||
`RELAY_URL`. The renderer's resolution order is:
|
||||
`/var/lib/bitspire/.env` → this NixOS default → renderer
|
||||
hardcoded fallback (`ws://localhost:7777`).
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -38,13 +39,10 @@ in
|
|||
type = types.str;
|
||||
default = "";
|
||||
description = ''
|
||||
Optional override for the LNbits nostr-transport server pubkey
|
||||
(hex, 64 chars). Empty by default (aiolabs/bitspire#70): the
|
||||
pubkey comes from the pairing SEED (the seed's `lnbits_npub`), so
|
||||
a seed-paired machine needs nothing here. A non-empty value is
|
||||
seeded into `.env` as `VITE_LNBITS_SERVER_PUBKEY=…` and WINS over
|
||||
the seed (env-first precedence) — set it only to pin a machine to
|
||||
a specific server. Mirrors `relayUrl`.
|
||||
LNbits nostr-transport server pubkey (hex, 64 chars). Published
|
||||
by the LNbits server on startup. Required for the ATM to talk
|
||||
to its wallet. Provisioned by provision-atm.sh; can be left
|
||||
empty on disk-image builds.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -143,14 +141,11 @@ in
|
|||
"d ${cfg.dataDir}/branding 0755 bitspire bitspire -"
|
||||
];
|
||||
|
||||
# Descriptive-only ATM info at /etc/bitspire/config.env. NOTE: this is NOT
|
||||
# the runtime environment — the systemd service's EnvironmentFile is
|
||||
# mkForce'd to /var/lib/bitspire/.env, and the renderer reads only VITE_*
|
||||
# vars. Relay + server pubkey are deliberately omitted here: they come from
|
||||
# the pairing seed (aiolabs/bitspire#70), and duplicating them as non-VITE
|
||||
# RELAY_URL/LNBITS_SERVER_PUBKEY only invited "looks authoritative" confusion.
|
||||
# Environment file for ATM configuration
|
||||
environment.etc."bitspire/config.env".text = ''
|
||||
# bitSpire ATM Configuration (descriptive; not the runtime env)
|
||||
# bitSpire ATM Configuration
|
||||
RELAY_URL=${cfg.relayUrl}
|
||||
LNBITS_SERVER_PUBKEY=${cfg.lnbitsServerPubkey}
|
||||
LOG_LEVEL=${cfg.logLevel}
|
||||
DATA_DIR=${cfg.dataDir}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ esac
|
|||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
echo "=== Building bitSpire ATM Live USB ISO (model: $MODEL) ==="
|
||||
echo "=== Building Lamassu ATM Live USB ISO (model: $MODEL) ==="
|
||||
echo ""
|
||||
echo "This is a pure Nix build — no local pnpm required."
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -7,16 +7,6 @@
|
|||
# System basics
|
||||
system.stateVersion = "24.05";
|
||||
|
||||
# ── Image slimming (bitspire#70 sizing) ──────────────────────────────
|
||||
# This is a single-purpose Electron kiosk; strip the desktop/multimedia
|
||||
# baggage NixOS pulls in by default so the disk image stays lean.
|
||||
# - speechd: text-to-speech (speech-dispatcher → espeak-ng → mbrola, ~1GB).
|
||||
# An ATM does not talk.
|
||||
# - documentation: man/info/NixOS manual — no one reads them on a kiosk.
|
||||
services.speechd.enable = lib.mkForce false;
|
||||
documentation.enable = false;
|
||||
documentation.nixos.enable = false;
|
||||
|
||||
# Networking
|
||||
networking = {
|
||||
hostName = "bitspire";
|
||||
|
|
@ -46,9 +36,8 @@
|
|||
};
|
||||
};
|
||||
|
||||
# Fleet default. Override per machine with timeZoneForModel in flake.nix —
|
||||
# mkDefault is what lets that override win without mkForce.
|
||||
time.timeZone = lib.mkDefault "America/Guatemala";
|
||||
# Timezone - set to your location
|
||||
time.timeZone = "America/Guatemala";
|
||||
|
||||
# Locale
|
||||
i18n.defaultLocale = "en_US.UTF-8";
|
||||
|
|
@ -132,10 +121,8 @@
|
|||
# Node.js for the application
|
||||
pkgs-unstable.nodejs_22
|
||||
|
||||
# Camera support. v4l-utils' default build drags in the whole Qt6 stack
|
||||
# for its qv4l2 GUI (~0.5GB) — we only ever use the v4l2-ctl CLI, so drop
|
||||
# the GUI.
|
||||
(v4l-utils.override { withGUI = false; })
|
||||
# Camera support
|
||||
v4l-utils
|
||||
fswebcam
|
||||
|
||||
# ATM operations
|
||||
|
|
@ -160,18 +147,6 @@
|
|||
# Auto-updates (optional - disabled by default for stability)
|
||||
# system.autoUpgrade.enable = false;
|
||||
|
||||
# Trust the Forgejo host key up front. system.autoUpgrade fetches the flake
|
||||
# over ssh AS ROOT, and a machine whose root has never connected by hand has
|
||||
# no known_hosts entry, so every nightly run dies at
|
||||
# "Host key verification failed" before it reaches authentication. batm3 did
|
||||
# exactly that, silently, from its 2026-08-06 install until 09-22 (#98): it
|
||||
# sat on its install generation for six weeks while reporting a failed unit
|
||||
# nobody was watching. sintra only ever worked because a human had ssh'd as
|
||||
# root once and accepted the key. Declaring it means a freshly flashed ATM
|
||||
# can update from first boot with no manual step.
|
||||
programs.ssh.knownHosts."git.atitlan.io".publicKey =
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMlo3f05o4+bk0+8x2VG91o9GubshOb46HmBPvND9pJx";
|
||||
|
||||
# pragma: allowlist secret
|
||||
# Ensure WireGuard private key directory exists with correct permissions
|
||||
system.activationScripts.wireguard-key = ''
|
||||
|
|
@ -182,40 +157,6 @@
|
|||
fi
|
||||
'';
|
||||
|
||||
# The tunnel is operator-provisioned: wg0.key is written per machine after
|
||||
# flashing, and until it is, `wg set … private-key` exits 1 with
|
||||
# "fopen: No such file or directory". One failed unit makes
|
||||
# switch-to-configuration exit 4, which marks the entire nightly
|
||||
# system.autoUpgrade run as failed — so an ATM that simply never had its
|
||||
# tunnel provisioned reports a broken updater for the life of the machine
|
||||
# (sintra, #98). Skip the unit when there is no key instead of failing
|
||||
# activation over an interface that was never set up; a provisioned machine
|
||||
# is unaffected. Guarded on wg0 still being declared so the live image,
|
||||
# which mkForce's the interfaces away, doesn't get a unit with no ExecStart.
|
||||
systemd.services = lib.mkIf (config.networking.wireguard.interfaces ? wg0) (
|
||||
let
|
||||
iface = config.networking.wireguard.interfaces.wg0;
|
||||
guard = { unitConfig.ConditionPathExists = "/var/lib/wireguard/wg0.key"; };
|
||||
# The module emits one unit per peer alongside the interface unit, and a
|
||||
# skipped interface is NOT a failed dependency, so the peer units still
|
||||
# run and die on "Unable to modify interface: No such device" — same
|
||||
# exit 4, different unit. Guard them too. Names come from the module's
|
||||
# own `peers.*.name` option (whose default is the escaped public key)
|
||||
# rather than re-deriving the escaping here; the `-refresh` suffix
|
||||
# follows nixpkgs' peerUnitServiceName, where a peer's null refresh
|
||||
# interval falls back to the interface's.
|
||||
refreshes = peer:
|
||||
(if peer.dynamicEndpointRefreshSeconds != null then
|
||||
peer.dynamicEndpointRefreshSeconds
|
||||
else
|
||||
iface.dynamicEndpointRefreshSeconds) != 0;
|
||||
peerUnit = peer:
|
||||
"wireguard-wg0-peer-${peer.name}" + lib.optionalString (refreshes peer) "-refresh";
|
||||
in
|
||||
{ wireguard-wg0 = guard; }
|
||||
// lib.listToAttrs (map (peer: lib.nameValuePair (peerUnit peer) guard) iface.peers)
|
||||
);
|
||||
|
||||
# In-place rename migration: lamassu user → bitspire user.
|
||||
# Runs after `users` activation so the bitspire user exists with its UID.
|
||||
# Idempotent: re-running on an already-migrated system is a chown no-op.
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Factory-reset a bitSpire ATM to a truly-fresh state — the deterministic way to
|
||||
# reproduce a brand-new machine so tests aren't masked by leftover env/db values
|
||||
# (aiolabs/bitspire#70 remnant hygiene).
|
||||
#
|
||||
# WIPES:
|
||||
# - /var/lib/bitspire/state.db (bunker binding, fee config, cassettes, cashbox,
|
||||
# transactions, operator commands, replay watermarks — recreated on next boot)
|
||||
# - /var/lib/bitspire/.env (truncated to the minimal image-baked template:
|
||||
# machine model + fiat + display; drops relay, server pubkey, operator pubkey
|
||||
# and any stored spire seed)
|
||||
#
|
||||
# After this the ATM boots UNPAIRED into the pairing wizard, exactly like a fresh
|
||||
# disk image — so a scanned seed is the sole source of truth.
|
||||
#
|
||||
# Usage:
|
||||
# bash factory-reset-atm.sh # SSH to localhost:2222 (QEMU)
|
||||
# bash factory-reset-atm.sh 192.168.1.50 # a real ATM on the LAN
|
||||
# bash factory-reset-atm.sh 192.168.1.50 22 # custom SSH port
|
||||
# FORCE=1 bash factory-reset-atm.sh … # skip the confirmation prompt
|
||||
# ATM_USER=root bash factory-reset-atm.sh … # override SSH user (default: bitspire)
|
||||
set -euo pipefail
|
||||
|
||||
ATM_HOST="${1:-localhost}"
|
||||
ATM_SSH_PORT="${2:-2222}"
|
||||
ATM_USER="${ATM_USER:-bitspire}"
|
||||
|
||||
echo "=== Factory-reset bitSpire ATM at $ATM_USER@$ATM_HOST:$ATM_SSH_PORT ==="
|
||||
echo "This WIPES state.db and truncates .env to the minimal template (keeps only"
|
||||
echo "machine model + fiat). ALL pairing, cash accounting, and transaction history"
|
||||
echo "on the ATM will be lost."
|
||||
if [ "${FORCE:-}" != "1" ]; then
|
||||
read -r -p "Type 'yes' to proceed: " confirm
|
||||
[ "$confirm" = "yes" ] || { echo "Aborted."; exit 1; }
|
||||
fi
|
||||
|
||||
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" 'sudo bash -s' <<'REMOTE'
|
||||
set -euo pipefail
|
||||
ENV=/var/lib/bitspire/.env
|
||||
DB=/var/lib/bitspire/state.db
|
||||
|
||||
# Preserve model + fiat from the existing .env (fall back to sintra/EUR).
|
||||
model=$(grep -E '^VITE_LAMASSU_MACHINE_MODEL=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
|
||||
fiat=$(grep -E '^VITE_LAMASSU_FIAT_CODE=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
|
||||
model=${model:-sintra}
|
||||
fiat=${fiat:-EUR}
|
||||
|
||||
systemctl stop bitspire 2>/dev/null || true
|
||||
|
||||
# Wipe persisted state (db + WAL/SHM sidecars).
|
||||
rm -f "$DB" "$DB-wal" "$DB-shm"
|
||||
|
||||
# Truncate .env to the minimal image-baked template.
|
||||
cat > "$ENV" <<EOF
|
||||
VITE_LAMASSU_MACHINE_MODEL=$model
|
||||
VITE_LAMASSU_FIAT_CODE=$fiat
|
||||
VITE_SPIRE_SEED=
|
||||
ELECTRON_FORCE_PROD=1
|
||||
DISPLAY=:0
|
||||
EOF
|
||||
chmod 600 "$ENV"
|
||||
chown bitspire:bitspire "$ENV" 2>/dev/null || true
|
||||
|
||||
systemctl start bitspire 2>/dev/null || true
|
||||
|
||||
echo "--- .env is now (values blanked) ---"
|
||||
sed -E 's/=.*/=/' "$ENV"
|
||||
echo "--- state.db removed (recreated fresh on next boot) ---"
|
||||
REMOTE
|
||||
|
||||
echo ""
|
||||
echo "=== ATM factory-reset. It boots UNPAIRED → the pairing wizard. ==="
|
||||
echo "Watch: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"
|
||||
|
|
@ -12,36 +12,13 @@
|
|||
timeout = 3;
|
||||
};
|
||||
|
||||
# Pin the 6.6 LTS kernel. The Dell 9030 AIO's eGalax SAW touch panel
|
||||
# (0eef:0001) works with the usbtouchscreen driver on 6.6 (the known-good
|
||||
# internal-SATA install runs 6.6.68). On 25.11's default 6.12 kernel this
|
||||
# old controller regressed: hid-multitouch grabs it and mis-parses the HID
|
||||
# report ("failed to fetch feature 7", axes read stuck), usbtouchscreen
|
||||
# refuses it, and touch is unusable regardless of udev/X config. Matching
|
||||
# douro.nix's per-hardware kernel pin. Re-test touch before bumping this.
|
||||
kernelPackages = pkgs.linuxPackages_6_6;
|
||||
|
||||
initrd.availableKernelModules = [
|
||||
"xhci_pci"
|
||||
"ahci"
|
||||
"usbhid"
|
||||
"sd_mod"
|
||||
# USB mass-storage: required to boot the dd'd image from a USB stick
|
||||
# (stage-1 must bind the flash drive as a SCSI disk so
|
||||
# /dev/disk/by-label/nixos appears). Harmless on the internal-SATA
|
||||
# install, where ahci+sd_mod already cover the root device.
|
||||
#
|
||||
# NOTE: deliberately NO "uas" here. Many USB sticks/bridges advertise
|
||||
# UAS but drop off the bus ("device offline error, dev sdb") under the
|
||||
# sustained write load of first-boot growPartition/journal/swapfile.
|
||||
# Blacklisting uas below forces the slower-but-reliable usb-storage
|
||||
# (Bulk-Only Transport) path. SATA/eMMC installs don't use uas anyway.
|
||||
"usb_storage"
|
||||
];
|
||||
|
||||
# Keep the USB flash drive off the flaky UAS driver (see note above).
|
||||
blacklistedKernelModules = [ "uas" ];
|
||||
|
||||
kernelModules = [
|
||||
"kvm-intel"
|
||||
"usbtouchscreen"
|
||||
|
|
@ -50,9 +27,6 @@
|
|||
kernelParams = [
|
||||
"quiet"
|
||||
"splash"
|
||||
# Disable USB autosuspend so the boot medium (and kiosk peripherals)
|
||||
# aren't power-suspended mid-I/O — another cause of "device offline".
|
||||
"usbcore.autosuspend=-1"
|
||||
];
|
||||
};
|
||||
|
||||
|
|
@ -85,66 +59,6 @@
|
|||
cpuFreqGovernor = "performance";
|
||||
};
|
||||
|
||||
# PC/SC daemon for the Feitian KP382 contactless reader (096e:0608, a CCID
|
||||
# smart-card reader) used for Bolt Card tap-to-pay on cash-out. Enabling it
|
||||
# binds the CCID driver to the reader; the app talks to pcscd's socket (via
|
||||
# nfc-pcsc) rather than the USB device directly. Harmless if no reader is
|
||||
# attached — pcscd just idles.
|
||||
services.pcscd.enable = true;
|
||||
|
||||
# pcscd gates client access via polkit; without a rule the sandboxed
|
||||
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
|
||||
# it to talk to the daemon and the card. The second rule lets the app trigger
|
||||
# the NFC reader wedge-recovery service (see nfc-reader-reset below).
|
||||
security.polkit.extraConfig = ''
|
||||
polkit.addRule(function(action, subject) {
|
||||
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
||||
action.id == "org.debian.pcsc-lite.access_card") &&
|
||||
subject.user == "bitspire") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
action.lookup("unit") == "nfc-reader-reset.service" &&
|
||||
subject.user == "bitspire") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
'';
|
||||
|
||||
# NFC reader wedge-recovery. The Feitian R502-CL CCID reader (and, less often,
|
||||
# any CCID reader) can wedge: it keeps detecting a card but every APDU returns
|
||||
# "card absent or mute", and ONLY a USB power-cycle clears it — restarting
|
||||
# pcscd or the app does not. This oneshot re-binds the reader's USB device (a
|
||||
# software replug); pcscd + nfc-pcsc then re-detect it on hotplug with no app
|
||||
# restart (verified on-device). The app (unprivileged `bitspire`) starts it via
|
||||
# the polkit rule above when it sees repeated read failures. Reader-agnostic:
|
||||
# it matches the USB CCID interface class (0x0B), so it also covers a future
|
||||
# ACR1252U swap without a config change.
|
||||
systemd.services.nfc-reader-reset = {
|
||||
description = "Power-cycle a wedged CCID NFC reader (USB re-bind)";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = pkgs.writeShellScript "reset-nfc-reader" ''
|
||||
set -u
|
||||
found=0
|
||||
for iface in /sys/bus/usb/devices/*:*/bInterfaceClass; do
|
||||
[ -f "$iface" ] || continue
|
||||
[ "$(${pkgs.coreutils}/bin/cat "$iface" 2>/dev/null)" = "0b" ] || continue
|
||||
ifname=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/dirname "$iface")")
|
||||
dev=''${ifname%%:*}
|
||||
echo "reset-nfc-reader: power-cycling CCID reader USB device $dev" >&2
|
||||
echo -n "$dev" > /sys/bus/usb/drivers/usb/unbind 2>/dev/null || true
|
||||
${pkgs.coreutils}/bin/sleep 2
|
||||
echo -n "$dev" > /sys/bus/usb/drivers/usb/bind 2>/dev/null || true
|
||||
found=1
|
||||
done
|
||||
[ "$found" = 1 ] || { echo "reset-nfc-reader: no CCID reader found" >&2; exit 1; }
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# Disable suspend/hibernate for kiosk
|
||||
systemd.targets = {
|
||||
sleep.enable = false;
|
||||
|
|
@ -191,20 +105,10 @@
|
|||
'';
|
||||
|
||||
# eGalax touchscreen (Dell 9030 AIO built-in panel)
|
||||
# By default usbhid/hid-multitouch claim the eGalax and mis-parse its
|
||||
# HID report descriptor (X axis reads as stuck), so touch is unusable.
|
||||
# Fix: hand the device to the usbtouchscreen kernel driver, which parses
|
||||
# the raw eGalax protocol into a clean single-touch ABS device that the
|
||||
# X evdev driver + calibration matrix (below) map correctly. This mirrors
|
||||
# the known-good internal-SATA install.
|
||||
#
|
||||
# The RUN command modprobes usbtouchscreen ITSELF before unbinding usbhid
|
||||
# and handing over via new_id. usbtouchscreen is also in boot.kernelModules
|
||||
# (systemd-modules-load), but on a USB boot systemd-udev-trigger fires this
|
||||
# rule (~2s) BEFORE modules-load gets usbtouchscreen in (~12s) — so the
|
||||
# new_id write hit a not-yet-loaded driver and the panel was left bound to
|
||||
# nothing. Loading it inline here makes the handoff independent of that
|
||||
# boot-ordering race (on internal-SATA boot the order happened to work).
|
||||
# The eGalax HID descriptor confuses libinput (treats it as touchpad).
|
||||
# Fix: unbind from usbhid at boot, bind to usbtouchscreen kernel module,
|
||||
# then apply calibration matrix after X11 starts.
|
||||
# Unbind eGalax from usbhid, bind to usbtouchscreen
|
||||
services.udev.extraRules = lib.mkAfter ''
|
||||
KERNEL=="ttyS[0-9]*", MODE="0666"
|
||||
KERNEL=="ttyUSB[0-9]*", MODE="0666"
|
||||
|
|
@ -212,32 +116,7 @@
|
|||
SUBSYSTEM=="tty", ATTRS{serial}=="DDDLb103Y23", SYMLINK+="ttyF56", MODE="0666"
|
||||
SUBSYSTEM=="tty", ATTRS{serial}=="A9YW78OC", SYMLINK+="ttyMEI", MODE="0666"
|
||||
SUBSYSTEM=="tty", ATTRS{serial}=="A9ZF8ELY", SYMLINK+="ttyNFC", MODE="0666"
|
||||
ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="0eef", ATTRS{idProduct}=="0001", RUN+="${pkgs.bash}/bin/bash -c '${pkgs.kmod}/bin/modprobe usbtouchscreen 2>/dev/null; echo ''$kernel:1.0 > /sys/bus/usb/drivers/usbhid/unbind 2>/dev/null; echo 0eef 0001 > /sys/bus/usb/drivers/usbtouchscreen/new_id 2>/dev/null'"
|
||||
# Belt-and-suspenders for touch calibration: on a slow USB boot the
|
||||
# usbtouchscreen panel can bind AFTER egalax-calibrate's poll window, which
|
||||
# leaves the panel uncalibrated and unresponsive ("dead"). (Re)start the
|
||||
# calibration the instant the eGalax input node actually appears — this is
|
||||
# device-driven, so it cannot lose a boot-timing race no matter how late the
|
||||
# driver hands over. Pairs with egalax-calibrate's own (widened) poll loop.
|
||||
ACTION=="add", SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="eGalax Inc. USB TouchController", TAG+="systemd", ENV{SYSTEMD_WANTS}+="egalax-calibrate.service"
|
||||
'';
|
||||
|
||||
# Force the X evdev driver on the eGalax (not libinput). The usbtouchscreen
|
||||
# node is a plain single-touch absolute device; evdev + the transformation
|
||||
# matrix in egalax-calibrate below give correct orientation. Mirrors the
|
||||
# working internal-SATA install's /etc/X11/xorg.conf.d/99-egalax.conf.
|
||||
environment.etc."X11/xorg.conf.d/99-egalax.conf".text = ''
|
||||
Section "InputClass"
|
||||
Identifier "eGalax Touchscreen"
|
||||
MatchVendor "0eef"
|
||||
MatchProduct "0001"
|
||||
MatchDevicePath "/dev/input/event*"
|
||||
Driver "evdev"
|
||||
Option "InvertY" "false"
|
||||
Option "InvertX" "false"
|
||||
Option "SwapAxes" "false"
|
||||
Option "Calibration" ""
|
||||
EndSection
|
||||
ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="0eef", ATTRS{idProduct}=="0001", RUN+="${pkgs.bash}/bin/bash -c 'echo ''$kernel:1.0 > /sys/bus/usb/drivers/usbhid/unbind 2>/dev/null; echo 0eef 0001 > /sys/bus/usb/drivers/usbtouchscreen/new_id 2>/dev/null'"
|
||||
'';
|
||||
|
||||
# Apply touchscreen calibration after X11 starts
|
||||
|
|
@ -251,30 +130,9 @@
|
|||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = "bitspire";
|
||||
# DISPLAY *and* XAUTHORITY — without the auth cookie xinput dies with
|
||||
# "Invalid MIT-MAGIC-COOKIE-1 key / Unable to connect to X server" and
|
||||
# the matrix is never applied, so touches register but land in the wrong
|
||||
# place (the panel then feels dead). This was the actual boot-time bug.
|
||||
Environment = [ "DISPLAY=:0" "XAUTHORITY=/home/bitspire/.Xauthority" ];
|
||||
# Wait for the eGalax X device to appear (usbtouchscreen binds a little
|
||||
# after display-manager on a USB boot) and retry, instead of a fixed
|
||||
# sleep — more robust to boot timing. 120s window: on a slow USB boot the
|
||||
# panel has bound as late as ~30-60s after display-manager, so a 30s cap
|
||||
# gave up before the device appeared and left touch dead (this service is
|
||||
# ALSO re-triggered by a udev rule when the input node shows up, so this
|
||||
# loop is the fallback, not the only path). Matrix: swap X/Y + invert +
|
||||
# scale to the active panel area (matches the known-good internal install).
|
||||
ExecStart = pkgs.writeShellScript "egalax-calibrate" ''
|
||||
for i in $(${pkgs.coreutils}/bin/seq 1 120); do
|
||||
if ${pkgs.xorg.xinput}/bin/xinput list --name-only 2>/dev/null | ${pkgs.gnugrep}/bin/grep -qx 'eGalax Inc. USB TouchController'; then
|
||||
exec ${pkgs.xorg.xinput}/bin/xinput set-prop 'eGalax Inc. USB TouchController' \
|
||||
'Coordinate Transformation Matrix' 0 -1.268 1.147 -1.224 0 1.118 0 0 1
|
||||
fi
|
||||
${pkgs.coreutils}/bin/sleep 1
|
||||
done
|
||||
echo "egalax-calibrate: eGalax device not found after 120s" >&2
|
||||
exit 1
|
||||
'';
|
||||
Environment = "DISPLAY=:0";
|
||||
ExecStartPre = "${pkgs.coreutils}/bin/sleep 3";
|
||||
ExecStart = "${pkgs.xorg.xinput}/bin/xinput set-prop 'eGalax Inc. USB TouchController' 'Coordinate Transformation Matrix' 0 -1.268 1.147 -1.224 0 1.118 0 0 1";
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
# UP Board serial peripherals — the validator / dispenser / printer wiring
|
||||
# shared by the INSTALLED configs (hardware/upboard.nix, used by both
|
||||
# tejo-installed and sintra-installed) AND the sintra live ISO (live.nix).
|
||||
# Single source of truth so the two artifacts can't drift — the earlier bug
|
||||
# was exactly this drift (the sintra live ISO lacked ftdi_sio + the ttyJ7
|
||||
# symlink, so the F56 dispenser failed while the installed image worked).
|
||||
#
|
||||
# Sintra IS a UP Board, so these are the UP Board rules; ttyS1/ttyS5 cover the
|
||||
# older UP Board / UP4000 (Tejo) dispenser nodes and ttyS4 covers the Sintra
|
||||
# (Apollo Lake) where the F56 is on the SoC MMIO UART. Only the device that
|
||||
# actually exists at runtime gets the symlink, so all three coexist safely.
|
||||
#
|
||||
# Serial port mapping:
|
||||
# ttyJ4 = Printer (Nippon NP-2511D-2)
|
||||
# ttyJ5 = Validator (iVIZION, ID003)
|
||||
# ttyJ7 = Dispenser (Fujitsu F53/F56)
|
||||
{ lib, ... }:
|
||||
|
||||
{
|
||||
boot.kernelModules = [
|
||||
"usbserial" # USB-to-serial adapters
|
||||
"ftdi_sio" # FTDI USB serial (the iVIZION validator bridge)
|
||||
"cp210x" # CP210x USB serial (alternative adapter)
|
||||
];
|
||||
|
||||
boot.kernelParams = [
|
||||
# Do NOT route the kernel console through ttyS4 on Sintra. ttyS4 is the
|
||||
# SoC's MMIO 16550A (the only real UART besides the legacy ttyS0 at I/O
|
||||
# 0x3f8) and is wired to the Fujitsu F56 dispenser's RS-232 header. Holding
|
||||
# it as console prevents userspace opening it at 9600 baud and HAL fails
|
||||
# with "Input/output error setting custom baud rate of 9600". For serial
|
||||
# debug, point console at ttyS0 instead.
|
||||
"console=tty0"
|
||||
];
|
||||
|
||||
services.udev.extraRules = lib.mkAfter ''
|
||||
# Generic serial port permissions (so the non-root HAL user can open them)
|
||||
KERNEL=="ttyS[0-9]*", MODE="0666"
|
||||
KERNEL=="ttyUSB[0-9]*", MODE="0666"
|
||||
KERNEL=="ttyACM[0-9]*", MODE="0666"
|
||||
|
||||
# Printer (ttyJ4)
|
||||
KERNELS=="1-7.2:1.0", SYMLINK+="ttyJ4"
|
||||
KERNEL=="ttyUSB0", SYMLINK+="ttyJ4"
|
||||
|
||||
# Validator (ttyJ5)
|
||||
KERNELS=="1-7.3:1.0", SYMLINK+="ttyJ5"
|
||||
KERNEL=="ttyUSB1", SYMLINK+="ttyJ5"
|
||||
|
||||
# Dispenser (ttyJ7). ttyS1/ttyS5 = older UP Board / UP4000; ttyS4 = Sintra.
|
||||
KERNEL=="ttyS1", SYMLINK+="ttyJ7"
|
||||
KERNEL=="ttyS4", SYMLINK+="ttyJ7"
|
||||
KERNEL=="ttyS5", SYMLINK+="ttyJ7"
|
||||
|
||||
# Legacy ttyAMA0 alias
|
||||
SUBSYSTEM=="tty", KERNEL=="ttyS1", SYMLINK+="ttyAMA0", GROUP="dialout"
|
||||
|
||||
# Disable USB autosuspend (prevents serial adapters from sleeping)
|
||||
ACTION=="add", SUBSYSTEM=="usb", TEST=="power/control", ATTR{power/control}="on"
|
||||
'';
|
||||
}
|
||||
|
|
@ -11,10 +11,6 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
# Serial peripherals (validator/dispenser/printer modules + udev symlinks +
|
||||
# console=tty0) are shared with the live ISO via ./upboard-serial.nix.
|
||||
imports = [ ./upboard-serial.nix ];
|
||||
|
||||
boot = {
|
||||
loader = {
|
||||
systemd-boot.enable = true;
|
||||
|
|
@ -46,12 +42,21 @@
|
|||
"kvm-intel"
|
||||
"i2c-dev"
|
||||
"spi-dev"
|
||||
# Serial modules (usbserial/ftdi_sio/cp210x) → ./upboard-serial.nix.
|
||||
"usbserial" # USB-to-serial adapters
|
||||
"ftdi_sio" # FTDI USB serial
|
||||
"cp210x" # CP210x USB serial
|
||||
];
|
||||
|
||||
kernelParams = [
|
||||
"i915.enable_psr=0"
|
||||
# console=tty0 (keeps ttyS4 free for the F56) → ./upboard-serial.nix.
|
||||
# NOTE: do NOT route the kernel console through ttyS4 on Sintra.
|
||||
# ttyS4 is the SoC's MMIO 16550A (the only real UART besides the
|
||||
# legacy ttyS0 at I/O 0x3f8) and is wired to the Fujitsu F56
|
||||
# dispenser's RS-232 header on Sintra. Holding it as console
|
||||
# prevents userspace from opening it at 9600 baud and HAL fails
|
||||
# with "Input/output error setting custom baud rate of 9600".
|
||||
# If you want serial debug, point console at ttyS0 instead.
|
||||
"console=tty0"
|
||||
"quiet"
|
||||
"splash"
|
||||
];
|
||||
|
|
@ -91,27 +96,6 @@
|
|||
cpuFreqGovernor = "performance";
|
||||
};
|
||||
|
||||
# PC/SC daemon for the HID Global OMNIKEY 5022 contactless reader
|
||||
# (076b:5022, a CCID smart-card reader) used for Bolt Card tap-to-enter
|
||||
# (ADR-003). pcscd binds the CCID driver; the app talks to pcscd's socket
|
||||
# (via nfc-pcsc) rather than the USB device directly. Device-agnostic —
|
||||
# same wiring as batm3's Feitian KP382; harmless if no reader is attached,
|
||||
# pcscd just idles. Shared by every upboard machine (sintra, tejo).
|
||||
services.pcscd.enable = true;
|
||||
|
||||
# pcscd gates client access via polkit; without a rule the sandboxed
|
||||
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
|
||||
# it to talk to the daemon and the card.
|
||||
security.polkit.extraConfig = ''
|
||||
polkit.addRule(function(action, subject) {
|
||||
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
||||
action.id == "org.debian.pcsc-lite.access_card") &&
|
||||
subject.user == "bitspire") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
'';
|
||||
|
||||
# Disable suspend/hibernate for kiosk
|
||||
systemd.targets = {
|
||||
sleep.enable = false;
|
||||
|
|
@ -120,9 +104,37 @@
|
|||
hybrid-sleep.enable = false;
|
||||
};
|
||||
|
||||
# Camera + LED/SPI peripherals. The serial rules (validator/dispenser/printer
|
||||
# symlinks + permissions) are shared with the live ISO in ./upboard-serial.nix.
|
||||
# Serial port permissions + tejo-specific symlinks
|
||||
services.udev.extraRules = lib.mkAfter ''
|
||||
# Generic serial port permissions
|
||||
KERNEL=="ttyS[0-9]*", MODE="0666"
|
||||
KERNEL=="ttyUSB[0-9]*", MODE="0666"
|
||||
KERNEL=="ttyACM[0-9]*", MODE="0666"
|
||||
|
||||
# ── Tejo serial port symlinks ──────────────────────────────────────
|
||||
# Both UP Board and UP4000 rules included (match different kernel paths)
|
||||
|
||||
# Printer (ttyJ4)
|
||||
KERNELS=="1-7.2:1.0", SYMLINK+="ttyJ4"
|
||||
KERNEL=="ttyUSB0", SYMLINK+="ttyJ4"
|
||||
|
||||
# Validator (ttyJ5)
|
||||
KERNELS=="1-7.3:1.0", SYMLINK+="ttyJ5"
|
||||
KERNEL=="ttyUSB1", SYMLINK+="ttyJ5"
|
||||
|
||||
# Dispenser (ttyJ7).
|
||||
# ttyS1 / ttyS5 cover earlier UP Board variants where the dispenser
|
||||
# lands on those kernel-enumerated serial nodes; ttyS4 covers the
|
||||
# Sintra (UP Board Atom/Apollo Lake) where the dispenser is wired
|
||||
# to the SoC's MMIO UART. Whichever device actually exists at
|
||||
# runtime gets the ttyJ7 symlink.
|
||||
KERNEL=="ttyS1", SYMLINK+="ttyJ7"
|
||||
KERNEL=="ttyS4", SYMLINK+="ttyJ7"
|
||||
KERNEL=="ttyS5", SYMLINK+="ttyJ7"
|
||||
|
||||
# Legacy ttyAMA0 alias
|
||||
SUBSYSTEM=="tty", KERNEL=="ttyS1", SYMLINK+="ttyAMA0", GROUP="dialout"
|
||||
|
||||
# ── Camera devices ─────────────────────────────────────────────────
|
||||
SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-5", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan"
|
||||
SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-2", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan"
|
||||
|
|
@ -135,5 +147,8 @@
|
|||
SUBSYSTEM=="spidev", GROUP="spi", MODE="0660"
|
||||
SUBSYSTEM=="i2c-dev", GROUP="i2c", MODE="0660"
|
||||
SUBSYSTEM=="leds", KERNEL=="upboard:*", ACTION=="add|change", RUN+="${pkgs.findutils}/bin/find /sys$devpath -type f -exec ${pkgs.coreutils}/bin/chmod g+u {} + -exec ${pkgs.coreutils}/bin/chown :leds {} +"
|
||||
|
||||
# Disable USB autosuspend (prevents serial adapters from sleeping)
|
||||
ACTION=="add", SUBSYSTEM=="usb", TEST=="power/control", ATTR{power/control}="on"
|
||||
'';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# bitSpire ATM Live USB Configuration
|
||||
# Lamassu ATM Live USB Configuration
|
||||
# Bootable ISO for testing on physical hardware without installing to disk.
|
||||
#
|
||||
# Parameterized by machineModel (passed via specialArgs from flake.nix):
|
||||
|
|
@ -21,17 +21,19 @@ let
|
|||
batm3 = "USD";
|
||||
}.${machineModel} or "USD";
|
||||
|
||||
# Minimal .env template (aiolabs/bitspire#70 remnant hygiene). Seed ONLY
|
||||
# image-baked, non-maskable values. Relay + server pubkey come from the pairing
|
||||
# SEED, operator pubkey + fee config come from LNbits over the transport — so we
|
||||
# deliberately do NOT pre-seed those keys (a present-but-empty VITE_RELAY_URL /
|
||||
# VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS would win over the seed and
|
||||
# mask its source). VITE_SPIRE_SEED is written by the wizard / provision-atm.sh;
|
||||
# the dev-only VITE_ATM_PRIVATE_KEY fallback is omitted on purpose.
|
||||
# .env template — runtime secrets are provisioned later via provision-atm.sh.
|
||||
# Only non-secret defaults and display vars go here.
|
||||
envTemplate = pkgs.writeText "bitspire-env" ''
|
||||
VITE_RELAY_URL=
|
||||
VITE_LIGHTNING_PUB_PUBKEY=
|
||||
VITE_LIGHTNING_PUB_API_URL=
|
||||
VITE_ADMIN_TOKEN=
|
||||
VITE_ATM_PRIVATE_KEY=
|
||||
VITE_EXTENSION_API_URL=
|
||||
VITE_APP_ID=
|
||||
VITE_LNDCONNECT_URL=
|
||||
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
||||
VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel}
|
||||
VITE_SPIRE_SEED=
|
||||
ELECTRON_FORCE_PROD=1
|
||||
DISPLAY=:0
|
||||
'';
|
||||
|
|
@ -47,24 +49,13 @@ in
|
|||
|
||||
# Reuse ATM systemd service module
|
||||
./bitspire-atm.nix
|
||||
]
|
||||
# Sintra: share the UP Board serial hardware (validator/dispenser/printer
|
||||
# modules + udev symlinks + console=tty0) with the installed image so the
|
||||
# live ISO drives the same hardware. Safe to import here — unlike upboard.nix
|
||||
# it declares no fileSystems, so there's no live-boot mount conflict.
|
||||
++ lib.optionals (machineModel == "sintra") [ ./hardware/upboard-serial.nix ];
|
||||
];
|
||||
|
||||
# ISO image settings
|
||||
image.fileName = "bitspire-${machineModel}-live.iso";
|
||||
isoImage = {
|
||||
makeEfiBootable = true;
|
||||
makeBiosBootable = true;
|
||||
# Apply the isohybrid MBR + GPT/ESP so the image boots when dd'd to a USB
|
||||
# stick — not just from optical media via El Torito. Without this the ISO
|
||||
# has BIOS+UEFI El Torito boot catalogs but no partition table, and picky
|
||||
# firmware (e.g. the Sintra's Aaeon UP Board) won't recognise the USB as
|
||||
# bootable. Requires makeBiosBootable (isohdpfx.bin), set above.
|
||||
makeUsbBootable = true;
|
||||
squashfsCompression = "zstd -Xcompression-level 6";
|
||||
};
|
||||
|
||||
|
|
@ -176,15 +167,8 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# Install the .env on first boot. Attrset form with deps=["users"] so the
|
||||
# chown runs AFTER the bitspire user is created. Otherwise on a fresh live
|
||||
# boot (where /var/lib/bitspire/.env doesn't exist yet) the chown runs in the
|
||||
# default activation order — before `users` — and fails with
|
||||
# "chown: invalid user: 'bitspire:bitspire'". The installed system skips this
|
||||
# block because its .env already exists, which is why only live boots tripped.
|
||||
system.activationScripts.bitspire-env = {
|
||||
deps = [ "users" ];
|
||||
text = ''
|
||||
# Install the .env on first boot
|
||||
system.activationScripts.bitspire-env = ''
|
||||
mkdir -p /var/lib/bitspire
|
||||
if [ ! -f /var/lib/bitspire/.env ]; then
|
||||
cp ${envTemplate} /var/lib/bitspire/.env
|
||||
|
|
@ -192,13 +176,10 @@ in
|
|||
chown bitspire:bitspire /var/lib/bitspire/.env
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# Reset display output after X starts (required for kexec boots where
|
||||
# the GPU wasn't reinitialized by BIOS firmware). Only the eDP-panel models
|
||||
# (Douro/Tejo) have an eDP-1 output; the Sintra drives HDMI-1, so the
|
||||
# `xrandr --output eDP-1` here just errors out — skip it there.
|
||||
systemd.services.display-reset = lib.mkIf (machineModel != "sintra") {
|
||||
# the GPU wasn't reinitialized by BIOS firmware)
|
||||
systemd.services.display-reset = {
|
||||
description = "Reset eDP display output";
|
||||
after = [ "display-manager.service" ];
|
||||
requires = [ "display-manager.service" ];
|
||||
|
|
@ -212,17 +193,12 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# Low-RAM models (Douro/Tejo, 2GB) need a swap cushion or they hard-freeze
|
||||
# under memory pressure. The live system is RAM-rooted, so a /var/swapfile
|
||||
# lives in tmpfs — pointless, and its init fails on a fresh boot. Use
|
||||
# compressed RAM swap (zram) instead; no on-disk file required.
|
||||
zramSwap.enable = true;
|
||||
|
||||
# The wg0 VPN tunnel (declared in configuration.nix) needs a provisioned key
|
||||
# at /var/lib/wireguard/wg0.key, which a fresh live boot doesn't have — it
|
||||
# fails and drags network-setup down with it. A live test image doesn't need
|
||||
# the VPN, so drop the interface entirely.
|
||||
networking.wireguard.interfaces = lib.mkForce { };
|
||||
# Swap file — Douro/Tejo have only 2GB RAM; without swap the system
|
||||
# hard-freezes under memory pressure instead of gracefully OOM-killing.
|
||||
swapDevices = [{
|
||||
device = "/var/swapfile";
|
||||
size = 1024; # MB
|
||||
}];
|
||||
|
||||
# Clean /tmp on boot to prevent stale Nix build artifacts from filling disk
|
||||
boot.tmp.cleanOnBoot = true;
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Provision the access-control gate (ADR-003) to a deployed bitSpire ATM.
|
||||
# Pushes an access.json to /var/lib/bitspire/ and restarts the service, so the
|
||||
# gate can be toggled on a machine without an image rebuild (mirrors
|
||||
# provision-branding.sh). Env defaults are overridden by whatever this file sets.
|
||||
#
|
||||
# Usage:
|
||||
# bash provision-access.sh <access.json> # SSH to localhost:2222 (QEMU)
|
||||
# bash provision-access.sh <access.json> 192.168.1.50 # a real ATM on the LAN
|
||||
# bash provision-access.sh <access.json> 192.168.1.50 22 # custom SSH port
|
||||
#
|
||||
# access.json schema (all keys optional; omitted keys fall back to env/defaults):
|
||||
# {
|
||||
# "enabled": true, // master switch for the gate
|
||||
# "openEnrollment": true, // admit any Bolt Card (gate is not a security boundary)
|
||||
# "devUnlock": false, // on-screen dev/operator unlock (bypasses the gate; default off)
|
||||
# "salt": "per-machine", // hashing salt (provision a real one for prod)
|
||||
# "allowList": [ // authorized identities (hashed); empty in open mode
|
||||
# { "idHash": "<hashId(external_id,salt)>", "role": "user", "pinHash": "<hashPin(pin,salt)>" }
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# To DISABLE the gate again: push a file with {"enabled": false} (or delete
|
||||
# /var/lib/bitspire/access.json on the machine) and restart.
|
||||
set -euo pipefail
|
||||
|
||||
ACCESS_FILE="${1:-}"
|
||||
ATM_HOST="${2:-localhost}"
|
||||
ATM_SSH_PORT="${3:-2222}"
|
||||
ATM_USER="bitspire"
|
||||
REMOTE_FILE="/var/lib/bitspire/access.json"
|
||||
|
||||
if [ -z "$ACCESS_FILE" ]; then
|
||||
echo "Usage: $0 <access.json> [host] [port]" >&2
|
||||
echo " $0 ./access.json (QEMU on localhost:2222)" >&2
|
||||
echo " $0 ./access.json 192.168.1.50 (real ATM)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$ACCESS_FILE" ]; then
|
||||
echo "ERROR: access file not found: $ACCESS_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail fast on malformed JSON before touching the machine.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq empty "$ACCESS_FILE" || { echo "ERROR: $ACCESS_FILE is not valid JSON" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
echo "=== Provisioning access gate to $ATM_HOST:$ATM_SSH_PORT ==="
|
||||
echo "Local file : $ACCESS_FILE"
|
||||
echo "Remote file: $REMOTE_FILE"
|
||||
cat "$ACCESS_FILE"
|
||||
echo ""
|
||||
|
||||
# Copy over SSH. --rsync-path=sudo because /var/lib/bitspire is owned by the
|
||||
# bitspire service user, not the SSH user.
|
||||
rsync -avz \
|
||||
--rsync-path="sudo rsync" \
|
||||
-e "ssh -o StrictHostKeyChecking=no -p $ATM_SSH_PORT" \
|
||||
"$ACCESS_FILE" \
|
||||
"$ATM_USER@$ATM_HOST:$REMOTE_FILE"
|
||||
|
||||
# Restart so loadAccessControl() re-reads the file.
|
||||
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" \
|
||||
"sudo systemctl restart bitspire"
|
||||
|
||||
echo ""
|
||||
echo "=== Access gate provisioned. Service restarted. ==="
|
||||
|
|
@ -4,25 +4,16 @@
|
|||
# kind-21000 NIP-44 v2 events on a relay — there is no out-of-band token,
|
||||
# the ATM's nostr private key IS the credential. # pragma: allowlist secret
|
||||
#
|
||||
# The primary input is SPIRE_SEED — the pairing seed carries the relay, the
|
||||
# LNbits server pubkey AND the signing identity, so a seed-provisioned machine
|
||||
# needs nothing else (aiolabs/bitspire#70).
|
||||
#
|
||||
# Environment variables:
|
||||
# SPIRE_SEED RECOMMENDED. The spire pairing seed
|
||||
# (`spire-seed:v1:<base64url>`) minted by spirekeeper.
|
||||
# Carries relay + LNbits server pubkey + the production
|
||||
# identity under the NIP-46 bunker (aiolabs/bitspire#52 / #70).
|
||||
# RELAY_URL OPTIONAL override — pins VITE_RELAY_URL and WINS over the
|
||||
# seed's relay (env-first precedence). Leave unset to let the
|
||||
# seed drive it. Required only on the no-seed dev path
|
||||
# (default there: ws://$HOST_IP:5001/nostrrelay/test).
|
||||
# LNBITS_SERVER_PUBKEY OPTIONAL override (hex). Leave unset with a seed. On the
|
||||
# no-seed dev path it's scraped from
|
||||
# `docker logs lnbits | grep 'nostr_transport pubkey'`.
|
||||
# ATM_PRIVATE_KEY DEV-ONLY 32-byte hex nsec fallback, used only when
|
||||
# SPIRE_SEED is unset (no bunker). Generated if unset
|
||||
# AND no SPIRE_SEED is provided.
|
||||
# Required environment variables (or edit defaults below):
|
||||
# LNBITS_SERVER_PUBKEY Hex pubkey published by the LNbits server at startup.
|
||||
# From the LNbits compose:
|
||||
# docker logs lnbits | grep 'nostr_transport pubkey'
|
||||
# LNBITS_HTTP_URL Origin LNbits is reachable at over HTTP, used only
|
||||
# to compose the LNURL-withdraw callback URL that
|
||||
# customer wallets dereference. Default: http://10.0.2.2:5000
|
||||
# RELAY_URL Nostr relay LNbits subscribes on. Default uses host gateway.
|
||||
# ATM_PRIVATE_KEY 32-byte hex key, ATM's nostr identity. If unset, a
|
||||
# fresh key is generated and saved in the .env.
|
||||
#
|
||||
# Usage:
|
||||
# bash provision-atm.sh # defaults: SSH to localhost:2222 (QEMU)
|
||||
|
|
@ -64,58 +55,33 @@ else
|
|||
echo "--- LAN ATM: using $HOST_IP as dev machine address ---"
|
||||
fi
|
||||
|
||||
# Steps 2-4: transport config (relay + LNbits server pubkey) + signing identity.
|
||||
#
|
||||
# Under aiolabs/bitspire#70 the relay + server pubkey come from the pairing SEED,
|
||||
# so a seed-provisioned machine needs NEITHER in .env. We only pin them when the
|
||||
# operator EXPLICITLY passes RELAY_URL / LNBITS_SERVER_PUBKEY (a deliberate
|
||||
# override that WINS over the seed via env-first precedence), or when there is no
|
||||
# seed (the dev-nsec fallback has nothing else to supply them, so we scrape/default).
|
||||
TRANSPORT_LINES=""
|
||||
|
||||
if [ -n "${SPIRE_SEED:-}" ]; then
|
||||
case "$SPIRE_SEED" in
|
||||
spire-seed:v1:*) : ;;
|
||||
*) echo "ERROR: SPIRE_SEED must start with 'spire-seed:v1:'"; exit 1 ;;
|
||||
esac
|
||||
echo ""
|
||||
echo "--- Spire pairing seed: relay + LNbits pubkey come from the seed ---"
|
||||
if [ -n "${RELAY_URL:-}" ]; then
|
||||
echo " (pinning VITE_RELAY_URL=$RELAY_URL — overrides the seed's relay)"
|
||||
TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL"
|
||||
fi
|
||||
if [ -n "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
||||
TRANSPORT_LINES="${TRANSPORT_LINES:+$TRANSPORT_LINES
|
||||
}VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY"
|
||||
fi
|
||||
IDENTITY_LINES="# Spire pairing seed — bunker-backed identity (aiolabs/bitspire#52)
|
||||
VITE_SPIRE_SEED=$SPIRE_SEED"
|
||||
else
|
||||
# No seed → DEV-ONLY nsec fallback. Nothing else supplies the relay + pubkey,
|
||||
# so scrape/default them.
|
||||
# Step 2: Resolve the LNbits server pubkey. Prefer the env override; else
|
||||
# fall back to scraping the local docker compose stack.
|
||||
if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
||||
echo ""
|
||||
echo "--- No seed: extracting LNbits nostr-transport pubkey from docker logs ---"
|
||||
echo "--- Step 1: Extracting LNbits nostr-transport pubkey from docker logs ---"
|
||||
LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \
|
||||
| grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \
|
||||
| tail -1 || true)
|
||||
if [ -z "$LNBITS_SERVER_PUBKEY" ]; then
|
||||
echo "ERROR: no SPIRE_SEED, and could not extract the LNbits pubkey."
|
||||
echo "Provide a SPIRE_SEED (recommended — the seed carries relay + pubkey),"
|
||||
echo "or set LNBITS_SERVER_PUBKEY explicitly."
|
||||
echo "ERROR: Could not extract LNbits pubkey. Set LNBITS_SERVER_PUBKEY explicitly"
|
||||
echo "or start the LNbits stack first (docker compose -f docker/docker-compose.dev.yml up lnbits)."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
RELAY_URL="${RELAY_URL:-ws://$HOST_IP:5001/nostrrelay/test}"
|
||||
TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL
|
||||
VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY"
|
||||
echo "LNbits server pubkey: ${LNBITS_SERVER_PUBKEY:0:16}..."
|
||||
|
||||
# Step 3: Pin LNbits HTTP origin.
|
||||
LNBITS_HTTP_URL="${LNBITS_HTTP_URL:-http://$HOST_IP:5000}"
|
||||
|
||||
# Step 4: Relay URL.
|
||||
RELAY_URL="${RELAY_URL:-ws://$HOST_IP:7777}"
|
||||
|
||||
# Step 5: ATM identity. Generate if unset.
|
||||
if [ -z "${ATM_PRIVATE_KEY:-}" ]; then
|
||||
ATM_PRIVATE_KEY=$(openssl rand -hex 32)
|
||||
echo ""
|
||||
echo "--- No SPIRE_SEED; generated a DEV-ONLY ATM_PRIVATE_KEY (no bunker) ---"
|
||||
fi
|
||||
IDENTITY_LINES="# DEV-ONLY local nsec (no bunker pairing) # pragma: allowlist secret
|
||||
VITE_ATM_PRIVATE_KEY=$ATM_PRIVATE_KEY"
|
||||
echo "--- Generated fresh ATM_PRIVATE_KEY (save this if you want it persisted) ---"
|
||||
fi
|
||||
|
||||
# Step 6: Write .env to the ATM via SSH.
|
||||
|
|
@ -124,12 +90,13 @@ echo "--- Step 2: Writing .env to ATM ---"
|
|||
ENV_CONTENT="# bitSpire Configuration
|
||||
# Auto-generated by provision-atm.sh on $(date -Iseconds)
|
||||
|
||||
# LNbits nostr-transport. Relay + server pubkey come from the pairing seed
|
||||
# (aiolabs/bitspire#70); present below only as an explicit override or the
|
||||
# no-seed dev fallback.
|
||||
$TRANSPORT_LINES
|
||||
# LNbits nostr-transport connection
|
||||
VITE_RELAY_URL=$RELAY_URL
|
||||
VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY
|
||||
VITE_LNBITS_HTTP_URL=$LNBITS_HTTP_URL
|
||||
|
||||
$IDENTITY_LINES
|
||||
# ATM identity (signing key IS the credential under nostr-transport)
|
||||
VITE_ATM_PRIVATE_KEY=$ATM_PRIVATE_KEY
|
||||
|
||||
# Machine configuration
|
||||
VITE_LAMASSU_MACHINE_MODEL=$MODEL
|
||||
|
|
@ -146,6 +113,6 @@ echo ""
|
|||
echo "=== ATM provisioned successfully ==="
|
||||
echo ""
|
||||
echo "Credentials written to /var/lib/bitspire/.env"
|
||||
echo "ATM service restarted. Relay: ${RELAY_URL:-from the pairing seed}."
|
||||
echo "ATM service restarted. It should connect to LNbits via relay $RELAY_URL."
|
||||
echo ""
|
||||
echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# bitSpire ATM Hardware udev Rules
|
||||
# Lamassu ATM Hardware udev Rules
|
||||
# Place in /etc/udev/rules.d/ or use services.udev.extraRules in NixOS
|
||||
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -1,144 +0,0 @@
|
|||
# ADR-002: Remote Access & Fleet Management — Three Planes, Operator-Owned Access via NetBird
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-14
|
||||
**Context:** Multi-operator bitSpire fleet — separating the payment, control, and recovery planes by who owns them.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Separate three planes by trust owner**, and never conflate them:
|
||||
- **Payment plane** — ATM ↔ LNbits over the nostr-native-transport. Owned by the **SaaS operator**. Implies *no* machine access.
|
||||
- **Fleet control plane** — routine ops/telemetry/enrollment over Nostr (see [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42)). Authorized by the **machine operator's** key.
|
||||
- **Access / recovery plane** — SSH for the unanticipated and the broken. Owned by the **machine operator**.
|
||||
|
||||
2. **The machine operator's own recovery access is provisioned at install and is app-independent.** Their SSH key and their VPN/NetBird enrollment are established when the machine is set up, so they can always reach a box even when the bitSpire app or OS is broken. Access for *anyone else* is runtime-granted, scoped, and revocable — never the owner's own path.
|
||||
|
||||
3. **Adopt NetBird as the standard access/recovery plane**, chosen for fleet scale and a **self-hostable, fully FOSS control plane**. The platform may provide a default NetBird setup as a convenience; a machine operator who does not wish to trust whoever runs that control plane **disables it and provisions their own access plane** (self-hosted NetBird, or their own WireGuard hub).
|
||||
|
||||
4. **We will NOT build a "revoke SaaS-operator access" toggle in the operator dashboard.** It is a false promise of security: the SaaS operator runs LNbits (and, in the default deployment, the NetBird control plane), so a toggle they ultimately control cannot protect a machine operator against them. The honest boundary is **exclusion-by-ownership, not exclusion-by-toggle** — an operator who wants to exclude the SaaS operator takes ownership of the access plane.
|
||||
|
||||
## Context
|
||||
|
||||
### The players
|
||||
|
||||
A deployed bitSpire machine sits between two distinct principals:
|
||||
|
||||
- **SaaS operator** — runs the LNbits instance and provides the Lightning backend as a service.
|
||||
- **Machine operator** — owns the physical ATM(s) and is identified by a Nostr key (the operator pubkey in the [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) allow-list).
|
||||
|
||||
These are different parties with different interests. A machine operator will want to SSH to their own machine for support and recovery, and **may or may not want to grant the SaaS operator that same access.**
|
||||
|
||||
### Why the SaaS operator needs zero box access by design
|
||||
|
||||
The whole nostr-native architecture (no admin tokens on the kiosk, no inbound network surface, payment over Nostr) means the SaaS operator can deliver the full service **without ever touching the machine**. So "the machine operator may refuse the SaaS operator access" is not a constraint to engineer around — it is the **default that costs nothing**. SaaS-operator box access is a *support convenience*, never a service requirement. The natural posture is therefore **default-deny for the SaaS operator**.
|
||||
|
||||
### Why SSH can't be replaced by the Nostr control plane
|
||||
|
||||
The Nostr control plane (#42) is a fixed menu of structured, capability-scoped commands dispatched by a handler *inside the app*. It is excellent for routine, auditable, fleet-wide ops on **healthy** machines, and strictly better than SSH for those (signed, scoped, logged, fan-out). But:
|
||||
|
||||
- It can only do what a handler was written for; incidents are by definition unanticipated.
|
||||
- The listener lives in the app, so it dies exactly when the app dies — the case you most need recovery for.
|
||||
|
||||
SSH (arbitrary, interactive, app-independent) is therefore irreducible as the **recovery plane**. The two are complements, not substitutes.
|
||||
|
||||
### Why the recovery path must be app-independent
|
||||
|
||||
The whole point of a recovery path is to survive the failure of the thing it recovers. So it must not be gated by the bitSpire app, nor by a Nostr command the app dispatches. The kernel/agent that carries the tunnel and `sshd` must come up at boot independent of the app. (`allowedTCPPorts = []` already means `sshd` is unreachable except across the tunnel — the VPN handshake is the outer lock, the SSH key the inner one.)
|
||||
|
||||
### Why the single shared hub had to change
|
||||
|
||||
The pre-existing design used one WireGuard hub (`170.75.161.21`) run by platform infra. Whoever runs that hub has a standing network path to every enrolled box — i.e. the SaaS operator having access to machines they don't own. Multi-tenancy requires the access plane to be **per-operator or policy-isolated**, rooted in the machine operator, not the platform.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Access-plane mechanism
|
||||
|
||||
#### Option A: Always-up minimal WireGuard hub
|
||||
|
||||
**Pros:** After boot, zero userspace dependency — the kernel holds the tunnel, nothing can crash it short of a kernel/networking fault; smallest, most battle-tested trusted-code surface; simplest possible recovery floor.
|
||||
**Cons:** Manual peer management; no policy/ACL/enrollment ergonomics; a single shared hub re-creates the multi-tenant trust problem (must be run per-operator to avoid it); does not scale operationally to many operators × many machines.
|
||||
|
||||
#### Option B: NetBird (Selected)
|
||||
|
||||
**Pros:** Policy/ACL-based, revocable, per-peer access control; enrollment + audit out of the box; **self-hostable, fully FOSS control plane** — we retain the ability to run and modify every layer; scales to the many-operators × many-machines world #42 anticipates.
|
||||
**Cons:** The NetBird agent is a userspace daemon, so the recovery path depends on that daemon being up (less bulletproof than kernel-level always-up WG) — mitigated by it being independent of the bitSpire app, mature, and systemd-restarted; running a control plane is operational weight (acceptable: the platform provides a default; sovereignty-seeking operators self-host).
|
||||
|
||||
#### Option C: Tailscale
|
||||
|
||||
**Pros:** Best-in-class ergonomics and NAT traversal.
|
||||
**Cons:** **Control plane is closed source with no FOSS alternative** (headscale only reimplements the coordination server, chasing an upstream we don't control). Fails the hard requirement that we can always self-host and modify any software we depend on. Rejected on that basis alone.
|
||||
|
||||
#### Option D: On-demand tunnel toggled by the Nostr control plane
|
||||
|
||||
**Pros:** No standing reachability; every access window is a signed, audited, time-boxed event.
|
||||
**Cons:** If the toggle is handled by the app, it fails in the exact recovery scenario (listener died with the app). If handled by a separate daemon, it reintroduces a privileged userspace listener into the recovery path and grows, rather than shrinks, the trusted-code surface. Acceptable only as an **audited convenience layer on top of** an always-available floor (and designed to fail open), never as the load-bearing gate. Not adopted as the primary mechanism.
|
||||
|
||||
### Trust model for excluding the SaaS operator
|
||||
|
||||
#### Option 1: Dashboard toggle to revoke SaaS-operator access (Rejected)
|
||||
|
||||
The SaaS operator controls LNbits (the machine's wallet/account is an LNbits user they can administer) and, in the default deployment, the NetBird control plane. A toggle whose enforcement they ultimately control gives the machine operator no real protection against them — it is security theater. **Rejected as a false promise.**
|
||||
|
||||
#### Option 2: Exclusion by ownership (Selected)
|
||||
|
||||
The only honest way for a machine operator to exclude the SaaS operator is to **own the access plane**: disable the default (platform-provided) NetBird enrollment and stand up their own — self-hosted NetBird, or their own WireGuard hub. The default deployment trusts whoever runs the control plane *and says so plainly*; operators who won't extend that trust take ownership. Control = ownership; we do not pretend otherwise.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Honest trust boundaries: the SaaS operator has no standing box access by default, and the limits of platform-provided convenience are stated rather than faked.
|
||||
- Scales to many operators × many machines via NetBird policy/enrollment, while preserving a self-hosting escape hatch for sovereignty.
|
||||
- The recovery plane survives app and OS failure because it is provisioned at install and independent of the runtime.
|
||||
- Every dependency remains FOSS and self-hostable — no closed control plane anywhere in the stack.
|
||||
|
||||
### Negative
|
||||
|
||||
- The NetBird agent is a standing userspace daemon; a box where *both* the app and the agent are down falls to the physical/LAN floor (same floor as any remote scheme — only pure kernel-WG narrows it, at the cost of NetBird's ergonomics). Operators who weight reliability over ergonomics can choose self-hosted plain WireGuard.
|
||||
- Sovereignty for a distrusting operator costs them operational work (running their own access plane). This is inherent to "control = ownership," not incidental.
|
||||
- Two enrollment surfaces at provisioning: app/payment identity (#42 seed URL) and system/access identity (this plane). They must be kept conceptually distinct.
|
||||
|
||||
### Future Considerations
|
||||
|
||||
- An **audited convenience layer** (Nostr `OpenAccess`/`CloseAccess` that opens a time-boxed SSH window and logs it as a signed event) may be added *on top of* the always-available floor, designed to fail open, for the routine "let me in" case. It is explicitly not the recovery gate.
|
||||
- The machine operator's Nostr key can become the single root of trust across all three planes — SSH `authorized_keys` + VPN enrollment at install, `AddOperator`/`RevokeOperator` (#42) for delegation — so granting/revoking any party (including the SaaS operator) is one scoped, revocable capability model.
|
||||
- `sshd` posture should be tightened to key-only for deployed boxes (password auth is currently forced on for installed configs for first-boot provisioning; scope it to the LAN/first-boot window). Tracks with [#51](https://git.atitlan.io/aiolabs/bitspire/issues/51).
|
||||
|
||||
## Amendment (2026-08-04): the access/recovery plane is not the *only* recovery
|
||||
|
||||
**Status:** Accepted · **Context:** the ATM app had no way to recover its own
|
||||
connectivity — a machine that booted with no internet (or whose init otherwise
|
||||
failed) sat on "ATM Unavailable" until a manual `systemctl restart bitspire`,
|
||||
even after the network came back.
|
||||
|
||||
This ADR's SSH/NetBird recovery plane stands — it is the operator's
|
||||
**app-and-OS-independent** path for the unanticipated and the broken, and may
|
||||
carry recovery *procedures* (restart the service, inspect logs, re-provision).
|
||||
But it is explicitly **not the first-line and not the only recovery method.**
|
||||
Recovery is layered, cheapest-first:
|
||||
|
||||
1. **App auto-recovery (first-line, no human).** The ATM app recovers its own
|
||||
relay/Lightning connectivity when possible: the nostr client already
|
||||
reconnects with backoff, and the app now re-initializes when connectivity
|
||||
returns (a fresh renderer reload — HAL is preserved in the main process),
|
||||
so "internet came back" self-heals without anyone touching the machine.
|
||||
2. **On-screen manual retry (operator at the machine).** The maintenance
|
||||
("ATM Unavailable") screen carries a **Retry** button so a person standing
|
||||
at the kiosk can force an immediate recovery attempt without shell access.
|
||||
3. **SSH/NetBird (operator remote, last resort).** This plane — for when the
|
||||
app *can't* self-heal or the box is genuinely broken. Unchanged by this
|
||||
amendment beyond the reframing: it is the floor, not the front line.
|
||||
|
||||
Rationale: the common failure (transient network / boot-before-network) must
|
||||
not require remote shell access to a public kiosk. Reserve the heavyweight
|
||||
recovery plane for genuine app/OS failure. Implemented on branch
|
||||
`feat/connection-recovery`.
|
||||
|
||||
## References
|
||||
|
||||
- [#41](https://git.atitlan.io/aiolabs/bitspire/issues/41) — Multi-location deployment: runtime site config (the access plane's per-machine identity is provisioned here, not baked into the closure).
|
||||
- [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) — Fleet management: Nostr-native remote control & telemetry (the control plane this ADR sits beside).
|
||||
- [#51](https://git.atitlan.io/aiolabs/bitspire/issues/51) — NixOS systemd hardening (sshd posture tightening).
|
||||
- [#52](https://git.atitlan.io/aiolabs/bitspire/issues/52) — Sidecar bunker for the ATM key (related key-handling direction).
|
||||
- `deploy/nixos/configuration.nix` — current WireGuard hub + `sshd` config (to be reworked per this decision).
|
||||
- NetBird — <https://github.com/netbirdio/netbird> (self-hostable, FOSS control plane).
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
# ADR-003: NFC Access-Control Layer (badge-to-enter) + Developer Bypass
|
||||
|
||||
**Status:** Accepted — amended 2026-09-20 (see [Amendment](#amendment-2026-09-20-what-shipped) below; the original text follows it unchanged)
|
||||
**Date:** 2026-07-29
|
||||
**Context:** batm3 gaining a physical access layer — an NFC card must be presented to unlock the machine before anyone can transact. Reader hardware is not yet on hand; this ADR defines the direction and a non-breaking skeleton that is fully testable without it.
|
||||
|
||||
## Amendment (2026-09-20): what shipped
|
||||
|
||||
The gate landed in aiolabs/bitspire#86 as **Bolt Card tap-to-enter**, not the npub-QR → UID → serial-reader path planned below. The decisions still stand (opt-in `locked` state, operator-owned authorization, hashed identities, fail-closed, audited); the mechanism differs:
|
||||
|
||||
- **Reader.** The batm3 / upboard reader is a USB CCID contactless reader (Feitian KP382, OMNIKEY 5022) driven by `pcscd` + `nfc-pcsc` in the **main process** (`electron/nfc-service.ts`, from #83), not the serial `/dev/ttyNFC` device or Web NFC. Taps reach the renderer over the existing `nfc:card-tapped` IPC and the store routes them by state (`locked` → enter; the cash screens → pay / receive). The renderer-side `AccessReader` abstraction, the camera npub-QR reader and the mock reader were never wired to anything and have been removed; `services/access/` now holds only `authorize`, the Bolt Card parser and the credential types.
|
||||
- **Credential.** Identity is the card's boltcards `external_id`, parsed **locally** from the tapped `lnurlw` (`AccessScan` kind `boltcard`), not the NFC UID. Only `hashId(external_id, salt)` is compared or logged. The `npub` variant (with its PIN second factor) stays in `authorize()` and its tests for a future non-card credential; `challenge` remains the v2 seam; the planned `uid` variant is gone.
|
||||
- **Verified entry via `/session`** (superseding #86's soft entry). A tap yields a single-use SUN `p`/`c`, and verifying it at entry would have spent the voucher Complete needed, so #86 made no server call at entry. The fork now exposes `/session/<id>?p=&c=` (`docs/boltcard-session.md`): it spends the SUN **once**, proves a genuine non-replayed card, returns the card wallet's balance + fiat, and hands back the hit-keyed LUD-03 / LUD-06 second steps so Complete still needs no second tap. The session is single-shot — after the first Complete attempt, accepted or declined, the store drops it and the customer re-taps. The balance is shown on the idle menu and cash screens **hidden by default** behind an eye toggle (`CardChip.vue`), priced the way the LNbits wallet page prices it (wallet currency, else instance default, else the ATM's fiat at its own rate).
|
||||
- **Open enrollment is still not a security boundary.** `/session` proves the card is genuine *to the server the card names*: the session URL is derived from the tapped `lnurlw`'s host, so with `openEnrollment` on a forged NDEF tag pointing at a server that answers `authenticated: true` still unlocks the terminal. Money is unaffected (cash-out never dispenses without `PAYMENT_RECEIVED`; cash-in pays where the holder pointed). Closing the gate means pinning the accepted card-server host(s) and/or a provisioned allow-list — tracked in aiolabs/bitspire#91.
|
||||
- **Session semantics.** One tap = one session; every transaction terminal state returns to `locked`. Inactivity (60 s on the idle menu) and the absolute cap (10 min) are measured at the DOM layer (`useSessionSecurity`) because an XState `after` cannot observe touches. Both send `END_SESSION`, which the machine accepts **from `idle` only**, so a timer can never abandon stacked bills or an in-flight dispense. An explicit End Session button re-locks immediately.
|
||||
- **Config.** `ACCESS_CONTROL_ENABLED` / `ACCESS_OPEN_ENROLLMENT` / `ACCESS_DEV_UNLOCK` / `ACCESS_SALT` from env, overridden by `/var/lib/bitspire/access.json` (pushed with `deploy/nixos/provision-access.sh`, no rebuild). `devUnlock` defaults **off**.
|
||||
- **Audit** is still a console stub; the state.db write promised in decision 7 is tracked in aiolabs/bitspire#90.
|
||||
- **Phased plan superseded.** PR3 (Web NFC) and PR4 (serial HAL) will not happen — the pcscd reader covers real hardware and there is no laptop dev path beyond the debug paste-box on `LockedView`. PR5's challenge-response idea survives as the `challenge` seam.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Add a top-level `locked` state to the ATM state machine, and make it the initial state.** It sits *below* the existing initialization gates (`unpaired` / `awaiting-fees` / `maintenance` / `signer-unreachable`, which live in `App.vue`). A healthy, paired machine boots into `locked` and only reveals `idle` (Buy/Sell) after an access grant.
|
||||
|
||||
2. **Access control is opt-in via runtime config (`accessControl.enabled`, default `false`).** When disabled, the machine behaves exactly as today (boots straight to `idle`). This makes the whole feature non-breaking for the current test unit and for production ATMs, and lets a half-built access layer never brick a working box. This is the single most important constraint on the design.
|
||||
|
||||
3. **The reader lives behind an `AccessReader` abstraction that mirrors the existing `PairingSource` seam.** First implementation is a `MockAccessReader` (dev button / hotkey), so the locked→idle→transaction path is exercisable today with zero hardware. Web NFC and serial-NFC implementations follow.
|
||||
|
||||
4. **Credential model is a discriminated union with an explicit upgrade path.** v1 = card UID matched against a hashed allow-list. v2 = challenge-response (card-held key signs a machine nonce), verified against an operator-authorized set. Ship v1; design the types so v2 is additive.
|
||||
|
||||
5. **Three-tier developer bypass**, following existing conventions: a build flag (`VITE_SKIP_ACCESS_GATE`), the config disable (`accessControl.enabled=false`), and a runtime operator/dev unlock gesture that dispatches a synthetic grant.
|
||||
|
||||
6. **Authorization is owned by the machine operator, not the SaaS operator** — consistent with [ADR-002](./002-remote-access-and-fleet-management.md). The card allow-list is authorized by the operator key (the [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) allow-list mechanism), local first, operator-synced later. When access control is *enabled* and the reader is absent/broken, the machine **fails closed** (with the operator/dev unlock as the escape hatch); when *disabled*, reader state is irrelevant.
|
||||
|
||||
7. **Every grant/deny is audited** to `state.db` (hashed credential + timestamp + role + outcome), with optional later publication as a Nostr event. No PII, consistent with the KYC-free principle.
|
||||
|
||||
## Context
|
||||
|
||||
### What this is (and what it is not)
|
||||
|
||||
This is the **end-user physical access plane**: a person must badge in to use the machine. It is distinct from the three planes in [ADR-002](./002-remote-access-and-fleet-management.md) — it is *not* the operator's SSH/NetBird recovery plane, and *not* the SaaS payment plane. It shares one idea with ADR-002: **the machine operator owns who is authorized**, expressed through the operator key / #42 allow-list.
|
||||
|
||||
### Why the codebase is well-shaped for this
|
||||
|
||||
Three seams already exist; we extend them rather than invent:
|
||||
|
||||
- **The idle→transaction transition is unguarded.** `packages/state-machine/src/machine.ts` starts at `initial: 'idle'` (~L431) and `idle` moves to `cashIn`/`cashOut` via plain `SELECT_CASH_IN` / `SELECT_CASH_OUT` transitions with no guards (~L457-464). Inserting a `locked` predecessor state is a localized change.
|
||||
- **`PairingSource` is a reader abstraction designed to grow.** Its doc (`apps/machine/src/services/pairing/types.ts`) explicitly anticipates *"an NFC reader or a HAL barcode scanner… a HAL-scanner source can be added the same way without touching the wizard."* `AccessReader` mirrors it: `qr-source.ts` / `nfc-source.ts` → `mock-reader.ts` / `web-nfc-reader.ts` / `serial-reader.ts`.
|
||||
- **Dev-flag and config conventions are established.** `import.meta.env.VITE_* === 'true'` (e.g. `VITE_MAINTENANCE_MODE`, `VITE_FORCE_MOCK`), plus Electron `get-config` fields that the renderer reads (`electron/main.ts` L280–312: `maintenanceMode`, `branding`). A new `accessControl` config field and a `VITE_SKIP_ACCESS_GATE` flag follow the same shape.
|
||||
|
||||
### Hardware reality check (important)
|
||||
|
||||
The batm3 already exposes an NFC device, but it is **serial**: `deploy/nixos/hardware/batm3.nix` L154 maps udev serial `A9ZF8ELY` → `/dev/ttyNFC`. The *existing* `pairing/nfc-source.ts` uses **Web NFC** (`NDEFReader`), which drives a phone/laptop NFC radio, **not** a serial reader. So the real batm3 access reader needs a **main-process serial driver** (HAL-style, per [ADR-001](./001-hal-architecture.md)) exposing card events to the renderer over IPC — the Web NFC path is only useful for laptop/phone dev. This ADR keeps that driver as a clearly-scoped later PR so the skeleton doesn't pretend the scaffold "just works" on the panel.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Boot / render layering
|
||||
|
||||
```
|
||||
Electron get-config ─┐
|
||||
▼
|
||||
App.vue init gates (unchanged):
|
||||
unpaired? → PairingWizard
|
||||
initError (maintenance / awaiting-fees / signer-unreachable)? → maintenance screen
|
||||
else ▼
|
||||
State machine (paired + healthy):
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ locked ──ACCESS_GRANTED──▶ idle │ ← NEW initial state
|
||||
│ ▲ │ SELECT_CASH_* │
|
||||
│ │ re-lock (session end / ▼ │
|
||||
│ │ inactivity / complete) cashIn / cashOut │
|
||||
│ └──────────────────────────┘ │
|
||||
└───────────────────────────────────────────────┘
|
||||
(when accessControl.enabled === false,
|
||||
`locked` immediately `always`-bypasses to `idle`)
|
||||
```
|
||||
|
||||
The access gate is strictly below App.vue's init gates: a machine that is unpaired or in maintenance never reaches `locked`.
|
||||
|
||||
### 1. State machine (`packages/state-machine`)
|
||||
|
||||
- New top-level state `locked`, `initial: 'locked'`.
|
||||
- New events on the machine's event union: `ACCESS_GRANTED` (carries an authorized `CardCredential` + resolved role), `ACCESS_DENIED` (carries a reason), `DEV_UNLOCK`.
|
||||
- `locked` transitions:
|
||||
- `always: [{ guard: 'accessBypass', target: 'idle' }]` — instant pass-through when disabled/bypassed (no UI flicker; the view is gated on the same predicate).
|
||||
- `on: { ACCESS_GRANTED: { target: 'idle', actions: ['startSession', 'recordAccessGrant'] }, ACCESS_DENIED: { actions: 'recordAccessDeny' }, DEV_UNLOCK: { guard: 'devUnlockAllowed', target: 'idle', actions: 'startDevSession' } }`.
|
||||
- Re-lock: the existing `complete` auto-return (currently 60s → `idle`) and the inactivity timeouts (`INACTIVITY_TIMEOUT`/`TIMEOUT_MS`, ~L422-429) target `locked` instead of `idle`. Because `locked` `always`-bypasses when disabled, this is one code path for both modes.
|
||||
- Purity: the state-machine package must not read Vite env. `accessControl.enabled` and the bypass boolean are passed in as **actor input → context** (`context.accessControlEnabled`, `context.accessBypass`); guard `accessBypass` reads context only. New context fields: `accessControlEnabled`, `accessBypass`, `session` (`{ role, grantedAt, credentialIdHash } | null`).
|
||||
- Guards: `accessBypass`, `devUnlockAllowed`. Actions: `startSession`, `startDevSession`, `recordAccessGrant`, `recordAccessDeny`, and `resetContext` extended to clear `session`.
|
||||
|
||||
### 2. Config plumbing
|
||||
|
||||
- `apps/machine/src/types/electron.d.ts` — extend `RuntimeConfig` (L5) with:
|
||||
```ts
|
||||
accessControl: {
|
||||
enabled: boolean // default false
|
||||
devUnlock: boolean // allow the runtime operator/dev unlock gesture
|
||||
// v2: allowListSource, challengeRequired, …
|
||||
}
|
||||
```
|
||||
- `apps/machine/electron/main.ts` — the `get-config` handler (L280) returns `accessControl`, sourced from env for now (`ACCESS_CONTROL_ENABLED === 'true'`, `VITE_SKIP_ACCESS_GATE` → forces `enabled:false`), later from a provisioned file under `/var/lib/bitspire/` alongside branding.
|
||||
- `apps/machine/.env.example` — document `VITE_SKIP_ACCESS_GATE=true` (browser/dev straight to idle) and `ACCESS_CONTROL_ENABLED`.
|
||||
|
||||
### 3. Reader abstraction (`apps/machine/src/services/access/`)
|
||||
|
||||
Mirrors `services/pairing/`:
|
||||
|
||||
```
|
||||
services/access/
|
||||
types.ts # AccessReader, CardCredential (union), AccessRole, StopCapture
|
||||
mock-reader.ts # PR1 — fires a card event on demand (dev button / hotkey)
|
||||
web-nfc-reader.ts # PR3 — NDEFReader, dev on laptop/phone
|
||||
serial-reader.ts # PR4 — /dev/ttyNFC via main-process HAL + IPC
|
||||
authorize.ts # allow-list check + role resolution (hashed UID v1)
|
||||
index.ts # availableAccessReaders(): AccessReader[]
|
||||
__tests__/
|
||||
```
|
||||
|
||||
```ts
|
||||
export type AccessRole = 'user' | 'operator'
|
||||
|
||||
export type CardCredential =
|
||||
| { kind: 'uid'; uidHash: string } // v1
|
||||
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string } // v2 (seam)
|
||||
|
||||
export interface AccessReader {
|
||||
readonly kind: 'mock' | 'nfc-web' | 'nfc-serial'
|
||||
readonly label: string
|
||||
isAvailable(): Promise<boolean>
|
||||
start(opts: {
|
||||
onCard: (cred: CardCredential) => void
|
||||
onError?: (e: unknown) => void
|
||||
}): Promise<StopCapture>
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Renderer wiring
|
||||
|
||||
- `apps/machine/src/views/LockedView.vue` (new) — the badge-in screen. Shows brand/logo + "Present your card", a live reader status, and (when `accessControl.devUnlock`) a discreet operator/dev unlock affordance (hidden long-press corner, or a button on the existing debug bar).
|
||||
- `apps/machine/src/App.vue` — add a `locked` render branch mirroring the `PairingWizard` branch (L179) and the `initError` branch (L183): `<LockedView v-else-if="atmStore.isLocked" />`, then the existing `<router-view>` only when unlocked. Keeps rendering state-driven and matches the current shape.
|
||||
- `apps/machine/src/stores/atm.ts` —
|
||||
- `createActor(machine, { input: { accessControlEnabled, accessBypass } })` at the existing `createActor(machine)` site (L452), seeded from `RuntimeConfig`.
|
||||
- `isLocked` computed off the snapshot (peer of `isIdle`, ~L422).
|
||||
- `grantAccess(cred, role)` / `denyAccess(reason)` / `devUnlock()` that `send({ type: 'ACCESS_GRANTED' | 'ACCESS_DENIED' | 'DEV_UNLOCK', … })` (peers of `selectCashIn` at L1382, using the existing `send` at L1378).
|
||||
- On init, when `accessControl.enabled`, subscribe to `availableAccessReaders()[0]`; on `onCard`, run `authorize()` → `grantAccess`/`denyAccess`. When disabled, do nothing (machine `always`-bypasses).
|
||||
|
||||
### 5. Audit
|
||||
|
||||
- Add `recordAccessEvent({ credentialIdHash, role, outcome, at })` alongside the existing state.db handlers (`state:record-transaction` etc. in `electron/main.ts`, exposed via `preload.ts`). v1 writes locally; a later PR can mirror to a replaceable Nostr event.
|
||||
|
||||
### Session semantics (decided)
|
||||
|
||||
**One badge = one transaction-scoped session.** A grant unlocks `idle`, the user runs a single transaction (Buy or Sell), and the machine re-locks on `complete`, on inactivity, or on an explicit "Done". The `session` context field is deliberately shaped as a general access session (`{ role, grantedAt, credentialIdHash }`), not a transaction handle, because this terminal may later handle **non-transaction functions** — so "unlock the terminal" and "authorize a transaction" stay separate concepts.
|
||||
|
||||
**Step-up authorization (future seam, not in PR1).** The badge tap grants *terminal access*; a specific sensitive action can independently *request re-authorization* — e.g. "tap your phone" or "enter a PIN" — without conflating the two. This is why the credential model is a union and the machine carries a `session` rather than a boolean "unlocked": a later `REQUIRE_STEPUP` event can gate an individual action against a fresh credential/PIN while the terminal session stays open. PR1 ships only the entry gate; step-up is a documented extension.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Gate between `idle` and the transaction** (idle visible, tap requires a card). Rejected: the requirement is "gain access to the exchange" — the whole machine should be locked, not just the transact button. A `locked` predecessor matches the mental model and gives a clean re-lock boundary.
|
||||
- **Web NFC only** (reuse `nfc-source.ts` as-is). Rejected: the batm3 reader is serial (`ttyNFC`); Web NFC can't drive it. Web NFC stays a dev-only convenience.
|
||||
- **Fail-open by default** (no card → allow). Rejected for an access-control feature; but note the *disabled* default sidesteps this — access is simply off until an operator turns it on, at which point it fails **closed**.
|
||||
- **OS/kiosk-level lock** (lock the desktop, not the app). Rejected: too coarse, no per-transaction audit, no role model, and it fights the existing state-driven UI.
|
||||
- **UID allow-list as the permanent model.** Rejected as an endpoint (UIDs clone trivially) but accepted as v1 behind a union type, so challenge-response is additive.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **UID cloning** — card UIDs are not secret and are cloneable; v1 is "better than nothing" and is explicitly labeled upgradeable. The v2 challenge-response path (card signs a machine nonce) is the real security boundary; design the credential union and the `authorize()` seam for it now.
|
||||
- **Data at rest** — store only a salted hash of the credential id; never raw UIDs or any PII (KYC-free). Never log a card secret or nsec (repo security priority #1).
|
||||
- **Fail-closed when enabled** — reader absent/broken + `enabled` ⇒ locked, escape hatch = operator/dev unlock. Reader problems on a *disabled* machine are inert.
|
||||
- **Operator ownership** — authorization derives from the operator key / #42 allow-list, not the SaaS operator (ADR-002 boundary). Local allow-list first; operator-published (NIP-51-style) sync later.
|
||||
- **Dev bypass blast radius** — `VITE_SKIP_ACCESS_GATE` is build-time and never set in a production image; `devUnlock` is gated by `accessControl.devUnlock` (off in a locked-down deployment) and every dev unlock is audited with role `operator`/`dev`.
|
||||
|
||||
## Resolved decisions (2026-07-29)
|
||||
|
||||
1. **Session model** — ✅ one badge = one **transaction-scoped session**, with the `session` context modeled generally (terminal access, not a transaction handle) to allow non-transaction functions and per-action **step-up auth** (tap phone / PIN) later. See *Session semantics* above.
|
||||
2. **v1 credential** — ✅ **UID allow-list first** (hashed), behind a `CardCredential` union so challenge-response is additive (PR5).
|
||||
3. **Allow-list home** — ✅ **local first** (`state.db` / provisioned `access.json`, peer of `branding/`); operator-Nostr sync is a later PR.
|
||||
|
||||
Still open (cosmetic, decide during PR2):
|
||||
|
||||
4. **Dev unlock affordance** — hidden long-press corner vs a labeled button on the existing debug bar.
|
||||
|
||||
---
|
||||
|
||||
## Implementation plan (phased PRs)
|
||||
|
||||
Each PR is independently mergeable. **PR1 changes nothing observable while `accessControl.enabled=false` (the default).**
|
||||
|
||||
### PR1 — Non-breaking skeleton (state + config + mock reader + dev bypass + audit stub)
|
||||
**Goal:** the locked→idle→transaction path is exercisable on the batm3 today, and the flag-off machine is byte-for-byte behavior-identical.
|
||||
- `packages/state-machine`: add `locked` state (`initial`), `ACCESS_GRANTED`/`ACCESS_DENIED`/`DEV_UNLOCK` events, `accessBypass`/`devUnlockAllowed` guards, `startSession`/`recordAccess*` actions, context fields + actor `input`. Re-point `complete`/inactivity re-lock targets to `locked`.
|
||||
- `apps/machine/src/types/electron.d.ts`: `RuntimeConfig.accessControl`.
|
||||
- `apps/machine/electron/main.ts`: `get-config` returns `accessControl` (env-sourced); `.env.example` documents `VITE_SKIP_ACCESS_GATE` + `ACCESS_CONTROL_ENABLED`.
|
||||
- `apps/machine/src/services/access/`: `types.ts`, `mock-reader.ts`, `authorize.ts` (UID allow-list, hashed), `index.ts`.
|
||||
- `apps/machine/src/stores/atm.ts`: actor `input`, `isLocked`, `grantAccess`/`denyAccess`/`devUnlock`, reader subscription (only when enabled).
|
||||
- `apps/machine/src/views/LockedView.vue` + `App.vue` `locked` render branch.
|
||||
- Audit stub: `recordAccessEvent` handler + preload exposure (local write only).
|
||||
- **Tests:** state-machine `locked → idle` on grant; `always`-bypass when disabled; re-lock from `complete`; `authorize()` allow/deny; `devUnlock` gated by config.
|
||||
- **Flag state on merge:** `enabled=false`. CI green = no behavior change.
|
||||
|
||||
### PR2 — Locked UX polish
|
||||
- Reader status/animation, brand-aware LockedView, denied-flash + reason, inactivity copy, operator/dev unlock affordance per open-question #4. Pure renderer.
|
||||
|
||||
### PR3 — Web NFC reader (dev)
|
||||
- `web-nfc-reader.ts` (`NDEFReader`), registered in `availableAccessReaders()` behind availability check. Lets a laptop/phone drive the gate for demos/dev. No hardware dependency.
|
||||
|
||||
### PR4 — Serial NFC HAL driver (real batm3 hardware)
|
||||
- Main-process serial driver for `/dev/ttyNFC` (HAL-style per ADR-001), IPC channel `access:watch-card` + `preload.ts` exposure; `serial-reader.ts` renderer client. Requires the physical reader to validate. Document the reader's protocol/baud in `docs/device-configuration.md`.
|
||||
|
||||
### PR5 — Challenge-response credential + operator allow-list sync
|
||||
- Extend `CardCredential` with the `challenge` variant; `authorize.ts` verifies a signature over a machine nonce against the operator-authorized set; allow-list synced from an operator-published event (#42 mechanism). This is the real security upgrade; v1 UID path stays as a fallback/dev mode.
|
||||
|
||||
### Cross-cutting
|
||||
- **Docs:** update `docs/machine-installation.md` (enabling access control, enrolling cards) and `deploy/nixos/README.md` (the `accessControl` config + `/dev/ttyNFC`) as PR4/PR5 land.
|
||||
- **Provisioning:** a later change can add an `access.json` under `/var/lib/bitspire/` (peer of `branding/`) with the allow-list + `enabled`, plus a `provision-access.sh` mirroring `provision-branding.sh`.
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
# ADR-004: Cassette-State Synchronization
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-09-22
|
||||
**Context:** Cassette counts exist on two machines that both write them, over a transport
|
||||
that cannot report a losing write. This has been load-bearing since #56 shipped, and until
|
||||
now its only specification was a closed issue and a chat log — which is how four separate
|
||||
divergence bugs went unnoticed.
|
||||
|
||||
## The problem
|
||||
|
||||
The ATM holds per-bay rows in `state.db` (`position` → `denomination`, `count`). spirekeeper
|
||||
holds its own `cassette_configs` view for the operator dashboard. They are kept in step over
|
||||
Nostr kind-30078, one addressable document per direction:
|
||||
|
||||
| d-tag | Direction | Author |
|
||||
| --------------------------------------- | --------------------- | -------- |
|
||||
| `bitspire-cassettes-state:<atm_pubkey>` | ATM reports counts up | ATM |
|
||||
| `bitspire-cassettes:<atm_pubkey>` | operator pushes down | operator |
|
||||
|
||||
Counts drive cash dispensing and the public availability beacon, so a wrong number either
|
||||
strands a customer at a machine that will not pay out or advertises cash that is not there.
|
||||
|
||||
The transport shapes everything else. Per NIP-01, an addressable event is identified by
|
||||
`kind:pubkey:d` and ordered by `created_at` at **second granularity**, ties broken by lowest
|
||||
event id. Relays MAY discard the loser, and a relay returns `OK` for an event it then
|
||||
discards — so **acceptance is not persistence, and a losing writer is never told**. That
|
||||
single fact rules out the obvious design.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. The ATM owns `count`. The operator publishes operations, not counts.
|
||||
|
||||
A value with one writer cannot be clobbered. Compare-and-swap was considered and rejected:
|
||||
CAS works because the writer learns it failed and retries, and every standard implementation
|
||||
of it — HTTP `412`, Kubernetes `409`, a zero rowcount, `CMPXCHG` returning false — delivers
|
||||
that signal. A kind-30078 publish cannot. Bolting a version onto the current design would let
|
||||
the ATM refuse a stale push but leave the operator believing they set a count they did not,
|
||||
trading a wrong number for a phantom edit.
|
||||
|
||||
So the operator publishes `refill`, `empty`, `recount` and `set_denomination` operations. The
|
||||
vocabulary mirrors lamassu-server's `cash_unit_operation_type`, which is the same shape the
|
||||
ancestor of this HAL arrived at. Absolute writes survive only as `recount`, which is what an
|
||||
operator opening a bay and counting actually does.
|
||||
|
||||
`denomination` stays operator-authoritative: the machine cannot know what was physically
|
||||
loaded into a bay.
|
||||
|
||||
### 2. Idempotency is explicit, because deltas are not idempotent.
|
||||
|
||||
Addressable events are re-delivered on reconnect, so a naive delta would be applied twice.
|
||||
Every operation carries an operator-minted `id`; the ATM records applied ids and ignores
|
||||
duplicates. This is lamassu-server's `pullNewBills` pattern — a client-minted UUID per unit of
|
||||
work, making resend free and ordering irrelevant — rather than a sequence number.
|
||||
|
||||
### 3. The operator publishes a window of recent operations, not one.
|
||||
|
||||
An event the ATM missed self-heals on the next publish, because the next event still carries
|
||||
the earlier operations. This is the same trick as Lightning.Pub piggybacking `latest_balance`
|
||||
on every incremental message so a client that missed events corrects itself.
|
||||
|
||||
### 4. The ATM echoes applied ids back, which is the acknowledgement.
|
||||
|
||||
The state document carries `applied_ops`, so the dashboard can render each published operation
|
||||
as applied or pending. This supplies the feedback leg a replaceable event cannot, without
|
||||
needing the transport to report failures.
|
||||
|
||||
### 4a. Superseded. Before decisions 1 to 4 shipped, the overwrite was warned about.
|
||||
|
||||
The dashboard's publish dialog stated the failure plainly — that the publish would overwrite
|
||||
the ATM's tracked counts, that decrements since the last baseline would be lost, and that it
|
||||
should follow a physical refill rather than a mid-day tweak.
|
||||
|
||||
Kept here rather than deleted, because it is the calibration for how much a warning is worth.
|
||||
It was a known, deliberately accepted risk carrying a human-factors mitigation, not an
|
||||
oversight, and the product had already reached the same conclusion these decisions formalise.
|
||||
It was also the weakest control available: it depended on an operator reading a dialog at the
|
||||
end of a refill round, and it could not help at all when the stale value was the one already
|
||||
in the form. Confirmed live on 2026-09-22 — a dispense moved a bay from 54 to 53 while a form
|
||||
loaded at 54 stayed open, and nothing but that dialog stood between the operator and
|
||||
discarding the decrement.
|
||||
|
||||
The dialog and the endpoint behind it are both gone. The operator dashboard no longer has a
|
||||
field that accepts a count, which is a stronger guarantee than any wording could be.
|
||||
|
||||
### 5. Ordering is decided by `created_at`, never by arrival order, on both sides.
|
||||
|
||||
The ATM forces each stamp strictly above its last published one, so a same-second publish or a
|
||||
clock stepping backwards cannot silently discard a report. spirekeeper applies an event only
|
||||
when strictly newer than the **oldest** stamp on file for that machine.
|
||||
|
||||
Oldest, not newest, because LNbits' `Connection.execute` commits per call: a multi-row apply
|
||||
cannot be made atomic through that data layer, so a crash mid-apply leaves some rows advanced.
|
||||
Gating on the oldest means a partial apply is re-applied rather than mistaken for a complete
|
||||
one, and the ATM's heartbeat makes it converge.
|
||||
|
||||
### 6. The machine's bay set is authoritative for layout.
|
||||
|
||||
Bay count is hardware-determined. spirekeeper deletes positions absent from a report rather
|
||||
than leaving them; the operator cannot add or remove bays.
|
||||
|
||||
### 7. Unverified counts are declared, not guessed.
|
||||
|
||||
When a dispense ends with no per-bay report — a driver throw, or the dispense timeout — bills
|
||||
may have reached the customer with nothing knowing how many. The ATM flags
|
||||
`counts_uncertain_since` and carries it in the state document rather than letting a number
|
||||
known to read high stand as measurement. An operator `recount` clears it.
|
||||
|
||||
### 8. State is published on every change and on a heartbeat.
|
||||
|
||||
A publish is one fire-and-forget event with no retry. The heartbeat is what makes the channel
|
||||
self-healing after a relay outage, and the only way an out-of-band edit to the table ever
|
||||
reaches the operator.
|
||||
|
||||
## What this replaces
|
||||
|
||||
The original design published a single hello-event gated on a one-shot flag, deduplicated on
|
||||
one remembered event id, and never compared `created_at` at all. In practice that produced:
|
||||
|
||||
| Failure | Issue |
|
||||
| --------------------------------------------------------------- | -------------- |
|
||||
| Layout changes after first boot never published | bitspire#94 |
|
||||
| Remediation dispenses debited HAL but not the rows | bitspire#76 |
|
||||
| Absent positions never deleted; publishes then rejected forever | spirekeeper#43 |
|
||||
| A stale dashboard publish overwriting a newer report | spirekeeper#43 |
|
||||
| A drained machine advertising bills it had already dispensed | found in audit |
|
||||
| A re-delivered A, B, A applied three times | found in audit |
|
||||
|
||||
The last of these is the only one decisions 5 to 8 do not close, because it is not a defect
|
||||
in the mechanism: the operator is permitted to write the count, so a stale write is
|
||||
indistinguishable from an intended one. Only decisions 1 to 4 remove it, by removing the
|
||||
operator's ability to write counts at all.
|
||||
|
||||
## Status of implementation
|
||||
|
||||
Decisions 5 through 8 shipped in bitspire#104 and spirekeeper#44, on the existing wire format,
|
||||
and were verified against the deployed code on sintra on 2026-09-22: a zeroed machine reported
|
||||
drained rather than freezing its beacon, a re-delivered operator config was dropped as stale on
|
||||
eight consecutive restarts, three heartbeat republishes carried strictly increasing stamps read
|
||||
back off the relay, and the machine, the relay and the operator dashboard agreed on the counts
|
||||
with timestamps correlated to the second.
|
||||
|
||||
Decisions 1 through 4 are the v2 operations wire and shipped in spirekeeper#46 and
|
||||
bitspire#106.
|
||||
|
||||
On the operator side there is no longer any endpoint that accepts a count: the absolute
|
||||
publish, its CRUD write and its request model were removed rather than deprecated. The
|
||||
dashboard records operations and renders each as applied or pending from the machine's
|
||||
`applied_ops` echo. On the machine side, schema v13 adds a `cassette_ops` dedup ledger, the
|
||||
`created_at` watermark on this path is retired in favour of per-op ids, and the state document
|
||||
carries `schema_version`, `seq` and `applied_ops`.
|
||||
|
||||
The wire shapes are those given under decisions 1 and 4 above.
|
||||
|
||||
Cutover for v2 is strict, no compatibility code: spirekeeper deploys first, machines follow on
|
||||
their nightly pull. During that window a not-yet-updated ATM ignores an ops payload, so an
|
||||
operator refill does not land until it updates — which fails safe, since the machine
|
||||
under-counts and will not dispense bills it believes it lacks. In the other direction an
|
||||
updated machine drops a v1 absolute-count payload on the missing `ops` array, which is the
|
||||
same safe direction: the machine keeps the counts it is now the only writer of.
|
||||
|
||||
One gap stays open deliberately. An operation recorded while the relay is unreachable waits
|
||||
for the operator's next action to be published, because only an operator action triggers a
|
||||
publish. The window makes that self-healing once anything is published, but nothing on the
|
||||
operator side republishes on its own. An operator-side heartbeat is the fix; it is not built.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Compare-and-swap on absolute writes.** Rejected: see decision 1. Viable only with a
|
||||
feedback leg the transport cannot provide, and decision 4 gets the same benefit without
|
||||
pretending the transport is something it is not.
|
||||
- **NIP-77 negentropy for reconciliation.** Rejected: it reconciles sets of event ids and
|
||||
still requires a separate fetch. For a single mutable document it costs more than
|
||||
re-reading it.
|
||||
- **One envelope carrying all operator config.** Rejected earlier and still right: a fee edit
|
||||
that republished a stale cassette inventory is a real failure mode. One d-tag per lifecycle.
|
||||
- **Publishing the operation log as kind-78.** Deferred. The operator authors the operations
|
||||
and the ATM records what it applied, so both sides already hold an audit trail.
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
# Bolt Card tap-to-receive — LNbits resolver endpoint
|
||||
|
||||
Spec for the small **custom endpoint** the ATM needs on the LNbits `boltcards`
|
||||
extension to support **tap-to-receive** (the cash-in / buy flow). The ATM side
|
||||
(`apps/machine/electron/lnurl-pay.ts`) is already built against this contract;
|
||||
this document is what to implement in the `omni-private` LNbits fork.
|
||||
|
||||
## Why a new endpoint
|
||||
|
||||
A Bolt Card only ever emits its `lnurlw://…/scan/<external_id>?p=&c=` voucher —
|
||||
a **withdraw** (spend) credential. You cannot push sats _into_ the card with it.
|
||||
To deposit to the card's wallet, the ATM uses the same tap as an **authenticated
|
||||
identity** (the `external_id` + the SUN `p`/`c`, verified exactly as `/scan`
|
||||
does) and needs the wallet's **pay** target back. Stock `boltcards` is
|
||||
withdraw-only, so we add a `pay` sibling of `scan`.
|
||||
|
||||
The card is **not re-written** — same NDEF, same keys, same `external_id`. Only
|
||||
the server learns a new way to answer the same tap.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET /boltcards/api/v1/pay/{external_id}?p={p}&c={c}
|
||||
```
|
||||
|
||||
- Same URL shape as `GET /boltcards/api/v1/scan/{external_id}?p=&c=`, with the
|
||||
path segment `scan` → `pay`. The ATM derives it by string-substitution on the
|
||||
tapped `lnurlw` (`scanUrlToResolver()` in `lnurl-pay.ts`).
|
||||
- **Verify `p`/`c` exactly like `/scan`**: decrypt the PICC (`p`) with the
|
||||
card's `k1`, recompute the CMAC (`c`) with `k2`, check the read counter is
|
||||
fresh (monotonic). Reject replays. Reuse the boltcards SUN verification path —
|
||||
do not fork it. A valid `p`/`c` is the authorization: it proves card
|
||||
possession and prevents a cloned UID from misdirecting a deposit.
|
||||
- No auth key/header — like `/scan`, this is a public LNURL-style endpoint
|
||||
gated solely by the SUN.
|
||||
|
||||
## Response
|
||||
|
||||
Return **one** of the following JSON shapes (the ATM accepts all three). Since
|
||||
each card wallet has a Lightning Address, either of the first two is simplest.
|
||||
|
||||
### (a) Lightning Address (recommended)
|
||||
|
||||
```json
|
||||
{ "lightningAddress": "cardname@l484.com" }
|
||||
```
|
||||
|
||||
The ATM resolves it via LUD-16 (`/.well-known/lnurlp/cardname`) → LUD-06 pay.
|
||||
|
||||
### (b) LUD-06 payRequest, inline
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "payRequest",
|
||||
"callback": "https://lnbits.l484.com/lnurlp/api/v1/lnurl/<id>",
|
||||
"minSendable": 1000,
|
||||
"maxSendable": 100000000,
|
||||
"metadata": "[[\"text/plain\",\"bolt card top-up\"]]"
|
||||
}
|
||||
```
|
||||
|
||||
Hand back the card wallet's existing `lnurlp` payRequest directly (no extra
|
||||
round-trip for the ATM).
|
||||
|
||||
### (c) lnurlp pointer
|
||||
|
||||
```json
|
||||
{ "lnurlp": "https://lnbits.l484.com/lnurlp/<id>" }
|
||||
```
|
||||
|
||||
An `https://` (or `lnurl://`) URL the ATM will fetch to get the payRequest.
|
||||
|
||||
### Error
|
||||
|
||||
```json
|
||||
{ "status": "ERROR", "reason": "invalid card" }
|
||||
```
|
||||
|
||||
Use for a failed SUN check, a disabled/unknown card, or a wallet with no pay
|
||||
target. `reason` is surfaced verbatim on the ATM screen, so keep it terse and
|
||||
non-sensitive.
|
||||
|
||||
## Flow, end to end
|
||||
|
||||
```
|
||||
customer inserts cash → ATM owes N sats → customer taps Bolt Card
|
||||
→ ATM reads lnurlw (external_id + fresh p/c)
|
||||
→ GET /boltcards/api/v1/pay/<external_id>?p=&c= ← THIS ENDPOINT
|
||||
→ { lightningAddress | payRequest | lnurlp }
|
||||
→ ATM: LUD-16/LUD-06 → GET callback?amount=<N*1000 msat> → BOLT11
|
||||
→ ATM pays the BOLT11 over its own nostr transport → card wallet credited
|
||||
→ PAYMENT_RECEIVED → cash-in completes
|
||||
```
|
||||
|
||||
Amounts are in **millisatoshis** on the LUD-06 callback (`amount=<msat>`), per
|
||||
spec. Make sure each card wallet's `minSendable`/`maxSendable` span the ATM's
|
||||
payout range or the tap will be declined with "amount is above/below the card
|
||||
wallet …".
|
||||
|
||||
## Notes
|
||||
|
||||
- **Tap-to-enter uses `/session` instead.** When the access gate is on, the
|
||||
card was already verified at entry and the ATM holds the LUD-06 second step
|
||||
from `/session` (see `boltcard-session.md`), so Complete calls `pay.callback`
|
||||
directly and never touches `/pay`. `/pay` remains the path for a card tapped
|
||||
directly on the cash-in screen (gate off, or a second card).
|
||||
|
||||
- **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a
|
||||
fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry
|
||||
while a tap is in flight and leaves `displayingQR` on success; the withdraw
|
||||
link is `uses:1`. A customer would have to both tap and pull near-simultaneously
|
||||
to double-collect — acceptable for now, revisit if it bites.
|
||||
- **Future nostr transport:** `resolveCardPayTarget` (the `/pay` GET) is the one
|
||||
HTTPS-today / nostr-tomorrow seam. A nostr-native boltcard would answer the
|
||||
same `external_id + SUN` identity over the ATM's existing nostr connection,
|
||||
dropping the clearnet HTTPS call. The rest (standard LNURL-pay) is unchanged.
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
# Bolt Card session — one verified tap for a terminal visit
|
||||
|
||||
Wire contract for the `/session` endpoint the ATM's access gate (ADR-003
|
||||
tap-to-enter) uses on the LNbits `boltcards` extension. Implemented in the
|
||||
aiolabs fork (`git.atitlan.io/aiolabs/boltcards`, `v1.1.1-aio.3`+); consumed by
|
||||
`apps/machine/electron/boltcard-session.ts`.
|
||||
|
||||
## Why a session
|
||||
|
||||
A Bolt Card tap yields a single-use SUN `p`/`c`: the card server verifies it
|
||||
and advances the card's read counter, so any endpoint that checks it — `/scan`,
|
||||
`/pay`, `/verify` — spends it. The gate wants two things from one tap:
|
||||
|
||||
1. **Verify at entry** — a genuine, non-replayed card unlocks the terminal and
|
||||
we can show the holder their balance.
|
||||
2. **Complete without a second tap** — the buy or sell later in the visit
|
||||
moves sats with the same card.
|
||||
|
||||
`/session` does the verification once and hands back the _second steps_ of
|
||||
both LNURL flows, keyed by a single-use server-side `hit` — the same bearer
|
||||
`/scan` (as `k1`) and `/pay` already issue. The terminal holds no `p`/`c`
|
||||
afterwards.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET /boltcards/api/v1/session/{external_id}?p={p}&c={c}
|
||||
```
|
||||
|
||||
Same URL shape as `/scan/{external_id}?p=&c=` with `scan` → `session`; the ATM
|
||||
derives it by string substitution on the tapped `lnurlw`
|
||||
(`scanUrlToSessionUrl()`). SUN verification is byte-for-byte `/scan`'s (shared
|
||||
helper in the fork): unknown / disabled card, UID mismatch, bad CMAC, replayed
|
||||
counter all reject with `/scan`'s reasons. On success the counter advances and
|
||||
one `hit` is recorded.
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"external_id": "abc123",
|
||||
"card_name": "Alice",
|
||||
"balance_msat": 123456000,
|
||||
"currency": "USD",
|
||||
"fiat": 98.76,
|
||||
"withdraw": {
|
||||
"callback": "https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/<hit>",
|
||||
"k1": "<hit>",
|
||||
"minWithdrawable": 1000,
|
||||
"maxWithdrawable": 50000000
|
||||
},
|
||||
"withdraw_blocked_reason": null,
|
||||
"pay": {
|
||||
"callback": "https://lnbits.l484.com/boltcards/api/v1/pay/cb/<hit>",
|
||||
"minSendable": 1000,
|
||||
"maxSendable": 50000000,
|
||||
"metadata": "[[\"text/plain\",\"Bolt Card top-up\"]]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `balance_msat` — the card wallet's balance. Display only.
|
||||
- `currency` / `fiat` — the balance priced the way the LNbits wallet page does
|
||||
it: the wallet's own currency (per-wallet setting) first, else the instance's
|
||||
default accounting currency. `fiat` is filled **only from the server's
|
||||
already-warm rate cache** — this response gates the unlock, and a cold rate
|
||||
lookup queries external exchanges (~1 s). On a cache miss it is `null` and
|
||||
the ATM prices the sats itself: in `currency` from its own rate source, else
|
||||
in its own fiat at its display rate. No rate lookup ever blocks the session.
|
||||
- `withdraw` — the LUD-03 second step. The ATM calls
|
||||
`callback?k1=<hit>&pr=<bolt11>` at cash-out Complete. `null` with
|
||||
`withdraw_blocked_reason` set when `/scan` would have refused (daily limit
|
||||
spent); cash-in stays possible.
|
||||
- `pay` — the LUD-06 second step. The ATM calls `callback?amount=<msat>` at
|
||||
cash-in Complete and pays the returned BOLT11 over its own nostr transport.
|
||||
- Limits are the card's `tx_limit`, as on `/scan` and `/pay`.
|
||||
|
||||
Rejection:
|
||||
|
||||
```json
|
||||
{ "authenticated": false, "reason": "This link is already used." }
|
||||
```
|
||||
|
||||
`reason` is surfaced verbatim on the locked screen — terse, non-sensitive.
|
||||
|
||||
## Semantics of the hit
|
||||
|
||||
- The first withdraw that uses the hit spends it (`spent = true`), exactly as
|
||||
after a `/scan`; a second withdraw is refused with "Payment already claimed."
|
||||
- A top-up does not mark the hit spent (as `/pay` today).
|
||||
- The ATM treats the whole session as single-shot regardless: after the first
|
||||
Complete attempt, accepted or declined, it drops the session and asks for a
|
||||
re-tap (`completeWithCard` in `stores/atm.ts`).
|
||||
- Hits do not expire server-side. The ATM's session security (60 s idle,
|
||||
10 min cap, End Session) bounds how long one is held.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
locked ── tap ─▶ GET /session/<id>?p=&c= (spends the SUN)
|
||||
├─ authenticated:false → stay locked, show reason
|
||||
└─ authenticated:true → authorize(external_id) → idle, session held
|
||||
CardChip: "Alice · ••c123 •••••• [eye]"
|
||||
sell: pick amount → Complete Sale → withdraw.callback?k1&pr → PAYMENT_RECEIVED → dispense
|
||||
buy: insert cash → Complete Purchase → pay.callback?amount → BOLT11 → ATM pays → complete
|
||||
… re-lock (End Session / idle / complete) drops the session
|
||||
```
|
||||
|
||||
## Trust boundary (read this)
|
||||
|
||||
The ATM derives the session URL from the **card's own `lnurlw` host**. With
|
||||
`openEnrollment` on, a forged NDEF tag pointing at an attacker's server that
|
||||
answers `{"authenticated": true, …}` still unlocks the terminal. Money is not
|
||||
at risk — a fake server can only make the ATM pay an invoice the holder chose
|
||||
(cash-in) or accept a pull it never honours (cash-out never dispenses without
|
||||
`PAYMENT_RECEIVED`) — but the _gate_ is only as trustworthy as the host it was
|
||||
told to ask. Closing that means pinning the card-server host(s) the gate
|
||||
accepts; tracked in aiolabs/bitspire#91.
|
||||
244
flake.nix
244
flake.nix
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
description = "bitSpire - Nostr-Native Lightning ATM";
|
||||
description = "Lamassu Next - Nostr-Native Lightning ATM";
|
||||
|
||||
inputs = {
|
||||
# Stable NixOS for the ATM OS base
|
||||
|
|
@ -67,18 +67,6 @@
|
|||
batm3 = "USD";
|
||||
};
|
||||
|
||||
# Timezone per machine model. Wall-clock time on an ATM is not cosmetic:
|
||||
# it is what the journal is read in when someone is standing at the
|
||||
# machine, and system.autoUpgrade's `dates = "04:00"` is local, so this
|
||||
# decides when the nightly rebuild restarts the app and the dispenser
|
||||
# runs its audible init routine. A machine on the wrong zone does that
|
||||
# in the middle of its own business hours.
|
||||
#
|
||||
# Unlisted models fall back to the fleet default in configuration.nix.
|
||||
timeZoneForModel = {
|
||||
sintra = "Europe/Paris";
|
||||
};
|
||||
|
||||
lib = nixpkgs.lib;
|
||||
|
||||
# Helper to create a live USB NixOS config for a specific machine model
|
||||
|
|
@ -136,10 +124,6 @@
|
|||
appDir = "${atm-app}";
|
||||
};
|
||||
|
||||
# Per-machine wall clock; see timeZoneForModel above.
|
||||
time.timeZone = lib.mkIf (timeZoneForModel ? ${machineModel})
|
||||
timeZoneForModel.${machineModel};
|
||||
|
||||
# Operator TUI and CLI tools
|
||||
environment.systemPackages = [
|
||||
atm-tui.packages.${system}.default
|
||||
|
|
@ -185,51 +169,45 @@
|
|||
};
|
||||
|
||||
# Auto-upgrade: pulls latest flake and runs nixos-rebuild switch.
|
||||
# NOTE: bitSpire machines pull from the `aiolabs/bitspire` repo —
|
||||
# the post-migration home of this code. This branch (dev) pins the
|
||||
# upgrade source to ?ref=dev so any ATM flashed from `dev` stays on
|
||||
# `dev`. Without the explicit ?ref=dev, nix would resolve the repo's
|
||||
# default branch and could silently change a dev-deployed Sintra at
|
||||
# 04:00. The legacy `aiolabs/lamassu-next` repo still feeds the
|
||||
# not-yet-converted production ATMs (batm3, douro) from its own
|
||||
# branches; it is retired once those machines migrate to bitspire.
|
||||
# To update manually: sudo nixos-rebuild switch --flake git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#<model>-installed
|
||||
# NOTE: this branch (dev) pins the upgrade source to ?ref=dev so
|
||||
# any ATM flashed from `dev` stays on `dev`. Without the explicit
|
||||
# ref, nix would resolve to the repo's default branch (main),
|
||||
# which would silently regress a dev-deployed Sintra back to the
|
||||
# lamassu-next production code at 04:00. The `main` branch's
|
||||
# flake.nix continues to omit ?ref= so production ATMs (batm3,
|
||||
# douro) keep pulling main HEAD as before.
|
||||
# To update manually: sudo nixos-rebuild switch --flake git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#<model>-installed
|
||||
system.autoUpgrade = {
|
||||
enable = true;
|
||||
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#${machineModel}-installed";
|
||||
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#${machineModel}-installed";
|
||||
flags = [ "--refresh" ];
|
||||
dates = "04:00"; # daily at 4am
|
||||
allowReboot = false;
|
||||
};
|
||||
|
||||
# Minimal env template (aiolabs/bitspire#70 remnant hygiene).
|
||||
# Seed ONLY image-baked, non-maskable values. Everything else the
|
||||
# ATM needs comes from the pairing SEED (relay, lnbits_npub, bunker)
|
||||
# or from LNbits over the transport (operator pubkey, fee config) —
|
||||
# so we must NOT pre-seed those keys. A present-but-empty
|
||||
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS
|
||||
# is a masking hazard: env WINS over the seed, and this activation
|
||||
# only writes when .env is ABSENT, so any value written at first
|
||||
# boot is frozen for the life of the disk. Leaving the keys out
|
||||
# entirely lets the seed/transport be the sole source.
|
||||
# Env template — runtime secrets provisioned via provision-atm.sh.
|
||||
# Identity fields are intentionally empty so a fresh disk image
|
||||
# boots cleanly into the "needs provisioning" state; provision-
|
||||
# atm.sh SSHes in and overwrites with real values.
|
||||
#
|
||||
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY are emitted ONLY when
|
||||
# the operator deliberately pins them via the Nix options (non-empty
|
||||
# default ""), which is an explicit override that wins over the seed.
|
||||
# VITE_RELAY_URL seeds from `config.services.bitspire.relayUrl`
|
||||
# so the NixOS module's `relayUrl` option becomes the default
|
||||
# without losing the operator's ability to override via .env
|
||||
# (edit the file or re-run provision-atm.sh).
|
||||
system.activationScripts.bitspire-env = ''
|
||||
mkdir -p /var/lib/bitspire
|
||||
if [ ! -f /var/lib/bitspire/.env ]; then
|
||||
cp ${pkgs.writeText "bitspire-env-default" (''
|
||||
cp ${pkgs.writeText "bitspire-env-default" ''
|
||||
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
|
||||
VITE_LNBITS_SERVER_PUBKEY=
|
||||
VITE_ATM_PRIVATE_KEY=
|
||||
VITE_APP_ID=
|
||||
VITE_OPERATOR_PUBKEYS=
|
||||
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
||||
VITE_LAMASSU_FIAT_CODE=${fiatCode}
|
||||
VITE_SPIRE_SEED=
|
||||
ELECTRON_FORCE_PROD=1
|
||||
DISPLAY=:0
|
||||
'' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") ''
|
||||
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
|
||||
'' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") ''
|
||||
VITE_LNBITS_SERVER_PUBKEY=${config.services.bitspire.lnbitsServerPubkey}
|
||||
'')} /var/lib/bitspire/.env
|
||||
''} /var/lib/bitspire/.env
|
||||
chmod 600 /var/lib/bitspire/.env
|
||||
chown bitspire:bitspire /var/lib/bitspire/.env
|
||||
fi
|
||||
|
|
@ -308,37 +286,6 @@
|
|||
# at ttyJ5, dispenser at ttyJ7 layout) — reuse the same hw module.
|
||||
sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix;
|
||||
batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix;
|
||||
|
||||
# USB-bootable variant of batm3-installed. This is the config the
|
||||
# flashed USB stick actually runs — distinct fs labels so stage-1 can't
|
||||
# latch the internal drive, nofail /boot, no growPartition, autoUpgrade
|
||||
# off. Exposed as a named config (not just inline in the disk-image
|
||||
# target) so its system closure can be built here and deployed in-place
|
||||
# with `nix copy` + `switch-to-configuration` — updating the app on a
|
||||
# running stick WITHOUT reflashing (preserves pairing + /var/lib state).
|
||||
# disk-image-batm3-usb builds its filesystem image from this same config.
|
||||
batm3-usb = self.nixosConfigurations.batm3-installed.extendModules {
|
||||
modules = [
|
||||
({ lib, ... }: {
|
||||
fileSystems."/".device = lib.mkForce "/dev/disk/by-label/nixos-usb";
|
||||
fileSystems."/boot".device = lib.mkForce "/dev/disk/by-label/ESP-USB";
|
||||
# /boot must NOT be a hard boot dependency on the USB image. The
|
||||
# firmware already loaded the bootloader before Linux; without
|
||||
# nofail, a slow/late ESP-USB enumeration (BOT is slower than UAS)
|
||||
# blows past systemd's 90s device-timeout into emergency mode with
|
||||
# root locked — a dead end. nofail + short timeout lets the
|
||||
# already-mounted root carry the boot; /boot mounts if/when it shows.
|
||||
fileSystems."/boot".options = [ "nofail" "x-systemd.device-timeout=10s" ];
|
||||
# NO growPartition/autoResize: sfdisk rewriting the partition table
|
||||
# on first boot is the single most bus-stressing write, and flaky
|
||||
# USB bridges drop off the bus mid-rewrite (sfdisk wedges in D-state
|
||||
# and ESP-USB vanishes with the device). Persistent state is a few
|
||||
# MB and the image ships ~2GB free. The internal-SATA disk-image-
|
||||
# batm3 keeps growPartition (a real AHCI SSD won't drop the bus).
|
||||
system.autoUpgrade.enable = lib.mkForce false;
|
||||
})
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# ── Standalone NixOS module ───────────────────────────────────
|
||||
|
|
@ -378,147 +325,6 @@
|
|||
diskSize = "auto";
|
||||
};
|
||||
|
||||
# BATM3 (OptiPlex 9030 AIO board-swap) installed image, dd-able to
|
||||
# its SATA drive. Unlike the douro/sintra images, this one grows
|
||||
# itself: growPartition expands the root partition to fill whatever
|
||||
# drive it lands on (16GB today) at first boot and autoResize
|
||||
# stretches the ext4 to match — no manual parted/resize2fs step
|
||||
# after flashing, and all the drive's headroom is available to the
|
||||
# nix store from day one (cf. #55). Image-only override: once
|
||||
# grown, subsequent nixos-rebuilds against plain batm3-installed
|
||||
# are unaffected.
|
||||
disk-image-batm3 =
|
||||
let
|
||||
cfg = self.nixosConfigurations.batm3-installed.extendModules {
|
||||
modules = [
|
||||
{
|
||||
boot.growPartition = true;
|
||||
fileSystems."/".autoResize = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
in
|
||||
import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
|
||||
inherit pkgs lib;
|
||||
config = cfg.config;
|
||||
format = "raw";
|
||||
partitionTableType = "efi";
|
||||
diskSize = "auto";
|
||||
};
|
||||
|
||||
# USB-bootable Sintra image with DISTINCT partition labels
|
||||
# (nixos-usb / ESP-USB) so the stick can be booted on a Sintra whose
|
||||
# eMMC already holds a nixos/ESP-labelled install without a by-label
|
||||
# collision — stage-1 would otherwise race between the two roots and
|
||||
# likely mount the eMMC. Auto-upgrade is disabled: this is a portable
|
||||
# test / hand-off image, not a managed fleet member, and disabling it
|
||||
# also removes the scheduled bootloader writes that could otherwise
|
||||
# land on the eMMC's ESP.
|
||||
disk-image-sintra-usb =
|
||||
let
|
||||
cfg = self.nixosConfigurations.sintra-installed.extendModules {
|
||||
modules = [
|
||||
({ lib, ... }: {
|
||||
fileSystems."/".device = lib.mkForce "/dev/disk/by-label/nixos-usb";
|
||||
fileSystems."/boot".device = lib.mkForce "/dev/disk/by-label/ESP-USB";
|
||||
system.autoUpgrade.enable = lib.mkForce false;
|
||||
|
||||
# The Sintra's Aaeon firmware USB-boots in Legacy/BIOS mode — it
|
||||
# boots the live ISO via its isolinux (BIOS) El Torito image, not
|
||||
# the UEFI ESP. systemd-boot is UEFI-only, so a dd'd systemd-boot
|
||||
# image isn't recognised as bootable. Switch THIS USB image to
|
||||
# GRUB with BOTH BIOS (MBR + bios_grub partition, via the "hybrid"
|
||||
# table below) and UEFI (removable /EFI/BOOT/BOOTX64.EFI) — mirroring
|
||||
# the live ISO's dual boot — so it boots on Legacy and UEFI alike.
|
||||
# Scoped to the USB image; the eMMC install keeps systemd-boot.
|
||||
boot.loader.systemd-boot.enable = lib.mkForce false;
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||
boot.loader.grub = {
|
||||
enable = lib.mkForce true;
|
||||
efiSupport = true;
|
||||
efiInstallAsRemovable = true;
|
||||
# make-disk-image's build VM exposes the image as /dev/vda;
|
||||
# GRUB installs its BIOS stage to that disk's MBR.
|
||||
devices = lib.mkForce [ "/dev/vda" ];
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
baseImage = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
|
||||
inherit pkgs lib;
|
||||
config = cfg.config;
|
||||
format = "raw";
|
||||
# hybrid = GPT + bios_grub partition + ESP → BIOS + UEFI bootable.
|
||||
partitionTableType = "hybrid";
|
||||
diskSize = "auto";
|
||||
label = "nixos-usb"; # ext4 root label (make-disk-image -L)
|
||||
};
|
||||
in
|
||||
pkgs.runCommand "nixos-disk-image-sintra-usb"
|
||||
{ nativeBuildInputs = [ pkgs.parted pkgs.mtools ]; }
|
||||
''
|
||||
mkdir -p $out
|
||||
cp --sparse=always ${baseImage}/nixos.img $out/nixos.img
|
||||
chmod +w $out/nixos.img
|
||||
# make-disk-image hardcodes the ESP FAT label to "ESP"; relabel the
|
||||
# volume to ESP-USB so /boot (by-label/ESP-USB) doesn't collide with
|
||||
# the eMMC's ESP. Volume label only — bootloader files are untouched,
|
||||
# and UEFI loads /EFI/BOOT/BOOTX64.EFI regardless of the label.
|
||||
espStart=$(parted -sm "$out/nixos.img" unit B print | awk -F: '$1==1 {gsub("B","",$2); print $2}')
|
||||
echo "ESP partition starts at byte $espStart — relabelling to ESP-USB"
|
||||
export MTOOLS_SKIP_CHECK=1
|
||||
mlabel -i "$out/nixos.img@@$espStart" ::ESP-USB
|
||||
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
|
||||
'';
|
||||
|
||||
# USB-bootable BATM3 TEST image with DISTINCT partition labels
|
||||
# (nixos-usb / ESP-USB). The plain disk-image-batm3 reuses the generic
|
||||
# nixos/ESP labels, so a USB stick carrying it, booted on a batm3 whose
|
||||
# internal SATA drive ALREADY holds a nixos/ESP-labelled install, makes
|
||||
# stage-1's by-label/nixos resolve to the internal drive (larger fs,
|
||||
# journal recovers) instead of the stick — the stage-2 init path baked
|
||||
# into the USB's boot entry isn't on that root, so stage 1 aborts.
|
||||
# Distinct labels make stage-1 pick the stick unambiguously WITHOUT
|
||||
# touching the internal drive. Unlike disk-image-sintra-usb this keeps
|
||||
# systemd-boot: the batm3 firmware UEFI-USB-boots fine via the ESP's
|
||||
# /EFI/BOOT/BOOTX64.EFI removable fallback, so no GRUB/hybrid-table
|
||||
# change is needed — only the label disambiguation here plus the
|
||||
# usb_storage/uas initrd modules (in batm3.nix). Does NOT grow to fill
|
||||
# the stick (see the growPartition note below — sfdisk on first boot
|
||||
# wedges flaky USB bridges); auto-upgrade off (test image, not a managed
|
||||
# fleet member — also stops scheduled bootloader writes landing on the
|
||||
# internal drive's ESP).
|
||||
disk-image-batm3-usb =
|
||||
let
|
||||
# Filesystem image of the batm3-usb config (defined in
|
||||
# nixosConfigurations). Same config that in-place deploys target, so
|
||||
# a reflash and a `switch-to-configuration` converge on one system.
|
||||
baseImage = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
|
||||
inherit pkgs lib;
|
||||
config = self.nixosConfigurations.batm3-usb.config;
|
||||
format = "raw";
|
||||
partitionTableType = "efi";
|
||||
diskSize = "auto";
|
||||
label = "nixos-usb"; # ext4 root label (make-disk-image -L)
|
||||
};
|
||||
in
|
||||
pkgs.runCommand "nixos-disk-image-batm3-usb"
|
||||
{ nativeBuildInputs = [ pkgs.parted pkgs.mtools ]; }
|
||||
''
|
||||
mkdir -p $out
|
||||
cp --sparse=always ${baseImage}/nixos.img $out/nixos.img
|
||||
chmod +w $out/nixos.img
|
||||
# make-disk-image hardcodes the ESP FAT label to "ESP"; relabel the
|
||||
# volume to ESP-USB so /boot (by-label/ESP-USB) can't resolve to an
|
||||
# internal drive's ESP. Volume label only — bootloader files are
|
||||
# untouched, and UEFI loads /EFI/BOOT/BOOTX64.EFI regardless.
|
||||
espStart=$(parted -sm "$out/nixos.img" unit B print | awk -F: '$1==1 {gsub("B","",$2); print $2}')
|
||||
echo "ESP partition starts at byte $espStart — relabelling to ESP-USB"
|
||||
export MTOOLS_SKIP_CHECK=1
|
||||
mlabel -i "$out/nixos.img@@$espStart" ::ESP-USB
|
||||
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
|
||||
'';
|
||||
|
||||
# Backwards compat
|
||||
iso = self.nixosConfigurations.douro.config.system.build.isoImage;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Pure Nix derivation for the bitSpire ATM Electron app.
|
||||
# Pure Nix derivation for the Lamassu ATM Electron app.
|
||||
#
|
||||
# Uses fetchPnpmDeps + pnpmConfigHook to build entirely inside the Nix sandbox,
|
||||
# eliminating the need for --impure or a local pnpm install.
|
||||
|
|
@ -38,7 +38,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
inherit (finalAttrs) pname version src pnpmWorkspaces;
|
||||
inherit pnpm;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-XqpQpFL3PqnFltb4ujAmmnnV0LOqTHKV/bo3riFu9pY=";
|
||||
hash = "sha256-Jv5p62E40DtCSZvN/+LTzkhRYJBTyyUOVSBlQyxzcEw=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
|
|
@ -57,11 +57,6 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
pkgs.sqlite.dev # better-sqlite3
|
||||
pkgs.libudev-zero # serialport
|
||||
pkgs.stdenv.cc.cc.lib # libstdc++
|
||||
# @pokusew/pcsclite (nfc-pcsc): the `lib` output carries libpcsclite.so so
|
||||
# autoPatchelf wires it into the .node RPATH at runtime. Compile/link paths
|
||||
# are injected via CPATH/LIBRARY_PATH in buildPhase (its binding.gyp
|
||||
# hardcodes Debian /usr paths instead of using pkg-config).
|
||||
pkgs.pcsclite.lib
|
||||
];
|
||||
|
||||
env = {
|
||||
|
|
@ -88,19 +83,6 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
--arch=x64
|
||||
popd
|
||||
|
||||
# @pokusew/pcsclite (nfc-pcsc's native addon) — also V8 C++ API, so it too
|
||||
# must be rebuilt against Electron's headers. Its binding.gyp hardcodes
|
||||
# /usr/include/PCSC + /usr/lib, so point the compiler/linker at nixpkgs'
|
||||
# pcsclite explicitly (winscard.h lives under include/PCSC).
|
||||
echo "=== Rebuilding @pokusew/pcsclite against Electron ${electron.version} headers ==="
|
||||
pushd node_modules/.pnpm/@pokusew+pcsclite@*/node_modules/@pokusew/pcsclite
|
||||
CPATH="${pkgs.pcsclite.dev}/include/PCSC''${CPATH:+:$CPATH}" \
|
||||
LIBRARY_PATH="${pkgs.pcsclite.lib}/lib''${LIBRARY_PATH:+:$LIBRARY_PATH}" \
|
||||
HOME=$TMPDIR ${nodejs}/bin/npx --yes node-gyp rebuild \
|
||||
--nodedir="$electron_nodedir" \
|
||||
--arch=x64
|
||||
popd
|
||||
|
||||
# Build the Electron app (turbo builds all workspace deps + app)
|
||||
pnpm --filter="@bitSpire/machine..." build
|
||||
|
||||
|
|
@ -113,7 +95,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/node_modules/{@lamassu,@serialport,@pokusew}
|
||||
mkdir -p $out/node_modules/{@lamassu,@serialport}
|
||||
|
||||
# Helper: find a package dir inside the pnpm virtual store.
|
||||
# pnpm store dirs look like: node_modules/.pnpm/<name>@<ver>[_<peer-suffix>]/node_modules/<name>
|
||||
|
|
@ -150,12 +132,6 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
|||
copy_pnpm_pkg bindings $out/node_modules/bindings
|
||||
copy_pnpm_pkg file-uri-to-path $out/node_modules/file-uri-to-path
|
||||
|
||||
# nfc-pcsc + @pokusew/pcsclite (Bolt Card reader). The compiled
|
||||
# pcsclite.node (from the rebuild above) rides along in the package dir and
|
||||
# loads via `bindings` (already copied). autoPatchelf wires libpcsclite.
|
||||
copy_pnpm_pkg nfc-pcsc $out/node_modules/nfc-pcsc
|
||||
copy_pnpm_pkg @pokusew/pcsclite $out/node_modules/@pokusew/pcsclite
|
||||
|
||||
# @bitSpire/hal (workspace package, dynamically imported for hardware access)
|
||||
mkdir -p $out/node_modules/@bitSpire/hal/dist
|
||||
cp -rL packages/hal/dist/* $out/node_modules/@bitSpire/hal/dist/
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
"build": "turbo build",
|
||||
"build:web": "turbo build:web",
|
||||
"test": "turbo test",
|
||||
"lint": "turbo lint",
|
||||
"format": "prettier --write .",
|
||||
|
|
|
|||
|
|
@ -10,30 +10,34 @@
|
|||
* Uses NIP-44v2 encryption for all messages.
|
||||
*/
|
||||
|
||||
import type { Event, EventTemplate } from 'nostr-tools'
|
||||
import type { NostrClient, Signer } from '@bitSpire/nostr-client'
|
||||
import type { Event, UnsignedEvent } from 'nostr-tools'
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import type { MachineIdentity, NostrClient } from '@bitSpire/nostr-client'
|
||||
import { encryptContentV2, decryptContentV2 } from '@bitSpire/nostr-client'
|
||||
|
||||
/** CLINK protocol version tag (mandatory per CLINK spec) */
|
||||
const CLINK_VERSION_TAG: [string, string] = ['clink_version', '1']
|
||||
|
||||
/**
|
||||
* Encrypt content using NIP-44 v2 (required for CLINK events).
|
||||
* Routes through the Signer so the spire identity can live in a bunker.
|
||||
* Encrypt content using NIP-44 v2 (required for CLINK events)
|
||||
*/
|
||||
function encryptCLINK(signer: Signer, recipientPubkey: string, content: unknown): Promise<string> {
|
||||
const plaintext = typeof content === 'string' ? content : JSON.stringify(content)
|
||||
return signer.nip44Encrypt(recipientPubkey, plaintext)
|
||||
function encryptCLINK(
|
||||
identity: MachineIdentity,
|
||||
recipientPubkey: string,
|
||||
content: unknown
|
||||
): string {
|
||||
return encryptContentV2(identity, recipientPubkey, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and parse JSON content using NIP-44 v2.
|
||||
* Decrypt and parse JSON content using NIP-44 v2
|
||||
*/
|
||||
async function decryptCLINKJSON<T = unknown>(
|
||||
signer: Signer,
|
||||
function decryptCLINKJSON<T = unknown>(
|
||||
identity: MachineIdentity,
|
||||
senderPubkey: string,
|
||||
ciphertext: string
|
||||
): Promise<T> {
|
||||
const plaintext = await signer.nip44Decrypt(senderPubkey, ciphertext)
|
||||
): T {
|
||||
const plaintext = decryptContentV2(identity, senderPubkey, ciphertext)
|
||||
return JSON.parse(plaintext) as T
|
||||
}
|
||||
import {
|
||||
|
|
@ -63,8 +67,8 @@ import { encodeNoffer, decodeNoffer } from './noffer.js'
|
|||
export interface CLINKClientOptions {
|
||||
/** Nostr client for communication */
|
||||
nostrClient: NostrClient
|
||||
/** Signer for the spire identity (local nsec or remote bunker) */
|
||||
signer: Signer
|
||||
/** Machine identity */
|
||||
identity: MachineIdentity
|
||||
/** Operator pubkey(s) for management commands */
|
||||
operatorPubkey: string | string[]
|
||||
/** Relays to use for offers */
|
||||
|
|
@ -98,7 +102,7 @@ export type ManagementHandler = (
|
|||
*/
|
||||
export class CLINKClient {
|
||||
private nostrClient: NostrClient
|
||||
private signer: Signer
|
||||
private identity: MachineIdentity
|
||||
private operatorPubkeys: string[]
|
||||
private relays: string[]
|
||||
private generateInvoice?: GenerateInvoice
|
||||
|
|
@ -116,7 +120,7 @@ export class CLINKClient {
|
|||
|
||||
constructor(options: CLINKClientOptions) {
|
||||
this.nostrClient = options.nostrClient
|
||||
this.signer = options.signer
|
||||
this.identity = options.identity
|
||||
this.operatorPubkeys = Array.isArray(options.operatorPubkey)
|
||||
? options.operatorPubkey
|
||||
: [options.operatorPubkey]
|
||||
|
|
@ -140,7 +144,7 @@ export class CLINKClient {
|
|||
currency?: string
|
||||
}): string {
|
||||
const offer: CLINKOffer = {
|
||||
pubkey: this.signer.pubkey,
|
||||
pubkey: this.identity.publicKey,
|
||||
relays: this.relays,
|
||||
priceType: options.priceType,
|
||||
offerId: options.offerId,
|
||||
|
|
@ -189,7 +193,7 @@ export class CLINKClient {
|
|||
[
|
||||
{
|
||||
kinds: [CLINKEventKind.Offer, CLINKEventKind.Debit, CLINKEventKind.Manage],
|
||||
'#p': [this.signer.pubkey],
|
||||
'#p': [this.identity.publicKey],
|
||||
},
|
||||
],
|
||||
{
|
||||
|
|
@ -230,9 +234,9 @@ export class CLINKClient {
|
|||
expires_in_seconds: options?.expiresInSeconds,
|
||||
}
|
||||
|
||||
const content = await encryptCLINK(this.signer, offer.pubkey, request)
|
||||
const content = encryptCLINK(this.identity, offer.pubkey, request)
|
||||
|
||||
const event = await this.createSignedEvent({
|
||||
const event = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Offer,
|
||||
content,
|
||||
tags: [['p', offer.pubkey], CLINK_VERSION_TAG],
|
||||
|
|
@ -265,9 +269,9 @@ export class CLINKClient {
|
|||
description: options?.description,
|
||||
}
|
||||
|
||||
const content = await encryptCLINK(this.signer, targetPubkey, request)
|
||||
const content = encryptCLINK(this.identity, targetPubkey, request)
|
||||
|
||||
const event = await this.createSignedEvent({
|
||||
const event = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Debit,
|
||||
content,
|
||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||
|
|
@ -299,9 +303,9 @@ export class CLINKClient {
|
|||
description: options?.description,
|
||||
}
|
||||
|
||||
const content = await encryptCLINK(this.signer, targetPubkey, request)
|
||||
const content = encryptCLINK(this.identity, targetPubkey, request)
|
||||
|
||||
const event = await this.createSignedEvent({
|
||||
const event = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Debit,
|
||||
content,
|
||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||
|
|
@ -320,9 +324,9 @@ export class CLINKClient {
|
|||
targetPubkey: string,
|
||||
request: ManagementRequest
|
||||
): Promise<ManagementResponse> {
|
||||
const content = await encryptCLINK(this.signer, targetPubkey, request)
|
||||
const content = encryptCLINK(this.identity, targetPubkey, request)
|
||||
|
||||
const event = await this.createSignedEvent({
|
||||
const event = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Manage,
|
||||
content,
|
||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||
|
|
@ -390,15 +394,15 @@ export class CLINKClient {
|
|||
return
|
||||
}
|
||||
|
||||
const request = await decryptCLINKJSON<OfferRequest>(this.signer, event.pubkey, event.content)
|
||||
const request = decryptCLINKJSON<OfferRequest>(this.identity, event.pubkey, event.content)
|
||||
|
||||
const response = await this.offerHandler(request, event.pubkey)
|
||||
if (!response) return
|
||||
|
||||
// Send encrypted response with clink_version tag
|
||||
const content = await encryptCLINK(this.signer, event.pubkey, response)
|
||||
const content = encryptCLINK(this.identity, event.pubkey, response)
|
||||
|
||||
const responseEvent = await this.createSignedEvent({
|
||||
const responseEvent = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Offer,
|
||||
content,
|
||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||
|
|
@ -421,14 +425,14 @@ export class CLINKClient {
|
|||
return
|
||||
}
|
||||
|
||||
const request = await decryptCLINKJSON<DebitRequest>(this.signer, event.pubkey, event.content)
|
||||
const request = decryptCLINKJSON<DebitRequest>(this.identity, event.pubkey, event.content)
|
||||
|
||||
const response = await this.debitHandler(request, event.pubkey)
|
||||
|
||||
// Send encrypted response with clink_version tag
|
||||
const content = await encryptCLINK(this.signer, event.pubkey, response)
|
||||
const content = encryptCLINK(this.identity, event.pubkey, response)
|
||||
|
||||
const responseEvent = await this.createSignedEvent({
|
||||
const responseEvent = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Debit,
|
||||
content,
|
||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||
|
|
@ -487,15 +491,15 @@ export class CLINKClient {
|
|||
if (first) this.processedManageEvents.delete(first)
|
||||
}
|
||||
|
||||
const request = await decryptCLINKJSON<ManagementRequest>(this.signer, event.pubkey, event.content)
|
||||
const request = decryptCLINKJSON<ManagementRequest>(this.identity, event.pubkey, event.content)
|
||||
|
||||
const response = await this.managementHandler(request, event.pubkey)
|
||||
if (!response) return
|
||||
|
||||
// Send encrypted response with clink_version tag
|
||||
const content = await encryptCLINK(this.signer, event.pubkey, response)
|
||||
const content = encryptCLINK(this.identity, event.pubkey, response)
|
||||
|
||||
const responseEvent = await this.createSignedEvent({
|
||||
const responseEvent = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Manage,
|
||||
content,
|
||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||
|
|
@ -520,7 +524,7 @@ export class CLINKClient {
|
|||
{
|
||||
kinds: [kind],
|
||||
authors: [fromPubkey],
|
||||
'#p': [this.signer.pubkey],
|
||||
'#p': [this.identity.publicKey],
|
||||
'#e': [requestEventId],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
|
|
@ -536,7 +540,12 @@ export class CLINKClient {
|
|||
|
||||
clearTimeout(timeout)
|
||||
this.nostrClient.unsubscribe(subId)
|
||||
decryptCLINKJSON<T>(this.signer, fromPubkey, event.content).then(resolve).catch(reject)
|
||||
try {
|
||||
const response = decryptCLINKJSON<T>(this.identity, fromPubkey, event.content)
|
||||
resolve(response)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -544,11 +553,11 @@ export class CLINKClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a signed event via the signer (sets pubkey/id/sig). Async because
|
||||
* a BunkerSigner is a relay round-trip.
|
||||
* Create a signed event
|
||||
*/
|
||||
private createSignedEvent(template: EventTemplate): Promise<Event> {
|
||||
return this.signer.signEvent(template)
|
||||
private createSignedEvent(event: Omit<UnsignedEvent, 'pubkey'>): Event {
|
||||
// finalizeEvent derives pubkey from the secret key
|
||||
return finalizeEvent(event, this.identity.privateKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@bitSpire/hal",
|
||||
"version": "0.1.0",
|
||||
"description": "Hardware Abstraction Layer for bitSpire ATM devices",
|
||||
"description": "Hardware Abstraction Layer for Lamassu ATM devices",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
"src"
|
||||
],
|
||||
"keywords": [
|
||||
"bitspire",
|
||||
"lamassu",
|
||||
"atm",
|
||||
"hardware",
|
||||
"bill-validator",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* @bitSpire/hal - Hardware Abstraction Layer
|
||||
*
|
||||
* Provides drivers for bitSpire ATM hardware devices:
|
||||
* Provides drivers for Lamassu ATM hardware devices:
|
||||
* - Bill validators (JCM iVIZION via ID003 protocol)
|
||||
* - Bill dispensers (Fujitsu F53/F56)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -313,13 +313,6 @@ export class EbdsRs232 extends EventEmitter {
|
|||
private serial: SerialPort | null = null
|
||||
private ack: number = 0x0
|
||||
private enabledDenominations: number = 0x00
|
||||
// Latched escrow decision. In EBDS the stack/return choice is NOT a one-shot
|
||||
// message — it's carried as bits in the omnibus poll command, and the device
|
||||
// holds the escrowed note until a poll asserts stack or return. We keep the
|
||||
// action set and re-assert it on every poll until the device leaves escrow
|
||||
// (cleared in _process), so a single dropped/collided frame no longer strands
|
||||
// the note in escrow forever.
|
||||
private pendingAction: 'none' | 'stack' | 'return' = 'none'
|
||||
private lastStatusFlags: string | null = null
|
||||
private firmwareLogged: boolean = false
|
||||
|
||||
|
|
@ -412,38 +405,23 @@ export class EbdsRs232 extends EventEmitter {
|
|||
|
||||
// -- Commands (Appendix D, Controller Message) ---------------------------
|
||||
|
||||
/**
|
||||
* Command byte 1 for the omnibus poll, encoding any latched escrow action.
|
||||
* `stack` (0x3f) and `return` (0x5f) differ from the plain poll (0x1b) only
|
||||
* in the stack/return bits; while an action is latched every poll re-asserts
|
||||
* it until the device acts.
|
||||
*/
|
||||
private commandByte(): number {
|
||||
if (this.pendingAction === 'stack') return 0x3f
|
||||
if (this.pendingAction === 'return') return 0x5f
|
||||
return 0x1b
|
||||
}
|
||||
|
||||
/** Send an Omnibus poll command with the current mask + latched action */
|
||||
/** Send an Omnibus poll command with current denomination mask */
|
||||
poll(): void {
|
||||
this._dispatch([this.enabledDenominations, this.commandByte(), 0x10])
|
||||
this._dispatch([this.enabledDenominations, 0x1b, 0x10])
|
||||
}
|
||||
|
||||
/** Latch "stack the escrowed note"; re-asserted each poll until it takes. */
|
||||
/** Stack the bill currently in escrow */
|
||||
stack(): void {
|
||||
this.pendingAction = 'stack'
|
||||
this.poll()
|
||||
this._dispatch([this.enabledDenominations, 0x3f, 0x10])
|
||||
}
|
||||
|
||||
/** Latch "return the escrowed note"; re-asserted each poll until it takes. */
|
||||
/** Reject/return the bill currently in escrow */
|
||||
reject(): void {
|
||||
this.pendingAction = 'return'
|
||||
this.poll()
|
||||
this._dispatch([this.enabledDenominations, 0x5f, 0x10])
|
||||
}
|
||||
|
||||
/** Send initial setup command (disable all, reset state) */
|
||||
reset(): void {
|
||||
this.pendingAction = 'none'
|
||||
this._dispatch([0x00, 0x1b, 0x10])
|
||||
}
|
||||
|
||||
|
|
@ -492,13 +470,7 @@ export class EbdsRs232 extends EventEmitter {
|
|||
validatePacket(packet)
|
||||
const result = interpret(packet)
|
||||
if (result) {
|
||||
if (result.destructedData) {
|
||||
// Clear a latched stack/return once the device has left escrow — it
|
||||
// is now stacking/returning/idle, so we must stop asserting the
|
||||
// action or it would leak onto the next note.
|
||||
if (!result.destructedData[0].escrowed) this.pendingAction = 'none'
|
||||
this._logStatusOnChange(result.destructedData)
|
||||
}
|
||||
if (result.destructedData) this._logStatusOnChange(result.destructedData)
|
||||
this.emit('message', result)
|
||||
}
|
||||
} catch (ex) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {
|
|||
} from 'nostr-tools'
|
||||
import {
|
||||
encryptContentV2,
|
||||
LocalSigner,
|
||||
type MachineIdentity,
|
||||
type NostrClient,
|
||||
} from '@bitSpire/nostr-client'
|
||||
|
|
@ -153,7 +152,7 @@ describe('isAuthenticServerEvent', () => {
|
|||
describe('LnbitsClient.handleReply wiring', () => {
|
||||
function makeMockNostr(): {
|
||||
nostr: NostrClient
|
||||
triggerEvent: (ev: NostrEvent) => Promise<void>
|
||||
triggerEvent: (ev: NostrEvent) => void
|
||||
} {
|
||||
let captured: ((ev: NostrEvent) => void) | null = null
|
||||
const nostr = {
|
||||
|
|
@ -169,12 +168,9 @@ describe('LnbitsClient.handleReply wiring', () => {
|
|||
} as unknown as NostrClient
|
||||
return {
|
||||
nostr,
|
||||
// `handleReply` is async (the signer's nip44Decrypt is a promise),
|
||||
// so flush microtasks + a macrotask tick before the caller asserts.
|
||||
triggerEvent: async (ev) => {
|
||||
triggerEvent: (ev) => {
|
||||
if (!captured) throw new Error('handleReply not wired yet')
|
||||
captured(ev)
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -192,7 +188,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
|||
client: LnbitsClient
|
||||
serverIdentity: MachineIdentity
|
||||
recipientIdentity: MachineIdentity
|
||||
triggerEvent: (ev: NostrEvent) => Promise<void>
|
||||
triggerEvent: (ev: NostrEvent) => void
|
||||
} {
|
||||
const serverIdentity = makeIdentity()
|
||||
const recipientIdentity = makeIdentity()
|
||||
|
|
@ -201,11 +197,11 @@ describe('LnbitsClient.handleReply wiring', () => {
|
|||
serverPubkey: serverIdentity.publicKey,
|
||||
relays: ['ws://test/'],
|
||||
})
|
||||
client.initialize(nostr, new LocalSigner(recipientIdentity))
|
||||
client.initialize(nostr, recipientIdentity)
|
||||
return { client, serverIdentity, recipientIdentity, triggerEvent }
|
||||
}
|
||||
|
||||
it('drops a forged event without resolving any pending RPC', async () => {
|
||||
it('drops a forged event without resolving any pending RPC', () => {
|
||||
const { client, serverIdentity, triggerEvent } = setupClient()
|
||||
|
||||
// Pre-register a pending entry as `sendRpc` would have.
|
||||
|
|
@ -237,7 +233,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
|||
attackerKey,
|
||||
)
|
||||
|
||||
await triggerEvent(forged)
|
||||
triggerEvent(forged)
|
||||
|
||||
expect(resolveCalls).toBe(0)
|
||||
expect(rejectCalls).toBe(0)
|
||||
|
|
@ -245,7 +241,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
|||
expect((client as any).pending.has('req-forged')).toBe(true)
|
||||
})
|
||||
|
||||
it('processes a legitimate server-signed reply (positive sanity)', async () => {
|
||||
it('processes a legitimate server-signed reply (positive sanity)', () => {
|
||||
const { client, serverIdentity, recipientIdentity, triggerEvent } =
|
||||
setupClient()
|
||||
|
||||
|
|
@ -281,7 +277,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
|||
serverIdentity.privateKey,
|
||||
)
|
||||
|
||||
await triggerEvent(reply)
|
||||
triggerEvent(reply)
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
status: 'OK',
|
||||
|
|
@ -294,7 +290,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
|||
// fine, we only need to assert resolve fired with the right payload.
|
||||
})
|
||||
|
||||
it('does not poison the seenEventIds cache with a forged event', async () => {
|
||||
it('does not poison the seenEventIds cache with a forged event', () => {
|
||||
// This is the test scenario where the #49 guard's contribution
|
||||
// actually shows up: ev.id is the dedup key for the client-global
|
||||
// exact-replay cache. WITHOUT the guard, an attacker could publish
|
||||
|
|
@ -324,7 +320,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
|||
attackerKey,
|
||||
)
|
||||
|
||||
await triggerEvent(forged)
|
||||
triggerEvent(forged)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((client as any).seenEventIds.size).toBe(0)
|
||||
|
|
@ -349,7 +345,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
|||
describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
||||
function makeMockNostr(): {
|
||||
nostr: NostrClient
|
||||
triggerEvent: (ev: NostrEvent) => Promise<void>
|
||||
triggerEvent: (ev: NostrEvent) => void
|
||||
} {
|
||||
let captured: ((ev: NostrEvent) => void) | null = null
|
||||
const nostr = {
|
||||
|
|
@ -365,12 +361,9 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
|||
} as unknown as NostrClient
|
||||
return {
|
||||
nostr,
|
||||
// `handleReply` is async (the signer's nip44Decrypt is a promise),
|
||||
// so flush microtasks + a macrotask tick before the caller asserts.
|
||||
triggerEvent: async (ev) => {
|
||||
triggerEvent: (ev) => {
|
||||
if (!captured) throw new Error('handleReply not wired yet')
|
||||
captured(ev)
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -443,7 +436,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
|||
)
|
||||
}
|
||||
|
||||
it('fires onPush once when the same event is injected twice (exact-replay dedup)', async () => {
|
||||
it('fires onPush once when the same event is injected twice (exact-replay dedup)', () => {
|
||||
const serverIdentity = makeIdentity()
|
||||
const recipientIdentity = makeIdentity()
|
||||
const { nostr, triggerEvent } = makeMockNostr()
|
||||
|
|
@ -451,7 +444,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
|||
serverPubkey: serverIdentity.publicKey,
|
||||
relays: ['ws://test/'],
|
||||
})
|
||||
client.initialize(nostr, new LocalSigner(recipientIdentity))
|
||||
client.initialize(nostr, recipientIdentity)
|
||||
|
||||
const { received } = preregisterSub(client, 'sub-1')
|
||||
|
||||
|
|
@ -462,14 +455,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
|||
paymentHash: 'hash-aaa',
|
||||
})
|
||||
// Same bytes both times — same ev.id, same payment_hash.
|
||||
await triggerEvent(ev)
|
||||
await triggerEvent(ev)
|
||||
triggerEvent(ev)
|
||||
triggerEvent(ev)
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]!.payment_hash).toBe('hash-aaa')
|
||||
})
|
||||
|
||||
it('fires onPush once when two distinct ev.ids carry the same payment_hash', async () => {
|
||||
it('fires onPush once when two distinct ev.ids carry the same payment_hash', () => {
|
||||
const serverIdentity = makeIdentity()
|
||||
const recipientIdentity = makeIdentity()
|
||||
const { nostr, triggerEvent } = makeMockNostr()
|
||||
|
|
@ -477,7 +470,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
|||
serverPubkey: serverIdentity.publicKey,
|
||||
relays: ['ws://test/'],
|
||||
})
|
||||
client.initialize(nostr, new LocalSigner(recipientIdentity))
|
||||
client.initialize(nostr, recipientIdentity)
|
||||
|
||||
const { received } = preregisterSub(client, 'sub-1')
|
||||
|
||||
|
|
@ -500,14 +493,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
|||
createdAt: now + 1,
|
||||
})
|
||||
expect(ev1.id).not.toBe(ev2.id) // sanity: ev.id dedup would NOT catch this
|
||||
await triggerEvent(ev1)
|
||||
await triggerEvent(ev2)
|
||||
triggerEvent(ev1)
|
||||
triggerEvent(ev2)
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]!.payment_hash).toBe('hash-bbb')
|
||||
})
|
||||
|
||||
it('fires onPush for each distinct payment_hash (negative dedup case)', async () => {
|
||||
it('fires onPush for each distinct payment_hash (negative dedup case)', () => {
|
||||
const serverIdentity = makeIdentity()
|
||||
const recipientIdentity = makeIdentity()
|
||||
const { nostr, triggerEvent } = makeMockNostr()
|
||||
|
|
@ -515,7 +508,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
|||
serverPubkey: serverIdentity.publicKey,
|
||||
relays: ['ws://test/'],
|
||||
})
|
||||
client.initialize(nostr, new LocalSigner(recipientIdentity))
|
||||
client.initialize(nostr, recipientIdentity)
|
||||
|
||||
const { received } = preregisterSub(client, 'sub-1')
|
||||
|
||||
|
|
@ -532,14 +525,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
|||
paymentHash: 'hash-2',
|
||||
createdAt: Math.floor(Date.now() / 1000) + 2,
|
||||
})
|
||||
await triggerEvent(ev1)
|
||||
await triggerEvent(ev2)
|
||||
triggerEvent(ev1)
|
||||
triggerEvent(ev2)
|
||||
|
||||
expect(received).toHaveLength(2)
|
||||
expect(received.map((p) => p.payment_hash)).toEqual(['hash-1', 'hash-2'])
|
||||
})
|
||||
|
||||
it('keeps dedup state per-subscription (one sub seeing a hash does not silence another)', async () => {
|
||||
it('keeps dedup state per-subscription (one sub seeing a hash does not silence another)', () => {
|
||||
const serverIdentity = makeIdentity()
|
||||
const recipientIdentity = makeIdentity()
|
||||
const { nostr, triggerEvent } = makeMockNostr()
|
||||
|
|
@ -547,7 +540,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
|||
serverPubkey: serverIdentity.publicKey,
|
||||
relays: ['ws://test/'],
|
||||
})
|
||||
client.initialize(nostr, new LocalSigner(recipientIdentity))
|
||||
client.initialize(nostr, recipientIdentity)
|
||||
|
||||
const a = preregisterSub(client, 'sub-A')
|
||||
const b = preregisterSub(client, 'sub-B')
|
||||
|
|
@ -568,8 +561,8 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
|||
paymentHash: 'hash-shared',
|
||||
createdAt: Math.floor(Date.now() / 1000) + 1,
|
||||
})
|
||||
await triggerEvent(evA)
|
||||
await triggerEvent(evB)
|
||||
triggerEvent(evA)
|
||||
triggerEvent(evB)
|
||||
|
||||
// Each subscription sees its own push exactly once.
|
||||
expect(a.received).toHaveLength(1)
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
LnbitsErrorCode,
|
||||
LnbitsRpcError,
|
||||
parseErrorCode,
|
||||
retryPolicyFor,
|
||||
} from '../error-codes.js'
|
||||
|
||||
describe('parseErrorCode', () => {
|
||||
it('recognizes every canonical code', () => {
|
||||
for (const code of Object.values(LnbitsErrorCode)) {
|
||||
expect(parseErrorCode(code)).toBe(code)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for unknown / absent codes', () => {
|
||||
expect(parseErrorCode('made_up_code')).toBeNull()
|
||||
expect(parseErrorCode(undefined)).toBeNull()
|
||||
expect(parseErrorCode(null)).toBeNull()
|
||||
expect(parseErrorCode('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryPolicyFor', () => {
|
||||
it('classifies the signer + transport + app codes as agreed', () => {
|
||||
expect(retryPolicyFor(LnbitsErrorCode.OperatorSignerUnavailable)).toBe('retry-backoff')
|
||||
expect(retryPolicyFor(LnbitsErrorCode.OperatorSignerRejected)).toBe('terminal')
|
||||
expect(retryPolicyFor(LnbitsErrorCode.RateLimited)).toBe('retry-long-backoff')
|
||||
expect(retryPolicyFor(LnbitsErrorCode.InternalError)).toBe('retry-once')
|
||||
expect(retryPolicyFor(LnbitsErrorCode.InvoiceAlreadyPaid)).toBe('terminal-idempotent')
|
||||
expect(retryPolicyFor(LnbitsErrorCode.InsufficientBalance)).toBe('terminal')
|
||||
})
|
||||
|
||||
it('has a policy for every code (exhaustive map)', () => {
|
||||
for (const code of Object.values(LnbitsErrorCode)) {
|
||||
expect(retryPolicyFor(code)).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('LnbitsRpcError.fromResponse', () => {
|
||||
it('maps a known error_code through', () => {
|
||||
const err = LnbitsRpcError.fromResponse('pay_invoice', {
|
||||
request_id: 'pay-1',
|
||||
error_code: 'insufficient_balance',
|
||||
error: 'not enough sats',
|
||||
})
|
||||
expect(err).toBeInstanceOf(LnbitsRpcError)
|
||||
expect(err.code).toBe(LnbitsErrorCode.InsufficientBalance)
|
||||
expect(err.rpcName).toBe('pay_invoice')
|
||||
expect(err.requestId).toBe('pay-1')
|
||||
expect(err.message).toBe('not enough sats')
|
||||
expect(err.retryPolicy).toBe('terminal')
|
||||
expect(err.isRetryable).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an ABSENT error_code as internal_error (retry-once)', () => {
|
||||
const err = LnbitsRpcError.fromResponse('get_wallet', { request_id: 'w-1' })
|
||||
expect(err.code).toBe(LnbitsErrorCode.InternalError)
|
||||
expect(err.retryPolicy).toBe('retry-once')
|
||||
expect(err.isRetryable).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an UNKNOWN error_code as internal_error', () => {
|
||||
const err = LnbitsRpcError.fromResponse('get_wallet', {
|
||||
request_id: 'w-2',
|
||||
error_code: 'brand_new_code_we_dont_know',
|
||||
})
|
||||
expect(err.code).toBe(LnbitsErrorCode.InternalError)
|
||||
})
|
||||
|
||||
it('flags invoice_already_paid as idempotent-success', () => {
|
||||
const err = LnbitsRpcError.fromResponse('pay_invoice', {
|
||||
request_id: 'p-1',
|
||||
error_code: 'invoice_already_paid',
|
||||
})
|
||||
expect(err.isIdempotentSuccess).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { withRetry } from '../retry.js'
|
||||
import { LnbitsErrorCode, LnbitsRpcError } from '../error-codes.js'
|
||||
|
||||
const noSleep = () => Promise.resolve()
|
||||
|
||||
function rpcErr(code: LnbitsErrorCode): LnbitsRpcError {
|
||||
return new LnbitsRpcError({ code, rpcName: 'get_wallet', requestId: 'r1' })
|
||||
}
|
||||
|
||||
/** A fn that throws `err` the first `failTimes` calls, then returns `value`. */
|
||||
function failingFn<T>(failTimes: number, err: unknown, value: T): { fn: () => Promise<T>; calls: () => number } {
|
||||
let calls = 0
|
||||
return {
|
||||
fn: async () => {
|
||||
calls++
|
||||
if (calls <= failTimes) throw err
|
||||
return value
|
||||
},
|
||||
calls: () => calls,
|
||||
}
|
||||
}
|
||||
|
||||
describe('withRetry', () => {
|
||||
it('returns immediately on success (one call)', async () => {
|
||||
const { fn, calls } = failingFn(0, rpcErr(LnbitsErrorCode.InternalError), 'ok')
|
||||
expect(await withRetry(fn, { sleep: noSleep })).toBe('ok')
|
||||
expect(calls()).toBe(1)
|
||||
})
|
||||
|
||||
it('retries a transient operator_signer_unavailable, then succeeds', async () => {
|
||||
const { fn, calls } = failingFn(1, rpcErr(LnbitsErrorCode.OperatorSignerUnavailable), 'ok')
|
||||
expect(await withRetry(fn, { sleep: noSleep })).toBe('ok')
|
||||
expect(calls()).toBe(2)
|
||||
})
|
||||
|
||||
it('retries rate_limited (long backoff) up to maxAttempts then throws', async () => {
|
||||
const err = rpcErr(LnbitsErrorCode.RateLimited)
|
||||
const { fn, calls } = failingFn(99, err, 'never')
|
||||
await expect(withRetry(fn, { sleep: noSleep, maxAttempts: 3 })).rejects.toBe(err)
|
||||
expect(calls()).toBe(3)
|
||||
})
|
||||
|
||||
it('internal_error (retry-once) retries exactly once', async () => {
|
||||
const err = rpcErr(LnbitsErrorCode.InternalError)
|
||||
const { fn, calls } = failingFn(99, err, 'never')
|
||||
await expect(withRetry(fn, { sleep: noSleep, maxAttempts: 5 })).rejects.toBe(err)
|
||||
expect(calls()).toBe(2) // initial + one retry, then null delay stops it
|
||||
})
|
||||
|
||||
it('throws a terminal error immediately (no retry)', async () => {
|
||||
const err = rpcErr(LnbitsErrorCode.InsufficientBalance)
|
||||
const { fn, calls } = failingFn(99, err, 'never')
|
||||
await expect(withRetry(fn, { sleep: noSleep })).rejects.toBe(err)
|
||||
expect(calls()).toBe(1)
|
||||
})
|
||||
|
||||
it('treats unauthorized as terminal (no retry)', async () => {
|
||||
const err = rpcErr(LnbitsErrorCode.Unauthorized)
|
||||
const { fn, calls } = failingFn(99, err, 'never')
|
||||
await expect(withRetry(fn, { sleep: noSleep })).rejects.toBe(err)
|
||||
expect(calls()).toBe(1)
|
||||
})
|
||||
|
||||
it('retries a transport timeout error', async () => {
|
||||
const timeout = new Error('LnbitsClient.get_wallet: timeout after 30000ms')
|
||||
const { fn, calls } = failingFn(1, timeout, 'ok')
|
||||
expect(await withRetry(fn, { sleep: noSleep })).toBe('ok')
|
||||
expect(calls()).toBe(2)
|
||||
})
|
||||
|
||||
it('does NOT retry an unknown error (rethrows immediately)', async () => {
|
||||
const boom = new Error('relay socket closed')
|
||||
const { fn, calls } = failingFn(99, boom, 'never')
|
||||
await expect(withRetry(fn, { sleep: noSleep })).rejects.toBe(boom)
|
||||
expect(calls()).toBe(1)
|
||||
})
|
||||
|
||||
it('backs off with increasing delay per attempt (retry-backoff)', async () => {
|
||||
const delays: number[] = []
|
||||
const err = rpcErr(LnbitsErrorCode.OperatorSignerUnavailable)
|
||||
const { fn } = failingFn(99, err, 'never')
|
||||
await expect(
|
||||
withRetry(fn, { sleep: (ms) => { delays.push(ms); return Promise.resolve() }, maxAttempts: 3 })
|
||||
).rejects.toBe(err)
|
||||
expect(delays).toEqual([200, 400]) // before attempt 2 and 3; attempt 3 is last → no 3rd sleep
|
||||
})
|
||||
|
||||
it('invokes onRetry with attempt/delay/error', async () => {
|
||||
const onRetry = vi.fn()
|
||||
const { fn } = failingFn(1, rpcErr(LnbitsErrorCode.OperatorSignerUnavailable), 'ok')
|
||||
await withRetry(fn, { sleep: noSleep, onRetry })
|
||||
expect(onRetry).toHaveBeenCalledOnce()
|
||||
expect(onRetry.mock.calls[0]![0]).toMatchObject({ attempt: 1, delayMs: 200 })
|
||||
})
|
||||
})
|
||||
|
|
@ -22,12 +22,12 @@
|
|||
|
||||
import {
|
||||
type NostrClient,
|
||||
type Signer,
|
||||
type MachineIdentity,
|
||||
type Event as NostrEvent,
|
||||
encryptContentV2,
|
||||
decryptContentV2,
|
||||
} from '@bitSpire/nostr-client'
|
||||
import { verifyEvent } from 'nostr-tools'
|
||||
import { LnbitsRpcError } from './error-codes.js'
|
||||
import { withRetry } from './retry.js'
|
||||
import { finalizeEvent, verifyEvent } from 'nostr-tools'
|
||||
|
||||
import type {
|
||||
LnbitsConfig,
|
||||
|
|
@ -37,15 +37,11 @@ import type {
|
|||
CreateInvoiceBody,
|
||||
PayInvoiceBody,
|
||||
WalletInfo,
|
||||
CreatedWallet,
|
||||
MachineConfigResponse,
|
||||
SubscribePaymentsBody,
|
||||
SubscribeAck,
|
||||
PaymentPushCallback,
|
||||
SubscriptionCloseCallback,
|
||||
CreateWithdrawLinkBody,
|
||||
CreateWithdrawBody,
|
||||
CreateWithdrawResult,
|
||||
LnbitsWithdrawLink,
|
||||
UniqueHashesResponse,
|
||||
} from './types.js'
|
||||
|
|
@ -125,7 +121,7 @@ const SEEN_PAYMENT_HASHES_MAX = 500
|
|||
export class LnbitsClient {
|
||||
private readonly config: Required<LnbitsConfig>
|
||||
private nostr: NostrClient | null = null
|
||||
private signer: Signer | null = null
|
||||
private identity: MachineIdentity | null = null
|
||||
private requestCounter = 0
|
||||
private readonly pending = new Map<
|
||||
string,
|
||||
|
|
@ -154,28 +150,12 @@ export class LnbitsClient {
|
|||
}
|
||||
}
|
||||
|
||||
initialize(nostr: NostrClient, signer: Signer): void {
|
||||
initialize(nostr: NostrClient, identity: MachineIdentity): void {
|
||||
this.nostr = nostr
|
||||
this.signer = signer
|
||||
this.identity = identity
|
||||
this.startReplyListener()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry-policy switch for IDEMPOTENT reads only (aiolabs/bitspire#52, Phase D).
|
||||
* Transient failures (operator_signer_unavailable / rate_limited /
|
||||
* internal_error / transport timeout) back off and retry; terminal errors
|
||||
* surface immediately. Never used for pay/create — those would double-pay or
|
||||
* duplicate on retry.
|
||||
*/
|
||||
private idempotent<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return withRetry(fn, {
|
||||
onRetry: ({ attempt, delayMs, error }) => {
|
||||
const code = error instanceof LnbitsRpcError ? error.code : 'timeout'
|
||||
console.warn(`[LnbitsClient] transient ${code} — retry ${attempt} in ${delayMs}ms`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wallet
|
||||
// ============================================================================
|
||||
|
|
@ -187,7 +167,8 @@ export class LnbitsClient {
|
|||
*/
|
||||
async getWallet(walletId?: string): Promise<WalletInfo> {
|
||||
if (walletId) {
|
||||
return this.idempotent(() => this.sendRpc<WalletInfo>('get_wallet', { walletId }))
|
||||
const data = await this.sendRpc<WalletInfo>('get_wallet', { walletId })
|
||||
return data
|
||||
}
|
||||
const wallets = await this.listWallets()
|
||||
if (wallets.length === 0) {
|
||||
|
|
@ -208,40 +189,14 @@ export class LnbitsClient {
|
|||
|
||||
/** Enumerate every wallet owned by the calling account. */
|
||||
async listWallets(): Promise<WalletInfo[]> {
|
||||
const data = await this.idempotent(() => this.sendRpc<WalletInfo[]>('list_wallets', {}))
|
||||
const data = await this.sendRpc<WalletInfo[]>('list_wallets', {})
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an additional wallet on the calling account (`create_wallet`).
|
||||
*
|
||||
* Account-scoped (AUTH_ACCOUNT): the envelope carries no `wallet_id`, which
|
||||
* is what makes the server resolve auth to the Account rather than a Wallet.
|
||||
* NOT wrapped in `idempotent()` — a retry would mint a duplicate wallet.
|
||||
*/
|
||||
async createWallet(name: string): Promise<CreatedWallet> {
|
||||
return this.sendRpc<CreatedWallet>('create_wallet', { body: { name } })
|
||||
}
|
||||
|
||||
/** Pull server-delivered machine config (operator pubkey + fee config) over
|
||||
* the authenticated transport — spirekeeper's `get_machine_config` RPC
|
||||
* (bitspire#70 P1). Lets a seed-only ATM configure itself with no per-machine
|
||||
* env provisioning. Rejects (LnbitsRpcError) if the server hasn't registered
|
||||
* the RPC (older spirekeeper) — callers should soft-fall-back. */
|
||||
async getMachineConfig(): Promise<MachineConfigResponse> {
|
||||
return this.idempotent(() =>
|
||||
this.sendRpc<MachineConfigResponse>('get_machine_config', {}),
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Invoices
|
||||
// ============================================================================
|
||||
|
||||
// NOTE: create_invoice / pay_invoice / lnurlw_create_link are NOT wrapped in
|
||||
// `idempotent()` — a retry would mint a duplicate invoice/link or double-pay.
|
||||
// Their errors surface for flow-level handling (state machine / operator).
|
||||
|
||||
async createInvoice(walletId: string, body: CreateInvoiceBody): Promise<LnbitsPayment> {
|
||||
const data = await this.sendRpc<LnbitsPayment>('create_invoice', { walletId, body })
|
||||
return data
|
||||
|
|
@ -254,20 +209,17 @@ export class LnbitsClient {
|
|||
|
||||
/** Point-lookup of a payment by hash. AUTH_NONE — hashes are hard to guess. */
|
||||
async getPayment(paymentHash: string): Promise<LnbitsPayment | null> {
|
||||
const data = await this.idempotent(() =>
|
||||
this.sendRpc<LnbitsPayment | null>('get_payment', {
|
||||
const data = await this.sendRpc<LnbitsPayment | null>('get_payment', {
|
||||
body: { payment_hash: paymentHash },
|
||||
}),
|
||||
)
|
||||
})
|
||||
return data ?? null
|
||||
}
|
||||
|
||||
async decodePayment(paymentRequest: string): Promise<Record<string, unknown>> {
|
||||
return this.idempotent(() =>
|
||||
this.sendRpc<Record<string, unknown>>('decode_payment', {
|
||||
const data = await this.sendRpc<Record<string, unknown>>('decode_payment', {
|
||||
body: { payment_request: paymentRequest },
|
||||
}),
|
||||
)
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -288,7 +240,7 @@ export class LnbitsClient {
|
|||
onPush: PaymentPushCallback,
|
||||
onClose?: SubscriptionCloseCallback,
|
||||
): Promise<string> {
|
||||
if (!this.nostr || !this.signer) {
|
||||
if (!this.nostr || !this.identity) {
|
||||
throw new Error('LnbitsClient.subscribePayments: client not initialized')
|
||||
}
|
||||
const requestId = this.nextRequestId('sub')
|
||||
|
|
@ -414,35 +366,19 @@ export class LnbitsClient {
|
|||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash-in: create a SERVER-STAMPED LNURL-withdraw via the secure
|
||||
* `create_withdraw` RPC (aiolabs/spirekeeper#31 / #32). The ATM sends only the
|
||||
* hardware-attested `principal_sats`; the operator side verifies the signer,
|
||||
* derives fee + NET, and stamps the link's attribution from the verified
|
||||
* sender — the machine cannot understate the fee or forge attribution. NOT
|
||||
* idempotent (mints a link) → not retry-wrapped; supersedes the direct,
|
||||
* client-amount `createWithdrawLink` for cash-in.
|
||||
*/
|
||||
async createWithdraw(walletId: string, body: CreateWithdrawBody): Promise<CreateWithdrawResult> {
|
||||
return this.sendRpc<CreateWithdrawResult>('create_withdraw', { walletId, body })
|
||||
}
|
||||
|
||||
async getWithdrawLink(walletId: string, id: string): Promise<LnbitsWithdrawLink> {
|
||||
return this.idempotent(() =>
|
||||
this.sendRpc<LnbitsWithdrawLink>('lnurlw_get_link', { walletId, body: { id } }),
|
||||
)
|
||||
const data = await this.sendRpc<LnbitsWithdrawLink>('lnurlw_get_link', { walletId, body: { id } })
|
||||
return data
|
||||
}
|
||||
|
||||
async listWithdrawLinks(
|
||||
walletId: string | undefined,
|
||||
body: { limit?: number; offset?: number } = {},
|
||||
): Promise<{ data: LnbitsWithdrawLink[]; total: number }> {
|
||||
return this.idempotent(() =>
|
||||
this.sendRpc<{ data: LnbitsWithdrawLink[]; total: number }>('lnurlw_list_links', {
|
||||
return this.sendRpc<{ data: LnbitsWithdrawLink[]; total: number }>('lnurlw_list_links', {
|
||||
walletId,
|
||||
body,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -452,12 +388,10 @@ export class LnbitsClient {
|
|||
* — the URL a customer wallet GETs to redeem that specific sub-link.
|
||||
*/
|
||||
async getWithdrawLinkUniqueHashes(walletId: string, id: string): Promise<UniqueHashesResponse> {
|
||||
return this.idempotent(() =>
|
||||
this.sendRpc<UniqueHashesResponse>('lnurlw_unique_hashes', {
|
||||
return this.sendRpc<UniqueHashesResponse>('lnurlw_unique_hashes', {
|
||||
walletId,
|
||||
body: { id },
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async updateWithdrawLink(
|
||||
|
|
@ -497,7 +431,7 @@ export class LnbitsClient {
|
|||
requestId?: string
|
||||
},
|
||||
): Promise<T> {
|
||||
if (!this.nostr || !this.signer) {
|
||||
if (!this.nostr || !this.identity) {
|
||||
throw new Error(`LnbitsClient.${rpcName}: client not initialized`)
|
||||
}
|
||||
const requestId = args.requestId ?? this.nextRequestId(rpcName)
|
||||
|
|
@ -510,10 +444,10 @@ export class LnbitsClient {
|
|||
if (args.query !== undefined) request.query = args.query as Record<string, unknown>
|
||||
|
||||
const plaintext = JSON.stringify(request)
|
||||
const encrypted = await this.signer.nip44Encrypt(this.config.serverPubkey, plaintext)
|
||||
const encrypted = encryptContentV2(this.identity, this.config.serverPubkey, plaintext)
|
||||
|
||||
// Build + sign the kind-21000 event via the signer. The server reads
|
||||
// our pubkey directly off the signature, so there's no separate
|
||||
// Build + sign the kind-21000 event ourselves. The server reads our
|
||||
// pubkey directly off the signature, so there's no separate
|
||||
// authIdentifier in the envelope (unlike LightningPubClient).
|
||||
//
|
||||
// NIP-40 expiration: 5 minutes past now. Defence-in-depth at the
|
||||
|
|
@ -523,7 +457,8 @@ export class LnbitsClient {
|
|||
// attacker can't bypass this by stripping the tag; the tag just
|
||||
// lets the relay short-circuit earlier.
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const event = await this.signer.signEvent({
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: LNBITS_KIND_RPC,
|
||||
content: encrypted,
|
||||
tags: [
|
||||
|
|
@ -531,7 +466,9 @@ export class LnbitsClient {
|
|||
['expiration', String(now + 300)],
|
||||
],
|
||||
created_at: now,
|
||||
})
|
||||
},
|
||||
this.identity.privateKey,
|
||||
)
|
||||
|
||||
// The pending entry MUST be registered before publish so we don't race
|
||||
// an extremely fast reply.
|
||||
|
|
@ -546,7 +483,7 @@ export class LnbitsClient {
|
|||
clearTimeout(timer)
|
||||
this.pending.delete(requestId)
|
||||
if (response.status === 'ERROR') {
|
||||
reject(LnbitsRpcError.fromResponse(rpcName, response))
|
||||
reject(new Error(response.error ?? `${rpcName}: server returned ERROR`))
|
||||
return
|
||||
}
|
||||
resolve(response.data as T)
|
||||
|
|
@ -571,8 +508,8 @@ export class LnbitsClient {
|
|||
* based on `request_id` and `subscription_id`.
|
||||
*/
|
||||
private startReplyListener(): void {
|
||||
if (!this.nostr || !this.signer) return
|
||||
const myPubkey = this.signer.pubkey
|
||||
if (!this.nostr || !this.identity) return
|
||||
const myPubkey = this.identity.publicKey
|
||||
const since = Math.floor(Date.now() / 1000) - 5
|
||||
|
||||
this.relaySubIdForReplies = this.nostr.subscribe(
|
||||
|
|
@ -585,28 +522,25 @@ export class LnbitsClient {
|
|||
},
|
||||
],
|
||||
{
|
||||
onEvent: (ev: NostrEvent) => void this.handleReply(ev),
|
||||
onEvent: (ev: NostrEvent) => this.handleReply(ev),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private async handleReply(ev: NostrEvent): Promise<void> {
|
||||
const signer = this.signer
|
||||
if (!signer) return
|
||||
private handleReply(ev: NostrEvent): void {
|
||||
if (!this.identity) return
|
||||
if (!isAuthenticServerEvent(ev, this.config.serverPubkey)) return
|
||||
// Exact-replay dedup. Skip if we've already processed this event id.
|
||||
// Safe to trust `ev.id` here because `isAuthenticServerEvent` just
|
||||
// Schnorr-verified the event (`verifyEvent` recomputes the id and
|
||||
// confirms it matches the signed pubkey + body). Without that
|
||||
// guarantee an attacker could pre-poison this set with chosen ids.
|
||||
// Runs before the async decrypt so concurrent re-deliveries of the
|
||||
// same id still dedup synchronously.
|
||||
if (this.seenEventIds.has(ev.id)) return
|
||||
this.recordSeenEventId(ev.id)
|
||||
|
||||
let plaintext: string
|
||||
try {
|
||||
plaintext = await signer.nip44Decrypt(this.config.serverPubkey, ev.content)
|
||||
plaintext = decryptContentV2(this.identity, this.config.serverPubkey, ev.content)
|
||||
} catch {
|
||||
return // not our peer or wrong key
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
/**
|
||||
* LNbits nostr-transport error taxonomy.
|
||||
*
|
||||
* Machine-readable discriminators for kind-21000 ERROR responses. Mirror of
|
||||
* the lnbits canonical enum (`core/services/nostr_transport/error_codes.py`)
|
||||
* and the vocabulary table in `docs/devs/nostr-transport.md`. Drift detection
|
||||
* = diff this enum against that table. Agreed in the 2026-05-26 cross-session
|
||||
* handshake on aiolabs/bitspire#52.
|
||||
*
|
||||
* Wire shape (additive to the existing envelope):
|
||||
* { "status": "ERROR", "request_id": "...", "error_code": "...", "error": "..." }
|
||||
*
|
||||
* `error_code` is optional-additive for one lnbits release, then required.
|
||||
* An ABSENT `error_code` is treated as `internal_error` (retry-once) — we do
|
||||
* not string-match the human-readable `error`. So un-migrated handlers get a
|
||||
* safe retry-then-surface default with no special parser paths.
|
||||
*/
|
||||
|
||||
export enum LnbitsErrorCode {
|
||||
// signer class — the operator's signer (bunker) on the LNbits side
|
||||
OperatorSignerUnavailable = 'operator_signer_unavailable',
|
||||
OperatorSignerRejected = 'operator_signer_rejected',
|
||||
OperatorSignerUnconfigured = 'operator_signer_unconfigured',
|
||||
// transport class
|
||||
Unauthorized = 'unauthorized',
|
||||
RateLimited = 'rate_limited',
|
||||
UnknownMethod = 'unknown_method',
|
||||
InvalidParams = 'invalid_params',
|
||||
InternalError = 'internal_error',
|
||||
// app class
|
||||
WalletNotFound = 'wallet_not_found',
|
||||
InsufficientBalance = 'insufficient_balance',
|
||||
InvoiceAlreadyPaid = 'invoice_already_paid',
|
||||
InvoiceExpired = 'invoice_expired',
|
||||
PaymentFailed = 'payment_failed',
|
||||
AccountNotFound = 'account_not_found',
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry disposition for an error code:
|
||||
* - `retry-backoff` — transient; retry with short exponential backoff.
|
||||
* - `retry-long-backoff` — rate-limited; retry with a longer backoff.
|
||||
* - `retry-once` — retry exactly once, then surface (the `internal_error` default).
|
||||
* - `terminal` — do not retry; surface to the user.
|
||||
* - `terminal-idempotent` — terminal, but the operation already took effect
|
||||
* (e.g. `invoice_already_paid` — a cash-out watcher treats it as settled).
|
||||
*/
|
||||
export type RetryPolicy =
|
||||
| 'retry-backoff'
|
||||
| 'retry-long-backoff'
|
||||
| 'retry-once'
|
||||
| 'terminal'
|
||||
| 'terminal-idempotent'
|
||||
|
||||
const RETRY_POLICIES: Record<LnbitsErrorCode, RetryPolicy> = {
|
||||
[LnbitsErrorCode.OperatorSignerUnavailable]: 'retry-backoff',
|
||||
[LnbitsErrorCode.OperatorSignerRejected]: 'terminal',
|
||||
[LnbitsErrorCode.OperatorSignerUnconfigured]: 'terminal',
|
||||
[LnbitsErrorCode.Unauthorized]: 'terminal',
|
||||
[LnbitsErrorCode.RateLimited]: 'retry-long-backoff',
|
||||
[LnbitsErrorCode.UnknownMethod]: 'terminal',
|
||||
[LnbitsErrorCode.InvalidParams]: 'terminal',
|
||||
[LnbitsErrorCode.InternalError]: 'retry-once',
|
||||
[LnbitsErrorCode.WalletNotFound]: 'terminal',
|
||||
[LnbitsErrorCode.InsufficientBalance]: 'terminal',
|
||||
[LnbitsErrorCode.InvoiceAlreadyPaid]: 'terminal-idempotent',
|
||||
[LnbitsErrorCode.InvoiceExpired]: 'terminal',
|
||||
// payment_failed is terminal-with-detail: the sub-reason rides in `error`.
|
||||
[LnbitsErrorCode.PaymentFailed]: 'terminal',
|
||||
[LnbitsErrorCode.AccountNotFound]: 'terminal',
|
||||
}
|
||||
|
||||
const RETRYABLE: ReadonlySet<RetryPolicy> = new Set<RetryPolicy>([
|
||||
'retry-backoff',
|
||||
'retry-long-backoff',
|
||||
'retry-once',
|
||||
])
|
||||
|
||||
/** Parse a wire string into a known code, or null if unrecognized. */
|
||||
export function parseErrorCode(raw: string | undefined | null): LnbitsErrorCode | null {
|
||||
if (!raw) return null
|
||||
return (Object.values(LnbitsErrorCode) as string[]).includes(raw)
|
||||
? (raw as LnbitsErrorCode)
|
||||
: null
|
||||
}
|
||||
|
||||
/** Retry disposition for a code. */
|
||||
export function retryPolicyFor(code: LnbitsErrorCode): RetryPolicy {
|
||||
return RETRY_POLICIES[code]
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed error thrown by `LnbitsClient` on an ERROR response. Carries the
|
||||
* machine-readable `code` + its `retryPolicy` so callers (and the state
|
||||
* machine) branch on disposition rather than string-matching `message`.
|
||||
*/
|
||||
export class LnbitsRpcError extends Error {
|
||||
readonly code: LnbitsErrorCode
|
||||
readonly rpcName: string
|
||||
readonly requestId: string
|
||||
readonly retryPolicy: RetryPolicy
|
||||
|
||||
constructor(args: {
|
||||
code: LnbitsErrorCode
|
||||
rpcName: string
|
||||
requestId: string
|
||||
message?: string
|
||||
}) {
|
||||
super(args.message ?? `${args.rpcName}: ${args.code}`)
|
||||
this.name = 'LnbitsRpcError'
|
||||
this.code = args.code
|
||||
this.rpcName = args.rpcName
|
||||
this.requestId = args.requestId
|
||||
this.retryPolicy = retryPolicyFor(args.code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from a wire ERROR response. An absent/unknown `error_code` maps to
|
||||
* `internal_error` (retry-once) per the deprecation-window contract.
|
||||
*/
|
||||
static fromResponse(
|
||||
rpcName: string,
|
||||
response: { request_id: string; error_code?: string | null; error?: string }
|
||||
): LnbitsRpcError {
|
||||
const code = parseErrorCode(response.error_code) ?? LnbitsErrorCode.InternalError
|
||||
return new LnbitsRpcError({
|
||||
code,
|
||||
rpcName,
|
||||
requestId: response.request_id,
|
||||
message: response.error ?? `${rpcName}: server returned ERROR (${code})`,
|
||||
})
|
||||
}
|
||||
|
||||
/** True when the disposition permits a retry (any backoff/once policy). */
|
||||
get isRetryable(): boolean {
|
||||
return RETRYABLE.has(this.retryPolicy)
|
||||
}
|
||||
|
||||
/** True when the operation already took effect despite the error. */
|
||||
get isIdempotentSuccess(): boolean {
|
||||
return this.retryPolicy === 'terminal-idempotent'
|
||||
}
|
||||
}
|
||||
|
|
@ -49,15 +49,6 @@
|
|||
*/
|
||||
|
||||
export { LnbitsClient } from './client.js'
|
||||
export {
|
||||
LnbitsErrorCode,
|
||||
LnbitsRpcError,
|
||||
parseErrorCode,
|
||||
retryPolicyFor,
|
||||
} from './error-codes.js'
|
||||
export type { RetryPolicy } from './error-codes.js'
|
||||
export { withRetry } from './retry.js'
|
||||
export type { WithRetryOptions } from './retry.js'
|
||||
export type {
|
||||
LnbitsConfig,
|
||||
LnbitsRpcRequest,
|
||||
|
|
@ -66,7 +57,6 @@ export type {
|
|||
CreateInvoiceBody,
|
||||
PayInvoiceBody,
|
||||
WalletInfo,
|
||||
CreatedWallet,
|
||||
SubscribePaymentsBody,
|
||||
SubscribeAck,
|
||||
SubscribePush,
|
||||
|
|
@ -74,8 +64,6 @@ export type {
|
|||
PaymentPushCallback,
|
||||
SubscriptionCloseCallback,
|
||||
CreateWithdrawLinkBody,
|
||||
CreateWithdrawBody,
|
||||
CreateWithdrawResult,
|
||||
LnbitsWithdrawLink,
|
||||
UniqueHashEntry,
|
||||
UniqueHashesResponse,
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
/**
|
||||
* Retry policy switch for the nostr-transport (aiolabs/bitspire#52, Phase D).
|
||||
*
|
||||
* Retries an operation according to the *disposition* of the error it throws —
|
||||
* the machine-readable `retryPolicy` carried by `LnbitsRpcError` (which mirrors
|
||||
* the lnbits canonical enum). Transient conditions back off and retry;
|
||||
* terminal ones throw immediately.
|
||||
*
|
||||
* ⚠️ ONLY wrap IDEMPOTENT operations. A blind retry of `pay_invoice` could
|
||||
* double-pay, and of `create_invoice` / `lnurlw_create_link` would mint
|
||||
* duplicates — those surface their error for flow-level handling (the state
|
||||
* machine / operator) instead. See LnbitsClient for which methods opt in.
|
||||
*/
|
||||
|
||||
import { LnbitsRpcError, type RetryPolicy } from './error-codes.js'
|
||||
|
||||
export interface WithRetryOptions {
|
||||
/** Max total attempts (default 3). */
|
||||
maxAttempts?: number
|
||||
/** Injectable sleep (tests pass a fake-timer-friendly version). */
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
/** Called before each backoff wait — useful for logging. */
|
||||
onRetry?: (info: { attempt: number; delayMs: number; error: unknown }) => void
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_ATTEMPTS = 3
|
||||
|
||||
/** Backoff (ms) for the Nth attempt (1-based), or null if the policy is terminal. */
|
||||
function backoffMs(policy: RetryPolicy, attempt: number): number | null {
|
||||
switch (policy) {
|
||||
case 'retry-backoff':
|
||||
return 200 * 2 ** (attempt - 1) // 200, 400, 800…
|
||||
case 'retry-long-backoff':
|
||||
return 1_000 * 2 ** (attempt - 1) // 1s, 2s, 4s… (rate_limited)
|
||||
case 'retry-once':
|
||||
return attempt === 1 ? 0 : null // exactly one retry (internal_error / absent code)
|
||||
case 'terminal':
|
||||
case 'terminal-idempotent':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry delay for an error, or null if it must not be retried. */
|
||||
function delayForError(error: unknown, attempt: number): number | null {
|
||||
if (error instanceof LnbitsRpcError) {
|
||||
return backoffMs(error.retryPolicy, attempt)
|
||||
}
|
||||
// A transport timeout from sendRpc ("…: timeout after <n>ms") is transient.
|
||||
if (error instanceof Error && /timeout after \d+ms/.test(error.message)) {
|
||||
return 200 * 2 ** (attempt - 1)
|
||||
}
|
||||
// Unknown error (programming bug, network teardown) — don't mask it.
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn`, retrying transient failures per the error's `retryPolicy` (or a
|
||||
* transport timeout) with backoff, up to `maxAttempts`. Terminal errors and
|
||||
* unknown errors throw immediately; the last error is rethrown on exhaustion.
|
||||
*/
|
||||
export async function withRetry<T>(fn: () => Promise<T>, opts: WithRetryOptions = {}): Promise<T> {
|
||||
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
|
||||
const sleep = opts.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
|
||||
|
||||
let lastError: unknown
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt === maxAttempts) break
|
||||
const delayMs = delayForError(error, attempt)
|
||||
if (delayMs === null) throw error
|
||||
opts.onRetry?.({ attempt, delayMs, error })
|
||||
await sleep(delayMs)
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
|
@ -39,12 +39,7 @@ export interface LnbitsRpcResponse<T = unknown> {
|
|||
/** Non-null on subscription push events. Null on regular acks. */
|
||||
subscription_id?: string | null
|
||||
data?: T
|
||||
/** Human-readable error detail (ERROR status only). */
|
||||
error?: string
|
||||
/** Machine-readable error discriminator (ERROR status). Optional-additive
|
||||
* for one lnbits release, then required; absent → internal_error. See
|
||||
* error-codes.ts (aiolabs/bitspire#52). */
|
||||
error_code?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -112,15 +107,6 @@ export interface WalletInfo {
|
|||
balance: number
|
||||
}
|
||||
|
||||
/** Reply shape of the `create_wallet` RPC — unlike WalletInfo it carries the
|
||||
* fresh wallet's keys, so never log it verbatim. */
|
||||
export interface CreatedWallet {
|
||||
id: string
|
||||
name: string
|
||||
adminkey: string
|
||||
inkey: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Subscriptions
|
||||
// ============================================================================
|
||||
|
|
@ -155,45 +141,6 @@ export interface SubscribeClose {
|
|||
// LNURL-withdraw (the `withdraw` extension's transport surface)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Cash-in request for the SECURE `create_withdraw` RPC (aiolabs/spirekeeper#31).
|
||||
* The ATM supplies only the hardware-attested gross principal; the operator
|
||||
* side derives fee + NET and stamps attribution from the *verified* signer, so
|
||||
* the machine cannot understate the fee or forge attribution. Contrast with
|
||||
* `CreateWithdrawLinkBody`, where the amount + extra were client-supplied.
|
||||
*/
|
||||
export interface CreateWithdrawBody {
|
||||
/** Gross principal in sats — the fiat value the ATM measured. REQUIRED. */
|
||||
principal_sats: number
|
||||
/** Fiat amount for the settlement row + display. */
|
||||
fiat_amount?: number
|
||||
/** Fiat code; defaults to the machine's configured currency server-side. */
|
||||
fiat_code?: string
|
||||
/** Link display title. */
|
||||
title?: string
|
||||
/** Seconds between withdraws (default 1). */
|
||||
wait_time?: number
|
||||
/** Audit ref → settlement.nostr_event_id (use the ATM tx id). */
|
||||
client_ref?: string
|
||||
}
|
||||
|
||||
/** Response from `create_withdraw` — server-derived amounts + the LNURL to show. */
|
||||
export interface CreateWithdrawResult {
|
||||
/** Settlement-watch key — `subscribe_payments { tag:'withdraw', link_id }`. */
|
||||
link_id: string
|
||||
/** bech32 LNURL — the QR the ATM displays. */
|
||||
lnurl: string
|
||||
/** Raw callback URL (alternative for QR generation). */
|
||||
lnurl_url?: string
|
||||
/** NET sats the customer receives (principal − fee). */
|
||||
net_sats: number
|
||||
/** Gross principal echoed back. */
|
||||
principal_sats: number
|
||||
/** Fee withheld (server-computed). */
|
||||
fee_sats: number
|
||||
k1?: string
|
||||
}
|
||||
|
||||
export interface CreateWithdrawLinkBody {
|
||||
title: string
|
||||
min_withdrawable: number
|
||||
|
|
@ -283,30 +230,3 @@ export type PaymentPushCallback = (payment: LnbitsPayment) => void
|
|||
|
||||
/** Called when the subscription has been closed (by TTL or explicit unsubscribe). */
|
||||
export type SubscriptionCloseCallback = (reason: 'ttl' | 'unsubscribed') => void
|
||||
|
||||
// ============================================================================
|
||||
// get_machine_config RPC (spirekeeper#41 / bitspire#70 P1)
|
||||
// ============================================================================
|
||||
|
||||
/** Fee-config wire shape inside `get_machine_config` — mirrors spirekeeper's
|
||||
* `FeeConfigPayload.to_wire_dict()` (snake_case). */
|
||||
export interface FeeConfigWire {
|
||||
schema_version: number
|
||||
cash_in_fee_fraction: number
|
||||
cash_out_fee_fraction: number
|
||||
components?: Record<string, number>
|
||||
}
|
||||
|
||||
/** Response of the `get_machine_config` RPC: the operator pubkey + fee config
|
||||
* (+ fiat, ids) LNbits delivers to a paired ATM over the authenticated
|
||||
* transport, so a seed-only machine needs no per-machine env provisioning.
|
||||
* `fee_config` is null until the operator has a super-config. */
|
||||
export interface MachineConfigResponse {
|
||||
operator_pubkey: string
|
||||
fee_config: FeeConfigWire | null
|
||||
fiat_code: string
|
||||
machine_npub: string
|
||||
wallet_id: string
|
||||
/** Freshness watermark (unix s) for the consumer's fee-config replay guard. */
|
||||
created_at: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@bitSpire/nostr-client",
|
||||
"version": "0.1.0",
|
||||
"description": "Nostr client library for bitSpire ATM",
|
||||
"description": "Nostr client library for Lamassu ATM",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { EventTemplate, VerifiedEvent } from 'nostr-tools'
|
||||
import {
|
||||
BunkerSigner,
|
||||
BunkerRejectedError,
|
||||
BunkerTimeoutError,
|
||||
generateClientTransportKey,
|
||||
connectNewSeed,
|
||||
resumeFromBinding,
|
||||
type Nip46Inner,
|
||||
} from '../bunker-signer.js'
|
||||
|
||||
const SPIRE_PUBKEY = 'b'.repeat(64)
|
||||
|
||||
function fakeInner(overrides: Partial<Nip46Inner> = {}): Nip46Inner {
|
||||
return {
|
||||
connect: vi.fn(async () => {}),
|
||||
signEvent: vi.fn(async (t: EventTemplate) => ({ ...t, id: 'id', sig: 'sig', pubkey: SPIRE_PUBKEY }) as unknown as VerifiedEvent),
|
||||
nip44Encrypt: vi.fn(async (_pk: string, pt: string) => `enc(${pt})`),
|
||||
nip44Decrypt: vi.fn(async (_pk: string, ct: string) => ct.replace(/^enc\((.*)\)$/, '$1')),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('BunkerSigner', () => {
|
||||
it('exposes the spire pubkey synchronously', () => {
|
||||
const signer = new BunkerSigner(SPIRE_PUBKEY, fakeInner())
|
||||
expect(signer.pubkey).toBe(SPIRE_PUBKEY)
|
||||
})
|
||||
|
||||
it('delegates sign / encrypt / decrypt to the inner client', async () => {
|
||||
const inner = fakeInner()
|
||||
const signer = new BunkerSigner(SPIRE_PUBKEY, inner)
|
||||
|
||||
const tmpl: EventTemplate = { kind: 21000, tags: [], content: 'x', created_at: 1 }
|
||||
await signer.signEvent(tmpl)
|
||||
expect(inner.signEvent).toHaveBeenCalledWith(tmpl)
|
||||
|
||||
expect(await signer.nip44Encrypt('peer', 'hi')).toBe('enc(hi)')
|
||||
expect(inner.nip44Encrypt).toHaveBeenCalledWith('peer', 'hi')
|
||||
|
||||
expect(await signer.nip44Decrypt('peer', 'enc(hi)')).toBe('hi')
|
||||
})
|
||||
|
||||
it('maps an inner rejection to BunkerRejectedError (revoked / off-policy)', async () => {
|
||||
const inner = fakeInner({
|
||||
signEvent: vi.fn(async () => {
|
||||
throw new Error('not authorized to sign kind 9999')
|
||||
}),
|
||||
})
|
||||
const signer = new BunkerSigner(SPIRE_PUBKEY, inner)
|
||||
await expect(signer.signEvent({ kind: 9999, tags: [], content: '', created_at: 1 })).rejects.toBeInstanceOf(
|
||||
BunkerRejectedError
|
||||
)
|
||||
})
|
||||
|
||||
it('times out a non-responding bunker with BunkerTimeoutError', async () => {
|
||||
vi.useFakeTimers()
|
||||
const inner = fakeInner({ signEvent: vi.fn(() => new Promise<VerifiedEvent>(() => {})) })
|
||||
const signer = new BunkerSigner(SPIRE_PUBKEY, inner, { timeoutMs: 50 })
|
||||
|
||||
const p = signer.signEvent({ kind: 21000, tags: [], content: '', created_at: 1 })
|
||||
const assertion = expect(p).rejects.toBeInstanceOf(BunkerTimeoutError)
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
await assertion
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transport key + factory guards', () => {
|
||||
it('generates a hex transport keypair', () => {
|
||||
const key = generateClientTransportKey()
|
||||
expect(key.secretHex).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(key.publicHex).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(key.secretHex).not.toBe(key.publicHex)
|
||||
})
|
||||
|
||||
it('connectNewSeed rejects an unparseable bunker_url', async () => {
|
||||
await expect(
|
||||
connectNewSeed({ spirePubkey: SPIRE_PUBKEY, bunkerUrl: 'not-a-bunker-url', clientSecretHex: 'a'.repeat(64) })
|
||||
).rejects.toThrow(/unparseable bunker_url/)
|
||||
})
|
||||
|
||||
it('resumeFromBinding rejects an unparseable bunker_url', async () => {
|
||||
await expect(
|
||||
resumeFromBinding({ spirePubkey: SPIRE_PUBKEY, bunkerUrl: 'not-a-bunker-url', clientSecretHex: 'a'.repeat(64) })
|
||||
).rejects.toThrow(/unparseable bunker_url/)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,33 +1,46 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { generateIdentity } from '../identity.js'
|
||||
import { encryptContentV2, decryptContentV2 } from '../encryption.js'
|
||||
import { encryptContent, decryptContent, decryptJSON } from '../encryption.js'
|
||||
|
||||
describe('encryption (NIP-44 v2)', () => {
|
||||
describe('encryptContentV2 / decryptContentV2', () => {
|
||||
describe('encryption', () => {
|
||||
describe('encryptContent / decryptContent', () => {
|
||||
it('should encrypt and decrypt string content', () => {
|
||||
const sender = generateIdentity()
|
||||
const recipient = generateIdentity()
|
||||
const message = 'Hello, Nostr!'
|
||||
|
||||
const encrypted = encryptContentV2(sender, recipient.publicKey, message)
|
||||
const encrypted = encryptContent(sender, recipient.publicKey, message)
|
||||
|
||||
expect(encrypted).not.toBe(message)
|
||||
expect(typeof encrypted).toBe('string')
|
||||
|
||||
const decrypted = decryptContentV2(recipient, sender.publicKey, encrypted)
|
||||
const decrypted = decryptContent(recipient, sender.publicKey, encrypted)
|
||||
|
||||
expect(decrypted).toBe(message)
|
||||
})
|
||||
|
||||
it('should encrypt and decrypt object content (serialized to JSON)', () => {
|
||||
it('should encrypt and decrypt object content', () => {
|
||||
const sender = generateIdentity()
|
||||
const recipient = generateIdentity()
|
||||
const data = { amount: 1000, currency: 'USD', timestamp: 1_700_000_000 }
|
||||
const data = { amount: 1000, currency: 'USD', timestamp: Date.now() }
|
||||
|
||||
const encrypted = encryptContentV2(sender, recipient.publicKey, data)
|
||||
const decrypted = decryptContentV2(recipient, sender.publicKey, encrypted)
|
||||
const encrypted = encryptContent(sender, recipient.publicKey, data)
|
||||
const decrypted = decryptContent(recipient, sender.publicKey, encrypted)
|
||||
|
||||
expect(JSON.parse(decrypted)).toEqual(data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decryptJSON', () => {
|
||||
it('should decrypt and parse JSON directly', () => {
|
||||
const sender = generateIdentity()
|
||||
const recipient = generateIdentity()
|
||||
const data = { test: true, nested: { value: 42 } }
|
||||
|
||||
const encrypted = encryptContent(sender, recipient.publicKey, data)
|
||||
const decrypted = decryptJSON<typeof data>(recipient, sender.publicKey, encrypted)
|
||||
|
||||
expect(decrypted).toEqual(data)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { generateIdentity } from '../identity.js'
|
||||
import { LocalSigner } from '../signer.js'
|
||||
import { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from '../events.js'
|
||||
import { LamassuEventKind } from '../types.js'
|
||||
import {
|
||||
createSignedEvent,
|
||||
createMachineStatusEvent,
|
||||
createAuthEvent,
|
||||
validateEvent,
|
||||
generateTxId,
|
||||
} from '../events.js'
|
||||
import { LamassuEventKind, type MachineStatus } from '../types.js'
|
||||
|
||||
describe('events', () => {
|
||||
describe('createSignedEvent', () => {
|
||||
it('should create a properly signed event via the signer', async () => {
|
||||
it('should create a properly signed event', () => {
|
||||
const identity = generateIdentity()
|
||||
const signer = new LocalSigner(identity)
|
||||
const event = await createSignedEvent(signer, {
|
||||
const event = createSignedEvent(identity, {
|
||||
kind: 1,
|
||||
content: 'test',
|
||||
tags: [],
|
||||
|
|
@ -21,23 +25,48 @@ describe('events', () => {
|
|||
expect(event.content).toBe('test')
|
||||
expect(event.id).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(event.sig).toMatch(/^[0-9a-f]{128}$/)
|
||||
expect(validateEvent(event)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMachineStatusEvent', () => {
|
||||
it('should create encrypted status event', () => {
|
||||
const machine = generateIdentity()
|
||||
const operator = generateIdentity()
|
||||
|
||||
const status: MachineStatus = {
|
||||
online: true,
|
||||
lastTransaction: Date.now(),
|
||||
cashLevels: {
|
||||
validator: 1000,
|
||||
dispenser: [{ denomination: 20, count: 100, capacity: 500 }],
|
||||
},
|
||||
errors: [],
|
||||
version: '1.0.0',
|
||||
}
|
||||
|
||||
const event = createMachineStatusEvent(machine, operator.publicKey, status)
|
||||
|
||||
expect(event.kind).toBe(LamassuEventKind.MachineStatus)
|
||||
expect(event.pubkey).toBe(machine.publicKey)
|
||||
expect(event.tags).toContainEqual(['d', 'status'])
|
||||
expect(event.tags).toContainEqual(['p', operator.publicKey])
|
||||
// Content should be encrypted (not readable JSON)
|
||||
expect(() => JSON.parse(event.content)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAuthEvent', () => {
|
||||
it('should create a signed NIP-42 auth event (kind 22242)', async () => {
|
||||
const signer = new LocalSigner(generateIdentity())
|
||||
it('should create NIP-42 auth event', () => {
|
||||
const identity = generateIdentity()
|
||||
const relayUrl = 'wss://relay.test.com'
|
||||
const challenge = 'random-challenge-string'
|
||||
|
||||
const event = await createAuthEvent(signer, relayUrl, challenge)
|
||||
const event = createAuthEvent(identity, relayUrl, challenge)
|
||||
|
||||
expect(event.kind).toBe(LamassuEventKind.Auth)
|
||||
expect(event.content).toBe('')
|
||||
expect(event.tags).toContainEqual(['relay', relayUrl])
|
||||
expect(event.tags).toContainEqual(['challenge', challenge])
|
||||
expect(event.pubkey).toBe(signer.pubkey)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { npubEncode } from 'nostr-tools/nip19'
|
||||
import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js'
|
||||
|
||||
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
||||
function makeSeed(json: unknown): string {
|
||||
const b64 = Buffer.from(JSON.stringify(json), 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
return SPIRE_SEED_SCHEME + b64
|
||||
}
|
||||
|
||||
const SPIRE_PUBKEY = 'a'.repeat(64)
|
||||
const LNBITS_PUBKEY = 'b'.repeat(64)
|
||||
const SPIRE_NPUB = npubEncode(SPIRE_PUBKEY)
|
||||
const LNBITS_NPUB = npubEncode(LNBITS_PUBKEY)
|
||||
|
||||
const VALID = {
|
||||
v: 1,
|
||||
spire_npub: SPIRE_NPUB,
|
||||
lnbits_npub: LNBITS_NPUB,
|
||||
bunker_secret: 'deadbeef',
|
||||
relays: ['wss://events.relay/'],
|
||||
}
|
||||
|
||||
describe('parseSpireSeed', () => {
|
||||
it('derives hex pubkeys from npubs and reconstructs the bunker URL', () => {
|
||||
const seed = parseSpireSeed(makeSeed(VALID))
|
||||
expect(seed).toEqual({
|
||||
v: 1,
|
||||
spirePubkey: SPIRE_PUBKEY,
|
||||
lnbitsServerPubkey: LNBITS_PUBKEY,
|
||||
bunkerUrl: `bunker://${SPIRE_PUBKEY}?relay=${encodeURIComponent('wss://events.relay/')}&secret=deadbeef`,
|
||||
relays: ['wss://events.relay/'],
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults the bunker relay to relays[0] when bunker_relay is absent', () => {
|
||||
const seed = parseSpireSeed(makeSeed(VALID))
|
||||
expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://events.relay/')}`)
|
||||
})
|
||||
|
||||
it('uses an explicit bunker_relay when present (distinct from event relays)', () => {
|
||||
const seed = parseSpireSeed(makeSeed({ ...VALID, bunker_relay: 'wss://bunker.relay/' }))
|
||||
expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://bunker.relay/')}`)
|
||||
// event relays are unchanged
|
||||
expect(seed.relays).toEqual(['wss://events.relay/'])
|
||||
})
|
||||
|
||||
it('percent-encodes relay + secret for parseBunkerInput to decode', () => {
|
||||
const seed = parseSpireSeed(makeSeed(VALID))
|
||||
expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F')
|
||||
expect(seed.bunkerUrl).toContain('secret=deadbeef')
|
||||
})
|
||||
|
||||
it('re-pads stripped base64url of any residue length', () => {
|
||||
// Vary the secret so the encoded payload lands on each mod-4 residue.
|
||||
for (const suffix of ['', 'a', 'ab', 'abc']) {
|
||||
const seed = makeSeed({ ...VALID, bunker_secret: `deadbeef${suffix}` })
|
||||
expect(() => parseSpireSeed(seed)).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['wrong scheme', 'spire-seed:v2:abc'],
|
||||
['not a seed', 'bunker://whatever'],
|
||||
])('rejects %s', (_label, url) => {
|
||||
expect(() => parseSpireSeed(url)).toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['bad version', { ...VALID, v: 2 }],
|
||||
['missing spire_npub', { ...VALID, spire_npub: undefined }],
|
||||
['non-npub spire_npub', { ...VALID, spire_npub: 'a'.repeat(64) }],
|
||||
['missing lnbits_npub', { ...VALID, lnbits_npub: undefined }],
|
||||
['non-npub lnbits_npub', { ...VALID, lnbits_npub: 'notanpub' }],
|
||||
['empty bunker_secret', { ...VALID, bunker_secret: '' }],
|
||||
['missing bunker_secret', { ...VALID, bunker_secret: undefined }],
|
||||
['empty relays', { ...VALID, relays: [] }],
|
||||
['non-string relay', { ...VALID, relays: [123] }],
|
||||
['non-ws relay (scan corruption ws://→As://)', { ...VALID, relays: ['As://events.relay/'] }],
|
||||
['non-ws relay (http)', { ...VALID, relays: ['http://events.relay/'] }],
|
||||
['empty bunker_relay', { ...VALID, bunker_relay: '' }],
|
||||
['non-ws bunker_relay', { ...VALID, bunker_relay: 'As://bunker.relay/' }],
|
||||
])('rejects %s', (_label, json) => {
|
||||
expect(() => parseSpireSeed(makeSeed(json))).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('seedFingerprint', () => {
|
||||
it('is stable for the same seed and differs across seeds', () => {
|
||||
const a = makeSeed(VALID)
|
||||
const b = makeSeed({ ...VALID, relays: ['wss://other.relay/'] })
|
||||
expect(seedFingerprint(a)).toBe(seedFingerprint(a))
|
||||
expect(seedFingerprint(a)).not.toBe(seedFingerprint(b))
|
||||
expect(seedFingerprint(a)).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { finalizeEvent, verifyEvent } from 'nostr-tools'
|
||||
import { generateIdentity } from '../identity.js'
|
||||
import { LocalSigner } from '../signer.js'
|
||||
import { encryptContentV2, decryptContentV2 } from '../encryption.js'
|
||||
|
||||
describe('LocalSigner', () => {
|
||||
it('exposes the identity pubkey synchronously', () => {
|
||||
const identity = generateIdentity()
|
||||
const signer = new LocalSigner(identity)
|
||||
expect(signer.pubkey).toBe(identity.publicKey)
|
||||
})
|
||||
|
||||
it('signEvent produces a valid signature equivalent to finalizeEvent', async () => {
|
||||
const identity = generateIdentity()
|
||||
const signer = new LocalSigner(identity)
|
||||
const template = {
|
||||
kind: 21000,
|
||||
content: 'rpc',
|
||||
tags: [['p', identity.publicKey]],
|
||||
created_at: 1_700_000_000,
|
||||
}
|
||||
|
||||
const signed = await signer.signEvent(template)
|
||||
const reference = finalizeEvent(template, identity.privateKey)
|
||||
|
||||
expect(verifyEvent(signed)).toBe(true)
|
||||
expect(signed.pubkey).toBe(identity.publicKey)
|
||||
// Same template + same key ⇒ same id (id is deterministic over content).
|
||||
expect(signed.id).toBe(reference.id)
|
||||
})
|
||||
|
||||
it('nip44Encrypt round-trips with the counterparty signer', async () => {
|
||||
const alice = generateIdentity()
|
||||
const bob = generateIdentity()
|
||||
const aliceSigner = new LocalSigner(alice)
|
||||
const bobSigner = new LocalSigner(bob)
|
||||
|
||||
const ciphertext = await aliceSigner.nip44Encrypt(bob.publicKey, 'secret')
|
||||
const plaintext = await bobSigner.nip44Decrypt(alice.publicKey, ciphertext)
|
||||
|
||||
expect(plaintext).toBe('secret')
|
||||
})
|
||||
|
||||
it('nip44 output interops with the standalone encryptContentV2 helper', async () => {
|
||||
const alice = generateIdentity()
|
||||
const bob = generateIdentity()
|
||||
const aliceSigner = new LocalSigner(alice)
|
||||
|
||||
const viaSigner = await aliceSigner.nip44Encrypt(bob.publicKey, 'hello')
|
||||
// The helper and the signer share NIP-44 v2 conversation-key derivation,
|
||||
// so each can decrypt the other's ciphertext.
|
||||
expect(decryptContentV2(bob, alice.publicKey, viaSigner)).toBe('hello')
|
||||
|
||||
const viaHelper = encryptContentV2(alice, bob.publicKey, 'hello')
|
||||
expect(await aliceSigner.nip44Decrypt(bob.publicKey, viaHelper)).toBe('hello')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
/**
|
||||
* NIP-46 (nsecbunkerd) signer.
|
||||
*
|
||||
* Implements the `Signer` contract by delegating sign / nip44 to a remote
|
||||
* bunker over NIP-46, so no operator key lives on the ATM. The ATM holds
|
||||
* only its own *transport* keypair (`client_nsec`); the signing identity
|
||||
* (`spire_pubkey`) is held by the operator's nsecbunkerd. See
|
||||
* aiolabs/bitspire#52 (model A1) and lnbits `nip46_bunker_client.py`.
|
||||
*
|
||||
* Two lifecycle entry points:
|
||||
* - `connectNewSeed` — first pairing: generate a transport key, redeem the
|
||||
* one-shot connect secret, bind `client_pubkey → spire_key` on the bunker.
|
||||
* - `resumeFromBinding` — restart: reuse the persisted transport key. The
|
||||
* binding is server-persistent, so we do NOT re-redeem (the secret is
|
||||
* spent); we just re-open the relay subscription.
|
||||
*
|
||||
* `pubkey` is the spire identity, known synchronously from the seed/binding,
|
||||
* so subscription filters and `p` tags work before any round-trip.
|
||||
*/
|
||||
|
||||
import { BunkerSigner as Nip46BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { bytesToHex, hexToBytes } from 'nostr-tools/utils'
|
||||
import type { EventTemplate, VerifiedEvent } from 'nostr-tools'
|
||||
import type { Signer } from './signer.js'
|
||||
|
||||
/** Default per-RPC timeout. nostr-tools' nip46 sendRequest has none — a dead
|
||||
* bunker would hang forever — so we race every call against this. */
|
||||
const DEFAULT_BUNKER_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* Raised when the bunker actively rejects a request. Post-bind causes
|
||||
* (nsecbunkerd#27, sign-time lifecycle enforcement): the operator revoked the
|
||||
* binding (`KeyUser`/`Token.revokedAt`), the token's TTL (`expiresAt`) lapsed,
|
||||
* or the requested kind/method is outside the policy. Callers should treat
|
||||
* this as "unpaired" and surface a re-pair prompt.
|
||||
*/
|
||||
export class BunkerRejectedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'BunkerRejectedError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when the bunker does not answer within the timeout (transient). */
|
||||
export class BunkerTimeoutError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'BunkerTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
/** The subset of nostr-tools' nip46 BunkerSigner this wrapper drives. */
|
||||
export interface Nip46Inner {
|
||||
connect(): Promise<void>
|
||||
signEvent(event: EventTemplate): Promise<VerifiedEvent>
|
||||
nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string>
|
||||
nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string>
|
||||
}
|
||||
|
||||
export interface BunkerSignerOptions {
|
||||
/** Per-RPC timeout in ms (default 10000). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export class BunkerSigner implements Signer {
|
||||
readonly pubkey: string
|
||||
readonly #inner: Nip46Inner
|
||||
readonly #timeoutMs: number
|
||||
|
||||
constructor(spirePubkey: string, inner: Nip46Inner, opts: BunkerSignerOptions = {}) {
|
||||
this.pubkey = spirePubkey
|
||||
this.#inner = inner
|
||||
this.#timeoutMs = opts.timeoutMs ?? DEFAULT_BUNKER_TIMEOUT_MS
|
||||
}
|
||||
|
||||
signEvent(template: EventTemplate): Promise<VerifiedEvent> {
|
||||
return this.#call('sign_event', () => this.#inner.signEvent(template))
|
||||
}
|
||||
|
||||
nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string> {
|
||||
return this.#call('nip44_encrypt', () => this.#inner.nip44Encrypt(peerPubkey, plaintext))
|
||||
}
|
||||
|
||||
nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string> {
|
||||
return this.#call('nip44_decrypt', () => this.#inner.nip44Decrypt(peerPubkey, ciphertext))
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a bunker RPC with a timeout and normalize failures. nostr-tools'
|
||||
* nip46 rejects with the bunker's `error` string (a rejection) — mapped to
|
||||
* `BunkerRejectedError`; a non-response surfaces as `BunkerTimeoutError`.
|
||||
*/
|
||||
async #call<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new BunkerTimeoutError(`bunker ${label}: no response in ${this.#timeoutMs}ms`)),
|
||||
this.#timeoutMs
|
||||
)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([fn(), timeout])
|
||||
} catch (err) {
|
||||
if (err instanceof BunkerTimeoutError) throw err
|
||||
throw new BunkerRejectedError(`bunker ${label}: ${(err as Error).message ?? String(err)}`)
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A freshly-generated NIP-46 transport keypair (the ATM's `client_nsec`). */
|
||||
export interface ClientTransportKey {
|
||||
/** 64-char hex secret key — persist this to state.db. */
|
||||
secretHex: string
|
||||
/** 64-char hex public key — what the bunker binds to the spire identity. */
|
||||
publicHex: string
|
||||
}
|
||||
|
||||
/** Generate the ATM's own NIP-46 transport keypair. */
|
||||
export function generateClientTransportKey(): ClientTransportKey {
|
||||
const sk = generateSecretKey()
|
||||
return { secretHex: bytesToHex(sk), publicHex: getPublicKey(sk) }
|
||||
}
|
||||
|
||||
/** Persisted bunker binding — everything needed to resume without re-pairing. */
|
||||
export interface BunkerBinding {
|
||||
/** Hex transport secret key (`client_nsec`). */
|
||||
clientSecretHex: string
|
||||
/** The spire's signing pubkey (hex). */
|
||||
spirePubkey: string
|
||||
/** `bunker://…` URL, re-parsed into a pointer on resume. */
|
||||
bunkerUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* First pairing: build a transport-keyed bunker signer, redeem the one-shot
|
||||
* connect secret, and bind `client_pubkey → spire_key`. The returned signer
|
||||
* is live; the caller persists `clientSecretHex` so a restart can resume.
|
||||
*
|
||||
* `bunkerUrl` is the seed's `bunker_url`; `spirePubkey` is the seed's
|
||||
* `spire_pubkey`.
|
||||
*/
|
||||
export async function connectNewSeed(
|
||||
args: { spirePubkey: string; bunkerUrl: string; clientSecretHex: string },
|
||||
opts: BunkerSignerOptions = {}
|
||||
): Promise<BunkerSigner> {
|
||||
const pointer = await parseBunkerInput(args.bunkerUrl)
|
||||
if (!pointer) {
|
||||
throw new Error(`connectNewSeed: unparseable bunker_url`)
|
||||
}
|
||||
const inner = Nip46BunkerSigner.fromBunker(hexToBytes(args.clientSecretHex), pointer)
|
||||
await inner.connect() // redeems the one-shot secret; eager-binds on the bunker
|
||||
return new BunkerSigner(args.spirePubkey, inner, opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart: reuse the persisted transport key. The bunker binding is
|
||||
* server-persistent, so we do NOT call `connect()` (the secret is spent);
|
||||
* `fromBunker` opens the relay subscription and `sign_event` works against
|
||||
* the existing binding.
|
||||
*/
|
||||
export async function resumeFromBinding(
|
||||
binding: BunkerBinding,
|
||||
opts: BunkerSignerOptions = {}
|
||||
): Promise<BunkerSigner> {
|
||||
const pointer = await parseBunkerInput(binding.bunkerUrl)
|
||||
if (!pointer) {
|
||||
throw new Error(`resumeFromBinding: unparseable bunker_url`)
|
||||
}
|
||||
// The connect secret is already spent; drop it so nothing re-redeems.
|
||||
pointer.secret = null
|
||||
const inner = Nip46BunkerSigner.fromBunker(hexToBytes(binding.clientSecretHex), pointer)
|
||||
return new BunkerSigner(binding.spirePubkey, inner, opts)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Nostr client for bitSpire ATM
|
||||
* Nostr client for Lamassu ATM
|
||||
*
|
||||
* Manages connections to Nostr relays with support for:
|
||||
* - NIP-42 authentication
|
||||
|
|
@ -7,7 +7,14 @@
|
|||
* - Automatic reconnection
|
||||
*/
|
||||
|
||||
import { type Event, type Filter, Relay, SimplePool, verifyEvent, nip19 } from 'nostr-tools'
|
||||
import {
|
||||
type Event,
|
||||
type Filter,
|
||||
type VerifiedEvent,
|
||||
Relay,
|
||||
SimplePool,
|
||||
verifyEvent,
|
||||
} from 'nostr-tools'
|
||||
import { createAuthEvent } from './events.js'
|
||||
import type {
|
||||
NostrClientConfig,
|
||||
|
|
@ -149,15 +156,10 @@ export class NostrClient {
|
|||
// We need to extract the challenge and create our auth response
|
||||
const challenge =
|
||||
evt.tags?.find((t): t is [string, string] => t[0] === 'challenge')?.[1] ?? ''
|
||||
const authEvent = await createAuthEvent(
|
||||
this.config.signer,
|
||||
connection.config.url,
|
||||
challenge
|
||||
)
|
||||
// The signer returns a fully-signed event; re-verify defensively
|
||||
// (a remote bunker could in principle return a malformed reply).
|
||||
const authEvent = createAuthEvent(this.config.identity, connection.config.url, challenge)
|
||||
// Verify the event to get a VerifiedEvent type
|
||||
if (verifyEvent(authEvent)) {
|
||||
return authEvent
|
||||
return authEvent as VerifiedEvent
|
||||
}
|
||||
throw new Error('Failed to create valid auth event')
|
||||
})
|
||||
|
|
@ -391,13 +393,13 @@ export class NostrClient {
|
|||
* Get the machine's public key
|
||||
*/
|
||||
get publicKey(): string {
|
||||
return this.config.signer.pubkey
|
||||
return this.config.identity.publicKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the machine's npub
|
||||
*/
|
||||
get npub(): string {
|
||||
return nip19.npubEncode(this.config.signer.pubkey)
|
||||
return this.config.identity.npub
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,274 @@
|
|||
/**
|
||||
* NIP-44 v2 encryption helpers.
|
||||
* NIP-44 Encryption utilities
|
||||
*
|
||||
* Thin wrappers over nostr-tools `nip44.v2`, used for operator-directed
|
||||
* kind-30078 content and by the dormant CLINK client. Kind-21000 RPC and
|
||||
* the availability/cassette paths route through the `Signer` abstraction
|
||||
* (`signer.ts`) instead.
|
||||
* Supports both:
|
||||
* - v1: Lightning.Pub's custom format (xchacha20, used for kind 21000)
|
||||
* - v2: Standard NIP-44 v2 (used for other kinds)
|
||||
*
|
||||
* The legacy NIP-44 v1 / Lightning.Pub XChaCha20 format was retired with
|
||||
* the LNbits migration (aiolabs/bitspire#52): the nsecbunkerd signer is
|
||||
* NIP-44 v2 only and nothing live used v1.
|
||||
* NOTE: Lightning.Pub currently only supports NIP-44 v1 for kind 21000 RPC.
|
||||
* A contribution to support v2 would be welcome:
|
||||
* https://github.com/shocknet/Lightning.Pub
|
||||
*/
|
||||
|
||||
import { nip44 } from 'nostr-tools'
|
||||
import { bytesToHex, hexToBytes } from 'nostr-tools/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1.js'
|
||||
import { sha256 } from '@noble/hashes/sha2.js'
|
||||
import type { MachineIdentity } from './types.js'
|
||||
|
||||
const V1_ENCRYPTION_VERSION = 1
|
||||
|
||||
// Base64 utilities that work in both browser and Node
|
||||
function base64Encode(bytes: Uint8Array): string {
|
||||
if (typeof btoa !== 'undefined') {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]!)
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
function base64Decode(str: string): Uint8Array {
|
||||
if (typeof atob !== 'undefined') {
|
||||
const binary = atob(str)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
return new Uint8Array(Buffer.from(str, 'base64'))
|
||||
}
|
||||
|
||||
// Crypto random bytes
|
||||
function getRandomBytes(length: number): Uint8Array {
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
return crypto.getRandomValues(new Uint8Array(length))
|
||||
}
|
||||
// Node.js fallback
|
||||
const { randomBytes } = require('crypto') as typeof import('crypto')
|
||||
return new Uint8Array(randomBytes(length))
|
||||
}
|
||||
|
||||
// XChaCha20 implementation
|
||||
function rotl(a: number, b: number): number {
|
||||
return ((a << b) | (a >>> (32 - b))) >>> 0
|
||||
}
|
||||
|
||||
function quarterRound(state: Uint32Array, a: number, b: number, c: number, d: number): void {
|
||||
state[a] = (state[a]! + state[b]!) >>> 0
|
||||
state[d] = rotl(state[d]! ^ state[a]!, 16)
|
||||
state[c] = (state[c]! + state[d]!) >>> 0
|
||||
state[b] = rotl(state[b]! ^ state[c]!, 12)
|
||||
state[a] = (state[a]! + state[b]!) >>> 0
|
||||
state[d] = rotl(state[d]! ^ state[a]!, 8)
|
||||
state[c] = (state[c]! + state[d]!) >>> 0
|
||||
state[b] = rotl(state[b]! ^ state[c]!, 7)
|
||||
}
|
||||
|
||||
function chacha20Block(key: Uint8Array, nonce: Uint8Array, counter: number): Uint8Array {
|
||||
const state = new Uint32Array(16)
|
||||
const keyBuf = new ArrayBuffer(32)
|
||||
new Uint8Array(keyBuf).set(key)
|
||||
const nonceBuf = new ArrayBuffer(12)
|
||||
new Uint8Array(nonceBuf).set(nonce)
|
||||
const view = new DataView(keyBuf)
|
||||
const nonceView = new DataView(nonceBuf)
|
||||
|
||||
// "expand 32-byte k"
|
||||
state[0] = 0x61707865
|
||||
state[1] = 0x3320646e
|
||||
state[2] = 0x79622d32
|
||||
state[3] = 0x6b206574
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
state[4 + i] = view.getUint32(i * 4, true)
|
||||
}
|
||||
|
||||
state[12] = counter >>> 0
|
||||
for (let i = 0; i < 3; i++) {
|
||||
state[13 + i] = nonceView.getUint32(i * 4, true)
|
||||
}
|
||||
|
||||
const working = new Uint32Array(state)
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
quarterRound(working, 0, 4, 8, 12)
|
||||
quarterRound(working, 1, 5, 9, 13)
|
||||
quarterRound(working, 2, 6, 10, 14)
|
||||
quarterRound(working, 3, 7, 11, 15)
|
||||
quarterRound(working, 0, 5, 10, 15)
|
||||
quarterRound(working, 1, 6, 11, 12)
|
||||
quarterRound(working, 2, 7, 8, 13)
|
||||
quarterRound(working, 3, 4, 9, 14)
|
||||
}
|
||||
|
||||
const output = new Uint8Array(64)
|
||||
const outView = new DataView(output.buffer)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
outView.setUint32(i * 4, (working[i]! + state[i]!) >>> 0, true)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function hchacha20(key: Uint8Array, nonce: Uint8Array): Uint8Array {
|
||||
const state = new Uint32Array(16)
|
||||
const keyBuf = new ArrayBuffer(32)
|
||||
new Uint8Array(keyBuf).set(key)
|
||||
const nonceBuf = new ArrayBuffer(16)
|
||||
new Uint8Array(nonceBuf).set(nonce)
|
||||
const keyView = new DataView(keyBuf)
|
||||
const nonceView = new DataView(nonceBuf)
|
||||
|
||||
state[0] = 0x61707865
|
||||
state[1] = 0x3320646e
|
||||
state[2] = 0x79622d32
|
||||
state[3] = 0x6b206574
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
state[4 + i] = keyView.getUint32(i * 4, true)
|
||||
}
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
state[12 + i] = nonceView.getUint32(i * 4, true)
|
||||
}
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
quarterRound(state, 0, 4, 8, 12)
|
||||
quarterRound(state, 1, 5, 9, 13)
|
||||
quarterRound(state, 2, 6, 10, 14)
|
||||
quarterRound(state, 3, 7, 11, 15)
|
||||
quarterRound(state, 0, 5, 10, 15)
|
||||
quarterRound(state, 1, 6, 11, 12)
|
||||
quarterRound(state, 2, 7, 8, 13)
|
||||
quarterRound(state, 3, 4, 9, 14)
|
||||
}
|
||||
|
||||
const result = new Uint8Array(32)
|
||||
const resultView = new DataView(result.buffer)
|
||||
resultView.setUint32(0, state[0]!, true)
|
||||
resultView.setUint32(4, state[1]!, true)
|
||||
resultView.setUint32(8, state[2]!, true)
|
||||
resultView.setUint32(12, state[3]!, true)
|
||||
resultView.setUint32(16, state[12]!, true)
|
||||
resultView.setUint32(20, state[13]!, true)
|
||||
resultView.setUint32(24, state[14]!, true)
|
||||
resultView.setUint32(28, state[15]!, true)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function xchacha20Encrypt(key: Uint8Array, nonce: Uint8Array, data: Uint8Array): Uint8Array {
|
||||
const subkey = hchacha20(key, nonce.subarray(0, 16))
|
||||
const chacha20Nonce = new Uint8Array(12)
|
||||
chacha20Nonce.set(nonce.subarray(16, 24), 4)
|
||||
|
||||
const result = new Uint8Array(data.length)
|
||||
let counter = 0
|
||||
|
||||
for (let offset = 0; offset < data.length; offset += 64) {
|
||||
const block = chacha20Block(subkey, chacha20Nonce, counter++)
|
||||
const remaining = Math.min(64, data.length - offset)
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
result[offset + i] = data[offset + i]! ^ block[i]!
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared secret for v1 encryption (Lightning.Pub format)
|
||||
*
|
||||
* NIP-44 v1 key derivation:
|
||||
* sha256(secp256k1.getSharedSecret(privKey, "02" + pubKey).slice(1, 33))
|
||||
*
|
||||
* This differs from v2 which uses HKDF instead of plain SHA-256.
|
||||
*/
|
||||
function getConversationKeyV1(privateKey: Uint8Array, publicKey: string): Uint8Array {
|
||||
// Compute ECDH shared point with compressed pubkey (02 prefix for even y)
|
||||
const compressedPubkey = hexToBytes('02' + publicKey)
|
||||
const sharedPoint = secp256k1.getSharedSecret(privateKey, compressedPubkey)
|
||||
// Take x-coordinate only (skip the 0x04 prefix byte) and hash with SHA-256
|
||||
return sha256(sharedPoint.slice(1, 33))
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt content using v1 format (Lightning.Pub's format for kind 21000)
|
||||
*/
|
||||
export function encryptV1(content: string, sharedSecret: Uint8Array): string {
|
||||
const nonce = getRandomBytes(24)
|
||||
const plaintext = new TextEncoder().encode(content)
|
||||
const ciphertext = xchacha20Encrypt(sharedSecret, nonce, plaintext)
|
||||
|
||||
const payload = new Uint8Array(1 + nonce.length + ciphertext.length)
|
||||
payload[0] = V1_ENCRYPTION_VERSION
|
||||
payload.set(nonce, 1)
|
||||
payload.set(ciphertext, 25)
|
||||
|
||||
return base64Encode(payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt content using v1 format (Lightning.Pub's format)
|
||||
*/
|
||||
export function decryptV1(content: string, sharedSecret: Uint8Array): string {
|
||||
const buf = base64Decode(content)
|
||||
|
||||
if (buf[0] !== V1_ENCRYPTION_VERSION) {
|
||||
throw new Error('Encryption version unsupported')
|
||||
}
|
||||
|
||||
const nonce = buf.subarray(1, 25)
|
||||
const ciphertext = buf.subarray(25)
|
||||
const plaintext = xchacha20Encrypt(sharedSecret, nonce, ciphertext) // XChaCha20 is symmetric
|
||||
|
||||
return new TextDecoder().decode(plaintext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt content for Lightning.Pub RPC (kind 21000)
|
||||
* Uses v1 format that Lightning.Pub expects
|
||||
*/
|
||||
export function encryptContent(
|
||||
identity: MachineIdentity,
|
||||
recipientPubkey: string,
|
||||
content: unknown
|
||||
): string {
|
||||
const plaintext = typeof content === 'string' ? content : JSON.stringify(content)
|
||||
const sharedSecret = getConversationKeyV1(identity.privateKey, recipientPubkey)
|
||||
return encryptV1(plaintext, sharedSecret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt content from Lightning.Pub RPC (kind 21000)
|
||||
* Uses v1 format
|
||||
*/
|
||||
export function decryptContent(
|
||||
identity: MachineIdentity,
|
||||
senderPubkey: string,
|
||||
ciphertext: string
|
||||
): string {
|
||||
const sharedSecret = getConversationKeyV1(identity.privateKey, senderPubkey)
|
||||
return decryptV1(ciphertext, sharedSecret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and parse JSON content
|
||||
*/
|
||||
export function decryptJSON<T = unknown>(
|
||||
identity: MachineIdentity,
|
||||
senderPubkey: string,
|
||||
ciphertext: string
|
||||
): T {
|
||||
const plaintext = decryptContent(identity, senderPubkey, ciphertext)
|
||||
return JSON.parse(plaintext) as T
|
||||
}
|
||||
|
||||
// Also export v2 functions for other use cases (non-RPC encrypted messages)
|
||||
export const encryptContentV2 = (
|
||||
identity: MachineIdentity,
|
||||
recipientPubkey: string,
|
||||
|
|
|
|||
|
|
@ -1,34 +1,88 @@
|
|||
/**
|
||||
* Event creation utilities for bitSpire ATM
|
||||
* Event creation utilities for Lamassu ATM
|
||||
*/
|
||||
|
||||
import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools'
|
||||
import type { Signer } from './signer.js'
|
||||
import { LamassuEventKind } from './types.js'
|
||||
import { type Event, type UnsignedEvent, finalizeEvent, getEventHash } from 'nostr-tools'
|
||||
import { encryptContent } from './encryption.js'
|
||||
import {
|
||||
type MachineIdentity,
|
||||
type MachineStatus,
|
||||
type TransactionRecord,
|
||||
LamassuEventKind,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Sign an event template with the given signer.
|
||||
*
|
||||
* Thin async wrapper over `Signer.signEvent` — the signer sets `pubkey`,
|
||||
* `id` and `sig`. With a `BunkerSigner` this is a relay round-trip.
|
||||
* Create a signed event
|
||||
*/
|
||||
export function createSignedEvent(signer: Signer, template: EventTemplate): Promise<VerifiedEvent> {
|
||||
return signer.signEvent(template)
|
||||
export function createSignedEvent(
|
||||
identity: MachineIdentity,
|
||||
event: Omit<UnsignedEvent, 'pubkey'>
|
||||
): Event {
|
||||
const unsigned: UnsignedEvent = {
|
||||
...event,
|
||||
pubkey: identity.publicKey,
|
||||
}
|
||||
|
||||
return finalizeEvent(unsigned, identity.privateKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NIP-42 auth event for relay authentication.
|
||||
* Create a machine status event (Kind 30078)
|
||||
*
|
||||
* Signed as the spire identity (kind 22242). Under the bunker this kind
|
||||
* must be present in the signer policy (`SPIRE_POLICY_RULES`) or the sign
|
||||
* request is rejected — see aiolabs/spirekeeper#26.
|
||||
* This is a replaceable event that represents the current machine state.
|
||||
* Content is encrypted with NIP-44 for the operator.
|
||||
*/
|
||||
export function createMachineStatusEvent(
|
||||
identity: MachineIdentity,
|
||||
operatorPubkey: string,
|
||||
status: MachineStatus
|
||||
): Event {
|
||||
const encryptedContent = encryptContent(identity, operatorPubkey, status)
|
||||
|
||||
return createSignedEvent(identity, {
|
||||
kind: LamassuEventKind.MachineStatus,
|
||||
content: encryptedContent,
|
||||
tags: [
|
||||
['d', 'status'],
|
||||
['p', operatorPubkey],
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a transaction record event (Kind 30079)
|
||||
*
|
||||
* Replaceable event for each transaction, identified by txid.
|
||||
* Content is encrypted with NIP-44 for the operator.
|
||||
*/
|
||||
export function createTransactionEvent(
|
||||
identity: MachineIdentity,
|
||||
operatorPubkey: string,
|
||||
transaction: TransactionRecord
|
||||
): Event {
|
||||
const encryptedContent = encryptContent(identity, operatorPubkey, transaction)
|
||||
|
||||
return createSignedEvent(identity, {
|
||||
kind: LamassuEventKind.TransactionRecord,
|
||||
content: encryptedContent,
|
||||
tags: [
|
||||
['d', `tx:${transaction.txid}`],
|
||||
['p', operatorPubkey],
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NIP-42 auth event for relay authentication
|
||||
*/
|
||||
export function createAuthEvent(
|
||||
signer: Signer,
|
||||
identity: MachineIdentity,
|
||||
relayUrl: string,
|
||||
challenge: string
|
||||
): Promise<VerifiedEvent> {
|
||||
return signer.signEvent({
|
||||
): Event {
|
||||
return createSignedEvent(identity, {
|
||||
kind: LamassuEventKind.Auth,
|
||||
content: '',
|
||||
tags: [
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* @bitSpire/nostr-client
|
||||
*
|
||||
* Nostr client library for bitSpire ATM communication.
|
||||
* Nostr client library for Lamassu ATM communication.
|
||||
*
|
||||
* Features:
|
||||
* - NIP-42 authentication for private relays
|
||||
|
|
@ -15,32 +15,30 @@
|
|||
* import {
|
||||
* NostrClient,
|
||||
* generateIdentity,
|
||||
* LocalSigner,
|
||||
* createSignedEvent
|
||||
* createMachineStatusEvent
|
||||
* } from '@bitSpire/nostr-client'
|
||||
*
|
||||
* // Create or load identity, wrap it in a signer
|
||||
* const signer = new LocalSigner(generateIdentity())
|
||||
* // Create or load identity
|
||||
* const identity = generateIdentity()
|
||||
*
|
||||
* // Create client
|
||||
* const client = new NostrClient({
|
||||
* relays: [
|
||||
* { url: 'wss://relay.youratm.company', requiresAuth: true }
|
||||
* ],
|
||||
* signer
|
||||
* identity
|
||||
* })
|
||||
*
|
||||
* // Connect
|
||||
* await client.connect()
|
||||
*
|
||||
* // Sign + publish an event
|
||||
* const event = await createSignedEvent(signer, {
|
||||
* kind: 30078,
|
||||
* created_at: Math.floor(Date.now() / 1000),
|
||||
* tags: [['d', 'status']],
|
||||
* content: '...'
|
||||
* })
|
||||
* await client.publish(event)
|
||||
* // Publish machine status
|
||||
* const statusEvent = createMachineStatusEvent(
|
||||
* identity,
|
||||
* operatorPubkey,
|
||||
* { online: true, ... }
|
||||
* )
|
||||
* await client.publish(statusEvent)
|
||||
* ```
|
||||
*/
|
||||
|
||||
|
|
@ -57,28 +55,25 @@ export {
|
|||
bytesToHex,
|
||||
} from './identity.js'
|
||||
|
||||
// Signing abstraction
|
||||
export { LocalSigner } from './signer.js'
|
||||
export type { Signer } from './signer.js'
|
||||
|
||||
// NIP-46 bunker signer + pairing seed (aiolabs/bitspire#52)
|
||||
export {
|
||||
BunkerSigner,
|
||||
BunkerRejectedError,
|
||||
BunkerTimeoutError,
|
||||
generateClientTransportKey,
|
||||
connectNewSeed,
|
||||
resumeFromBinding,
|
||||
} from './bunker-signer.js'
|
||||
export type { BunkerBinding, BunkerSignerOptions, ClientTransportKey } from './bunker-signer.js'
|
||||
export { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from './seed.js'
|
||||
export type { SpireSeed } from './seed.js'
|
||||
|
||||
// Event creation
|
||||
export { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from './events.js'
|
||||
export {
|
||||
createSignedEvent,
|
||||
createMachineStatusEvent,
|
||||
createTransactionEvent,
|
||||
createAuthEvent,
|
||||
validateEvent,
|
||||
generateTxId,
|
||||
} from './events.js'
|
||||
|
||||
// Encryption — NIP-44 v2 (used by the dormant CLINK client + tests)
|
||||
export { encryptContentV2, decryptContentV2 } from './encryption.js'
|
||||
// Encryption
|
||||
export {
|
||||
encryptContent,
|
||||
decryptContent,
|
||||
decryptJSON,
|
||||
// NIP-44 v2 (standard, for CLINK protocol)
|
||||
encryptContentV2,
|
||||
decryptContentV2,
|
||||
} from './encryption.js'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -1,166 +0,0 @@
|
|||
/**
|
||||
* Spire pairing seed-URL parser.
|
||||
*
|
||||
* The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a
|
||||
* one-time seed URL that encodes the bunker connection + the spire's signing
|
||||
* identity. Wire contract (model A1, minimal encoding):
|
||||
*
|
||||
* spire-seed:v1:<base64url(json, no padding)>
|
||||
* json = {
|
||||
* "v": 1,
|
||||
* "spire_npub": "npub1…", // spire signing identity (bech32; hex derived)
|
||||
* "lnbits_npub": "npub1…", // LNbits nostr-transport server identity
|
||||
* "bunker_secret": "<sec>", // one-shot NIP-46 connect token
|
||||
* "relays": ["wss://…"], // relays the spire's OWN events use (21000/30078)
|
||||
* "bunker_relay": "wss://…" // OPTIONAL — NIP-46 relay; defaults to relays[0]
|
||||
* }
|
||||
*
|
||||
* Design (see aiolabs/bitspire#70): the pubkey is carried ONCE, as an npub.
|
||||
* The old shape spelled it three times (spire_npub + spire_pubkey hex + inside
|
||||
* a full bunker_url), which bloats a QR that's already hard to scan. Here:
|
||||
*
|
||||
* - `spire_pubkey` (hex) is derived from `spire_npub` (npub is ~the same length
|
||||
* as hex but carries a bech32 checksum — real error-detection for a value
|
||||
* read off a camera).
|
||||
* - `bunker_url` is RECONSTRUCTED from `spire_pubkey`, `bunker_relay` (or
|
||||
* `relays[0]`), and `bunker_secret`, then handed verbatim to nostr-tools
|
||||
* `parseBunkerInput` (see bunker-signer.ts).
|
||||
* - `lnbits_npub` gives the ATM its LNbits transport server pubkey so a paired
|
||||
* machine needs nothing else provisioned to reach the backend (#70 part 2).
|
||||
*
|
||||
* base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple of 4
|
||||
* before decoding.
|
||||
*/
|
||||
|
||||
import { sha256 } from '@noble/hashes/sha2.js'
|
||||
import { bytesToHex } from 'nostr-tools/utils'
|
||||
import { decode as nip19Decode } from 'nostr-tools/nip19'
|
||||
|
||||
export const SPIRE_SEED_SCHEME = 'spire-seed:v1:'
|
||||
|
||||
export interface SpireSeed {
|
||||
/** Seed format version (always 1 for this scheme). */
|
||||
v: number
|
||||
/** The spire's signing identity — 64-char hex, derived from `spire_npub`. */
|
||||
spirePubkey: string
|
||||
/** `bunker://<pubkey>?relay=&secret=` — reconstructed, handed to parseBunkerInput. */
|
||||
bunkerUrl: string
|
||||
/** Relays where the spire publishes its own events (kind 21000 / 30078). */
|
||||
relays: string[]
|
||||
/** LNbits nostr-transport server pubkey — 64-char hex, derived from `lnbits_npub`. */
|
||||
lnbitsServerPubkey: string
|
||||
}
|
||||
|
||||
const HEX64 = /^[0-9a-f]{64}$/
|
||||
|
||||
/**
|
||||
* A relay must be a `ws://` or `wss://` URL. Unlike the npubs (bech32-checksummed,
|
||||
* so a mis-scanned character is caught), the relay strings are raw inside the
|
||||
* seed's base64 — a QR misread can silently corrupt `ws://` into e.g. `As://`
|
||||
* and the pairing then crash-loops on an unreachable relay. Reject at parse time
|
||||
* so the wizard refuses a garbled scan instead of persisting it (bitspire#70).
|
||||
*/
|
||||
const WS_URL = /^wss?:\/\/[^\s]+$/
|
||||
function assertRelayUrl(value: string, field: string): void {
|
||||
if (!WS_URL.test(value)) {
|
||||
throw new Error(`parseSpireSeed: ${field} must be a ws:// or wss:// URL (got "${value}")`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode an unpadded base64url string in both browser and Node. */
|
||||
function base64urlDecode(input: string): string {
|
||||
const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=')
|
||||
if (typeof atob !== 'undefined') {
|
||||
return atob(padded)
|
||||
}
|
||||
return Buffer.from(padded, 'base64').toString('binary')
|
||||
}
|
||||
|
||||
/** Decode an `npub1…` to its 64-char hex pubkey, failing closed. */
|
||||
function hexFromNpub(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`parseSpireSeed: ${field} must be a string`)
|
||||
}
|
||||
let decoded: ReturnType<typeof nip19Decode>
|
||||
try {
|
||||
decoded = nip19Decode(value)
|
||||
} catch (err) {
|
||||
throw new Error(`parseSpireSeed: ${field} is not a valid npub (${(err as Error).message})`)
|
||||
}
|
||||
if (decoded.type !== 'npub' || typeof decoded.data !== 'string' || !HEX64.test(decoded.data)) {
|
||||
throw new Error(`parseSpireSeed: ${field} must be an npub`)
|
||||
}
|
||||
return decoded.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse + validate a `spire-seed:v1:` URL. Throws on any malformation —
|
||||
* the seed is a trust root, so we fail closed rather than connect to a
|
||||
* half-understood bunker.
|
||||
*/
|
||||
export function parseSpireSeed(seedUrl: string): SpireSeed {
|
||||
if (typeof seedUrl !== 'string' || !seedUrl.startsWith(SPIRE_SEED_SCHEME)) {
|
||||
throw new Error(`parseSpireSeed: not a ${SPIRE_SEED_SCHEME} URL`)
|
||||
}
|
||||
|
||||
const payload = seedUrl.slice(SPIRE_SEED_SCHEME.length)
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(base64urlDecode(payload))
|
||||
} catch (err) {
|
||||
throw new Error(`parseSpireSeed: undecodable payload (${(err as Error).message})`)
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('parseSpireSeed: payload is not an object')
|
||||
}
|
||||
const obj = raw as Record<string, unknown>
|
||||
|
||||
if (obj.v !== 1) {
|
||||
throw new Error(`parseSpireSeed: unsupported version ${String(obj.v)}`)
|
||||
}
|
||||
|
||||
const spirePubkey = hexFromNpub(obj.spire_npub, 'spire_npub')
|
||||
const lnbitsServerPubkey = hexFromNpub(obj.lnbits_npub, 'lnbits_npub')
|
||||
|
||||
const bunkerSecret = obj.bunker_secret
|
||||
if (typeof bunkerSecret !== 'string' || bunkerSecret.length === 0) {
|
||||
throw new Error('parseSpireSeed: bunker_secret must be a non-empty string')
|
||||
}
|
||||
|
||||
const relays = obj.relays
|
||||
if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) {
|
||||
throw new Error('parseSpireSeed: relays must be a non-empty string array')
|
||||
}
|
||||
relays.forEach((r, i) => assertRelayUrl(r as string, `relays[${i}]`))
|
||||
|
||||
// Optional bunker relay; default to the first event relay. Keeps the common
|
||||
// case (bunker on the same relay) one field lighter, while still allowing a
|
||||
// distinct NIP-46 relay when the operator runs one.
|
||||
let bunkerRelay = relays[0] as string
|
||||
if (obj.bunker_relay !== undefined) {
|
||||
if (typeof obj.bunker_relay !== 'string' || obj.bunker_relay.length === 0) {
|
||||
throw new Error('parseSpireSeed: bunker_relay, if present, must be a non-empty string')
|
||||
}
|
||||
assertRelayUrl(obj.bunker_relay, 'bunker_relay')
|
||||
bunkerRelay = obj.bunker_relay
|
||||
}
|
||||
|
||||
// Reconstruct the bunker URL nostr-tools expects. relay + secret are
|
||||
// percent-encoded here; parseBunkerInput decodes them downstream.
|
||||
const bunkerUrl =
|
||||
`bunker://${spirePubkey}` +
|
||||
`?relay=${encodeURIComponent(bunkerRelay)}` +
|
||||
`&secret=${encodeURIComponent(bunkerSecret)}`
|
||||
|
||||
return { v: 1, spirePubkey, bunkerUrl, relays: relays as string[], lnbitsServerPubkey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable fingerprint of a seed URL, used to detect a re-pair (operator/relay
|
||||
* change). A different seed ⇒ a different fingerprint ⇒ the ATM re-binds and
|
||||
* resets its bootstrap gate (aiolabs/bitspire#56).
|
||||
*/
|
||||
export function seedFingerprint(seedUrl: string): string {
|
||||
return bytesToHex(sha256(new TextEncoder().encode(seedUrl)))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue