Compare commits

..

No commits in common. "fix/sintra-timezone" and "main" have entirely different histories.

111 changed files with 1743 additions and 10314 deletions

View file

@ -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.

View file

@ -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

View file

@ -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()
})
})

View file

@ -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)
})
})

View file

@ -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' })
})
})

View file

@ -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,
},
}
}

View file

@ -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]

View file

@ -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)

View file

@ -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.' })
})
})

View file

@ -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)
}

View file

@ -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',
})
})
})

View file

@ -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' }
}

View file

@ -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) {

View file

@ -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()
})
})

View file

@ -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
}

View file

@ -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

View file

@ -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 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.
*/
relays?: string[]
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
lnbitsServerPubkey?: string
export type OperatorCassettesPayload = {
positions: Record<string, { denomination: number; count: number }>
}
/** Read the persisted bunker binding, or null if the ATM is unpaired. */
export function getBunkerBinding(): StoredBunkerBinding | null {
export type ApplyResult =
| { applied: true }
| { applied: false; reason: string }
/**
* 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).
*/
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
const watermark = getLastKnownConfigCreatedAt()
if (eventCreatedAt <= watermark) {
return {
applied: false,
reason: `event.created_at (${eventCreatedAt}) <= lastKnownConfigCreatedAt (${watermark})`,
}
}
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}`,
}
}
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}` }
}
}
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})`,
}
| undefined
if (!row) return null
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,
}
}
/** 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[]
}
} catch {
// fall through
}
return undefined
}
/** 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
)
}
/** 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 (!Number.isInteger(entry.count) || entry.count < 0) {
return {
applied: false,
reason: `count must be non-negative int (position ${posKey}, got ${entry.count})`,
}
}
}
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})`
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))
}
}
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', '')
})()
setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt')
})
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,14 +694,9 @@ 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).
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
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,48 +838,20 @@ 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') {

View file

@ -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>

View file

@ -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",

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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.
const event = createSignedEvent(identity, {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags: [['d', 'atm-availability']],
content,
})
try {
const event = await createSignedEvent(signer, {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags: [['d', 'atm-availability']],
content,
})
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)
}
}

View file

@ -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)
}

View file

@ -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 },

View file

@ -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()

View file

@ -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')
})
})

View file

@ -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)
})
})

View file

@ -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()
})
})

View file

@ -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 }
}

View file

@ -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
}
}

View file

@ -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'

View file

@ -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 }

View file

@ -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) => {

View file

@ -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
}

View file

@ -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(() => {})
}
}
},

View file

@ -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 })
}

View file

@ -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) {

View file

@ -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')
})
})

View file

@ -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])
}

View file

@ -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 }
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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)' })
})
}

View file

@ -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>
}

View file

@ -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()
}

View file

@ -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,24 +379,12 @@ 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
persistedInventory.value = inv
console.log('[ATM] Persisted inventory updated:', inv)
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) */
@ -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,

View file

@ -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;
}

View file

@ -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

View file

@ -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">
<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>
<!-- 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">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>

View file

@ -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">
<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>
<!-- 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">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>

View file

@ -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,38 +193,37 @@ 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()"
>
✕
</Button>
<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"
@click="$router.push('/support')"
>
?
</Button>
<!-- 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="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>
<!-- Debug toggle (only visible when debug bar is hidden, never in production) -->
<Button
v-if="!atmStore.debugMode && atmStore.allowMockFallback"

View file

@ -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>

View file

@ -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">

View file

@ -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

View file

@ -1,5 +0,0 @@
{
"enabled": true,
"openEnrollment": true,
"devUnlock": false
}

View file

@ -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}

View file

@ -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 ""

View file

@ -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.

View file

@ -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'"

View file

@ -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";
};
};

View file

@ -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"
'';
}

View file

@ -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"
'';
}

View file

@ -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,29 +167,19 @@ 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 = ''
mkdir -p /var/lib/bitspire
if [ ! -f /var/lib/bitspire/.env ]; then
cp ${envTemplate} /var/lib/bitspire/.env
chmod 600 /var/lib/bitspire/.env
chown bitspire:bitspire /var/lib/bitspire/.env
fi
'';
};
# 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
chmod 600 /var/lib/bitspire/.env
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;

View file

@ -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. ==="

View file

@ -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
# 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 "--- 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"
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: 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
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.
if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then
echo ""
echo "--- No seed: 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."
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"
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"
fi
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 "--- 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'"

View file

@ -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
# ============================================

View file

@ -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).

View file

@ -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`.

View file

@ -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.

View file

@ -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.

View file

@ -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
View file

@ -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;
};

View file

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

View file

@ -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 .",

View file

@ -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)
}
}

View file

@ -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",

View file

@ -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)
*

View file

@ -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) {

View file

@ -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)

View file

@ -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)
})
})

View file

@ -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 })
})
})

View file

@ -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', {
body: { payment_hash: paymentHash },
}),
)
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', {
body: { payment_request: paymentRequest },
}),
)
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', {
walletId,
body,
}),
)
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', {
walletId,
body: { id },
}),
)
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,15 +457,18 @@ 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({
kind: LNBITS_KIND_RPC,
content: encrypted,
tags: [
['p', this.config.serverPubkey],
['expiration', String(now + 300)],
],
created_at: now,
})
const event = finalizeEvent(
{
kind: LNBITS_KIND_RPC,
content: encrypted,
tags: [
['p', this.config.serverPubkey],
['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
}

View file

@ -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'
}
}

View file

@ -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,

View file

@ -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
}

View file

@ -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
}

View file

@ -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",

View file

@ -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/)
})
})

View file

@ -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)
})
})
})

View file

@ -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)
})
})

View file

@ -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}$/)
})
})

View file

@ -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')
})
})

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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,

View file

@ -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: [

View file

@ -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 {

View file

@ -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