Compare commits
No commits in common. "feat/rpi4-target" and "main" have entirely different histories.
feat/rpi4-
...
main
91 changed files with 1462 additions and 7821 deletions
35
CLAUDE.md
35
CLAUDE.md
|
|
@ -15,11 +15,9 @@ Core principles:
|
||||||
|
|
||||||
## Provenance + legal status
|
## 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.
|
**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.
|
||||||
|
|
||||||
The `lamassu-server` boundary has not been re-verified against its own history and may differ — check its license-change commit before referencing it.
|
|
||||||
|
|
||||||
bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lamassu Industries AG.
|
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 |
|
| 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_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` | 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_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints 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` | yes (prod) | 64-char hex. The ATM's nostr identity. Generates ephemeral on first boot if unset (dev only) |
|
||||||
| `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_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |
|
| `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.
|
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
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -211,7 +188,7 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l
|
||||||
|
|
||||||
## Security priorities
|
## 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.
|
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.
|
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.
|
4. **Encryption** — All RPC content is NIP-44 v2. NIP-04 is forbidden.
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,11 @@ VITE_LAMASSU_FIAT_CODE=USD
|
||||||
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]'
|
# 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:
|
# Nostr relay WebSocket URL — relay LNbits is subscribed to.
|
||||||
# VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test
|
VITE_RELAY_URL=ws://localhost:7777
|
||||||
VITE_RELAY_URL=
|
|
||||||
|
|
||||||
# LNbits nostr-transport server pubkey (hex, 64 chars).
|
# LNbits nostr-transport server pubkey (hex, 64 chars).
|
||||||
# Printed by the LNbits server on startup:
|
# Printed by the LNbits server on startup:
|
||||||
|
|
@ -40,23 +36,16 @@ VITE_LNBITS_SERVER_PUBKEY=
|
||||||
# aiolabs/withdraw#1 / commit e9d911e.)
|
# 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
|
# pragma: allowlist secret
|
||||||
# DEV ONLY fallback — a raw Nostr private key (hex, 64 chars) for running
|
# ATM's Nostr private key (hex format, 64 characters). This signing
|
||||||
# without a bunker. Ignored when VITE_SPIRE_SEED or a stored binding exists.
|
# key IS the credential — LNbits derives the account from it on first
|
||||||
|
# contact (issue aiolabs/lnbits#9 alignment).
|
||||||
# Generate with: openssl rand -hex 32
|
# 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
|
# Operator Identity
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
/**
|
|
||||||
* Tests for bunker-binding persistence in state-store (aiolabs/bitspire#52,
|
|
||||||
* transport config added in #70).
|
|
||||||
*
|
|
||||||
* Validates the round-trip of the binding singleton, including the v11→v12
|
|
||||||
* transport columns (relays JSON + lnbits_server_pubkey) and their absence on
|
|
||||||
* a pre-#70 binding.
|
|
||||||
*
|
|
||||||
* Uses an in-memory SQLite database — fresh per test, no on-disk artifacts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
||||||
import {
|
|
||||||
clearBunkerBinding,
|
|
||||||
closeDatabase,
|
|
||||||
getBunkerBinding,
|
|
||||||
initDatabase,
|
|
||||||
saveBunkerBinding,
|
|
||||||
type StoredBunkerBinding,
|
|
||||||
} from '../state-store.js'
|
|
||||||
|
|
||||||
const BASE: StoredBunkerBinding = {
|
|
||||||
clientSecretHex: 'aa'.repeat(32),
|
|
||||||
spirePubkey: 'bb'.repeat(32),
|
|
||||||
bunkerUrl: 'bunker://bb?relay=wss%3A%2F%2Fr%2F&secret=deadbeef',
|
|
||||||
seedFingerprint: 'cc'.repeat(32),
|
|
||||||
pairedAt: 1_780_000_000,
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
initDatabase(':memory:')
|
|
||||||
})
|
|
||||||
afterEach(() => {
|
|
||||||
closeDatabase()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('bunker binding persistence', () => {
|
|
||||||
it('round-trips a binding carrying transport config (#70)', () => {
|
|
||||||
const binding: StoredBunkerBinding = {
|
|
||||||
...BASE,
|
|
||||||
relays: ['wss://one.relay/', 'wss://two.relay/'],
|
|
||||||
lnbitsServerPubkey: 'dd'.repeat(32),
|
|
||||||
}
|
|
||||||
saveBunkerBinding(binding)
|
|
||||||
expect(getBunkerBinding()).toEqual(binding)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('round-trips a pre-#70 binding (no transport config) as undefined fields', () => {
|
|
||||||
saveBunkerBinding(BASE)
|
|
||||||
const got = getBunkerBinding()
|
|
||||||
expect(got).toEqual(BASE)
|
|
||||||
expect(got?.relays).toBeUndefined()
|
|
||||||
expect(got?.lnbitsServerPubkey).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('upserts transport config in place (re-pair overwrites)', () => {
|
|
||||||
saveBunkerBinding({ ...BASE, relays: ['wss://old/'], lnbitsServerPubkey: 'ee'.repeat(32) })
|
|
||||||
saveBunkerBinding({ ...BASE, relays: ['wss://new/'], lnbitsServerPubkey: 'ff'.repeat(32) })
|
|
||||||
const got = getBunkerBinding()
|
|
||||||
expect(got?.relays).toEqual(['wss://new/'])
|
|
||||||
expect(got?.lnbitsServerPubkey).toBe('ff'.repeat(32))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null after clear', () => {
|
|
||||||
saveBunkerBinding(BASE)
|
|
||||||
clearBunkerBinding()
|
|
||||||
expect(getBunkerBinding()).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,170 +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 {
|
|
||||||
closeDatabase,
|
|
||||||
getCashbox,
|
|
||||||
initDatabase,
|
|
||||||
loadCassettes,
|
|
||||||
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 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -13,15 +13,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import {
|
import { NostrClient, loadIdentityFromHex } from '@bitSpire/nostr-client'
|
||||||
NostrClient,
|
|
||||||
LocalSigner,
|
|
||||||
loadIdentityFromHex,
|
|
||||||
resumeFromBinding,
|
|
||||||
type Signer,
|
|
||||||
} from '@bitSpire/nostr-client'
|
|
||||||
import { LnbitsClient } from '@bitSpire/lnbits'
|
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
|
// @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
|
|
@ -63,38 +56,19 @@ async function main() {
|
||||||
const lnbitsServerPubkey = env['VITE_LNBITS_SERVER_PUBKEY']
|
const lnbitsServerPubkey = env['VITE_LNBITS_SERVER_PUBKEY']
|
||||||
const atmPrivateKey = env['VITE_ATM_PRIVATE_KEY']
|
const atmPrivateKey = env['VITE_ATM_PRIVATE_KEY']
|
||||||
|
|
||||||
if (!relayUrl || !lnbitsServerPubkey) {
|
if (!relayUrl || !lnbitsServerPubkey || !atmPrivateKey) {
|
||||||
console.error('Missing required config in', envPath)
|
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)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(`Generating invoice for ${amountSats} sats...`)
|
console.error(`Generating invoice for ${amountSats} sats...`)
|
||||||
|
|
||||||
// Resolve the signer. Prod: resume the bunker binding from state.db (the
|
const identity = loadIdentityFromHex(atmPrivateKey)
|
||||||
// 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 nostrClient = new NostrClient({
|
const nostrClient = new NostrClient({
|
||||||
relays: [{ url: relayUrl }],
|
relays: [{ url: relayUrl }],
|
||||||
signer,
|
identity,
|
||||||
})
|
})
|
||||||
await nostrClient.connect()
|
await nostrClient.connect()
|
||||||
|
|
||||||
|
|
@ -102,7 +76,7 @@ async function main() {
|
||||||
serverPubkey: lnbitsServerPubkey,
|
serverPubkey: lnbitsServerPubkey,
|
||||||
relays: [relayUrl],
|
relays: [relayUrl],
|
||||||
})
|
})
|
||||||
lnbits.initialize(nostrClient, signer)
|
lnbits.initialize(nostrClient, identity)
|
||||||
|
|
||||||
const wallets = await lnbits.listWallets()
|
const wallets = await lnbits.listWallets()
|
||||||
const wallet = wallets[0]
|
const wallet = wallets[0]
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,6 @@ export interface HalConfig {
|
||||||
export interface ValidatorCallbacks {
|
export interface ValidatorCallbacks {
|
||||||
shouldAcceptBill: (denomination: number) => boolean | 'hold'
|
shouldAcceptBill: (denomination: number) => boolean | 'hold'
|
||||||
onBillRead?: (denomination: number) => void
|
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
|
onBillInserted: (denomination: number) => void
|
||||||
onBillRejected: (reason: string) => void
|
onBillRejected: (reason: string) => void
|
||||||
onError: (error: string) => void
|
onError: (error: string) => void
|
||||||
|
|
@ -87,40 +82,19 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
|
|
||||||
const { validator: valConfig, dispenser: dispConfig } = config
|
const { validator: valConfig, dispenser: dispConfig } = config
|
||||||
|
|
||||||
// Start dispenser (optional — mirrors the validator handling below).
|
// Create hardware instances
|
||||||
//
|
const dispenser: BillDispenser = hal.createDispenser(dispConfig.type, {
|
||||||
// A cash-in-only machine is a legitimate configuration: the Raspberry Pi
|
device: dispConfig.device,
|
||||||
// reference build has a bill acceptor and no dispenser at all. This used to
|
})
|
||||||
// create and init the dispenser unconditionally, so a missing device threw
|
|
||||||
// and aborted the WHOLE of initializeHal — taking the validator with it,
|
|
||||||
// even though the validator was present and working. The Pi bring-up hit
|
|
||||||
// exactly that: "cannot open /dev/ttyDispenser-not-fitted", then an endless
|
|
||||||
// renderer-reload loop, with a perfectly good acceptor on ttyValidator0.
|
|
||||||
//
|
|
||||||
// The validator has been optional since it was written; the asymmetry was
|
|
||||||
// the bug.
|
|
||||||
let dispenser: BillDispenser | null = null
|
|
||||||
|
|
||||||
// `dispenserInitData` is `let` because `setCassettes` swaps it in to re-init
|
// Initialize dispenser. `dispenserInitData` is `let` because
|
||||||
// with a new layout (also used by the on-error re-init path at dispenseCash).
|
// `setCassettes` swaps it in to re-init with a new layout (also used by
|
||||||
|
// the on-error re-init path at dispenseCash).
|
||||||
let dispenserInitData = {
|
let dispenserInitData = {
|
||||||
fiatCode: valConfig.fiatCode,
|
fiatCode: valConfig.fiatCode,
|
||||||
cassettes: dispConfig.cassettes,
|
cassettes: dispConfig.cassettes,
|
||||||
}
|
}
|
||||||
|
await dispenser.init(dispenserInitData)
|
||||||
try {
|
|
||||||
const fs = await import('node:fs')
|
|
||||||
if (dispConfig.device && fs.existsSync(dispConfig.device)) {
|
|
||||||
dispenser = hal.createDispenser(dispConfig.type, { device: dispConfig.device })
|
|
||||||
await dispenser.init(dispenserInitData)
|
|
||||||
console.log('[HAL] Dispenser started')
|
|
||||||
} else {
|
|
||||||
console.log('[HAL] Dispenser device not found, running cash-in only')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[HAL] Dispenser failed to start, running cash-in only:', err)
|
|
||||||
dispenser = null
|
|
||||||
}
|
|
||||||
console.log('[HAL] Dispenser initialized')
|
console.log('[HAL] Dispenser initialized')
|
||||||
|
|
||||||
// Start validator (optional — proceed without if device is missing or fails)
|
// Start validator (optional — proceed without if device is missing or fails)
|
||||||
|
|
@ -168,14 +142,6 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
count: c.count ?? 0,
|
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 {
|
return {
|
||||||
connectValidator: (callbacks: ValidatorCallbacks) => {
|
connectValidator: (callbacks: ValidatorCallbacks) => {
|
||||||
if (!validator) {
|
if (!validator) {
|
||||||
|
|
@ -187,11 +153,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
const decision = callbacks.shouldAcceptBill(data.denomination)
|
const decision = callbacks.shouldAcceptBill(data.denomination)
|
||||||
if (decision === 'hold') {
|
if (decision === 'hold') {
|
||||||
console.log('[HAL] Bill in escrow:', data.denomination)
|
console.log('[HAL] Bill in escrow:', data.denomination)
|
||||||
escrowDenomination = data.denomination
|
|
||||||
callbacks.onBillRead?.(data.denomination)
|
callbacks.onBillRead?.(data.denomination)
|
||||||
} else if (decision) {
|
} else if (decision) {
|
||||||
inFlightDenomination = data.denomination
|
|
||||||
validator.stack()
|
validator.stack()
|
||||||
|
callbacks.onBillInserted(data.denomination)
|
||||||
} else {
|
} else {
|
||||||
console.log('[HAL] Bill rejected: insufficient balance for', data.denomination)
|
console.log('[HAL] Bill rejected: insufficient balance for', data.denomination)
|
||||||
validator.reject()
|
validator.reject()
|
||||||
|
|
@ -203,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 }) => {
|
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')
|
callbacks.onBillRejected(data?.reason ?? 'unknown')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -242,47 +191,16 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
},
|
},
|
||||||
|
|
||||||
disableValidator: () => {
|
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?.disable()
|
||||||
validator?.lightOff()
|
validator?.lightOff()
|
||||||
},
|
},
|
||||||
|
|
||||||
stackBill: () => {
|
stackBill: () => validator?.stack(),
|
||||||
if (escrowDenomination === null) {
|
rejectBill: () => validator?.reject(),
|
||||||
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inFlightDenomination = escrowDenomination
|
|
||||||
escrowDenomination = null
|
|
||||||
validator?.stack()
|
|
||||||
},
|
|
||||||
rejectBill: () => {
|
|
||||||
escrowDenomination = null
|
|
||||||
validator?.reject()
|
|
||||||
},
|
|
||||||
|
|
||||||
dispenseCash: async (amounts): Promise<DispenseResult> => {
|
dispenseCash: async (amounts): Promise<DispenseResult> => {
|
||||||
console.log('[HAL] Dispensing:', amounts)
|
console.log('[HAL] Dispensing:', amounts)
|
||||||
|
|
||||||
// Cash-in-only machine: refuse the ask rather than throwing a null
|
|
||||||
// dereference into the renderer's dispense path.
|
|
||||||
if (!dispenser) {
|
|
||||||
return {
|
|
||||||
bills: [],
|
|
||||||
cassettes: [],
|
|
||||||
dispensed: false,
|
|
||||||
error: 'No dispenser fitted on this machine — cash-out unavailable',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-initialize dispenser if it was closed after a previous error
|
// Re-initialize dispenser if it was closed after a previous error
|
||||||
if (!dispenser.initialized) {
|
if (!dispenser.initialized) {
|
||||||
console.log('[HAL] Dispenser not initialized, re-initializing...')
|
console.log('[HAL] Dispenser not initialized, re-initializing...')
|
||||||
|
|
@ -423,12 +341,6 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
count: c.count ?? 0,
|
count: c.count ?? 0,
|
||||||
}))
|
}))
|
||||||
dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes }
|
dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes }
|
||||||
// Without a dispenser the layout is still worth recording (the operator
|
|
||||||
// config consumer keeps calling this), but there is nothing to re-init.
|
|
||||||
if (!dispenser) {
|
|
||||||
console.log('[HAL] Cassettes recorded; no dispenser fitted, nothing to re-init')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Close + re-init the dispenser so its internal per-bay state matches
|
// Close + re-init the dispenser so its internal per-bay state matches
|
||||||
// the new layout. Errors here surface to the caller (operator-config
|
// the new layout. Errors here surface to the caller (operator-config
|
||||||
// consumer) — the renderer can decide whether to retry.
|
// consumer) — the renderer can decide whether to retry.
|
||||||
|
|
@ -445,7 +357,7 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
validator?.disable()
|
validator?.disable()
|
||||||
validator?.lightOff()
|
validator?.lightOff()
|
||||||
dispenser?.close()
|
dispenser.close()
|
||||||
if (validator) {
|
if (validator) {
|
||||||
validator.close((err?: Error) => {
|
validator.close((err?: Error) => {
|
||||||
if (err) console.error('[HAL] Validator close error:', err)
|
if (err) console.error('[HAL] Validator close error:', err)
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import { resolveCardInvoice, 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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,226 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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: PayRequest,
|
|
||||||
amountMsat: number,
|
|
||||||
ctx: Ctx
|
|
||||||
): Promise<ResolveCardInvoiceResult> {
|
|
||||||
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, amountMsat, ctx)
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import { executeLnurlWithdraw, 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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,127 +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
|
|
||||||
|
|
||||||
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' }
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
opts.amountMsat != null &&
|
|
||||||
typeof params.maxWithdrawable === 'number' &&
|
|
||||||
opts.amountMsat > params.maxWithdrawable
|
|
||||||
) {
|
|
||||||
return { ok: false, reason: 'card limit is below this amount' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Hand our invoice to the callback — the card's wallet pays it.
|
|
||||||
const cbUrl = appendQuery(params.callback, { k1: params.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' }
|
|
||||||
}
|
|
||||||
|
|
@ -26,25 +26,16 @@ import {
|
||||||
getLastKnownConfigCreatedAt,
|
getLastKnownConfigCreatedAt,
|
||||||
getBootstrapPublishedAt,
|
getBootstrapPublishedAt,
|
||||||
markBootstrapPublished,
|
markBootstrapPublished,
|
||||||
resetBootstrapGate,
|
|
||||||
resetForRepair,
|
|
||||||
applyOperatorCassettesConfig,
|
applyOperatorCassettesConfig,
|
||||||
getFeeConfig,
|
getFeeConfig,
|
||||||
getLastKnownFeeConfigCreatedAt,
|
getLastKnownFeeConfigCreatedAt,
|
||||||
applyFeeConfig,
|
applyFeeConfig,
|
||||||
getBunkerBinding,
|
|
||||||
saveBunkerBinding,
|
|
||||||
clearBunkerBinding,
|
|
||||||
type OperatorCassettesPayload,
|
type OperatorCassettesPayload,
|
||||||
type FeeConfigPayload,
|
type FeeConfigPayload,
|
||||||
type FeeConfigRow,
|
type FeeConfigRow,
|
||||||
type ApplyResult,
|
type ApplyResult,
|
||||||
type StoredBunkerBinding,
|
|
||||||
} from './state-store.js'
|
} from './state-store.js'
|
||||||
import { initializeHal, type HalInstance } from './hal-service.js'
|
import { initializeHal, type HalInstance } from './hal-service.js'
|
||||||
import { executeLnurlWithdraw } from './lnurl-withdraw.js'
|
|
||||||
import { resolveCardInvoice } from './lnurl-pay.js'
|
|
||||||
import { startNfcReader, type NfcStatus } from './nfc-service.js'
|
|
||||||
|
|
||||||
// ESM equivalent of __dirname
|
// ESM equivalent of __dirname
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
|
@ -90,6 +81,16 @@ type BrandingConfig = {
|
||||||
logoDarkDataUrl: string | null
|
logoDarkDataUrl: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_THEMES = new Set([
|
||||||
|
'gruvbox',
|
||||||
|
'catppuccin',
|
||||||
|
'cyberpunk',
|
||||||
|
'dracula',
|
||||||
|
'nord',
|
||||||
|
'tokyo-night',
|
||||||
|
'custom',
|
||||||
|
])
|
||||||
|
|
||||||
function loadBranding(): BrandingConfig | null {
|
function loadBranding(): BrandingConfig | null {
|
||||||
const brandingDir = path.join(
|
const brandingDir = path.join(
|
||||||
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
|
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
|
||||||
|
|
@ -109,10 +110,7 @@ function loadBranding(): BrandingConfig | null {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
||||||
if (typeof raw.title === 'string') title = raw.title
|
if (typeof raw.title === 'string') title = raw.title
|
||||||
// No theme-name validation here: the renderer's `themes` list (plus its
|
if (typeof raw.theme === 'string' && VALID_THEMES.has(raw.theme)) theme = raw.theme
|
||||||
// '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 (raw.custom_colors && typeof raw.custom_colors === 'object') {
|
if (raw.custom_colors && typeof raw.custom_colors === 'object') {
|
||||||
const { dark, ...flat } = raw.custom_colors as Record<string, unknown>
|
const { dark, ...flat } = raw.custom_colors as Record<string, unknown>
|
||||||
const colors = Object.fromEntries(
|
const colors = Object.fromEntries(
|
||||||
|
|
@ -121,7 +119,9 @@ function loadBranding(): BrandingConfig | null {
|
||||||
if (Object.keys(colors).length > 0) customColors = colors
|
if (Object.keys(colors).length > 0) customColors = colors
|
||||||
if (dark && typeof dark === 'object') {
|
if (dark && typeof dark === 'object') {
|
||||||
const darkColors = Object.fromEntries(
|
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>
|
) as Record<string, string>
|
||||||
if (Object.keys(darkColors).length > 0) customColorsDark = darkColors
|
if (Object.keys(darkColors).length > 0) customColorsDark = darkColors
|
||||||
}
|
}
|
||||||
|
|
@ -280,11 +280,8 @@ ipcMain.handle('watchdog:pong', () => {
|
||||||
// pragma: allowlist secret end
|
// pragma: allowlist secret end
|
||||||
ipcMain.handle('get-config', () => {
|
ipcMain.handle('get-config', () => {
|
||||||
return {
|
return {
|
||||||
// LNbits nostr-transport connection (public info only). Empty when
|
// LNbits nostr-transport connection (public info only)
|
||||||
// unprovisioned — the renderer then falls through to the pairing seed's
|
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777',
|
||||||
// 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 || '',
|
|
||||||
lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
|
lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
|
||||||
appId: process.env.VITE_APP_ID || '',
|
appId: process.env.VITE_APP_ID || '',
|
||||||
|
|
||||||
|
|
@ -333,117 +330,14 @@ let secretsConsumed = false
|
||||||
ipcMain.handle('get-atm-secrets', () => {
|
ipcMain.handle('get-atm-secrets', () => {
|
||||||
if (secretsConsumed) {
|
if (secretsConsumed) {
|
||||||
console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed')
|
console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed')
|
||||||
return { spireSeed: '', bunkerBinding: null }
|
return { atmPrivateKey: '' }
|
||||||
}
|
}
|
||||||
secretsConsumed = true
|
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 {
|
return {
|
||||||
spireSeed: process.env.VITE_SPIRE_SEED || '',
|
atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '',
|
||||||
bunkerBinding: getBunkerBinding(),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bunker binding persistence — the renderer writes the binding after a
|
|
||||||
// successful pairing (connectNewSeed), and resets the bootstrap gate 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-bootstrap-gate', (): void => {
|
|
||||||
resetBootstrapGate()
|
|
||||||
})
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// State persistence IPC handlers
|
// State persistence IPC handlers
|
||||||
ipcMain.handle('state:load-cassettes', () => loadCassettes())
|
ipcMain.handle('state:load-cassettes', () => loadCassettes())
|
||||||
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
|
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
|
||||||
|
|
@ -460,7 +354,9 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
|
||||||
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
|
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
|
||||||
getLastKnownConfigCreatedAt()
|
getLastKnownConfigCreatedAt()
|
||||||
)
|
)
|
||||||
ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt())
|
ipcMain.handle('state:get-bootstrap-published-at', (): number | null =>
|
||||||
|
getBootstrapPublishedAt()
|
||||||
|
)
|
||||||
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
|
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
|
||||||
markBootstrapPublished(unixTimestamp)
|
markBootstrapPublished(unixTimestamp)
|
||||||
})
|
})
|
||||||
|
|
@ -518,17 +414,6 @@ let pendingBillDenomination: number | null = null
|
||||||
|
|
||||||
ipcMain.handle('hal:init', async (_event, config) => {
|
ipcMain.handle('hal:init', async (_event, config) => {
|
||||||
try {
|
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
|
// 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
|
// 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
|
// HAL knows about every bay including duplicates of the same denomination — real
|
||||||
|
|
@ -634,12 +519,10 @@ ipcMain.handle('hal:stack-bill', () => {
|
||||||
console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring')
|
console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const denomination = pendingBillDenomination
|
||||||
pendingBillDenomination = null
|
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()
|
halInstance.stackBill()
|
||||||
|
mainWindow?.webContents.send('hal:bill-inserted', denomination)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('hal:reject-bill', () => {
|
ipcMain.handle('hal:reject-bill', () => {
|
||||||
|
|
@ -840,23 +723,6 @@ app.whenReady().then(() => {
|
||||||
startWatchdog()
|
startWatchdog()
|
||||||
startCommandPoller()
|
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', () => {
|
app.on('activate', () => {
|
||||||
// macOS: re-create window when dock icon clicked
|
// macOS: re-create window when dock icon clicked
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import { extractLnurlw, readNdefLnurlw } from './nfc-service'
|
|
||||||
|
|
||||||
const LNURLW =
|
|
||||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
|
||||||
|
|
||||||
/** Build a Type-4 NDEF message with a single URI record carrying `uri`. */
|
|
||||||
function ndefUriMessage(uri: string): Buffer {
|
|
||||||
const uriBytes = Buffer.from(uri, 'ascii')
|
|
||||||
const payload = Buffer.concat([Buffer.from([0x00]), uriBytes]) // 0x00 = no prefix
|
|
||||||
// D1 = MB|ME|SR, TNF=well-known; type length 1; payload length; 'U'
|
|
||||||
return Buffer.concat([Buffer.from([0xd1, 0x01, payload.length, 0x55]), payload])
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('extractLnurlw', () => {
|
|
||||||
it('pulls an lnurlw:// URI out of an NDEF record', () => {
|
|
||||||
expect(extractLnurlw(ndefUriMessage(LNURLW))).toBe(LNURLW)
|
|
||||||
})
|
|
||||||
it('pulls a boltcards https scan URL', () => {
|
|
||||||
const https = 'https://lnbits.l484.com/boltcards/api/v1/scan/x?p=aa&c=bb'
|
|
||||||
expect(extractLnurlw(ndefUriMessage(https))).toBe(https)
|
|
||||||
})
|
|
||||||
it('stops at the record boundary (no trailing binary)', () => {
|
|
||||||
const msg = Buffer.concat([ndefUriMessage(LNURLW), Buffer.from([0x00, 0xfe, 0x01])])
|
|
||||||
expect(extractLnurlw(msg)).toBe(LNURLW)
|
|
||||||
})
|
|
||||||
it('returns null when there is no lnurl', () => {
|
|
||||||
expect(extractLnurlw(Buffer.from('just some text', 'ascii'))).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('readNdefLnurlw', () => {
|
|
||||||
const SW_OK = Buffer.from([0x90, 0x00])
|
|
||||||
const SW_NOTFOUND = Buffer.from([0x6a, 0x82])
|
|
||||||
// Capability Container advertising the NDEF file id E104 (TLV 04 06 at [7,8]).
|
|
||||||
const CC = Buffer.from([
|
|
||||||
0x00, 0x0f, 0x20, 0x00, 0x3b, 0x00, 0x34, 0x04, 0x06, 0xe1, 0x04, 0x00, 0xff, 0x00, 0xff,
|
|
||||||
])
|
|
||||||
|
|
||||||
/** Route APDUs by content so the CC-read + fallback loop is exercised. */
|
|
||||||
function cardMock(opts: { noApp?: boolean; nlen0?: boolean; uri?: string } = {}) {
|
|
||||||
const msg = ndefUriMessage(opts.uri ?? LNURLW)
|
|
||||||
const nlen = msg.length
|
|
||||||
return vi.fn(async (apdu: Buffer) => {
|
|
||||||
const hex = apdu.toString('hex')
|
|
||||||
if (hex.includes('d2760000850101')) return opts.noApp ? SW_NOTFOUND : SW_OK // select app
|
|
||||||
if (hex.startsWith('00a4000c02e103')) return SW_OK // select CC
|
|
||||||
if (hex.startsWith('00b000000f')) return Buffer.concat([CC, SW_OK]) // read CC
|
|
||||||
if (hex.startsWith('00a4000c02e104')) return SW_OK // select NDEF file (E104)
|
|
||||||
if (hex.startsWith('00a4000c020004')) return SW_NOTFOUND // fallback file id: absent
|
|
||||||
if (hex.startsWith('00b0000002'))
|
|
||||||
return opts.nlen0
|
|
||||||
? Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK])
|
|
||||||
: Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK]) // NLEN
|
|
||||||
if (hex.startsWith('00b0')) return Buffer.concat([msg, SW_OK]) // read message
|
|
||||||
return SW_NOTFOUND
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
it('reads CC → NDEF file (E104) and returns the lnurlw', async () => {
|
|
||||||
const transmit = cardMock()
|
|
||||||
expect(await readNdefLnurlw(transmit)).toBe(LNURLW)
|
|
||||||
// First APDU selects the NDEF application (AID D2760000850101).
|
|
||||||
expect((transmit.mock.calls[0][0] as Buffer).toString('hex')).toContain('d2760000850101')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null if selecting the NDEF app fails', async () => {
|
|
||||||
expect(await readNdefLnurlw(cardMock({ noApp: true }))).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null on an empty NDEF file', async () => {
|
|
||||||
expect(await readNdefLnurlw(cardMock({ nlen0: true }))).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,250 +0,0 @@
|
||||||
/**
|
|
||||||
* NFC reader driver (main process) for Bolt Card tap-to-pay.
|
|
||||||
*
|
|
||||||
* Wraps `nfc-pcsc` (PC/SC via the Feitian KP382 CCID reader). On each card
|
|
||||||
* tap it reads the NTAG424 Type-4 NDEF file over ISO7816 APDUs and extracts
|
|
||||||
* the `lnurlw://…?p=…&c=…` voucher (the card computes fresh SUN p/c per tap),
|
|
||||||
* then hands it to the renderer over IPC. The renderer, when showing a
|
|
||||||
* cash-out invoice, pays it via LNURL-withdraw (see lnurl-withdraw.ts).
|
|
||||||
*
|
|
||||||
* Everything here is best-effort and lazy: `nfc-pcsc` is a native addon, so it
|
|
||||||
* is dynamically imported and every failure is swallowed into a status
|
|
||||||
* callback. If the reader/library is absent, NFC is simply unavailable and the
|
|
||||||
* QR path keeps working — cash-out never depends on this.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execFile } from 'node:child_process'
|
|
||||||
|
|
||||||
export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable'
|
|
||||||
export interface NfcStatus {
|
|
||||||
state: NfcState
|
|
||||||
reader?: string
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type CardHandler = (lnurlw: string) => void
|
|
||||||
type StatusHandler = (status: NfcStatus) => void
|
|
||||||
|
|
||||||
function errMsg(e: unknown): string {
|
|
||||||
return e instanceof Error ? e.message : String(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pull the lnurlw (or a boltcards https scan URL) out of a Type-4 NDEF blob. */
|
|
||||||
export function extractLnurlw(ndef: Buffer): string | null {
|
|
||||||
// Robust to record framing: the URI record embeds the literal string; grab
|
|
||||||
// it directly, bounded to URL-safe characters so we stop at the record end.
|
|
||||||
const text = ndef.toString('latin1')
|
|
||||||
const urlChars = "[A-Za-z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+"
|
|
||||||
const m =
|
|
||||||
text.match(new RegExp('lnurlw://' + urlChars, 'i')) ||
|
|
||||||
text.match(new RegExp('https://' + urlChars + '/boltcards/' + urlChars, 'i'))
|
|
||||||
return m ? m[0] : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const swOk = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00
|
|
||||||
|
|
||||||
/** Select an EF by its 2-byte file id and read + parse its NDEF message. */
|
|
||||||
async function readNdefFile(
|
|
||||||
send: (bytes: number[]) => Promise<Buffer>,
|
|
||||||
fid: [number, number]
|
|
||||||
): Promise<string | null> {
|
|
||||||
if (!swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, fid[0], fid[1]]))) return null
|
|
||||||
// 2-byte NLEN header at offset 0.
|
|
||||||
const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02])
|
|
||||||
if (!swOk(lenResp)) return null
|
|
||||||
const nlen = (lenResp[0] << 8) | lenResp[1]
|
|
||||||
if (nlen <= 0 || nlen > 0x2000) return null
|
|
||||||
// NDEF message starts at offset 2; read in <=250-byte chunks.
|
|
||||||
const chunks: Buffer[] = []
|
|
||||||
let offset = 2
|
|
||||||
let remaining = nlen
|
|
||||||
while (remaining > 0) {
|
|
||||||
const toRead = Math.min(remaining, 0xfa)
|
|
||||||
const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead])
|
|
||||||
if (!swOk(resp)) break
|
|
||||||
const data = resp.subarray(0, resp.length - 2)
|
|
||||||
if (data.length === 0) break
|
|
||||||
chunks.push(data)
|
|
||||||
offset += data.length
|
|
||||||
remaining -= data.length
|
|
||||||
}
|
|
||||||
return extractLnurlw(Buffer.concat(chunks))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the NDEF of a Type-4 tag and return the extracted lnurlw, or null.
|
|
||||||
* `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2.
|
|
||||||
*
|
|
||||||
* Select the NDEF Tag Application, read the Capability Container to learn the
|
|
||||||
* real NDEF FileID (NTAG424 Bolt Cards use E104, not the 0004 some tags use),
|
|
||||||
* then read that file. Falls back to E104/0004 if the CC read is unavailable.
|
|
||||||
*/
|
|
||||||
export async function readNdefLnurlw(
|
|
||||||
transmit: (apdu: Buffer, maxLen: number) => Promise<Buffer>
|
|
||||||
): Promise<string | null> {
|
|
||||||
const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256)
|
|
||||||
|
|
||||||
// Select the NDEF Tag Application (AID D2760000850101).
|
|
||||||
if (
|
|
||||||
!swOk(
|
|
||||||
await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00])
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// NTAG424 Bolt Cards use NDEF FileID E104. Try it (and 0004) directly to
|
|
||||||
// minimise APDU round-trips over a flaky RF link; only fall back to reading
|
|
||||||
// the Capability Container to discover the id if both direct reads fail.
|
|
||||||
for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) {
|
|
||||||
const found = await readNdefFile(send, fid)
|
|
||||||
if (found) return found
|
|
||||||
}
|
|
||||||
if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) {
|
|
||||||
const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f])
|
|
||||||
// CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID.
|
|
||||||
if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) {
|
|
||||||
const found = await readNdefFile(send, [cc[9], cc[10]])
|
|
||||||
if (found) return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let stopFn: (() => void) | null = null
|
|
||||||
|
|
||||||
// ── Wedge auto-recovery ───────────────────────────────────────────────────
|
|
||||||
// Cheap CCID readers (the Feitian R502-CL especially) occasionally wedge: they
|
|
||||||
// keep detecting a card but every APDU returns "card absent or mute", and ONLY
|
|
||||||
// a USB power-cycle clears it — pcscd/app restarts do NOT. When we see a run of
|
|
||||||
// consecutive read failures we trigger nfc-reader-reset.service (a root oneshot
|
|
||||||
// that re-binds the reader's USB device = a software replug); nfc-pcsc then
|
|
||||||
// re-detects the reader on hotplug with no app restart. The trigger is gated by
|
|
||||||
// a cooldown so a still-wedged reader can't reset-loop. A quality reader (e.g.
|
|
||||||
// ACR1252U) wedges far less; this is belt-and-suspenders for any reader.
|
|
||||||
const WEDGE_FAILURE_THRESHOLD = 3
|
|
||||||
const RESET_COOLDOWN_MS = 30_000
|
|
||||||
// Persist across reader re-enumerations (a reset spawns a fresh reader closure).
|
|
||||||
let lastReaderResetAt = 0
|
|
||||||
|
|
||||||
/** Trigger the privileged USB power-cycle of the reader. Best-effort. */
|
|
||||||
function resetWedgedReader(): void {
|
|
||||||
// NixOS: the app runs unprivileged as `bitspire`; a polkit rule authorises it
|
|
||||||
// to start this one unit. systemctl lives at a stable path on the device.
|
|
||||||
execFile('/run/current-system/sw/bin/systemctl', ['start', 'nfc-reader-reset.service'], () => {
|
|
||||||
/* best-effort — if it fails the reader stays wedged until a manual reset */
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start listening for Bolt Card taps. Idempotent. Returns a stop function.
|
|
||||||
* Never throws — failures surface via onStatus.
|
|
||||||
*/
|
|
||||||
export async function startNfcReader(
|
|
||||||
onCard: CardHandler,
|
|
||||||
onStatus: StatusHandler
|
|
||||||
): Promise<() => void> {
|
|
||||||
if (stopFn) return stopFn
|
|
||||||
|
|
||||||
let mod: unknown
|
|
||||||
try {
|
|
||||||
// Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc
|
|
||||||
// while resolving normally at runtime.
|
|
||||||
const pkg = 'nfc-pcsc'
|
|
||||||
mod = (await import(pkg)) as unknown
|
|
||||||
} catch (e) {
|
|
||||||
onStatus({ state: 'unavailable', message: `NFC library unavailable: ${errMsg(e)}` })
|
|
||||||
return () => {}
|
|
||||||
}
|
|
||||||
const NFC =
|
|
||||||
(mod as { NFC?: unknown }).NFC ?? (mod as { default?: { NFC?: unknown } }).default?.NFC
|
|
||||||
if (typeof NFC !== 'function') {
|
|
||||||
onStatus({ state: 'unavailable', message: 'NFC library has no NFC export' })
|
|
||||||
return () => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nfc: { on: (e: string, cb: (...a: unknown[]) => void) => void; close?: () => void }
|
|
||||||
try {
|
|
||||||
nfc = new (NFC as new () => typeof nfc)()
|
|
||||||
} catch (e) {
|
|
||||||
onStatus({ state: 'unavailable', message: `NFC init failed: ${errMsg(e)}` })
|
|
||||||
return () => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
nfc.on('reader', (reader: unknown) => {
|
|
||||||
const r = reader as {
|
|
||||||
name?: string
|
|
||||||
reader?: { name?: string }
|
|
||||||
autoProcessing?: boolean
|
|
||||||
on: (e: string, cb: (...a: unknown[]) => void) => void
|
|
||||||
transmit: (data: Buffer, maxLen: number) => Promise<Buffer>
|
|
||||||
}
|
|
||||||
const name = r.name ?? r.reader?.name ?? 'reader'
|
|
||||||
// We do our own NDEF APDU read, not nfc-pcsc's UID auto-processing.
|
|
||||||
r.autoProcessing = false
|
|
||||||
onStatus({ state: 'ready', reader: name })
|
|
||||||
|
|
||||||
// Cooldown after a failed read: these cheap CCID readers can get wedged into
|
|
||||||
// a present↔empty storm when hammered, so ignore re-detections for a beat
|
|
||||||
// after a failure. Successful reads don't cool down.
|
|
||||||
let cooldownUntil = 0
|
|
||||||
// Consecutive failed reads → wedge detection (see resetWedgedReader above).
|
|
||||||
// A completed read (Bolt Card or not) proves the reader is healthy and
|
|
||||||
// clears the count; only a run of thrown transmits trips the reset.
|
|
||||||
let consecutiveFailures = 0
|
|
||||||
r.on('card', async () => {
|
|
||||||
if (Date.now() < cooldownUntil) return
|
|
||||||
onStatus({ state: 'reading', reader: name })
|
|
||||||
// Single attempt: retrying hammers a flaky RF link. A read is a few APDU
|
|
||||||
// round-trips; if the card shifts mid-read the transmit fails and the
|
|
||||||
// user simply re-taps.
|
|
||||||
try {
|
|
||||||
const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen))
|
|
||||||
consecutiveFailures = 0
|
|
||||||
if (lnurlw) {
|
|
||||||
onCard(lnurlw)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' })
|
|
||||||
} catch (e) {
|
|
||||||
consecutiveFailures++
|
|
||||||
if (
|
|
||||||
consecutiveFailures >= WEDGE_FAILURE_THRESHOLD &&
|
|
||||||
Date.now() - lastReaderResetAt > RESET_COOLDOWN_MS
|
|
||||||
) {
|
|
||||||
// Reader looks wedged — auto power-cycle it (only fix that works).
|
|
||||||
lastReaderResetAt = Date.now()
|
|
||||||
consecutiveFailures = 0
|
|
||||||
onStatus({ state: 'error', reader: name, message: 'reader stuck — auto-resetting…' })
|
|
||||||
resetWedgedReader()
|
|
||||||
} else {
|
|
||||||
onStatus({
|
|
||||||
state: 'error',
|
|
||||||
reader: name,
|
|
||||||
message: 'card read failed — hold steady & retap',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
void e
|
|
||||||
}
|
|
||||||
cooldownUntil = Date.now() + 1500
|
|
||||||
})
|
|
||||||
r.on('card.off', () => onStatus({ state: 'card-removed', reader: name }))
|
|
||||||
r.on('error', (err: unknown) =>
|
|
||||||
onStatus({ state: 'error', reader: name, message: errMsg(err) })
|
|
||||||
)
|
|
||||||
r.on('end', () =>
|
|
||||||
onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })
|
|
||||||
)
|
|
||||||
})
|
|
||||||
nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) }))
|
|
||||||
|
|
||||||
stopFn = () => {
|
|
||||||
try {
|
|
||||||
nfc.close?.()
|
|
||||||
} catch {
|
|
||||||
/* idempotent */
|
|
||||||
}
|
|
||||||
stopFn = null
|
|
||||||
}
|
|
||||||
return stopFn
|
|
||||||
}
|
|
||||||
|
|
@ -17,6 +17,10 @@ export interface RuntimeConfig {
|
||||||
relayUrl: string
|
relayUrl: string
|
||||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||||
lnbitsServerPubkey: string
|
lnbitsServerPubkey: string
|
||||||
|
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
|
||||||
|
lightningPubPubkey?: string
|
||||||
|
lightningPubApiUrl?: string
|
||||||
|
extensionApiUrl?: string
|
||||||
appId: string
|
appId: string
|
||||||
machineModel: string
|
machineModel: string
|
||||||
fiatCode: string
|
fiatCode: string
|
||||||
|
|
@ -38,29 +42,13 @@ export interface BrandingConfig {
|
||||||
logoDarkDataUrl: string | null
|
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.
|
* 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 {
|
export interface AtmSecrets {
|
||||||
spireSeed: string
|
atmPrivateKey: string
|
||||||
bunkerBinding: BunkerBindingRecord | null
|
/** Legacy LP admin token — retained until 3d removes the LP backend. */
|
||||||
|
adminToken?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose protected methods to renderer
|
// Expose protected methods to renderer
|
||||||
|
|
@ -112,35 +100,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
ipcRenderer.invoke('state:get-bootstrap-published-at'),
|
ipcRenderer.invoke('state:get-bootstrap-published-at'),
|
||||||
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
|
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
|
||||||
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
|
ipcRenderer.invoke('state:mark-bootstrap-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'),
|
|
||||||
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'),
|
|
||||||
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),
|
|
||||||
|
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: {
|
payload: {
|
||||||
positions: Record<string, { denomination: number; count: number }>
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
|
|
@ -202,20 +161,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
ipcRenderer.on('hal:error', (_event, error) => callback(error))
|
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.
|
|
||||||
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)
|
// Watchdog heartbeat (main process → renderer → main process)
|
||||||
onWatchdogPing: (callback: () => void) => {
|
onWatchdogPing: (callback: () => void) => {
|
||||||
ipcRenderer.on('watchdog:ping', () => callback())
|
ipcRenderer.on('watchdog:ping', () => callback())
|
||||||
|
|
@ -267,22 +212,6 @@ declare global {
|
||||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||||
getBootstrapPublishedAt: () => Promise<number | null>
|
getBootstrapPublishedAt: () => Promise<number | null>
|
||||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
|
||||||
clearBunkerBinding: () => Promise<void>
|
|
||||||
resetBootstrapGate: () => 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 }>
|
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
|
|
@ -320,10 +249,6 @@ declare global {
|
||||||
onHalBillInserted: (callback: (denomination: number) => void) => void
|
onHalBillInserted: (callback: (denomination: number) => void) => void
|
||||||
onHalBillRejected: (callback: (reason: string) => void) => void
|
onHalBillRejected: (callback: (reason: string) => void) => void
|
||||||
onHalError: (callback: (error: 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
|
onWatchdogPing: (callback: () => void) => void
|
||||||
watchdogPong: () => Promise<void>
|
watchdogPong: () => Promise<void>
|
||||||
platform: NodeJS.Platform
|
platform: NodeJS.Platform
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '12'
|
const SCHEMA_VERSION = '10'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/bitspire'
|
const prodDir = '/var/lib/bitspire'
|
||||||
|
|
@ -114,17 +114,6 @@ export function initDatabase(dbPath?: string): void {
|
||||||
event_created_at INTEGER NOT NULL,
|
event_created_at INTEGER NOT NULL,
|
||||||
applied_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
|
// Seed meta + cashbox if first run, or run migrations
|
||||||
|
|
@ -331,44 +320,6 @@ export function initDatabase(dbPath?: string): void {
|
||||||
)
|
)
|
||||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('10', 'schema_version')
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('10', 'schema_version')
|
||||||
console.log('[StateStore] Migrated schema v9 → v10 (added fee_config + watermark)')
|
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)')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||||||
|
|
@ -430,142 +381,6 @@ export function markBootstrapPublished(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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the persisted bunker binding, or null if the ATM is unpaired. */
|
|
||||||
export function getBunkerBinding(): StoredBunkerBinding | null {
|
|
||||||
if (!db) throw new Error('Database not initialized')
|
|
||||||
const row = db
|
|
||||||
.prepare(
|
|
||||||
'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey FROM bunker_binding WHERE id = 1'
|
|
||||||
)
|
|
||||||
.get() as
|
|
||||||
| {
|
|
||||||
client_secret_hex: string
|
|
||||||
spire_pubkey: string
|
|
||||||
bunker_url: string
|
|
||||||
seed_fingerprint: string
|
|
||||||
paired_at: number
|
|
||||||
relays: string | null
|
|
||||||
lnbits_server_pubkey: string | null
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
if (!row) return null
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the bootstrap-publish gate so the ATM re-publishes its
|
|
||||||
* `bitspire-cassettes-state` hello-event. Called on a re-pair (new seed) so
|
|
||||||
* the new operator receives the spire's current state (aiolabs/bitspire#56).
|
|
||||||
*/
|
|
||||||
export function resetBootstrapGate(): 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')
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OperatorCassettesPayload = {
|
export type OperatorCassettesPayload = {
|
||||||
positions: Record<string, { denomination: number; count: number }>
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
}
|
}
|
||||||
|
|
@ -982,11 +797,8 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
const insertCassetteBill = db.prepare(
|
const insertCassetteBill = db.prepare(
|
||||||
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
)
|
)
|
||||||
const updateCassetteByPosition = db.prepare(
|
const updateCassette = db.prepare(
|
||||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
|
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?'
|
||||||
)
|
|
||||||
const selectBaysByDenom = db.prepare(
|
|
||||||
'SELECT position, count FROM cassettes WHERE denomination = ? ORDER BY position'
|
|
||||||
)
|
)
|
||||||
const updateCashboxStmt = db.prepare(
|
const updateCashboxStmt = db.prepare(
|
||||||
'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1'
|
'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1'
|
||||||
|
|
@ -1027,33 +839,17 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.type === 'cash_out') {
|
if (t.type === 'cash_out') {
|
||||||
// Decrement cassettes by ACTUALLY dispensed count (not requested).
|
// 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.cassettes) {
|
if (t.cassettes) {
|
||||||
for (const c of t.cassettes) {
|
for (const c of t.cassettes) {
|
||||||
if (c.dispensed > 0) {
|
if (c.dispensed > 0) {
|
||||||
updateCassetteByPosition.run(-c.dispensed, c.position)
|
updateCassette.run(-c.dispensed, c.denomination)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: per-denomination bill counts (mocks without per-bay
|
// Fallback: use bill counts (backward compat for mocks without cassette data)
|
||||||
// results). Drain matching bays greedily in position order —
|
|
||||||
// the dispenser's own fill order.
|
|
||||||
for (const bill of t.bills) {
|
for (const bill of t.bills) {
|
||||||
let remaining = bill.count
|
updateCassette.run(-bill.count, bill.denomination)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
|
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
|
||||||
"dev:vite": "vite",
|
"dev:vite": "vite",
|
||||||
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
|
"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": "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",
|
"build:electron": "pnpm build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
|
@ -35,10 +35,8 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.563.0",
|
"lucide-vue-next": "^0.563.0",
|
||||||
"marked": "^17.0.5",
|
"marked": "^17.0.5",
|
||||||
"nfc-pcsc": "^0.8.1",
|
|
||||||
"nostr-tools": "^2.10.0",
|
"nostr-tools": "^2.10.0",
|
||||||
"pinia": "^2.2.0",
|
"pinia": "^2.2.0",
|
||||||
"qr": "^0.6.0",
|
|
||||||
"qrcode.vue": "^3.6.0",
|
"qrcode.vue": "^3.6.0",
|
||||||
"reka-ui": "^2.7.0",
|
"reka-ui": "^2.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useAtmStore } from '@/stores/atm'
|
import { useAtmStore } from '@/stores/atm'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
import { setBranding } from '@/composables/useBranding'
|
import { setBranding } from '@/composables/useBranding'
|
||||||
import { classifyInitError } from '@/services/init-error'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Sun, Moon } from 'lucide-vue-next'
|
import { Sun, Moon } from 'lucide-vue-next'
|
||||||
import PairingWizard from '@/components/PairingWizard.vue'
|
|
||||||
|
|
||||||
const atmStore = useAtmStore()
|
const atmStore = useAtmStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -22,54 +20,6 @@ function formatSats(sats: number): string {
|
||||||
return sats.toLocaleString()
|
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(() => {
|
const formattedBtcPrice = computed(() => {
|
||||||
if (atmStore.btcPrice === null) return null
|
if (atmStore.btcPrice === null) return null
|
||||||
const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}`
|
const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}`
|
||||||
|
|
@ -101,23 +51,18 @@ onMounted(async () => {
|
||||||
atmStore.initError = 'maintenance'
|
atmStore.initError = 'maintenance'
|
||||||
// Publish maintenance beacon — minimal Nostr connection only (no Lightning.Pub)
|
// Publish maintenance beacon — minimal Nostr connection only (no Lightning.Pub)
|
||||||
try {
|
try {
|
||||||
const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client')
|
const { NostrClient, loadIdentityFromHex, createSignedEvent } = await import(
|
||||||
const { resolveSigner } = await import('@/services/signer-resolver')
|
'@bitSpire/nostr-client'
|
||||||
// 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 secrets = isElectron ? await window.electronAPI?.getAtmSecrets() : null
|
||||||
const resolved = await resolveSigner({ allowEphemeral: true }).catch(() => null)
|
const privKey = secrets?.atmPrivateKey || import.meta.env.VITE_ATM_PRIVATE_KEY
|
||||||
const signer = resolved?.signer ?? null
|
const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL
|
||||||
// Same env → pairing-seed precedence as lightning.ts: on a blank-.env
|
if (privKey && relayUrl) {
|
||||||
// seed-driven machine the relay comes from the pairing transport, not env.
|
const identity = loadIdentityFromHex(privKey)
|
||||||
const relayUrl =
|
const client = new NostrClient({ relays: [{ url: relayUrl }], identity })
|
||||||
config?.relayUrl ||
|
|
||||||
import.meta.env.VITE_RELAY_URL ||
|
|
||||||
resolved?.transport?.relays?.[0]
|
|
||||||
if (signer && relayUrl) {
|
|
||||||
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
|
||||||
await client.connect()
|
await client.connect()
|
||||||
const publishBeacon = async () => {
|
const publishBeacon = () => {
|
||||||
const event = await createSignedEvent(signer, {
|
const event = createSignedEvent(identity, {
|
||||||
kind: 30078,
|
kind: 30078,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [['d', 'atm-availability']],
|
tags: [['d', 'atm-availability']],
|
||||||
|
|
@ -132,8 +77,8 @@ onMounted(async () => {
|
||||||
})
|
})
|
||||||
client.publish(event).catch(() => {})
|
client.publish(event).catch(() => {})
|
||||||
}
|
}
|
||||||
void publishBeacon()
|
publishBeacon()
|
||||||
setInterval(() => void publishBeacon(), 5 * 60 * 1000)
|
setInterval(publishBeacon, 5 * 60 * 1000)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[App] Failed to start maintenance beacon:', e)
|
console.warn('[App] Failed to start maintenance beacon:', e)
|
||||||
|
|
@ -152,77 +97,14 @@ onMounted(async () => {
|
||||||
atmStore.startPricePolling()
|
atmStore.startPricePolling()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[App] Initialization failed:', error)
|
console.error('[App] Initialization failed:', error)
|
||||||
atmStore.initError = classifyInitError(error)
|
atmStore.initError = error instanceof Error ? error.message : 'Initialization failed'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
atmStore.stopPricePolling()
|
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() {
|
function toggleLiveServices() {
|
||||||
if (atmStore.useLiveServices) {
|
if (atmStore.useLiveServices) {
|
||||||
// Switch to mock
|
// Switch to mock
|
||||||
|
|
@ -238,12 +120,9 @@ function toggleLiveServices() {
|
||||||
<div
|
<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"
|
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 -->
|
<!-- Maintenance screen: shown when initialization fails in production -->
|
||||||
<div
|
<div
|
||||||
v-else-if="atmStore.initError"
|
v-if="atmStore.initError"
|
||||||
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
|
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -263,30 +142,33 @@ function toggleLiveServices() {
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
<h1 class="text-2xl lg:text-[3.5rem] font-bold">
|
<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>
|
</h1>
|
||||||
<p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground">
|
<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>
|
||||||
<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"
|
class="max-w-lg text-center font-mono text-sm text-destructive"
|
||||||
>
|
>
|
||||||
{{ atmStore.initError }}
|
{{ atmStore.initError }}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* QR-pairing wizard (aiolabs/bitspire#52).
|
|
||||||
*
|
|
||||||
* Shown in place of the "Pairing Required" maintenance screen when the machine
|
|
||||||
* is unpaired. The operator displays the spire-seed QR (minted by spirekeeper)
|
|
||||||
* to the machine's camera; we decode it, persist it as VITE_SPIRE_SEED, and
|
|
||||||
* relaunch so the normal boot path performs the bunker pairing.
|
|
||||||
*
|
|
||||||
* Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be
|
|
||||||
* offered later without changing this view.
|
|
||||||
*/
|
|
||||||
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
|
||||||
import {
|
|
||||||
availablePairingSources,
|
|
||||||
ingestScannedSeed,
|
|
||||||
parseScannedSeed,
|
|
||||||
testRelay,
|
|
||||||
type PairingSource,
|
|
||||||
type RelayTestResult,
|
|
||||||
type StopCapture,
|
|
||||||
} from '@/services/pairing'
|
|
||||||
|
|
||||||
type Phase = 'probing' | 'scanning' | 'review' | 'no-source' | 'pairing' | 'error'
|
|
||||||
|
|
||||||
const phase = ref<Phase>('probing')
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
|
||||||
|
|
||||||
const sources = shallowRef<PairingSource[]>([])
|
|
||||||
const activeSource = shallowRef<PairingSource | null>(null)
|
|
||||||
let stopCapture: StopCapture | null = null
|
|
||||||
|
|
||||||
// Review-step state: the scanned-but-not-yet-committed seed + relay tests.
|
|
||||||
const scannedRaw = ref('')
|
|
||||||
const previewSpire = ref('')
|
|
||||||
const previewRelays = ref<string[]>([])
|
|
||||||
type RelayState = { status: 'idle' | 'testing' | 'done'; result?: RelayTestResult }
|
|
||||||
const relayTests = ref<Record<string, RelayState>>({})
|
|
||||||
const testingRelays = ref(false)
|
|
||||||
const committing = ref(false)
|
|
||||||
|
|
||||||
const anyRelayFailed = computed(() =>
|
|
||||||
Object.values(relayTests.value).some((s) => s.status === 'done' && s.result != null && !s.result.ok),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function startWith(source: PairingSource) {
|
|
||||||
await teardown()
|
|
||||||
activeSource.value = source
|
|
||||||
errorMessage.value = ''
|
|
||||||
phase.value = 'scanning'
|
|
||||||
try {
|
|
||||||
stopCapture = await source.start({
|
|
||||||
video: source.kind === 'qr' ? (videoEl.value ?? undefined) : undefined,
|
|
||||||
onScan: handleScan,
|
|
||||||
onError: (e) => console.warn('[Pairing] capture glitch:', e),
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
phase.value = 'error'
|
|
||||||
errorMessage.value =
|
|
||||||
e instanceof Error ? e.message : 'Could not start the camera. Check permissions.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let handling = false
|
|
||||||
async function handleScan(raw: string) {
|
|
||||||
if (handling) return
|
|
||||||
handling = true
|
|
||||||
// Validate only — don't commit yet. Show a review step with the decoded
|
|
||||||
// relay + a "test relay" button so a well-formed but unreachable relay is
|
|
||||||
// caught before we relaunch into a pairing crash-loop (aiolabs/bitspire#70).
|
|
||||||
const preview = parseScannedSeed(raw)
|
|
||||||
if (preview.ok) {
|
|
||||||
await teardown() // camera off during review
|
|
||||||
scannedRaw.value = raw.trim()
|
|
||||||
previewSpire.value = preview.spirePubkey
|
|
||||||
previewRelays.value = preview.relays
|
|
||||||
relayTests.value = Object.fromEntries(preview.relays.map((r) => [r, { status: 'idle' }]))
|
|
||||||
errorMessage.value = ''
|
|
||||||
phase.value = 'review'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Reject non-seed / malformed scans (a stray QR, a corrupted relay) and resume.
|
|
||||||
console.warn('[Pairing] rejected scan:', preview.reason, preview.message)
|
|
||||||
errorMessage.value = 'That code is not a valid pairing code. Show the operator pairing QR.'
|
|
||||||
handling = false
|
|
||||||
if (activeSource.value) await startWith(activeSource.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Probe every relay in the scanned seed and record reachability. */
|
|
||||||
async function testRelays() {
|
|
||||||
testingRelays.value = true
|
|
||||||
await Promise.all(
|
|
||||||
previewRelays.value.map(async (url) => {
|
|
||||||
relayTests.value[url] = { status: 'testing' }
|
|
||||||
const result = await testRelay(url)
|
|
||||||
relayTests.value[url] = { status: 'done', result }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
testingRelays.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Commit the reviewed seed: persist + relaunch into the real pairing path. */
|
|
||||||
async function confirmPair() {
|
|
||||||
committing.value = true
|
|
||||||
const result = await ingestScannedSeed(scannedRaw.value)
|
|
||||||
if (result.ok) {
|
|
||||||
phase.value = 'pairing' // relaunch in flight
|
|
||||||
return
|
|
||||||
}
|
|
||||||
committing.value = false
|
|
||||||
errorMessage.value = result.message
|
|
||||||
phase.value = 'error'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Discard the scan and go back to scanning. */
|
|
||||||
async function rescan() {
|
|
||||||
scannedRaw.value = ''
|
|
||||||
previewRelays.value = []
|
|
||||||
relayTests.value = {}
|
|
||||||
handling = false
|
|
||||||
if (activeSource.value) await startWith(activeSource.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function teardown() {
|
|
||||||
if (stopCapture) {
|
|
||||||
try {
|
|
||||||
stopCapture()
|
|
||||||
} catch {
|
|
||||||
/* idempotent */
|
|
||||||
}
|
|
||||||
stopCapture = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const available = await availablePairingSources()
|
|
||||||
sources.value = available
|
|
||||||
const first = available[0]
|
|
||||||
if (!first) {
|
|
||||||
phase.value = 'no-source'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await startWith(first)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(teardown)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-8">
|
|
||||||
<h1 class="text-2xl lg:text-[3.5rem] font-bold">Pair This Machine</h1>
|
|
||||||
|
|
||||||
<!-- Camera viewfinder -->
|
|
||||||
<div
|
|
||||||
v-show="phase === 'scanning' && activeSource?.kind === 'qr'"
|
|
||||||
class="relative overflow-hidden rounded-2xl border-4 border-primary/40 bg-black"
|
|
||||||
style="width: min(80vw, 28rem); aspect-ratio: 1 / 1"
|
|
||||||
>
|
|
||||||
<!-- The Sintra's camera is mounted rotated, so rotate the preview 90° CCW
|
|
||||||
for an upright image. Preview-only: qr-source decodes the raw (un-
|
|
||||||
rotated) frame and QR decoding is rotation-invariant. The container is
|
|
||||||
square + overflow-hidden, so the rotated square stays in the box. -->
|
|
||||||
<video
|
|
||||||
ref="videoEl"
|
|
||||||
class="h-full w-full -rotate-90 object-cover"
|
|
||||||
muted
|
|
||||||
autoplay
|
|
||||||
playsinline
|
|
||||||
></video>
|
|
||||||
<!-- Reticle -->
|
|
||||||
<div class="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="phase === 'scanning'"
|
|
||||||
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
|
|
||||||
>
|
|
||||||
Hold the operator's pairing QR up to the camera.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-if="phase === 'probing'" class="text-base lg:text-2xl text-muted-foreground">
|
|
||||||
Starting camera…
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div v-if="phase === 'pairing'" class="flex flex-col items-center gap-4">
|
|
||||||
<p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Review: confirm the scanned relay is reachable before committing -->
|
|
||||||
<div v-if="phase === 'review'" class="flex w-full max-w-md flex-col items-center gap-5">
|
|
||||||
<p class="text-base lg:text-2xl text-muted-foreground">
|
|
||||||
Pairing code scanned. Test the relay, then pair.
|
|
||||||
</p>
|
|
||||||
<div class="w-full rounded-xl border border-border p-4 text-left">
|
|
||||||
<p class="text-xs uppercase text-muted-foreground">Spire</p>
|
|
||||||
<p class="mb-3 break-all font-mono text-sm">{{ previewSpire.slice(0, 16) }}…</p>
|
|
||||||
<p class="text-xs uppercase text-muted-foreground">Relay(s)</p>
|
|
||||||
<ul class="flex flex-col gap-2">
|
|
||||||
<li
|
|
||||||
v-for="url in previewRelays"
|
|
||||||
:key="url"
|
|
||||||
class="flex items-center justify-between gap-3"
|
|
||||||
>
|
|
||||||
<span class="break-all font-mono text-xs">{{ url }}</span>
|
|
||||||
<span class="shrink-0 text-sm">
|
|
||||||
<template v-if="relayTests[url]?.status === 'testing'">
|
|
||||||
<span class="text-muted-foreground">testing…</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="relayTests[url]?.status === 'done'">
|
|
||||||
<span v-if="relayTests[url]?.result?.ok" class="text-green-500"
|
|
||||||
>✓ {{ relayTests[url]?.result?.ms }}ms</span
|
|
||||||
>
|
|
||||||
<span v-else class="text-destructive">✗ unreachable</span>
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-center gap-3">
|
|
||||||
<button
|
|
||||||
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
|
|
||||||
:disabled="testingRelays || committing"
|
|
||||||
@click="testRelays"
|
|
||||||
>
|
|
||||||
{{ testingRelays ? 'Testing…' : 'Test relay' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
|
|
||||||
:disabled="committing"
|
|
||||||
@click="rescan"
|
|
||||||
>
|
|
||||||
Rescan
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
|
|
||||||
:disabled="committing"
|
|
||||||
@click="confirmPair"
|
|
||||||
>
|
|
||||||
{{ committing ? 'Pairing…' : 'Pair this machine' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="anyRelayFailed" class="max-w-md text-center text-sm text-warning">
|
|
||||||
A relay looks unreachable from this machine — pairing will fail unless it can reach the
|
|
||||||
relay. Check the URL/network, or rescan a corrected code.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="phase === 'no-source'"
|
|
||||||
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
|
|
||||||
>
|
|
||||||
No camera or NFC reader is available on this machine. Pair by provisioning
|
|
||||||
<span class="font-mono">VITE_SPIRE_SEED</span> instead.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="phase === 'error'"
|
|
||||||
class="max-w-md text-center text-base lg:text-xl text-destructive"
|
|
||||||
>
|
|
||||||
{{ errorMessage }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Transient rejected-scan hint while still scanning -->
|
|
||||||
<p
|
|
||||||
v-if="phase === 'scanning' && errorMessage"
|
|
||||||
class="max-w-md text-center text-sm lg:text-base text-warning"
|
|
||||||
>
|
|
||||||
{{ errorMessage }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Alternate sources (e.g. NFC) when more than one is available -->
|
|
||||||
<div v-if="sources.length > 1" class="flex gap-3">
|
|
||||||
<button
|
|
||||||
v-for="source in sources"
|
|
||||||
:key="source.kind"
|
|
||||||
class="rounded-lg border border-border px-4 py-2 text-sm"
|
|
||||||
:class="activeSource?.kind === source.kind ? 'bg-primary text-primary-foreground' : ''"
|
|
||||||
@click="startWith(source)"
|
|
||||||
>
|
|
||||||
{{ source.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
import { watch, type Ref } from 'vue'
|
import { watch, type Ref } from 'vue'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
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'
|
import { createSignedEvent } from '@bitSpire/nostr-client'
|
||||||
|
|
||||||
type CashLevel = 'none' | 'low' | 'good' | 'full'
|
type CashLevel = 'none' | 'low' | 'good' | 'full'
|
||||||
|
|
@ -26,7 +26,7 @@ interface AvailabilitySnapshot {
|
||||||
|
|
||||||
interface UseAvailabilityBroadcastOptions {
|
interface UseAvailabilityBroadcastOptions {
|
||||||
nostrClient: NostrClient
|
nostrClient: NostrClient
|
||||||
signer: Signer
|
identity: MachineIdentity
|
||||||
/** Reactive inventory: denomination -> count */
|
/** Reactive inventory: denomination -> count */
|
||||||
inventory: Ref<Record<number, number>>
|
inventory: Ref<Record<number, number>>
|
||||||
/** Reactive Lightning.Pub balance in sats (null = unknown) */
|
/** Reactive Lightning.Pub balance in sats (null = unknown) */
|
||||||
|
|
@ -38,7 +38,7 @@ interface UseAvailabilityBroadcastOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAvailabilityBroadcast(options: 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
|
let lastSnapshot: AvailabilitySnapshot | null = null
|
||||||
|
|
||||||
|
|
@ -73,22 +73,19 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
|
||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Signing goes through the bunker, so it can throw BunkerTimeoutError /
|
const event = createSignedEvent(identity, {
|
||||||
// BunkerRejectedError — keep it INSIDE the try so a transient signer blip
|
kind: 30078,
|
||||||
// is swallowed (the beacon re-publishes every interval) rather than
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
// surfacing as an uncaught rejection. `publish()` is fire-and-forget.
|
tags: [['d', 'atm-availability']],
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const event = await createSignedEvent(signer, {
|
|
||||||
kind: 30078,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [['d', 'atm-availability']],
|
|
||||||
content,
|
|
||||||
})
|
|
||||||
await nostrClient.publish(event)
|
await nostrClient.publish(event)
|
||||||
lastSnapshot = snap
|
lastSnapshot = snap
|
||||||
console.log('[Availability] Published:', content)
|
console.log('[Availability] Published:', content)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Availability] Publish failed (sign or relay):', e)
|
console.warn('[Availability] Failed to publish:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,7 @@ import type { HalConfig, CassetteConfig } from '@/services/hal'
|
||||||
/**
|
/**
|
||||||
* Supported machine models
|
* Supported machine models
|
||||||
*/
|
*/
|
||||||
export type MachineModel =
|
export type MachineModel = 'sintra' | 'tejo' | 'douro' | 'gaia' | 'batm3' | 'custom'
|
||||||
| 'sintra'
|
|
||||||
| 'tejo'
|
|
||||||
| 'douro'
|
|
||||||
| 'gaia'
|
|
||||||
| 'batm3'
|
|
||||||
| 'rpi4'
|
|
||||||
| 'rpi5'
|
|
||||||
| 'custom'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full device configuration
|
* Full device configuration
|
||||||
|
|
@ -36,10 +28,7 @@ export interface DeviceConfig {
|
||||||
/** Bill validator configuration */
|
/** Bill validator configuration */
|
||||||
validator: {
|
validator: {
|
||||||
/** Validator protocol type */
|
/** Validator protocol type */
|
||||||
// 'apex' = Pyramid Apex RS-232. The driver landed with the Pi 5 work
|
type: 'id003' | 'ebds'
|
||||||
// (packages/hal ValidatorType) but these app-side unions were never
|
|
||||||
// widened, so no machine could actually be configured to use it.
|
|
||||||
type: 'id003' | 'ebds' | 'apex'
|
|
||||||
/** Serial device path(s) */
|
/** Serial device path(s) */
|
||||||
device: string | string[]
|
device: string | string[]
|
||||||
}
|
}
|
||||||
|
|
@ -128,52 +117,6 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
|
||||||
* - Dispenser: Fujitsu F56
|
* - Dispenser: Fujitsu F56
|
||||||
* Note: Device paths may vary - verify on hardware
|
* Note: Device paths may vary - verify on hardware
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* Raspberry Pi 4 / CM4 reference build (aarch64).
|
|
||||||
* - Validator: Pyramid Apex 7600 over RS-232, via a USB-serial adapter
|
|
||||||
* - Dispenser: NONE WIRED YET — cash-in only
|
|
||||||
*
|
|
||||||
* The device path is the udev symlink raspberry-pi-4.nix creates for an
|
|
||||||
* FTDI bridge. Swap to ttyValidator1 (CP210x) or ttyValidator2 (CH340) to
|
|
||||||
* match the adapter actually fitted; `ls -l /dev/ttyValidator*` after
|
|
||||||
* plugging it in will say which one appeared.
|
|
||||||
*
|
|
||||||
* The dispenser block is a placeholder, not a claim. DispenserType has no
|
|
||||||
* 'none' variant and DeviceConfig requires the field, so it points at a
|
|
||||||
* path that does not exist and carries no cassettes. Cash-out is not
|
|
||||||
* available on this board until real hardware and a real path land here.
|
|
||||||
*/
|
|
||||||
rpi4: {
|
|
||||||
model: 'rpi4',
|
|
||||||
validator: {
|
|
||||||
type: 'apex',
|
|
||||||
device: '/dev/ttyValidator0',
|
|
||||||
},
|
|
||||||
dispenser: {
|
|
||||||
type: 'f56',
|
|
||||||
device: '/dev/ttyDispenser-not-fitted',
|
|
||||||
cassettes: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raspberry Pi 5 reference build (aarch64). Same shape as rpi4 — same
|
|
||||||
* validator, same absent dispenser, same udev symlinks from
|
|
||||||
* raspberry-pi-5.nix.
|
|
||||||
*/
|
|
||||||
rpi5: {
|
|
||||||
model: 'rpi5',
|
|
||||||
validator: {
|
|
||||||
type: 'apex',
|
|
||||||
device: '/dev/ttyValidator0',
|
|
||||||
},
|
|
||||||
dispenser: {
|
|
||||||
type: 'f56',
|
|
||||||
device: '/dev/ttyDispenser-not-fitted',
|
|
||||||
cassettes: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
gaia: {
|
gaia: {
|
||||||
model: 'gaia',
|
model: 'gaia',
|
||||||
validator: {
|
validator: {
|
||||||
|
|
@ -196,21 +139,11 @@ export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'
|
||||||
model: 'batm3',
|
model: 'batm3',
|
||||||
validator: {
|
validator: {
|
||||||
type: 'ebds',
|
type: 'ebds',
|
||||||
// MEI bill acceptor (EBDS) on a USB-serial bridge, exposed via the
|
device: '/dev/ttyACM0',
|
||||||
// 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',
|
|
||||||
},
|
},
|
||||||
dispenser: {
|
dispenser: {
|
||||||
type: 'f56',
|
type: 'f56',
|
||||||
// Fujitsu F56 on a USB-serial bridge, via the stable udev symlink
|
device: '/dev/ttyUSB0',
|
||||||
// /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',
|
|
||||||
cassettes: [
|
cassettes: [
|
||||||
{ denomination: 20, count: 400 },
|
{ denomination: 20, count: 400 },
|
||||||
{ denomination: 1, count: 400 },
|
{ denomination: 1, count: 400 },
|
||||||
|
|
|
||||||
|
|
@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -23,7 +23,7 @@ export interface CassetteConfig {
|
||||||
|
|
||||||
export interface HalConfig {
|
export interface HalConfig {
|
||||||
validator: {
|
validator: {
|
||||||
type: 'id003' | 'ebds' | 'apex'
|
type: 'id003' | 'ebds'
|
||||||
device: string | string[]
|
device: string | string[]
|
||||||
fiatCode: string
|
fiatCode: string
|
||||||
}
|
}
|
||||||
|
|
@ -109,12 +109,6 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
})
|
})
|
||||||
console.log('[HAL] Validator started')
|
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)
|
// Track inventory (decremented on dispense)
|
||||||
const inventory: Record<number, number> = {}
|
const inventory: Record<number, number> = {}
|
||||||
for (const cassette of dispConfig.cassettes) {
|
for (const cassette of dispConfig.cassettes) {
|
||||||
|
|
@ -212,13 +206,10 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
if (decision === 'hold') {
|
if (decision === 'hold') {
|
||||||
// Hold in escrow — caller will call stackBill() or rejectBill()
|
// Hold in escrow — caller will call stackBill() or rejectBill()
|
||||||
console.log('[HAL] Bill in escrow:', data.denomination)
|
console.log('[HAL] Bill in escrow:', data.denomination)
|
||||||
escrowDenomination = data.denomination
|
|
||||||
callbacks.onBillRead?.(data.denomination)
|
callbacks.onBillRead?.(data.denomination)
|
||||||
} else if (decision) {
|
} else if (decision) {
|
||||||
// Credit waits for the validator's stacked-confirmation
|
|
||||||
// (`billsValid`) — see the handler below.
|
|
||||||
inFlightDenomination = data.denomination
|
|
||||||
validator.stack()
|
validator.stack()
|
||||||
|
callbacks.onBillInserted(data.denomination)
|
||||||
} else {
|
} else {
|
||||||
console.log('[HAL] Bill rejected: insufficient ATM balance for', data.denomination)
|
console.log('[HAL] Bill rejected: insufficient ATM balance for', data.denomination)
|
||||||
validator.reject()
|
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 }) => {
|
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
|
||||||
escrowDenomination = null
|
|
||||||
inFlightDenomination = null
|
|
||||||
callbacks.onBillRejected(data?.reason ?? 'unknown')
|
callbacks.onBillRejected(data?.reason ?? 'unknown')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -271,19 +248,8 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
validator.lightOff()
|
validator.lightOff()
|
||||||
},
|
},
|
||||||
|
|
||||||
stackBill: () => {
|
stackBill: () => validator.stack(),
|
||||||
if (escrowDenomination === null) {
|
rejectBill: () => validator.reject(),
|
||||||
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inFlightDenomination = escrowDenomination
|
|
||||||
escrowDenomination = null
|
|
||||||
validator.stack()
|
|
||||||
},
|
|
||||||
rejectBill: () => {
|
|
||||||
escrowDenomination = null
|
|
||||||
validator.reject()
|
|
||||||
},
|
|
||||||
|
|
||||||
cleanup: async () => {
|
cleanup: async () => {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
/**
|
|
||||||
* Classify an initialization failure into a maintenance-screen sentinel
|
|
||||||
* (see App.vue's MAINTENANCE_SCREENS).
|
|
||||||
*
|
|
||||||
* Bunker failures (aiolabs/bitspire#52) get dedicated screens:
|
|
||||||
* - `NoPairingError` (fresh machine, never paired) → `unpaired` — render the
|
|
||||||
* interactive QR-pairing wizard so the operator can scan a spire-seed.
|
|
||||||
* - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) →
|
|
||||||
* `unpaired` too — re-pairing is the same scan-a-fresh-seed flow.
|
|
||||||
* - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`,
|
|
||||||
* a transient condition.
|
|
||||||
* Everything else surfaces its raw message (or the caller's fallback).
|
|
||||||
*/
|
|
||||||
export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string {
|
|
||||||
const name = (error as { name?: string } | null)?.name
|
|
||||||
if (name === 'NoPairingError') return 'unpaired'
|
|
||||||
if (name === 'BunkerRejectedError') return 'unpaired'
|
|
||||||
if (name === 'BunkerTimeoutError') return 'signer-unreachable'
|
|
||||||
return error instanceof Error ? error.message : fallback
|
|
||||||
}
|
|
||||||
|
|
@ -12,8 +12,12 @@
|
||||||
* the customer's invoice.
|
* the customer's invoice.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NostrClient, type Signer } from '@bitSpire/nostr-client'
|
import {
|
||||||
import { resolveSigner } from './signer-resolver.js'
|
NostrClient,
|
||||||
|
generateIdentity,
|
||||||
|
loadIdentityFromHex,
|
||||||
|
type MachineIdentity,
|
||||||
|
} from '@bitSpire/nostr-client'
|
||||||
import { LnbitsClient } from '@bitSpire/lnbits'
|
import { LnbitsClient } from '@bitSpire/lnbits'
|
||||||
import { CLINKClient } from '@bitSpire/clink'
|
import { CLINKClient } from '@bitSpire/clink'
|
||||||
import type { OfferRequest, ManagementRequest, ManagementResponse } 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:
|
* Environment variables:
|
||||||
* - VITE_RELAY_URL: Nostr relay WebSocket URL
|
* - VITE_RELAY_URL: Nostr relay WebSocket URL
|
||||||
* - VITE_LNBITS_SERVER_PUBKEY: LNbits nostr-transport server pubkey (hex)
|
* - VITE_LIGHTNING_PUB_PUBKEY: Lightning.Pub's Nostr pubkey (hex or npub)
|
||||||
* - VITE_SPIRE_SEED: spire pairing seed (NIP-46 bunker); see signer-resolver.ts
|
* - VITE_LIGHTNING_PUB_API_URL: Lightning.Pub HTTP API URL
|
||||||
* - VITE_OPERATOR_PUBKEYS: comma-separated operator pubkeys (hex)
|
* - 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 {
|
interface LightningConfig {
|
||||||
relayUrl: string
|
relayUrl: string
|
||||||
|
atmPrivateKey: string
|
||||||
appId: string
|
appId: string
|
||||||
operatorPubkeys: string[]
|
operatorPubkeys: string[]
|
||||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||||
|
|
@ -54,10 +60,8 @@ interface LightningConfig {
|
||||||
*/
|
*/
|
||||||
async function loadLightningConfig(): Promise<LightningConfig> {
|
async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
const defaults: LightningConfig = {
|
const defaults: LightningConfig = {
|
||||||
// Empty when unset (not the dev relay) so initializeLightningServices can
|
relayUrl: 'ws://localhost:7777',
|
||||||
// tell "operator gave us a relay" from "fall back to the pairing seed". See
|
atmPrivateKey: '',
|
||||||
// aiolabs/bitspire#70 and DEV_DEFAULT_RELAY.
|
|
||||||
relayUrl: '',
|
|
||||||
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
|
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
|
||||||
operatorPubkeys: [],
|
operatorPubkeys: [],
|
||||||
lnbitsServerPubkey: '',
|
lnbitsServerPubkey: '',
|
||||||
|
|
@ -66,8 +70,10 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
if (isElectron && window.electronAPI) {
|
if (isElectron && window.electronAPI) {
|
||||||
try {
|
try {
|
||||||
const rc = await window.electronAPI.getConfig()
|
const rc = await window.electronAPI.getConfig()
|
||||||
|
const sec = await window.electronAPI.getAtmSecrets()
|
||||||
return {
|
return {
|
||||||
relayUrl: rc.relayUrl || defaults.relayUrl,
|
relayUrl: rc.relayUrl || defaults.relayUrl,
|
||||||
|
atmPrivateKey: sec.atmPrivateKey || defaults.atmPrivateKey,
|
||||||
appId: rc.appId || defaults.appId,
|
appId: rc.appId || defaults.appId,
|
||||||
operatorPubkeys: rc.operatorPubkeys
|
operatorPubkeys: rc.operatorPubkeys
|
||||||
? rc.operatorPubkeys
|
? rc.operatorPubkeys
|
||||||
|
|
@ -84,6 +90,7 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl,
|
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,
|
appId: import.meta.env.VITE_APP_ID || defaults.appId,
|
||||||
lnbitsServerPubkey:
|
lnbitsServerPubkey:
|
||||||
(import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) ||
|
(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
|
// Config is loaded async now - will be set in initializeLightningServices
|
||||||
let CONFIG: LightningConfig
|
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.
|
/** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime.
|
||||||
* Sessions are normally cleaned up by the state machine on idle transition.
|
* 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. */
|
* 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 */
|
/** Active LNURL-withdraw session */
|
||||||
interface LnurlSession {
|
interface LnurlSession {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
/** Link ID — the management + settlement-watch key (delete/subscribe). */
|
/** Link ID for management operations (delete/update) */
|
||||||
linkId: string
|
linkId: string
|
||||||
|
uniqueHash: string
|
||||||
satsAmount: number
|
satsAmount: number
|
||||||
status: 'active' | 'claimed' | 'expired'
|
status: 'active' | 'claimed' | 'expired'
|
||||||
createdAt: number
|
createdAt: number
|
||||||
cleanup?: () => void
|
cleanup?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map of linkId -> LNURL session data. Keyed on link_id since the secure
|
/** Map of uniqueHash -> LNURL session data */
|
||||||
* `create_withdraw` response (spirekeeper#31) carries no `unique_hash`. */
|
|
||||||
const lnurlSessions = new Map<string, LnurlSession>()
|
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 {
|
function registerLnurlSession(
|
||||||
console.log('[LNURL Session] Registering:', linkId, 'for', satsAmount, 'sats')
|
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,
|
sessionId,
|
||||||
linkId,
|
linkId,
|
||||||
|
uniqueHash,
|
||||||
satsAmount,
|
satsAmount,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
createdAt: Date.now(),
|
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.
|
// Safety timeout — normally cleaned up by state machine on idle transition.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const session = lnurlSessions.get(linkId)
|
const session = lnurlSessions.get(uniqueHash)
|
||||||
if (session && session.status === 'active') {
|
if (session && session.status === 'active') {
|
||||||
console.warn('[LNURL Session] Safety timeout reached, expiring:', linkId)
|
console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash)
|
||||||
expireLnurlSession(linkId)
|
expireLnurlSession(uniqueHash)
|
||||||
}
|
}
|
||||||
}, SESSION_SAFETY_TIMEOUT_MS)
|
}, 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
|
/** Invalidate an active LNURL session by cash-in sessionId. The session's
|
||||||
* cleanup closure unsubscribes from LNbits and deletes the link. */
|
* cleanup closure unsubscribes from LNbits and deletes the link. */
|
||||||
function invalidateLnurlSessionBySessionId(sessionId: string): void {
|
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') {
|
if (session.sessionId === sessionId && session.status === 'active') {
|
||||||
console.log('[LNURL Session] Invalidating previous session:', linkId)
|
console.log('[LNURL Session] Invalidating previous session:', hash)
|
||||||
expireLnurlSession(linkId)
|
expireLnurlSession(hash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Expire a single LNURL session via its cleanup closure. */
|
/** Expire a single LNURL session via its cleanup closure. */
|
||||||
function expireLnurlSession(linkId: string): void {
|
function expireLnurlSession(uniqueHash: string): void {
|
||||||
const session = lnurlSessions.get(linkId)
|
const session = lnurlSessions.get(uniqueHash)
|
||||||
if (!session || session.status !== 'active') return
|
if (!session || session.status !== 'active') return
|
||||||
|
|
||||||
console.log('[LNURL Session] Expiring:', linkId)
|
console.log('[LNURL Session] Expiring:', uniqueHash)
|
||||||
session.status = 'expired'
|
session.status = 'expired'
|
||||||
if (session.cleanup) session.cleanup()
|
if (session.cleanup) session.cleanup()
|
||||||
setTimeout(() => lnurlSessions.delete(linkId), 60000)
|
setTimeout(() => lnurlSessions.delete(uniqueHash), 60000)
|
||||||
}
|
}
|
||||||
|
|
||||||
let _lnbitsRef: LnbitsClient | null = null
|
let _lnbitsRef: LnbitsClient | null = null
|
||||||
|
|
@ -225,7 +234,7 @@ interface LightningServices {
|
||||||
nostrClient: NostrClient
|
nostrClient: NostrClient
|
||||||
lightningPub: LightningBackend
|
lightningPub: LightningBackend
|
||||||
clink: CLINKClient
|
clink: CLINKClient
|
||||||
signer: Signer
|
identity: MachineIdentity
|
||||||
/** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */
|
/** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */
|
||||||
operatorPubkeys: string[]
|
operatorPubkeys: string[]
|
||||||
atmServices: ATMServices
|
atmServices: ATMServices
|
||||||
|
|
@ -402,85 +411,50 @@ export async function initializeLightningServices(options?: {
|
||||||
// Load configuration (async for Electron runtime config)
|
// Load configuration (async for Electron runtime config)
|
||||||
CONFIG = await loadLightningConfig()
|
CONFIG = await loadLightningConfig()
|
||||||
|
|
||||||
// Resolve the signing identity BEFORE validating the LNbits transport
|
console.log('[Lightning] Relay URL:', CONFIG.relayUrl)
|
||||||
// config. An unpaired machine must reach the QR-pairing wizard regardless
|
console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)')
|
||||||
// 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)
|
|
||||||
|
|
||||||
// Resolve the effective LNbits transport. Precedence: explicit env wins (dev
|
// Strict mode: validate config is production-ready (no localhost, no ephemeral identity)
|
||||||
// + 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).
|
|
||||||
if (options?.strict) {
|
if (options?.strict) {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
|
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) {
|
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) {
|
if (errors.length > 0) {
|
||||||
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
|
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required configuration. Reached only for a paired machine (an
|
// Validate required configuration
|
||||||
// unpaired one threw NoPairingError above) — it needs the LNbits server
|
|
||||||
// pubkey to talk to the transport, from either env or the pairing seed.
|
|
||||||
if (!CONFIG.lnbitsServerPubkey) {
|
if (!CONFIG.lnbitsServerPubkey) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' +
|
'[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' +
|
||||||
'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).',
|
'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
|
// Create Nostr client
|
||||||
const nostrClient = new NostrClient({
|
const nostrClient = new NostrClient({
|
||||||
relays: relays.map((url) => ({ url })),
|
relays: [{ url: CONFIG.relayUrl }],
|
||||||
signer,
|
identity,
|
||||||
})
|
})
|
||||||
|
|
||||||
await nostrClient.connect()
|
await nostrClient.connect()
|
||||||
|
|
@ -489,9 +463,9 @@ export async function initializeLightningServices(options?: {
|
||||||
// LNbits nostr-transport client.
|
// LNbits nostr-transport client.
|
||||||
const lnbits = new LnbitsClient({
|
const lnbits = new LnbitsClient({
|
||||||
serverPubkey: CONFIG.lnbitsServerPubkey,
|
serverPubkey: CONFIG.lnbitsServerPubkey,
|
||||||
relays,
|
relays: [CONFIG.relayUrl],
|
||||||
})
|
})
|
||||||
lnbits.initialize(nostrClient, signer)
|
lnbits.initialize(nostrClient, identity)
|
||||||
_lnbitsRef = lnbits
|
_lnbitsRef = lnbits
|
||||||
console.log('[Lightning] LNbits client initialized')
|
console.log('[Lightning] LNbits client initialized')
|
||||||
|
|
||||||
|
|
@ -505,54 +479,14 @@ export async function initializeLightningServices(options?: {
|
||||||
}
|
}
|
||||||
console.log('[Lightning] LNbits wallet:', lnbitsWalletId)
|
console.log('[Lightning] LNbits wallet:', lnbitsWalletId)
|
||||||
|
|
||||||
// #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.
|
// CLINK client — kept in tree but not actively wired into LNbits flows.
|
||||||
// operatorPubkey is the operator allowlist for kind-21003 management
|
// operatorPubkey is the operator allowlist for kind-21003 management
|
||||||
// commands; it has no Lightning.Pub dependency.
|
// commands; it has no Lightning.Pub dependency.
|
||||||
const clink = new CLINKClient({
|
const clink = new CLINKClient({
|
||||||
nostrClient,
|
nostrClient,
|
||||||
signer,
|
identity,
|
||||||
operatorPubkey: CONFIG.operatorPubkeys,
|
operatorPubkey: CONFIG.operatorPubkeys,
|
||||||
relays,
|
relays: [CONFIG.relayUrl],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Callbacks for events
|
// Callbacks for events
|
||||||
|
|
@ -640,6 +574,7 @@ export async function initializeLightningServices(options?: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const atmServices = createATMServices(
|
const atmServices = createATMServices(
|
||||||
|
identity,
|
||||||
(preimage) => {
|
(preimage) => {
|
||||||
if (paymentReceivedCallback) {
|
if (paymentReceivedCallback) {
|
||||||
paymentReceivedCallback(preimage)
|
paymentReceivedCallback(preimage)
|
||||||
|
|
@ -653,7 +588,7 @@ export async function initializeLightningServices(options?: {
|
||||||
nostrClient,
|
nostrClient,
|
||||||
lightningPub,
|
lightningPub,
|
||||||
clink,
|
clink,
|
||||||
signer,
|
identity,
|
||||||
operatorPubkeys: CONFIG.operatorPubkeys,
|
operatorPubkeys: CONFIG.operatorPubkeys,
|
||||||
atmServices,
|
atmServices,
|
||||||
onOfferRequest: (callback: OfferRequestCallback) => {
|
onOfferRequest: (callback: OfferRequestCallback) => {
|
||||||
|
|
@ -682,6 +617,7 @@ export async function initializeLightningServices(options?: {
|
||||||
* Create ATMServices implementation using the LNbits nostr-transport.
|
* Create ATMServices implementation using the LNbits nostr-transport.
|
||||||
*/
|
*/
|
||||||
function createATMServices(
|
function createATMServices(
|
||||||
|
_identity: MachineIdentity,
|
||||||
onPaymentSuccess: (preimage: string) => void,
|
onPaymentSuccess: (preimage: string) => void,
|
||||||
lnbits: LnbitsClient,
|
lnbits: LnbitsClient,
|
||||||
lnbitsWalletId: string,
|
lnbitsWalletId: string,
|
||||||
|
|
@ -725,70 +661,57 @@ function createATMServices(
|
||||||
* over nostr, we trigger dispense.
|
* over nostr, we trigger dispense.
|
||||||
*/
|
*/
|
||||||
generateLnurlWithdraw: async (context: ATMContext): Promise<string> => {
|
generateLnurlWithdraw: async (context: ATMContext): Promise<string> => {
|
||||||
// GROSS principal (fiat × rate, BEFORE commission). The server derives
|
console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats')
|
||||||
// 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})`
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (context.cashInSessionId) {
|
if (context.cashInSessionId) {
|
||||||
invalidateLnurlSessionBySessionId(context.cashInSessionId)
|
invalidateLnurlSessionBySessionId(context.cashInSessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secure cash-in: the ATM sends only the hardware-attested gross
|
const link = await lnbits.createWithdrawLink(lnbitsWalletId, {
|
||||||
// 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,
|
|
||||||
title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
|
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) {
|
if (!link.lnurl) {
|
||||||
throw new Error(
|
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()
|
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) {
|
if (context.cashInSessionId) {
|
||||||
// Track the NET (what the customer withdraws); keyed by link_id.
|
registerLnurlSession(
|
||||||
registerLnurlSession(context.cashInSessionId, link.link_id, link.net_sats)
|
context.cashInSessionId,
|
||||||
|
link.id,
|
||||||
|
link.unique_hash,
|
||||||
|
context.satsAmount,
|
||||||
|
)
|
||||||
const subId = await lnbits.subscribePayments(
|
const subId = await lnbits.subscribePayments(
|
||||||
lnbitsWalletId,
|
lnbitsWalletId,
|
||||||
{ tag: 'withdraw', link_id: link.link_id, max_seconds: 600 },
|
{ tag: 'withdraw', link_id: link.id, max_seconds: 600 },
|
||||||
(push) => {
|
(push) => {
|
||||||
console.log('[ATM Service] LNURL-withdraw claimed (LNbits 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) {
|
if (session) {
|
||||||
session.status = 'claimed'
|
session.status = 'claimed'
|
||||||
lnurlSessions.delete(link.link_id)
|
lnurlSessions.delete(link.unique_hash)
|
||||||
}
|
}
|
||||||
if (onPaymentCallback) {
|
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.
|
// 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) {
|
if (session) {
|
||||||
session.cleanup = () => {
|
session.cleanup = () => {
|
||||||
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {})
|
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {})
|
||||||
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.link_id).catch(() => {})
|
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.id).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,12 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type Signer,
|
type MachineIdentity,
|
||||||
type NostrClient,
|
type NostrClient,
|
||||||
type Event,
|
type Event,
|
||||||
createSignedEvent,
|
createSignedEvent,
|
||||||
|
decryptContentV2,
|
||||||
|
encryptContentV2,
|
||||||
validateEvent,
|
validateEvent,
|
||||||
} from '@bitSpire/nostr-client'
|
} from '@bitSpire/nostr-client'
|
||||||
|
|
||||||
|
|
@ -45,28 +47,17 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
||||||
export interface OperatorConfigServiceConfig {
|
export interface OperatorConfigServiceConfig {
|
||||||
/** Connected NostrClient — shared with the Lightning service. */
|
/** Connected NostrClient — shared with the Lightning service. */
|
||||||
nostrClient: NostrClient
|
nostrClient: NostrClient
|
||||||
/** Signer for the ATM identity. Decrypts operator events + signs the bootstrap. */
|
/** ATM's nostr identity. Used to decrypt operator events + sign the bootstrap. */
|
||||||
signer: Signer
|
identity: MachineIdentity
|
||||||
/** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */
|
/** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */
|
||||||
operatorPubkeys: string[]
|
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
|
machineId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OperatorConfigService {
|
export interface OperatorConfigService {
|
||||||
/** Unsubscribe from operator events and free resources. */
|
/** Unsubscribe from operator events and free resources. */
|
||||||
stop(): void
|
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(
|
export async function startOperatorConfigService(
|
||||||
|
|
@ -74,14 +65,14 @@ export async function startOperatorConfigService(
|
||||||
): Promise<OperatorConfigService> {
|
): Promise<OperatorConfigService> {
|
||||||
if (cfg.operatorPubkeys.length === 0) {
|
if (cfg.operatorPubkeys.length === 0) {
|
||||||
console.log('[OperatorConfig] No operator pubkeys configured — service disabled')
|
console.log('[OperatorConfig] No operator pubkeys configured — service disabled')
|
||||||
return NOOP_SERVICE
|
return { stop: () => {} }
|
||||||
}
|
}
|
||||||
if (!isElectron || !window.electronAPI) {
|
if (!isElectron || !window.electronAPI) {
|
||||||
console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)')
|
console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)')
|
||||||
return NOOP_SERVICE
|
return { stop: () => {} }
|
||||||
}
|
}
|
||||||
const api = window.electronAPI
|
const api = window.electronAPI
|
||||||
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
const machineId = cfg.machineId ?? cfg.identity.publicKey
|
||||||
|
|
||||||
// Bootstrap hello-event on first boot (best-effort — failure leaves the
|
// Bootstrap hello-event on first boot (best-effort — failure leaves the
|
||||||
// gate null so the next boot retries).
|
// gate null so the next boot retries).
|
||||||
|
|
@ -97,7 +88,7 @@ export async function startOperatorConfigService(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
kinds: [KIND_NIP78],
|
kinds: [KIND_NIP78],
|
||||||
'#p': [cfg.signer.pubkey],
|
'#p': [cfg.identity.publicKey],
|
||||||
'#d': [dTag],
|
'#d': [dTag],
|
||||||
authors: cfg.operatorPubkeys,
|
authors: cfg.operatorPubkeys,
|
||||||
},
|
},
|
||||||
|
|
@ -114,12 +105,6 @@ export async function startOperatorConfigService(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
||||||
publishCassettesState: () =>
|
|
||||||
publishCassettesState(cfg, api, machineId)
|
|
||||||
.then(() => {})
|
|
||||||
.catch((err) => {
|
|
||||||
console.warn('[OperatorConfig] cassettes-state republish failed:', err)
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +150,7 @@ async function handleOperatorConfigEvent(
|
||||||
// 4. Decrypt content (NIP-44 v2).
|
// 4. Decrypt content (NIP-44 v2).
|
||||||
let parsed: { positions: Record<string, { denomination: number; count: number }> }
|
let parsed: { positions: Record<string, { denomination: number; count: number }> }
|
||||||
try {
|
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
|
parsed = JSON.parse(plaintext) as typeof parsed
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[OperatorConfig] Decrypt/parse failed:', err)
|
console.error('[OperatorConfig] Decrypt/parse failed:', err)
|
||||||
|
|
@ -210,63 +195,8 @@ async function handleOperatorConfigEvent(
|
||||||
console.log(
|
console.log(
|
||||||
`[OperatorConfig] Applied — created_at=${event.created_at}, positions=${Object.keys(parsed.positions).join(',')}`
|
`[OperatorConfig] Applied — created_at=${event.created_at}, positions=${Object.keys(parsed.positions).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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 and on a cassette reload so the operator view tracks reality, not
|
|
||||||
* the frozen bootstrap snapshot (coord 2026-06-21 / lamassu-next#56).
|
|
||||||
*
|
|
||||||
* NOT gated on the bootstrap flag — this is the live update. Returns whether an
|
|
||||||
* event was published (false when there are no cassettes / no operator).
|
|
||||||
*/
|
|
||||||
async function publishCassettesState(
|
|
||||||
cfg: OperatorConfigServiceConfig,
|
|
||||||
api: NonNullable<typeof window.electronAPI>,
|
|
||||||
machineId: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
const cassettes = await api.loadCassettes()
|
|
||||||
if (cassettes.length === 0) return false
|
|
||||||
const operatorPubkey = cfg.operatorPubkeys[0]
|
|
||||||
if (!operatorPubkey) return false
|
|
||||||
|
|
||||||
const positions: Record<string, { denomination: number; count: number }> = {}
|
|
||||||
for (const c of cassettes) {
|
|
||||||
positions[String(c.position)] = { denomination: c.denomination, count: c.count }
|
|
||||||
}
|
|
||||||
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions }))
|
|
||||||
|
|
||||||
const dTag = atmStateDTag(machineId)
|
|
||||||
const event = await createSignedEvent(cfg.signer, {
|
|
||||||
kind: KIND_NIP78,
|
|
||||||
content: ciphertext,
|
|
||||||
tags: [
|
|
||||||
['d', dTag],
|
|
||||||
['p', operatorPubkey],
|
|
||||||
],
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
})
|
|
||||||
|
|
||||||
await cfg.nostrClient.publish(event)
|
|
||||||
console.log('[OperatorConfig] cassettes-state published:', { dTag, eventId: event.id })
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First-boot hello: publish the cassette state once and mark the gate. The
|
|
||||||
* gate (lamassu-next#56) prevents re-emitting the *bootstrap* on every boot;
|
|
||||||
* live updates after dispenses go through `publishCassettesState` directly.
|
|
||||||
*/
|
|
||||||
async function maybePublishBootstrap(
|
async function maybePublishBootstrap(
|
||||||
cfg: OperatorConfigServiceConfig,
|
cfg: OperatorConfigServiceConfig,
|
||||||
api: NonNullable<typeof window.electronAPI>,
|
api: NonNullable<typeof window.electronAPI>,
|
||||||
|
|
@ -277,11 +207,36 @@ async function maybePublishBootstrap(
|
||||||
console.log('[OperatorConfig] Bootstrap already published at unix', already)
|
console.log('[OperatorConfig] Bootstrap already published at unix', already)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const published = await publishCassettesState(cfg, api, machineId)
|
const cassettes = await api.loadCassettes()
|
||||||
if (published) {
|
if (cassettes.length === 0) {
|
||||||
await api.markBootstrapPublished(Math.floor(Date.now() / 1000))
|
console.log('[OperatorConfig] state.db.cassettes empty — skipping bootstrap')
|
||||||
console.log('[OperatorConfig] Bootstrap hello-event published')
|
return
|
||||||
} else {
|
|
||||||
console.log('[OperatorConfig] No cassettes/operator — skipping bootstrap')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const operatorPubkey = cfg.operatorPubkeys[0]
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
const ciphertext = encryptContentV2(cfg.identity, operatorPubkey, { positions })
|
||||||
|
|
||||||
|
const dTag = atmStateDTag(machineId)
|
||||||
|
const event = createSignedEvent(cfg.identity, {
|
||||||
|
kind: KIND_NIP78,
|
||||||
|
content: ciphertext,
|
||||||
|
tags: [
|
||||||
|
['d', dTag],
|
||||||
|
['p', operatorPubkey],
|
||||||
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
await cfg.nostrClient.publish(event)
|
||||||
|
await api.markBootstrapPublished(Math.floor(Date.now() / 1000))
|
||||||
|
console.log('[OperatorConfig] Bootstrap hello-event published:', { dTag, eventId: event.id })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type Signer,
|
type MachineIdentity,
|
||||||
type NostrClient,
|
type NostrClient,
|
||||||
type Event,
|
type Event,
|
||||||
|
decryptContentV2,
|
||||||
validateEvent,
|
validateEvent,
|
||||||
} from '@bitSpire/nostr-client'
|
} from '@bitSpire/nostr-client'
|
||||||
|
|
||||||
|
|
@ -79,11 +80,11 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
||||||
export interface OperatorFeesServiceConfig {
|
export interface OperatorFeesServiceConfig {
|
||||||
/** Connected NostrClient — shared with the Lightning service. */
|
/** Connected NostrClient — shared with the Lightning service. */
|
||||||
nostrClient: NostrClient
|
nostrClient: NostrClient
|
||||||
/** Signer for the ATM identity. Decrypts operator events. */
|
/** ATM's nostr identity. Used to decrypt operator events. */
|
||||||
signer: Signer
|
identity: MachineIdentity
|
||||||
/** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */
|
/** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */
|
||||||
operatorPubkeys: string[]
|
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
|
machineId?: string
|
||||||
/**
|
/**
|
||||||
* Called when a valid fee-config event is applied. Renderer should
|
* Called when a valid fee-config event is applied. Renderer should
|
||||||
|
|
@ -111,7 +112,7 @@ export async function startOperatorFeesService(
|
||||||
return { stop: () => {} }
|
return { stop: () => {} }
|
||||||
}
|
}
|
||||||
const api = window.electronAPI
|
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.
|
// Subscribe to operator-published fee config events.
|
||||||
const dTag = feeConfigDTag(machineId)
|
const dTag = feeConfigDTag(machineId)
|
||||||
|
|
@ -119,7 +120,7 @@ export async function startOperatorFeesService(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
kinds: [KIND_NIP78],
|
kinds: [KIND_NIP78],
|
||||||
'#p': [cfg.signer.pubkey],
|
'#p': [cfg.identity.publicKey],
|
||||||
'#d': [dTag],
|
'#d': [dTag],
|
||||||
authors: cfg.operatorPubkeys,
|
authors: cfg.operatorPubkeys,
|
||||||
},
|
},
|
||||||
|
|
@ -188,7 +189,7 @@ async function handleFeeConfigEvent(
|
||||||
// fields (v2 forward-compat — future promo payloads).
|
// fields (v2 forward-compat — future promo payloads).
|
||||||
let parsed: ParsedFeePayload
|
let parsed: ParsedFeePayload
|
||||||
try {
|
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>
|
const raw = JSON.parse(plaintext) as Record<string, unknown>
|
||||||
parsed = parseV1Payload(raw)
|
parsed = parseV1Payload(raw)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
||||||
import { ingestScannedSeed } from '../ingest'
|
|
||||||
import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
|
|
||||||
import { npubEncode } from 'nostr-tools/nip19'
|
|
||||||
|
|
||||||
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
|
||||||
function makeSeed(json: unknown): string {
|
|
||||||
const b64 = Buffer.from(JSON.stringify(json), 'utf8')
|
|
||||||
.toString('base64')
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/, '')
|
|
||||||
return SPIRE_SEED_SCHEME + b64
|
|
||||||
}
|
|
||||||
|
|
||||||
const SPIRE_PUBKEY = 'a'.repeat(64)
|
|
||||||
const VALID_SEED = makeSeed({
|
|
||||||
v: 1,
|
|
||||||
spire_npub: npubEncode(SPIRE_PUBKEY),
|
|
||||||
lnbits_npub: npubEncode('b'.repeat(64)),
|
|
||||||
bunker_secret: 'deadbeef',
|
|
||||||
relays: ['wss://events.relay/'],
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ingestScannedSeed', () => {
|
|
||||||
const originalWindow = globalThis.window
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.window = originalWindow
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a non-seed scan without touching the bridge', async () => {
|
|
||||||
const saveSpireSeed = vi.fn()
|
|
||||||
globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
|
|
||||||
|
|
||||||
const result = await ingestScannedSeed('https://example.com/not-a-seed')
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.reason).toBe('invalid-seed')
|
|
||||||
expect(saveSpireSeed).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports no-bridge when Electron is absent', async () => {
|
|
||||||
globalThis.window = {} as unknown as Window & typeof globalThis
|
|
||||||
const result = await ingestScannedSeed(VALID_SEED)
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.reason).toBe('no-bridge')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('persists the seed and relaunches on a valid scan', async () => {
|
|
||||||
const saveSpireSeed = vi.fn().mockResolvedValue(undefined)
|
|
||||||
const relaunchApp = vi.fn().mockResolvedValue(undefined)
|
|
||||||
globalThis.window = {
|
|
||||||
electronAPI: { saveSpireSeed, relaunchApp },
|
|
||||||
} as unknown as Window & typeof globalThis
|
|
||||||
|
|
||||||
const result = await ingestScannedSeed(` ${VALID_SEED} `) // tolerate whitespace
|
|
||||||
expect(result.ok).toBe(true)
|
|
||||||
if (result.ok) expect(result.spirePubkey).toBe(SPIRE_PUBKEY)
|
|
||||||
expect(saveSpireSeed).toHaveBeenCalledWith(VALID_SEED)
|
|
||||||
expect(relaunchApp).toHaveBeenCalledOnce()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('surfaces persist-failed when saveSpireSeed throws', async () => {
|
|
||||||
const saveSpireSeed = vi.fn().mockRejectedValue(new Error('EACCES'))
|
|
||||||
globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
|
|
||||||
|
|
||||||
const result = await ingestScannedSeed(VALID_SEED)
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.reason).toBe('persist-failed')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ingest does not pair in-renderer', () => {
|
|
||||||
it('never imports connect logic — persistence + relaunch only', () => {
|
|
||||||
// Guard: the design intentionally reuses the boot-time pairing path.
|
|
||||||
// If someone wires connectNewSeed here, this comment + the ingest source
|
|
||||||
// should be revisited together.
|
|
||||||
expect(ingestScannedSeed).toBeTypeOf('function')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
/**
|
|
||||||
* Pairing module surface (aiolabs/bitspire#52).
|
|
||||||
*
|
|
||||||
* `availablePairingSources()` probes each known source and returns those the
|
|
||||||
* current device can actually run, in preference order (camera first, NFC if
|
|
||||||
* present). The wizard renders the first available source and offers the rest
|
|
||||||
* as alternates.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { QrPairingSource } from './qr-source'
|
|
||||||
import { NfcPairingSource } from './nfc-source'
|
|
||||||
import type { PairingSource } from './types'
|
|
||||||
|
|
||||||
export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types'
|
|
||||||
export { QrPairingSource } from './qr-source'
|
|
||||||
export { NfcPairingSource } from './nfc-source'
|
|
||||||
export { ingestScannedSeed, parseScannedSeed } from './ingest'
|
|
||||||
export type { IngestResult, SeedPreview } from './ingest'
|
|
||||||
export { testRelay } from './relay-test'
|
|
||||||
export type { RelayTestResult } from './relay-test'
|
|
||||||
|
|
||||||
/** All sources in preference order, regardless of availability. */
|
|
||||||
export function allPairingSources(): PairingSource[] {
|
|
||||||
return [new QrPairingSource(), new NfcPairingSource()]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Only the sources this device can run, in preference order. */
|
|
||||||
export async function availablePairingSources(): Promise<PairingSource[]> {
|
|
||||||
const sources = allPairingSources()
|
|
||||||
const flags = await Promise.all(sources.map((s) => s.isAvailable()))
|
|
||||||
return sources.filter((_, i) => flags[i])
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
/**
|
|
||||||
* Seed ingest pipeline (aiolabs/bitspire#52).
|
|
||||||
*
|
|
||||||
* Turns a raw scanned payload into a paired machine. The wizard captures a
|
|
||||||
* string off some PairingSource and hands it here; we:
|
|
||||||
* 1. validate it parses as a spire-seed (reject anything else — a QR on the
|
|
||||||
* counter, a URL, a different protocol),
|
|
||||||
* 2. persist it as VITE_SPIRE_SEED via the Electron bridge,
|
|
||||||
* 3. relaunch so the normal boot path (signer-resolver → connectNewSeed)
|
|
||||||
* performs the actual bunker pairing.
|
|
||||||
*
|
|
||||||
* We do NOT pair in-renderer here: persisting + relaunching reuses the single,
|
|
||||||
* hardware-tested pairing path rather than duplicating connect/redeem logic in
|
|
||||||
* the wizard. The trade-off is a ~kiosk-restart of latency, which is fine for a
|
|
||||||
* one-time provisioning step.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { parseSpireSeed, seedFingerprint } from '@bitSpire/nostr-client'
|
|
||||||
|
|
||||||
export type IngestResult =
|
|
||||||
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
|
|
||||||
| { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string }
|
|
||||||
|
|
||||||
export type SeedPreview =
|
|
||||||
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
|
|
||||||
| { ok: false; reason: 'invalid-seed'; message: string }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate-only: parse a scanned payload as a spire-seed WITHOUT persisting or
|
|
||||||
* relaunching. The wizard uses this to show a review step (decoded relay + a
|
|
||||||
* "test relay" button) before committing, so a well-formed but unreachable
|
|
||||||
* relay is caught before the machine relaunches into a pairing crash-loop.
|
|
||||||
* `parseSpireSeed` already rejects a malformed relay (e.g. a QR misread of
|
|
||||||
* `ws://` → `As://`); this surfaces that as an invalid-seed rejection.
|
|
||||||
*/
|
|
||||||
export function parseScannedSeed(raw: string): SeedPreview {
|
|
||||||
const trimmed = (raw || '').trim()
|
|
||||||
try {
|
|
||||||
const seed = parseSpireSeed(trimmed)
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
spirePubkey: seed.spirePubkey,
|
|
||||||
fingerprint: seedFingerprint(trimmed),
|
|
||||||
relays: seed.relays,
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
reason: 'invalid-seed',
|
|
||||||
message: e instanceof Error ? e.message : 'Not a valid pairing code',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ingestScannedSeed(raw: string): Promise<IngestResult> {
|
|
||||||
const trimmed = (raw || '').trim()
|
|
||||||
|
|
||||||
let spirePubkey: string
|
|
||||||
let relays: string[]
|
|
||||||
try {
|
|
||||||
const seed = parseSpireSeed(trimmed)
|
|
||||||
spirePubkey = seed.spirePubkey
|
|
||||||
relays = seed.relays
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
reason: 'invalid-seed',
|
|
||||||
message: e instanceof Error ? e.message : 'Not a valid pairing code',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window === 'undefined' || !window.electronAPI) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
reason: 'no-bridge',
|
|
||||||
message: 'Pairing must run on the machine (no kiosk bridge available).',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await window.electronAPI.saveSpireSeed(trimmed)
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
reason: 'persist-failed',
|
|
||||||
message: e instanceof Error ? e.message : 'Could not save the pairing.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire-and-forget: the relaunch tears this process down.
|
|
||||||
void window.electronAPI.relaunchApp()
|
|
||||||
|
|
||||||
return { ok: true, spirePubkey, fingerprint: seedFingerprint(trimmed), relays }
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
/**
|
|
||||||
* NFC pairing source — SCAFFOLD (aiolabs/bitspire#52).
|
|
||||||
*
|
|
||||||
* The user flagged NFC as a plausible future pairing method (tap a tag/phone
|
|
||||||
* carrying the spire-seed). This wires the seam against the Web NFC API
|
|
||||||
* (`NDEFReader`) so a future build can light it up without reworking the
|
|
||||||
* wizard. It is NOT active on current hardware: Web NFC ships only on Chrome
|
|
||||||
* for Android, so `isAvailable()` returns false on the Sintra's Linux Electron
|
|
||||||
* and the wizard simply won't offer it.
|
|
||||||
*
|
|
||||||
* When real NFC hardware lands (likely a HAL peripheral rather than Web NFC),
|
|
||||||
* replace the body of `start()` with that driver — the PairingSource contract
|
|
||||||
* stays the same.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
|
|
||||||
|
|
||||||
// Minimal structural type for the Web NFC API (not in lib.dom for Electron).
|
|
||||||
interface NDEFReaderLike {
|
|
||||||
scan(): Promise<void>
|
|
||||||
addEventListener(
|
|
||||||
type: 'reading',
|
|
||||||
listener: (event: { message: { records: Array<{ recordType: string; data?: BufferSource }> } }) => void
|
|
||||||
): void
|
|
||||||
addEventListener(type: 'readingerror', listener: (event: unknown) => void): void
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNDEFReaderCtor(): (new () => NDEFReaderLike) | null {
|
|
||||||
const ctor = (globalThis as { NDEFReader?: new () => NDEFReaderLike }).NDEFReader
|
|
||||||
return ctor ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NfcPairingSource implements PairingSource {
|
|
||||||
readonly kind = 'nfc' as const
|
|
||||||
readonly label = 'NFC tap'
|
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
|
||||||
return getNDEFReaderCtor() !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(opts: PairingSourceStartOptions): Promise<StopCapture> {
|
|
||||||
const Ctor = getNDEFReaderCtor()
|
|
||||||
if (!Ctor) throw new Error('Web NFC unavailable on this device')
|
|
||||||
|
|
||||||
const reader = new Ctor()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let stopped = false
|
|
||||||
|
|
||||||
reader.addEventListener('reading', (event) => {
|
|
||||||
if (stopped) return
|
|
||||||
for (const record of event.message.records) {
|
|
||||||
if (record.recordType === 'text' && record.data) {
|
|
||||||
const raw = decoder.decode(record.data).trim()
|
|
||||||
if (raw) opts.onScan(raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
reader.addEventListener('readingerror', (e) => opts.onError?.(e))
|
|
||||||
|
|
||||||
await reader.scan()
|
|
||||||
// Web NFC has no explicit stop; the AbortController form would, but the
|
|
||||||
// scaffold just flips a guard so late events are ignored after teardown.
|
|
||||||
return () => {
|
|
||||||
stopped = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
/**
|
|
||||||
* Camera-based QR pairing source (aiolabs/bitspire#52).
|
|
||||||
*
|
|
||||||
* Decodes with `qr` (paulmillr) — a zero-dependency, auditable, dual
|
|
||||||
* MIT/Apache library from the same author as the `@noble`/`@scure` crypto our
|
|
||||||
* nostr stack already trusts (chosen over the dormant `jsqr` for that ethos +
|
|
||||||
* active maintenance). Its `qr/dom.js` browser helper wraps getUserMedia and
|
|
||||||
* the per-frame decode loop, so this source is a thin adapter onto the
|
|
||||||
* PairingSource contract.
|
|
||||||
*
|
|
||||||
* The first successful decode wins; the loop then stops itself so a single
|
|
||||||
* seed isn't ingested repeatedly.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js'
|
|
||||||
import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
|
|
||||||
|
|
||||||
export class QrPairingSource implements PairingSource {
|
|
||||||
readonly kind = 'qr' as const
|
|
||||||
readonly label = 'Camera'
|
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
|
||||||
return (
|
|
||||||
typeof navigator !== 'undefined' &&
|
|
||||||
!!navigator.mediaDevices &&
|
|
||||||
typeof navigator.mediaDevices.getUserMedia === 'function'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(opts: PairingSourceStartOptions): Promise<StopCapture> {
|
|
||||||
const { onScan, onError, video } = opts
|
|
||||||
if (!video) throw new Error('QrPairingSource requires a <video> element')
|
|
||||||
|
|
||||||
const camera = await frontalCamera(video)
|
|
||||||
|
|
||||||
// `frontalCamera` requests `ideal: screen.{width,height}`, so on the kiosk's
|
|
||||||
// 1280x800 panel it otherwise streams at ~720p — and `decodeQR` then
|
|
||||||
// center-crops to a square, leaving too few pixels-per-module for a dense
|
|
||||||
// spire-seed QR on this fixed-focus lens. Pin a deliberate 1280x960 capture
|
|
||||||
// instead: lamassu-machine caps QR scanning at 640x480 for decode speed
|
|
||||||
// (megapixels just slow the per-frame decode), but our seed QR is denser
|
|
||||||
// than a lightning invoice, so 1280x960 is the balance — ~14-18px/module at
|
|
||||||
// frame-fill, still fast, and it meters exposure better than maxing the
|
|
||||||
// sensor (a frame-filling QR keeps auto-exposure from blowing out on a
|
|
||||||
// bright phone screen). The stream lives on the <video>'s srcObject
|
|
||||||
// (QRCamera.stream is private); soft `ideal` so a camera that can't honor
|
|
||||||
// it degrades to its closest mode instead of throwing.
|
|
||||||
try {
|
|
||||||
const stream = video.srcObject
|
|
||||||
if (stream instanceof MediaStream) {
|
|
||||||
await stream.getVideoTracks()[0]?.applyConstraints({
|
|
||||||
width: { ideal: 1280 },
|
|
||||||
height: { ideal: 960 },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
onError?.(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = new QRCanvas() // decode-only; no overlay canvases needed
|
|
||||||
|
|
||||||
let stopped = false
|
|
||||||
let cancel: (() => void) | null = null
|
|
||||||
const stop: StopCapture = () => {
|
|
||||||
if (stopped) return
|
|
||||||
stopped = true
|
|
||||||
cancel?.()
|
|
||||||
camera.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel = frameLoop(() => {
|
|
||||||
if (stopped) return
|
|
||||||
try {
|
|
||||||
// `fullSize: true` decodes the camera's intrinsic frame (videoWidth ×
|
|
||||||
// videoHeight) rather than the <video> element's CSS box — the default
|
|
||||||
// (`false`) was decoding the few-hundred-px on-screen preview, which
|
|
||||||
// (compounded by `object-cover` cropping) starved the decoder.
|
|
||||||
const result = camera.readFrame(canvas, true)
|
|
||||||
if (result) {
|
|
||||||
stop()
|
|
||||||
onScan(result)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
onError?.(e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return stop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
/**
|
|
||||||
* Relay reachability probe for the pairing wizard (aiolabs/bitspire#70).
|
|
||||||
*
|
|
||||||
* `parseSpireSeed` catches a MALFORMED relay (e.g. a QR misread of `ws://` into
|
|
||||||
* `As://`), but a well-formed-yet-unreachable relay — `ws://localhost:…` baked
|
|
||||||
* into a seed for a remote machine, a wrong LAN IP, or a relay that's simply
|
|
||||||
* down — still parses fine and would only fail later as a NIP-46 connect
|
|
||||||
* crash-loop. This opens a WebSocket to the relay (and sends a NIP-01 REQ so a
|
|
||||||
* real relay answers) so the operator can confirm reachability on-machine,
|
|
||||||
* before committing the pairing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface RelayTestResult {
|
|
||||||
url: string
|
|
||||||
ok: boolean
|
|
||||||
/** Round-trip time to open (ms), when reachable. */
|
|
||||||
ms?: number
|
|
||||||
/** True when the relay answered our REQ — i.e. it's actually a nostr relay. */
|
|
||||||
answered?: boolean
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open a WebSocket to `url` and report whether it connects within `timeoutMs`. */
|
|
||||||
export function testRelay(url: string, timeoutMs = 6000): Promise<RelayTestResult> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const start = Date.now()
|
|
||||||
let ws: WebSocket | null = null
|
|
||||||
let settled = false
|
|
||||||
|
|
||||||
const finish = (r: Omit<RelayTestResult, 'url'>): void => {
|
|
||||||
if (settled) return
|
|
||||||
settled = true
|
|
||||||
clearTimeout(timer)
|
|
||||||
try {
|
|
||||||
ws?.close()
|
|
||||||
} catch {
|
|
||||||
/* already closing */
|
|
||||||
}
|
|
||||||
resolve({ url, ...r })
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(
|
|
||||||
() => finish({ ok: false, error: `timed out after ${timeoutMs}ms` }),
|
|
||||||
timeoutMs,
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
ws = new WebSocket(url)
|
|
||||||
} catch (e) {
|
|
||||||
finish({ ok: false, error: e instanceof Error ? e.message : 'invalid relay URL' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
// Connected. Probe it as a nostr relay; a genuine relay replies (EOSE /
|
|
||||||
// notice). If it stays silent we still count the open as reachable.
|
|
||||||
try {
|
|
||||||
ws?.send(JSON.stringify(['REQ', 'bitspire-relay-test', { limit: 0 }]))
|
|
||||||
} catch {
|
|
||||||
/* send failed, but the socket opened → still reachable */
|
|
||||||
}
|
|
||||||
const graceMs = Math.min(600, timeoutMs)
|
|
||||||
setTimeout(() => finish({ ok: true, ms: Date.now() - start, answered: false }), graceMs)
|
|
||||||
}
|
|
||||||
ws.onmessage = () => finish({ ok: true, ms: Date.now() - start, answered: true })
|
|
||||||
ws.onerror = () =>
|
|
||||||
finish({ ok: false, error: 'connection failed (unreachable or not a relay)' })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
/**
|
|
||||||
* Pairing-source abstraction (aiolabs/bitspire#52).
|
|
||||||
*
|
|
||||||
* A fresh ATM is paired by getting a `spire-seed:v1:…` onto the device. The
|
|
||||||
* operator's spirekeeper mints that seed and renders it as a QR (and, later,
|
|
||||||
* possibly an NFC tag). The machine ingests it via whatever capture hardware
|
|
||||||
* it has — today a camera, tomorrow maybe an NFC reader or a HAL barcode
|
|
||||||
* scanner. `PairingSource` is the seam that keeps the wizard UI and the
|
|
||||||
* ingest pipeline agnostic to *how* the seed arrived.
|
|
||||||
*
|
|
||||||
* Implementations live next to this file: `qr-source.ts` (camera + jsQR),
|
|
||||||
* `nfc-source.ts` (Web NFC scaffold). A HAL-scanner source can be added the
|
|
||||||
* same way without touching the wizard.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type PairingSourceKind = 'qr' | 'nfc'
|
|
||||||
|
|
||||||
export interface PairingSourceStartOptions {
|
|
||||||
/** Invoked with each decoded payload (the raw seed string). */
|
|
||||||
onScan: (raw: string) => void
|
|
||||||
/** Invoked on a non-fatal capture error (e.g. a frame decode glitch). */
|
|
||||||
onError?: (error: unknown) => void
|
|
||||||
/**
|
|
||||||
* The <video> element the camera preview renders into. Required by
|
|
||||||
* camera-based sources; ignored by sources that don't show a viewfinder
|
|
||||||
* (e.g. NFC).
|
|
||||||
*/
|
|
||||||
video?: HTMLVideoElement
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Releases capture hardware (camera stream, NFC reader). Idempotent. */
|
|
||||||
export type StopCapture = () => void
|
|
||||||
|
|
||||||
export interface PairingSource {
|
|
||||||
readonly kind: PairingSourceKind
|
|
||||||
/** Short label for the wizard's source picker (e.g. "Camera", "NFC tap"). */
|
|
||||||
readonly label: string
|
|
||||||
/** Whether this source can run in the current environment. */
|
|
||||||
isAvailable(): Promise<boolean>
|
|
||||||
/** Begin capturing; resolves once hardware is live. */
|
|
||||||
start(opts: PairingSourceStartOptions): Promise<StopCapture>
|
|
||||||
}
|
|
||||||
|
|
@ -1,191 +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
|
|
||||||
* bootstrap gate so the (possibly new) operator gets a hello-event (#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.resetBootstrapGate()
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
@ -10,9 +10,14 @@ import {
|
||||||
type ATMMachine,
|
type ATMMachine,
|
||||||
} from '@bitSpire/state-machine'
|
} from '@bitSpire/state-machine'
|
||||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||||
import { classifyInitError } from '@/services/init-error'
|
import {
|
||||||
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
|
startOperatorConfigService,
|
||||||
import { startOperatorFeesService, type OperatorFeesService } from '@/services/operator-fees'
|
type OperatorConfigService,
|
||||||
|
} from '@/services/operator-config'
|
||||||
|
import {
|
||||||
|
startOperatorFeesService,
|
||||||
|
type OperatorFeesService,
|
||||||
|
} from '@/services/operator-fees'
|
||||||
import type { HalConfig, HalServices } from '@/services/hal'
|
import type { HalConfig, HalServices } from '@/services/hal'
|
||||||
import type { MachineModel } from '@/config'
|
import type { MachineModel } from '@/config'
|
||||||
import type { LightningBackend } from '@/services/lightning'
|
import type { LightningBackend } from '@/services/lightning'
|
||||||
|
|
@ -46,8 +51,7 @@ function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number {
|
||||||
`Unit fraction expected (0.05 = 5%), not a percentage.`
|
`Unit fraction expected (0.05 = 5%), not a percentage.`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const principalSats =
|
const principalSats = ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
|
||||||
ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
|
|
||||||
const feeSats = isCashIn
|
const feeSats = isCashIn
|
||||||
? principalSats - ctx.satsAmount // cash-in: customer receives less than principal
|
? principalSats - ctx.satsAmount // cash-in: customer receives less than principal
|
||||||
: ctx.satsAmount - principalSats // cash-out: customer pays more than principal
|
: ctx.satsAmount - principalSats // cash-out: customer pays more than principal
|
||||||
|
|
@ -286,11 +290,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
const debugMode = ref(true)
|
const debugMode = ref(true)
|
||||||
const allowMockFallback = ref(true) // default true for browser dev
|
const allowMockFallback = ref(true) // default true for browser dev
|
||||||
const initError = ref<string | null>(null) // fatal error → maintenance screen
|
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)
|
|
||||||
const fiatCode = ref('USD')
|
const fiatCode = ref('USD')
|
||||||
// Defaults are 0 — the operator's fee config (received via Nostr
|
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||||
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
||||||
|
|
@ -458,7 +457,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
actor.value.subscribe((newSnapshot: SnapshotFrom<ATMMachine>) => {
|
actor.value.subscribe((newSnapshot: SnapshotFrom<ATMMachine>) => {
|
||||||
const prevSnapshot = snapshot.value
|
const prevSnapshot = snapshot.value
|
||||||
snapshot.value = newSnapshot
|
snapshot.value = newSnapshot
|
||||||
console.log('[ATM] State:', JSON.stringify(newSnapshot.value))
|
console.log('[ATM] State:', newSnapshot.value)
|
||||||
|
|
||||||
// Detect transition into a complete state
|
// Detect transition into a complete state
|
||||||
const state = newSnapshot.value
|
const state = newSnapshot.value
|
||||||
|
|
@ -521,10 +520,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
bills,
|
bills,
|
||||||
cassettes: dr?.cassettes,
|
cassettes: dr?.cassettes,
|
||||||
error: dr?.error ?? ctx.error,
|
error: dr?.error ?? ctx.error,
|
||||||
})
|
}).then(() => reloadPersistedInventory())
|
||||||
.then(() => reloadPersistedInventory())
|
|
||||||
// Republish cassette state — a partial dispense changed counts.
|
|
||||||
.then(() => operatorConfigSvc?.publishCassettesState())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -554,135 +550,18 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
bills,
|
bills,
|
||||||
cassettes: dr?.cassettes,
|
cassettes: dr?.cassettes,
|
||||||
error: dr?.error,
|
error: dr?.error,
|
||||||
})
|
}).then(() => reloadPersistedInventory())
|
||||||
.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()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
|
||||||
boltCardProcessing.value = false
|
|
||||||
nfcStatus.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
prevNestedState = currentNested
|
prevNestedState = currentNested
|
||||||
})
|
})
|
||||||
|
|
||||||
// Start the machine
|
// Start the machine
|
||||||
actor.value.start()
|
actor.value.start()
|
||||||
setupNfcListener()
|
|
||||||
console.log('[ATM] State machine initialized')
|
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(lnurlw: string) {
|
|
||||||
if (nestedState.value !== 'displayingInvoice') return
|
|
||||||
const invoice = context.value?.invoice
|
|
||||||
if (!invoice) return
|
|
||||||
if (boltCardProcessing.value) return // one pull at a time
|
|
||||||
boltCardProcessing.value = true
|
|
||||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
|
||||||
try {
|
|
||||||
const amountMsat = (context.value?.satsAmount ?? 0) * 1000
|
|
||||||
const res = await window.electronAPI!.lnurlWithdraw({ lnurlw, bolt11: invoice, amountMsat })
|
|
||||||
if (res.ok) {
|
|
||||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
|
|
||||||
} else {
|
|
||||||
boltCardProcessing.value = false
|
|
||||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' }
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[ATM] Bolt Card withdraw failed:', e)
|
|
||||||
boltCardProcessing.value = false
|
|
||||||
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(lnurlw: string) {
|
|
||||||
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return
|
|
||||||
const amountSats = context.value?.satsAmount ?? 0
|
|
||||||
if (amountSats <= 0) return
|
|
||||||
if (boltCardProcessing.value) return // one at a time
|
|
||||||
boltCardProcessing.value = true
|
|
||||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
|
||||||
try {
|
|
||||||
const amountMsat = amountSats * 1000
|
|
||||||
const res = await window.electronAPI!.resolveCardInvoice({ lnurlw, amountMsat })
|
|
||||||
if (!res.ok || !res.bolt11) {
|
|
||||||
boltCardProcessing.value = false
|
|
||||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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' }
|
|
||||||
}
|
|
||||||
// On success payInvoice fires PAYMENT_RECEIVED; state leaves displayingQR
|
|
||||||
// and the subscribe-cleanup above resets nfcStatus/boltCardProcessing.
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[ATM] Bolt Card receive failed:', e)
|
|
||||||
boltCardProcessing.value = false
|
|
||||||
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
|
||||||
function setupNfcListener() {
|
|
||||||
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
|
||||||
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
|
||||||
// Route the same physical tap by flow: cash-out pulls, cash-in receives.
|
|
||||||
if (isCashOut.value && nestedState.value === 'displayingInvoice') {
|
|
||||||
void handleBoltCardTap(lnurlw)
|
|
||||||
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
|
|
||||||
void handleBoltCardReceive(lnurlw)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
window.electronAPI.onNfcStatus?.((status) => {
|
|
||||||
// Only surface reader status on a tap screen, and don't clobber an
|
|
||||||
// in-flight tap's message.
|
|
||||||
const onTapScreen =
|
|
||||||
(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group an array of inserted bill denominations into { denomination, count } pairs.
|
* Group an array of inserted bill denominations into { denomination, count } pairs.
|
||||||
*/
|
*/
|
||||||
|
|
@ -797,14 +676,14 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
initialize(servicesWithInventory)
|
initialize(servicesWithInventory)
|
||||||
|
|
||||||
// Start broadcasting availability (Kind 30078) with 5-minute heartbeat
|
// 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
|
// Start operator-config consumer (aiolabs/lamassu-next#56) — subscribes
|
||||||
// to kind-30078 cassette config events + publishes one-shot bootstrap
|
// to kind-30078 cassette config events + publishes one-shot bootstrap
|
||||||
operatorConfigSvc?.stop()
|
operatorConfigSvc?.stop()
|
||||||
operatorConfigSvc = await startOperatorConfigService({
|
operatorConfigSvc = await startOperatorConfigService({
|
||||||
nostrClient: services.nostrClient,
|
nostrClient: services.nostrClient,
|
||||||
signer: services.signer,
|
identity: services.identity,
|
||||||
operatorPubkeys: services.operatorPubkeys,
|
operatorPubkeys: services.operatorPubkeys,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -813,7 +692,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
operatorFeesSvc?.stop()
|
operatorFeesSvc?.stop()
|
||||||
operatorFeesSvc = await startOperatorFeesService({
|
operatorFeesSvc = await startOperatorFeesService({
|
||||||
nostrClient: services.nostrClient,
|
nostrClient: services.nostrClient,
|
||||||
signer: services.signer,
|
identity: services.identity,
|
||||||
operatorPubkeys: services.operatorPubkeys,
|
operatorPubkeys: services.operatorPubkeys,
|
||||||
onApply: applyFeeConfig,
|
onApply: applyFeeConfig,
|
||||||
})
|
})
|
||||||
|
|
@ -827,7 +706,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
useLiveServices.value = false
|
useLiveServices.value = false
|
||||||
initialize(mockServices)
|
initialize(mockServices)
|
||||||
} else {
|
} else {
|
||||||
initError.value = classifyInitError(error, 'Lightning initialization failed')
|
initError.value = error instanceof Error ? error.message : 'Lightning initialization failed'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1062,20 +941,11 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// Wire validator events to state machine
|
// Wire validator events to state machine
|
||||||
hal.connectValidator({
|
hal.connectValidator({
|
||||||
shouldAcceptBill: (denomination) => {
|
shouldAcceptBill: (denomination) => {
|
||||||
|
// Check if accepting this bill would exceed available balance
|
||||||
const ctx = context.value
|
const ctx = context.value
|
||||||
|
if (!ctx || ctx.exchangeRate === 0) {
|
||||||
// Fail closed: no rate/balance, or not in the accepting state →
|
console.warn('[ATM] Cannot check balance: no exchange rate')
|
||||||
// return the bill (legacy _billsRead parity).
|
return true // Allow if we don't have rate yet (shouldn't happen)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate what the new sats amount would be
|
// Calculate what the new sats amount would be
|
||||||
|
|
@ -1093,9 +963,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
return false
|
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
|
return true
|
||||||
},
|
},
|
||||||
onBillInserted: (denomination) => {
|
onBillInserted: (denomination) => {
|
||||||
|
|
@ -1122,13 +989,13 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Start broadcasting availability (Kind 30078)
|
// 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)
|
// Operator-config consumer (aiolabs/lamassu-next#56)
|
||||||
operatorConfigSvc?.stop()
|
operatorConfigSvc?.stop()
|
||||||
operatorConfigSvc = await startOperatorConfigService({
|
operatorConfigSvc = await startOperatorConfigService({
|
||||||
nostrClient: lightning.nostrClient,
|
nostrClient: lightning.nostrClient,
|
||||||
signer: lightning.signer,
|
identity: lightning.identity,
|
||||||
operatorPubkeys: lightning.operatorPubkeys,
|
operatorPubkeys: lightning.operatorPubkeys,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1136,7 +1003,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
operatorFeesSvc?.stop()
|
operatorFeesSvc?.stop()
|
||||||
operatorFeesSvc = await startOperatorFeesService({
|
operatorFeesSvc = await startOperatorFeesService({
|
||||||
nostrClient: lightning.nostrClient,
|
nostrClient: lightning.nostrClient,
|
||||||
signer: lightning.signer,
|
identity: lightning.identity,
|
||||||
operatorPubkeys: lightning.operatorPubkeys,
|
operatorPubkeys: lightning.operatorPubkeys,
|
||||||
onApply: applyFeeConfig,
|
onApply: applyFeeConfig,
|
||||||
})
|
})
|
||||||
|
|
@ -1152,7 +1019,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
useLiveServices.value = false
|
useLiveServices.value = false
|
||||||
initialize(mockServices)
|
initialize(mockServices)
|
||||||
} else {
|
} else {
|
||||||
initError.value = classifyInitError(error, 'HAL initialization failed')
|
initError.value = error instanceof Error ? error.message : 'HAL initialization failed'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1374,22 +1241,11 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// Wire validator events from main process via IPC
|
// Wire validator events from main process via IPC
|
||||||
api.onHalBillRead((denomination) => {
|
api.onHalBillRead((denomination) => {
|
||||||
console.log('[ATM] Bill in escrow:', denomination)
|
console.log('[ATM] Bill in escrow:', denomination)
|
||||||
|
// Check if we should accept this bill
|
||||||
const ctx = context.value
|
const ctx = context.value
|
||||||
|
if (!ctx || ctx.exchangeRate === 0) {
|
||||||
// Fail closed (legacy _billsRead parity): only stack while the
|
// No rate yet, accept anyway
|
||||||
// machine is accepting bills AND rate + balance are known.
|
api.halStackBill()
|
||||||
// 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()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1408,10 +1264,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept the bill: mark it in flight, then command the stack.
|
// Accept the bill
|
||||||
// Credit (BILL_INSERTED) arrives via onHalBillInserted once the
|
|
||||||
// validator confirms the bill reached the stacker.
|
|
||||||
send({ type: 'BILL_PENDING', denomination })
|
|
||||||
api.halStackBill()
|
api.halStackBill()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1442,13 +1295,13 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// Real hardware connected — disable mock bill simulator
|
// Real hardware connected — disable mock bill simulator
|
||||||
debugMode.value = false
|
debugMode.value = false
|
||||||
// Start broadcasting availability (Kind 30078)
|
// 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)
|
// Operator-config consumer (aiolabs/lamassu-next#56)
|
||||||
operatorConfigSvc?.stop()
|
operatorConfigSvc?.stop()
|
||||||
operatorConfigSvc = await startOperatorConfigService({
|
operatorConfigSvc = await startOperatorConfigService({
|
||||||
nostrClient: lightning.nostrClient,
|
nostrClient: lightning.nostrClient,
|
||||||
signer: lightning.signer,
|
identity: lightning.identity,
|
||||||
operatorPubkeys: lightning.operatorPubkeys,
|
operatorPubkeys: lightning.operatorPubkeys,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1456,7 +1309,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
operatorFeesSvc?.stop()
|
operatorFeesSvc?.stop()
|
||||||
operatorFeesSvc = await startOperatorFeesService({
|
operatorFeesSvc = await startOperatorFeesService({
|
||||||
nostrClient: lightning.nostrClient,
|
nostrClient: lightning.nostrClient,
|
||||||
signer: lightning.signer,
|
identity: lightning.identity,
|
||||||
operatorPubkeys: lightning.operatorPubkeys,
|
operatorPubkeys: lightning.operatorPubkeys,
|
||||||
onApply: applyFeeConfig,
|
onApply: applyFeeConfig,
|
||||||
})
|
})
|
||||||
|
|
@ -1477,7 +1330,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
initialize(mockServices)
|
initialize(mockServices)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
initError.value = classifyInitError(error, 'Hardware initialization failed')
|
initError.value = error instanceof Error ? error.message : 'Hardware initialization failed'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1487,7 +1340,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
console.error('[ATM] Cannot send event: machine not initialized')
|
console.error('[ATM] Cannot send event: machine not initialized')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.log('[ATM] Sending event:', event.type, JSON.stringify(event))
|
console.log('[ATM] Sending event:', event)
|
||||||
actor.value.send(event)
|
actor.value.send(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1505,11 +1358,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertBill(denomination: number) {
|
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 })
|
send({ type: 'BILL_INSERTED', denomination })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1589,7 +1437,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
/** Start broadcasting ATM availability (Kind 30078) with 5-minute heartbeat */
|
/** Start broadcasting ATM availability (Kind 30078) with 5-minute heartbeat */
|
||||||
let stopAvailabilityBroadcast: (() => void) | null = null
|
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
|
if (stopAvailabilityBroadcast) return
|
||||||
|
|
||||||
// Ensure persisted inventory is loaded before first broadcast
|
// Ensure persisted inventory is loaded before first broadcast
|
||||||
|
|
@ -1597,7 +1445,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
|
|
||||||
const { stop } = useAvailabilityBroadcast({
|
const { stop } = useAvailabilityBroadcast({
|
||||||
nostrClient,
|
nostrClient,
|
||||||
signer,
|
identity,
|
||||||
inventory: persistedInventory,
|
inventory: persistedInventory,
|
||||||
balanceSats,
|
balanceSats,
|
||||||
fiatCode: fiatCode.value,
|
fiatCode: fiatCode.value,
|
||||||
|
|
@ -1635,12 +1483,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
isCashOut,
|
isCashOut,
|
||||||
nestedState,
|
nestedState,
|
||||||
|
|
||||||
// Bolt Card (NFC): cash-out pulls, cash-in receives
|
|
||||||
nfcStatus,
|
|
||||||
boltCardProcessing,
|
|
||||||
simulateBoltCardTap,
|
|
||||||
simulateBoltCardReceive,
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initialize,
|
initialize,
|
||||||
initializeWithLightning,
|
initializeWithLightning,
|
||||||
|
|
|
||||||
49
apps/machine/src/types/electron.d.ts
vendored
49
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -6,6 +6,10 @@ export interface RuntimeConfig {
|
||||||
relayUrl: string
|
relayUrl: string
|
||||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||||
lnbitsServerPubkey: string
|
lnbitsServerPubkey: string
|
||||||
|
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
|
||||||
|
lightningPubPubkey?: string
|
||||||
|
lightningPubApiUrl?: string
|
||||||
|
extensionApiUrl?: string
|
||||||
appId: string
|
appId: string
|
||||||
machineModel: string
|
machineModel: string
|
||||||
fiatCode: string
|
fiatCode: string
|
||||||
|
|
@ -35,24 +39,10 @@ export interface BrandingConfig {
|
||||||
logoDarkDataUrl: string | null
|
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 {
|
export interface AtmSecrets {
|
||||||
/** Spire pairing seed URL (`spire-seed:v1:…`); carries the one-shot connect token. */
|
atmPrivateKey: string
|
||||||
spireSeed: string
|
/** Legacy LP admin token — retained until 3d removes the LP backend. */
|
||||||
/** Persisted bunker binding, or null when the ATM is unpaired. */
|
adminToken?: string
|
||||||
bunkerBinding: BunkerBindingRecord | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -95,25 +85,6 @@ declare global {
|
||||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||||
getBootstrapPublishedAt: () => Promise<number | null>
|
getBootstrapPublishedAt: () => Promise<number | null>
|
||||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
|
||||||
clearBunkerBinding: () => Promise<void>
|
|
||||||
resetBootstrapGate: () => 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 }>
|
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
|
|
@ -151,12 +122,6 @@ declare global {
|
||||||
onHalBillInserted: (callback: (denomination: number) => void) => void
|
onHalBillInserted: (callback: (denomination: number) => void) => void
|
||||||
onHalBillRejected: (callback: (reason: string) => void) => void
|
onHalBillRejected: (callback: (reason: string) => void) => void
|
||||||
onHalError: (callback: (error: string) => void) => void
|
onHalError: (callback: (error: string) => 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
|
onWatchdogPing: (callback: () => void) => void
|
||||||
watchdogPong: () => Promise<void>
|
watchdogPong: () => Promise<void>
|
||||||
platform: NodeJS.Platform
|
platform: NodeJS.Platform
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,6 @@ const showCancelButton = computed(() => {
|
||||||
// Invoice input for manual payment
|
// Invoice input for manual payment
|
||||||
const invoiceInput = ref('')
|
const invoiceInput = ref('')
|
||||||
|
|
||||||
// Dev: paste an lnurlw to simulate a Bolt Card tap-to-receive
|
|
||||||
const mockLnurlw = ref('')
|
|
||||||
|
|
||||||
// Copy state for ndebit URI
|
// Copy state for ndebit URI
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
|
|
||||||
|
|
@ -245,10 +242,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<p v-if="context?.billPending" class="text-sm lg:text-xl text-muted-foreground">
|
<p v-if="balanceLimitReached" 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">
|
|
||||||
Maximum amount reached — press Done to continue
|
Maximum amount reached — press Done to continue
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-sm lg:text-xl text-muted-foreground">
|
<p v-else class="text-sm lg:text-xl text-muted-foreground">
|
||||||
|
|
@ -275,16 +269,11 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<!-- Done button — also blocked while a bill is between the
|
<!-- Done button -->
|
||||||
stack command and the validator's stacked-confirmation
|
|
||||||
(the machine guard drops FINISH_INSERTING regardless;
|
|
||||||
this keeps the UI honest about it) -->
|
|
||||||
<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"
|
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"
|
size="kiosk-lg"
|
||||||
:disabled="
|
:disabled="!context || context.billsInserted.length === 0"
|
||||||
!context || context.billsInserted.length === 0 || context.billPending !== null
|
|
||||||
"
|
|
||||||
@click="finishInserting"
|
@click="finishInserting"
|
||||||
>
|
>
|
||||||
Done Inserting
|
Done Inserting
|
||||||
|
|
@ -336,31 +325,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents || 0) / 100).toFixed(2) }}
|
{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents || 0) / 100).toFixed(2) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Waiting indicator + Bolt Card tap-to-receive status -->
|
<!-- Waiting indicator -->
|
||||||
<div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
|
<div class="flex items-center gap-3 pt-2 lg:pt-4">
|
||||||
<div class="flex items-center gap-3">
|
<PickaxeIcon :size="32" />
|
||||||
<PickaxeIcon :size="32" />
|
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for wallet scan...</p>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- LNURL URI (web-ui only) -->
|
<!-- LNURL URI (web-ui only) -->
|
||||||
|
|
@ -379,8 +347,8 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Debug: simulate payment / Bolt Card tap-to-receive -->
|
<!-- Debug: Simulate payment button -->
|
||||||
<div v-if="atmStore.debugMode" class="pt-2 flex flex-col items-center gap-2">
|
<div v-if="atmStore.debugMode" class="pt-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -389,21 +357,6 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
>
|
>
|
||||||
Dev: Skip to Success
|
Dev: Skip to Success
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,6 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
||||||
|
|
||||||
const cashOutSteps = ['Select', 'Pay', 'Collect']
|
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(() => {
|
const currentStepIndex = computed(() => {
|
||||||
switch (nestedState.value) {
|
switch (nestedState.value) {
|
||||||
case 'fetchingRate':
|
case 'fetchingRate':
|
||||||
|
|
@ -288,9 +284,7 @@ function formatFiat(cents: number): string {
|
||||||
<div
|
<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"
|
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">
|
<p class="text-lg lg:text-[2rem] font-semibold text-warning">Scan to Pay</p>
|
||||||
{{ isElectron ? 'Tap Card or Scan to Pay' : 'Scan to Pay' }}
|
|
||||||
</p>
|
|
||||||
<p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight">
|
<p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight">
|
||||||
{{ context ? formatSats(context.satsAmount) : 0 }} sats
|
{{ context ? formatSats(context.satsAmount) : 0 }} sats
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -301,31 +295,10 @@ function formatFiat(cents: number): string {
|
||||||
</Badge>
|
</Badge>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Waiting indicator + Bolt Card status -->
|
<!-- Waiting indicator -->
|
||||||
<div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
|
<div class="flex items-center gap-3 pt-2 lg:pt-4">
|
||||||
<div class="flex items-center gap-3">
|
<PickaxeIcon :size="32" />
|
||||||
<PickaxeIcon :size="32" />
|
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for payment...</p>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Invoice info with copy button (web-ui only) -->
|
<!-- Invoice info with copy button (web-ui only) -->
|
||||||
|
|
@ -349,21 +322,6 @@ function formatFiat(cents: number): string {
|
||||||
>
|
>
|
||||||
Simulate Payment
|
Simulate Payment
|
||||||
</Button>
|
</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>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useAtmStore } from '@/stores/atm'
|
import { useAtmStore } from '@/stores/atm'
|
||||||
import { useBranding } from '@/composables/useBranding'
|
import { useBranding } from '@/composables/useBranding'
|
||||||
import { initialContext } from '@bitSpire/state-machine'
|
import { initialContext } from '@bitSpire/state-machine'
|
||||||
|
|
@ -14,8 +15,18 @@ const atmStore = useAtmStore()
|
||||||
const { logoUrl, title: brandTitle } = useBranding()
|
const { logoUrl, title: brandTitle } = useBranding()
|
||||||
const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || ''
|
const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || ''
|
||||||
const showZeusQR = ref(false)
|
const showZeusQR = ref(false)
|
||||||
|
const showLpQR = ref(false)
|
||||||
const copied = 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) {
|
async function copyToClipboard(value: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(value)
|
await navigator.clipboard.writeText(value)
|
||||||
|
|
@ -146,6 +157,15 @@ function handleCashOut() {
|
||||||
>
|
>
|
||||||
Zeus QR (lnd-alice)
|
Zeus QR (lnd-alice)
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="lpNprofile"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
@click="showLpQR = true"
|
||||||
|
>
|
||||||
|
Lightning.Pub nprofile
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zeus QR fullscreen overlay -->
|
<!-- Zeus QR fullscreen overlay -->
|
||||||
|
|
@ -173,6 +193,27 @@ function handleCashOut() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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) -->
|
<!-- Help button (top-left) -->
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
|
@ -22,6 +23,35 @@ import { QrCode, ExternalLink } from 'lucide-vue-next'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
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 {
|
interface SupportPage {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -44,11 +74,17 @@ const defaultPages: SupportPage[] = [
|
||||||
| Blink | No | Partial | Yes | Yes | https://www.blink.sv |
|
| Blink | No | Partial | Yes | Yes | https://www.blink.sv |
|
||||||
| Zeus | Yes | Yes | Yes | Yes | https://zeusln.com |
|
| Zeus | Yes | Yes | Yes | Yes | https://zeusln.com |
|
||||||
| Breez | Yes | Yes | Yes | Yes | https://breez.technology |
|
| 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.
|
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',
|
id: 'faq',
|
||||||
|
|
@ -117,6 +153,7 @@ type Segment =
|
||||||
| { type: 'qr'; content: string }
|
| { type: 'qr'; content: string }
|
||||||
| { type: 'table'; table: ParsedTable }
|
| { type: 'table'; table: ParsedTable }
|
||||||
| { type: 'qr-placeholder' }
|
| { type: 'qr-placeholder' }
|
||||||
|
| { type: 'lp-nprofile' }
|
||||||
|
|
||||||
/** Parse markdown table into structured data */
|
/** Parse markdown table into structured data */
|
||||||
function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
||||||
|
|
@ -139,8 +176,17 @@ function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
||||||
return { headers, rows }
|
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 */
|
/** Parse content into segments: html, qr, or table */
|
||||||
function parseContent(md: string): Segment[] {
|
function parseContent(md: string): Segment[] {
|
||||||
|
md = resolvePlaceholders(md)
|
||||||
const segments: Segment[] = []
|
const segments: Segment[] = []
|
||||||
const lines = md.split('\n')
|
const lines = md.split('\n')
|
||||||
let htmlBlock = ''
|
let htmlBlock = ''
|
||||||
|
|
@ -189,6 +235,9 @@ function parseContent(md: string): Segment[] {
|
||||||
} else if (trimmed === '[operator-qr-placeholder]') {
|
} else if (trimmed === '[operator-qr-placeholder]') {
|
||||||
flushHtml()
|
flushHtml()
|
||||||
segments.push({ type: 'qr-placeholder' })
|
segments.push({ type: 'qr-placeholder' })
|
||||||
|
} else if (trimmed === '[lp-nprofile]') {
|
||||||
|
flushHtml()
|
||||||
|
segments.push({ type: 'lp-nprofile' })
|
||||||
} else {
|
} else {
|
||||||
htmlBlock += line + '\n'
|
htmlBlock += line + '\n'
|
||||||
}
|
}
|
||||||
|
|
@ -326,6 +375,33 @@ onUnmounted(() => {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 -->
|
<!-- Table with inline QR codes -->
|
||||||
<div v-else-if="seg.type === 'table'" class="mb-8">
|
<div v-else-if="seg.type === 'table'" class="mb-8">
|
||||||
<Table class="text-sm sm:text-base lg:text-xl w-full">
|
<Table class="text-sm sm:text-base lg:text-xl w-full">
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,7 @@ deploy/nixos/
|
||||||
├── hardware/
|
├── hardware/
|
||||||
│ ├── douro.nix # Dell OptiPlex 9030 AIO (stock Douro motherboard; SATA SSD, eGalax touch)
|
│ ├── douro.nix # Dell OptiPlex 9030 AIO (stock Douro motherboard; SATA SSD, eGalax touch)
|
||||||
│ ├── batm3.nix # GeneralBytes BATM3 chassis with a Dell OptiPlex 9030 AIO grafted in (custom mod; WireGuard wired in)
|
│ ├── batm3.nix # GeneralBytes BATM3 chassis with a Dell OptiPlex 9030 AIO grafted in (custom mod; WireGuard wired in)
|
||||||
│ ├── upboard.nix # Aaeon UP Board (Sintra + tejo; eMMC root via sdhci-acpi + mmc_block)
|
│ └── upboard.nix # Aaeon UP Board (Sintra + tejo; eMMC root via sdhci-acpi + mmc_block)
|
||||||
│ ├── raspberry-pi-5.nix # Raspberry Pi 5 (aarch64) — DIY build; board glue on top of nixos-hardware
|
|
||||||
│ └── raspberry-pi-4.nix # Raspberry Pi 4 (aarch64) — same, previous-generation board
|
|
||||||
├── udev/
|
├── udev/
|
||||||
│ └── 99-bitspire-hardware.rules # additional udev rules (loaded via configuration.nix)
|
│ └── 99-bitspire-hardware.rules # additional udev rules (loaded via configuration.nix)
|
||||||
├── provision-atm.sh # Push LNbits credentials to a deployed ATM via SSH
|
├── provision-atm.sh # Push LNbits credentials to a deployed ATM via SSH
|
||||||
|
|
@ -38,11 +36,6 @@ Each ATM model has two flake outputs:
|
||||||
|
|
||||||
Models: `douro`, `tejo`, `sintra`, `batm3`.
|
Models: `douro`, `tejo`, `sintra`, `batm3`.
|
||||||
|
|
||||||
The Raspberry Pi boards (`rpi5`, `rpi4`) are aarch64 and follow a different
|
|
||||||
pipeline — SD-card image instead of GPT disk image, U-Boot instead of
|
|
||||||
systemd-boot, and they need an aarch64 builder. See
|
|
||||||
[`docs/raspberry-pi-setup.md`](../../docs/raspberry-pi-setup.md).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build a Sintra disk image
|
# Build a Sintra disk image
|
||||||
nix build .#disk-image-sintra
|
nix build .#disk-image-sintra
|
||||||
|
|
@ -66,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)/
|
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).
|
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).
|
||||||
|
|
||||||
|
|
@ -194,7 +187,7 @@ The `dev`-branch `flake.nix` pins the auto-upgrade source to `?ref=dev` so any A
|
||||||
```nix
|
```nix
|
||||||
system.autoUpgrade = {
|
system.autoUpgrade = {
|
||||||
enable = true;
|
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";
|
dates = "04:00";
|
||||||
allowReboot = false;
|
allowReboot = false;
|
||||||
};
|
};
|
||||||
|
|
@ -209,7 +202,7 @@ Production ATMs on `main` continue to read `main`'s flake (no `?ref=` pin → re
|
||||||
| Path | Owner | Purpose |
|
| Path | Owner | Purpose |
|
||||||
|------|-------|---------|
|
|------|-------|---------|
|
||||||
| `/var/lib/bitspire/` | bitspire:bitspire, 0750 | Service data directory |
|
| `/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/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/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 |
|
| `/var/lib/bitspire/branding/` | bitspire:bitspire, 0755 | Operator branding override (logo.png + branding.json) — see issue #47 |
|
||||||
|
|
@ -269,8 +262,8 @@ ls -la /dev/serial/by-id/
|
||||||
{
|
{
|
||||||
services.bitspire = {
|
services.bitspire = {
|
||||||
enable = true;
|
enable = true;
|
||||||
relayUrl = ""; # seed-provided (#70); set to PIN a relay
|
relayUrl = "wss://relay.aiolabs.dev"; # ATM ↔ LNbits relay
|
||||||
lnbitsServerPubkey = ""; # seed-provided (#70); set to PIN a pubkey
|
lnbitsServerPubkey = "<64-hex>"; # LNbits transport pubkey
|
||||||
appDir = "/opt/bitspire"; # rarely overridden — defaults via flake
|
appDir = "/opt/bitspire"; # rarely overridden — defaults via flake
|
||||||
dataDir = "/var/lib/bitspire"; # rarely overridden
|
dataDir = "/var/lib/bitspire"; # rarely overridden
|
||||||
logLevel = "info"; # error | warn | info | debug
|
logLevel = "info"; # error | warn | info | debug
|
||||||
|
|
|
||||||
|
|
@ -20,17 +20,18 @@ in
|
||||||
|
|
||||||
relayUrl = mkOption {
|
relayUrl = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
default = "";
|
default = "wss://relay.aiolabs.dev";
|
||||||
description = ''
|
description = ''
|
||||||
Optional override for the Nostr relay the ATM uses. Empty by
|
Nostr relay URL the ATM and LNbits both subscribe to.
|
||||||
default (aiolabs/bitspire#70): the relay comes from the pairing
|
|
||||||
SEED, not from provisioning — a fresh machine boots blank, scans a
|
On a fresh-boot disk image this value is seeded into
|
||||||
spire-seed, and the seed's relay drives the connection. A non-empty
|
`/var/lib/bitspire/.env` as `VITE_RELAY_URL=…` (see flake.nix
|
||||||
value here is seeded into `/var/lib/bitspire/.env` as
|
`bitspire-env` activation script). The operator can override
|
||||||
`VITE_RELAY_URL=…` and WINS over the seed (env-first precedence), so
|
the seeded value at runtime by editing `.env` directly or by
|
||||||
only set it to pin a machine to a specific relay. The renderer's
|
re-running `deploy/nixos/provision-atm.sh` with a different
|
||||||
resolution order is: `VITE_RELAY_URL` (this / .env) → the pairing
|
`RELAY_URL`. The renderer's resolution order is:
|
||||||
seed's relay → a dev-only `ws://localhost:7777` fallback.
|
`/var/lib/bitspire/.env` → this NixOS default → renderer
|
||||||
|
hardcoded fallback (`ws://localhost:7777`).
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -38,13 +39,10 @@ in
|
||||||
type = types.str;
|
type = types.str;
|
||||||
default = "";
|
default = "";
|
||||||
description = ''
|
description = ''
|
||||||
Optional override for the LNbits nostr-transport server pubkey
|
LNbits nostr-transport server pubkey (hex, 64 chars). Published
|
||||||
(hex, 64 chars). Empty by default (aiolabs/bitspire#70): the
|
by the LNbits server on startup. Required for the ATM to talk
|
||||||
pubkey comes from the pairing SEED (the seed's `lnbits_npub`), so
|
to its wallet. Provisioned by provision-atm.sh; can be left
|
||||||
a seed-paired machine needs nothing here. A non-empty value is
|
empty on disk-image builds.
|
||||||
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`.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -143,14 +141,11 @@ in
|
||||||
"d ${cfg.dataDir}/branding 0755 bitspire bitspire -"
|
"d ${cfg.dataDir}/branding 0755 bitspire bitspire -"
|
||||||
];
|
];
|
||||||
|
|
||||||
# Descriptive-only ATM info at /etc/bitspire/config.env. NOTE: this is NOT
|
# Environment file for ATM configuration
|
||||||
# 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.etc."bitspire/config.env".text = ''
|
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}
|
LOG_LEVEL=${cfg.logLevel}
|
||||||
DATA_DIR=${cfg.dataDir}
|
DATA_DIR=${cfg.dataDir}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,6 @@
|
||||||
# System basics
|
# System basics
|
||||||
system.stateVersion = "24.05";
|
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
|
||||||
networking = {
|
networking = {
|
||||||
hostName = "bitspire";
|
hostName = "bitspire";
|
||||||
|
|
@ -131,10 +121,8 @@
|
||||||
# Node.js for the application
|
# Node.js for the application
|
||||||
pkgs-unstable.nodejs_22
|
pkgs-unstable.nodejs_22
|
||||||
|
|
||||||
# Camera support. v4l-utils' default build drags in the whole Qt6 stack
|
# Camera support
|
||||||
# for its qv4l2 GUI (~0.5GB) — we only ever use the v4l2-ctl CLI, so drop
|
v4l-utils
|
||||||
# the GUI.
|
|
||||||
(v4l-utils.override { withGUI = false; })
|
|
||||||
fswebcam
|
fswebcam
|
||||||
|
|
||||||
# ATM operations
|
# ATM operations
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Factory-reset a bitSpire ATM to a truly-fresh state — the deterministic way to
|
|
||||||
# reproduce a brand-new machine so tests aren't masked by leftover env/db values
|
|
||||||
# (aiolabs/bitspire#70 remnant hygiene).
|
|
||||||
#
|
|
||||||
# WIPES:
|
|
||||||
# - /var/lib/bitspire/state.db (bunker binding, fee config, cassettes, cashbox,
|
|
||||||
# transactions, operator commands, replay watermarks — recreated on next boot)
|
|
||||||
# - /var/lib/bitspire/.env (truncated to the minimal image-baked template:
|
|
||||||
# machine model + fiat + display; drops relay, server pubkey, operator pubkey
|
|
||||||
# and any stored spire seed)
|
|
||||||
#
|
|
||||||
# After this the ATM boots UNPAIRED into the pairing wizard, exactly like a fresh
|
|
||||||
# disk image — so a scanned seed is the sole source of truth.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# bash factory-reset-atm.sh # SSH to localhost:2222 (QEMU)
|
|
||||||
# bash factory-reset-atm.sh 192.168.1.50 # a real ATM on the LAN
|
|
||||||
# bash factory-reset-atm.sh 192.168.1.50 22 # custom SSH port
|
|
||||||
# FORCE=1 bash factory-reset-atm.sh … # skip the confirmation prompt
|
|
||||||
# ATM_USER=root bash factory-reset-atm.sh … # override SSH user (default: bitspire)
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ATM_HOST="${1:-localhost}"
|
|
||||||
ATM_SSH_PORT="${2:-2222}"
|
|
||||||
ATM_USER="${ATM_USER:-bitspire}"
|
|
||||||
|
|
||||||
echo "=== Factory-reset bitSpire ATM at $ATM_USER@$ATM_HOST:$ATM_SSH_PORT ==="
|
|
||||||
echo "This WIPES state.db and truncates .env to the minimal template (keeps only"
|
|
||||||
echo "machine model + fiat). ALL pairing, cash accounting, and transaction history"
|
|
||||||
echo "on the ATM will be lost."
|
|
||||||
if [ "${FORCE:-}" != "1" ]; then
|
|
||||||
read -r -p "Type 'yes' to proceed: " confirm
|
|
||||||
[ "$confirm" = "yes" ] || { echo "Aborted."; exit 1; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" 'sudo bash -s' <<'REMOTE'
|
|
||||||
set -euo pipefail
|
|
||||||
ENV=/var/lib/bitspire/.env
|
|
||||||
DB=/var/lib/bitspire/state.db
|
|
||||||
|
|
||||||
# Preserve model + fiat from the existing .env (fall back to sintra/EUR).
|
|
||||||
model=$(grep -E '^VITE_LAMASSU_MACHINE_MODEL=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
|
|
||||||
fiat=$(grep -E '^VITE_LAMASSU_FIAT_CODE=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
|
|
||||||
model=${model:-sintra}
|
|
||||||
fiat=${fiat:-EUR}
|
|
||||||
|
|
||||||
systemctl stop bitspire 2>/dev/null || true
|
|
||||||
|
|
||||||
# Wipe persisted state (db + WAL/SHM sidecars).
|
|
||||||
rm -f "$DB" "$DB-wal" "$DB-shm"
|
|
||||||
|
|
||||||
# Truncate .env to the minimal image-baked template.
|
|
||||||
cat > "$ENV" <<EOF
|
|
||||||
VITE_LAMASSU_MACHINE_MODEL=$model
|
|
||||||
VITE_LAMASSU_FIAT_CODE=$fiat
|
|
||||||
VITE_SPIRE_SEED=
|
|
||||||
ELECTRON_FORCE_PROD=1
|
|
||||||
DISPLAY=:0
|
|
||||||
EOF
|
|
||||||
chmod 600 "$ENV"
|
|
||||||
chown bitspire:bitspire "$ENV" 2>/dev/null || true
|
|
||||||
|
|
||||||
systemctl start bitspire 2>/dev/null || true
|
|
||||||
|
|
||||||
echo "--- .env is now (values blanked) ---"
|
|
||||||
sed -E 's/=.*/=/' "$ENV"
|
|
||||||
echo "--- state.db removed (recreated fresh on next boot) ---"
|
|
||||||
REMOTE
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== ATM factory-reset. It boots UNPAIRED → the pairing wizard. ==="
|
|
||||||
echo "Watch: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"
|
|
||||||
|
|
@ -12,36 +12,13 @@
|
||||||
timeout = 3;
|
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 = [
|
initrd.availableKernelModules = [
|
||||||
"xhci_pci"
|
"xhci_pci"
|
||||||
"ahci"
|
"ahci"
|
||||||
"usbhid"
|
"usbhid"
|
||||||
"sd_mod"
|
"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 = [
|
kernelModules = [
|
||||||
"kvm-intel"
|
"kvm-intel"
|
||||||
"usbtouchscreen"
|
"usbtouchscreen"
|
||||||
|
|
@ -50,9 +27,6 @@
|
||||||
kernelParams = [
|
kernelParams = [
|
||||||
"quiet"
|
"quiet"
|
||||||
"splash"
|
"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";
|
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
|
# Disable suspend/hibernate for kiosk
|
||||||
systemd.targets = {
|
systemd.targets = {
|
||||||
sleep.enable = false;
|
sleep.enable = false;
|
||||||
|
|
@ -191,20 +105,10 @@
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# eGalax touchscreen (Dell 9030 AIO built-in panel)
|
# eGalax touchscreen (Dell 9030 AIO built-in panel)
|
||||||
# By default usbhid/hid-multitouch claim the eGalax and mis-parse its
|
# The eGalax HID descriptor confuses libinput (treats it as touchpad).
|
||||||
# HID report descriptor (X axis reads as stuck), so touch is unusable.
|
# Fix: unbind from usbhid at boot, bind to usbtouchscreen kernel module,
|
||||||
# Fix: hand the device to the usbtouchscreen kernel driver, which parses
|
# then apply calibration matrix after X11 starts.
|
||||||
# the raw eGalax protocol into a clean single-touch ABS device that the
|
# Unbind eGalax from usbhid, bind to usbtouchscreen
|
||||||
# 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).
|
|
||||||
services.udev.extraRules = lib.mkAfter ''
|
services.udev.extraRules = lib.mkAfter ''
|
||||||
KERNEL=="ttyS[0-9]*", MODE="0666"
|
KERNEL=="ttyS[0-9]*", MODE="0666"
|
||||||
KERNEL=="ttyUSB[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}=="DDDLb103Y23", SYMLINK+="ttyF56", MODE="0666"
|
||||||
SUBSYSTEM=="tty", ATTRS{serial}=="A9YW78OC", SYMLINK+="ttyMEI", MODE="0666"
|
SUBSYSTEM=="tty", ATTRS{serial}=="A9YW78OC", SYMLINK+="ttyMEI", MODE="0666"
|
||||||
SUBSYSTEM=="tty", ATTRS{serial}=="A9ZF8ELY", SYMLINK+="ttyNFC", 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'"
|
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'"
|
||||||
# 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
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Apply touchscreen calibration after X11 starts
|
# Apply touchscreen calibration after X11 starts
|
||||||
|
|
@ -251,30 +130,9 @@
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
User = "bitspire";
|
User = "bitspire";
|
||||||
# DISPLAY *and* XAUTHORITY — without the auth cookie xinput dies with
|
Environment = "DISPLAY=:0";
|
||||||
# "Invalid MIT-MAGIC-COOKIE-1 key / Unable to connect to X server" and
|
ExecStartPre = "${pkgs.coreutils}/bin/sleep 3";
|
||||||
# the matrix is never applied, so touches register but land in the wrong
|
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";
|
||||||
# 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
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
# Raspberry Pi 4 hardware module (aarch64).
|
|
||||||
#
|
|
||||||
# The Pi 4 twin of raspberry-pi-5.nix. Kernel, firmware, bootloader and device
|
|
||||||
# tree come from the nixos-hardware `raspberry-pi-4` module (paired with this
|
|
||||||
# file in flake.nix's piBoards); here we set only the bitSpire-specific
|
|
||||||
# hardware glue: serial for the bill validators, the kiosk display driver, and
|
|
||||||
# no-suspend. The wiring notes in raspberry-pi-5.nix apply unchanged — same
|
|
||||||
# validators over USB-serial, same QR scanner, same touchscreen options.
|
|
||||||
#
|
|
||||||
# What differs from the Pi 5:
|
|
||||||
# - GPU/KMS: the Pi 5 module enables vc4/v3d modesetting by default; on the
|
|
||||||
# Pi 4 it is an opt-in (`fkms-3d`) that also injects the CMA + vc4 device
|
|
||||||
# tree overlays. Without it X falls back to the plain framebuffer and
|
|
||||||
# Electron renders in software.
|
|
||||||
# - Memory: 4 GB is the floor for Electron + the kiosk; 8 GB is comfortable.
|
|
||||||
# The shared Pi runtime's MemoryMax=2G leaves headroom on either.
|
|
||||||
# - No PCIe (the Pi 5's NVMe path); boot/root is SD or USB-SATA only.
|
|
||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
{
|
|
||||||
# aarch64 target. (The flake instantiates this config with aarch64 pkgs; this
|
|
||||||
# line documents/asserts it.)
|
|
||||||
nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux";
|
|
||||||
|
|
||||||
# Mainline kernel, NOT the Raspberry Pi vendor one.
|
|
||||||
#
|
|
||||||
# nixos-hardware's raspberry-pi/4 module mkDefaults boot.kernelPackages to
|
|
||||||
# the vendor kernel (linux-rpi, via common/kernel.nix). That kernel is in no
|
|
||||||
# binary cache — Hydra does not build nixos-hardware's overlays, and it is
|
|
||||||
# not in aiolabs.cachix.org either — so every Pi compiles a kernel from
|
|
||||||
# source, on an SD card, and recompiles on every bump. The first bring-up
|
|
||||||
# attempt spent hours on `CC [M] fs/overlayfs/inode.o` before anyone noticed
|
|
||||||
# what it was doing.
|
|
||||||
#
|
|
||||||
# Mainline aarch64 kernels are cached, and mainline demonstrably boots a
|
|
||||||
# Pi 4: it is what the stock NixOS aarch64 SD image runs. The vendor kernel's
|
|
||||||
# Pi-specific patches buy nothing this kiosk needs — display, USB serial and
|
|
||||||
# WiFi are all mainline, and vc4/v3d KMS has been mainline for years.
|
|
||||||
#
|
|
||||||
# Watch the graphics path when changing this. fkms-3d below is a
|
|
||||||
# nixos-hardware overlay built around the vendor kernel's firmware-KMS route;
|
|
||||||
# the mainline equivalent is full KMS (vc4-kms-v3d). If X ends up on the
|
|
||||||
# framebuffer with Electron rendering in software, that overlay is where to
|
|
||||||
# look — not the kernel choice, which is worth keeping either way.
|
|
||||||
boot.kernelPackages = pkgs.linuxPackages;
|
|
||||||
|
|
||||||
# Bootloader: the aarch64 sd-image uses the extlinux-compatible generator;
|
|
||||||
# nixos-hardware's rpi4 module wires the firmware/u-boot. No systemd-boot.
|
|
||||||
boot.loader.grub.enable = lib.mkDefault false;
|
|
||||||
boot.loader.generic-extlinux-compatible.enable = lib.mkDefault true;
|
|
||||||
|
|
||||||
# Primary UART (GPIO 14/15) available for a GPIO-wired validator. Keep the
|
|
||||||
# serial console OFF it so the validator owns the line — mirrors upboard.nix
|
|
||||||
# keeping ttyS4 free for the dispenser. USB-serial adapters are unaffected.
|
|
||||||
#
|
|
||||||
# Not mkDefault: kernelParams is list-merged, and only definitions at the
|
|
||||||
# highest priority survive. nixpkgs sets loglevel/lsm at normal priority, so
|
|
||||||
# a mkDefault list here is dropped entirely — and with no console= at all
|
|
||||||
# the kernel falls back to the device tree's stdout-path, i.e. this UART.
|
|
||||||
# cma=256M: the vc4 display pipeline allocates its framebuffer from the
|
|
||||||
# contiguous memory area, and the default reservation on this board is 32MiB
|
|
||||||
# with about 11MiB free. A 3840x1080 framebuffer is ~16.6MB before double
|
|
||||||
# buffering, so X got as far as picking the mode and then died:
|
|
||||||
#
|
|
||||||
# Output HDMI-1 using initial mode 3840x1080 +0+0
|
|
||||||
# (EE) AddScreen/ScreenInit failed for driver 0
|
|
||||||
#
|
|
||||||
# nixos-hardware's fkms-3d injected a CMA overlay alongside the display one;
|
|
||||||
# this replaces that half of it. 256M is generous for any panel an ATM will
|
|
||||||
# carry and trivial against 4-8GB of RAM.
|
|
||||||
boot.kernelParams = [ "console=tty0" "cma=256M" ];
|
|
||||||
|
|
||||||
# ── REQUIRES A MANUAL STEP ON THE FIRMWARE PARTITION ────────────────
|
|
||||||
# This board boots the FIRMWARE's vendor DTB, not the DTBs NixOS builds.
|
|
||||||
# Confirmed on the CM4: the live device tree carries __symbols__ and the
|
|
||||||
# mainline DTBs in dtbs-filtered do not, and U-Boot found no FDTDIR match for
|
|
||||||
# compatible "raspberrypi,4-compute-module" so it passed the firmware's DTB
|
|
||||||
# through. That means hardware.deviceTree.overlays cannot reach the running
|
|
||||||
# device tree, and the display has to be enabled by the firmware instead.
|
|
||||||
#
|
|
||||||
# In the vendor DTB every display node (hvs, gpu, all pixelvalves, both hdmi)
|
|
||||||
# ships `disabled`. So /boot/firmware/config.txt needs:
|
|
||||||
#
|
|
||||||
# dtoverlay=vc4-kms-v3d,noaudio
|
|
||||||
#
|
|
||||||
# and /boot/firmware/overlays/ needs to be populated from raspberrypifw --
|
|
||||||
# the NixOS sd-image writes the DTBs there but NOT the overlays, so the
|
|
||||||
# directory ships empty and the dtoverlay line fails silently. Copy the whole
|
|
||||||
# directory (2MB, 356 files); copying only vc4-kms-v3d.dtbo is not enough
|
|
||||||
# because the firmware remaps that to vc4-kms-v3d-pi4.dtbo on this board.
|
|
||||||
#
|
|
||||||
# `noaudio` is required, not cosmetic. With HDMI audio enabled vc4_hdmi cannot
|
|
||||||
# register its PCM component, returns -517 (EPROBE_DEFER) forever, and the DRM
|
|
||||||
# device never registers -- so X finds no card at all. We removed the audio
|
|
||||||
# stack anyway, so there is nothing to lose.
|
|
||||||
#
|
|
||||||
# This is a reflash-losing manual step and it should be folded into the image
|
|
||||||
# builder. Tracked as a follow-up; noted here so the next person does not
|
|
||||||
# rediscover it from a blank screen.
|
|
||||||
|
|
||||||
# Kiosk display: mainline full KMS, NOT nixos-hardware's fkms-3d.
|
|
||||||
#
|
|
||||||
# fkms-3d applies the rpi4-cma-overlay and rpi4-vc4-fkms-v3d-overlay device
|
|
||||||
# tree overlays. Those target nodes that exist in the Raspberry Pi VENDOR
|
|
||||||
# kernel's DTBs and not in mainline's, so with the mainline kernel above the
|
|
||||||
# overlay step fails outright:
|
|
||||||
#
|
|
||||||
# Applying overlay rpi4-vc4-fkms-v3d-overlay
|
|
||||||
# libfdt.FdtException: pylibfdt error -1: FDT_ERR_NOTFOUND
|
|
||||||
#
|
|
||||||
# It is also unnecessary. "fkms" is FIRMWARE KMS, the older route where the
|
|
||||||
# VideoCore firmware owns the display and Linux drives it at arm's length.
|
|
||||||
# Mainline does full KMS instead, and mainline's own bcm2711-rpi-4-b.dtb
|
|
||||||
# already describes the hardware — it carries brcm,bcm2711-vc5 and
|
|
||||||
# brcm,2711-v3d nodes, checked with dtc. The vc4 and v3d drivers bind to
|
|
||||||
# those directly with no overlay involved.
|
|
||||||
#
|
|
||||||
# fkms-3d used to set services.xserver.videoDrivers as a side effect. Nothing
|
|
||||||
# needs to replace it: the shared configuration.nix already declares
|
|
||||||
# modesetting, which is the correct driver for full KMS and what the x86
|
|
||||||
# machines use. Setting it again here only produced a duplicate entry.
|
|
||||||
|
|
||||||
hardware.enableRedistributableFirmware = true;
|
|
||||||
|
|
||||||
# pcscd MUST be enabled, and not because this board has a card reader.
|
|
||||||
#
|
|
||||||
# The app constructs @pokusew/pcsclite at startup. That calls
|
|
||||||
# SCardEstablishContext(), which calls SCardCheckDaemonAvailability(), which
|
|
||||||
# — when there is no pcscd to find — BUSY-LOOPS in fstatat64 at ~92% CPU
|
|
||||||
# instead of returning an error. It runs on Electron's main thread, before
|
|
||||||
# the BrowserWindow is created, so the window never appears and the panel
|
|
||||||
# stays white forever. Nothing is logged, nothing throws, and V8's own
|
|
||||||
# inspector cannot be serviced because the thread never yields: CDP
|
|
||||||
# Debugger.pause and Profiler.stop both hang. It took a native gdb backtrace
|
|
||||||
# to see it at all:
|
|
||||||
#
|
|
||||||
# #0 fstatat64 libc
|
|
||||||
# #1 SCardCheckDaemonAvailability libpcsclite
|
|
||||||
# #2 SCardEstablishContext libpcsclite
|
|
||||||
# #3 PCSCLite::PCSCLite() pcsclite.node
|
|
||||||
#
|
|
||||||
# The x86 machines never hit this because upboard.nix and batm3.nix both
|
|
||||||
# enable pcscd for their actual readers. This module did not, which is the
|
|
||||||
# entire difference. A running pcscd with no reader attached just idles, so
|
|
||||||
# this is cheap insurance rather than a claim about the hardware.
|
|
||||||
services.pcscd.enable = true;
|
|
||||||
|
|
||||||
# pcscd gates client access via polkit; without a rule the `bitspire` service
|
|
||||||
# user is "Rejected unauthorized PC/SC client". Same wiring as upboard.nix.
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Kiosk: never sleep.
|
|
||||||
systemd.targets = {
|
|
||||||
sleep.enable = false;
|
|
||||||
suspend.enable = false;
|
|
||||||
hibernate.enable = false;
|
|
||||||
hybrid-sleep.enable = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
# Stable device symlinks for USB-serial bill-validator adapters, so the ATM
|
|
||||||
# config can point at /dev/ttyValidator0 regardless of enumeration order.
|
|
||||||
# Identical to the Pi 5 module — same adapters, same bridges. If two adapters
|
|
||||||
# of the SAME chip are used, disambiguate by KERNELS/serial instead — tune
|
|
||||||
# during bring-up.
|
|
||||||
services.udev.extraRules = lib.mkAfter ''
|
|
||||||
# FTDI (e.g. FT232R) → ttyValidator0
|
|
||||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="ttyValidator0"
|
|
||||||
# Silicon Labs CP210x → ttyValidator1
|
|
||||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="ttyValidator1"
|
|
||||||
# WCH CH340 → ttyValidator2
|
|
||||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="ttyValidator2"
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
# Raspberry Pi 5 hardware module (aarch64).
|
|
||||||
#
|
|
||||||
# The bitSpire equivalent of upboard.nix, but for a Pi 5 instead of the x86
|
|
||||||
# UP Board. Kernel, firmware, GPU and bootloader come from the nixos-hardware
|
|
||||||
# `raspberry-pi-5` module (added alongside this one in flake.nix); here we set
|
|
||||||
# only the bitSpire-specific hardware glue: serial for the bill validators,
|
|
||||||
# the kiosk display driver, and no-suspend.
|
|
||||||
#
|
|
||||||
# Wiring the parts (see docs) to a Pi 5:
|
|
||||||
# - Bill validators (Apex 7600 RS-232, NV10 USB+): easiest via one USB-serial
|
|
||||||
# adapter each → stable /dev/ttyValidator* symlinks below. A GPIO-UART wire
|
|
||||||
# is also supported (primary UART enabled, console kept off it).
|
|
||||||
# - QR scanner: USB HID, no config.
|
|
||||||
# - 7" touchscreen: DSI or HDMI; the vc4/v3d KMS driver (from nixos-hardware)
|
|
||||||
# backs X.
|
|
||||||
# - Boot/root: USB-SATA SSD or the SD card.
|
|
||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
{
|
|
||||||
# aarch64 target. (The flake instantiates this config with aarch64 pkgs; this
|
|
||||||
# line documents/asserts it.)
|
|
||||||
nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux";
|
|
||||||
|
|
||||||
# Bootloader: the aarch64 sd-image uses the extlinux-compatible generator;
|
|
||||||
# nixos-hardware's rpi5 module wires the firmware/u-boot. No systemd-boot
|
|
||||||
# (that's x86/UEFI, as on the UP Board).
|
|
||||||
boot.loader.grub.enable = lib.mkDefault false;
|
|
||||||
boot.loader.generic-extlinux-compatible.enable = lib.mkDefault true;
|
|
||||||
|
|
||||||
# Primary UART (GPIO 14/15) available for a GPIO-wired validator. Keep the
|
|
||||||
# serial console OFF it so the validator owns the line — mirrors upboard.nix
|
|
||||||
# keeping ttyS4 free for the dispenser. USB-serial adapters are unaffected.
|
|
||||||
#
|
|
||||||
# Not mkDefault: kernelParams is list-merged, and only definitions at the
|
|
||||||
# highest priority survive. nixpkgs sets loglevel/lsm at normal priority, so
|
|
||||||
# a mkDefault list here is dropped entirely — and with no console= at all
|
|
||||||
# the kernel falls back to the device tree's stdout-path, i.e. this UART.
|
|
||||||
boot.kernelParams = [ "console=tty0" ];
|
|
||||||
|
|
||||||
# X uses the Pi GPU's kernel modesetting driver (vc4/v3d KMS from
|
|
||||||
# nixos-hardware). Electron renders through it as on the UP Board.
|
|
||||||
services.xserver.videoDrivers = lib.mkDefault [ "modesetting" ];
|
|
||||||
|
|
||||||
hardware.enableRedistributableFirmware = true;
|
|
||||||
|
|
||||||
# Kiosk: never sleep.
|
|
||||||
systemd.targets = {
|
|
||||||
sleep.enable = false;
|
|
||||||
suspend.enable = false;
|
|
||||||
hibernate.enable = false;
|
|
||||||
hybrid-sleep.enable = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
# Stable device symlinks for USB-serial bill-validator adapters, so the ATM
|
|
||||||
# config can point at /dev/ttyValidator0 regardless of enumeration order.
|
|
||||||
# Covers the common bridges (FTDI, Silicon Labs CP210x, WCH CH340). If two
|
|
||||||
# adapters of the SAME chip are used, disambiguate by KERNELS/serial instead —
|
|
||||||
# tune during bring-up. The NV10 USB+ presents its own USB CDC serial; add its
|
|
||||||
# idVendor/idProduct here once known.
|
|
||||||
services.udev.extraRules = lib.mkAfter ''
|
|
||||||
# FTDI (e.g. FT232R) → ttyValidator0
|
|
||||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="ttyValidator0"
|
|
||||||
# Silicon Labs CP210x → ttyValidator1
|
|
||||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="ttyValidator1"
|
|
||||||
# WCH CH340 → ttyValidator2
|
|
||||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="ttyValidator2"
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
# UP Board serial peripherals — the validator / dispenser / printer wiring
|
|
||||||
# shared by the INSTALLED configs (hardware/upboard.nix, used by both
|
|
||||||
# tejo-installed and sintra-installed) AND the sintra live ISO (live.nix).
|
|
||||||
# Single source of truth so the two artifacts can't drift — the earlier bug
|
|
||||||
# was exactly this drift (the sintra live ISO lacked ftdi_sio + the ttyJ7
|
|
||||||
# symlink, so the F56 dispenser failed while the installed image worked).
|
|
||||||
#
|
|
||||||
# Sintra IS a UP Board, so these are the UP Board rules; ttyS1/ttyS5 cover the
|
|
||||||
# older UP Board / UP4000 (Tejo) dispenser nodes and ttyS4 covers the Sintra
|
|
||||||
# (Apollo Lake) where the F56 is on the SoC MMIO UART. Only the device that
|
|
||||||
# actually exists at runtime gets the symlink, so all three coexist safely.
|
|
||||||
#
|
|
||||||
# Serial port mapping:
|
|
||||||
# ttyJ4 = Printer (Nippon NP-2511D-2)
|
|
||||||
# ttyJ5 = Validator (iVIZION, ID003)
|
|
||||||
# ttyJ7 = Dispenser (Fujitsu F53/F56)
|
|
||||||
{ lib, ... }:
|
|
||||||
|
|
||||||
{
|
|
||||||
boot.kernelModules = [
|
|
||||||
"usbserial" # USB-to-serial adapters
|
|
||||||
"ftdi_sio" # FTDI USB serial (the iVIZION validator bridge)
|
|
||||||
"cp210x" # CP210x USB serial (alternative adapter)
|
|
||||||
];
|
|
||||||
|
|
||||||
boot.kernelParams = [
|
|
||||||
# Do NOT route the kernel console through ttyS4 on Sintra. ttyS4 is the
|
|
||||||
# SoC's MMIO 16550A (the only real UART besides the legacy ttyS0 at I/O
|
|
||||||
# 0x3f8) and is wired to the Fujitsu F56 dispenser's RS-232 header. Holding
|
|
||||||
# it as console prevents userspace opening it at 9600 baud and HAL fails
|
|
||||||
# with "Input/output error setting custom baud rate of 9600". For serial
|
|
||||||
# debug, point console at ttyS0 instead.
|
|
||||||
"console=tty0"
|
|
||||||
];
|
|
||||||
|
|
||||||
services.udev.extraRules = lib.mkAfter ''
|
|
||||||
# Generic serial port permissions (so the non-root HAL user can open them)
|
|
||||||
KERNEL=="ttyS[0-9]*", MODE="0666"
|
|
||||||
KERNEL=="ttyUSB[0-9]*", MODE="0666"
|
|
||||||
KERNEL=="ttyACM[0-9]*", MODE="0666"
|
|
||||||
|
|
||||||
# Printer (ttyJ4)
|
|
||||||
KERNELS=="1-7.2:1.0", SYMLINK+="ttyJ4"
|
|
||||||
KERNEL=="ttyUSB0", SYMLINK+="ttyJ4"
|
|
||||||
|
|
||||||
# Validator (ttyJ5)
|
|
||||||
KERNELS=="1-7.3:1.0", SYMLINK+="ttyJ5"
|
|
||||||
KERNEL=="ttyUSB1", SYMLINK+="ttyJ5"
|
|
||||||
|
|
||||||
# Dispenser (ttyJ7). ttyS1/ttyS5 = older UP Board / UP4000; ttyS4 = Sintra.
|
|
||||||
KERNEL=="ttyS1", SYMLINK+="ttyJ7"
|
|
||||||
KERNEL=="ttyS4", SYMLINK+="ttyJ7"
|
|
||||||
KERNEL=="ttyS5", SYMLINK+="ttyJ7"
|
|
||||||
|
|
||||||
# Legacy ttyAMA0 alias
|
|
||||||
SUBSYSTEM=="tty", KERNEL=="ttyS1", SYMLINK+="ttyAMA0", GROUP="dialout"
|
|
||||||
|
|
||||||
# Disable USB autosuspend (prevents serial adapters from sleeping)
|
|
||||||
ACTION=="add", SUBSYSTEM=="usb", TEST=="power/control", ATTR{power/control}="on"
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
|
|
@ -11,10 +11,6 @@
|
||||||
{ config, lib, pkgs, ... }:
|
{ 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 = {
|
boot = {
|
||||||
loader = {
|
loader = {
|
||||||
systemd-boot.enable = true;
|
systemd-boot.enable = true;
|
||||||
|
|
@ -46,12 +42,21 @@
|
||||||
"kvm-intel"
|
"kvm-intel"
|
||||||
"i2c-dev"
|
"i2c-dev"
|
||||||
"spi-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 = [
|
kernelParams = [
|
||||||
"i915.enable_psr=0"
|
"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"
|
"quiet"
|
||||||
"splash"
|
"splash"
|
||||||
];
|
];
|
||||||
|
|
@ -99,9 +104,37 @@
|
||||||
hybrid-sleep.enable = false;
|
hybrid-sleep.enable = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
# Camera + LED/SPI peripherals. The serial rules (validator/dispenser/printer
|
# Serial port permissions + tejo-specific symlinks
|
||||||
# symlinks + permissions) are shared with the live ISO in ./upboard-serial.nix.
|
|
||||||
services.udev.extraRules = lib.mkAfter ''
|
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 ─────────────────────────────────────────────────
|
# ── 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-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"
|
SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-2", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan"
|
||||||
|
|
@ -114,5 +147,8 @@
|
||||||
SUBSYSTEM=="spidev", GROUP="spi", MODE="0660"
|
SUBSYSTEM=="spidev", GROUP="spi", MODE="0660"
|
||||||
SUBSYSTEM=="i2c-dev", GROUP="i2c", 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 {} +"
|
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"
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,17 +21,19 @@ let
|
||||||
batm3 = "USD";
|
batm3 = "USD";
|
||||||
}.${machineModel} or "USD";
|
}.${machineModel} or "USD";
|
||||||
|
|
||||||
# Minimal .env template (aiolabs/bitspire#70 remnant hygiene). Seed ONLY
|
# .env template — runtime secrets are provisioned later via provision-atm.sh.
|
||||||
# image-baked, non-maskable values. Relay + server pubkey come from the pairing
|
# Only non-secret defaults and display vars go here.
|
||||||
# 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.
|
|
||||||
envTemplate = pkgs.writeText "bitspire-env" ''
|
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_MACHINE_MODEL=${machineModel}
|
||||||
VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel}
|
VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel}
|
||||||
VITE_SPIRE_SEED=
|
|
||||||
ELECTRON_FORCE_PROD=1
|
ELECTRON_FORCE_PROD=1
|
||||||
DISPLAY=:0
|
DISPLAY=:0
|
||||||
'';
|
'';
|
||||||
|
|
@ -47,24 +49,13 @@ in
|
||||||
|
|
||||||
# Reuse ATM systemd service module
|
# Reuse ATM systemd service module
|
||||||
./bitspire-atm.nix
|
./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
|
# ISO image settings
|
||||||
image.fileName = "bitspire-${machineModel}-live.iso";
|
image.fileName = "bitspire-${machineModel}-live.iso";
|
||||||
isoImage = {
|
isoImage = {
|
||||||
makeEfiBootable = true;
|
makeEfiBootable = true;
|
||||||
makeBiosBootable = 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";
|
squashfsCompression = "zstd -Xcompression-level 6";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -176,29 +167,19 @@ in
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Install the .env on first boot. Attrset form with deps=["users"] so the
|
# Install the .env on first boot
|
||||||
# chown runs AFTER the bitspire user is created. Otherwise on a fresh live
|
system.activationScripts.bitspire-env = ''
|
||||||
# boot (where /var/lib/bitspire/.env doesn't exist yet) the chown runs in the
|
mkdir -p /var/lib/bitspire
|
||||||
# default activation order — before `users` — and fails with
|
if [ ! -f /var/lib/bitspire/.env ]; then
|
||||||
# "chown: invalid user: 'bitspire:bitspire'". The installed system skips this
|
cp ${envTemplate} /var/lib/bitspire/.env
|
||||||
# block because its .env already exists, which is why only live boots tripped.
|
chmod 600 /var/lib/bitspire/.env
|
||||||
system.activationScripts.bitspire-env = {
|
chown bitspire:bitspire /var/lib/bitspire/.env
|
||||||
deps = [ "users" ];
|
fi
|
||||||
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
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
# Reset display output after X starts (required for kexec boots where
|
# Reset display output after X starts (required for kexec boots where
|
||||||
# the GPU wasn't reinitialized by BIOS firmware). Only the eDP-panel models
|
# the GPU wasn't reinitialized by BIOS firmware)
|
||||||
# (Douro/Tejo) have an eDP-1 output; the Sintra drives HDMI-1, so the
|
systemd.services.display-reset = {
|
||||||
# `xrandr --output eDP-1` here just errors out — skip it there.
|
|
||||||
systemd.services.display-reset = lib.mkIf (machineModel != "sintra") {
|
|
||||||
description = "Reset eDP display output";
|
description = "Reset eDP display output";
|
||||||
after = [ "display-manager.service" ];
|
after = [ "display-manager.service" ];
|
||||||
requires = [ "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
|
# Swap file — Douro/Tejo have only 2GB RAM; without swap the system
|
||||||
# under memory pressure. The live system is RAM-rooted, so a /var/swapfile
|
# hard-freezes under memory pressure instead of gracefully OOM-killing.
|
||||||
# lives in tmpfs — pointless, and its init fails on a fresh boot. Use
|
swapDevices = [{
|
||||||
# compressed RAM swap (zram) instead; no on-disk file required.
|
device = "/var/swapfile";
|
||||||
zramSwap.enable = true;
|
size = 1024; # MB
|
||||||
|
}];
|
||||||
# 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 { };
|
|
||||||
|
|
||||||
# Clean /tmp on boot to prevent stale Nix build artifacts from filling disk
|
# Clean /tmp on boot to prevent stale Nix build artifacts from filling disk
|
||||||
boot.tmp.cleanOnBoot = true;
|
boot.tmp.cleanOnBoot = true;
|
||||||
|
|
|
||||||
|
|
@ -4,25 +4,16 @@
|
||||||
# kind-21000 NIP-44 v2 events on a relay — there is no out-of-band token,
|
# 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 ATM's nostr private key IS the credential. # pragma: allowlist secret
|
||||||
#
|
#
|
||||||
# The primary input is SPIRE_SEED — the pairing seed carries the relay, the
|
# Required environment variables (or edit defaults below):
|
||||||
# LNbits server pubkey AND the signing identity, so a seed-provisioned machine
|
# LNBITS_SERVER_PUBKEY Hex pubkey published by the LNbits server at startup.
|
||||||
# needs nothing else (aiolabs/bitspire#70).
|
# From the LNbits compose:
|
||||||
#
|
# docker logs lnbits | grep 'nostr_transport pubkey'
|
||||||
# Environment variables:
|
# LNBITS_HTTP_URL Origin LNbits is reachable at over HTTP, used only
|
||||||
# SPIRE_SEED RECOMMENDED. The spire pairing seed
|
# to compose the LNURL-withdraw callback URL that
|
||||||
# (`spire-seed:v1:<base64url>`) minted by spirekeeper.
|
# customer wallets dereference. Default: http://10.0.2.2:5000
|
||||||
# Carries relay + LNbits server pubkey + the production
|
# RELAY_URL Nostr relay LNbits subscribes on. Default uses host gateway.
|
||||||
# identity under the NIP-46 bunker (aiolabs/bitspire#52 / #70).
|
# ATM_PRIVATE_KEY 32-byte hex key, ATM's nostr identity. If unset, a
|
||||||
# RELAY_URL OPTIONAL override — pins VITE_RELAY_URL and WINS over the
|
# fresh key is generated and saved in the .env.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# bash provision-atm.sh # defaults: SSH to localhost:2222 (QEMU)
|
# 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 ---"
|
echo "--- LAN ATM: using $HOST_IP as dev machine address ---"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Steps 2-4: transport config (relay + LNbits server pubkey) + signing identity.
|
# Step 2: Resolve the LNbits server pubkey. Prefer the env override; else
|
||||||
#
|
# fall back to scraping the local docker compose stack.
|
||||||
# Under aiolabs/bitspire#70 the relay + server pubkey come from the pairing SEED,
|
if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
||||||
# so a seed-provisioned machine needs NEITHER in .env. We only pin them when the
|
|
||||||
# operator EXPLICITLY passes RELAY_URL / LNBITS_SERVER_PUBKEY (a deliberate
|
|
||||||
# override that WINS over the seed via env-first precedence), or when there is no
|
|
||||||
# seed (the dev-nsec fallback has nothing else to supply them, so we scrape/default).
|
|
||||||
TRANSPORT_LINES=""
|
|
||||||
|
|
||||||
if [ -n "${SPIRE_SEED:-}" ]; then
|
|
||||||
case "$SPIRE_SEED" in
|
|
||||||
spire-seed:v1:*) : ;;
|
|
||||||
*) echo "ERROR: SPIRE_SEED must start with 'spire-seed:v1:'"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- Spire pairing seed: relay + LNbits pubkey come from the seed ---"
|
echo "--- Step 1: Extracting LNbits nostr-transport pubkey from docker logs ---"
|
||||||
if [ -n "${RELAY_URL:-}" ]; then
|
LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \
|
||||||
echo " (pinning VITE_RELAY_URL=$RELAY_URL — overrides the seed's relay)"
|
| grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \
|
||||||
TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL"
|
| 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
|
fi
|
||||||
if [ -n "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
fi
|
||||||
TRANSPORT_LINES="${TRANSPORT_LINES:+$TRANSPORT_LINES
|
echo "LNbits server pubkey: ${LNBITS_SERVER_PUBKEY:0:16}..."
|
||||||
}VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY"
|
|
||||||
fi
|
# Step 3: Pin LNbits HTTP origin.
|
||||||
IDENTITY_LINES="# Spire pairing seed — bunker-backed identity (aiolabs/bitspire#52)
|
LNBITS_HTTP_URL="${LNBITS_HTTP_URL:-http://$HOST_IP:5000}"
|
||||||
VITE_SPIRE_SEED=$SPIRE_SEED"
|
|
||||||
else
|
# Step 4: Relay URL.
|
||||||
# No seed → DEV-ONLY nsec fallback. Nothing else supplies the relay + pubkey,
|
RELAY_URL="${RELAY_URL:-ws://$HOST_IP:7777}"
|
||||||
# so scrape/default them.
|
|
||||||
if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
# Step 5: ATM identity. Generate if unset.
|
||||||
echo ""
|
if [ -z "${ATM_PRIVATE_KEY:-}" ]; then
|
||||||
echo "--- No seed: extracting LNbits nostr-transport pubkey from docker logs ---"
|
ATM_PRIVATE_KEY=$(openssl rand -hex 32)
|
||||||
LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \
|
echo ""
|
||||||
| grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \
|
echo "--- Generated fresh ATM_PRIVATE_KEY (save this if you want it persisted) ---"
|
||||||
| 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
|
fi
|
||||||
|
|
||||||
# Step 6: Write .env to the ATM via SSH.
|
# Step 6: Write .env to the ATM via SSH.
|
||||||
|
|
@ -124,12 +90,13 @@ echo "--- Step 2: Writing .env to ATM ---"
|
||||||
ENV_CONTENT="# bitSpire Configuration
|
ENV_CONTENT="# bitSpire Configuration
|
||||||
# Auto-generated by provision-atm.sh on $(date -Iseconds)
|
# Auto-generated by provision-atm.sh on $(date -Iseconds)
|
||||||
|
|
||||||
# LNbits nostr-transport. Relay + server pubkey come from the pairing seed
|
# LNbits nostr-transport connection
|
||||||
# (aiolabs/bitspire#70); present below only as an explicit override or the
|
VITE_RELAY_URL=$RELAY_URL
|
||||||
# no-seed dev fallback.
|
VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY
|
||||||
$TRANSPORT_LINES
|
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
|
# Machine configuration
|
||||||
VITE_LAMASSU_MACHINE_MODEL=$MODEL
|
VITE_LAMASSU_MACHINE_MODEL=$MODEL
|
||||||
|
|
@ -146,6 +113,6 @@ echo ""
|
||||||
echo "=== ATM provisioned successfully ==="
|
echo "=== ATM provisioned successfully ==="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Credentials written to /var/lib/bitspire/.env"
|
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 ""
|
||||||
echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"
|
echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
# ADR-002: Remote Access & Fleet Management — Three Planes, Operator-Owned Access via NetBird
|
|
||||||
|
|
||||||
**Status:** Accepted
|
|
||||||
**Date:** 2026-06-14
|
|
||||||
**Context:** Multi-operator bitSpire fleet — separating the payment, control, and recovery planes by who owns them.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
1. **Separate three planes by trust owner**, and never conflate them:
|
|
||||||
- **Payment plane** — ATM ↔ LNbits over the nostr-native-transport. Owned by the **SaaS operator**. Implies *no* machine access.
|
|
||||||
- **Fleet control plane** — routine ops/telemetry/enrollment over Nostr (see [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42)). Authorized by the **machine operator's** key.
|
|
||||||
- **Access / recovery plane** — SSH for the unanticipated and the broken. Owned by the **machine operator**.
|
|
||||||
|
|
||||||
2. **The machine operator's own recovery access is provisioned at install and is app-independent.** Their SSH key and their VPN/NetBird enrollment are established when the machine is set up, so they can always reach a box even when the bitSpire app or OS is broken. Access for *anyone else* is runtime-granted, scoped, and revocable — never the owner's own path.
|
|
||||||
|
|
||||||
3. **Adopt NetBird as the standard access/recovery plane**, chosen for fleet scale and a **self-hostable, fully FOSS control plane**. The platform may provide a default NetBird setup as a convenience; a machine operator who does not wish to trust whoever runs that control plane **disables it and provisions their own access plane** (self-hosted NetBird, or their own WireGuard hub).
|
|
||||||
|
|
||||||
4. **We will NOT build a "revoke SaaS-operator access" toggle in the operator dashboard.** It is a false promise of security: the SaaS operator runs LNbits (and, in the default deployment, the NetBird control plane), so a toggle they ultimately control cannot protect a machine operator against them. The honest boundary is **exclusion-by-ownership, not exclusion-by-toggle** — an operator who wants to exclude the SaaS operator takes ownership of the access plane.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
### The players
|
|
||||||
|
|
||||||
A deployed bitSpire machine sits between two distinct principals:
|
|
||||||
|
|
||||||
- **SaaS operator** — runs the LNbits instance and provides the Lightning backend as a service.
|
|
||||||
- **Machine operator** — owns the physical ATM(s) and is identified by a Nostr key (the operator pubkey in the [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) allow-list).
|
|
||||||
|
|
||||||
These are different parties with different interests. A machine operator will want to SSH to their own machine for support and recovery, and **may or may not want to grant the SaaS operator that same access.**
|
|
||||||
|
|
||||||
### Why the SaaS operator needs zero box access by design
|
|
||||||
|
|
||||||
The whole nostr-native architecture (no admin tokens on the kiosk, no inbound network surface, payment over Nostr) means the SaaS operator can deliver the full service **without ever touching the machine**. So "the machine operator may refuse the SaaS operator access" is not a constraint to engineer around — it is the **default that costs nothing**. SaaS-operator box access is a *support convenience*, never a service requirement. The natural posture is therefore **default-deny for the SaaS operator**.
|
|
||||||
|
|
||||||
### Why SSH can't be replaced by the Nostr control plane
|
|
||||||
|
|
||||||
The Nostr control plane (#42) is a fixed menu of structured, capability-scoped commands dispatched by a handler *inside the app*. It is excellent for routine, auditable, fleet-wide ops on **healthy** machines, and strictly better than SSH for those (signed, scoped, logged, fan-out). But:
|
|
||||||
|
|
||||||
- It can only do what a handler was written for; incidents are by definition unanticipated.
|
|
||||||
- The listener lives in the app, so it dies exactly when the app dies — the case you most need recovery for.
|
|
||||||
|
|
||||||
SSH (arbitrary, interactive, app-independent) is therefore irreducible as the **recovery plane**. The two are complements, not substitutes.
|
|
||||||
|
|
||||||
### Why the recovery path must be app-independent
|
|
||||||
|
|
||||||
The whole point of a recovery path is to survive the failure of the thing it recovers. So it must not be gated by the bitSpire app, nor by a Nostr command the app dispatches. The kernel/agent that carries the tunnel and `sshd` must come up at boot independent of the app. (`allowedTCPPorts = []` already means `sshd` is unreachable except across the tunnel — the VPN handshake is the outer lock, the SSH key the inner one.)
|
|
||||||
|
|
||||||
### Why the single shared hub had to change
|
|
||||||
|
|
||||||
The pre-existing design used one WireGuard hub (`170.75.161.21`) run by platform infra. Whoever runs that hub has a standing network path to every enrolled box — i.e. the SaaS operator having access to machines they don't own. Multi-tenancy requires the access plane to be **per-operator or policy-isolated**, rooted in the machine operator, not the platform.
|
|
||||||
|
|
||||||
## Options Considered
|
|
||||||
|
|
||||||
### Access-plane mechanism
|
|
||||||
|
|
||||||
#### Option A: Always-up minimal WireGuard hub
|
|
||||||
|
|
||||||
**Pros:** After boot, zero userspace dependency — the kernel holds the tunnel, nothing can crash it short of a kernel/networking fault; smallest, most battle-tested trusted-code surface; simplest possible recovery floor.
|
|
||||||
**Cons:** Manual peer management; no policy/ACL/enrollment ergonomics; a single shared hub re-creates the multi-tenant trust problem (must be run per-operator to avoid it); does not scale operationally to many operators × many machines.
|
|
||||||
|
|
||||||
#### Option B: NetBird (Selected)
|
|
||||||
|
|
||||||
**Pros:** Policy/ACL-based, revocable, per-peer access control; enrollment + audit out of the box; **self-hostable, fully FOSS control plane** — we retain the ability to run and modify every layer; scales to the many-operators × many-machines world #42 anticipates.
|
|
||||||
**Cons:** The NetBird agent is a userspace daemon, so the recovery path depends on that daemon being up (less bulletproof than kernel-level always-up WG) — mitigated by it being independent of the bitSpire app, mature, and systemd-restarted; running a control plane is operational weight (acceptable: the platform provides a default; sovereignty-seeking operators self-host).
|
|
||||||
|
|
||||||
#### Option C: Tailscale
|
|
||||||
|
|
||||||
**Pros:** Best-in-class ergonomics and NAT traversal.
|
|
||||||
**Cons:** **Control plane is closed source with no FOSS alternative** (headscale only reimplements the coordination server, chasing an upstream we don't control). Fails the hard requirement that we can always self-host and modify any software we depend on. Rejected on that basis alone.
|
|
||||||
|
|
||||||
#### Option D: On-demand tunnel toggled by the Nostr control plane
|
|
||||||
|
|
||||||
**Pros:** No standing reachability; every access window is a signed, audited, time-boxed event.
|
|
||||||
**Cons:** If the toggle is handled by the app, it fails in the exact recovery scenario (listener died with the app). If handled by a separate daemon, it reintroduces a privileged userspace listener into the recovery path and grows, rather than shrinks, the trusted-code surface. Acceptable only as an **audited convenience layer on top of** an always-available floor (and designed to fail open), never as the load-bearing gate. Not adopted as the primary mechanism.
|
|
||||||
|
|
||||||
### Trust model for excluding the SaaS operator
|
|
||||||
|
|
||||||
#### Option 1: Dashboard toggle to revoke SaaS-operator access (Rejected)
|
|
||||||
|
|
||||||
The SaaS operator controls LNbits (the machine's wallet/account is an LNbits user they can administer) and, in the default deployment, the NetBird control plane. A toggle whose enforcement they ultimately control gives the machine operator no real protection against them — it is security theater. **Rejected as a false promise.**
|
|
||||||
|
|
||||||
#### Option 2: Exclusion by ownership (Selected)
|
|
||||||
|
|
||||||
The only honest way for a machine operator to exclude the SaaS operator is to **own the access plane**: disable the default (platform-provided) NetBird enrollment and stand up their own — self-hosted NetBird, or their own WireGuard hub. The default deployment trusts whoever runs the control plane *and says so plainly*; operators who won't extend that trust take ownership. Control = ownership; we do not pretend otherwise.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
### Positive
|
|
||||||
|
|
||||||
- Honest trust boundaries: the SaaS operator has no standing box access by default, and the limits of platform-provided convenience are stated rather than faked.
|
|
||||||
- Scales to many operators × many machines via NetBird policy/enrollment, while preserving a self-hosting escape hatch for sovereignty.
|
|
||||||
- The recovery plane survives app and OS failure because it is provisioned at install and independent of the runtime.
|
|
||||||
- Every dependency remains FOSS and self-hostable — no closed control plane anywhere in the stack.
|
|
||||||
|
|
||||||
### Negative
|
|
||||||
|
|
||||||
- The NetBird agent is a standing userspace daemon; a box where *both* the app and the agent are down falls to the physical/LAN floor (same floor as any remote scheme — only pure kernel-WG narrows it, at the cost of NetBird's ergonomics). Operators who weight reliability over ergonomics can choose self-hosted plain WireGuard.
|
|
||||||
- Sovereignty for a distrusting operator costs them operational work (running their own access plane). This is inherent to "control = ownership," not incidental.
|
|
||||||
- Two enrollment surfaces at provisioning: app/payment identity (#42 seed URL) and system/access identity (this plane). They must be kept conceptually distinct.
|
|
||||||
|
|
||||||
### Future Considerations
|
|
||||||
|
|
||||||
- An **audited convenience layer** (Nostr `OpenAccess`/`CloseAccess` that opens a time-boxed SSH window and logs it as a signed event) may be added *on top of* the always-available floor, designed to fail open, for the routine "let me in" case. It is explicitly not the recovery gate.
|
|
||||||
- The machine operator's Nostr key can become the single root of trust across all three planes — SSH `authorized_keys` + VPN enrollment at install, `AddOperator`/`RevokeOperator` (#42) for delegation — so granting/revoking any party (including the SaaS operator) is one scoped, revocable capability model.
|
|
||||||
- `sshd` posture should be tightened to key-only for deployed boxes (password auth is currently forced on for installed configs for first-boot provisioning; scope it to the LAN/first-boot window). Tracks with [#51](https://git.atitlan.io/aiolabs/bitspire/issues/51).
|
|
||||||
|
|
||||||
## Amendment (2026-08-04): the access/recovery plane is not the *only* recovery
|
|
||||||
|
|
||||||
**Status:** Accepted · **Context:** the ATM app had no way to recover its own
|
|
||||||
connectivity — a machine that booted with no internet (or whose init otherwise
|
|
||||||
failed) sat on "ATM Unavailable" until a manual `systemctl restart bitspire`,
|
|
||||||
even after the network came back.
|
|
||||||
|
|
||||||
This ADR's SSH/NetBird recovery plane stands — it is the operator's
|
|
||||||
**app-and-OS-independent** path for the unanticipated and the broken, and may
|
|
||||||
carry recovery *procedures* (restart the service, inspect logs, re-provision).
|
|
||||||
But it is explicitly **not the first-line and not the only recovery method.**
|
|
||||||
Recovery is layered, cheapest-first:
|
|
||||||
|
|
||||||
1. **App auto-recovery (first-line, no human).** The ATM app recovers its own
|
|
||||||
relay/Lightning connectivity when possible: the nostr client already
|
|
||||||
reconnects with backoff, and the app now re-initializes when connectivity
|
|
||||||
returns (a fresh renderer reload — HAL is preserved in the main process),
|
|
||||||
so "internet came back" self-heals without anyone touching the machine.
|
|
||||||
2. **On-screen manual retry (operator at the machine).** The maintenance
|
|
||||||
("ATM Unavailable") screen carries a **Retry** button so a person standing
|
|
||||||
at the kiosk can force an immediate recovery attempt without shell access.
|
|
||||||
3. **SSH/NetBird (operator remote, last resort).** This plane — for when the
|
|
||||||
app *can't* self-heal or the box is genuinely broken. Unchanged by this
|
|
||||||
amendment beyond the reframing: it is the floor, not the front line.
|
|
||||||
|
|
||||||
Rationale: the common failure (transient network / boot-before-network) must
|
|
||||||
not require remote shell access to a public kiosk. Reserve the heavyweight
|
|
||||||
recovery plane for genuine app/OS failure. Implemented on branch
|
|
||||||
`feat/connection-recovery`.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [#41](https://git.atitlan.io/aiolabs/bitspire/issues/41) — Multi-location deployment: runtime site config (the access plane's per-machine identity is provisioned here, not baked into the closure).
|
|
||||||
- [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) — Fleet management: Nostr-native remote control & telemetry (the control plane this ADR sits beside).
|
|
||||||
- [#51](https://git.atitlan.io/aiolabs/bitspire/issues/51) — NixOS systemd hardening (sshd posture tightening).
|
|
||||||
- [#52](https://git.atitlan.io/aiolabs/bitspire/issues/52) — Sidecar bunker for the ATM key (related key-handling direction).
|
|
||||||
- `deploy/nixos/configuration.nix` — current WireGuard hub + `sshd` config (to be reworked per this decision).
|
|
||||||
- NetBird — <https://github.com/netbirdio/netbird> (self-hostable, FOSS control plane).
|
|
||||||
|
|
@ -1,110 +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
|
|
||||||
|
|
||||||
- **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a
|
|
||||||
fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry
|
|
||||||
while a tap is in flight and leaves `displayingQR` on success; the withdraw
|
|
||||||
link is `uses:1`. A customer would have to both tap and pull near-simultaneously
|
|
||||||
to double-collect — acceptable for now, revisit if it bites.
|
|
||||||
- **Future nostr transport:** `resolveCardPayTarget` (the `/pay` GET) is the one
|
|
||||||
HTTPS-today / nostr-tomorrow seam. A nostr-native boltcard would answer the
|
|
||||||
same `external_id + SUN` identity over the ATM's existing nostr connection,
|
|
||||||
dropping the clearnet HTTPS call. The rest (standard LNURL-pay) is unchanged.
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
# Deploying bitSpire to a Raspberry Pi (4 or 5)
|
|
||||||
|
|
||||||
The DIY reference build: a Raspberry Pi, a bill validator on USB-serial, a
|
|
||||||
touchscreen and a QR scanner. Both boards share one runtime in `flake.nix`
|
|
||||||
(`piBaseModules`) and differ only in their hardware glue module:
|
|
||||||
|
|
||||||
| Board | `nixosConfigurations` | Flashable image | Hardware glue |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Raspberry Pi 5 | `rpi5-installed`, `rpi5-image` | `packages.aarch64-linux.sd-image-rpi5` | `deploy/nixos/hardware/raspberry-pi-5.nix` |
|
|
||||||
| Raspberry Pi 4 | `rpi4-installed`, `rpi4-image` | `packages.aarch64-linux.sd-image-rpi4` | `deploy/nixos/hardware/raspberry-pi-4.nix` |
|
|
||||||
|
|
||||||
`<board>-image` is what you flash; `<board>-installed` is what a running Pi
|
|
||||||
rebuilds itself against afterwards. They share every module except the root
|
|
||||||
filesystem declaration and the SD-image builder — see the comments above
|
|
||||||
`mkPiInstalled` / `mkPiImage` in `flake.nix` for why they're split.
|
|
||||||
|
|
||||||
**Status:** the Pi 4 target is evaluation-verified (it instantiates, and its
|
|
||||||
configuration differs from the Pi 5's only in the expected board-specific
|
|
||||||
places) but has not yet been booted on hardware. Treat the first bring-up as
|
|
||||||
exactly that.
|
|
||||||
|
|
||||||
## What's different from the x86 fleet
|
|
||||||
|
|
||||||
- **aarch64.** Any Pi build needs an aarch64 builder — a Pi itself, an ARM
|
|
||||||
box, or `boot.binfmt.emulatedSystems = [ "aarch64-linux" ]` on an x86 host.
|
|
||||||
The Electron app closure will not build on plain x86.
|
|
||||||
- **Boot.** Raspberry Pi firmware + U-Boot + extlinux (from `nixos-hardware`),
|
|
||||||
not systemd-boot. The image is an SD-card image, not a GPT disk image.
|
|
||||||
- **No `determinate`, no `atm-tui`** in the Pi runtime yet (neither ships an
|
|
||||||
aarch64 package). Add them when they do.
|
|
||||||
- **Cachix is pre-wired** (`aiolabs.cachix.org` in `nix.settings`), so an
|
|
||||||
in-place rebuild substitutes the heavy closure rather than compiling on the
|
|
||||||
Pi — provided the closure was pushed there first.
|
|
||||||
|
|
||||||
## Board differences that matter
|
|
||||||
|
|
||||||
| | Pi 5 | Pi 4 |
|
|
||||||
|---|---|---|
|
|
||||||
| SoC / device tree | BCM2712 (`bcm2712*-rpi-*.dtb`) | BCM2711 (`bcm2711-rpi-4*.dtb`) |
|
|
||||||
| Kiosk GPU / KMS | vc4/v3d on by default | opt-in via `hardware.raspberry-pi."4".fkms-3d` (the glue module enables it; it also injects the CMA + vc4 overlays) |
|
|
||||||
| RAM | 4–16 GB | 4 GB is the floor for Electron; 8 GB is comfortable |
|
|
||||||
| Root storage | SD, USB, or NVMe over PCIe | SD or USB only |
|
|
||||||
| Power | 5 V / 5 A USB-C PD | 5 V / 3 A; Electron startup is the peak |
|
|
||||||
|
|
||||||
Everything else — validator serial symlinks, `console=tty0` keeping the GPIO
|
|
||||||
UART free, no-suspend, the bitspire service — is identical between the two
|
|
||||||
glue modules by design. Keep it that way: a change to one almost certainly
|
|
||||||
belongs in the other.
|
|
||||||
|
|
||||||
## 1. Build the image
|
|
||||||
|
|
||||||
On (or via) an aarch64 builder:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nix build .#packages.aarch64-linux.sd-image-rpi4 # or sd-image-rpi5
|
|
||||||
ls result/sd-image/
|
|
||||||
# → nixos-image-sd-card-<version>-aarch64-linux.img.zst
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Flash
|
|
||||||
|
|
||||||
```bash
|
|
||||||
lsblk -f # identify the SD card — NOT your main disk
|
|
||||||
zstd -dc result/sd-image/*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
|
|
||||||
```
|
|
||||||
|
|
||||||
The image carries two labelled partitions the installed config expects:
|
|
||||||
`FIRMWARE` (vfat, Pi firmware + U-Boot) and `NIXOS_SD` (ext4 root). The root
|
|
||||||
partition grows to fill the card on first boot.
|
|
||||||
|
|
||||||
## 3. First boot
|
|
||||||
|
|
||||||
Insert the card, connect Ethernet and power. The Pi boots into the kiosk with
|
|
||||||
no pairing, so the screen shows the pairing wizard — with a camera it waits
|
|
||||||
for a QR; without one it says so and tells you to provision `VITE_SPIRE_SEED`
|
|
||||||
instead. It also comes up with sshd and password auth enabled, same as the
|
|
||||||
x86 installed configs — this is the provisioning window.
|
|
||||||
|
|
||||||
Provision exactly as for a Sintra — from the dev box, with the spire seed
|
|
||||||
minted by spirekeeper:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SPIRE_SEED='spire-seed:v1:…' bash deploy/nixos/provision-atm.sh <pi-ip> 22
|
|
||||||
```
|
|
||||||
|
|
||||||
That writes `/var/lib/bitspire/.env` and restarts the service; the seed
|
|
||||||
carries the relay and the LNbits transport pubkey, so nothing else is needed.
|
|
||||||
Alternatively, show the seed's QR to the machine's camera and let the
|
|
||||||
on-screen wizard do the same thing. Verify with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh bitspire@<pi-ip> 'journalctl -u bitspire -n 50 --no-pager | grep "\["'
|
|
||||||
# expect [Signer] Pairing to bunker … then [Lightning] LNbits client initialized
|
|
||||||
```
|
|
||||||
|
|
||||||
The dev-only `VITE_ATM_PRIVATE_KEY` fallback works here too for a bench
|
|
||||||
setup without a bunker — see `provision-atm.sh`'s header for the variables.
|
|
||||||
|
|
||||||
## 4. Updating in place
|
|
||||||
|
|
||||||
Once flashed, never re-flash for a software update. The `-installed` target
|
|
||||||
is the aarch64 twin of the fleet's rebuild ritual:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nixos-rebuild switch --flake \
|
|
||||||
"git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#rpi4-installed"
|
|
||||||
```
|
|
||||||
|
|
||||||
(Pi 5: `#rpi5-installed`.) With the closure on cachix this is a download, not
|
|
||||||
a build. If it starts compiling Electron on the Pi, the closure wasn't pushed
|
|
||||||
— stop, build on the aarch64 builder, `cachix push aiolabs`, retry.
|
|
||||||
|
|
||||||
## 5. Wiring the peripherals
|
|
||||||
|
|
||||||
- **Bill validator** — over a USB-serial adapter. The glue modules give
|
|
||||||
stable symlinks so `.env` never depends on enumeration order:
|
|
||||||
FTDI → `/dev/ttyValidator0`, CP210x → `/dev/ttyValidator1`,
|
|
||||||
CH340 → `/dev/ttyValidator2`. Two adapters of the *same* chip need
|
|
||||||
disambiguating by `KERNELS`/serial in the udev rule — do that at bring-up.
|
|
||||||
A validator wired straight to the GPIO UART (pins 14/15) also works: the
|
|
||||||
kernel console is pinned to `tty0` precisely so that UART stays free.
|
|
||||||
- **QR scanner** — USB HID keyboard-emulation, no configuration.
|
|
||||||
- **Touchscreen** — DSI or HDMI. X runs on the Pi's KMS driver.
|
|
||||||
- **Serial console for debugging** — there isn't one by default (see above).
|
|
||||||
Use SSH, or temporarily add `console=ttyAMA0,115200` to
|
|
||||||
`boot.kernelParams` in the glue module.
|
|
||||||
|
|
||||||
## Hardware notes for 24/7 operation
|
|
||||||
|
|
||||||
- **Storage.** SD cards have finite write endurance; for anything beyond a
|
|
||||||
bench build put root on a USB-SATA SSD (both boards) or NVMe (Pi 5).
|
|
||||||
`state.db` and the journal are the writers.
|
|
||||||
- **Thermal.** Both boards throttle under sustained load without cooling.
|
|
||||||
Heatsinks at minimum; the official active cooler for the Pi 5.
|
|
||||||
- **Power.** Brown-outs on Electron startup look like random reboots. Use the
|
|
||||||
official supply or one rated above the board's peak, never a hub.
|
|
||||||
- **Swap.** The runtime provisions a 2 GB swapfile so memory pressure degrades
|
|
||||||
instead of hard-freezing. On a 4 GB Pi 4 that is not optional.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- `deploy/nixos/README.md` — the x86 fleet pipeline this mirrors
|
|
||||||
- `docs/machine-installation.md` — why images rather than `nixos-install`
|
|
||||||
- `deploy/nixos/hardware/raspberry-pi-{4,5}.nix` — the per-board glue
|
|
||||||
- `flake.nix` — `piBoards`, `piBaseModules`, `mkPiInstalled`, `mkPiImage`
|
|
||||||
34
flake.lock
generated
34
flake.lock
generated
|
|
@ -405,24 +405,6 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixos-hardware": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": "nixpkgs_3"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1786867632,
|
|
||||||
"narHash": "sha256-ez+ubZlA1RtdjCB18a6zJ9M4u8qoPDy08EcnsW5M3Xw=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixos-hardware",
|
|
||||||
"rev": "ff17823245ab9ff7bcae6acf950bd89cba82c38c",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixos-hardware",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773222311,
|
"lastModified": 1773222311,
|
||||||
|
|
@ -500,19 +482,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_3": {
|
"nixpkgs_3": {
|
||||||
"locked": {
|
|
||||||
"lastModified": 1767892417,
|
|
||||||
"narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=",
|
|
||||||
"rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
|
|
||||||
"type": "tarball",
|
|
||||||
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"type": "tarball",
|
|
||||||
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs_4": {
|
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1779467186,
|
"lastModified": 1779467186,
|
||||||
"narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=",
|
"narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=",
|
||||||
|
|
@ -534,8 +503,7 @@
|
||||||
"determinate": "determinate",
|
"determinate": "determinate",
|
||||||
"devenv": "devenv",
|
"devenv": "devenv",
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixos-hardware": "nixos-hardware",
|
"nixpkgs": "nixpkgs_3",
|
||||||
"nixpkgs": "nixpkgs_4",
|
|
||||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
440
flake.nix
440
flake.nix
|
|
@ -36,12 +36,9 @@
|
||||||
url = "git+ssh://forgejo@git.atitlan.io/aiolabs/atm-tui.git";
|
url = "git+ssh://forgejo@git.atitlan.io/aiolabs/atm-tui.git";
|
||||||
inputs.nixpkgs.follows = "nixpkgs-unstable";
|
inputs.nixpkgs.follows = "nixpkgs-unstable";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Raspberry Pi 5 (aarch64) hardware support for the DIY Pi build.
|
|
||||||
nixos-hardware.url = "github:NixOS/nixos-hardware";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, nixpkgs-unstable, flake-utils, rust-overlay, devenv, determinate, atm-tui, nixos-hardware }:
|
outputs = { self, nixpkgs, nixpkgs-unstable, flake-utils, rust-overlay, devenv, determinate, atm-tui }:
|
||||||
let
|
let
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
|
|
||||||
|
|
@ -62,24 +59,6 @@
|
||||||
src = self;
|
src = self;
|
||||||
};
|
};
|
||||||
|
|
||||||
# aarch64 (Raspberry Pi 5) toolchain — a parallel set of pkgs + app
|
|
||||||
# builder for the DIY Pi build. Kept fully separate from the x86 fleet
|
|
||||||
# path so nothing above changes.
|
|
||||||
pkgsAarch64 = import nixpkgs {
|
|
||||||
system = "aarch64-linux";
|
|
||||||
config.allowUnfree = true;
|
|
||||||
};
|
|
||||||
pkgsUnstableAarch64 = import nixpkgs-unstable {
|
|
||||||
system = "aarch64-linux";
|
|
||||||
config.allowUnfree = true;
|
|
||||||
overlays = [ (import rust-overlay) ];
|
|
||||||
};
|
|
||||||
mkAtmAppAarch64 = import ./nix/mkAtmApp.nix {
|
|
||||||
pkgs = pkgsAarch64;
|
|
||||||
pkgs-unstable = pkgsUnstableAarch64;
|
|
||||||
src = self;
|
|
||||||
};
|
|
||||||
|
|
||||||
# Fiat code per machine model
|
# Fiat code per machine model
|
||||||
fiatCodeForModel = {
|
fiatCodeForModel = {
|
||||||
douro = "GTQ";
|
douro = "GTQ";
|
||||||
|
|
@ -190,51 +169,45 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
# Auto-upgrade: pulls latest flake and runs nixos-rebuild switch.
|
# Auto-upgrade: pulls latest flake and runs nixos-rebuild switch.
|
||||||
# NOTE: bitSpire machines pull from the `aiolabs/bitspire` repo —
|
# NOTE: this branch (dev) pins the upgrade source to ?ref=dev so
|
||||||
# the post-migration home of this code. This branch (dev) pins the
|
# any ATM flashed from `dev` stays on `dev`. Without the explicit
|
||||||
# upgrade source to ?ref=dev so any ATM flashed from `dev` stays on
|
# ref, nix would resolve to the repo's default branch (main),
|
||||||
# `dev`. Without the explicit ?ref=dev, nix would resolve the repo's
|
# which would silently regress a dev-deployed Sintra back to the
|
||||||
# default branch and could silently change a dev-deployed Sintra at
|
# lamassu-next production code at 04:00. The `main` branch's
|
||||||
# 04:00. The legacy `aiolabs/lamassu-next` repo still feeds the
|
# flake.nix continues to omit ?ref= so production ATMs (batm3,
|
||||||
# not-yet-converted production ATMs (batm3, douro) from its own
|
# douro) keep pulling main HEAD as before.
|
||||||
# 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/lamassu-next.git?ref=dev#<model>-installed
|
||||||
# To update manually: sudo nixos-rebuild switch --flake git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#<model>-installed
|
|
||||||
system.autoUpgrade = {
|
system.autoUpgrade = {
|
||||||
enable = true;
|
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" ];
|
flags = [ "--refresh" ];
|
||||||
dates = "04:00"; # daily at 4am
|
dates = "04:00"; # daily at 4am
|
||||||
allowReboot = false;
|
allowReboot = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
# Minimal env template (aiolabs/bitspire#70 remnant hygiene).
|
# Env template — runtime secrets provisioned via provision-atm.sh.
|
||||||
# Seed ONLY image-baked, non-maskable values. Everything else the
|
# Identity fields are intentionally empty so a fresh disk image
|
||||||
# ATM needs comes from the pairing SEED (relay, lnbits_npub, bunker)
|
# boots cleanly into the "needs provisioning" state; provision-
|
||||||
# or from LNbits over the transport (operator pubkey, fee config) —
|
# atm.sh SSHes in and overwrites with real values.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY are emitted ONLY when
|
# VITE_RELAY_URL seeds from `config.services.bitspire.relayUrl`
|
||||||
# the operator deliberately pins them via the Nix options (non-empty
|
# so the NixOS module's `relayUrl` option becomes the default
|
||||||
# default ""), which is an explicit override that wins over the seed.
|
# without losing the operator's ability to override via .env
|
||||||
|
# (edit the file or re-run provision-atm.sh).
|
||||||
system.activationScripts.bitspire-env = ''
|
system.activationScripts.bitspire-env = ''
|
||||||
mkdir -p /var/lib/bitspire
|
mkdir -p /var/lib/bitspire
|
||||||
if [ ! -f /var/lib/bitspire/.env ]; then
|
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_MACHINE_MODEL=${machineModel}
|
||||||
VITE_LAMASSU_FIAT_CODE=${fiatCode}
|
VITE_LAMASSU_FIAT_CODE=${fiatCode}
|
||||||
VITE_SPIRE_SEED=
|
|
||||||
ELECTRON_FORCE_PROD=1
|
ELECTRON_FORCE_PROD=1
|
||||||
DISPLAY=:0
|
DISPLAY=:0
|
||||||
'' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") ''
|
''} /var/lib/bitspire/.env
|
||||||
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
|
|
||||||
chmod 600 /var/lib/bitspire/.env
|
chmod 600 /var/lib/bitspire/.env
|
||||||
chown bitspire:bitspire /var/lib/bitspire/.env
|
chown bitspire:bitspire /var/lib/bitspire/.env
|
||||||
fi
|
fi
|
||||||
|
|
@ -282,170 +255,6 @@
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# Raspberry Pi 5 (aarch64) DIY build. Two products from one shared runtime:
|
|
||||||
# - mkPiInstalled: the in-place rebuild target. `nixos-rebuild switch
|
|
||||||
# --flake .#rpi5-installed` (or the git+ssh remote form) targets this.
|
|
||||||
# Declares the flashed media's own root fs (NIXOS_SD / FIRMWARE) and
|
|
||||||
# NOTHING image-specific, so a switch on a running Pi never trips over
|
|
||||||
# the sd-image builder.
|
|
||||||
# - mkPiImage: the same runtime + the aarch64 sd-image module, whose
|
|
||||||
# system.build.sdImage is the flashable artifact. The module supplies
|
|
||||||
# its OWN NIXOS_SD/FIRMWARE fileSystems + u-boot firmware, so we must
|
|
||||||
# not re-declare the root fs here (double definition = eval conflict).
|
|
||||||
#
|
|
||||||
# The runtime mirrors mkInstalledConfig (bitspire service, env activation,
|
|
||||||
# electron override, cache substituters) on aarch64 + Pi hardware, but
|
|
||||||
# deliberately drops the x86 fleet machinery for first bring-up: no
|
|
||||||
# determinate, no atm-tui (add once it ships an aarch64 package). Any Pi
|
|
||||||
# build needs an aarch64 builder (native Pi / arm box / binfmt emulation) —
|
|
||||||
# the app closure won't build on x86.
|
|
||||||
mkPiRuntime = machineModel: {
|
|
||||||
atm-app = mkAtmAppAarch64 {
|
|
||||||
model = machineModel;
|
|
||||||
fiatCode = fiatCodeForModel.${machineModel} or "USD";
|
|
||||||
};
|
|
||||||
fiatCode = fiatCodeForModel.${machineModel} or "USD";
|
|
||||||
};
|
|
||||||
|
|
||||||
# Supported Pi boards, keyed by machine model. Each pairs the
|
|
||||||
# nixos-hardware board module (kernel, firmware, bootloader, device tree)
|
|
||||||
# with our own hardware glue (validator serial, kiosk display, no-suspend).
|
|
||||||
# Everything else in the Pi runtime is board-agnostic.
|
|
||||||
piBoards = {
|
|
||||||
rpi5 = {
|
|
||||||
hardware = nixos-hardware.nixosModules.raspberry-pi-5;
|
|
||||||
glue = ./deploy/nixos/hardware/raspberry-pi-5.nix;
|
|
||||||
};
|
|
||||||
rpi4 = {
|
|
||||||
hardware = nixos-hardware.nixosModules.raspberry-pi-4;
|
|
||||||
glue = ./deploy/nixos/hardware/raspberry-pi-4.nix;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# Shared module list (everything EXCEPT the root fs and the sd-image
|
|
||||||
# builder). atm-app/fiatCode are threaded in so both products share one
|
|
||||||
# evaluated app closure.
|
|
||||||
piBaseModules = { machineModel, atm-app, fiatCode }:
|
|
||||||
let board = piBoards.${machineModel}; in [
|
|
||||||
board.hardware
|
|
||||||
./deploy/nixos/configuration.nix
|
|
||||||
./deploy/nixos/bitspire-atm.nix
|
|
||||||
board.glue
|
|
||||||
({ config, lib, pkgs, pkgs-unstable, ... }: {
|
|
||||||
services.bitspire = {
|
|
||||||
enable = true;
|
|
||||||
appDir = "${atm-app}";
|
|
||||||
};
|
|
||||||
|
|
||||||
environment.systemPackages = [
|
|
||||||
(pkgs.writeShellScriptBin "fund-atm" ''
|
|
||||||
exec ${pkgs-unstable.nodejs}/bin/node ${atm-app}/dist-electron/fund-atm.bundle.cjs "$@"
|
|
||||||
'')
|
|
||||||
];
|
|
||||||
environment.variables.ATM_DB_PATH = "/var/lib/bitspire/state.db";
|
|
||||||
|
|
||||||
boot.kernel.sysctl."kernel.unprivileged_userns_clone" = 1;
|
|
||||||
security.sudo.wheelNeedsPassword = false;
|
|
||||||
|
|
||||||
# Pull from the aiolabs binary cache so a `nixos-rebuild switch`
|
|
||||||
# (local or the git+ssh remote form) substitutes the heavy aarch64
|
|
||||||
# closure instead of compiling on the Pi. Mirrors the x86 fleet's
|
|
||||||
# nix.settings, minus max-jobs/timeout — the Pi 5 can actually build
|
|
||||||
# locally if it must, so we don't want the 60s watchdog killing a
|
|
||||||
# legitimate first build.
|
|
||||||
nix.settings = {
|
|
||||||
trusted-users = [ "root" "bitspire" ];
|
|
||||||
substituters = [ "https://cache.nixos.org" "https://aiolabs.cachix.org" ];
|
|
||||||
trusted-public-keys = [
|
|
||||||
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
|
|
||||||
"aiolabs.cachix.org-1:PrAjsGU9PE77tFKP2+iO+mgR88c4xv3utM9JmpTblUQ="
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
# Same first-boot env seed as the x86 installed configs.
|
|
||||||
system.activationScripts.bitspire-env = ''
|
|
||||||
mkdir -p /var/lib/bitspire
|
|
||||||
if [ ! -f /var/lib/bitspire/.env ]; then
|
|
||||||
cp ${pkgs.writeText "bitspire-env-default" (''
|
|
||||||
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
|
|
||||||
chmod 600 /var/lib/bitspire/.env
|
|
||||||
chown bitspire:bitspire /var/lib/bitspire/.env
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Electron runtime override (same flags as the x86 fleet, aarch64
|
|
||||||
# electron). No eDP display-reset here — that's UP-Board-specific;
|
|
||||||
# the Pi drives HDMI/DSI directly.
|
|
||||||
systemd.services.bitspire.serviceConfig = {
|
|
||||||
EnvironmentFile = lib.mkForce "/var/lib/bitspire/.env";
|
|
||||||
Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib";
|
|
||||||
ExecStart = lib.mkForce "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-software-rasterizer --enable-logging ${atm-app}";
|
|
||||||
MemoryMax = lib.mkForce "2G";
|
|
||||||
NoNewPrivileges = lib.mkForce false;
|
|
||||||
ProtectSystem = lib.mkForce false;
|
|
||||||
ProtectHome = lib.mkForce false;
|
|
||||||
PrivateTmp = lib.mkForce false;
|
|
||||||
DevicePolicy = lib.mkForce "auto";
|
|
||||||
DeviceAllow = lib.mkForce [ "char-* rw" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
swapDevices = [{ device = "/var/swapfile"; size = 2048; }];
|
|
||||||
boot.tmp.cleanOnBoot = true;
|
|
||||||
services.openssh.settings.PasswordAuthentication = lib.mkForce true;
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
# In-place rebuild target — declares the flashed media's own filesystems
|
|
||||||
# (the labels mkPiImage's sd-image module writes), no image builder.
|
|
||||||
mkPiInstalled = machineModel:
|
|
||||||
let rt = mkPiRuntime machineModel; in
|
|
||||||
nixpkgs.lib.nixosSystem {
|
|
||||||
system = "aarch64-linux";
|
|
||||||
specialArgs = {
|
|
||||||
pkgs-unstable = pkgsUnstableAarch64;
|
|
||||||
inherit (rt) atm-app;
|
|
||||||
};
|
|
||||||
modules = (piBaseModules { inherit machineModel; inherit (rt) atm-app fiatCode; }) ++ [
|
|
||||||
{
|
|
||||||
fileSystems."/" = {
|
|
||||||
device = "/dev/disk/by-label/NIXOS_SD";
|
|
||||||
fsType = "ext4";
|
|
||||||
};
|
|
||||||
fileSystems."/boot/firmware" = {
|
|
||||||
device = "/dev/disk/by-label/FIRMWARE";
|
|
||||||
fsType = "vfat";
|
|
||||||
options = [ "nofail" "noauto" ];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
# Flashable SD/USB image — same runtime + the aarch64 sd-image builder,
|
|
||||||
# which brings its own NIXOS_SD/FIRMWARE fileSystems and the u-boot
|
|
||||||
# firmware. Its system.build.sdImage is exposed as
|
|
||||||
# packages.aarch64-linux.sd-image-rpi5.
|
|
||||||
mkPiImage = machineModel:
|
|
||||||
let rt = mkPiRuntime machineModel; in
|
|
||||||
nixpkgs.lib.nixosSystem {
|
|
||||||
system = "aarch64-linux";
|
|
||||||
specialArgs = {
|
|
||||||
pkgs-unstable = pkgsUnstableAarch64;
|
|
||||||
inherit (rt) atm-app;
|
|
||||||
};
|
|
||||||
modules = (piBaseModules { inherit machineModel; inherit (rt) atm-app fiatCode; }) ++ [
|
|
||||||
(nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix")
|
|
||||||
];
|
|
||||||
};
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# ── NixOS Configurations (top-level, not per-system) ──────────
|
# ── NixOS Configurations (top-level, not per-system) ──────────
|
||||||
|
|
@ -477,52 +286,6 @@
|
||||||
# at ttyJ5, dispenser at ttyJ7 layout) — reuse the same hw module.
|
# at ttyJ5, dispenser at ttyJ7 layout) — reuse the same hw module.
|
||||||
sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix;
|
sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix;
|
||||||
batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix;
|
batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix;
|
||||||
|
|
||||||
# Raspberry Pi 5 (aarch64) DIY build — Apex 7600 / NV10 over USB-serial.
|
|
||||||
# rpi5-installed → in-place rebuild target:
|
|
||||||
# sudo nixos-rebuild switch --flake \
|
|
||||||
# "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=<branch>#rpi5-installed"
|
|
||||||
# rpi5-image → source of the flashable image
|
|
||||||
# (packages.aarch64-linux.sd-image-rpi5).
|
|
||||||
rpi5-installed = mkPiInstalled "rpi5";
|
|
||||||
rpi5-image = mkPiImage "rpi5";
|
|
||||||
|
|
||||||
# Raspberry Pi 4 (aarch64) — same runtime and products as rpi5, on the
|
|
||||||
# previous-generation board (see deploy/nixos/hardware/raspberry-pi-4.nix
|
|
||||||
# for what differs). 4 GB minimum for Electron; 8 GB comfortable.
|
|
||||||
rpi4-installed = mkPiInstalled "rpi4";
|
|
||||||
rpi4-image = mkPiImage "rpi4";
|
|
||||||
|
|
||||||
# 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 ───────────────────────────────────
|
# ── Standalone NixOS module ───────────────────────────────────
|
||||||
|
|
@ -562,162 +325,9 @@
|
||||||
diskSize = "auto";
|
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
|
# Backwards compat
|
||||||
iso = self.nixosConfigurations.douro.config.system.build.isoImage;
|
iso = self.nixosConfigurations.douro.config.system.build.isoImage;
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── Packages (aarch64-linux — Raspberry Pi builds) ────────────
|
|
||||||
# Flashable SD images for the Pi boards. Build on an aarch64 builder
|
|
||||||
# (native Pi / arm box / `boot.binfmt` emulation on this x86 host):
|
|
||||||
# nix build .#packages.aarch64-linux.sd-image-rpi5
|
|
||||||
# nix build .#packages.aarch64-linux.sd-image-rpi4
|
|
||||||
packages.aarch64-linux = {
|
|
||||||
sd-image-rpi5 = self.nixosConfigurations.rpi5-image.config.system.build.sdImage;
|
|
||||||
atm-app-rpi5 = mkAtmAppAarch64 { model = "rpi5"; fiatCode = "USD"; };
|
|
||||||
sd-image-rpi4 = self.nixosConfigurations.rpi4-image.config.system.build.sdImage;
|
|
||||||
atm-app-rpi4 = mkAtmAppAarch64 { model = "rpi4"; fiatCode = "USD"; };
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
# ── Dev shells (per-system via flake-utils) ───────────────────
|
# ── Dev shells (per-system via flake-utils) ───────────────────
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
inherit (finalAttrs) pname version src pnpmWorkspaces;
|
inherit (finalAttrs) pname version src pnpmWorkspaces;
|
||||||
inherit pnpm;
|
inherit pnpm;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-XqpQpFL3PqnFltb4ujAmmnnV0LOqTHKV/bo3riFu9pY=";
|
hash = "sha256-Jv5p62E40DtCSZvN/+LTzkhRYJBTyyUOVSBlQyxzcEw=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
|
|
@ -57,11 +57,6 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
pkgs.sqlite.dev # better-sqlite3
|
pkgs.sqlite.dev # better-sqlite3
|
||||||
pkgs.libudev-zero # serialport
|
pkgs.libudev-zero # serialport
|
||||||
pkgs.stdenv.cc.cc.lib # libstdc++
|
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 = {
|
env = {
|
||||||
|
|
@ -88,19 +83,6 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
--arch=x64
|
--arch=x64
|
||||||
popd
|
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)
|
# Build the Electron app (turbo builds all workspace deps + app)
|
||||||
pnpm --filter="@bitSpire/machine..." build
|
pnpm --filter="@bitSpire/machine..." build
|
||||||
|
|
||||||
|
|
@ -113,7 +95,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
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.
|
# 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>
|
# 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 bindings $out/node_modules/bindings
|
||||||
copy_pnpm_pkg file-uri-to-path $out/node_modules/file-uri-to-path
|
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)
|
# @bitSpire/hal (workspace package, dynamically imported for hardware access)
|
||||||
mkdir -p $out/node_modules/@bitSpire/hal/dist
|
mkdir -p $out/node_modules/@bitSpire/hal/dist
|
||||||
cp -rL packages/hal/dist/* $out/node_modules/@bitSpire/hal/dist/
|
cp -rL packages/hal/dist/* $out/node_modules/@bitSpire/hal/dist/
|
||||||
|
|
@ -172,27 +148,6 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
cp -rL "$bcpp_store/node_modules/@serialport/bindings-cpp/prebuilds" $out/node_modules/@serialport/bindings-cpp/prebuilds
|
cp -rL "$bcpp_store/node_modules/@serialport/bindings-cpp/prebuilds" $out/node_modules/@serialport/bindings-cpp/prebuilds
|
||||||
cp "$bcpp_store/node_modules/@serialport/bindings-cpp/package.json" $out/node_modules/@serialport/bindings-cpp/package.json
|
cp "$bcpp_store/node_modules/@serialport/bindings-cpp/package.json" $out/node_modules/@serialport/bindings-cpp/package.json
|
||||||
|
|
||||||
# bindings-cpp ships prebuilds for every platform it supports: android,
|
|
||||||
# win32, darwin, and linux for several arches in both glibc and musl. Keep
|
|
||||||
# only the one this system can actually load.
|
|
||||||
#
|
|
||||||
# This is load-bearing on aarch64, not just tidiness. On x86_64 autoPatchelf
|
|
||||||
# skipped the foreign prebuilds because their ELF architecture did not match
|
|
||||||
# the host. On aarch64 the android-arm64 prebuild IS the host architecture,
|
|
||||||
# so autoPatchelf tries to patch it and fails hunting for Android's
|
|
||||||
# liblog.so and libc++_shared.so, which do not exist on NixOS. First Pi
|
|
||||||
# build died exactly there.
|
|
||||||
#
|
|
||||||
# Pruning rather than extending autoPatchelfIgnoreMissingDeps: teaching
|
|
||||||
# autoPatchelf to tolerate a binary we never load, for a platform we do not
|
|
||||||
# target, is the wrong shape of fix. The musl entry in that list below is
|
|
||||||
# the same problem solved the other way, and is now redundant.
|
|
||||||
keep_prebuild=${if pkgs.stdenv.hostPlatform.isAarch64 then "linux-arm64" else "linux-x64"}
|
|
||||||
find $out/node_modules/@serialport/bindings-cpp/prebuilds -mindepth 1 -maxdepth 1 \
|
|
||||||
! -name "$keep_prebuild" -exec rm -rf {} +
|
|
||||||
rm -f $out/node_modules/@serialport/bindings-cpp/prebuilds/*/*.musl.node
|
|
||||||
echo "serialport prebuilds kept: $(ls $out/node_modules/@serialport/bindings-cpp/prebuilds)/$(ls $out/node_modules/@serialport/bindings-cpp/prebuilds/"$keep_prebuild")"
|
|
||||||
|
|
||||||
copy_pnpm_pkg @serialport/bindings-interface $out/node_modules/@serialport/bindings-interface
|
copy_pnpm_pkg @serialport/bindings-interface $out/node_modules/@serialport/bindings-interface
|
||||||
copy_pnpm_pkg @serialport/binding-mock $out/node_modules/@serialport/binding-mock
|
copy_pnpm_pkg @serialport/binding-mock $out/node_modules/@serialport/binding-mock
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,30 +10,34 @@
|
||||||
* Uses NIP-44v2 encryption for all messages.
|
* Uses NIP-44v2 encryption for all messages.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Event, EventTemplate } from 'nostr-tools'
|
import type { Event, UnsignedEvent } from 'nostr-tools'
|
||||||
import type { NostrClient, Signer } from '@bitSpire/nostr-client'
|
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) */
|
/** CLINK protocol version tag (mandatory per CLINK spec) */
|
||||||
const CLINK_VERSION_TAG: [string, string] = ['clink_version', '1']
|
const CLINK_VERSION_TAG: [string, string] = ['clink_version', '1']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt content using NIP-44 v2 (required for CLINK events).
|
* Encrypt content using NIP-44 v2 (required for CLINK events)
|
||||||
* Routes through the Signer so the spire identity can live in a bunker.
|
|
||||||
*/
|
*/
|
||||||
function encryptCLINK(signer: Signer, recipientPubkey: string, content: unknown): Promise<string> {
|
function encryptCLINK(
|
||||||
const plaintext = typeof content === 'string' ? content : JSON.stringify(content)
|
identity: MachineIdentity,
|
||||||
return signer.nip44Encrypt(recipientPubkey, plaintext)
|
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>(
|
function decryptCLINKJSON<T = unknown>(
|
||||||
signer: Signer,
|
identity: MachineIdentity,
|
||||||
senderPubkey: string,
|
senderPubkey: string,
|
||||||
ciphertext: string
|
ciphertext: string
|
||||||
): Promise<T> {
|
): T {
|
||||||
const plaintext = await signer.nip44Decrypt(senderPubkey, ciphertext)
|
const plaintext = decryptContentV2(identity, senderPubkey, ciphertext)
|
||||||
return JSON.parse(plaintext) as T
|
return JSON.parse(plaintext) as T
|
||||||
}
|
}
|
||||||
import {
|
import {
|
||||||
|
|
@ -63,8 +67,8 @@ import { encodeNoffer, decodeNoffer } from './noffer.js'
|
||||||
export interface CLINKClientOptions {
|
export interface CLINKClientOptions {
|
||||||
/** Nostr client for communication */
|
/** Nostr client for communication */
|
||||||
nostrClient: NostrClient
|
nostrClient: NostrClient
|
||||||
/** Signer for the spire identity (local nsec or remote bunker) */
|
/** Machine identity */
|
||||||
signer: Signer
|
identity: MachineIdentity
|
||||||
/** Operator pubkey(s) for management commands */
|
/** Operator pubkey(s) for management commands */
|
||||||
operatorPubkey: string | string[]
|
operatorPubkey: string | string[]
|
||||||
/** Relays to use for offers */
|
/** Relays to use for offers */
|
||||||
|
|
@ -98,7 +102,7 @@ export type ManagementHandler = (
|
||||||
*/
|
*/
|
||||||
export class CLINKClient {
|
export class CLINKClient {
|
||||||
private nostrClient: NostrClient
|
private nostrClient: NostrClient
|
||||||
private signer: Signer
|
private identity: MachineIdentity
|
||||||
private operatorPubkeys: string[]
|
private operatorPubkeys: string[]
|
||||||
private relays: string[]
|
private relays: string[]
|
||||||
private generateInvoice?: GenerateInvoice
|
private generateInvoice?: GenerateInvoice
|
||||||
|
|
@ -116,7 +120,7 @@ export class CLINKClient {
|
||||||
|
|
||||||
constructor(options: CLINKClientOptions) {
|
constructor(options: CLINKClientOptions) {
|
||||||
this.nostrClient = options.nostrClient
|
this.nostrClient = options.nostrClient
|
||||||
this.signer = options.signer
|
this.identity = options.identity
|
||||||
this.operatorPubkeys = Array.isArray(options.operatorPubkey)
|
this.operatorPubkeys = Array.isArray(options.operatorPubkey)
|
||||||
? options.operatorPubkey
|
? options.operatorPubkey
|
||||||
: [options.operatorPubkey]
|
: [options.operatorPubkey]
|
||||||
|
|
@ -140,7 +144,7 @@ export class CLINKClient {
|
||||||
currency?: string
|
currency?: string
|
||||||
}): string {
|
}): string {
|
||||||
const offer: CLINKOffer = {
|
const offer: CLINKOffer = {
|
||||||
pubkey: this.signer.pubkey,
|
pubkey: this.identity.publicKey,
|
||||||
relays: this.relays,
|
relays: this.relays,
|
||||||
priceType: options.priceType,
|
priceType: options.priceType,
|
||||||
offerId: options.offerId,
|
offerId: options.offerId,
|
||||||
|
|
@ -189,7 +193,7 @@ export class CLINKClient {
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
kinds: [CLINKEventKind.Offer, CLINKEventKind.Debit, CLINKEventKind.Manage],
|
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,
|
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,
|
kind: CLINKEventKind.Offer,
|
||||||
content,
|
content,
|
||||||
tags: [['p', offer.pubkey], CLINK_VERSION_TAG],
|
tags: [['p', offer.pubkey], CLINK_VERSION_TAG],
|
||||||
|
|
@ -265,9 +269,9 @@ export class CLINKClient {
|
||||||
description: options?.description,
|
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,
|
kind: CLINKEventKind.Debit,
|
||||||
content,
|
content,
|
||||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||||
|
|
@ -299,9 +303,9 @@ export class CLINKClient {
|
||||||
description: options?.description,
|
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,
|
kind: CLINKEventKind.Debit,
|
||||||
content,
|
content,
|
||||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||||
|
|
@ -320,9 +324,9 @@ export class CLINKClient {
|
||||||
targetPubkey: string,
|
targetPubkey: string,
|
||||||
request: ManagementRequest
|
request: ManagementRequest
|
||||||
): Promise<ManagementResponse> {
|
): 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,
|
kind: CLINKEventKind.Manage,
|
||||||
content,
|
content,
|
||||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||||
|
|
@ -390,15 +394,15 @@ export class CLINKClient {
|
||||||
return
|
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)
|
const response = await this.offerHandler(request, event.pubkey)
|
||||||
if (!response) return
|
if (!response) return
|
||||||
|
|
||||||
// Send encrypted response with clink_version tag
|
// 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,
|
kind: CLINKEventKind.Offer,
|
||||||
content,
|
content,
|
||||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||||
|
|
@ -421,14 +425,14 @@ export class CLINKClient {
|
||||||
return
|
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)
|
const response = await this.debitHandler(request, event.pubkey)
|
||||||
|
|
||||||
// Send encrypted response with clink_version tag
|
// 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,
|
kind: CLINKEventKind.Debit,
|
||||||
content,
|
content,
|
||||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||||
|
|
@ -487,15 +491,15 @@ export class CLINKClient {
|
||||||
if (first) this.processedManageEvents.delete(first)
|
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)
|
const response = await this.managementHandler(request, event.pubkey)
|
||||||
if (!response) return
|
if (!response) return
|
||||||
|
|
||||||
// Send encrypted response with clink_version tag
|
// 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,
|
kind: CLINKEventKind.Manage,
|
||||||
content,
|
content,
|
||||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||||
|
|
@ -520,7 +524,7 @@ export class CLINKClient {
|
||||||
{
|
{
|
||||||
kinds: [kind],
|
kinds: [kind],
|
||||||
authors: [fromPubkey],
|
authors: [fromPubkey],
|
||||||
'#p': [this.signer.pubkey],
|
'#p': [this.identity.publicKey],
|
||||||
'#e': [requestEventId],
|
'#e': [requestEventId],
|
||||||
since: Math.floor(Date.now() / 1000) - 5,
|
since: Math.floor(Date.now() / 1000) - 5,
|
||||||
},
|
},
|
||||||
|
|
@ -536,7 +540,12 @@ export class CLINKClient {
|
||||||
|
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
this.nostrClient.unsubscribe(subId)
|
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
|
* Create a signed event
|
||||||
* a BunkerSigner is a relay round-trip.
|
|
||||||
*/
|
*/
|
||||||
private createSignedEvent(template: EventTemplate): Promise<Event> {
|
private createSignedEvent(event: Omit<UnsignedEvent, 'pubkey'>): Event {
|
||||||
return this.signer.signEvent(template)
|
// finalizeEvent derives pubkey from the secret key
|
||||||
|
return finalizeEvent(event, this.identity.privateKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import {
|
|
||||||
computeChecksum,
|
|
||||||
buildFrame,
|
|
||||||
creditChannel,
|
|
||||||
parseStatus,
|
|
||||||
parseResponse,
|
|
||||||
} from '../apex-rs232.js'
|
|
||||||
import { denomForChannel } from '../denominations.js'
|
|
||||||
|
|
||||||
// Cassette-present bit; OR it into event bytes so parseStatus doesn't short to
|
|
||||||
// 'stackerOpen'.
|
|
||||||
const PRESENT = 0x10
|
|
||||||
|
|
||||||
describe('Apex RS-232 protocol', () => {
|
|
||||||
describe('computeChecksum', () => {
|
|
||||||
it('XORs bytes 1..5 (matches the Pyramid reference poll frame)', () => {
|
|
||||||
// 02 08 10 7F 00 00 03 -> checksum 0x67
|
|
||||||
const frame = [0x02, 0x08, 0x10, 0x7f, 0x00, 0x00, 0x03, 0x00]
|
|
||||||
expect(computeChecksum(frame)).toBe(0x67)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('computeChecksum spans the frame, not a fixed range', () => {
|
|
||||||
it('matches the two reset frames the spec spells out literally', () => {
|
|
||||||
// Rev G gives these verbatim, checksum included, so they are the only
|
|
||||||
// ground truth available for the XOR range without hardware.
|
|
||||||
const a = Buffer.from([0x02, 0x08, 0x61, 0x7f, 0x7f, 0x7f, 0x03, 0x16])
|
|
||||||
const b = Buffer.from([0x02, 0x08, 0x60, 0x7f, 0x7f, 0x7f, 0x03, 0x17])
|
|
||||||
expect(computeChecksum(a)).toBe(0x16)
|
|
||||||
expect(computeChecksum(b)).toBe(0x17)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('covers the six data bytes of an 11-byte reply', () => {
|
|
||||||
// The reply is longer than the host frame. A checksum hardcoded to the
|
|
||||||
// host range silently mis-validates every reply the acceptor sends.
|
|
||||||
const reply = [0x02, 0x0b, 0x20, 0x01, 0x10, 0x00, 0x00, 0x12, 0x34, 0x03, 0x00]
|
|
||||||
let want = 0
|
|
||||||
for (let i = 1; i <= 8; i++) want ^= reply[i] as number
|
|
||||||
reply[10] = want
|
|
||||||
expect(computeChecksum(Buffer.from(reply))).toBe(want)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('buildFrame', () => {
|
|
||||||
it('lays out the 8-byte poll frame with the ACK bit and checksum', () => {
|
|
||||||
const f = buildFrame(0, 0x7f, 0x00)
|
|
||||||
expect([...f]).toEqual([0x02, 0x08, 0x10, 0x7f, 0x00, 0x00, 0x03, 0x67])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets the ACK bit in the control byte and recomputes the checksum', () => {
|
|
||||||
const f = buildFrame(1, 0x7f, 0x00)
|
|
||||||
expect(f[2]).toBe(0x11)
|
|
||||||
expect(f[7]).toBe(0x66)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('carries escrow, stack and return in the command byte', () => {
|
|
||||||
// Rev G BYTE 1: bit 4 escrow enable, bit 5 stack, bit 6 return. Escrow
|
|
||||||
// is an enable held across polls, so it rides alongside the action bit.
|
|
||||||
expect(buildFrame(0, 0x7f, 0x10)[4]).toBe(0x10)
|
|
||||||
expect(buildFrame(0, 0x7f, 0x30)[4]).toBe(0x30)
|
|
||||||
expect(buildFrame(0, 0x7f, 0x50)[4]).toBe(0x50)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets the stack command bit (0x20) in the command byte', () => {
|
|
||||||
const f = buildFrame(0, 0x7f, 0x20)
|
|
||||||
expect(f[4]).toBe(0x20)
|
|
||||||
expect(f[7]).toBe(computeChecksum([...f]))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('masks the enable byte to a single note channel', () => {
|
|
||||||
const f = buildFrame(0, 0x01, 0x00)
|
|
||||||
expect(f[3]).toBe(0x01)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('creditChannel', () => {
|
|
||||||
it('extracts the 1-based channel from bits 3..5', () => {
|
|
||||||
expect(creditChannel(0x00)).toBe(0)
|
|
||||||
expect(creditChannel(0x08)).toBe(1)
|
|
||||||
expect(creditChannel(0x28)).toBe(5)
|
|
||||||
expect(creditChannel(0x38)).toBe(7)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('parseStatus', () => {
|
|
||||||
it('maps each state bit to its status', () => {
|
|
||||||
expect(parseStatus(0x01, PRESENT)).toBe('standby')
|
|
||||||
expect(parseStatus(0x02, PRESENT)).toBe('accepting')
|
|
||||||
expect(parseStatus(0x04, PRESENT)).toBe('billsRead')
|
|
||||||
expect(parseStatus(0x08, PRESENT)).toBe('stacking')
|
|
||||||
expect(parseStatus(0x10, PRESENT)).toBe('billsValid')
|
|
||||||
expect(parseStatus(0x20, PRESENT)).toBe('returning')
|
|
||||||
expect(parseStatus(0x40, PRESENT)).toBe('billsRejected')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports stackerOpen when the cassette-present bit is clear', () => {
|
|
||||||
expect(parseStatus(0x01, 0x00)).toBe('stackerOpen')
|
|
||||||
expect(parseStatus(0x10, 0x00)).toBe('stackerOpen') // even mid-stack
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prioritises jam and reject events over motion', () => {
|
|
||||||
expect(parseStatus(0x08, PRESENT | 0x04)).toBe('jam')
|
|
||||||
expect(parseStatus(0x04, PRESENT | 0x02)).toBe('billsRejected') // rejected
|
|
||||||
expect(parseStatus(0x04, PRESENT | 0x01)).toBe('billsRejected') // cheated
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prefers a terminal stacked state over a combined idling bit', () => {
|
|
||||||
// 0x11 = stacked | idling
|
|
||||||
expect(parseStatus(0x11, PRESENT)).toBe('billsValid')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('parseResponse', () => {
|
|
||||||
// Resolve through the real module, not a hand-copied array. The previous
|
|
||||||
// local copy duplicated the shipping table's off-by-one and so asserted
|
|
||||||
// the bug instead of catching it.
|
|
||||||
const resolve = (ch: number) => denomForChannel('USD', ch)
|
|
||||||
|
|
||||||
it('resolves the escrowed note denomination from the credit channel', () => {
|
|
||||||
// state=escrowed, event=present, credit=channel 4. Per spec Rev G the
|
|
||||||
// USD channel order is $1 $2 $5 $10 $20 $50 $100, so channel 4 is $10.
|
|
||||||
const frame = Buffer.from([
|
|
||||||
0x02,
|
|
||||||
0x0b,
|
|
||||||
0x10,
|
|
||||||
0x04,
|
|
||||||
PRESENT,
|
|
||||||
0x20,
|
|
||||||
0x00,
|
|
||||||
0x00,
|
|
||||||
0x00,
|
|
||||||
0x03,
|
|
||||||
0x00,
|
|
||||||
])
|
|
||||||
const r = parseResponse(frame, resolve)
|
|
||||||
expect(r.status).toBe('billsRead')
|
|
||||||
expect(r.bill?.denomination).toBe(10)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns no bill when no channel is credited', () => {
|
|
||||||
const frame = Buffer.from([
|
|
||||||
0x02,
|
|
||||||
0x0b,
|
|
||||||
0x10,
|
|
||||||
0x01,
|
|
||||||
PRESENT,
|
|
||||||
0x00,
|
|
||||||
0x00,
|
|
||||||
0x00,
|
|
||||||
0x00,
|
|
||||||
0x03,
|
|
||||||
0x00,
|
|
||||||
])
|
|
||||||
const r = parseResponse(frame, resolve)
|
|
||||||
expect(r.status).toBe('standby')
|
|
||||||
expect(r.bill).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps the top channel to the largest note', () => {
|
|
||||||
// Channel 7 is $100. It read as unmapped while the table omitted $2,
|
|
||||||
// which is exactly the shift this test now pins down.
|
|
||||||
const frame = Buffer.from([
|
|
||||||
0x02,
|
|
||||||
0x0b,
|
|
||||||
0x10,
|
|
||||||
0x10,
|
|
||||||
PRESENT,
|
|
||||||
0x38,
|
|
||||||
0x00,
|
|
||||||
0x00,
|
|
||||||
0x00,
|
|
||||||
0x03,
|
|
||||||
0x00,
|
|
||||||
])
|
|
||||||
const r = parseResponse(frame, resolve)
|
|
||||||
expect(r.bill?.denomination).toBe(100)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pins the whole USD channel order from the spec', () => {
|
|
||||||
// Rev G, BYTE 2 bits 3-5: 001=$1 010=$2 011=$5 100=$10 101=$20
|
|
||||||
// 110=$50 111=$100. A note credited at the wrong value is silent and
|
|
||||||
// costs real money, so the full mapping is asserted rather than sampled.
|
|
||||||
expect([1, 2, 3, 4, 5, 6, 7].map((ch) => denomForChannel('USD', ch))).toEqual([
|
|
||||||
1, 2, 5, 10, 20, 50, 100,
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('has no denomination for channel 0, which means no note', () => {
|
|
||||||
expect(denomForChannel('USD', 0)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('has no denomination when the currency is unknown', () => {
|
|
||||||
expect(denomForChannel(null, 3)).toBeNull()
|
|
||||||
expect(denomForChannel('ZZZ', 3)).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
/**
|
|
||||||
* Apex Status Tracker
|
|
||||||
*
|
|
||||||
* Like EBDS, the Apex RS-232 protocol reports status directly via bits in each
|
|
||||||
* reply, so there's no complex command/response state machine — just dedupe
|
|
||||||
* status changes and translate them into the BillValidator event interface.
|
|
||||||
*
|
|
||||||
* Status flow:
|
|
||||||
* standby → accepting → billsRead(escrow) → billsValid(stacked)
|
|
||||||
* → billsRejected(returned)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events'
|
|
||||||
import type { ApexParseResult } from './apex-rs232.js'
|
|
||||||
|
|
||||||
// A note shouldn't sit in escrow long; warn if the host's stack/return
|
|
||||||
// decision lags, which on most acceptors risks an autonomous timeout-return.
|
|
||||||
const ESCROW_WATCHDOG_WARN_MS = 500
|
|
||||||
|
|
||||||
export class ApexFsm extends EventEmitter {
|
|
||||||
private currentStatus: string | null = null
|
|
||||||
private escrowOpenedAt: number | null = null
|
|
||||||
|
|
||||||
static factory(): ApexFsm {
|
|
||||||
return new ApexFsm()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Process a parsed Apex reply and emit status-change events. */
|
|
||||||
process(result: ApexParseResult): void {
|
|
||||||
const { status, bill } = result
|
|
||||||
if (!status) return
|
|
||||||
if (this.currentStatus === status) return // dedupe continuous polling
|
|
||||||
|
|
||||||
const prev = this.currentStatus
|
|
||||||
this.currentStatus = status
|
|
||||||
console.log('[APEX] %d status: %s → %s', Date.now(), prev ?? '(init)', status)
|
|
||||||
|
|
||||||
if (prev === 'billsRead' && this.escrowOpenedAt !== null) {
|
|
||||||
const elapsed = Date.now() - this.escrowOpenedAt
|
|
||||||
this.escrowOpenedAt = null
|
|
||||||
if (elapsed > ESCROW_WATCHDOG_WARN_MS) {
|
|
||||||
console.warn(
|
|
||||||
'[APEX] escrow held %dms before %s — host decision latency high',
|
|
||||||
elapsed,
|
|
||||||
status
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (status) {
|
|
||||||
case 'accepting':
|
|
||||||
this.emit('accepting')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'billsRead':
|
|
||||||
// Bill in escrow. Apex doesn't report currency, so there's no
|
|
||||||
// per-note currency check here (the enable mask already gates which
|
|
||||||
// channels the acceptor will escrow).
|
|
||||||
this.escrowOpenedAt = Date.now()
|
|
||||||
this.emit('billsAccepted')
|
|
||||||
// Match EBDS: emit billsRead on the next tick.
|
|
||||||
process.nextTick(() => this.emit('billsRead', bill ?? { denomination: null, code: '' }))
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'stacking':
|
|
||||||
this.emit('stacking')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'returning':
|
|
||||||
if (prev === 'billsRead') {
|
|
||||||
console.warn(
|
|
||||||
'[APEX] returning straight from escrow with no host reject — watch for autonomous return'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
this.emit('returning')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'billsValid':
|
|
||||||
if (bill && !bill.denomination) return // stacked with no denom (e.g. cashbox reinsert)
|
|
||||||
this.emit('billsValid')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'billsRejected':
|
|
||||||
this.emit('billsRejected')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'jam':
|
|
||||||
this.emit('error', new Error('Bill validator jam'))
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'stackerOpen':
|
|
||||||
this.emit('stackerOpen')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'standby':
|
|
||||||
this.emit('standby')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.currentStatus = null
|
|
||||||
this.escrowOpenedAt = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,392 +0,0 @@
|
||||||
/**
|
|
||||||
* Pyramid Apex RS-232 Protocol Layer
|
|
||||||
*
|
|
||||||
* Serial communication for Pyramid Technologies Apex-series bill acceptors
|
|
||||||
* (Apex 5000 / 7000 / 7600) running their RS-232 interface.
|
|
||||||
*
|
|
||||||
* Implemented from Pyramid's PUBLIC protocol facts only — the wire format,
|
|
||||||
* bit masks and serial parameters documented in Pyramid's "RS-232 Serial
|
|
||||||
* Interface Specification", document RS_232, Rev G 12/03/14. No third-party
|
|
||||||
* (or lamassu-machine) source is copied; the byte layout below is a functional
|
|
||||||
* spec, re-expressed for bitSpire under AGPL.
|
|
||||||
*
|
|
||||||
* The interface is Mars/MEI GL5-compatible, which is why it looks so much like
|
|
||||||
* the EBDS driver next door. The acceptor is a pure slave: it answers polls and
|
|
||||||
* never speaks first. Polls must not fall more than 5s apart or the acceptor
|
|
||||||
* may dump an escrowed note and stop accepting until the host resumes.
|
|
||||||
*
|
|
||||||
* Frame (host → acceptor), fixed 8 bytes:
|
|
||||||
* [0] STX 0x02
|
|
||||||
* [1] LEN 0x08
|
|
||||||
* [2] CTRL msg type 1 (master) in bits 4-6, ack in bit 0 (toggles every message)
|
|
||||||
* [3] ENA BYTE 0 — per-note enable bits: bit 0 = note 1 … bit 6 = note 7
|
|
||||||
* [4] CMD BYTE 1 — bit 4 escrow enable, bit 5 stack, bit 6 return
|
|
||||||
* [5] RSVD BYTE 2 — reserved, 0x00
|
|
||||||
* [6] ETX 0x03
|
|
||||||
* [7] CHK XOR of all bytes except STX, ETX and itself
|
|
||||||
*
|
|
||||||
* Frame (acceptor → host), 11 bytes — STX, LEN, CTRL, six data bytes, ETX,
|
|
||||||
* CHK. The fields this driver consumes:
|
|
||||||
* [3] STATE bits 1=idling 2=accepting 4=escrowed 8=stacking
|
|
||||||
* 16=stacked 32=returning 64=returned
|
|
||||||
* [4] EVENT bits 0x01=cheated 0x02=rejected 0x04=jammed
|
|
||||||
* 0x08=stacker-full 0x10=cassette-present
|
|
||||||
* [5] CREDIT denomination channel = (byte & 0x38) >> 3 (1..7, 0=none)
|
|
||||||
*
|
|
||||||
* Serial: 9600 baud, 7 data bits, even parity, 1 stop bit.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events'
|
|
||||||
import { SerialPort } from 'serialport'
|
|
||||||
|
|
||||||
const STX = 0x02
|
|
||||||
const ETX = 0x03
|
|
||||||
const HOST_FRAME_LEN = 0x08
|
|
||||||
|
|
||||||
// Host command byte (frame[4]) — spec Rev G, "Data Fields for Messages Sent
|
|
||||||
// By the Master", BYTE 1.
|
|
||||||
const CMD_ESCROW = 0x10 // bit 4: set to 1 to ENABLE escrow mode
|
|
||||||
const CMD_STACK = 0x20 // bit 5: stack the escrowed note
|
|
||||||
const CMD_RETURN = 0x40 // bit 6: return the escrowed note
|
|
||||||
|
|
||||||
// Response STATE byte (frame[3]) bit masks
|
|
||||||
const STATE_IDLING = 0x01
|
|
||||||
const STATE_ACCEPTING = 0x02
|
|
||||||
const STATE_ESCROWED = 0x04
|
|
||||||
const STATE_STACKING = 0x08
|
|
||||||
const STATE_STACKED = 0x10
|
|
||||||
const STATE_RETURNING = 0x20
|
|
||||||
const STATE_RETURNED = 0x40
|
|
||||||
|
|
||||||
// Response EVENT byte (frame[4]) bit masks
|
|
||||||
const EVENT_CHEATED = 0x01
|
|
||||||
const EVENT_REJECTED = 0x02
|
|
||||||
const EVENT_JAMMED = 0x04
|
|
||||||
const EVENT_STACKER_FULL = 0x08
|
|
||||||
const EVENT_CASSETTE_PRESENT = 0x10
|
|
||||||
|
|
||||||
// Plausibility bounds for the response length byte, used only to resync a
|
|
||||||
// desynced stream — a real reply is short (≈8–16 bytes).
|
|
||||||
const RESP_LEN_MIN = 6
|
|
||||||
const RESP_LEN_MAX = 32
|
|
||||||
|
|
||||||
export type ApexStatus =
|
|
||||||
| 'standby'
|
|
||||||
| 'accepting'
|
|
||||||
| 'billsRead'
|
|
||||||
| 'stacking'
|
|
||||||
| 'returning'
|
|
||||||
| 'billsValid'
|
|
||||||
| 'billsRejected'
|
|
||||||
| 'jam'
|
|
||||||
| 'stackerOpen'
|
|
||||||
|
|
||||||
export interface ApexParseResult {
|
|
||||||
status: ApexStatus | null
|
|
||||||
/** Populated when a denomination channel is present (escrow / stacked). */
|
|
||||||
bill?: { denomination: number | null; code: string }
|
|
||||||
/** Truthy status flags, for on-change diagnostic logging. */
|
|
||||||
flags: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApexRs232Config {
|
|
||||||
device: string | string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Pure functions — checksum, frame building, response parsing
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* XOR checksum over every byte except STX, ETX and the checksum itself — spec
|
|
||||||
* Rev G: "calculated on all bytes (except: STX, ETX and the checksum byte
|
|
||||||
* itself)". For the 8-byte host frame that is bytes 1..5; for the 11-byte
|
|
||||||
* reply it is bytes 1..8, which is why this is derived from the length rather
|
|
||||||
* than hardcoded. Confirmed against the two reset frames the spec spells out
|
|
||||||
* literally (02 08 61 7f 7f 7f 03 16 and 02 08 60 7f 7f 7f 03 17).
|
|
||||||
*/
|
|
||||||
export function computeChecksum(frame: number[] | Buffer, length?: number): number {
|
|
||||||
const n = length ?? frame.length
|
|
||||||
let cs = 0x00
|
|
||||||
for (let i = 1; i <= n - 3; i++) cs ^= frame[i] ?? 0
|
|
||||||
return cs
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the 8-byte host poll/command frame.
|
|
||||||
* @param ack current ACK bit (0 or 1)
|
|
||||||
* @param enableByte denomination enable bitmask
|
|
||||||
* @param cmdByte command bits (e.g. CMD_STACK)
|
|
||||||
*/
|
|
||||||
export function buildFrame(ack: number, enableByte: number, cmdByte: number): Buffer {
|
|
||||||
const frame = [
|
|
||||||
STX,
|
|
||||||
HOST_FRAME_LEN,
|
|
||||||
0x10 | (ack & 0x01),
|
|
||||||
enableByte & 0xff,
|
|
||||||
cmdByte & 0xff,
|
|
||||||
0x00,
|
|
||||||
ETX,
|
|
||||||
0x00,
|
|
||||||
]
|
|
||||||
frame[7] = computeChecksum(frame)
|
|
||||||
return Buffer.from(frame)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Channel index (1..7, 0 = none) of the credited note in the CREDIT byte. */
|
|
||||||
export function creditChannel(creditByte: number): number {
|
|
||||||
return (creditByte & 0x38) >> 3
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a high-level status from the STATE + EVENT bytes. Terminal outcomes
|
|
||||||
* (stacked / returned / cheated / rejected / jam / stacker-open) win over the
|
|
||||||
* transient in-motion states so a late-arriving reply can't mask a result.
|
|
||||||
*/
|
|
||||||
export function parseStatus(state: number, event: number): ApexStatus | null {
|
|
||||||
// Cassette (stacker box) removed — surfaces before anything transactional.
|
|
||||||
if (!(event & EVENT_CASSETTE_PRESENT)) return 'stackerOpen'
|
|
||||||
if (event & EVENT_JAMMED) return 'jam'
|
|
||||||
// Terminal resting states
|
|
||||||
if (state & STATE_STACKED) return 'billsValid'
|
|
||||||
if (state & STATE_RETURNED || event & (EVENT_CHEATED | EVENT_REJECTED)) return 'billsRejected'
|
|
||||||
// Transient — bill in motion. Surface before `escrowed`.
|
|
||||||
if (state & STATE_STACKING) return 'stacking'
|
|
||||||
if (state & STATE_RETURNING) return 'returning'
|
|
||||||
if (state & STATE_ESCROWED) return 'billsRead'
|
|
||||||
if (state & STATE_ACCEPTING) return 'accepting'
|
|
||||||
if (state & STATE_IDLING) return 'standby'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeFlags(state: number, event: number, credit: number): string {
|
|
||||||
const f: string[] = []
|
|
||||||
if (state & STATE_IDLING) f.push('idling')
|
|
||||||
if (state & STATE_ACCEPTING) f.push('accepting')
|
|
||||||
if (state & STATE_ESCROWED) f.push('escrowed')
|
|
||||||
if (state & STATE_STACKING) f.push('stacking')
|
|
||||||
if (state & STATE_STACKED) f.push('stacked')
|
|
||||||
if (state & STATE_RETURNING) f.push('returning')
|
|
||||||
if (state & STATE_RETURNED) f.push('returned')
|
|
||||||
if (event & EVENT_CHEATED) f.push('cheated')
|
|
||||||
if (event & EVENT_REJECTED) f.push('rejected')
|
|
||||||
if (event & EVENT_JAMMED) f.push('jammed')
|
|
||||||
if (event & EVENT_STACKER_FULL) f.push('stackerFull')
|
|
||||||
if (!(event & EVENT_CASSETTE_PRESENT)) f.push('cassetteMissing')
|
|
||||||
const ch = creditChannel(credit)
|
|
||||||
if (ch) f.push(`channel=${ch}`)
|
|
||||||
return f.join(', ') || '(none)'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a complete acceptor frame. `denomForChannel` maps a 1-based credit
|
|
||||||
* channel to a fiat value (null if the channel isn't configured).
|
|
||||||
*/
|
|
||||||
export function parseResponse(
|
|
||||||
frame: Buffer,
|
|
||||||
denomForChannel: (channel: number) => number | null
|
|
||||||
): ApexParseResult {
|
|
||||||
const state = frame[3] ?? 0
|
|
||||||
const event = frame[4] ?? 0
|
|
||||||
const credit = frame[5] ?? 0
|
|
||||||
const status = parseStatus(state, event)
|
|
||||||
const flags = summarizeFlags(state, event, credit)
|
|
||||||
|
|
||||||
const channel = creditChannel(credit)
|
|
||||||
if (channel > 0) {
|
|
||||||
return { status, bill: { denomination: denomForChannel(channel), code: '' }, flags }
|
|
||||||
}
|
|
||||||
return { status, flags }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ApexRs232 class
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export class ApexRs232 extends EventEmitter {
|
|
||||||
private buf: Buffer = Buffer.alloc(0)
|
|
||||||
private config: ApexRs232Config
|
|
||||||
private serial: SerialPort | null = null
|
|
||||||
private ack = 0x0
|
|
||||||
private enabledMask = 0x00
|
|
||||||
// Latched escrow decision. Like EBDS, the stack/return choice isn't a
|
|
||||||
// one-shot: the note sits in escrow until a poll asserts it, so we re-assert
|
|
||||||
// every poll until the device leaves escrow (cleared in _process). A single
|
|
||||||
// dropped frame then can't strand the note.
|
|
||||||
private pendingAction: 'none' | 'stack' | 'return' = 'none'
|
|
||||||
private lastFlags: string | null = null
|
|
||||||
private denomForChannel: (channel: number) => number | null = () => null
|
|
||||||
|
|
||||||
constructor(config: ApexRs232Config) {
|
|
||||||
super()
|
|
||||||
this.config = config
|
|
||||||
}
|
|
||||||
|
|
||||||
static factory(config: ApexRs232Config): ApexRs232 {
|
|
||||||
return new ApexRs232(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Provide the channel→denomination resolver (fiat-dependent). */
|
|
||||||
setDenomResolver(fn: (channel: number) => number | null): void {
|
|
||||||
this.denomForChannel = fn
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Serial connection ---------------------------------------------------
|
|
||||||
|
|
||||||
private async _open(device: string): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const serial = new SerialPort({
|
|
||||||
path: device,
|
|
||||||
baudRate: 9600,
|
|
||||||
parity: 'even' as const,
|
|
||||||
dataBits: 7 as const,
|
|
||||||
stopBits: 1 as const,
|
|
||||||
autoOpen: false,
|
|
||||||
rtscts: false,
|
|
||||||
})
|
|
||||||
this.serial = serial
|
|
||||||
|
|
||||||
serial.on('error', (err) => this.emit('error', err))
|
|
||||||
serial.on('open', (err?: Error | null) => {
|
|
||||||
if (err) return reject(err)
|
|
||||||
serial.on('readable', () => {
|
|
||||||
const data = serial.read() as Buffer | null
|
|
||||||
if (data) this._process(data)
|
|
||||||
})
|
|
||||||
serial.on('close', () => this.emit('disconnected'))
|
|
||||||
this.emit('connected')
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
serial.open()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async open(cb: (err?: Error) => void): Promise<void> {
|
|
||||||
const devices = this.config.device
|
|
||||||
if (!devices) {
|
|
||||||
this.emit('error', new Error('No configured devices.'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const list = typeof devices === 'string' ? [devices] : devices
|
|
||||||
for (const device of list) {
|
|
||||||
try {
|
|
||||||
await this._open(device)
|
|
||||||
cb()
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cb(new Error('No configured devices available.'))
|
|
||||||
}
|
|
||||||
|
|
||||||
close(cb: (err?: Error | null) => void): void {
|
|
||||||
this.serial?.close(cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Enable / commands ---------------------------------------------------
|
|
||||||
|
|
||||||
setEnabledDenominations(mask: number): void {
|
|
||||||
this.enabledMask = mask & 0xff
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Send one poll, carrying the current mask + latched escrow action. */
|
|
||||||
poll(): void {
|
|
||||||
// Escrow is asserted on EVERY poll. It is an enable bit, not a one-shot:
|
|
||||||
// with it clear the acceptor never stops at escrow, so the host is never
|
|
||||||
// offered the stack/return decision and notes are banked before anything
|
|
||||||
// has validated them. This driver's whole FSM is built around that
|
|
||||||
// decision point.
|
|
||||||
//
|
|
||||||
// Stack and return are the spec's own bits. An earlier version expressed
|
|
||||||
// return by zeroing the enable mask, on the assumption that the Apex had
|
|
||||||
// no return opcode; it has one, and disabling channels mid-escrow is not
|
|
||||||
// what it means.
|
|
||||||
//
|
|
||||||
// Both are re-asserted until the device leaves escrow, so a single dropped
|
|
||||||
// frame cannot strand a note.
|
|
||||||
let cmdByte = CMD_ESCROW
|
|
||||||
if (this.pendingAction === 'stack') cmdByte |= CMD_STACK
|
|
||||||
else if (this.pendingAction === 'return') cmdByte |= CMD_RETURN
|
|
||||||
|
|
||||||
this.ack ^= 0x01
|
|
||||||
this.serial?.write(buildFrame(this.ack, this.enabledMask, cmdByte))
|
|
||||||
}
|
|
||||||
|
|
||||||
stack(): void {
|
|
||||||
this.pendingAction = 'stack'
|
|
||||||
this.poll()
|
|
||||||
}
|
|
||||||
|
|
||||||
reject(): void {
|
|
||||||
this.pendingAction = 'return'
|
|
||||||
this.poll()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Disable all denominations and clear any latched escrow action. */
|
|
||||||
reset(): void {
|
|
||||||
this.pendingAction = 'none'
|
|
||||||
this.enabledMask = 0x00
|
|
||||||
this.poll()
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Receive / parse -----------------------------------------------------
|
|
||||||
|
|
||||||
private _process(data: Buffer): void {
|
|
||||||
this.buf = this._acquireSync(Buffer.concat([this.buf, data]))
|
|
||||||
if (this.buf.length < 2) return
|
|
||||||
|
|
||||||
const len = this.buf[1] ?? 0
|
|
||||||
if (len < RESP_LEN_MIN || len > RESP_LEN_MAX) {
|
|
||||||
// Implausible length byte — drop the STX we synced on and resync.
|
|
||||||
this.buf = this._acquireSync(this.buf.subarray(1))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.buf.length < len) return // wait for the whole frame
|
|
||||||
|
|
||||||
const frame = this.buf.subarray(0, len)
|
|
||||||
this.buf = this.buf.subarray(len)
|
|
||||||
|
|
||||||
if (frame[len - 2] !== ETX) {
|
|
||||||
this.emit('badFrame')
|
|
||||||
this.poll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// The XOR range is now confirmed from the spec, so a mismatch is a hard
|
|
||||||
// drop rather than the previous parse-anyway. A corrupted frame carries a
|
|
||||||
// denomination field, and crediting a note from a frame we know is damaged
|
|
||||||
// is the one outcome worth refusing outright. The raw bytes are logged so
|
|
||||||
// a systematic framing error is still diagnosable rather than silent.
|
|
||||||
const want = computeChecksum(frame, len)
|
|
||||||
if (frame[len - 1] !== want) {
|
|
||||||
console.warn(
|
|
||||||
`[APEX] reply checksum mismatch: got ${frame[len - 1]?.toString(16)} ` +
|
|
||||||
`want ${want.toString(16)} — frame dropped: ${frame.toString('hex')}`
|
|
||||||
)
|
|
||||||
this.emit('badFrame')
|
|
||||||
this.poll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = parseResponse(frame, this.denomForChannel)
|
|
||||||
|
|
||||||
// Clear a latched stack/return once the note has left escrow, so it can't
|
|
||||||
// leak onto the next note.
|
|
||||||
const escrowed = (frame[3] ?? 0) & STATE_ESCROWED
|
|
||||||
if (!escrowed) this.pendingAction = 'none'
|
|
||||||
|
|
||||||
if (result.flags !== this.lastFlags) {
|
|
||||||
console.log(`[APEX] status: ${this.lastFlags ?? '(initial)'} → ${result.flags}`)
|
|
||||||
this.lastFlags = result.flags
|
|
||||||
}
|
|
||||||
this.emit('message', result)
|
|
||||||
}
|
|
||||||
|
|
||||||
private _acquireSync(data: Buffer): Buffer {
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
if (data[i] === STX) return data.subarray(i)
|
|
||||||
}
|
|
||||||
return Buffer.alloc(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
/**
|
|
||||||
* Pyramid Apex Denomination Tables
|
|
||||||
*
|
|
||||||
* Indexed by (credit channel - 1). The channel is NOT an index into "the notes
|
|
||||||
* this unit happens to have enabled" — it is a fixed protocol constant.
|
|
||||||
* Pyramid's RS-232 spec (Rev G, "Data Fields for Messages sent by the Slave",
|
|
||||||
* BYTE 2 bits 3-5) fixes the USD mapping:
|
|
||||||
*
|
|
||||||
* 001 = $1 010 = $2 011 = $5 100 = $10
|
|
||||||
* 101 = $20 110 = $50 111 = $100
|
|
||||||
*
|
|
||||||
* $2 occupies channel 2 whether or not the unit accepts $2 notes. An earlier
|
|
||||||
* version of this table omitted it as "rarely enabled", which shifted every
|
|
||||||
* larger note down one slot: a $5 credited as $10, a $20 as $50, a $50 as
|
|
||||||
* $100, and a $100 as nothing at all. Never drop an unused channel from these
|
|
||||||
* arrays — pad it instead.
|
|
||||||
*
|
|
||||||
* For non-USD the spec says only "Foreign currencies are in sequential order
|
|
||||||
* as note 1-7", so channel N is the Nth note type of whatever dataset is
|
|
||||||
* flashed on the unit. The orderings below are the conventional ascending sets
|
|
||||||
* and are UNVERIFIED against a real configuration card. Check the card before
|
|
||||||
* a machine takes money in any of them.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const denominations: Record<string, number[]> = {
|
|
||||||
USD: [1, 2, 5, 10, 20, 50, 100],
|
|
||||||
EUR: [5, 10, 20, 50, 100, 200, 500],
|
|
||||||
GBP: [5, 10, 20, 50],
|
|
||||||
CAD: [5, 10, 20, 50, 100],
|
|
||||||
AUD: [5, 10, 20, 50, 100],
|
|
||||||
MXN: [20, 50, 100, 200, 500],
|
|
||||||
GTQ: [1, 5, 10, 20, 50, 100, 200],
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve a 1-based credit channel to a fiat value, or null if unmapped. */
|
|
||||||
export function denomForChannel(fiatCode: string | null, channel: number): number | null {
|
|
||||||
if (!fiatCode || channel < 1) return null
|
|
||||||
const table = denominations[fiatCode]
|
|
||||||
return table?.[channel - 1] ?? null
|
|
||||||
}
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
/**
|
|
||||||
* Pyramid Apex Bill Validator Driver
|
|
||||||
*
|
|
||||||
* Supports Pyramid Technologies Apex-series acceptors (Apex 5000 / 7000 / 7600)
|
|
||||||
* on their RS-232 interface. Set the acceptor to RS-232 mode via its DIP /
|
|
||||||
* configuration card.
|
|
||||||
*
|
|
||||||
* Protocol: RS-232, 9600 baud, 7 data bits, even parity, 1 stop bit.
|
|
||||||
*
|
|
||||||
* Written from Pyramid's public RS-232 protocol facts (see apex-rs232.ts) —
|
|
||||||
* not ported from any licensed source.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events'
|
|
||||||
import { throttle } from 'lodash-es'
|
|
||||||
import { ApexRs232 } from './apex-rs232.js'
|
|
||||||
import { ApexFsm } from './apex-fsm.js'
|
|
||||||
import { denominations as denominationsTable, denomForChannel } from './denominations.js'
|
|
||||||
import type { BillValidator, ValidatorConfig, BillData } from '../../types.js'
|
|
||||||
|
|
||||||
// Apex is host-polled; match the id003/ebds 100ms cadence so we observe escrow
|
|
||||||
// well inside the acceptor's grace window.
|
|
||||||
const POLLING_INTERVAL = 100
|
|
||||||
|
|
||||||
// All 7 credit channels enabled. Per-channel gating is handled upstream by the
|
|
||||||
// state machine (via lowest/highestBill), so the driver enables the full mask
|
|
||||||
// and relies on the acceptor's dataset for which notes exist.
|
|
||||||
const ALL_CHANNELS = 0x7f
|
|
||||||
|
|
||||||
interface BNLike {
|
|
||||||
lte: (n: number) => boolean
|
|
||||||
gte: (n: number) => boolean
|
|
||||||
toNumber: () => number
|
|
||||||
}
|
|
||||||
|
|
||||||
function BN(n: number): BNLike {
|
|
||||||
return { lte: (o: number) => n <= o, gte: (o: number) => n >= o, toNumber: () => n }
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApexValidator extends EventEmitter implements BillValidator {
|
|
||||||
private config: ValidatorConfig
|
|
||||||
private fiatCode: string | null = null
|
|
||||||
private rs232: ApexRs232 | null = null
|
|
||||||
private fsm: ApexFsm | null = null
|
|
||||||
private poller: ReturnType<typeof setInterval> | null = null
|
|
||||||
private _throttledError: (err: Error) => void
|
|
||||||
|
|
||||||
constructor(config: ValidatorConfig) {
|
|
||||||
super()
|
|
||||||
this.config = config
|
|
||||||
// Seed from config, as id003 effectively does by threading config.fiatCode
|
|
||||||
// into its rs232 config. Nothing in the app calls setFiatCode(), so a
|
|
||||||
// driver that relies on it alone resolves every credit channel to null and
|
|
||||||
// rejects every note. setFiatCode() stays available as a later override.
|
|
||||||
this.fiatCode = config.fiatCode ?? config.rs232.fiatCode ?? null
|
|
||||||
this._throttledError = throttle((err: Error) => this.emit('error', err), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
static factory(config: ValidatorConfig): ApexValidator {
|
|
||||||
return new ApexValidator(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
setFiatCode(fiatCode: string): void {
|
|
||||||
this.fiatCode = fiatCode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apex has no host-controllable insertion light.
|
|
||||||
lightOn(): void {}
|
|
||||||
lightOff(): void {}
|
|
||||||
|
|
||||||
run(cb: (err?: Error) => void): void {
|
|
||||||
this.fsm = ApexFsm.factory()
|
|
||||||
this.rs232 = ApexRs232.factory({ device: this.config.rs232.device })
|
|
||||||
this.rs232.setDenomResolver((channel) => denomForChannel(this.fiatCode, channel))
|
|
||||||
|
|
||||||
this.rs232.on('message', (result) => this.fsm?.process(result))
|
|
||||||
this.rs232.on('error', (err: Error) => this._throttledError(err))
|
|
||||||
this.rs232.on('badFrame', () => this.rs232?.poll())
|
|
||||||
this.rs232.on('disconnected', () => this.emit('disconnected'))
|
|
||||||
|
|
||||||
this.fsm.on('billsAccepted', () => this.emit('billsAccepted'))
|
|
||||||
this.fsm.on('billsRead', (bill: { denomination: number | null; code: string }) => {
|
|
||||||
if (!bill.denomination) {
|
|
||||||
// Say WHICH of the two causes this is. "unmapped channel" alone reads
|
|
||||||
// as a hardware/dataset mismatch and sent us looking at DIP switches
|
|
||||||
// when the real cause was a null fiat code rejecting every note.
|
|
||||||
if (!this.fiatCode) {
|
|
||||||
console.error(
|
|
||||||
'[APEX] Bill rejected: no fiat code set on the driver, so NO channel ' +
|
|
||||||
'can resolve to a value. Every note will be returned until this is fixed.'
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`[APEX] Bill rejected: channel ${bill.code || '?'} is not mapped in the ` +
|
|
||||||
`${this.fiatCode} dataset — check the acceptor's configuration card`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
this.rs232?.reject()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const billData: BillData = { denomination: bill.denomination, code: 0 }
|
|
||||||
this.emit('billsRead', billData)
|
|
||||||
})
|
|
||||||
this.fsm.on('billsValid', () => this.emit('billsValid'))
|
|
||||||
this.fsm.on('billsRejected', () => this.emit('billsRejected'))
|
|
||||||
this.fsm.on('accepting', () => this.emit('accepting'))
|
|
||||||
this.fsm.on('stacking', () => this.emit('stacking'))
|
|
||||||
this.fsm.on('returning', () => this.emit('returning'))
|
|
||||||
this.fsm.on('stackerOpen', () => this.emit('stackerOpen'))
|
|
||||||
this.fsm.on('standby', () => this.emit('standby'))
|
|
||||||
this.fsm.on('error', (err: Error) => this.emit('error', err))
|
|
||||||
|
|
||||||
if (!this.fiatCode) {
|
|
||||||
console.error(
|
|
||||||
'[APEX] starting with NO fiat code — denomination lookup will return null ' +
|
|
||||||
'for every credit channel and the acceptor will reject every note.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.rs232.open((err) => {
|
|
||||||
if (err) return cb(err)
|
|
||||||
this.rs232!.reset() // start disabled
|
|
||||||
this.poller = setInterval(() => this.rs232?.poll(), POLLING_INTERVAL)
|
|
||||||
cb()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
close(cb: (err?: Error) => void): void {
|
|
||||||
if (this.poller) {
|
|
||||||
clearInterval(this.poller)
|
|
||||||
this.poller = null
|
|
||||||
}
|
|
||||||
this.rs232?.close((err) => cb(err ?? undefined))
|
|
||||||
}
|
|
||||||
|
|
||||||
enable(): void {
|
|
||||||
if (!this.rs232) return
|
|
||||||
this.rs232.setEnabledDenominations(ALL_CHANNELS)
|
|
||||||
this.rs232.poll()
|
|
||||||
}
|
|
||||||
|
|
||||||
disable(): void {
|
|
||||||
if (!this.rs232) return
|
|
||||||
this.rs232.setEnabledDenominations(0x00)
|
|
||||||
this.rs232.poll()
|
|
||||||
}
|
|
||||||
|
|
||||||
stack(): void {
|
|
||||||
this.rs232?.stack()
|
|
||||||
}
|
|
||||||
|
|
||||||
reject(): void {
|
|
||||||
this.rs232?.reject()
|
|
||||||
}
|
|
||||||
|
|
||||||
lowestBill(fiat: BNLike): BNLike {
|
|
||||||
const bills = this._denominations()
|
|
||||||
if (!bills) return BN(0)
|
|
||||||
const filtered = bills.filter((b) => fiat.lte(b))
|
|
||||||
if (filtered.length === 0) return BN(Math.min(...bills))
|
|
||||||
return BN(Math.min(...filtered))
|
|
||||||
}
|
|
||||||
|
|
||||||
highestBill(fiat: BNLike): BNLike {
|
|
||||||
const bills = this._denominations()
|
|
||||||
if (!bills) return BN(-Infinity)
|
|
||||||
const filtered = bills.filter((b) => fiat.gte(b))
|
|
||||||
if (filtered.length === 0) return BN(-Infinity)
|
|
||||||
return BN(Math.max(...filtered))
|
|
||||||
}
|
|
||||||
|
|
||||||
hasDenominations(): boolean {
|
|
||||||
return this._denominations() !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
private _denominations(): number[] | null {
|
|
||||||
if (!this.fiatCode) return null
|
|
||||||
return denominationsTable[this.fiatCode] ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -313,13 +313,6 @@ export class EbdsRs232 extends EventEmitter {
|
||||||
private serial: SerialPort | null = null
|
private serial: SerialPort | null = null
|
||||||
private ack: number = 0x0
|
private ack: number = 0x0
|
||||||
private enabledDenominations: number = 0x00
|
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 lastStatusFlags: string | null = null
|
||||||
private firmwareLogged: boolean = false
|
private firmwareLogged: boolean = false
|
||||||
|
|
||||||
|
|
@ -412,38 +405,23 @@ export class EbdsRs232 extends EventEmitter {
|
||||||
|
|
||||||
// -- Commands (Appendix D, Controller Message) ---------------------------
|
// -- Commands (Appendix D, Controller Message) ---------------------------
|
||||||
|
|
||||||
/**
|
/** Send an Omnibus poll command with current denomination mask */
|
||||||
* 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 */
|
|
||||||
poll(): void {
|
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 {
|
stack(): void {
|
||||||
this.pendingAction = 'stack'
|
this._dispatch([this.enabledDenominations, 0x3f, 0x10])
|
||||||
this.poll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Latch "return the escrowed note"; re-asserted each poll until it takes. */
|
/** Reject/return the bill currently in escrow */
|
||||||
reject(): void {
|
reject(): void {
|
||||||
this.pendingAction = 'return'
|
this._dispatch([this.enabledDenominations, 0x5f, 0x10])
|
||||||
this.poll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send initial setup command (disable all, reset state) */
|
/** Send initial setup command (disable all, reset state) */
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.pendingAction = 'none'
|
|
||||||
this._dispatch([0x00, 0x1b, 0x10])
|
this._dispatch([0x00, 0x1b, 0x10])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -492,13 +470,7 @@ export class EbdsRs232 extends EventEmitter {
|
||||||
validatePacket(packet)
|
validatePacket(packet)
|
||||||
const result = interpret(packet)
|
const result = interpret(packet)
|
||||||
if (result) {
|
if (result) {
|
||||||
if (result.destructedData) {
|
if (result.destructedData) this._logStatusOnChange(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)
|
|
||||||
}
|
|
||||||
this.emit('message', result)
|
this.emit('message', result)
|
||||||
}
|
}
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,6 @@ export class EbdsValidator extends EventEmitter implements BillValidator {
|
||||||
constructor(config: ValidatorConfig) {
|
constructor(config: ValidatorConfig) {
|
||||||
super()
|
super()
|
||||||
this.config = config
|
this.config = config
|
||||||
// Seed from config, as id003 effectively does by threading config.fiatCode
|
|
||||||
// into its rs232 config. Nothing in the app calls setFiatCode(), so a
|
|
||||||
// driver that relies on it alone resolves every credit channel to null and
|
|
||||||
// rejects every note. setFiatCode() stays available as a later override.
|
|
||||||
this.fiatCode = config.fiatCode ?? config.rs232.fiatCode ?? null
|
|
||||||
this._throttledError = throttle((err: Error) => this.emit('error', err), 2000)
|
this._throttledError = throttle((err: Error) => this.emit('error', err), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,17 @@
|
||||||
|
|
||||||
export { Id003 } from './id003/index.js'
|
export { Id003 } from './id003/index.js'
|
||||||
export { EbdsValidator } from './ebds/index.js'
|
export { EbdsValidator } from './ebds/index.js'
|
||||||
export { ApexValidator } from './apex/index.js'
|
|
||||||
export type { ValidatorConfig, BillValidator, BillData } from '../types.js'
|
export type { ValidatorConfig, BillValidator, BillData } from '../types.js'
|
||||||
|
|
||||||
import { Id003 } from './id003/index.js'
|
import { Id003 } from './id003/index.js'
|
||||||
import { EbdsValidator } from './ebds/index.js'
|
import { EbdsValidator } from './ebds/index.js'
|
||||||
import { ApexValidator } from './apex/index.js'
|
|
||||||
import type { ValidatorConfig, BillValidator } from '../types.js'
|
import type { ValidatorConfig, BillValidator } from '../types.js'
|
||||||
|
|
||||||
export type ValidatorType = 'id003' | 'ebds' | 'apex'
|
export type ValidatorType = 'id003' | 'ebds'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a bill validator instance
|
* Create a bill validator instance
|
||||||
* @param type Validator type (e.g., 'id003', 'ebds', 'apex')
|
* @param type Validator type (e.g., 'id003', 'ebds')
|
||||||
* @param config Validator configuration
|
* @param config Validator configuration
|
||||||
*/
|
*/
|
||||||
export function createValidator(type: ValidatorType, config: ValidatorConfig): BillValidator {
|
export function createValidator(type: ValidatorType, config: ValidatorConfig): BillValidator {
|
||||||
|
|
@ -27,8 +25,6 @@ export function createValidator(type: ValidatorType, config: ValidatorConfig): B
|
||||||
return Id003.factory(config)
|
return Id003.factory(config)
|
||||||
case 'ebds':
|
case 'ebds':
|
||||||
return EbdsValidator.factory(config)
|
return EbdsValidator.factory(config)
|
||||||
case 'apex':
|
|
||||||
return ApexValidator.factory(config)
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown validator type: ${type}`)
|
throw new Error(`Unknown validator type: ${type}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
} from 'nostr-tools'
|
} from 'nostr-tools'
|
||||||
import {
|
import {
|
||||||
encryptContentV2,
|
encryptContentV2,
|
||||||
LocalSigner,
|
|
||||||
type MachineIdentity,
|
type MachineIdentity,
|
||||||
type NostrClient,
|
type NostrClient,
|
||||||
} from '@bitSpire/nostr-client'
|
} from '@bitSpire/nostr-client'
|
||||||
|
|
@ -153,7 +152,7 @@ describe('isAuthenticServerEvent', () => {
|
||||||
describe('LnbitsClient.handleReply wiring', () => {
|
describe('LnbitsClient.handleReply wiring', () => {
|
||||||
function makeMockNostr(): {
|
function makeMockNostr(): {
|
||||||
nostr: NostrClient
|
nostr: NostrClient
|
||||||
triggerEvent: (ev: NostrEvent) => Promise<void>
|
triggerEvent: (ev: NostrEvent) => void
|
||||||
} {
|
} {
|
||||||
let captured: ((ev: NostrEvent) => void) | null = null
|
let captured: ((ev: NostrEvent) => void) | null = null
|
||||||
const nostr = {
|
const nostr = {
|
||||||
|
|
@ -169,12 +168,9 @@ describe('LnbitsClient.handleReply wiring', () => {
|
||||||
} as unknown as NostrClient
|
} as unknown as NostrClient
|
||||||
return {
|
return {
|
||||||
nostr,
|
nostr,
|
||||||
// `handleReply` is async (the signer's nip44Decrypt is a promise),
|
triggerEvent: (ev) => {
|
||||||
// so flush microtasks + a macrotask tick before the caller asserts.
|
|
||||||
triggerEvent: async (ev) => {
|
|
||||||
if (!captured) throw new Error('handleReply not wired yet')
|
if (!captured) throw new Error('handleReply not wired yet')
|
||||||
captured(ev)
|
captured(ev)
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +188,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
||||||
client: LnbitsClient
|
client: LnbitsClient
|
||||||
serverIdentity: MachineIdentity
|
serverIdentity: MachineIdentity
|
||||||
recipientIdentity: MachineIdentity
|
recipientIdentity: MachineIdentity
|
||||||
triggerEvent: (ev: NostrEvent) => Promise<void>
|
triggerEvent: (ev: NostrEvent) => void
|
||||||
} {
|
} {
|
||||||
const serverIdentity = makeIdentity()
|
const serverIdentity = makeIdentity()
|
||||||
const recipientIdentity = makeIdentity()
|
const recipientIdentity = makeIdentity()
|
||||||
|
|
@ -201,11 +197,11 @@ describe('LnbitsClient.handleReply wiring', () => {
|
||||||
serverPubkey: serverIdentity.publicKey,
|
serverPubkey: serverIdentity.publicKey,
|
||||||
relays: ['ws://test/'],
|
relays: ['ws://test/'],
|
||||||
})
|
})
|
||||||
client.initialize(nostr, new LocalSigner(recipientIdentity))
|
client.initialize(nostr, recipientIdentity)
|
||||||
return { client, serverIdentity, recipientIdentity, triggerEvent }
|
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()
|
const { client, serverIdentity, triggerEvent } = setupClient()
|
||||||
|
|
||||||
// Pre-register a pending entry as `sendRpc` would have.
|
// Pre-register a pending entry as `sendRpc` would have.
|
||||||
|
|
@ -237,7 +233,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
||||||
attackerKey,
|
attackerKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
await triggerEvent(forged)
|
triggerEvent(forged)
|
||||||
|
|
||||||
expect(resolveCalls).toBe(0)
|
expect(resolveCalls).toBe(0)
|
||||||
expect(rejectCalls).toBe(0)
|
expect(rejectCalls).toBe(0)
|
||||||
|
|
@ -245,7 +241,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
||||||
expect((client as any).pending.has('req-forged')).toBe(true)
|
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 } =
|
const { client, serverIdentity, recipientIdentity, triggerEvent } =
|
||||||
setupClient()
|
setupClient()
|
||||||
|
|
||||||
|
|
@ -281,7 +277,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
||||||
serverIdentity.privateKey,
|
serverIdentity.privateKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
await triggerEvent(reply)
|
triggerEvent(reply)
|
||||||
|
|
||||||
expect(resolved).toMatchObject({
|
expect(resolved).toMatchObject({
|
||||||
status: 'OK',
|
status: 'OK',
|
||||||
|
|
@ -294,7 +290,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
||||||
// fine, we only need to assert resolve fired with the right payload.
|
// 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
|
// This is the test scenario where the #49 guard's contribution
|
||||||
// actually shows up: ev.id is the dedup key for the client-global
|
// actually shows up: ev.id is the dedup key for the client-global
|
||||||
// exact-replay cache. WITHOUT the guard, an attacker could publish
|
// exact-replay cache. WITHOUT the guard, an attacker could publish
|
||||||
|
|
@ -324,7 +320,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
||||||
attackerKey,
|
attackerKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
await triggerEvent(forged)
|
triggerEvent(forged)
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
expect((client as any).seenEventIds.size).toBe(0)
|
expect((client as any).seenEventIds.size).toBe(0)
|
||||||
|
|
@ -349,7 +345,7 @@ describe('LnbitsClient.handleReply wiring', () => {
|
||||||
describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
||||||
function makeMockNostr(): {
|
function makeMockNostr(): {
|
||||||
nostr: NostrClient
|
nostr: NostrClient
|
||||||
triggerEvent: (ev: NostrEvent) => Promise<void>
|
triggerEvent: (ev: NostrEvent) => void
|
||||||
} {
|
} {
|
||||||
let captured: ((ev: NostrEvent) => void) | null = null
|
let captured: ((ev: NostrEvent) => void) | null = null
|
||||||
const nostr = {
|
const nostr = {
|
||||||
|
|
@ -365,12 +361,9 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
||||||
} as unknown as NostrClient
|
} as unknown as NostrClient
|
||||||
return {
|
return {
|
||||||
nostr,
|
nostr,
|
||||||
// `handleReply` is async (the signer's nip44Decrypt is a promise),
|
triggerEvent: (ev) => {
|
||||||
// so flush microtasks + a macrotask tick before the caller asserts.
|
|
||||||
triggerEvent: async (ev) => {
|
|
||||||
if (!captured) throw new Error('handleReply not wired yet')
|
if (!captured) throw new Error('handleReply not wired yet')
|
||||||
captured(ev)
|
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 serverIdentity = makeIdentity()
|
||||||
const recipientIdentity = makeIdentity()
|
const recipientIdentity = makeIdentity()
|
||||||
const { nostr, triggerEvent } = makeMockNostr()
|
const { nostr, triggerEvent } = makeMockNostr()
|
||||||
|
|
@ -451,7 +444,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
||||||
serverPubkey: serverIdentity.publicKey,
|
serverPubkey: serverIdentity.publicKey,
|
||||||
relays: ['ws://test/'],
|
relays: ['ws://test/'],
|
||||||
})
|
})
|
||||||
client.initialize(nostr, new LocalSigner(recipientIdentity))
|
client.initialize(nostr, recipientIdentity)
|
||||||
|
|
||||||
const { received } = preregisterSub(client, 'sub-1')
|
const { received } = preregisterSub(client, 'sub-1')
|
||||||
|
|
||||||
|
|
@ -462,14 +455,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
||||||
paymentHash: 'hash-aaa',
|
paymentHash: 'hash-aaa',
|
||||||
})
|
})
|
||||||
// Same bytes both times — same ev.id, same payment_hash.
|
// Same bytes both times — same ev.id, same payment_hash.
|
||||||
await triggerEvent(ev)
|
triggerEvent(ev)
|
||||||
await triggerEvent(ev)
|
triggerEvent(ev)
|
||||||
|
|
||||||
expect(received).toHaveLength(1)
|
expect(received).toHaveLength(1)
|
||||||
expect(received[0]!.payment_hash).toBe('hash-aaa')
|
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 serverIdentity = makeIdentity()
|
||||||
const recipientIdentity = makeIdentity()
|
const recipientIdentity = makeIdentity()
|
||||||
const { nostr, triggerEvent } = makeMockNostr()
|
const { nostr, triggerEvent } = makeMockNostr()
|
||||||
|
|
@ -477,7 +470,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
||||||
serverPubkey: serverIdentity.publicKey,
|
serverPubkey: serverIdentity.publicKey,
|
||||||
relays: ['ws://test/'],
|
relays: ['ws://test/'],
|
||||||
})
|
})
|
||||||
client.initialize(nostr, new LocalSigner(recipientIdentity))
|
client.initialize(nostr, recipientIdentity)
|
||||||
|
|
||||||
const { received } = preregisterSub(client, 'sub-1')
|
const { received } = preregisterSub(client, 'sub-1')
|
||||||
|
|
||||||
|
|
@ -500,14 +493,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
||||||
createdAt: now + 1,
|
createdAt: now + 1,
|
||||||
})
|
})
|
||||||
expect(ev1.id).not.toBe(ev2.id) // sanity: ev.id dedup would NOT catch this
|
expect(ev1.id).not.toBe(ev2.id) // sanity: ev.id dedup would NOT catch this
|
||||||
await triggerEvent(ev1)
|
triggerEvent(ev1)
|
||||||
await triggerEvent(ev2)
|
triggerEvent(ev2)
|
||||||
|
|
||||||
expect(received).toHaveLength(1)
|
expect(received).toHaveLength(1)
|
||||||
expect(received[0]!.payment_hash).toBe('hash-bbb')
|
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 serverIdentity = makeIdentity()
|
||||||
const recipientIdentity = makeIdentity()
|
const recipientIdentity = makeIdentity()
|
||||||
const { nostr, triggerEvent } = makeMockNostr()
|
const { nostr, triggerEvent } = makeMockNostr()
|
||||||
|
|
@ -515,7 +508,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
||||||
serverPubkey: serverIdentity.publicKey,
|
serverPubkey: serverIdentity.publicKey,
|
||||||
relays: ['ws://test/'],
|
relays: ['ws://test/'],
|
||||||
})
|
})
|
||||||
client.initialize(nostr, new LocalSigner(recipientIdentity))
|
client.initialize(nostr, recipientIdentity)
|
||||||
|
|
||||||
const { received } = preregisterSub(client, 'sub-1')
|
const { received } = preregisterSub(client, 'sub-1')
|
||||||
|
|
||||||
|
|
@ -532,14 +525,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
||||||
paymentHash: 'hash-2',
|
paymentHash: 'hash-2',
|
||||||
createdAt: Math.floor(Date.now() / 1000) + 2,
|
createdAt: Math.floor(Date.now() / 1000) + 2,
|
||||||
})
|
})
|
||||||
await triggerEvent(ev1)
|
triggerEvent(ev1)
|
||||||
await triggerEvent(ev2)
|
triggerEvent(ev2)
|
||||||
|
|
||||||
expect(received).toHaveLength(2)
|
expect(received).toHaveLength(2)
|
||||||
expect(received.map((p) => p.payment_hash)).toEqual(['hash-1', 'hash-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 serverIdentity = makeIdentity()
|
||||||
const recipientIdentity = makeIdentity()
|
const recipientIdentity = makeIdentity()
|
||||||
const { nostr, triggerEvent } = makeMockNostr()
|
const { nostr, triggerEvent } = makeMockNostr()
|
||||||
|
|
@ -547,7 +540,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
||||||
serverPubkey: serverIdentity.publicKey,
|
serverPubkey: serverIdentity.publicKey,
|
||||||
relays: ['ws://test/'],
|
relays: ['ws://test/'],
|
||||||
})
|
})
|
||||||
client.initialize(nostr, new LocalSigner(recipientIdentity))
|
client.initialize(nostr, recipientIdentity)
|
||||||
|
|
||||||
const a = preregisterSub(client, 'sub-A')
|
const a = preregisterSub(client, 'sub-A')
|
||||||
const b = preregisterSub(client, 'sub-B')
|
const b = preregisterSub(client, 'sub-B')
|
||||||
|
|
@ -568,8 +561,8 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => {
|
||||||
paymentHash: 'hash-shared',
|
paymentHash: 'hash-shared',
|
||||||
createdAt: Math.floor(Date.now() / 1000) + 1,
|
createdAt: Math.floor(Date.now() / 1000) + 1,
|
||||||
})
|
})
|
||||||
await triggerEvent(evA)
|
triggerEvent(evA)
|
||||||
await triggerEvent(evB)
|
triggerEvent(evB)
|
||||||
|
|
||||||
// Each subscription sees its own push exactly once.
|
// Each subscription sees its own push exactly once.
|
||||||
expect(a.received).toHaveLength(1)
|
expect(a.received).toHaveLength(1)
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import {
|
|
||||||
LnbitsErrorCode,
|
|
||||||
LnbitsRpcError,
|
|
||||||
parseErrorCode,
|
|
||||||
retryPolicyFor,
|
|
||||||
} from '../error-codes.js'
|
|
||||||
|
|
||||||
describe('parseErrorCode', () => {
|
|
||||||
it('recognizes every canonical code', () => {
|
|
||||||
for (const code of Object.values(LnbitsErrorCode)) {
|
|
||||||
expect(parseErrorCode(code)).toBe(code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null for unknown / absent codes', () => {
|
|
||||||
expect(parseErrorCode('made_up_code')).toBeNull()
|
|
||||||
expect(parseErrorCode(undefined)).toBeNull()
|
|
||||||
expect(parseErrorCode(null)).toBeNull()
|
|
||||||
expect(parseErrorCode('')).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('retryPolicyFor', () => {
|
|
||||||
it('classifies the signer + transport + app codes as agreed', () => {
|
|
||||||
expect(retryPolicyFor(LnbitsErrorCode.OperatorSignerUnavailable)).toBe('retry-backoff')
|
|
||||||
expect(retryPolicyFor(LnbitsErrorCode.OperatorSignerRejected)).toBe('terminal')
|
|
||||||
expect(retryPolicyFor(LnbitsErrorCode.RateLimited)).toBe('retry-long-backoff')
|
|
||||||
expect(retryPolicyFor(LnbitsErrorCode.InternalError)).toBe('retry-once')
|
|
||||||
expect(retryPolicyFor(LnbitsErrorCode.InvoiceAlreadyPaid)).toBe('terminal-idempotent')
|
|
||||||
expect(retryPolicyFor(LnbitsErrorCode.InsufficientBalance)).toBe('terminal')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('has a policy for every code (exhaustive map)', () => {
|
|
||||||
for (const code of Object.values(LnbitsErrorCode)) {
|
|
||||||
expect(retryPolicyFor(code)).toBeTruthy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('LnbitsRpcError.fromResponse', () => {
|
|
||||||
it('maps a known error_code through', () => {
|
|
||||||
const err = LnbitsRpcError.fromResponse('pay_invoice', {
|
|
||||||
request_id: 'pay-1',
|
|
||||||
error_code: 'insufficient_balance',
|
|
||||||
error: 'not enough sats',
|
|
||||||
})
|
|
||||||
expect(err).toBeInstanceOf(LnbitsRpcError)
|
|
||||||
expect(err.code).toBe(LnbitsErrorCode.InsufficientBalance)
|
|
||||||
expect(err.rpcName).toBe('pay_invoice')
|
|
||||||
expect(err.requestId).toBe('pay-1')
|
|
||||||
expect(err.message).toBe('not enough sats')
|
|
||||||
expect(err.retryPolicy).toBe('terminal')
|
|
||||||
expect(err.isRetryable).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats an ABSENT error_code as internal_error (retry-once)', () => {
|
|
||||||
const err = LnbitsRpcError.fromResponse('get_wallet', { request_id: 'w-1' })
|
|
||||||
expect(err.code).toBe(LnbitsErrorCode.InternalError)
|
|
||||||
expect(err.retryPolicy).toBe('retry-once')
|
|
||||||
expect(err.isRetryable).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats an UNKNOWN error_code as internal_error', () => {
|
|
||||||
const err = LnbitsRpcError.fromResponse('get_wallet', {
|
|
||||||
request_id: 'w-2',
|
|
||||||
error_code: 'brand_new_code_we_dont_know',
|
|
||||||
})
|
|
||||||
expect(err.code).toBe(LnbitsErrorCode.InternalError)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('flags invoice_already_paid as idempotent-success', () => {
|
|
||||||
const err = LnbitsRpcError.fromResponse('pay_invoice', {
|
|
||||||
request_id: 'p-1',
|
|
||||||
error_code: 'invoice_already_paid',
|
|
||||||
})
|
|
||||||
expect(err.isIdempotentSuccess).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import { withRetry } from '../retry.js'
|
|
||||||
import { LnbitsErrorCode, LnbitsRpcError } from '../error-codes.js'
|
|
||||||
|
|
||||||
const noSleep = () => Promise.resolve()
|
|
||||||
|
|
||||||
function rpcErr(code: LnbitsErrorCode): LnbitsRpcError {
|
|
||||||
return new LnbitsRpcError({ code, rpcName: 'get_wallet', requestId: 'r1' })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A fn that throws `err` the first `failTimes` calls, then returns `value`. */
|
|
||||||
function failingFn<T>(failTimes: number, err: unknown, value: T): { fn: () => Promise<T>; calls: () => number } {
|
|
||||||
let calls = 0
|
|
||||||
return {
|
|
||||||
fn: async () => {
|
|
||||||
calls++
|
|
||||||
if (calls <= failTimes) throw err
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
calls: () => calls,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('withRetry', () => {
|
|
||||||
it('returns immediately on success (one call)', async () => {
|
|
||||||
const { fn, calls } = failingFn(0, rpcErr(LnbitsErrorCode.InternalError), 'ok')
|
|
||||||
expect(await withRetry(fn, { sleep: noSleep })).toBe('ok')
|
|
||||||
expect(calls()).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retries a transient operator_signer_unavailable, then succeeds', async () => {
|
|
||||||
const { fn, calls } = failingFn(1, rpcErr(LnbitsErrorCode.OperatorSignerUnavailable), 'ok')
|
|
||||||
expect(await withRetry(fn, { sleep: noSleep })).toBe('ok')
|
|
||||||
expect(calls()).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retries rate_limited (long backoff) up to maxAttempts then throws', async () => {
|
|
||||||
const err = rpcErr(LnbitsErrorCode.RateLimited)
|
|
||||||
const { fn, calls } = failingFn(99, err, 'never')
|
|
||||||
await expect(withRetry(fn, { sleep: noSleep, maxAttempts: 3 })).rejects.toBe(err)
|
|
||||||
expect(calls()).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('internal_error (retry-once) retries exactly once', async () => {
|
|
||||||
const err = rpcErr(LnbitsErrorCode.InternalError)
|
|
||||||
const { fn, calls } = failingFn(99, err, 'never')
|
|
||||||
await expect(withRetry(fn, { sleep: noSleep, maxAttempts: 5 })).rejects.toBe(err)
|
|
||||||
expect(calls()).toBe(2) // initial + one retry, then null delay stops it
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws a terminal error immediately (no retry)', async () => {
|
|
||||||
const err = rpcErr(LnbitsErrorCode.InsufficientBalance)
|
|
||||||
const { fn, calls } = failingFn(99, err, 'never')
|
|
||||||
await expect(withRetry(fn, { sleep: noSleep })).rejects.toBe(err)
|
|
||||||
expect(calls()).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats unauthorized as terminal (no retry)', async () => {
|
|
||||||
const err = rpcErr(LnbitsErrorCode.Unauthorized)
|
|
||||||
const { fn, calls } = failingFn(99, err, 'never')
|
|
||||||
await expect(withRetry(fn, { sleep: noSleep })).rejects.toBe(err)
|
|
||||||
expect(calls()).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retries a transport timeout error', async () => {
|
|
||||||
const timeout = new Error('LnbitsClient.get_wallet: timeout after 30000ms')
|
|
||||||
const { fn, calls } = failingFn(1, timeout, 'ok')
|
|
||||||
expect(await withRetry(fn, { sleep: noSleep })).toBe('ok')
|
|
||||||
expect(calls()).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does NOT retry an unknown error (rethrows immediately)', async () => {
|
|
||||||
const boom = new Error('relay socket closed')
|
|
||||||
const { fn, calls } = failingFn(99, boom, 'never')
|
|
||||||
await expect(withRetry(fn, { sleep: noSleep })).rejects.toBe(boom)
|
|
||||||
expect(calls()).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('backs off with increasing delay per attempt (retry-backoff)', async () => {
|
|
||||||
const delays: number[] = []
|
|
||||||
const err = rpcErr(LnbitsErrorCode.OperatorSignerUnavailable)
|
|
||||||
const { fn } = failingFn(99, err, 'never')
|
|
||||||
await expect(
|
|
||||||
withRetry(fn, { sleep: (ms) => { delays.push(ms); return Promise.resolve() }, maxAttempts: 3 })
|
|
||||||
).rejects.toBe(err)
|
|
||||||
expect(delays).toEqual([200, 400]) // before attempt 2 and 3; attempt 3 is last → no 3rd sleep
|
|
||||||
})
|
|
||||||
|
|
||||||
it('invokes onRetry with attempt/delay/error', async () => {
|
|
||||||
const onRetry = vi.fn()
|
|
||||||
const { fn } = failingFn(1, rpcErr(LnbitsErrorCode.OperatorSignerUnavailable), 'ok')
|
|
||||||
await withRetry(fn, { sleep: noSleep, onRetry })
|
|
||||||
expect(onRetry).toHaveBeenCalledOnce()
|
|
||||||
expect(onRetry.mock.calls[0]![0]).toMatchObject({ attempt: 1, delayMs: 200 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -22,12 +22,12 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type NostrClient,
|
type NostrClient,
|
||||||
type Signer,
|
type MachineIdentity,
|
||||||
type Event as NostrEvent,
|
type Event as NostrEvent,
|
||||||
|
encryptContentV2,
|
||||||
|
decryptContentV2,
|
||||||
} from '@bitSpire/nostr-client'
|
} from '@bitSpire/nostr-client'
|
||||||
import { verifyEvent } from 'nostr-tools'
|
import { finalizeEvent, verifyEvent } from 'nostr-tools'
|
||||||
import { LnbitsRpcError } from './error-codes.js'
|
|
||||||
import { withRetry } from './retry.js'
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
LnbitsConfig,
|
LnbitsConfig,
|
||||||
|
|
@ -37,14 +37,11 @@ import type {
|
||||||
CreateInvoiceBody,
|
CreateInvoiceBody,
|
||||||
PayInvoiceBody,
|
PayInvoiceBody,
|
||||||
WalletInfo,
|
WalletInfo,
|
||||||
MachineConfigResponse,
|
|
||||||
SubscribePaymentsBody,
|
SubscribePaymentsBody,
|
||||||
SubscribeAck,
|
SubscribeAck,
|
||||||
PaymentPushCallback,
|
PaymentPushCallback,
|
||||||
SubscriptionCloseCallback,
|
SubscriptionCloseCallback,
|
||||||
CreateWithdrawLinkBody,
|
CreateWithdrawLinkBody,
|
||||||
CreateWithdrawBody,
|
|
||||||
CreateWithdrawResult,
|
|
||||||
LnbitsWithdrawLink,
|
LnbitsWithdrawLink,
|
||||||
UniqueHashesResponse,
|
UniqueHashesResponse,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
@ -124,7 +121,7 @@ const SEEN_PAYMENT_HASHES_MAX = 500
|
||||||
export class LnbitsClient {
|
export class LnbitsClient {
|
||||||
private readonly config: Required<LnbitsConfig>
|
private readonly config: Required<LnbitsConfig>
|
||||||
private nostr: NostrClient | null = null
|
private nostr: NostrClient | null = null
|
||||||
private signer: Signer | null = null
|
private identity: MachineIdentity | null = null
|
||||||
private requestCounter = 0
|
private requestCounter = 0
|
||||||
private readonly pending = new Map<
|
private readonly pending = new Map<
|
||||||
string,
|
string,
|
||||||
|
|
@ -153,28 +150,12 @@ export class LnbitsClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize(nostr: NostrClient, signer: Signer): void {
|
initialize(nostr: NostrClient, identity: MachineIdentity): void {
|
||||||
this.nostr = nostr
|
this.nostr = nostr
|
||||||
this.signer = signer
|
this.identity = identity
|
||||||
this.startReplyListener()
|
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
|
// Wallet
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -186,7 +167,8 @@ export class LnbitsClient {
|
||||||
*/
|
*/
|
||||||
async getWallet(walletId?: string): Promise<WalletInfo> {
|
async getWallet(walletId?: string): Promise<WalletInfo> {
|
||||||
if (walletId) {
|
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()
|
const wallets = await this.listWallets()
|
||||||
if (wallets.length === 0) {
|
if (wallets.length === 0) {
|
||||||
|
|
@ -207,29 +189,14 @@ export class LnbitsClient {
|
||||||
|
|
||||||
/** Enumerate every wallet owned by the calling account. */
|
/** Enumerate every wallet owned by the calling account. */
|
||||||
async listWallets(): Promise<WalletInfo[]> {
|
async listWallets(): Promise<WalletInfo[]> {
|
||||||
const data = await this.idempotent(() => this.sendRpc<WalletInfo[]>('list_wallets', {}))
|
const data = await this.sendRpc<WalletInfo[]>('list_wallets', {})
|
||||||
return data ?? []
|
return data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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
|
// 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> {
|
async createInvoice(walletId: string, body: CreateInvoiceBody): Promise<LnbitsPayment> {
|
||||||
const data = await this.sendRpc<LnbitsPayment>('create_invoice', { walletId, body })
|
const data = await this.sendRpc<LnbitsPayment>('create_invoice', { walletId, body })
|
||||||
return data
|
return data
|
||||||
|
|
@ -242,20 +209,17 @@ export class LnbitsClient {
|
||||||
|
|
||||||
/** Point-lookup of a payment by hash. AUTH_NONE — hashes are hard to guess. */
|
/** Point-lookup of a payment by hash. AUTH_NONE — hashes are hard to guess. */
|
||||||
async getPayment(paymentHash: string): Promise<LnbitsPayment | null> {
|
async getPayment(paymentHash: string): Promise<LnbitsPayment | null> {
|
||||||
const data = await this.idempotent(() =>
|
const data = await this.sendRpc<LnbitsPayment | null>('get_payment', {
|
||||||
this.sendRpc<LnbitsPayment | null>('get_payment', {
|
body: { payment_hash: paymentHash },
|
||||||
body: { payment_hash: paymentHash },
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
return data ?? null
|
return data ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
async decodePayment(paymentRequest: string): Promise<Record<string, unknown>> {
|
async decodePayment(paymentRequest: string): Promise<Record<string, unknown>> {
|
||||||
return this.idempotent(() =>
|
const data = await this.sendRpc<Record<string, unknown>>('decode_payment', {
|
||||||
this.sendRpc<Record<string, unknown>>('decode_payment', {
|
body: { payment_request: paymentRequest },
|
||||||
body: { payment_request: paymentRequest },
|
})
|
||||||
}),
|
return data
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -276,7 +240,7 @@ export class LnbitsClient {
|
||||||
onPush: PaymentPushCallback,
|
onPush: PaymentPushCallback,
|
||||||
onClose?: SubscriptionCloseCallback,
|
onClose?: SubscriptionCloseCallback,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!this.nostr || !this.signer) {
|
if (!this.nostr || !this.identity) {
|
||||||
throw new Error('LnbitsClient.subscribePayments: client not initialized')
|
throw new Error('LnbitsClient.subscribePayments: client not initialized')
|
||||||
}
|
}
|
||||||
const requestId = this.nextRequestId('sub')
|
const requestId = this.nextRequestId('sub')
|
||||||
|
|
@ -402,35 +366,19 @@ export class LnbitsClient {
|
||||||
return data
|
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> {
|
async getWithdrawLink(walletId: string, id: string): Promise<LnbitsWithdrawLink> {
|
||||||
return this.idempotent(() =>
|
const data = await this.sendRpc<LnbitsWithdrawLink>('lnurlw_get_link', { walletId, body: { id } })
|
||||||
this.sendRpc<LnbitsWithdrawLink>('lnurlw_get_link', { walletId, body: { id } }),
|
return data
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listWithdrawLinks(
|
async listWithdrawLinks(
|
||||||
walletId: string | undefined,
|
walletId: string | undefined,
|
||||||
body: { limit?: number; offset?: number } = {},
|
body: { limit?: number; offset?: number } = {},
|
||||||
): Promise<{ data: LnbitsWithdrawLink[]; total: number }> {
|
): Promise<{ data: LnbitsWithdrawLink[]; total: number }> {
|
||||||
return this.idempotent(() =>
|
return this.sendRpc<{ data: LnbitsWithdrawLink[]; total: number }>('lnurlw_list_links', {
|
||||||
this.sendRpc<{ data: LnbitsWithdrawLink[]; total: number }>('lnurlw_list_links', {
|
walletId,
|
||||||
walletId,
|
body,
|
||||||
body,
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -440,12 +388,10 @@ export class LnbitsClient {
|
||||||
* — the URL a customer wallet GETs to redeem that specific sub-link.
|
* — the URL a customer wallet GETs to redeem that specific sub-link.
|
||||||
*/
|
*/
|
||||||
async getWithdrawLinkUniqueHashes(walletId: string, id: string): Promise<UniqueHashesResponse> {
|
async getWithdrawLinkUniqueHashes(walletId: string, id: string): Promise<UniqueHashesResponse> {
|
||||||
return this.idempotent(() =>
|
return this.sendRpc<UniqueHashesResponse>('lnurlw_unique_hashes', {
|
||||||
this.sendRpc<UniqueHashesResponse>('lnurlw_unique_hashes', {
|
walletId,
|
||||||
walletId,
|
body: { id },
|
||||||
body: { id },
|
})
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateWithdrawLink(
|
async updateWithdrawLink(
|
||||||
|
|
@ -485,7 +431,7 @@ export class LnbitsClient {
|
||||||
requestId?: string
|
requestId?: string
|
||||||
},
|
},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (!this.nostr || !this.signer) {
|
if (!this.nostr || !this.identity) {
|
||||||
throw new Error(`LnbitsClient.${rpcName}: client not initialized`)
|
throw new Error(`LnbitsClient.${rpcName}: client not initialized`)
|
||||||
}
|
}
|
||||||
const requestId = args.requestId ?? this.nextRequestId(rpcName)
|
const requestId = args.requestId ?? this.nextRequestId(rpcName)
|
||||||
|
|
@ -498,10 +444,10 @@ export class LnbitsClient {
|
||||||
if (args.query !== undefined) request.query = args.query as Record<string, unknown>
|
if (args.query !== undefined) request.query = args.query as Record<string, unknown>
|
||||||
|
|
||||||
const plaintext = JSON.stringify(request)
|
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
|
// Build + sign the kind-21000 event ourselves. The server reads our
|
||||||
// our pubkey directly off the signature, so there's no separate
|
// pubkey directly off the signature, so there's no separate
|
||||||
// authIdentifier in the envelope (unlike LightningPubClient).
|
// authIdentifier in the envelope (unlike LightningPubClient).
|
||||||
//
|
//
|
||||||
// NIP-40 expiration: 5 minutes past now. Defence-in-depth at the
|
// NIP-40 expiration: 5 minutes past now. Defence-in-depth at the
|
||||||
|
|
@ -511,15 +457,18 @@ export class LnbitsClient {
|
||||||
// attacker can't bypass this by stripping the tag; the tag just
|
// attacker can't bypass this by stripping the tag; the tag just
|
||||||
// lets the relay short-circuit earlier.
|
// lets the relay short-circuit earlier.
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const event = await this.signer.signEvent({
|
const event = finalizeEvent(
|
||||||
kind: LNBITS_KIND_RPC,
|
{
|
||||||
content: encrypted,
|
kind: LNBITS_KIND_RPC,
|
||||||
tags: [
|
content: encrypted,
|
||||||
['p', this.config.serverPubkey],
|
tags: [
|
||||||
['expiration', String(now + 300)],
|
['p', this.config.serverPubkey],
|
||||||
],
|
['expiration', String(now + 300)],
|
||||||
created_at: now,
|
],
|
||||||
})
|
created_at: now,
|
||||||
|
},
|
||||||
|
this.identity.privateKey,
|
||||||
|
)
|
||||||
|
|
||||||
// The pending entry MUST be registered before publish so we don't race
|
// The pending entry MUST be registered before publish so we don't race
|
||||||
// an extremely fast reply.
|
// an extremely fast reply.
|
||||||
|
|
@ -534,7 +483,7 @@ export class LnbitsClient {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
this.pending.delete(requestId)
|
this.pending.delete(requestId)
|
||||||
if (response.status === 'ERROR') {
|
if (response.status === 'ERROR') {
|
||||||
reject(LnbitsRpcError.fromResponse(rpcName, response))
|
reject(new Error(response.error ?? `${rpcName}: server returned ERROR`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resolve(response.data as T)
|
resolve(response.data as T)
|
||||||
|
|
@ -559,8 +508,8 @@ export class LnbitsClient {
|
||||||
* based on `request_id` and `subscription_id`.
|
* based on `request_id` and `subscription_id`.
|
||||||
*/
|
*/
|
||||||
private startReplyListener(): void {
|
private startReplyListener(): void {
|
||||||
if (!this.nostr || !this.signer) return
|
if (!this.nostr || !this.identity) return
|
||||||
const myPubkey = this.signer.pubkey
|
const myPubkey = this.identity.publicKey
|
||||||
const since = Math.floor(Date.now() / 1000) - 5
|
const since = Math.floor(Date.now() / 1000) - 5
|
||||||
|
|
||||||
this.relaySubIdForReplies = this.nostr.subscribe(
|
this.relaySubIdForReplies = this.nostr.subscribe(
|
||||||
|
|
@ -573,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> {
|
private handleReply(ev: NostrEvent): void {
|
||||||
const signer = this.signer
|
if (!this.identity) return
|
||||||
if (!signer) return
|
|
||||||
if (!isAuthenticServerEvent(ev, this.config.serverPubkey)) return
|
if (!isAuthenticServerEvent(ev, this.config.serverPubkey)) return
|
||||||
// Exact-replay dedup. Skip if we've already processed this event id.
|
// Exact-replay dedup. Skip if we've already processed this event id.
|
||||||
// Safe to trust `ev.id` here because `isAuthenticServerEvent` just
|
// Safe to trust `ev.id` here because `isAuthenticServerEvent` just
|
||||||
// Schnorr-verified the event (`verifyEvent` recomputes the id and
|
// Schnorr-verified the event (`verifyEvent` recomputes the id and
|
||||||
// confirms it matches the signed pubkey + body). Without that
|
// confirms it matches the signed pubkey + body). Without that
|
||||||
// guarantee an attacker could pre-poison this set with chosen ids.
|
// 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
|
if (this.seenEventIds.has(ev.id)) return
|
||||||
this.recordSeenEventId(ev.id)
|
this.recordSeenEventId(ev.id)
|
||||||
|
|
||||||
let plaintext: string
|
let plaintext: string
|
||||||
try {
|
try {
|
||||||
plaintext = await signer.nip44Decrypt(this.config.serverPubkey, ev.content)
|
plaintext = decryptContentV2(this.identity, this.config.serverPubkey, ev.content)
|
||||||
} catch {
|
} catch {
|
||||||
return // not our peer or wrong key
|
return // not our peer or wrong key
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
/**
|
|
||||||
* LNbits nostr-transport error taxonomy.
|
|
||||||
*
|
|
||||||
* Machine-readable discriminators for kind-21000 ERROR responses. Mirror of
|
|
||||||
* the lnbits canonical enum (`core/services/nostr_transport/error_codes.py`)
|
|
||||||
* and the vocabulary table in `docs/devs/nostr-transport.md`. Drift detection
|
|
||||||
* = diff this enum against that table. Agreed in the 2026-05-26 cross-session
|
|
||||||
* handshake on aiolabs/bitspire#52.
|
|
||||||
*
|
|
||||||
* Wire shape (additive to the existing envelope):
|
|
||||||
* { "status": "ERROR", "request_id": "...", "error_code": "...", "error": "..." }
|
|
||||||
*
|
|
||||||
* `error_code` is optional-additive for one lnbits release, then required.
|
|
||||||
* An ABSENT `error_code` is treated as `internal_error` (retry-once) — we do
|
|
||||||
* not string-match the human-readable `error`. So un-migrated handlers get a
|
|
||||||
* safe retry-then-surface default with no special parser paths.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export enum LnbitsErrorCode {
|
|
||||||
// signer class — the operator's signer (bunker) on the LNbits side
|
|
||||||
OperatorSignerUnavailable = 'operator_signer_unavailable',
|
|
||||||
OperatorSignerRejected = 'operator_signer_rejected',
|
|
||||||
OperatorSignerUnconfigured = 'operator_signer_unconfigured',
|
|
||||||
// transport class
|
|
||||||
Unauthorized = 'unauthorized',
|
|
||||||
RateLimited = 'rate_limited',
|
|
||||||
UnknownMethod = 'unknown_method',
|
|
||||||
InvalidParams = 'invalid_params',
|
|
||||||
InternalError = 'internal_error',
|
|
||||||
// app class
|
|
||||||
WalletNotFound = 'wallet_not_found',
|
|
||||||
InsufficientBalance = 'insufficient_balance',
|
|
||||||
InvoiceAlreadyPaid = 'invoice_already_paid',
|
|
||||||
InvoiceExpired = 'invoice_expired',
|
|
||||||
PaymentFailed = 'payment_failed',
|
|
||||||
AccountNotFound = 'account_not_found',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry disposition for an error code:
|
|
||||||
* - `retry-backoff` — transient; retry with short exponential backoff.
|
|
||||||
* - `retry-long-backoff` — rate-limited; retry with a longer backoff.
|
|
||||||
* - `retry-once` — retry exactly once, then surface (the `internal_error` default).
|
|
||||||
* - `terminal` — do not retry; surface to the user.
|
|
||||||
* - `terminal-idempotent` — terminal, but the operation already took effect
|
|
||||||
* (e.g. `invoice_already_paid` — a cash-out watcher treats it as settled).
|
|
||||||
*/
|
|
||||||
export type RetryPolicy =
|
|
||||||
| 'retry-backoff'
|
|
||||||
| 'retry-long-backoff'
|
|
||||||
| 'retry-once'
|
|
||||||
| 'terminal'
|
|
||||||
| 'terminal-idempotent'
|
|
||||||
|
|
||||||
const RETRY_POLICIES: Record<LnbitsErrorCode, RetryPolicy> = {
|
|
||||||
[LnbitsErrorCode.OperatorSignerUnavailable]: 'retry-backoff',
|
|
||||||
[LnbitsErrorCode.OperatorSignerRejected]: 'terminal',
|
|
||||||
[LnbitsErrorCode.OperatorSignerUnconfigured]: 'terminal',
|
|
||||||
[LnbitsErrorCode.Unauthorized]: 'terminal',
|
|
||||||
[LnbitsErrorCode.RateLimited]: 'retry-long-backoff',
|
|
||||||
[LnbitsErrorCode.UnknownMethod]: 'terminal',
|
|
||||||
[LnbitsErrorCode.InvalidParams]: 'terminal',
|
|
||||||
[LnbitsErrorCode.InternalError]: 'retry-once',
|
|
||||||
[LnbitsErrorCode.WalletNotFound]: 'terminal',
|
|
||||||
[LnbitsErrorCode.InsufficientBalance]: 'terminal',
|
|
||||||
[LnbitsErrorCode.InvoiceAlreadyPaid]: 'terminal-idempotent',
|
|
||||||
[LnbitsErrorCode.InvoiceExpired]: 'terminal',
|
|
||||||
// payment_failed is terminal-with-detail: the sub-reason rides in `error`.
|
|
||||||
[LnbitsErrorCode.PaymentFailed]: 'terminal',
|
|
||||||
[LnbitsErrorCode.AccountNotFound]: 'terminal',
|
|
||||||
}
|
|
||||||
|
|
||||||
const RETRYABLE: ReadonlySet<RetryPolicy> = new Set<RetryPolicy>([
|
|
||||||
'retry-backoff',
|
|
||||||
'retry-long-backoff',
|
|
||||||
'retry-once',
|
|
||||||
])
|
|
||||||
|
|
||||||
/** Parse a wire string into a known code, or null if unrecognized. */
|
|
||||||
export function parseErrorCode(raw: string | undefined | null): LnbitsErrorCode | null {
|
|
||||||
if (!raw) return null
|
|
||||||
return (Object.values(LnbitsErrorCode) as string[]).includes(raw)
|
|
||||||
? (raw as LnbitsErrorCode)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retry disposition for a code. */
|
|
||||||
export function retryPolicyFor(code: LnbitsErrorCode): RetryPolicy {
|
|
||||||
return RETRY_POLICIES[code]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typed error thrown by `LnbitsClient` on an ERROR response. Carries the
|
|
||||||
* machine-readable `code` + its `retryPolicy` so callers (and the state
|
|
||||||
* machine) branch on disposition rather than string-matching `message`.
|
|
||||||
*/
|
|
||||||
export class LnbitsRpcError extends Error {
|
|
||||||
readonly code: LnbitsErrorCode
|
|
||||||
readonly rpcName: string
|
|
||||||
readonly requestId: string
|
|
||||||
readonly retryPolicy: RetryPolicy
|
|
||||||
|
|
||||||
constructor(args: {
|
|
||||||
code: LnbitsErrorCode
|
|
||||||
rpcName: string
|
|
||||||
requestId: string
|
|
||||||
message?: string
|
|
||||||
}) {
|
|
||||||
super(args.message ?? `${args.rpcName}: ${args.code}`)
|
|
||||||
this.name = 'LnbitsRpcError'
|
|
||||||
this.code = args.code
|
|
||||||
this.rpcName = args.rpcName
|
|
||||||
this.requestId = args.requestId
|
|
||||||
this.retryPolicy = retryPolicyFor(args.code)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build from a wire ERROR response. An absent/unknown `error_code` maps to
|
|
||||||
* `internal_error` (retry-once) per the deprecation-window contract.
|
|
||||||
*/
|
|
||||||
static fromResponse(
|
|
||||||
rpcName: string,
|
|
||||||
response: { request_id: string; error_code?: string | null; error?: string }
|
|
||||||
): LnbitsRpcError {
|
|
||||||
const code = parseErrorCode(response.error_code) ?? LnbitsErrorCode.InternalError
|
|
||||||
return new LnbitsRpcError({
|
|
||||||
code,
|
|
||||||
rpcName,
|
|
||||||
requestId: response.request_id,
|
|
||||||
message: response.error ?? `${rpcName}: server returned ERROR (${code})`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the disposition permits a retry (any backoff/once policy). */
|
|
||||||
get isRetryable(): boolean {
|
|
||||||
return RETRYABLE.has(this.retryPolicy)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the operation already took effect despite the error. */
|
|
||||||
get isIdempotentSuccess(): boolean {
|
|
||||||
return this.retryPolicy === 'terminal-idempotent'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -49,15 +49,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { LnbitsClient } from './client.js'
|
export { 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 {
|
export type {
|
||||||
LnbitsConfig,
|
LnbitsConfig,
|
||||||
LnbitsRpcRequest,
|
LnbitsRpcRequest,
|
||||||
|
|
@ -73,8 +64,6 @@ export type {
|
||||||
PaymentPushCallback,
|
PaymentPushCallback,
|
||||||
SubscriptionCloseCallback,
|
SubscriptionCloseCallback,
|
||||||
CreateWithdrawLinkBody,
|
CreateWithdrawLinkBody,
|
||||||
CreateWithdrawBody,
|
|
||||||
CreateWithdrawResult,
|
|
||||||
LnbitsWithdrawLink,
|
LnbitsWithdrawLink,
|
||||||
UniqueHashEntry,
|
UniqueHashEntry,
|
||||||
UniqueHashesResponse,
|
UniqueHashesResponse,
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
/**
|
|
||||||
* Retry policy switch for the nostr-transport (aiolabs/bitspire#52, Phase D).
|
|
||||||
*
|
|
||||||
* Retries an operation according to the *disposition* of the error it throws —
|
|
||||||
* the machine-readable `retryPolicy` carried by `LnbitsRpcError` (which mirrors
|
|
||||||
* the lnbits canonical enum). Transient conditions back off and retry;
|
|
||||||
* terminal ones throw immediately.
|
|
||||||
*
|
|
||||||
* ⚠️ ONLY wrap IDEMPOTENT operations. A blind retry of `pay_invoice` could
|
|
||||||
* double-pay, and of `create_invoice` / `lnurlw_create_link` would mint
|
|
||||||
* duplicates — those surface their error for flow-level handling (the state
|
|
||||||
* machine / operator) instead. See LnbitsClient for which methods opt in.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { LnbitsRpcError, type RetryPolicy } from './error-codes.js'
|
|
||||||
|
|
||||||
export interface WithRetryOptions {
|
|
||||||
/** Max total attempts (default 3). */
|
|
||||||
maxAttempts?: number
|
|
||||||
/** Injectable sleep (tests pass a fake-timer-friendly version). */
|
|
||||||
sleep?: (ms: number) => Promise<void>
|
|
||||||
/** Called before each backoff wait — useful for logging. */
|
|
||||||
onRetry?: (info: { attempt: number; delayMs: number; error: unknown }) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_MAX_ATTEMPTS = 3
|
|
||||||
|
|
||||||
/** Backoff (ms) for the Nth attempt (1-based), or null if the policy is terminal. */
|
|
||||||
function backoffMs(policy: RetryPolicy, attempt: number): number | null {
|
|
||||||
switch (policy) {
|
|
||||||
case 'retry-backoff':
|
|
||||||
return 200 * 2 ** (attempt - 1) // 200, 400, 800…
|
|
||||||
case 'retry-long-backoff':
|
|
||||||
return 1_000 * 2 ** (attempt - 1) // 1s, 2s, 4s… (rate_limited)
|
|
||||||
case 'retry-once':
|
|
||||||
return attempt === 1 ? 0 : null // exactly one retry (internal_error / absent code)
|
|
||||||
case 'terminal':
|
|
||||||
case 'terminal-idempotent':
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retry delay for an error, or null if it must not be retried. */
|
|
||||||
function delayForError(error: unknown, attempt: number): number | null {
|
|
||||||
if (error instanceof LnbitsRpcError) {
|
|
||||||
return backoffMs(error.retryPolicy, attempt)
|
|
||||||
}
|
|
||||||
// A transport timeout from sendRpc ("…: timeout after <n>ms") is transient.
|
|
||||||
if (error instanceof Error && /timeout after \d+ms/.test(error.message)) {
|
|
||||||
return 200 * 2 ** (attempt - 1)
|
|
||||||
}
|
|
||||||
// Unknown error (programming bug, network teardown) — don't mask it.
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run `fn`, retrying transient failures per the error's `retryPolicy` (or a
|
|
||||||
* transport timeout) with backoff, up to `maxAttempts`. Terminal errors and
|
|
||||||
* unknown errors throw immediately; the last error is rethrown on exhaustion.
|
|
||||||
*/
|
|
||||||
export async function withRetry<T>(fn: () => Promise<T>, opts: WithRetryOptions = {}): Promise<T> {
|
|
||||||
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
|
|
||||||
const sleep = opts.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
|
|
||||||
|
|
||||||
let lastError: unknown
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
return await fn()
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error
|
|
||||||
if (attempt === maxAttempts) break
|
|
||||||
const delayMs = delayForError(error, attempt)
|
|
||||||
if (delayMs === null) throw error
|
|
||||||
opts.onRetry?.({ attempt, delayMs, error })
|
|
||||||
await sleep(delayMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastError
|
|
||||||
}
|
|
||||||
|
|
@ -39,12 +39,7 @@ export interface LnbitsRpcResponse<T = unknown> {
|
||||||
/** Non-null on subscription push events. Null on regular acks. */
|
/** Non-null on subscription push events. Null on regular acks. */
|
||||||
subscription_id?: string | null
|
subscription_id?: string | null
|
||||||
data?: T
|
data?: T
|
||||||
/** Human-readable error detail (ERROR status only). */
|
|
||||||
error?: string
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -146,45 +141,6 @@ export interface SubscribeClose {
|
||||||
// LNURL-withdraw (the `withdraw` extension's transport surface)
|
// 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 {
|
export interface CreateWithdrawLinkBody {
|
||||||
title: string
|
title: string
|
||||||
min_withdrawable: number
|
min_withdrawable: number
|
||||||
|
|
@ -274,30 +230,3 @@ export type PaymentPushCallback = (payment: LnbitsPayment) => void
|
||||||
|
|
||||||
/** Called when the subscription has been closed (by TTL or explicit unsubscribe). */
|
/** Called when the subscription has been closed (by TTL or explicit unsubscribe). */
|
||||||
export type SubscriptionCloseCallback = (reason: 'ttl' | 'unsubscribed') => void
|
export type SubscriptionCloseCallback = (reason: 'ttl' | 'unsubscribed') => void
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// get_machine_config RPC (spirekeeper#41 / bitspire#70 P1)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/** Fee-config wire shape inside `get_machine_config` — mirrors spirekeeper's
|
|
||||||
* `FeeConfigPayload.to_wire_dict()` (snake_case). */
|
|
||||||
export interface FeeConfigWire {
|
|
||||||
schema_version: number
|
|
||||||
cash_in_fee_fraction: number
|
|
||||||
cash_out_fee_fraction: number
|
|
||||||
components?: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response of the `get_machine_config` RPC: the operator pubkey + fee config
|
|
||||||
* (+ fiat, ids) LNbits delivers to a paired ATM over the authenticated
|
|
||||||
* transport, so a seed-only machine needs no per-machine env provisioning.
|
|
||||||
* `fee_config` is null until the operator has a super-config. */
|
|
||||||
export interface MachineConfigResponse {
|
|
||||||
operator_pubkey: string
|
|
||||||
fee_config: FeeConfigWire | null
|
|
||||||
fiat_code: string
|
|
||||||
machine_npub: string
|
|
||||||
wallet_id: string
|
|
||||||
/** Freshness watermark (unix s) for the consumer's fee-config replay guard. */
|
|
||||||
created_at: number
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import type { EventTemplate, VerifiedEvent } from 'nostr-tools'
|
|
||||||
import {
|
|
||||||
BunkerSigner,
|
|
||||||
BunkerRejectedError,
|
|
||||||
BunkerTimeoutError,
|
|
||||||
generateClientTransportKey,
|
|
||||||
connectNewSeed,
|
|
||||||
resumeFromBinding,
|
|
||||||
type Nip46Inner,
|
|
||||||
} from '../bunker-signer.js'
|
|
||||||
|
|
||||||
const SPIRE_PUBKEY = 'b'.repeat(64)
|
|
||||||
|
|
||||||
function fakeInner(overrides: Partial<Nip46Inner> = {}): Nip46Inner {
|
|
||||||
return {
|
|
||||||
connect: vi.fn(async () => {}),
|
|
||||||
signEvent: vi.fn(async (t: EventTemplate) => ({ ...t, id: 'id', sig: 'sig', pubkey: SPIRE_PUBKEY }) as unknown as VerifiedEvent),
|
|
||||||
nip44Encrypt: vi.fn(async (_pk: string, pt: string) => `enc(${pt})`),
|
|
||||||
nip44Decrypt: vi.fn(async (_pk: string, ct: string) => ct.replace(/^enc\((.*)\)$/, '$1')),
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('BunkerSigner', () => {
|
|
||||||
it('exposes the spire pubkey synchronously', () => {
|
|
||||||
const signer = new BunkerSigner(SPIRE_PUBKEY, fakeInner())
|
|
||||||
expect(signer.pubkey).toBe(SPIRE_PUBKEY)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('delegates sign / encrypt / decrypt to the inner client', async () => {
|
|
||||||
const inner = fakeInner()
|
|
||||||
const signer = new BunkerSigner(SPIRE_PUBKEY, inner)
|
|
||||||
|
|
||||||
const tmpl: EventTemplate = { kind: 21000, tags: [], content: 'x', created_at: 1 }
|
|
||||||
await signer.signEvent(tmpl)
|
|
||||||
expect(inner.signEvent).toHaveBeenCalledWith(tmpl)
|
|
||||||
|
|
||||||
expect(await signer.nip44Encrypt('peer', 'hi')).toBe('enc(hi)')
|
|
||||||
expect(inner.nip44Encrypt).toHaveBeenCalledWith('peer', 'hi')
|
|
||||||
|
|
||||||
expect(await signer.nip44Decrypt('peer', 'enc(hi)')).toBe('hi')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps an inner rejection to BunkerRejectedError (revoked / off-policy)', async () => {
|
|
||||||
const inner = fakeInner({
|
|
||||||
signEvent: vi.fn(async () => {
|
|
||||||
throw new Error('not authorized to sign kind 9999')
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const signer = new BunkerSigner(SPIRE_PUBKEY, inner)
|
|
||||||
await expect(signer.signEvent({ kind: 9999, tags: [], content: '', created_at: 1 })).rejects.toBeInstanceOf(
|
|
||||||
BunkerRejectedError
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('times out a non-responding bunker with BunkerTimeoutError', async () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
const inner = fakeInner({ signEvent: vi.fn(() => new Promise<VerifiedEvent>(() => {})) })
|
|
||||||
const signer = new BunkerSigner(SPIRE_PUBKEY, inner, { timeoutMs: 50 })
|
|
||||||
|
|
||||||
const p = signer.signEvent({ kind: 21000, tags: [], content: '', created_at: 1 })
|
|
||||||
const assertion = expect(p).rejects.toBeInstanceOf(BunkerTimeoutError)
|
|
||||||
await vi.advanceTimersByTimeAsync(60)
|
|
||||||
await assertion
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('transport key + factory guards', () => {
|
|
||||||
it('generates a hex transport keypair', () => {
|
|
||||||
const key = generateClientTransportKey()
|
|
||||||
expect(key.secretHex).toMatch(/^[0-9a-f]{64}$/)
|
|
||||||
expect(key.publicHex).toMatch(/^[0-9a-f]{64}$/)
|
|
||||||
expect(key.secretHex).not.toBe(key.publicHex)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('connectNewSeed rejects an unparseable bunker_url', async () => {
|
|
||||||
await expect(
|
|
||||||
connectNewSeed({ spirePubkey: SPIRE_PUBKEY, bunkerUrl: 'not-a-bunker-url', clientSecretHex: 'a'.repeat(64) })
|
|
||||||
).rejects.toThrow(/unparseable bunker_url/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resumeFromBinding rejects an unparseable bunker_url', async () => {
|
|
||||||
await expect(
|
|
||||||
resumeFromBinding({ spirePubkey: SPIRE_PUBKEY, bunkerUrl: 'not-a-bunker-url', clientSecretHex: 'a'.repeat(64) })
|
|
||||||
).rejects.toThrow(/unparseable bunker_url/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,33 +1,46 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { generateIdentity } from '../identity.js'
|
import { generateIdentity } from '../identity.js'
|
||||||
import { encryptContentV2, decryptContentV2 } from '../encryption.js'
|
import { encryptContent, decryptContent, decryptJSON } from '../encryption.js'
|
||||||
|
|
||||||
describe('encryption (NIP-44 v2)', () => {
|
describe('encryption', () => {
|
||||||
describe('encryptContentV2 / decryptContentV2', () => {
|
describe('encryptContent / decryptContent', () => {
|
||||||
it('should encrypt and decrypt string content', () => {
|
it('should encrypt and decrypt string content', () => {
|
||||||
const sender = generateIdentity()
|
const sender = generateIdentity()
|
||||||
const recipient = generateIdentity()
|
const recipient = generateIdentity()
|
||||||
const message = 'Hello, Nostr!'
|
const message = 'Hello, Nostr!'
|
||||||
|
|
||||||
const encrypted = encryptContentV2(sender, recipient.publicKey, message)
|
const encrypted = encryptContent(sender, recipient.publicKey, message)
|
||||||
|
|
||||||
expect(encrypted).not.toBe(message)
|
expect(encrypted).not.toBe(message)
|
||||||
expect(typeof encrypted).toBe('string')
|
expect(typeof encrypted).toBe('string')
|
||||||
|
|
||||||
const decrypted = decryptContentV2(recipient, sender.publicKey, encrypted)
|
const decrypted = decryptContent(recipient, sender.publicKey, encrypted)
|
||||||
|
|
||||||
expect(decrypted).toBe(message)
|
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 sender = generateIdentity()
|
||||||
const recipient = 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 encrypted = encryptContent(sender, recipient.publicKey, data)
|
||||||
const decrypted = decryptContentV2(recipient, sender.publicKey, encrypted)
|
const decrypted = decryptContent(recipient, sender.publicKey, encrypted)
|
||||||
|
|
||||||
expect(JSON.parse(decrypted)).toEqual(data)
|
expect(JSON.parse(decrypted)).toEqual(data)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('decryptJSON', () => {
|
||||||
|
it('should decrypt and parse JSON directly', () => {
|
||||||
|
const sender = generateIdentity()
|
||||||
|
const recipient = generateIdentity()
|
||||||
|
const data = { test: true, nested: { value: 42 } }
|
||||||
|
|
||||||
|
const encrypted = encryptContent(sender, recipient.publicKey, data)
|
||||||
|
const decrypted = decryptJSON<typeof data>(recipient, sender.publicKey, encrypted)
|
||||||
|
|
||||||
|
expect(decrypted).toEqual(data)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { generateIdentity } from '../identity.js'
|
import { generateIdentity } from '../identity.js'
|
||||||
import { LocalSigner } from '../signer.js'
|
import {
|
||||||
import { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from '../events.js'
|
createSignedEvent,
|
||||||
import { LamassuEventKind } from '../types.js'
|
createMachineStatusEvent,
|
||||||
|
createAuthEvent,
|
||||||
|
validateEvent,
|
||||||
|
generateTxId,
|
||||||
|
} from '../events.js'
|
||||||
|
import { LamassuEventKind, type MachineStatus } from '../types.js'
|
||||||
|
|
||||||
describe('events', () => {
|
describe('events', () => {
|
||||||
describe('createSignedEvent', () => {
|
describe('createSignedEvent', () => {
|
||||||
it('should create a properly signed event via the signer', async () => {
|
it('should create a properly signed event', () => {
|
||||||
const identity = generateIdentity()
|
const identity = generateIdentity()
|
||||||
const signer = new LocalSigner(identity)
|
const event = createSignedEvent(identity, {
|
||||||
const event = await createSignedEvent(signer, {
|
|
||||||
kind: 1,
|
kind: 1,
|
||||||
content: 'test',
|
content: 'test',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
|
@ -21,23 +25,48 @@ describe('events', () => {
|
||||||
expect(event.content).toBe('test')
|
expect(event.content).toBe('test')
|
||||||
expect(event.id).toMatch(/^[0-9a-f]{64}$/)
|
expect(event.id).toMatch(/^[0-9a-f]{64}$/)
|
||||||
expect(event.sig).toMatch(/^[0-9a-f]{128}$/)
|
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', () => {
|
describe('createAuthEvent', () => {
|
||||||
it('should create a signed NIP-42 auth event (kind 22242)', async () => {
|
it('should create NIP-42 auth event', () => {
|
||||||
const signer = new LocalSigner(generateIdentity())
|
const identity = generateIdentity()
|
||||||
const relayUrl = 'wss://relay.test.com'
|
const relayUrl = 'wss://relay.test.com'
|
||||||
const challenge = 'random-challenge-string'
|
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.kind).toBe(LamassuEventKind.Auth)
|
||||||
expect(event.content).toBe('')
|
expect(event.content).toBe('')
|
||||||
expect(event.tags).toContainEqual(['relay', relayUrl])
|
expect(event.tags).toContainEqual(['relay', relayUrl])
|
||||||
expect(event.tags).toContainEqual(['challenge', challenge])
|
expect(event.tags).toContainEqual(['challenge', challenge])
|
||||||
expect(event.pubkey).toBe(signer.pubkey)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { npubEncode } from 'nostr-tools/nip19'
|
|
||||||
import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js'
|
|
||||||
|
|
||||||
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
|
||||||
function makeSeed(json: unknown): string {
|
|
||||||
const b64 = Buffer.from(JSON.stringify(json), 'utf8')
|
|
||||||
.toString('base64')
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/, '')
|
|
||||||
return SPIRE_SEED_SCHEME + b64
|
|
||||||
}
|
|
||||||
|
|
||||||
const SPIRE_PUBKEY = 'a'.repeat(64)
|
|
||||||
const LNBITS_PUBKEY = 'b'.repeat(64)
|
|
||||||
const SPIRE_NPUB = npubEncode(SPIRE_PUBKEY)
|
|
||||||
const LNBITS_NPUB = npubEncode(LNBITS_PUBKEY)
|
|
||||||
|
|
||||||
const VALID = {
|
|
||||||
v: 1,
|
|
||||||
spire_npub: SPIRE_NPUB,
|
|
||||||
lnbits_npub: LNBITS_NPUB,
|
|
||||||
bunker_secret: 'deadbeef',
|
|
||||||
relays: ['wss://events.relay/'],
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('parseSpireSeed', () => {
|
|
||||||
it('derives hex pubkeys from npubs and reconstructs the bunker URL', () => {
|
|
||||||
const seed = parseSpireSeed(makeSeed(VALID))
|
|
||||||
expect(seed).toEqual({
|
|
||||||
v: 1,
|
|
||||||
spirePubkey: SPIRE_PUBKEY,
|
|
||||||
lnbitsServerPubkey: LNBITS_PUBKEY,
|
|
||||||
bunkerUrl: `bunker://${SPIRE_PUBKEY}?relay=${encodeURIComponent('wss://events.relay/')}&secret=deadbeef`,
|
|
||||||
relays: ['wss://events.relay/'],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('defaults the bunker relay to relays[0] when bunker_relay is absent', () => {
|
|
||||||
const seed = parseSpireSeed(makeSeed(VALID))
|
|
||||||
expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://events.relay/')}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses an explicit bunker_relay when present (distinct from event relays)', () => {
|
|
||||||
const seed = parseSpireSeed(makeSeed({ ...VALID, bunker_relay: 'wss://bunker.relay/' }))
|
|
||||||
expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://bunker.relay/')}`)
|
|
||||||
// event relays are unchanged
|
|
||||||
expect(seed.relays).toEqual(['wss://events.relay/'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('percent-encodes relay + secret for parseBunkerInput to decode', () => {
|
|
||||||
const seed = parseSpireSeed(makeSeed(VALID))
|
|
||||||
expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F')
|
|
||||||
expect(seed.bunkerUrl).toContain('secret=deadbeef')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('re-pads stripped base64url of any residue length', () => {
|
|
||||||
// Vary the secret so the encoded payload lands on each mod-4 residue.
|
|
||||||
for (const suffix of ['', 'a', 'ab', 'abc']) {
|
|
||||||
const seed = makeSeed({ ...VALID, bunker_secret: `deadbeef${suffix}` })
|
|
||||||
expect(() => parseSpireSeed(seed)).not.toThrow()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['wrong scheme', 'spire-seed:v2:abc'],
|
|
||||||
['not a seed', 'bunker://whatever'],
|
|
||||||
])('rejects %s', (_label, url) => {
|
|
||||||
expect(() => parseSpireSeed(url)).toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['bad version', { ...VALID, v: 2 }],
|
|
||||||
['missing spire_npub', { ...VALID, spire_npub: undefined }],
|
|
||||||
['non-npub spire_npub', { ...VALID, spire_npub: 'a'.repeat(64) }],
|
|
||||||
['missing lnbits_npub', { ...VALID, lnbits_npub: undefined }],
|
|
||||||
['non-npub lnbits_npub', { ...VALID, lnbits_npub: 'notanpub' }],
|
|
||||||
['empty bunker_secret', { ...VALID, bunker_secret: '' }],
|
|
||||||
['missing bunker_secret', { ...VALID, bunker_secret: undefined }],
|
|
||||||
['empty relays', { ...VALID, relays: [] }],
|
|
||||||
['non-string relay', { ...VALID, relays: [123] }],
|
|
||||||
['non-ws relay (scan corruption ws://→As://)', { ...VALID, relays: ['As://events.relay/'] }],
|
|
||||||
['non-ws relay (http)', { ...VALID, relays: ['http://events.relay/'] }],
|
|
||||||
['empty bunker_relay', { ...VALID, bunker_relay: '' }],
|
|
||||||
['non-ws bunker_relay', { ...VALID, bunker_relay: 'As://bunker.relay/' }],
|
|
||||||
])('rejects %s', (_label, json) => {
|
|
||||||
expect(() => parseSpireSeed(makeSeed(json))).toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('seedFingerprint', () => {
|
|
||||||
it('is stable for the same seed and differs across seeds', () => {
|
|
||||||
const a = makeSeed(VALID)
|
|
||||||
const b = makeSeed({ ...VALID, relays: ['wss://other.relay/'] })
|
|
||||||
expect(seedFingerprint(a)).toBe(seedFingerprint(a))
|
|
||||||
expect(seedFingerprint(a)).not.toBe(seedFingerprint(b))
|
|
||||||
expect(seedFingerprint(a)).toMatch(/^[0-9a-f]{64}$/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { finalizeEvent, verifyEvent } from 'nostr-tools'
|
|
||||||
import { generateIdentity } from '../identity.js'
|
|
||||||
import { LocalSigner } from '../signer.js'
|
|
||||||
import { encryptContentV2, decryptContentV2 } from '../encryption.js'
|
|
||||||
|
|
||||||
describe('LocalSigner', () => {
|
|
||||||
it('exposes the identity pubkey synchronously', () => {
|
|
||||||
const identity = generateIdentity()
|
|
||||||
const signer = new LocalSigner(identity)
|
|
||||||
expect(signer.pubkey).toBe(identity.publicKey)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('signEvent produces a valid signature equivalent to finalizeEvent', async () => {
|
|
||||||
const identity = generateIdentity()
|
|
||||||
const signer = new LocalSigner(identity)
|
|
||||||
const template = {
|
|
||||||
kind: 21000,
|
|
||||||
content: 'rpc',
|
|
||||||
tags: [['p', identity.publicKey]],
|
|
||||||
created_at: 1_700_000_000,
|
|
||||||
}
|
|
||||||
|
|
||||||
const signed = await signer.signEvent(template)
|
|
||||||
const reference = finalizeEvent(template, identity.privateKey)
|
|
||||||
|
|
||||||
expect(verifyEvent(signed)).toBe(true)
|
|
||||||
expect(signed.pubkey).toBe(identity.publicKey)
|
|
||||||
// Same template + same key ⇒ same id (id is deterministic over content).
|
|
||||||
expect(signed.id).toBe(reference.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('nip44Encrypt round-trips with the counterparty signer', async () => {
|
|
||||||
const alice = generateIdentity()
|
|
||||||
const bob = generateIdentity()
|
|
||||||
const aliceSigner = new LocalSigner(alice)
|
|
||||||
const bobSigner = new LocalSigner(bob)
|
|
||||||
|
|
||||||
const ciphertext = await aliceSigner.nip44Encrypt(bob.publicKey, 'secret')
|
|
||||||
const plaintext = await bobSigner.nip44Decrypt(alice.publicKey, ciphertext)
|
|
||||||
|
|
||||||
expect(plaintext).toBe('secret')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('nip44 output interops with the standalone encryptContentV2 helper', async () => {
|
|
||||||
const alice = generateIdentity()
|
|
||||||
const bob = generateIdentity()
|
|
||||||
const aliceSigner = new LocalSigner(alice)
|
|
||||||
|
|
||||||
const viaSigner = await aliceSigner.nip44Encrypt(bob.publicKey, 'hello')
|
|
||||||
// The helper and the signer share NIP-44 v2 conversation-key derivation,
|
|
||||||
// so each can decrypt the other's ciphertext.
|
|
||||||
expect(decryptContentV2(bob, alice.publicKey, viaSigner)).toBe('hello')
|
|
||||||
|
|
||||||
const viaHelper = encryptContentV2(alice, bob.publicKey, 'hello')
|
|
||||||
expect(await aliceSigner.nip44Decrypt(bob.publicKey, viaHelper)).toBe('hello')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
/**
|
|
||||||
* NIP-46 (nsecbunkerd) signer.
|
|
||||||
*
|
|
||||||
* Implements the `Signer` contract by delegating sign / nip44 to a remote
|
|
||||||
* bunker over NIP-46, so no operator key lives on the ATM. The ATM holds
|
|
||||||
* only its own *transport* keypair (`client_nsec`); the signing identity
|
|
||||||
* (`spire_pubkey`) is held by the operator's nsecbunkerd. See
|
|
||||||
* aiolabs/bitspire#52 (model A1) and lnbits `nip46_bunker_client.py`.
|
|
||||||
*
|
|
||||||
* Two lifecycle entry points:
|
|
||||||
* - `connectNewSeed` — first pairing: generate a transport key, redeem the
|
|
||||||
* one-shot connect secret, bind `client_pubkey → spire_key` on the bunker.
|
|
||||||
* - `resumeFromBinding` — restart: reuse the persisted transport key. The
|
|
||||||
* binding is server-persistent, so we do NOT re-redeem (the secret is
|
|
||||||
* spent); we just re-open the relay subscription.
|
|
||||||
*
|
|
||||||
* `pubkey` is the spire identity, known synchronously from the seed/binding,
|
|
||||||
* so subscription filters and `p` tags work before any round-trip.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { BunkerSigner as Nip46BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'
|
|
||||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
|
||||||
import { bytesToHex, hexToBytes } from 'nostr-tools/utils'
|
|
||||||
import type { EventTemplate, VerifiedEvent } from 'nostr-tools'
|
|
||||||
import type { Signer } from './signer.js'
|
|
||||||
|
|
||||||
/** Default per-RPC timeout. nostr-tools' nip46 sendRequest has none — a dead
|
|
||||||
* bunker would hang forever — so we race every call against this. */
|
|
||||||
const DEFAULT_BUNKER_TIMEOUT_MS = 10_000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raised when the bunker actively rejects a request. Post-bind causes
|
|
||||||
* (nsecbunkerd#27, sign-time lifecycle enforcement): the operator revoked the
|
|
||||||
* binding (`KeyUser`/`Token.revokedAt`), the token's TTL (`expiresAt`) lapsed,
|
|
||||||
* or the requested kind/method is outside the policy. Callers should treat
|
|
||||||
* this as "unpaired" and surface a re-pair prompt.
|
|
||||||
*/
|
|
||||||
export class BunkerRejectedError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'BunkerRejectedError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Raised when the bunker does not answer within the timeout (transient). */
|
|
||||||
export class BunkerTimeoutError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'BunkerTimeoutError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The subset of nostr-tools' nip46 BunkerSigner this wrapper drives. */
|
|
||||||
export interface Nip46Inner {
|
|
||||||
connect(): Promise<void>
|
|
||||||
signEvent(event: EventTemplate): Promise<VerifiedEvent>
|
|
||||||
nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string>
|
|
||||||
nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BunkerSignerOptions {
|
|
||||||
/** Per-RPC timeout in ms (default 10000). */
|
|
||||||
timeoutMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BunkerSigner implements Signer {
|
|
||||||
readonly pubkey: string
|
|
||||||
readonly #inner: Nip46Inner
|
|
||||||
readonly #timeoutMs: number
|
|
||||||
|
|
||||||
constructor(spirePubkey: string, inner: Nip46Inner, opts: BunkerSignerOptions = {}) {
|
|
||||||
this.pubkey = spirePubkey
|
|
||||||
this.#inner = inner
|
|
||||||
this.#timeoutMs = opts.timeoutMs ?? DEFAULT_BUNKER_TIMEOUT_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
signEvent(template: EventTemplate): Promise<VerifiedEvent> {
|
|
||||||
return this.#call('sign_event', () => this.#inner.signEvent(template))
|
|
||||||
}
|
|
||||||
|
|
||||||
nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string> {
|
|
||||||
return this.#call('nip44_encrypt', () => this.#inner.nip44Encrypt(peerPubkey, plaintext))
|
|
||||||
}
|
|
||||||
|
|
||||||
nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string> {
|
|
||||||
return this.#call('nip44_decrypt', () => this.#inner.nip44Decrypt(peerPubkey, ciphertext))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap a bunker RPC with a timeout and normalize failures. nostr-tools'
|
|
||||||
* nip46 rejects with the bunker's `error` string (a rejection) — mapped to
|
|
||||||
* `BunkerRejectedError`; a non-response surfaces as `BunkerTimeoutError`.
|
|
||||||
*/
|
|
||||||
async #call<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
const timeout = new Promise<never>((_, reject) => {
|
|
||||||
timer = setTimeout(
|
|
||||||
() => reject(new BunkerTimeoutError(`bunker ${label}: no response in ${this.#timeoutMs}ms`)),
|
|
||||||
this.#timeoutMs
|
|
||||||
)
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
return await Promise.race([fn(), timeout])
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof BunkerTimeoutError) throw err
|
|
||||||
throw new BunkerRejectedError(`bunker ${label}: ${(err as Error).message ?? String(err)}`)
|
|
||||||
} finally {
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A freshly-generated NIP-46 transport keypair (the ATM's `client_nsec`). */
|
|
||||||
export interface ClientTransportKey {
|
|
||||||
/** 64-char hex secret key — persist this to state.db. */
|
|
||||||
secretHex: string
|
|
||||||
/** 64-char hex public key — what the bunker binds to the spire identity. */
|
|
||||||
publicHex: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate the ATM's own NIP-46 transport keypair. */
|
|
||||||
export function generateClientTransportKey(): ClientTransportKey {
|
|
||||||
const sk = generateSecretKey()
|
|
||||||
return { secretHex: bytesToHex(sk), publicHex: getPublicKey(sk) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persisted bunker binding — everything needed to resume without re-pairing. */
|
|
||||||
export interface BunkerBinding {
|
|
||||||
/** Hex transport secret key (`client_nsec`). */
|
|
||||||
clientSecretHex: string
|
|
||||||
/** The spire's signing pubkey (hex). */
|
|
||||||
spirePubkey: string
|
|
||||||
/** `bunker://…` URL, re-parsed into a pointer on resume. */
|
|
||||||
bunkerUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First pairing: build a transport-keyed bunker signer, redeem the one-shot
|
|
||||||
* connect secret, and bind `client_pubkey → spire_key`. The returned signer
|
|
||||||
* is live; the caller persists `clientSecretHex` so a restart can resume.
|
|
||||||
*
|
|
||||||
* `bunkerUrl` is the seed's `bunker_url`; `spirePubkey` is the seed's
|
|
||||||
* `spire_pubkey`.
|
|
||||||
*/
|
|
||||||
export async function connectNewSeed(
|
|
||||||
args: { spirePubkey: string; bunkerUrl: string; clientSecretHex: string },
|
|
||||||
opts: BunkerSignerOptions = {}
|
|
||||||
): Promise<BunkerSigner> {
|
|
||||||
const pointer = await parseBunkerInput(args.bunkerUrl)
|
|
||||||
if (!pointer) {
|
|
||||||
throw new Error(`connectNewSeed: unparseable bunker_url`)
|
|
||||||
}
|
|
||||||
const inner = Nip46BunkerSigner.fromBunker(hexToBytes(args.clientSecretHex), pointer)
|
|
||||||
await inner.connect() // redeems the one-shot secret; eager-binds on the bunker
|
|
||||||
return new BunkerSigner(args.spirePubkey, inner, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restart: reuse the persisted transport key. The bunker binding is
|
|
||||||
* server-persistent, so we do NOT call `connect()` (the secret is spent);
|
|
||||||
* `fromBunker` opens the relay subscription and `sign_event` works against
|
|
||||||
* the existing binding.
|
|
||||||
*/
|
|
||||||
export async function resumeFromBinding(
|
|
||||||
binding: BunkerBinding,
|
|
||||||
opts: BunkerSignerOptions = {}
|
|
||||||
): Promise<BunkerSigner> {
|
|
||||||
const pointer = await parseBunkerInput(binding.bunkerUrl)
|
|
||||||
if (!pointer) {
|
|
||||||
throw new Error(`resumeFromBinding: unparseable bunker_url`)
|
|
||||||
}
|
|
||||||
// The connect secret is already spent; drop it so nothing re-redeems.
|
|
||||||
pointer.secret = null
|
|
||||||
const inner = Nip46BunkerSigner.fromBunker(hexToBytes(binding.clientSecretHex), pointer)
|
|
||||||
return new BunkerSigner(binding.spirePubkey, inner, opts)
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,14 @@
|
||||||
* - Automatic reconnection
|
* - 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 { createAuthEvent } from './events.js'
|
||||||
import type {
|
import type {
|
||||||
NostrClientConfig,
|
NostrClientConfig,
|
||||||
|
|
@ -149,15 +156,10 @@ export class NostrClient {
|
||||||
// We need to extract the challenge and create our auth response
|
// We need to extract the challenge and create our auth response
|
||||||
const challenge =
|
const challenge =
|
||||||
evt.tags?.find((t): t is [string, string] => t[0] === 'challenge')?.[1] ?? ''
|
evt.tags?.find((t): t is [string, string] => t[0] === 'challenge')?.[1] ?? ''
|
||||||
const authEvent = await createAuthEvent(
|
const authEvent = createAuthEvent(this.config.identity, connection.config.url, challenge)
|
||||||
this.config.signer,
|
// Verify the event to get a VerifiedEvent type
|
||||||
connection.config.url,
|
|
||||||
challenge
|
|
||||||
)
|
|
||||||
// The signer returns a fully-signed event; re-verify defensively
|
|
||||||
// (a remote bunker could in principle return a malformed reply).
|
|
||||||
if (verifyEvent(authEvent)) {
|
if (verifyEvent(authEvent)) {
|
||||||
return authEvent
|
return authEvent as VerifiedEvent
|
||||||
}
|
}
|
||||||
throw new Error('Failed to create valid auth event')
|
throw new Error('Failed to create valid auth event')
|
||||||
})
|
})
|
||||||
|
|
@ -391,13 +393,13 @@ export class NostrClient {
|
||||||
* Get the machine's public key
|
* Get the machine's public key
|
||||||
*/
|
*/
|
||||||
get publicKey(): string {
|
get publicKey(): string {
|
||||||
return this.config.signer.pubkey
|
return this.config.identity.publicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the machine's npub
|
* Get the machine's npub
|
||||||
*/
|
*/
|
||||||
get npub(): string {
|
get npub(): string {
|
||||||
return nip19.npubEncode(this.config.signer.pubkey)
|
return this.config.identity.npub
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,274 @@
|
||||||
/**
|
/**
|
||||||
* NIP-44 v2 encryption helpers.
|
* NIP-44 Encryption utilities
|
||||||
*
|
*
|
||||||
* Thin wrappers over nostr-tools `nip44.v2`, used for operator-directed
|
* Supports both:
|
||||||
* kind-30078 content and by the dormant CLINK client. Kind-21000 RPC and
|
* - v1: Lightning.Pub's custom format (xchacha20, used for kind 21000)
|
||||||
* the availability/cassette paths route through the `Signer` abstraction
|
* - v2: Standard NIP-44 v2 (used for other kinds)
|
||||||
* (`signer.ts`) instead.
|
|
||||||
*
|
*
|
||||||
* The legacy NIP-44 v1 / Lightning.Pub XChaCha20 format was retired with
|
* NOTE: Lightning.Pub currently only supports NIP-44 v1 for kind 21000 RPC.
|
||||||
* the LNbits migration (aiolabs/bitspire#52): the nsecbunkerd signer is
|
* A contribution to support v2 would be welcome:
|
||||||
* NIP-44 v2 only and nothing live used v1.
|
* https://github.com/shocknet/Lightning.Pub
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { nip44 } from 'nostr-tools'
|
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'
|
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 = (
|
export const encryptContentV2 = (
|
||||||
identity: MachineIdentity,
|
identity: MachineIdentity,
|
||||||
recipientPubkey: string,
|
recipientPubkey: string,
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,87 @@
|
||||||
* Event creation utilities for Lamassu ATM
|
* Event creation utilities for Lamassu ATM
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools'
|
import { type Event, type UnsignedEvent, finalizeEvent, getEventHash } from 'nostr-tools'
|
||||||
import type { Signer } from './signer.js'
|
import { encryptContent } from './encryption.js'
|
||||||
import { LamassuEventKind } from './types.js'
|
import {
|
||||||
|
type MachineIdentity,
|
||||||
|
type MachineStatus,
|
||||||
|
type TransactionRecord,
|
||||||
|
LamassuEventKind,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign an event template with the given signer.
|
* Create a signed event
|
||||||
*
|
|
||||||
* Thin async wrapper over `Signer.signEvent` — the signer sets `pubkey`,
|
|
||||||
* `id` and `sig`. With a `BunkerSigner` this is a relay round-trip.
|
|
||||||
*/
|
*/
|
||||||
export function createSignedEvent(signer: Signer, template: EventTemplate): Promise<VerifiedEvent> {
|
export function createSignedEvent(
|
||||||
return signer.signEvent(template)
|
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
|
* This is a replaceable event that represents the current machine state.
|
||||||
* must be present in the signer policy (`SPIRE_POLICY_RULES`) or the sign
|
* Content is encrypted with NIP-44 for the operator.
|
||||||
* request is rejected — see aiolabs/spirekeeper#26.
|
*/
|
||||||
|
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(
|
export function createAuthEvent(
|
||||||
signer: Signer,
|
identity: MachineIdentity,
|
||||||
relayUrl: string,
|
relayUrl: string,
|
||||||
challenge: string
|
challenge: string
|
||||||
): Promise<VerifiedEvent> {
|
): Event {
|
||||||
return signer.signEvent({
|
return createSignedEvent(identity, {
|
||||||
kind: LamassuEventKind.Auth,
|
kind: LamassuEventKind.Auth,
|
||||||
content: '',
|
content: '',
|
||||||
tags: [
|
tags: [
|
||||||
|
|
|
||||||
|
|
@ -15,32 +15,30 @@
|
||||||
* import {
|
* import {
|
||||||
* NostrClient,
|
* NostrClient,
|
||||||
* generateIdentity,
|
* generateIdentity,
|
||||||
* LocalSigner,
|
* createMachineStatusEvent
|
||||||
* createSignedEvent
|
|
||||||
* } from '@bitSpire/nostr-client'
|
* } from '@bitSpire/nostr-client'
|
||||||
*
|
*
|
||||||
* // Create or load identity, wrap it in a signer
|
* // Create or load identity
|
||||||
* const signer = new LocalSigner(generateIdentity())
|
* const identity = generateIdentity()
|
||||||
*
|
*
|
||||||
* // Create client
|
* // Create client
|
||||||
* const client = new NostrClient({
|
* const client = new NostrClient({
|
||||||
* relays: [
|
* relays: [
|
||||||
* { url: 'wss://relay.youratm.company', requiresAuth: true }
|
* { url: 'wss://relay.youratm.company', requiresAuth: true }
|
||||||
* ],
|
* ],
|
||||||
* signer
|
* identity
|
||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // Connect
|
* // Connect
|
||||||
* await client.connect()
|
* await client.connect()
|
||||||
*
|
*
|
||||||
* // Sign + publish an event
|
* // Publish machine status
|
||||||
* const event = await createSignedEvent(signer, {
|
* const statusEvent = createMachineStatusEvent(
|
||||||
* kind: 30078,
|
* identity,
|
||||||
* created_at: Math.floor(Date.now() / 1000),
|
* operatorPubkey,
|
||||||
* tags: [['d', 'status']],
|
* { online: true, ... }
|
||||||
* content: '...'
|
* )
|
||||||
* })
|
* await client.publish(statusEvent)
|
||||||
* await client.publish(event)
|
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -57,28 +55,25 @@ export {
|
||||||
bytesToHex,
|
bytesToHex,
|
||||||
} from './identity.js'
|
} 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
|
// 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)
|
// Encryption
|
||||||
export { encryptContentV2, decryptContentV2 } from './encryption.js'
|
export {
|
||||||
|
encryptContent,
|
||||||
|
decryptContent,
|
||||||
|
decryptJSON,
|
||||||
|
// NIP-44 v2 (standard, for CLINK protocol)
|
||||||
|
encryptContentV2,
|
||||||
|
decryptContentV2,
|
||||||
|
} from './encryption.js'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type {
|
export type {
|
||||||
|
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
/**
|
|
||||||
* Spire pairing seed-URL parser.
|
|
||||||
*
|
|
||||||
* The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a
|
|
||||||
* one-time seed URL that encodes the bunker connection + the spire's signing
|
|
||||||
* identity. Wire contract (model A1, minimal encoding):
|
|
||||||
*
|
|
||||||
* spire-seed:v1:<base64url(json, no padding)>
|
|
||||||
* json = {
|
|
||||||
* "v": 1,
|
|
||||||
* "spire_npub": "npub1…", // spire signing identity (bech32; hex derived)
|
|
||||||
* "lnbits_npub": "npub1…", // LNbits nostr-transport server identity
|
|
||||||
* "bunker_secret": "<sec>", // one-shot NIP-46 connect token
|
|
||||||
* "relays": ["wss://…"], // relays the spire's OWN events use (21000/30078)
|
|
||||||
* "bunker_relay": "wss://…" // OPTIONAL — NIP-46 relay; defaults to relays[0]
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* Design (see aiolabs/bitspire#70): the pubkey is carried ONCE, as an npub.
|
|
||||||
* The old shape spelled it three times (spire_npub + spire_pubkey hex + inside
|
|
||||||
* a full bunker_url), which bloats a QR that's already hard to scan. Here:
|
|
||||||
*
|
|
||||||
* - `spire_pubkey` (hex) is derived from `spire_npub` (npub is ~the same length
|
|
||||||
* as hex but carries a bech32 checksum — real error-detection for a value
|
|
||||||
* read off a camera).
|
|
||||||
* - `bunker_url` is RECONSTRUCTED from `spire_pubkey`, `bunker_relay` (or
|
|
||||||
* `relays[0]`), and `bunker_secret`, then handed verbatim to nostr-tools
|
|
||||||
* `parseBunkerInput` (see bunker-signer.ts).
|
|
||||||
* - `lnbits_npub` gives the ATM its LNbits transport server pubkey so a paired
|
|
||||||
* machine needs nothing else provisioned to reach the backend (#70 part 2).
|
|
||||||
*
|
|
||||||
* base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple of 4
|
|
||||||
* before decoding.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { sha256 } from '@noble/hashes/sha2.js'
|
|
||||||
import { bytesToHex } from 'nostr-tools/utils'
|
|
||||||
import { decode as nip19Decode } from 'nostr-tools/nip19'
|
|
||||||
|
|
||||||
export const SPIRE_SEED_SCHEME = 'spire-seed:v1:'
|
|
||||||
|
|
||||||
export interface SpireSeed {
|
|
||||||
/** Seed format version (always 1 for this scheme). */
|
|
||||||
v: number
|
|
||||||
/** The spire's signing identity — 64-char hex, derived from `spire_npub`. */
|
|
||||||
spirePubkey: string
|
|
||||||
/** `bunker://<pubkey>?relay=&secret=` — reconstructed, handed to parseBunkerInput. */
|
|
||||||
bunkerUrl: string
|
|
||||||
/** Relays where the spire publishes its own events (kind 21000 / 30078). */
|
|
||||||
relays: string[]
|
|
||||||
/** LNbits nostr-transport server pubkey — 64-char hex, derived from `lnbits_npub`. */
|
|
||||||
lnbitsServerPubkey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const HEX64 = /^[0-9a-f]{64}$/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A relay must be a `ws://` or `wss://` URL. Unlike the npubs (bech32-checksummed,
|
|
||||||
* so a mis-scanned character is caught), the relay strings are raw inside the
|
|
||||||
* seed's base64 — a QR misread can silently corrupt `ws://` into e.g. `As://`
|
|
||||||
* and the pairing then crash-loops on an unreachable relay. Reject at parse time
|
|
||||||
* so the wizard refuses a garbled scan instead of persisting it (bitspire#70).
|
|
||||||
*/
|
|
||||||
const WS_URL = /^wss?:\/\/[^\s]+$/
|
|
||||||
function assertRelayUrl(value: string, field: string): void {
|
|
||||||
if (!WS_URL.test(value)) {
|
|
||||||
throw new Error(`parseSpireSeed: ${field} must be a ws:// or wss:// URL (got "${value}")`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decode an unpadded base64url string in both browser and Node. */
|
|
||||||
function base64urlDecode(input: string): string {
|
|
||||||
const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=')
|
|
||||||
if (typeof atob !== 'undefined') {
|
|
||||||
return atob(padded)
|
|
||||||
}
|
|
||||||
return Buffer.from(padded, 'base64').toString('binary')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decode an `npub1…` to its 64-char hex pubkey, failing closed. */
|
|
||||||
function hexFromNpub(value: unknown, field: string): string {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
throw new Error(`parseSpireSeed: ${field} must be a string`)
|
|
||||||
}
|
|
||||||
let decoded: ReturnType<typeof nip19Decode>
|
|
||||||
try {
|
|
||||||
decoded = nip19Decode(value)
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`parseSpireSeed: ${field} is not a valid npub (${(err as Error).message})`)
|
|
||||||
}
|
|
||||||
if (decoded.type !== 'npub' || typeof decoded.data !== 'string' || !HEX64.test(decoded.data)) {
|
|
||||||
throw new Error(`parseSpireSeed: ${field} must be an npub`)
|
|
||||||
}
|
|
||||||
return decoded.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse + validate a `spire-seed:v1:` URL. Throws on any malformation —
|
|
||||||
* the seed is a trust root, so we fail closed rather than connect to a
|
|
||||||
* half-understood bunker.
|
|
||||||
*/
|
|
||||||
export function parseSpireSeed(seedUrl: string): SpireSeed {
|
|
||||||
if (typeof seedUrl !== 'string' || !seedUrl.startsWith(SPIRE_SEED_SCHEME)) {
|
|
||||||
throw new Error(`parseSpireSeed: not a ${SPIRE_SEED_SCHEME} URL`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = seedUrl.slice(SPIRE_SEED_SCHEME.length)
|
|
||||||
let raw: unknown
|
|
||||||
try {
|
|
||||||
raw = JSON.parse(base64urlDecode(payload))
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`parseSpireSeed: undecodable payload (${(err as Error).message})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!raw || typeof raw !== 'object') {
|
|
||||||
throw new Error('parseSpireSeed: payload is not an object')
|
|
||||||
}
|
|
||||||
const obj = raw as Record<string, unknown>
|
|
||||||
|
|
||||||
if (obj.v !== 1) {
|
|
||||||
throw new Error(`parseSpireSeed: unsupported version ${String(obj.v)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const spirePubkey = hexFromNpub(obj.spire_npub, 'spire_npub')
|
|
||||||
const lnbitsServerPubkey = hexFromNpub(obj.lnbits_npub, 'lnbits_npub')
|
|
||||||
|
|
||||||
const bunkerSecret = obj.bunker_secret
|
|
||||||
if (typeof bunkerSecret !== 'string' || bunkerSecret.length === 0) {
|
|
||||||
throw new Error('parseSpireSeed: bunker_secret must be a non-empty string')
|
|
||||||
}
|
|
||||||
|
|
||||||
const relays = obj.relays
|
|
||||||
if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) {
|
|
||||||
throw new Error('parseSpireSeed: relays must be a non-empty string array')
|
|
||||||
}
|
|
||||||
relays.forEach((r, i) => assertRelayUrl(r as string, `relays[${i}]`))
|
|
||||||
|
|
||||||
// Optional bunker relay; default to the first event relay. Keeps the common
|
|
||||||
// case (bunker on the same relay) one field lighter, while still allowing a
|
|
||||||
// distinct NIP-46 relay when the operator runs one.
|
|
||||||
let bunkerRelay = relays[0] as string
|
|
||||||
if (obj.bunker_relay !== undefined) {
|
|
||||||
if (typeof obj.bunker_relay !== 'string' || obj.bunker_relay.length === 0) {
|
|
||||||
throw new Error('parseSpireSeed: bunker_relay, if present, must be a non-empty string')
|
|
||||||
}
|
|
||||||
assertRelayUrl(obj.bunker_relay, 'bunker_relay')
|
|
||||||
bunkerRelay = obj.bunker_relay
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconstruct the bunker URL nostr-tools expects. relay + secret are
|
|
||||||
// percent-encoded here; parseBunkerInput decodes them downstream.
|
|
||||||
const bunkerUrl =
|
|
||||||
`bunker://${spirePubkey}` +
|
|
||||||
`?relay=${encodeURIComponent(bunkerRelay)}` +
|
|
||||||
`&secret=${encodeURIComponent(bunkerSecret)}`
|
|
||||||
|
|
||||||
return { v: 1, spirePubkey, bunkerUrl, relays: relays as string[], lnbitsServerPubkey }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stable fingerprint of a seed URL, used to detect a re-pair (operator/relay
|
|
||||||
* change). A different seed ⇒ a different fingerprint ⇒ the ATM re-binds and
|
|
||||||
* resets its bootstrap gate (aiolabs/bitspire#56).
|
|
||||||
*/
|
|
||||||
export function seedFingerprint(seedUrl: string): string {
|
|
||||||
return bytesToHex(sha256(new TextEncoder().encode(seedUrl)))
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
/**
|
|
||||||
* Signing + NIP-44 abstraction.
|
|
||||||
*
|
|
||||||
* Decouples every signing / encryption call site from the concrete key
|
|
||||||
* material. Two implementations:
|
|
||||||
*
|
|
||||||
* - `LocalSigner` holds an nsec in-process. Used for dev / ephemeral
|
|
||||||
* identities and as the transitional fallback when no bunker pairing
|
|
||||||
* exists. The underlying crypto is synchronous.
|
|
||||||
* - `BunkerSigner` (Phase B, aiolabs/bitspire#52) routes to a remote
|
|
||||||
* NIP-46 nsecbunkerd so no operator key ever lives on the ATM.
|
|
||||||
*
|
|
||||||
* `pubkey` is the *signing* identity and is always known synchronously —
|
|
||||||
* from the local nsec, or from the spire seed before the bunker connects —
|
|
||||||
* so subscription filters and `p` tags need no refactor when the backing
|
|
||||||
* implementation changes.
|
|
||||||
*
|
|
||||||
* All methods are async: the bunker path is a relay round-trip. The local
|
|
||||||
* path satisfies the contract with immediately-resolved promises so call
|
|
||||||
* sites are bunker-ready without further change.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type EventTemplate, type VerifiedEvent, finalizeEvent, nip44 } from 'nostr-tools'
|
|
||||||
import type { MachineIdentity } from './types.js'
|
|
||||||
|
|
||||||
export interface Signer {
|
|
||||||
/** Hex pubkey of the signing identity. */
|
|
||||||
readonly pubkey: string
|
|
||||||
/** Sign an unsigned event template, returning a fully-signed event. */
|
|
||||||
signEvent(template: EventTemplate): Promise<VerifiedEvent>
|
|
||||||
/** NIP-44 v2 encrypt `plaintext` for `peerPubkey`. */
|
|
||||||
nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string>
|
|
||||||
/** NIP-44 v2 decrypt `ciphertext` from `peerPubkey`. */
|
|
||||||
nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-process signer backed by a local nsec. The crypto is synchronous;
|
|
||||||
* the async surface is satisfied by immediately-resolved promises so call
|
|
||||||
* sites are identical whether the signer is local or a remote bunker.
|
|
||||||
*/
|
|
||||||
export class LocalSigner implements Signer {
|
|
||||||
readonly pubkey: string
|
|
||||||
readonly #privateKey: Uint8Array
|
|
||||||
|
|
||||||
constructor(identity: MachineIdentity) {
|
|
||||||
this.pubkey = identity.publicKey
|
|
||||||
this.#privateKey = identity.privateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
signEvent(template: EventTemplate): Promise<VerifiedEvent> {
|
|
||||||
return Promise.resolve(finalizeEvent(template, this.#privateKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string> {
|
|
||||||
const conversationKey = nip44.v2.utils.getConversationKey(this.#privateKey, peerPubkey)
|
|
||||||
return Promise.resolve(nip44.v2.encrypt(plaintext, conversationKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string> {
|
|
||||||
const conversationKey = nip44.v2.utils.getConversationKey(this.#privateKey, peerPubkey)
|
|
||||||
return Promise.resolve(nip44.v2.decrypt(ciphertext, conversationKey))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
* Nostr client type definitions for Lamassu ATM
|
* Nostr client type definitions for Lamassu ATM
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Event } from 'nostr-tools'
|
import type { Event, UnsignedEvent } from 'nostr-tools'
|
||||||
import type { Signer } from './signer.js'
|
|
||||||
|
|
||||||
/** Connection states for relay */
|
/** Connection states for relay */
|
||||||
export type ConnectionState =
|
export type ConnectionState =
|
||||||
|
|
@ -26,7 +25,6 @@ export interface RelayConfig {
|
||||||
|
|
||||||
/** Machine identity configuration */
|
/** Machine identity configuration */
|
||||||
export interface MachineIdentity {
|
export interface MachineIdentity {
|
||||||
// pragma: allowlist secret
|
|
||||||
/** Private key in hex format */
|
/** Private key in hex format */
|
||||||
privateKey: Uint8Array
|
privateKey: Uint8Array
|
||||||
/** Public key in hex format */
|
/** Public key in hex format */
|
||||||
|
|
@ -39,8 +37,8 @@ export interface MachineIdentity {
|
||||||
export interface NostrClientConfig {
|
export interface NostrClientConfig {
|
||||||
/** Relays to connect to */
|
/** Relays to connect to */
|
||||||
relays: RelayConfig[]
|
relays: RelayConfig[]
|
||||||
/** Signer for the machine identity (local nsec or remote bunker) */
|
/** Machine identity (keypair) */
|
||||||
signer: Signer
|
identity: MachineIdentity
|
||||||
/** Connection timeout in ms (default: 10000) */
|
/** Connection timeout in ms (default: 10000) */
|
||||||
connectionTimeout?: number
|
connectionTimeout?: number
|
||||||
/** Reconnect automatically on disconnect */
|
/** Reconnect automatically on disconnect */
|
||||||
|
|
|
||||||
|
|
@ -130,17 +130,12 @@ describe('ATM State Machine', () => {
|
||||||
// Wait for rate fetch
|
// Wait for rate fetch
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
// Real hardware flow: escrow accepted (stack commanded) →
|
|
||||||
// stacked-confirmation credits the bill.
|
|
||||||
actor.send({ type: 'BILL_PENDING', denomination: 20 })
|
|
||||||
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
||||||
actor.send({ type: 'BILL_PENDING', denomination: 10 })
|
|
||||||
actor.send({ type: 'BILL_INSERTED', denomination: 10 })
|
actor.send({ type: 'BILL_INSERTED', denomination: 10 })
|
||||||
|
|
||||||
const context = actor.getSnapshot().context
|
const context = actor.getSnapshot().context
|
||||||
expect(context.billsInserted).toEqual([20, 10])
|
expect(context.billsInserted).toEqual([20, 10])
|
||||||
expect(context.fiatCents).toBe(3000) // $30 in cents
|
expect(context.fiatCents).toBe(3000) // $30 in cents
|
||||||
expect(context.billPending).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return to idle on CANCEL', async () => {
|
it('should return to idle on CANCEL', async () => {
|
||||||
|
|
@ -157,112 +152,6 @@ describe('ATM State Machine', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('cash-in escrow credit interlock (aiolabs/bitspire#58)', () => {
|
|
||||||
// Legacy brain.js parity: a bill is credited only on the validator's
|
|
||||||
// stacked-confirmation, and the insert phase cannot finish while a
|
|
||||||
// bill is between the stack command and that confirmation.
|
|
||||||
|
|
||||||
async function startInserting() {
|
|
||||||
const machine = createATMMachine(mockServices)
|
|
||||||
const actor = createActor(machine)
|
|
||||||
actor.start()
|
|
||||||
actor.send({ type: 'SELECT_CASH_IN' })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
||||||
expect(actor.getSnapshot().value).toEqual({ cashIn: 'insertingBills' })
|
|
||||||
return actor
|
|
||||||
}
|
|
||||||
|
|
||||||
it('blocks FINISH_INSERTING while a bill is in flight', async () => {
|
|
||||||
const actor = await startInserting()
|
|
||||||
|
|
||||||
actor.send({ type: 'BILL_PENDING', denomination: 20 })
|
|
||||||
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
|
||||||
// Second bill enters flight; user mashes "done" before it settles.
|
|
||||||
actor.send({ type: 'BILL_PENDING', denomination: 1 })
|
|
||||||
actor.send({ type: 'FINISH_INSERTING' })
|
|
||||||
|
|
||||||
// Still inserting — the in-flight bill holds the door.
|
|
||||||
const snap = actor.getSnapshot()
|
|
||||||
expect(snap.value).toEqual({ cashIn: 'insertingBills' })
|
|
||||||
expect(snap.context.billPending).toBe(1)
|
|
||||||
// Nothing was credited for the in-flight bill.
|
|
||||||
expect(snap.context.fiatCents).toBe(2000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allows FINISH_INSERTING once the in-flight bill is confirmed', async () => {
|
|
||||||
const actor = await startInserting()
|
|
||||||
|
|
||||||
actor.send({ type: 'BILL_PENDING', denomination: 1 })
|
|
||||||
actor.send({ type: 'FINISH_INSERTING' }) // blocked
|
|
||||||
actor.send({ type: 'BILL_INSERTED', denomination: 1 }) // confirmation lands
|
|
||||||
actor.send({ type: 'FINISH_INSERTING' })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
||||||
|
|
||||||
const snap = actor.getSnapshot()
|
|
||||||
expect(snap.value).not.toEqual({ cashIn: 'insertingBills' })
|
|
||||||
expect(snap.context.fiatCents).toBe(100)
|
|
||||||
expect(snap.context.billPending).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('BILL_REJECTED clears the in-flight bill without crediting', async () => {
|
|
||||||
const actor = await startInserting()
|
|
||||||
|
|
||||||
actor.send({ type: 'BILL_PENDING', denomination: 20 })
|
|
||||||
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
|
||||||
actor.send({ type: 'BILL_PENDING', denomination: 10 })
|
|
||||||
// Stack failed — validator returned the bill.
|
|
||||||
actor.send({ type: 'BILL_REJECTED', reason: 'returned' })
|
|
||||||
|
|
||||||
const snap = actor.getSnapshot()
|
|
||||||
expect(snap.context.billPending).toBeNull()
|
|
||||||
expect(snap.context.fiatCents).toBe(2000) // only the confirmed bill
|
|
||||||
expect(snap.context.billsInserted).toEqual([20])
|
|
||||||
|
|
||||||
// And the door is open again.
|
|
||||||
actor.send({ type: 'FINISH_INSERTING' })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
||||||
expect(actor.getSnapshot().value).not.toEqual({ cashIn: 'insertingBills' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not credit a BILL_INSERTED with no matching pending bill', async () => {
|
|
||||||
const actor = await startInserting()
|
|
||||||
|
|
||||||
// Stray stacked-confirmation (no stack was commanded).
|
|
||||||
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
|
||||||
|
|
||||||
const snap = actor.getSnapshot()
|
|
||||||
expect(snap.context.fiatCents).toBe(0)
|
|
||||||
expect(snap.context.billsInserted).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('drops the credit too when BILL_PENDING was refused (over balance)', async () => {
|
|
||||||
const actor = await startInserting()
|
|
||||||
|
|
||||||
// Mock balance is 1M sats @ 2500 sats/USD → $400 ceiling. $500 bill
|
|
||||||
// exceeds it: pending is guard-refused, so its confirmation (which a
|
|
||||||
// correctly-driven validator would never send) is not credited either.
|
|
||||||
actor.send({ type: 'BILL_PENDING', denomination: 500 })
|
|
||||||
actor.send({ type: 'BILL_INSERTED', denomination: 500 })
|
|
||||||
|
|
||||||
const snap = actor.getSnapshot()
|
|
||||||
expect(snap.context.billPending).toBeNull()
|
|
||||||
expect(snap.context.fiatCents).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('CANCEL with a bill in flight lands in confirmAbandon with pending cleared', async () => {
|
|
||||||
const actor = await startInserting()
|
|
||||||
|
|
||||||
actor.send({ type: 'BILL_PENDING', denomination: 20 })
|
|
||||||
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
|
||||||
actor.send({ type: 'BILL_PENDING', denomination: 10 })
|
|
||||||
actor.send({ type: 'CANCEL' })
|
|
||||||
|
|
||||||
const snap = actor.getSnapshot()
|
|
||||||
expect(snap.value).toEqual({ cashIn: 'confirmAbandon' })
|
|
||||||
expect(snap.context.billPending).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('cash-out flow (ATM-driven amount selection)', () => {
|
describe('cash-out flow (ATM-driven amount selection)', () => {
|
||||||
it('should transition to cashOut on SELECT_CASH_OUT', async () => {
|
it('should transition to cashOut on SELECT_CASH_OUT', async () => {
|
||||||
const machine = createATMMachine(mockServices)
|
const machine = createATMMachine(mockServices)
|
||||||
|
|
|
||||||
|
|
@ -181,15 +181,6 @@ export function createATMMachine(
|
||||||
setCashOutFee: assign({
|
setCashOutFee: assign({
|
||||||
feeFraction: ({ context }) => context.cashOutFeeFraction,
|
feeFraction: ({ context }) => context.cashOutFeeFraction,
|
||||||
}),
|
}),
|
||||||
setBillPending: assign({
|
|
||||||
billPending: ({ context, event }) => {
|
|
||||||
if (event.type !== 'BILL_PENDING') return context.billPending
|
|
||||||
return event.denomination
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
clearBillPending: assign({
|
|
||||||
billPending: null,
|
|
||||||
}),
|
|
||||||
addBill: assign({
|
addBill: assign({
|
||||||
billsInserted: ({ context, event }) => {
|
billsInserted: ({ context, event }) => {
|
||||||
if (event.type !== 'BILL_INSERTED') return context.billsInserted
|
if (event.type !== 'BILL_INSERTED') return context.billsInserted
|
||||||
|
|
@ -199,7 +190,6 @@ export function createATMMachine(
|
||||||
if (event.type !== 'BILL_INSERTED') return context.fiatCents
|
if (event.type !== 'BILL_INSERTED') return context.fiatCents
|
||||||
return context.fiatCents + event.denomination * 100
|
return context.fiatCents + event.denomination * 100
|
||||||
},
|
},
|
||||||
billPending: null,
|
|
||||||
}),
|
}),
|
||||||
calculateSats: assign({
|
calculateSats: assign({
|
||||||
satsAmount: ({ context }) => {
|
satsAmount: ({ context }) => {
|
||||||
|
|
@ -377,26 +367,13 @@ export function createATMMachine(
|
||||||
},
|
},
|
||||||
guards: {
|
guards: {
|
||||||
hasInsertedBills: ({ context }) => context.billsInserted.length > 0,
|
hasInsertedBills: ({ context }) => context.billsInserted.length > 0,
|
||||||
// Legacy brain.js parity: "send coins" is a no-op while a bill is
|
|
||||||
// between the stack command and the validator's stacked-confirmation.
|
|
||||||
canFinishInserting: ({ context }) =>
|
|
||||||
context.billsInserted.length > 0 && context.billPending === null,
|
|
||||||
// A credit event must correspond to the bill we commanded to stack —
|
|
||||||
// a stray stacked-confirmation with no pending bill is not credited.
|
|
||||||
billMatchesPending: ({ context, event }) => {
|
|
||||||
if (event.type !== 'BILL_INSERTED') return false
|
|
||||||
return context.billPending === event.denomination
|
|
||||||
},
|
|
||||||
hasSufficientAmount: ({ context }) => context.fiatCents >= 100, // $1 minimum
|
hasSufficientAmount: ({ context }) => context.fiatCents >= 100, // $1 minimum
|
||||||
hasExchangeRate: ({ context }) => context.exchangeRate > 0,
|
hasExchangeRate: ({ context }) => context.exchangeRate > 0,
|
||||||
canRetry: ({ context }) => context.retryCount < 3,
|
canRetry: ({ context }) => context.retryCount < 3,
|
||||||
hasUserNpub: ({ context }) => context.userNpub !== null,
|
hasUserNpub: ({ context }) => context.userNpub !== null,
|
||||||
hasOfferRequest: ({ context }) => context.pendingOfferRequest !== null,
|
hasOfferRequest: ({ context }) => context.pendingOfferRequest !== null,
|
||||||
// Gate at the PENDING (pre-stack) decision, matching legacy
|
|
||||||
// _billsRead: balance/rate checks happen before the bill is
|
|
||||||
// physically committed. Once stacked, credit is unconditional.
|
|
||||||
billWithinBalance: ({ context, event }) => {
|
billWithinBalance: ({ context, event }) => {
|
||||||
if (event.type !== 'BILL_PENDING') return false
|
if (event.type !== 'BILL_INSERTED') return false
|
||||||
// Reject bills if balance or rate is unknown — don't risk accepting
|
// Reject bills if balance or rate is unknown — don't risk accepting
|
||||||
// cash we can't cover with sats
|
// cash we can't cover with sats
|
||||||
if (context.availableBalance <= 0 || context.exchangeRate === 0) return false
|
if (context.availableBalance <= 0 || context.exchangeRate === 0) return false
|
||||||
|
|
@ -499,25 +476,15 @@ export function createATMMachine(
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
// Escrow accepted → stack commanded. Marks the bill in
|
|
||||||
// flight; credit waits for the stacked-confirmation.
|
|
||||||
BILL_PENDING: {
|
|
||||||
guard: 'billWithinBalance',
|
|
||||||
actions: 'setBillPending',
|
|
||||||
},
|
|
||||||
// Validator confirmed the bill reached the stacker. The
|
|
||||||
// cash is physically in the box — credit unconditionally.
|
|
||||||
BILL_INSERTED: {
|
BILL_INSERTED: {
|
||||||
guard: 'billMatchesPending',
|
guard: 'billWithinBalance',
|
||||||
actions: ['addBill', 'calculateSats'],
|
actions: ['addBill', 'calculateSats'],
|
||||||
},
|
},
|
||||||
BILL_REJECTED: {
|
BILL_REJECTED: {
|
||||||
// Bill returned to customer (stack failed or refused) —
|
// Stay in state, maybe show message
|
||||||
// it was never credited; just clear the in-flight marker.
|
|
||||||
actions: 'clearBillPending',
|
|
||||||
},
|
},
|
||||||
FINISH_INSERTING: {
|
FINISH_INSERTING: {
|
||||||
guard: 'canFinishInserting',
|
guard: 'hasInsertedBills',
|
||||||
target: 'generatingNdebit',
|
target: 'generatingNdebit',
|
||||||
},
|
},
|
||||||
CANCEL: [
|
CANCEL: [
|
||||||
|
|
@ -582,9 +549,6 @@ export function createATMMachine(
|
||||||
confirmAbandon: {
|
confirmAbandon: {
|
||||||
// Warning: cash is in the machine, abandoning forfeits it.
|
// Warning: cash is in the machine, abandoning forfeits it.
|
||||||
// Auto-clear after 60s so the machine self-recovers if customer walked away.
|
// Auto-clear after 60s so the machine self-recovers if customer walked away.
|
||||||
// A bill still in flight when the user bails is forfeited
|
|
||||||
// like the rest — clear the marker so nothing blocks on it.
|
|
||||||
entry: 'clearBillPending',
|
|
||||||
after: {
|
after: {
|
||||||
60000: '#atm.idle',
|
60000: '#atm.idle',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -91,13 +91,6 @@ export interface ATMContext {
|
||||||
// Hardware state
|
// Hardware state
|
||||||
/** Bills inserted during cash-in */
|
/** Bills inserted during cash-in */
|
||||||
billsInserted: number[]
|
billsInserted: number[]
|
||||||
/**
|
|
||||||
* Bill in flight: stack commanded, awaiting the validator's
|
|
||||||
* stacked-confirmation. Credit (BILL_INSERTED) only lands once the
|
|
||||||
* bill physically reached the stacker; FINISH_INSERTING is blocked
|
|
||||||
* while a bill is in flight (legacy brain.js 'billsRead' interlock).
|
|
||||||
*/
|
|
||||||
billPending: number | null
|
|
||||||
/** Whether cash has been dispensed */
|
/** Whether cash has been dispensed */
|
||||||
cashDispensed: boolean
|
cashDispensed: boolean
|
||||||
/** Dispense amounts for cash-out */
|
/** Dispense amounts for cash-out */
|
||||||
|
|
@ -147,7 +140,6 @@ export type ATMEvent =
|
||||||
| { type: 'CLEAR_SELECTION' }
|
| { type: 'CLEAR_SELECTION' }
|
||||||
| { type: 'CONFIRM_AMOUNT' }
|
| { type: 'CONFIRM_AMOUNT' }
|
||||||
// Hardware events
|
// Hardware events
|
||||||
| { type: 'BILL_PENDING'; denomination: number }
|
|
||||||
| { type: 'BILL_INSERTED'; denomination: number }
|
| { type: 'BILL_INSERTED'; denomination: number }
|
||||||
| { type: 'BILL_REJECTED'; reason: string }
|
| { type: 'BILL_REJECTED'; reason: string }
|
||||||
| { type: 'CASH_DISPENSED' }
|
| { type: 'CASH_DISPENSED' }
|
||||||
|
|
@ -195,7 +187,6 @@ export const initialContext: ATMContext = {
|
||||||
paymentMethod: null,
|
paymentMethod: null,
|
||||||
pendingOfferRequest: null,
|
pendingOfferRequest: null,
|
||||||
billsInserted: [],
|
billsInserted: [],
|
||||||
billPending: null,
|
|
||||||
cashDispensed: false,
|
cashDispensed: false,
|
||||||
dispenseAmounts: [],
|
dispenseAmounts: [],
|
||||||
cashOutSelection: [],
|
cashOutSelection: [],
|
||||||
|
|
|
||||||
304
pnpm-lock.yaml
generated
304
pnpm-lock.yaml
generated
|
|
@ -59,18 +59,12 @@ importers:
|
||||||
marked:
|
marked:
|
||||||
specifier: ^17.0.5
|
specifier: ^17.0.5
|
||||||
version: 17.0.5
|
version: 17.0.5
|
||||||
nfc-pcsc:
|
|
||||||
specifier: ^0.8.1
|
|
||||||
version: 0.8.1
|
|
||||||
nostr-tools:
|
nostr-tools:
|
||||||
specifier: ^2.10.0
|
specifier: ^2.10.0
|
||||||
version: 2.19.4(typescript@5.9.3)
|
version: 2.19.4(typescript@5.9.3)
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.3.1(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
|
version: 2.3.1(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
|
||||||
qr:
|
|
||||||
specifier: ^0.6.0
|
|
||||||
version: 0.6.0
|
|
||||||
qrcode.vue:
|
qrcode.vue:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0(vue@3.5.27(typescript@5.9.3))
|
version: 3.6.0(vue@3.5.27(typescript@5.9.3))
|
||||||
|
|
@ -359,6 +353,12 @@ packages:
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [aix]
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.27.4':
|
'@esbuild/aix-ppc64@0.27.4':
|
||||||
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
|
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -377,6 +377,12 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
'@esbuild/android-arm64@0.27.4':
|
'@esbuild/android-arm64@0.27.4':
|
||||||
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
|
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -395,6 +401,12 @@ packages:
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.27.2':
|
||||||
|
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
'@esbuild/android-arm@0.27.4':
|
'@esbuild/android-arm@0.27.4':
|
||||||
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
|
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -413,6 +425,12 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
'@esbuild/android-x64@0.27.4':
|
'@esbuild/android-x64@0.27.4':
|
||||||
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
|
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -431,6 +449,12 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
'@esbuild/darwin-arm64@0.27.4':
|
'@esbuild/darwin-arm64@0.27.4':
|
||||||
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
|
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -449,6 +473,12 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
'@esbuild/darwin-x64@0.27.4':
|
'@esbuild/darwin-x64@0.27.4':
|
||||||
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
|
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -467,6 +497,12 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
'@esbuild/freebsd-arm64@0.27.4':
|
'@esbuild/freebsd-arm64@0.27.4':
|
||||||
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
|
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -485,6 +521,12 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
'@esbuild/freebsd-x64@0.27.4':
|
'@esbuild/freebsd-x64@0.27.4':
|
||||||
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
|
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -503,6 +545,12 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-arm64@0.27.4':
|
'@esbuild/linux-arm64@0.27.4':
|
||||||
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
|
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -521,6 +569,12 @@ packages:
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.27.2':
|
||||||
|
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-arm@0.27.4':
|
'@esbuild/linux-arm@0.27.4':
|
||||||
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
|
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -539,6 +593,12 @@ packages:
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.27.2':
|
||||||
|
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-ia32@0.27.4':
|
'@esbuild/linux-ia32@0.27.4':
|
||||||
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
|
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -557,6 +617,12 @@ packages:
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.27.4':
|
'@esbuild/linux-loong64@0.27.4':
|
||||||
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
|
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -575,6 +641,12 @@ packages:
|
||||||
cpu: [mips64el]
|
cpu: [mips64el]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.27.2':
|
||||||
|
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-mips64el@0.27.4':
|
'@esbuild/linux-mips64el@0.27.4':
|
||||||
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
|
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -593,6 +665,12 @@ packages:
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-ppc64@0.27.4':
|
'@esbuild/linux-ppc64@0.27.4':
|
||||||
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
|
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -611,6 +689,12 @@ packages:
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-riscv64@0.27.4':
|
'@esbuild/linux-riscv64@0.27.4':
|
||||||
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
|
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -629,6 +713,12 @@ packages:
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.27.2':
|
||||||
|
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-s390x@0.27.4':
|
'@esbuild/linux-s390x@0.27.4':
|
||||||
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
|
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -647,6 +737,12 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-x64@0.27.4':
|
'@esbuild/linux-x64@0.27.4':
|
||||||
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
|
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -659,6 +755,12 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [netbsd]
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
'@esbuild/netbsd-arm64@0.27.4':
|
'@esbuild/netbsd-arm64@0.27.4':
|
||||||
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
|
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -677,6 +779,12 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [netbsd]
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
'@esbuild/netbsd-x64@0.27.4':
|
'@esbuild/netbsd-x64@0.27.4':
|
||||||
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
|
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -689,6 +797,12 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [openbsd]
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
'@esbuild/openbsd-arm64@0.27.4':
|
'@esbuild/openbsd-arm64@0.27.4':
|
||||||
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
|
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -707,6 +821,12 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [openbsd]
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
'@esbuild/openbsd-x64@0.27.4':
|
'@esbuild/openbsd-x64@0.27.4':
|
||||||
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
|
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -719,6 +839,12 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [openharmony]
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
'@esbuild/openharmony-arm64@0.27.4':
|
'@esbuild/openharmony-arm64@0.27.4':
|
||||||
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
|
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -737,6 +863,12 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [sunos]
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
'@esbuild/sunos-x64@0.27.4':
|
'@esbuild/sunos-x64@0.27.4':
|
||||||
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
|
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -755,6 +887,12 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@esbuild/win32-arm64@0.27.4':
|
'@esbuild/win32-arm64@0.27.4':
|
||||||
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
|
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -773,6 +911,12 @@ packages:
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.27.2':
|
||||||
|
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@esbuild/win32-ia32@0.27.4':
|
'@esbuild/win32-ia32@0.27.4':
|
||||||
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
|
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -791,6 +935,12 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@esbuild/win32-x64@0.27.4':
|
'@esbuild/win32-x64@0.27.4':
|
||||||
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
|
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -900,9 +1050,6 @@ packages:
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
'@pokusew/pcsclite@0.6.0':
|
|
||||||
resolution: {integrity: sha512-jX7zRXM2Or5Pms1AFjNtawsXDjLiZOzOUo7Sf0put7Pnq/EKIR9g0KvTx62HtwdPpVP6hWHGydUTHgIi9PxodQ==}
|
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.56.0':
|
'@rollup/rollup-android-arm-eabi@4.56.0':
|
||||||
resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==}
|
resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
|
|
@ -1902,6 +2049,11 @@ packages:
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
esbuild@0.27.2:
|
||||||
|
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
esbuild@0.27.4:
|
esbuild@0.27.4:
|
||||||
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
|
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -2490,9 +2642,6 @@ packages:
|
||||||
muggle-string@0.4.1:
|
muggle-string@0.4.1:
|
||||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||||
|
|
||||||
nan@2.28.0:
|
|
||||||
resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==}
|
|
||||||
|
|
||||||
nanoid@3.3.11:
|
nanoid@3.3.11:
|
||||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
|
@ -2505,9 +2654,6 @@ packages:
|
||||||
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
nfc-pcsc@0.8.1:
|
|
||||||
resolution: {integrity: sha512-wEfacG0dwPVZOG/WY28Mk3P4Q+yz6q7LnjpnZvdFddx3iXavEXiGhftRZXBtudr0NrzH1MrGWSkWq77tef7BMA==}
|
|
||||||
|
|
||||||
node-abi@3.87.0:
|
node-abi@3.87.0:
|
||||||
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
|
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -2714,10 +2860,6 @@ packages:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
qr@0.6.0:
|
|
||||||
resolution: {integrity: sha512-P23VoX7SipHALdiIYG+D+LT/6n22dNKwV92FAb3d+Nlki/5WisSsfLt0UDFz2XEBtuwrECTznvu+chKKFCSYhA==}
|
|
||||||
engines: {node: '>= 20.19.0'}
|
|
||||||
|
|
||||||
qrcode.vue@3.6.0:
|
qrcode.vue@3.6.0:
|
||||||
resolution: {integrity: sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==}
|
resolution: {integrity: sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -2978,7 +3120,7 @@ packages:
|
||||||
tar@6.2.1:
|
tar@6.2.1:
|
||||||
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
|
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
|
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||||
|
|
||||||
temp-file@3.4.0:
|
temp-file@3.4.0:
|
||||||
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
|
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
|
||||||
|
|
@ -3437,6 +3579,9 @@ snapshots:
|
||||||
'@esbuild/aix-ppc64@0.25.12':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.27.4':
|
'@esbuild/aix-ppc64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3446,6 +3591,9 @@ snapshots:
|
||||||
'@esbuild/android-arm64@0.25.12':
|
'@esbuild/android-arm64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-arm64@0.27.4':
|
'@esbuild/android-arm64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3455,6 +3603,9 @@ snapshots:
|
||||||
'@esbuild/android-arm@0.25.12':
|
'@esbuild/android-arm@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-arm@0.27.4':
|
'@esbuild/android-arm@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3464,6 +3615,9 @@ snapshots:
|
||||||
'@esbuild/android-x64@0.25.12':
|
'@esbuild/android-x64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-x64@0.27.4':
|
'@esbuild/android-x64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3473,6 +3627,9 @@ snapshots:
|
||||||
'@esbuild/darwin-arm64@0.25.12':
|
'@esbuild/darwin-arm64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/darwin-arm64@0.27.4':
|
'@esbuild/darwin-arm64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3482,6 +3639,9 @@ snapshots:
|
||||||
'@esbuild/darwin-x64@0.25.12':
|
'@esbuild/darwin-x64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/darwin-x64@0.27.4':
|
'@esbuild/darwin-x64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3491,6 +3651,9 @@ snapshots:
|
||||||
'@esbuild/freebsd-arm64@0.25.12':
|
'@esbuild/freebsd-arm64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/freebsd-arm64@0.27.4':
|
'@esbuild/freebsd-arm64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3500,6 +3663,9 @@ snapshots:
|
||||||
'@esbuild/freebsd-x64@0.25.12':
|
'@esbuild/freebsd-x64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/freebsd-x64@0.27.4':
|
'@esbuild/freebsd-x64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3509,6 +3675,9 @@ snapshots:
|
||||||
'@esbuild/linux-arm64@0.25.12':
|
'@esbuild/linux-arm64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-arm64@0.27.4':
|
'@esbuild/linux-arm64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3518,6 +3687,9 @@ snapshots:
|
||||||
'@esbuild/linux-arm@0.25.12':
|
'@esbuild/linux-arm@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-arm@0.27.4':
|
'@esbuild/linux-arm@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3527,6 +3699,9 @@ snapshots:
|
||||||
'@esbuild/linux-ia32@0.25.12':
|
'@esbuild/linux-ia32@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-ia32@0.27.4':
|
'@esbuild/linux-ia32@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3536,6 +3711,9 @@ snapshots:
|
||||||
'@esbuild/linux-loong64@0.25.12':
|
'@esbuild/linux-loong64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.27.4':
|
'@esbuild/linux-loong64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3545,6 +3723,9 @@ snapshots:
|
||||||
'@esbuild/linux-mips64el@0.25.12':
|
'@esbuild/linux-mips64el@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-mips64el@0.27.4':
|
'@esbuild/linux-mips64el@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3554,6 +3735,9 @@ snapshots:
|
||||||
'@esbuild/linux-ppc64@0.25.12':
|
'@esbuild/linux-ppc64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-ppc64@0.27.4':
|
'@esbuild/linux-ppc64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3563,6 +3747,9 @@ snapshots:
|
||||||
'@esbuild/linux-riscv64@0.25.12':
|
'@esbuild/linux-riscv64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-riscv64@0.27.4':
|
'@esbuild/linux-riscv64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3572,6 +3759,9 @@ snapshots:
|
||||||
'@esbuild/linux-s390x@0.25.12':
|
'@esbuild/linux-s390x@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-s390x@0.27.4':
|
'@esbuild/linux-s390x@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3581,12 +3771,18 @@ snapshots:
|
||||||
'@esbuild/linux-x64@0.25.12':
|
'@esbuild/linux-x64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-x64@0.27.4':
|
'@esbuild/linux-x64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/netbsd-arm64@0.25.12':
|
'@esbuild/netbsd-arm64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/netbsd-arm64@0.27.4':
|
'@esbuild/netbsd-arm64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3596,12 +3792,18 @@ snapshots:
|
||||||
'@esbuild/netbsd-x64@0.25.12':
|
'@esbuild/netbsd-x64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/netbsd-x64@0.27.4':
|
'@esbuild/netbsd-x64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openbsd-arm64@0.25.12':
|
'@esbuild/openbsd-arm64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openbsd-arm64@0.27.4':
|
'@esbuild/openbsd-arm64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3611,12 +3813,18 @@ snapshots:
|
||||||
'@esbuild/openbsd-x64@0.25.12':
|
'@esbuild/openbsd-x64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openbsd-x64@0.27.4':
|
'@esbuild/openbsd-x64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openharmony-arm64@0.25.12':
|
'@esbuild/openharmony-arm64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openharmony-arm64@0.27.4':
|
'@esbuild/openharmony-arm64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3626,6 +3834,9 @@ snapshots:
|
||||||
'@esbuild/sunos-x64@0.25.12':
|
'@esbuild/sunos-x64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/sunos-x64@0.27.4':
|
'@esbuild/sunos-x64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3635,6 +3846,9 @@ snapshots:
|
||||||
'@esbuild/win32-arm64@0.25.12':
|
'@esbuild/win32-arm64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-arm64@0.27.4':
|
'@esbuild/win32-arm64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3644,6 +3858,9 @@ snapshots:
|
||||||
'@esbuild/win32-ia32@0.25.12':
|
'@esbuild/win32-ia32@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-ia32@0.27.4':
|
'@esbuild/win32-ia32@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3653,6 +3870,9 @@ snapshots:
|
||||||
'@esbuild/win32-x64@0.25.12':
|
'@esbuild/win32-x64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-x64@0.27.4':
|
'@esbuild/win32-x64@0.27.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3772,11 +3992,6 @@ snapshots:
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@pokusew/pcsclite@0.6.0':
|
|
||||||
dependencies:
|
|
||||||
bindings: 1.5.0
|
|
||||||
nan: 2.28.0
|
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.56.0':
|
'@rollup/rollup-android-arm-eabi@4.56.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -4918,6 +5133,35 @@ snapshots:
|
||||||
'@esbuild/win32-ia32': 0.25.12
|
'@esbuild/win32-ia32': 0.25.12
|
||||||
'@esbuild/win32-x64': 0.25.12
|
'@esbuild/win32-x64': 0.25.12
|
||||||
|
|
||||||
|
esbuild@0.27.2:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.27.2
|
||||||
|
'@esbuild/android-arm': 0.27.2
|
||||||
|
'@esbuild/android-arm64': 0.27.2
|
||||||
|
'@esbuild/android-x64': 0.27.2
|
||||||
|
'@esbuild/darwin-arm64': 0.27.2
|
||||||
|
'@esbuild/darwin-x64': 0.27.2
|
||||||
|
'@esbuild/freebsd-arm64': 0.27.2
|
||||||
|
'@esbuild/freebsd-x64': 0.27.2
|
||||||
|
'@esbuild/linux-arm': 0.27.2
|
||||||
|
'@esbuild/linux-arm64': 0.27.2
|
||||||
|
'@esbuild/linux-ia32': 0.27.2
|
||||||
|
'@esbuild/linux-loong64': 0.27.2
|
||||||
|
'@esbuild/linux-mips64el': 0.27.2
|
||||||
|
'@esbuild/linux-ppc64': 0.27.2
|
||||||
|
'@esbuild/linux-riscv64': 0.27.2
|
||||||
|
'@esbuild/linux-s390x': 0.27.2
|
||||||
|
'@esbuild/linux-x64': 0.27.2
|
||||||
|
'@esbuild/netbsd-arm64': 0.27.2
|
||||||
|
'@esbuild/netbsd-x64': 0.27.2
|
||||||
|
'@esbuild/openbsd-arm64': 0.27.2
|
||||||
|
'@esbuild/openbsd-x64': 0.27.2
|
||||||
|
'@esbuild/openharmony-arm64': 0.27.2
|
||||||
|
'@esbuild/sunos-x64': 0.27.2
|
||||||
|
'@esbuild/win32-arm64': 0.27.2
|
||||||
|
'@esbuild/win32-ia32': 0.27.2
|
||||||
|
'@esbuild/win32-x64': 0.27.2
|
||||||
|
|
||||||
esbuild@0.27.4:
|
esbuild@0.27.4:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.27.4
|
'@esbuild/aix-ppc64': 0.27.4
|
||||||
|
|
@ -5523,18 +5767,12 @@ snapshots:
|
||||||
|
|
||||||
muggle-string@0.4.1: {}
|
muggle-string@0.4.1: {}
|
||||||
|
|
||||||
nan@2.28.0: {}
|
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
napi-build-utils@2.0.0: {}
|
napi-build-utils@2.0.0: {}
|
||||||
|
|
||||||
negotiator@0.6.4: {}
|
negotiator@0.6.4: {}
|
||||||
|
|
||||||
nfc-pcsc@0.8.1:
|
|
||||||
dependencies:
|
|
||||||
'@pokusew/pcsclite': 0.6.0
|
|
||||||
|
|
||||||
node-abi@3.87.0:
|
node-abi@3.87.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
|
|
@ -5744,8 +5982,6 @@ snapshots:
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
qr@0.6.0: {}
|
|
||||||
|
|
||||||
qrcode.vue@3.6.0(vue@3.5.27(typescript@5.9.3)):
|
qrcode.vue@3.6.0(vue@3.5.27(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.27(typescript@5.9.3)
|
vue: 3.5.27(typescript@5.9.3)
|
||||||
|
|
@ -6110,7 +6346,7 @@ snapshots:
|
||||||
|
|
||||||
tsx@4.21.0:
|
tsx@4.21.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.4
|
esbuild: 0.27.2
|
||||||
get-tsconfig: 4.13.0
|
get-tsconfig: 4.13.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue