Compare commits
1 commit
23fe4a59f1
...
844fd5f821
| Author | SHA1 | Date | |
|---|---|---|---|
| 844fd5f821 |
61 changed files with 422 additions and 4648 deletions
54
CLAUDE.md
54
CLAUDE.md
|
|
@ -4,7 +4,7 @@ Guidance for Claude Code when working in this repo. Read this before touching co
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
**bitSpire** is a Nostr-native Lightning ATM running **LNbits over the nostr-native-transport**. The `dev` branch, which this file describes, is what the machines run.
|
**bitSpire** is a Nostr-native Lightning ATM. Production ATMs (`batm3`, `douro`) currently run from `main` against Lightning.Pub; the `dev` branch — which is what this file describes — has been migrated to **LNbits over the nostr-native-transport**.
|
||||||
|
|
||||||
Core principles:
|
Core principles:
|
||||||
|
|
||||||
|
|
@ -25,53 +25,8 @@ bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lam
|
||||||
|
|
||||||
## Branch model
|
## Branch model
|
||||||
|
|
||||||
- `dev` — **what every live machine runs.** Not a staging branch any more. Verified
|
- `main` — production. Lightning.Pub backend. The two production ATMs auto-pull from here daily at 04:00 (`flake.nix:152-160`). **DO NOT** push to `main` casually — a wrong commit gets baked into prod ATMs the next morning.
|
||||||
2026-09-24 on batm3, whose `nixos-upgrade` unit pulls
|
- `dev` — staging. LNbits backend. The Sintra dev unit auto-pulls from here (`?ref=dev` pin on this branch's `flake.nix`). Push freely; tag `pre-bitspire-cutover` is the rollback target if the migration ever needs to be reverted on prod.
|
||||||
`git+ssh://…/bitspire.git?ref=dev#batm3-installed` daily at 04:00. "Push freely
|
|
||||||
to dev" is no longer safe advice: a bad commit reaches production hardware the
|
|
||||||
next morning, unattended.
|
|
||||||
- `main` — Lightning.Pub era, historical. Tag `pre-bitspire-cutover` is the
|
|
||||||
rollback target if the migration ever has to be reverted.
|
|
||||||
|
|
||||||
> This section previously said the production ATMs ran `main` against
|
|
||||||
> Lightning.Pub and that only Sintra was on `dev`. That was stale and it was
|
|
||||||
> repeatedly taken at face value. Check the machine, not this file, before
|
|
||||||
> relying on which stack a given box runs: `systemctl cat nixos-upgrade` gives
|
|
||||||
> the branch, `/var/lib/bitspire` vs `/var/lib/lamassu-atm` gives the era.
|
|
||||||
|
|
||||||
### Fleet state (surveyed 2026-09-24)
|
|
||||||
|
|
||||||
| Machine | Reachable | Stack | GPU | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `sintra` | LAN `192.168.0.252` | dev / LNbits | Braswell `8086:22b0` → crocus | dev unit; ethernet `r8169` |
|
|
||||||
| `batm3` | wg `10.0.0.5` | dev / LNbits | Haswell GT2 `8086:0412` → crocus | **networks over WiFi**, `iwlwifi` 7260; ethernet down |
|
|
||||||
| `douro` | **down** | — | Bay Trail (Gen7) | needs reflashing with the current image and reconnecting to WireGuard |
|
|
||||||
| `tejo` | wg `10.0.0.3` | **Debian** (`ubilinux4`, kernel 4.9) | Braswell `8086:22b0` | never had bitspire installed; a flake target, not a deployment |
|
|
||||||
|
|
||||||
Two consequences worth holding onto. Every GPU in the fleet binds **crocus**, not
|
|
||||||
iris — sintra's Braswell does so despite being Gen8. And batm3's only working
|
|
||||||
network path is Intel WiFi, so `intel/iwlwifi` firmware is load-bearing there;
|
|
||||||
trimming it would strand the machine with no way back in.
|
|
||||||
|
|
||||||
### batm3's nightly upgrade is currently FAILING
|
|
||||||
|
|
||||||
Confirmed 2026-09-24. The run dies at:
|
|
||||||
|
|
||||||
```
|
|
||||||
04:03:26 building '…-bitspire-atm-app-0.1.0.drv'...
|
|
||||||
04:04:28 error: timed out after 60 seconds
|
|
||||||
```
|
|
||||||
|
|
||||||
The ATM app is built in-house and is **not in `aiolabs.cachix.org` or
|
|
||||||
`cache.nixos.org`**, so batm3 has to build it locally, and `nix.settings.timeout
|
|
||||||
= 60` in `flake.nix` kills it. The comment there assumes heavy derivations are
|
|
||||||
"effectively cache-only … upstream-cached", which is true of nixpkgs and false of
|
|
||||||
our own app.
|
|
||||||
|
|
||||||
So the machine is pinned to whatever generation last succeeded, and nothing
|
|
||||||
merged to `dev` reaches it. This is the same class of silent-updater failure as
|
|
||||||
#98, in a new form. The fix is pushing `atm-app-*` to the aiolabs cachix as part
|
|
||||||
of releasing, not raising the timeout — a 60s ceiling on ATM hardware is correct.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -264,8 +219,7 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l
|
||||||
## Useful invariants when debugging
|
## Useful invariants when debugging
|
||||||
|
|
||||||
- The renderer logs prefix every line with a tag: `[Lightning]`, `[ATM]`, `[ATM Service]`, `[LNURL Session]`, `[CLINK]`, `[StateStore]`. `journalctl -u bitspire | grep '\['` is your friend.
|
- The renderer logs prefix every line with a tag: `[Lightning]`, `[ATM]`, `[ATM Service]`, `[LNURL Session]`, `[CLINK]`, `[StateStore]`. `journalctl -u bitspire | grep '\['` is your friend.
|
||||||
- **Never pass an object as a console argument in the renderer.** Electron's console bridge stringifies each argument, so `console.log('msg:', { a, b })` reaches the journal as `msg: [object Object]` and every field is lost. Interpolate instead. Cost a debugging session on 2026-09-23, when a cassette publish that had worked looked like it had done nothing.
|
- `bitspire.service` runs as the `lamassu` user; `/var/lib/bitspire` is its `dataDir` (ReadWritePaths). DB lives at `/var/lib/bitspire/state.db` (we previously had `/var/lib/lamassu-atm` — that path is gone on dev, see commit `9c455d6`).
|
||||||
- `bitspire.service` runs as the `bitspire` user (verified on sintra 2026-09-24; this line used to say `lamassu`, left over from the rename in `46e52f6`); `/var/lib/bitspire` is its `dataDir` (ReadWritePaths). DB lives at `/var/lib/bitspire/state.db` (we previously had `/var/lib/lamassu-atm` — that path is gone on dev, see commit `9c455d6`).
|
|
||||||
- The `lightning.lightningPub` field on `LightningServices` is a `LightningBackend` *adapter*, not a `LightningPubClient`. Don't try to call LP-only methods on it.
|
- The `lightning.lightningPub` field on `LightningServices` is a `LightningBackend` *adapter*, not a `LightningPubClient`. Don't try to call LP-only methods on it.
|
||||||
|
|
||||||
## Related documentation
|
## Related documentation
|
||||||
|
|
|
||||||
|
|
@ -93,31 +93,3 @@ VITE_SPIRE_SEED=
|
||||||
# Set to 'true' for development/demo environments only
|
# Set to 'true' for development/demo environments only
|
||||||
# When false (production default), initialization failures show a maintenance screen
|
# When false (production default), initialization failures show a maintenance screen
|
||||||
# VITE_ALLOW_MOCK_FALLBACK=true
|
# VITE_ALLOW_MOCK_FALLBACK=true
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Access Control (ADR-003)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Tap-to-enter gate. When disabled (default), the machine boots straight to
|
|
||||||
# idle exactly as before. When enabled, it boots into a locked screen and a
|
|
||||||
# Bolt Card tap (read by the main-process NFC service over pcscd) unlocks it
|
|
||||||
# and loads the card for the session, so buy/sell finish with one Complete.
|
|
||||||
# ACCESS_CONTROL_ENABLED=true
|
|
||||||
|
|
||||||
# Admit ANY Bolt Card when the allow-list has no match. With this on the gate
|
|
||||||
# only keeps casual users off the menu — any NDEF tag with a /scan/<id> URL
|
|
||||||
# unlocks it; money still moves only on a valid SUN at Complete. Turn OFF once
|
|
||||||
# a real allow-list (/var/lib/bitspire/access.json) is provisioned.
|
|
||||||
# ACCESS_OPEN_ENROLLMENT=true
|
|
||||||
|
|
||||||
# Show the on-screen runtime dev/operator unlock button on the locked screen.
|
|
||||||
# Default OFF — it bypasses the gate, so enable only on a bench/dev machine.
|
|
||||||
# ACCESS_DEV_UNLOCK=true
|
|
||||||
|
|
||||||
# Per-machine salt for hashing credentials/PINs. Provision a real value in
|
|
||||||
# production (or in access.json); a fixed default is used if unset.
|
|
||||||
# ACCESS_SALT=change-me-per-machine
|
|
||||||
|
|
||||||
# Build/dev bypass — forces the gate OPEN even when enabled (browser dev / CI).
|
|
||||||
# Renderer-side (Vite) flag, never set in a production image.
|
|
||||||
# VITE_SKIP_ACCESS_GATE=true
|
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,10 @@
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
applyOperatorCassetteOps,
|
|
||||||
closeDatabase,
|
closeDatabase,
|
||||||
getAppliedOpIds,
|
|
||||||
getCassetteStateSeq,
|
|
||||||
getCashbox,
|
getCashbox,
|
||||||
getCountsUncertainSince,
|
|
||||||
getInventory,
|
|
||||||
initDatabase,
|
initDatabase,
|
||||||
loadCassettes,
|
loadCassettes,
|
||||||
markCountsUncertain,
|
|
||||||
recordTransaction,
|
recordTransaction,
|
||||||
setCassettes,
|
setCassettes,
|
||||||
} from '../state-store.js'
|
} from '../state-store.js'
|
||||||
|
|
@ -174,322 +168,3 @@ describe('state-store: recordTransaction cash_in cashbox', () => {
|
||||||
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 })
|
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('state-store: recordTransaction manual_dispense inventory (#76)', () => {
|
|
||||||
it('decrements the bays an operator remediation actually emptied', () => {
|
|
||||||
recordTransaction({
|
|
||||||
...TX_BASE,
|
|
||||||
txid: 'tx-manual',
|
|
||||||
type: 'manual_dispense',
|
|
||||||
status: 'complete',
|
|
||||||
bills: [{ denomination: 20, count: 2 }],
|
|
||||||
cassettes: [
|
|
||||||
{
|
|
||||||
name: 'cassette1',
|
|
||||||
position: 1,
|
|
||||||
denomination: 20,
|
|
||||||
provisioned: 2,
|
|
||||||
dispensed: 2,
|
|
||||||
rejected: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
// Bills physically left bay 1; before #76 this row was untouched and the
|
|
||||||
// inflated count became truth on the next boot.
|
|
||||||
expect(countsByPosition()).toEqual({ 1: 48, 2: 50, 3: 30 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('decrements again when remediating a partly-dispensed cash-out', () => {
|
|
||||||
// Original cash-out managed 1 of the 2 notes it provisioned.
|
|
||||||
recordTransaction({
|
|
||||||
...TX_BASE,
|
|
||||||
txid: 'tx-partial',
|
|
||||||
type: 'cash_out',
|
|
||||||
status: 'partial',
|
|
||||||
bills: [{ denomination: 50, count: 1 }],
|
|
||||||
cassettes: [
|
|
||||||
{
|
|
||||||
name: 'cassette3',
|
|
||||||
position: 3,
|
|
||||||
denomination: 50,
|
|
||||||
provisioned: 2,
|
|
||||||
dispensed: 1,
|
|
||||||
rejected: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(countsByPosition()[3]).toBe(29)
|
|
||||||
|
|
||||||
// The operator dispenses the missing note by hand. That is a second lot of
|
|
||||||
// bills leaving the bay, so it debits again — the original only ever
|
|
||||||
// debited what physically left.
|
|
||||||
recordTransaction({
|
|
||||||
...TX_BASE,
|
|
||||||
txid: 'tx-remediate',
|
|
||||||
type: 'manual_dispense',
|
|
||||||
status: 'complete',
|
|
||||||
bills: [{ denomination: 50, count: 1 }],
|
|
||||||
cassettes: [
|
|
||||||
{
|
|
||||||
name: 'cassette3',
|
|
||||||
position: 3,
|
|
||||||
denomination: 50,
|
|
||||||
provisioned: 1,
|
|
||||||
dispensed: 1,
|
|
||||||
rejected: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(countsByPosition()[3]).toBe(28)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves the cashbox alone (bills leave, they do not arrive)', () => {
|
|
||||||
const before = getCashbox()
|
|
||||||
recordTransaction({
|
|
||||||
...TX_BASE,
|
|
||||||
txid: 'tx-manual-cashbox',
|
|
||||||
type: 'manual_dispense',
|
|
||||||
status: 'complete',
|
|
||||||
bills: [{ denomination: 20, count: 1 }],
|
|
||||||
cassettes: [
|
|
||||||
{
|
|
||||||
name: 'cassette2',
|
|
||||||
position: 2,
|
|
||||||
denomination: 20,
|
|
||||||
provisioned: 1,
|
|
||||||
dispensed: 1,
|
|
||||||
rejected: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(getCashbox()).toEqual(before)
|
|
||||||
expect(countsByPosition()[2]).toBe(49)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('state-store: getInventory represents a drained machine', () => {
|
|
||||||
it('keeps configured bays at zero rather than dropping them', () => {
|
|
||||||
recordTransaction({
|
|
||||||
...TX_BASE,
|
|
||||||
txid: 'tx-drain-50s',
|
|
||||||
type: 'cash_out',
|
|
||||||
status: 'complete',
|
|
||||||
bills: [{ denomination: 50, count: 30 }],
|
|
||||||
cassettes: [
|
|
||||||
{
|
|
||||||
name: 'cassette3',
|
|
||||||
position: 3,
|
|
||||||
denomination: 50,
|
|
||||||
provisioned: 30,
|
|
||||||
dispensed: 30,
|
|
||||||
rejected: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
// The $50 bay is empty but still configured. Dropping the key made this
|
|
||||||
// look like "no inventory known", and callers then fell back to a stale
|
|
||||||
// snapshot or to HAL.
|
|
||||||
expect(getInventory()).toEqual({ 20: 100, 50: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports every bay at zero when the machine is fully drained', () => {
|
|
||||||
for (const [txid, position, denomination, count] of [
|
|
||||||
['d1', 1, 20, 50],
|
|
||||||
['d2', 2, 20, 50],
|
|
||||||
['d3', 3, 50, 30],
|
|
||||||
] as const) {
|
|
||||||
recordTransaction({
|
|
||||||
...TX_BASE,
|
|
||||||
txid,
|
|
||||||
type: 'cash_out',
|
|
||||||
status: 'complete',
|
|
||||||
bills: [{ denomination, count }],
|
|
||||||
cassettes: [
|
|
||||||
{
|
|
||||||
name: `cassette${position}`,
|
|
||||||
position,
|
|
||||||
denomination,
|
|
||||||
provisioned: count,
|
|
||||||
dispensed: count,
|
|
||||||
rejected: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
expect(getInventory()).toEqual({ 20: 0, 50: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns an empty map only when no cassettes are configured', () => {
|
|
||||||
// A fresh DB with no bays at all — the one case that should read as
|
|
||||||
// "nothing known", so callers may legitimately defer to the hardware.
|
|
||||||
closeDatabase()
|
|
||||||
initDatabase(':memory:')
|
|
||||||
expect(getInventory()).toEqual({})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('state-store: unverified counts after a silent dispense', () => {
|
|
||||||
it('starts clear, latches the first time, and keeps the earliest time', () => {
|
|
||||||
expect(getCountsUncertainSince()).toBeNull()
|
|
||||||
markCountsUncertain(1000)
|
|
||||||
expect(getCountsUncertainSince()).toBe(1000)
|
|
||||||
// A second failure does not move the clock forward — the question is how
|
|
||||||
// long the numbers have been untrustworthy, not when we last noticed.
|
|
||||||
markCountsUncertain(2000)
|
|
||||||
expect(getCountsUncertainSince()).toBe(1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clears on a recount, because that is what a recount is', () => {
|
|
||||||
markCountsUncertain(1000)
|
|
||||||
const result = applyOperatorCassetteOps([
|
|
||||||
{ id: 'op-1', at: 1_700_000_000, type: 'recount', position: 1, count: 40 },
|
|
||||||
])
|
|
||||||
expect(result.applied).toEqual(['op-1'])
|
|
||||||
expect(getCountsUncertainSince()).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not clear on a refill', () => {
|
|
||||||
// A refill adds to a number still known to be wrong. Only someone
|
|
||||||
// opening the bay and counting it resolves that.
|
|
||||||
markCountsUncertain(1000)
|
|
||||||
const result = applyOperatorCassetteOps([
|
|
||||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
|
||||||
])
|
|
||||||
expect(result.applied).toEqual(['op-1'])
|
|
||||||
expect(getCountsUncertainSince()).toBe(1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves the flag alone when the op is rejected', () => {
|
|
||||||
markCountsUncertain(1000)
|
|
||||||
// Bay 9 does not exist — the layout is hardware-determined.
|
|
||||||
const result = applyOperatorCassetteOps([
|
|
||||||
{ id: 'op-1', at: 1_700_000_000, type: 'recount', position: 9, count: 40 },
|
|
||||||
])
|
|
||||||
expect(result.applied).toEqual([])
|
|
||||||
expect(result.rejected).toHaveLength(1)
|
|
||||||
expect(getCountsUncertainSince()).toBe(1000)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('state-store: operator cassette operations (ADR-004)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
seedDuplicateDenomBays()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies a refill as a delta, not a total', () => {
|
|
||||||
applyOperatorCassetteOps([
|
|
||||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 30 },
|
|
||||||
])
|
|
||||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('is a no-op on a re-delivered operation', () => {
|
|
||||||
// Addressable events are re-delivered on every relay reconnect and the
|
|
||||||
// operator republishes a WINDOW, so the same op arrives many times. A
|
|
||||||
// delta applied twice is simply wrong, which is why every op carries an
|
|
||||||
// id and this table records the ones already applied.
|
|
||||||
const op = {
|
|
||||||
id: 'op-1',
|
|
||||||
at: 1_700_000_000,
|
|
||||||
type: 'refill' as const,
|
|
||||||
position: 1,
|
|
||||||
bills: 30,
|
|
||||||
}
|
|
||||||
expect(applyOperatorCassetteOps([op]).applied).toEqual(['op-1'])
|
|
||||||
expect(applyOperatorCassetteOps([op]).applied).toEqual([])
|
|
||||||
expect(applyOperatorCassetteOps([op, op]).applied).toEqual([])
|
|
||||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(80)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies only the unseen ops from a window that mixes both', () => {
|
|
||||||
applyOperatorCassetteOps([
|
|
||||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
|
||||||
])
|
|
||||||
const result = applyOperatorCassetteOps([
|
|
||||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 10 },
|
|
||||||
{ id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 5 },
|
|
||||||
])
|
|
||||||
expect(result.applied).toEqual(['op-2'])
|
|
||||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(65)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies a window oldest-first regardless of arrival order', () => {
|
|
||||||
// A recount then a refill is not the same as the reverse, so ordering is
|
|
||||||
// load-bearing and cannot be left to however the array arrived.
|
|
||||||
applyOperatorCassetteOps([
|
|
||||||
{ id: 'op-b', at: 1_700_000_002, type: 'refill', position: 1, bills: 7 },
|
|
||||||
{ id: 'op-a', at: 1_700_000_001, type: 'recount', position: 1, count: 3 },
|
|
||||||
])
|
|
||||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(10)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('empties a bay and sets a denomination', () => {
|
|
||||||
applyOperatorCassetteOps([
|
|
||||||
{ id: 'op-1', at: 1_700_000_000, type: 'empty', position: 2 },
|
|
||||||
{ id: 'op-2', at: 1_700_000_001, type: 'set_denomination', position: 2, denomination: 10 },
|
|
||||||
])
|
|
||||||
const bay = loadCassettes().find((c) => c.position === 2)!
|
|
||||||
expect(bay.count).toBe(0)
|
|
||||||
expect(bay.denomination).toBe(10)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a malformed op without applying or recording it', () => {
|
|
||||||
// Unrecorded on purpose: it stays pending on the operator's dashboard,
|
|
||||||
// which is the honest outcome. Recording it as applied would silence the
|
|
||||||
// noise by telling the operator their refill landed.
|
|
||||||
const result = applyOperatorCassetteOps([
|
|
||||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: -5 },
|
|
||||||
])
|
|
||||||
expect(result.applied).toEqual([])
|
|
||||||
expect(result.rejected[0]!.id).toBe('op-1')
|
|
||||||
expect(loadCassettes().find((c) => c.position === 1)!.count).toBe(50)
|
|
||||||
expect(getAppliedOpIds()).not.toContain('op-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('echoes applied ids back, newest first', () => {
|
|
||||||
applyOperatorCassetteOps([
|
|
||||||
{ id: 'op-1', at: 1_700_000_000, type: 'refill', position: 1, bills: 1 },
|
|
||||||
{ id: 'op-2', at: 1_700_000_001, type: 'refill', position: 1, bills: 1 },
|
|
||||||
])
|
|
||||||
expect(getAppliedOpIds()).toContain('op-1')
|
|
||||||
expect(getAppliedOpIds()).toContain('op-2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('advances the sequence on an applied op but not on a duplicate', () => {
|
|
||||||
const op = {
|
|
||||||
id: 'op-1',
|
|
||||||
at: 1_700_000_000,
|
|
||||||
type: 'refill' as const,
|
|
||||||
position: 1,
|
|
||||||
bills: 1,
|
|
||||||
}
|
|
||||||
const before = getCassetteStateSeq()
|
|
||||||
applyOperatorCassetteOps([op])
|
|
||||||
const after = getCassetteStateSeq()
|
|
||||||
expect(after).toBeGreaterThan(before)
|
|
||||||
applyOperatorCassetteOps([op])
|
|
||||||
expect(getCassetteStateSeq()).toBe(after)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('advances the sequence on a dispense', () => {
|
|
||||||
const before = getCassetteStateSeq()
|
|
||||||
recordTransaction({
|
|
||||||
...TX_BASE,
|
|
||||||
txid: 'tx-seq',
|
|
||||||
type: 'cash_out',
|
|
||||||
status: 'complete',
|
|
||||||
bills: [{ denomination: 20, count: 1 }],
|
|
||||||
cassettes: [
|
|
||||||
{
|
|
||||||
name: 'cassette1',
|
|
||||||
position: 1,
|
|
||||||
denomination: 20,
|
|
||||||
provisioned: 1,
|
|
||||||
dispensed: 1,
|
|
||||||
rejected: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(getCassetteStateSeq()).toBeGreaterThan(before)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import { openCardSession, scanUrlToSessionUrl } from './boltcard-session'
|
|
||||||
|
|
||||||
const LNURLW =
|
|
||||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
|
||||||
|
|
||||||
/** Mock fetch returning the given JSON bodies per call, in order (status 200). */
|
|
||||||
function mockFetch(bodies: unknown[], status = 200) {
|
|
||||||
const calls: string[] = []
|
|
||||||
const impl = vi.fn(async (url: string | URL) => {
|
|
||||||
calls.push(url.toString())
|
|
||||||
const body = bodies[calls.length - 1]
|
|
||||||
return { status, json: async () => body } as Response
|
|
||||||
})
|
|
||||||
return { impl: impl as unknown as typeof fetch, calls }
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSION = {
|
|
||||||
authenticated: true,
|
|
||||||
external_id: 'abc123',
|
|
||||||
card_name: 'Alice',
|
|
||||||
balance_msat: 123_456_789,
|
|
||||||
currency: 'usd',
|
|
||||||
fiat: 98.76,
|
|
||||||
withdraw: {
|
|
||||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
|
|
||||||
k1: 'hit1',
|
|
||||||
minWithdrawable: 1000,
|
|
||||||
maxWithdrawable: 50_000_000,
|
|
||||||
},
|
|
||||||
withdraw_blocked_reason: null,
|
|
||||||
pay: {
|
|
||||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
|
|
||||||
minSendable: 1000,
|
|
||||||
maxSendable: 50_000_000,
|
|
||||||
metadata: '[["text/plain","Bolt Card top-up"]]',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('scanUrlToSessionUrl', () => {
|
|
||||||
it('rewrites /scan/ to /session/ and preserves p + c', () => {
|
|
||||||
const u = scanUrlToSessionUrl(LNURLW)
|
|
||||||
expect(u).toContain('https://lnbits.l484.com/boltcards/api/v1/session/abc123')
|
|
||||||
expect(u).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF')
|
|
||||||
expect(u).toContain('c=1122334455667788')
|
|
||||||
})
|
|
||||||
it('returns null for a non-scan URL', () => {
|
|
||||||
expect(scanUrlToSessionUrl('lnurlw://host/somethingelse?p=1&c=2')).toBeNull()
|
|
||||||
expect(scanUrlToSessionUrl('http://host/boltcards/api/v1/scan/x')).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('openCardSession', () => {
|
|
||||||
it('opens a session: balance in sats, upper-cased currency, both steps', async () => {
|
|
||||||
const f = mockFetch([SESSION])
|
|
||||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
|
||||||
expect(f.calls).toHaveLength(1)
|
|
||||||
expect(f.calls[0]).toContain('/session/abc123')
|
|
||||||
expect(out).toEqual({
|
|
||||||
ok: true,
|
|
||||||
session: {
|
|
||||||
externalId: 'abc123',
|
|
||||||
cardName: 'Alice',
|
|
||||||
balanceSats: 123_456,
|
|
||||||
currency: 'USD',
|
|
||||||
fiat: 98.76,
|
|
||||||
withdraw: SESSION.withdraw,
|
|
||||||
withdrawBlockedReason: null,
|
|
||||||
pay: SESSION.pay,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('carries a withheld withdraw step with its reason', async () => {
|
|
||||||
const f = mockFetch([
|
|
||||||
{ ...SESSION, withdraw: null, withdraw_blocked_reason: 'Max daily limit spent.' },
|
|
||||||
])
|
|
||||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
|
||||||
expect(out.ok).toBe(true)
|
|
||||||
if (!out.ok) return
|
|
||||||
expect(out.session.withdraw).toBeNull()
|
|
||||||
expect(out.session.withdrawBlockedReason).toBe('Max daily limit spent.')
|
|
||||||
expect(out.session.pay.callback).toBe(SESSION.pay.callback)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('has no fiat when the server sent no currency', async () => {
|
|
||||||
const f = mockFetch([{ ...SESSION, currency: null, fiat: null }])
|
|
||||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
|
||||||
expect(out.ok && out.session.currency).toBeNull()
|
|
||||||
expect(out.ok && out.session.fiat).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('surfaces the server reason on a rejected tap', async () => {
|
|
||||||
const f = mockFetch([{ authenticated: false, reason: 'This link is already used.' }])
|
|
||||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
|
||||||
expect(out).toEqual({ ok: false, reason: 'This link is already used.' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects an incomplete session (no pay step)', async () => {
|
|
||||||
const f = mockFetch([{ ...SESSION, pay: undefined }])
|
|
||||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
|
||||||
expect(out).toEqual({ ok: false, reason: 'card server returned an incomplete session' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('names an old card server that has no /session', async () => {
|
|
||||||
const f = mockFetch([{ detail: 'Not Found' }], 404)
|
|
||||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
|
||||||
expect(out).toEqual({ ok: false, reason: 'card server does not support sessions' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a non-card tag without a network call', async () => {
|
|
||||||
const f = mockFetch([])
|
|
||||||
const out = await openCardSession('https://host/not/a/card', { fetchImpl: f.impl })
|
|
||||||
expect(out.ok).toBe(false)
|
|
||||||
expect(f.calls).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports an unreachable card server', async () => {
|
|
||||||
const impl = vi.fn(async () => {
|
|
||||||
throw new TypeError('fetch failed')
|
|
||||||
}) as unknown as typeof fetch
|
|
||||||
const out = await openCardSession(LNURLW, { fetchImpl: impl })
|
|
||||||
expect(out).toEqual({ ok: false, reason: 'could not reach the card: fetch failed' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
/**
|
|
||||||
* Bolt Card session (tap-to-enter) — one tap, one verified visit.
|
|
||||||
*
|
|
||||||
* A Bolt Card tap yields a single-use SUN `p`/`c`; anything that verifies it
|
|
||||||
* spends it. The access gate (ADR-003) wants to verify the card at entry AND
|
|
||||||
* let the holder finish a buy or sell later without tapping again, so the
|
|
||||||
* aiolabs `boltcards` fork exposes `/session/<external_id>?p=&c=` — a sibling
|
|
||||||
* of `/scan` and `/pay` that verifies once, records one hit, and returns:
|
|
||||||
* - the card wallet's balance and fiat equivalent (display only),
|
|
||||||
* - the LUD-03 second step (withdraw callback + k1 = hit) for cash-out,
|
|
||||||
* - the LUD-06 second step (pay callback) for cash-in.
|
|
||||||
* Both callbacks are keyed by the hit — the same single-use bearer `/scan`
|
|
||||||
* and `/pay` hand out — so the ATM holds no p/c for the rest of the visit.
|
|
||||||
* The withdraw step is withheld (with a reason) once the card's daily limit is
|
|
||||||
* spent, exactly as `/scan` would refuse.
|
|
||||||
*
|
|
||||||
* Runs in the MAIN process (Node fetch) to avoid renderer CORS, like the other
|
|
||||||
* LNURL modules. See docs/boltcard-session.md for the wire contract.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { lnurlwToHttps, type WithdrawStep } from './lnurl-withdraw.js'
|
|
||||||
import type { PayStep } from './lnurl-pay.js'
|
|
||||||
|
|
||||||
export interface CardSession {
|
|
||||||
externalId: string
|
|
||||||
cardName: string
|
|
||||||
balanceSats: number
|
|
||||||
/** ISO currency the card server priced the balance in; null → no fiat. */
|
|
||||||
currency: string | null
|
|
||||||
/** Balance in `currency` at the card server's rate; null when unknown. */
|
|
||||||
fiat: number | null
|
|
||||||
/** LUD-03 second step, or null when the card server withheld it. */
|
|
||||||
withdraw: WithdrawStep | null
|
|
||||||
/** Why `withdraw` is null (e.g. daily limit spent); safe to show on-screen. */
|
|
||||||
withdrawBlockedReason: string | null
|
|
||||||
/** LUD-06 second step for topping the card wallet up. */
|
|
||||||
pay: PayStep
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OpenCardSessionResult =
|
|
||||||
| { ok: true; session: CardSession }
|
|
||||||
| { ok: false; reason: string }
|
|
||||||
|
|
||||||
type FetchLike = typeof fetch
|
|
||||||
|
|
||||||
export interface OpenCardSessionOptions {
|
|
||||||
/** Injected for tests; defaults to global fetch. */
|
|
||||||
fetchImpl?: FetchLike
|
|
||||||
/** Per-request timeout (default 15s). */
|
|
||||||
timeoutMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive the session URL from a tapped card's `lnurlw`: the card presents
|
|
||||||
* `…/boltcards/api/v1/scan/<id>?p=&c=`; the session endpoint is its sibling
|
|
||||||
* `…/boltcards/api/v1/session/<id>?p=&c=` with the same SUN.
|
|
||||||
*/
|
|
||||||
export function scanUrlToSessionUrl(lnurlw: string): string | null {
|
|
||||||
const https = lnurlwToHttps(lnurlw)
|
|
||||||
if (!https) return null
|
|
||||||
const u = new URL(https)
|
|
||||||
if (!u.pathname.includes('/scan/')) return null
|
|
||||||
u.pathname = u.pathname.replace('/scan/', '/session/')
|
|
||||||
return u.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wire shape of a `/session` reply (any of the fields may be missing/odd). */
|
|
||||||
interface SessionWire {
|
|
||||||
authenticated?: unknown
|
|
||||||
reason?: unknown
|
|
||||||
external_id?: unknown
|
|
||||||
card_name?: unknown
|
|
||||||
balance_msat?: unknown
|
|
||||||
currency?: unknown
|
|
||||||
fiat?: unknown
|
|
||||||
withdraw?: unknown
|
|
||||||
withdraw_blocked_reason?: unknown
|
|
||||||
pay?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
|
|
||||||
const optNum = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
|
||||||
const optStr = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
|
||||||
|
|
||||||
function parseWithdraw(v: unknown): WithdrawStep | null {
|
|
||||||
if (!isObj(v)) return null
|
|
||||||
const callback = optStr(v.callback)
|
|
||||||
const k1 = optStr(v.k1)
|
|
||||||
if (!callback || !k1) return null
|
|
||||||
return {
|
|
||||||
callback,
|
|
||||||
k1,
|
|
||||||
minWithdrawable: optNum(v.minWithdrawable),
|
|
||||||
maxWithdrawable: optNum(v.maxWithdrawable),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePay(v: unknown): PayStep | null {
|
|
||||||
if (!isObj(v)) return null
|
|
||||||
const callback = optStr(v.callback)
|
|
||||||
if (!callback) return null
|
|
||||||
return {
|
|
||||||
callback,
|
|
||||||
minSendable: optNum(v.minSendable),
|
|
||||||
maxSendable: optNum(v.maxSendable),
|
|
||||||
metadata: optStr(v.metadata),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function errMsg(e: unknown): string {
|
|
||||||
if (e instanceof Error)
|
|
||||||
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
|
||||||
return String(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a session for a tapped card. Spends the tap's SUN. Never throws —
|
|
||||||
* every failure returns `{ ok: false, reason }` (reasons come from the card
|
|
||||||
* server verbatim and are safe to show).
|
|
||||||
*/
|
|
||||||
export async function openCardSession(
|
|
||||||
lnurlw: string,
|
|
||||||
opts: OpenCardSessionOptions = {}
|
|
||||||
): Promise<OpenCardSessionResult> {
|
|
||||||
const doFetch = opts.fetchImpl ?? fetch
|
|
||||||
const timeoutMs = opts.timeoutMs ?? 15_000
|
|
||||||
|
|
||||||
const url = scanUrlToSessionUrl(lnurlw)
|
|
||||||
if (!url) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
|
|
||||||
|
|
||||||
let wire: SessionWire
|
|
||||||
try {
|
|
||||||
const res = await doFetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
||||||
if (res.status === 404) {
|
|
||||||
// Older fork without /session — say so rather than "card rejected".
|
|
||||||
return { ok: false, reason: 'card server does not support sessions' }
|
|
||||||
}
|
|
||||||
wire = (await res.json()) as SessionWire
|
|
||||||
} catch (e) {
|
|
||||||
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wire.authenticated !== true) {
|
|
||||||
return { ok: false, reason: optStr(wire.reason) || 'card rejected the tap' }
|
|
||||||
}
|
|
||||||
const externalId = optStr(wire.external_id)
|
|
||||||
const pay = parsePay(wire.pay)
|
|
||||||
if (!externalId || !pay) {
|
|
||||||
return { ok: false, reason: 'card server returned an incomplete session' }
|
|
||||||
}
|
|
||||||
const balanceMsat = optNum(wire.balance_msat) ?? 0
|
|
||||||
const currency = optStr(wire.currency)?.toUpperCase() ?? null
|
|
||||||
const fiat = optNum(wire.fiat)
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
session: {
|
|
||||||
externalId,
|
|
||||||
cardName: optStr(wire.card_name) ?? '',
|
|
||||||
balanceSats: Math.floor(balanceMsat / 1000),
|
|
||||||
currency,
|
|
||||||
fiat: currency && fiat !== undefined ? fiat : null,
|
|
||||||
withdraw: parseWithdraw(wire.withdraw),
|
|
||||||
withdrawBlockedReason: optStr(wire.withdraw_blocked_reason) ?? null,
|
|
||||||
pay,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import {
|
import { resolveCardInvoice, scanUrlToResolver, lnAddressToLnurlp } from './lnurl-pay'
|
||||||
resolveCardInvoice,
|
|
||||||
resolveInvoiceFromPayStep,
|
|
||||||
scanUrlToResolver,
|
|
||||||
lnAddressToLnurlp,
|
|
||||||
} from './lnurl-pay'
|
|
||||||
|
|
||||||
const LNURLW =
|
const LNURLW =
|
||||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||||
|
|
@ -123,43 +118,3 @@ describe('resolveCardInvoice', () => {
|
||||||
expect(res.reason).toMatch(/could not reach the card/i)
|
expect(res.reason).toMatch(/could not reach the card/i)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('resolveInvoiceFromPayStep (session second step, no tap)', () => {
|
|
||||||
const step = {
|
|
||||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
|
|
||||||
minSendable: 1000,
|
|
||||||
maxSendable: 50_000_000,
|
|
||||||
metadata: '[["text/plain","Bolt Card top-up"]]',
|
|
||||||
}
|
|
||||||
|
|
||||||
it('fetches an invoice for the amount from the callback', async () => {
|
|
||||||
const f = mockFetch([{ pr: BOLT11 }])
|
|
||||||
const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl })
|
|
||||||
expect(out).toEqual({ ok: true, bolt11: BOLT11 })
|
|
||||||
expect(f.calls).toHaveLength(1)
|
|
||||||
expect(f.calls[0]).toContain('amount=25000')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('enforces the step bounds without calling out', async () => {
|
|
||||||
const f = mockFetch([])
|
|
||||||
expect(await resolveInvoiceFromPayStep(step, 500, { fetchImpl: f.impl })).toEqual({
|
|
||||||
ok: false,
|
|
||||||
reason: 'amount is below the card wallet minimum',
|
|
||||||
})
|
|
||||||
expect(await resolveInvoiceFromPayStep(step, 60_000_000, { fetchImpl: f.impl })).toEqual({
|
|
||||||
ok: false,
|
|
||||||
reason: 'amount is above the card wallet maximum',
|
|
||||||
})
|
|
||||||
expect(await resolveInvoiceFromPayStep(step, 0, { fetchImpl: f.impl })).toEqual({
|
|
||||||
ok: false,
|
|
||||||
reason: 'no amount to send',
|
|
||||||
})
|
|
||||||
expect(f.calls).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('surfaces a callback decline', async () => {
|
|
||||||
const f = mockFetch([{ status: 'ERROR', reason: 'Card is disabled.' }])
|
|
||||||
const out = await resolveInvoiceFromPayStep(step, 25_000, { fetchImpl: f.impl })
|
|
||||||
expect(out).toEqual({ ok: false, reason: 'Card is disabled.' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -59,17 +59,6 @@ interface CardPayTarget {
|
||||||
lnurl?: string
|
lnurl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The LUD-06 second step on its own: what a `payRequest` (or a Bolt Card
|
|
||||||
* session, see boltcard-session.ts) hands us to fetch an invoice.
|
|
||||||
*/
|
|
||||||
export interface PayStep {
|
|
||||||
callback: string
|
|
||||||
minSendable?: number
|
|
||||||
maxSendable?: number
|
|
||||||
metadata?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** LUD-06 payRequest (subset) + error shape. */
|
/** LUD-06 payRequest (subset) + error shape. */
|
||||||
interface PayRequest {
|
interface PayRequest {
|
||||||
tag?: string
|
tag?: string
|
||||||
|
|
@ -174,18 +163,17 @@ async function toPayRequest(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestInvoice(
|
async function requestInvoice(
|
||||||
pr: PayStep,
|
pr: PayRequest,
|
||||||
amountMsat: number,
|
amountMsat: number,
|
||||||
ctx: Ctx
|
ctx: Ctx
|
||||||
): Promise<ResolveCardInvoiceResult> {
|
): Promise<ResolveCardInvoiceResult> {
|
||||||
if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
|
|
||||||
if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) {
|
if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) {
|
||||||
return { ok: false, reason: 'amount is below the card wallet minimum' }
|
return { ok: false, reason: 'amount is below the card wallet minimum' }
|
||||||
}
|
}
|
||||||
if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) {
|
if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) {
|
||||||
return { ok: false, reason: 'amount is above the card wallet maximum' }
|
return { ok: false, reason: 'amount is above the card wallet maximum' }
|
||||||
}
|
}
|
||||||
const cbUrl = appendQuery(pr.callback, { amount: String(amountMsat) })
|
const cbUrl = appendQuery(pr.callback!, { amount: String(amountMsat) })
|
||||||
let vals: PayValues
|
let vals: PayValues
|
||||||
try {
|
try {
|
||||||
const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
||||||
|
|
@ -234,19 +222,5 @@ export async function resolveCardInvoice(
|
||||||
if (!pr.ok) return pr
|
if (!pr.ok) return pr
|
||||||
|
|
||||||
// 3) Ask for an invoice for the payout amount.
|
// 3) Ask for an invoice for the payout amount.
|
||||||
return requestInvoice({ ...pr.payRequest, callback: pr.payRequest.callback! }, amountMsat, ctx)
|
return requestInvoice(pr.payRequest, amountMsat, ctx)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The LUD-06 second step alone: fetch a BOLT11 for `amountMsat` from an
|
|
||||||
* already-obtained pay step (from a Bolt Card session opened at tap-to-enter).
|
|
||||||
* Never throws — every failure returns `{ ok: false, reason }`.
|
|
||||||
*/
|
|
||||||
export async function resolveInvoiceFromPayStep(
|
|
||||||
step: PayStep,
|
|
||||||
amountMsat: number,
|
|
||||||
opts: ResolveCardInvoiceOptions = {}
|
|
||||||
): Promise<ResolveCardInvoiceResult> {
|
|
||||||
const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
|
|
||||||
return requestInvoice(step, amountMsat, ctx)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { executeLnurlWithdraw, executeWithdrawCallback, lnurlwToHttps } from './lnurl-withdraw'
|
import { executeLnurlWithdraw, lnurlwToHttps } from './lnurl-withdraw'
|
||||||
|
|
||||||
const BOLT11 = 'lnbc10u1p3xyz...'
|
const BOLT11 = 'lnbc10u1p3xyz...'
|
||||||
const LNURLW =
|
const LNURLW =
|
||||||
|
|
@ -101,44 +101,3 @@ describe('executeLnurlWithdraw', () => {
|
||||||
expect(res.reason).toMatch(/could not reach the card/i)
|
expect(res.reason).toMatch(/could not reach the card/i)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('executeWithdrawCallback (session second step, no tap)', () => {
|
|
||||||
const step = {
|
|
||||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
|
|
||||||
k1: 'hit1',
|
|
||||||
maxWithdrawable: 5_000_000,
|
|
||||||
}
|
|
||||||
|
|
||||||
it('hands the invoice straight to the callback with k1', async () => {
|
|
||||||
const f = mockFetch([{ status: 'OK' }])
|
|
||||||
const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl })
|
|
||||||
expect(out).toEqual({ ok: true })
|
|
||||||
expect(f.calls).toHaveLength(1)
|
|
||||||
expect(f.calls[0]).toContain('k1=hit1')
|
|
||||||
expect(f.calls[0]).toContain('pr=' + BOLT11)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('refuses an amount above the step limit without calling out', async () => {
|
|
||||||
const f = mockFetch([])
|
|
||||||
const out = await executeWithdrawCallback(step, BOLT11, {
|
|
||||||
fetchImpl: f.impl,
|
|
||||||
amountMsat: 6_000_000,
|
|
||||||
})
|
|
||||||
expect(out).toEqual({ ok: false, reason: 'card limit is below this amount' })
|
|
||||||
expect(f.calls).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('surfaces a callback decline', async () => {
|
|
||||||
const f = mockFetch([{ status: 'ERROR', reason: 'Payment already claimed.' }])
|
|
||||||
const out = await executeWithdrawCallback(step, BOLT11, { fetchImpl: f.impl })
|
|
||||||
expect(out).toEqual({ ok: false, reason: 'Payment already claimed.' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a missing invoice', async () => {
|
|
||||||
const f = mockFetch([])
|
|
||||||
expect(await executeWithdrawCallback(step, '', { fetchImpl: f.impl })).toEqual({
|
|
||||||
ok: false,
|
|
||||||
reason: 'no invoice to charge',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -36,17 +36,6 @@ interface WithdrawRequest {
|
||||||
|
|
||||||
type FetchLike = typeof fetch
|
type FetchLike = typeof fetch
|
||||||
|
|
||||||
/**
|
|
||||||
* The LUD-03 second step on its own: what a `withdrawRequest` (or a Bolt Card
|
|
||||||
* session, see boltcard-session.ts) hands us to actually pull a payment.
|
|
||||||
*/
|
|
||||||
export interface WithdrawStep {
|
|
||||||
callback: string
|
|
||||||
k1: string
|
|
||||||
minWithdrawable?: number
|
|
||||||
maxWithdrawable?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExecuteLnurlWithdrawOptions {
|
export interface ExecuteLnurlWithdrawOptions {
|
||||||
/** Injected for tests; defaults to global fetch. */
|
/** Injected for tests; defaults to global fetch. */
|
||||||
fetchImpl?: FetchLike
|
fetchImpl?: FetchLike
|
||||||
|
|
@ -84,8 +73,7 @@ function appendQuery(url: string, params: Record<string, string>): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function errMsg(e: unknown): string {
|
function errMsg(e: unknown): string {
|
||||||
if (e instanceof Error)
|
if (e instanceof Error) return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||||
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
|
||||||
return String(e)
|
return String(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,46 +105,16 @@ export async function executeLnurlWithdraw(
|
||||||
if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) {
|
if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) {
|
||||||
return { ok: false, reason: 'card did not return a withdraw voucher' }
|
return { ok: false, reason: 'card did not return a withdraw voucher' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Hand our invoice to the callback — the card's wallet pays it.
|
|
||||||
return executeWithdrawCallback(
|
|
||||||
{
|
|
||||||
callback: params.callback,
|
|
||||||
k1: params.k1,
|
|
||||||
minWithdrawable: params.minWithdrawable,
|
|
||||||
maxWithdrawable: params.maxWithdrawable,
|
|
||||||
},
|
|
||||||
bolt11,
|
|
||||||
opts
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The LUD-03 second step alone: hand our invoice to an already-obtained
|
|
||||||
* withdraw step (from a `/scan` withdrawRequest, or from a Bolt Card session
|
|
||||||
* opened at tap-to-enter) — the card's wallet pays it. `{ ok: true }` means the
|
|
||||||
* card accepted the pull; settlement is observed by the invoice watcher.
|
|
||||||
*/
|
|
||||||
export async function executeWithdrawCallback(
|
|
||||||
step: WithdrawStep,
|
|
||||||
bolt11: string,
|
|
||||||
opts: ExecuteLnurlWithdrawOptions = {}
|
|
||||||
): Promise<LnurlWithdrawResult> {
|
|
||||||
const doFetch = opts.fetchImpl ?? fetch
|
|
||||||
const timeoutMs = opts.timeoutMs ?? 15_000
|
|
||||||
|
|
||||||
if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) {
|
|
||||||
return { ok: false, reason: 'no invoice to charge' }
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
opts.amountMsat != null &&
|
opts.amountMsat != null &&
|
||||||
typeof step.maxWithdrawable === 'number' &&
|
typeof params.maxWithdrawable === 'number' &&
|
||||||
opts.amountMsat > step.maxWithdrawable
|
opts.amountMsat > params.maxWithdrawable
|
||||||
) {
|
) {
|
||||||
return { ok: false, reason: 'card limit is below this amount' }
|
return { ok: false, reason: 'card limit is below this amount' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const cbUrl = appendQuery(step.callback, { k1: step.k1, pr: bolt11.trim() })
|
// 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 }
|
let cb: { status?: string; reason?: string }
|
||||||
try {
|
try {
|
||||||
const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) })
|
const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) })
|
||||||
|
|
|
||||||
|
|
@ -24,36 +24,26 @@ import {
|
||||||
markCommandExecuting,
|
markCommandExecuting,
|
||||||
completeCommand,
|
completeCommand,
|
||||||
getLastKnownConfigCreatedAt,
|
getLastKnownConfigCreatedAt,
|
||||||
getCountsUncertainSince,
|
getBootstrapPublishedAt,
|
||||||
getLastStatePublishedAt,
|
markBootstrapPublished,
|
||||||
markCountsUncertain,
|
resetBootstrapGate,
|
||||||
markStatePublished,
|
|
||||||
resetStatePublishWatermark,
|
|
||||||
resetForRepair,
|
resetForRepair,
|
||||||
applyOperatorCassetteOps,
|
applyOperatorCassettesConfig,
|
||||||
getAppliedOpIds,
|
|
||||||
getCassetteStateSeq,
|
|
||||||
getFeeConfig,
|
getFeeConfig,
|
||||||
getLastKnownFeeConfigCreatedAt,
|
getLastKnownFeeConfigCreatedAt,
|
||||||
applyFeeConfig,
|
applyFeeConfig,
|
||||||
getBunkerBinding,
|
getBunkerBinding,
|
||||||
saveBunkerBinding,
|
saveBunkerBinding,
|
||||||
clearBunkerBinding,
|
clearBunkerBinding,
|
||||||
type CassetteOp,
|
type OperatorCassettesPayload,
|
||||||
type ApplyOpsResult,
|
|
||||||
type FeeConfigPayload,
|
type FeeConfigPayload,
|
||||||
type FeeConfigRow,
|
type FeeConfigRow,
|
||||||
type ApplyResult,
|
type ApplyResult,
|
||||||
type StoredBunkerBinding,
|
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 {
|
import { executeLnurlWithdraw } from './lnurl-withdraw.js'
|
||||||
executeLnurlWithdraw,
|
import { resolveCardInvoice } from './lnurl-pay.js'
|
||||||
executeWithdrawCallback,
|
|
||||||
type WithdrawStep,
|
|
||||||
} from './lnurl-withdraw.js'
|
|
||||||
import { resolveCardInvoice, resolveInvoiceFromPayStep, type PayStep } from './lnurl-pay.js'
|
|
||||||
import { openCardSession, type OpenCardSessionResult } from './boltcard-session.js'
|
|
||||||
import { startNfcReader, type NfcStatus } from './nfc-service.js'
|
import { startNfcReader, type NfcStatus } from './nfc-service.js'
|
||||||
|
|
||||||
// ESM equivalent of __dirname
|
// ESM equivalent of __dirname
|
||||||
|
|
@ -174,62 +164,6 @@ function loadBranding(): BrandingConfig | null {
|
||||||
return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl }
|
return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access-control config loader (ADR-003). Env toggles the gate; an optional
|
|
||||||
// /var/lib/bitspire/access.json carries the salt + allow-list. Defaults OFF —
|
|
||||||
// a machine with neither env nor file behaves as if there is no access layer.
|
|
||||||
// The allow-list shape mirrors the renderer's AllowListEntry (authorize.ts);
|
|
||||||
// duplicated here to avoid a cross-project (electron↔renderer) import.
|
|
||||||
interface AccessAllowListEntry {
|
|
||||||
idHash: string
|
|
||||||
role: 'user' | 'operator'
|
|
||||||
pinHash?: string
|
|
||||||
label?: string
|
|
||||||
}
|
|
||||||
function loadAccessControl() {
|
|
||||||
// Env provides defaults; access.json (writable, operator-provisioned — same
|
|
||||||
// spirit as branding/) overrides them, so the gate can be toggled on a
|
|
||||||
// deployed machine by dropping a file + restarting the service, with no image
|
|
||||||
// rebuild. Defaults OFF.
|
|
||||||
let enabled = process.env.ACCESS_CONTROL_ENABLED === 'true'
|
|
||||||
// Dev unlock is OFF unless explicitly enabled: a gated machine must not ship
|
|
||||||
// a visible bypass button by default.
|
|
||||||
let devUnlock = process.env.ACCESS_DEV_UNLOCK === 'true'
|
|
||||||
let openEnrollment = process.env.ACCESS_OPEN_ENROLLMENT === 'true'
|
|
||||||
let salt = process.env.ACCESS_SALT || ''
|
|
||||||
let allowList: AccessAllowListEntry[] = []
|
|
||||||
|
|
||||||
const jsonPath = path.join(
|
|
||||||
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
|
|
||||||
'access.json'
|
|
||||||
)
|
|
||||||
if (fs.existsSync(jsonPath)) {
|
|
||||||
try {
|
|
||||||
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
|
||||||
if (typeof raw.enabled === 'boolean') enabled = raw.enabled
|
|
||||||
if (typeof raw.devUnlock === 'boolean') devUnlock = raw.devUnlock
|
|
||||||
if (typeof raw.openEnrollment === 'boolean') openEnrollment = raw.openEnrollment
|
|
||||||
if (typeof raw.salt === 'string' && raw.salt) salt = raw.salt
|
|
||||||
if (Array.isArray(raw.allowList)) {
|
|
||||||
allowList = (raw.allowList as unknown[]).filter(
|
|
||||||
(e): e is AccessAllowListEntry =>
|
|
||||||
!!e &&
|
|
||||||
typeof (e as AccessAllowListEntry).idHash === 'string' &&
|
|
||||||
((e as AccessAllowListEntry).role === 'user' ||
|
|
||||||
(e as AccessAllowListEntry).role === 'operator')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Electron] Failed to parse access.json:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A gated machine needs a stable salt for deterministic hashing. Fall back to
|
|
||||||
// a fixed default (prototype); production should provision a real salt.
|
|
||||||
if (!salt) salt = 'bitspire-access-v1'
|
|
||||||
|
|
||||||
return { enabled, devUnlock, openEnrollment, salt, allowList }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if we're in development
|
// Determine if we're in development
|
||||||
const isDev =
|
const isDev =
|
||||||
process.env.ELECTRON_FORCE_PROD !== '1' &&
|
process.env.ELECTRON_FORCE_PROD !== '1' &&
|
||||||
|
|
@ -377,9 +311,6 @@ ipcMain.handle('get-config', () => {
|
||||||
|
|
||||||
// Operator branding (logo/title/theme) — null when no override
|
// Operator branding (logo/title/theme) — null when no override
|
||||||
branding: loadBranding(),
|
branding: loadBranding(),
|
||||||
|
|
||||||
// Access-control gate (ADR-003) — `enabled` defaults false (no gate).
|
|
||||||
accessControl: loadAccessControl(),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -415,7 +346,7 @@ ipcMain.handle('get-atm-secrets', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bunker binding persistence — the renderer writes the binding after a
|
// Bunker binding persistence — the renderer writes the binding after a
|
||||||
// successful pairing (connectNewSeed), and resets the publish watermark so the
|
// successful pairing (connectNewSeed), and resets the bootstrap gate so the
|
||||||
// new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56).
|
// new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56).
|
||||||
ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => {
|
ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => {
|
||||||
saveBunkerBinding(binding)
|
saveBunkerBinding(binding)
|
||||||
|
|
@ -423,8 +354,8 @@ ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBindin
|
||||||
ipcMain.handle('state:clear-bunker-binding', (): void => {
|
ipcMain.handle('state:clear-bunker-binding', (): void => {
|
||||||
clearBunkerBinding()
|
clearBunkerBinding()
|
||||||
})
|
})
|
||||||
ipcMain.handle('state:reset-state-publish-watermark', (): void => {
|
ipcMain.handle('state:reset-bootstrap-gate', (): void => {
|
||||||
resetStatePublishWatermark()
|
resetBootstrapGate()
|
||||||
})
|
})
|
||||||
ipcMain.handle('state:reset-for-repair', (): void => {
|
ipcMain.handle('state:reset-for-repair', (): void => {
|
||||||
resetForRepair()
|
resetForRepair()
|
||||||
|
|
@ -513,37 +444,6 @@ ipcMain.handle(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bolt Card tap-to-enter (ADR-003): open a verified session for a tapped card.
|
|
||||||
// Spends the tap's SUN once and returns balance + fiat + the withdraw/pay
|
|
||||||
// second steps the session reuses at Complete. See boltcard-session.ts.
|
|
||||||
ipcMain.handle(
|
|
||||||
'lnurl:open-card-session',
|
|
||||||
async (_event, args: { lnurlw: string }): Promise<OpenCardSessionResult> => {
|
|
||||||
return openCardSession(args.lnurlw)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Session variants of the two Complete paths: no tap, no p/c — just the
|
|
||||||
// hit-keyed second step the session already holds.
|
|
||||||
ipcMain.handle(
|
|
||||||
'lnurl:withdraw-session',
|
|
||||||
async (
|
|
||||||
_event,
|
|
||||||
args: { withdraw: WithdrawStep; bolt11: string; amountMsat?: number }
|
|
||||||
): Promise<{ ok: boolean; reason?: string }> => {
|
|
||||||
return executeWithdrawCallback(args.withdraw, args.bolt11, { amountMsat: args.amountMsat })
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ipcMain.handle(
|
|
||||||
'lnurl:pay-session',
|
|
||||||
async (
|
|
||||||
_event,
|
|
||||||
args: { pay: PayStep; amountMsat: number }
|
|
||||||
): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
|
|
||||||
return resolveInvoiceFromPayStep(args.pay, args.amountMsat)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// State persistence IPC handlers
|
// 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))
|
||||||
|
|
@ -560,22 +460,15 @@ 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-last-state-published-at', (): number | null => getLastStatePublishedAt())
|
ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt())
|
||||||
ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCountsUncertainSince())
|
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
|
||||||
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
|
markBootstrapPublished(unixTimestamp)
|
||||||
markCountsUncertain(unixTimestamp)
|
|
||||||
})
|
|
||||||
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
|
|
||||||
markStatePublished(unixTimestamp)
|
|
||||||
})
|
})
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'state:apply-operator-cassette-ops',
|
'state:apply-operator-cassettes-config',
|
||||||
(_event, ops: CassetteOp[]): ApplyOpsResult => applyOperatorCassetteOps(ops)
|
(_event, payload: OperatorCassettesPayload, eventCreatedAt: number): ApplyResult =>
|
||||||
|
applyOperatorCassettesConfig(payload, eventCreatedAt)
|
||||||
)
|
)
|
||||||
ipcMain.handle('state:get-applied-op-ids', (_event, limit?: number): string[] =>
|
|
||||||
getAppliedOpIds(limit)
|
|
||||||
)
|
|
||||||
ipcMain.handle('state:get-cassette-state-seq', (): number => getCassetteStateSeq())
|
|
||||||
|
|
||||||
// Operator-fees consumer (aiolabs/lamassu-next#57) — persisted singleton
|
// Operator-fees consumer (aiolabs/lamassu-next#57) — persisted singleton
|
||||||
// fee config + per-d-tag replay watermark + atomic apply for kind-30078
|
// fee config + per-d-tag replay watermark + atomic apply for kind-30078
|
||||||
|
|
@ -853,11 +746,6 @@ function startCommandPoller(): void {
|
||||||
error: result.error,
|
error: result.error,
|
||||||
})
|
})
|
||||||
|
|
||||||
// This dispense happened entirely in the main process, so the renderer
|
|
||||||
// has no idea the bays moved — it would keep serving a stale inventory
|
|
||||||
// and would never republish the operator's view. Tell it.
|
|
||||||
mainWindow?.webContents.send('cassettes:changed')
|
|
||||||
|
|
||||||
// Only remediate the original tx if ALL requested bills were dispensed
|
// Only remediate the original tx if ALL requested bills were dispensed
|
||||||
let refRemediated = false
|
let refRemediated = false
|
||||||
if (parsed.ref_txid && result.dispensed) {
|
if (parsed.ref_txid && result.dispensed) {
|
||||||
|
|
|
||||||
|
|
@ -108,21 +108,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// Operator-config consumer (aiolabs/lamassu-next#56)
|
// Operator-config consumer (aiolabs/lamassu-next#56)
|
||||||
getLastKnownConfigCreatedAt: (): Promise<number> =>
|
getLastKnownConfigCreatedAt: (): Promise<number> =>
|
||||||
ipcRenderer.invoke('state:get-last-known-config-created-at'),
|
ipcRenderer.invoke('state:get-last-known-config-created-at'),
|
||||||
getLastStatePublishedAt: (): Promise<number | null> =>
|
getBootstrapPublishedAt: (): Promise<number | null> =>
|
||||||
ipcRenderer.invoke('state:get-last-state-published-at'),
|
ipcRenderer.invoke('state:get-bootstrap-published-at'),
|
||||||
getCountsUncertainSince: (): Promise<number | null> =>
|
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
|
||||||
ipcRenderer.invoke('state:get-counts-uncertain-since'),
|
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
|
||||||
markCountsUncertain: (unixTimestamp: number): Promise<void> =>
|
|
||||||
ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp),
|
|
||||||
markStatePublished: (unixTimestamp: number): Promise<void> =>
|
|
||||||
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
|
|
||||||
|
|
||||||
// Bunker binding persistence (aiolabs/bitspire#52)
|
// Bunker binding persistence (aiolabs/bitspire#52)
|
||||||
saveBunkerBinding: (binding: BunkerBindingRecord): Promise<void> =>
|
saveBunkerBinding: (binding: BunkerBindingRecord): Promise<void> =>
|
||||||
ipcRenderer.invoke('state:save-bunker-binding', binding),
|
ipcRenderer.invoke('state:save-bunker-binding', binding),
|
||||||
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
|
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
|
||||||
resetStatePublishWatermark: (): Promise<void> =>
|
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'),
|
||||||
ipcRenderer.invoke('state:reset-state-publish-watermark'),
|
|
||||||
resetForRepair: (): Promise<void> => ipcRenderer.invoke('state:reset-for-repair'),
|
resetForRepair: (): Promise<void> => ipcRenderer.invoke('state:reset-for-repair'),
|
||||||
|
|
||||||
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
|
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
|
||||||
|
|
@ -146,40 +141,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
|
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
|
||||||
ipcRenderer.invoke('lnurl:pay-card', args),
|
ipcRenderer.invoke('lnurl:pay-card', args),
|
||||||
|
|
||||||
// Bolt Card tap-to-enter: one verified session per tap (balance + fiat +
|
applyOperatorCassettesConfig: (
|
||||||
// the withdraw/pay second steps reused at Complete). Payload shapes are
|
payload: {
|
||||||
// declared in src/types/electron.d.ts (CardSession).
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
openCardSession: (args: { lnurlw: string }): Promise<unknown> =>
|
},
|
||||||
ipcRenderer.invoke('lnurl:open-card-session', args),
|
eventCreatedAt: number
|
||||||
withdrawWithSession: (args: {
|
): Promise<{ applied: true } | { applied: false; reason: string }> =>
|
||||||
withdraw: { callback: string; k1: string; minWithdrawable?: number; maxWithdrawable?: number }
|
ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt),
|
||||||
bolt11: string
|
|
||||||
amountMsat?: number
|
|
||||||
}): Promise<{ ok: boolean; reason?: string }> =>
|
|
||||||
ipcRenderer.invoke('lnurl:withdraw-session', args),
|
|
||||||
resolveSessionInvoice: (args: {
|
|
||||||
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
|
|
||||||
amountMsat: number
|
|
||||||
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
|
|
||||||
ipcRenderer.invoke('lnurl:pay-session', args),
|
|
||||||
|
|
||||||
applyOperatorCassetteOps: (
|
|
||||||
ops: {
|
|
||||||
id: string
|
|
||||||
at: number
|
|
||||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
|
||||||
position: number
|
|
||||||
bills?: number
|
|
||||||
count?: number
|
|
||||||
denomination?: number
|
|
||||||
}[]
|
|
||||||
): Promise<{
|
|
||||||
applied: string[]
|
|
||||||
rejected: { id: string; reason: string }[]
|
|
||||||
}> => ipcRenderer.invoke('state:apply-operator-cassette-ops', ops),
|
|
||||||
getAppliedOpIds: (limit?: number): Promise<string[]> =>
|
|
||||||
ipcRenderer.invoke('state:get-applied-op-ids', limit),
|
|
||||||
getCassetteStateSeq: (): Promise<number> => ipcRenderer.invoke('state:get-cassette-state-seq'),
|
|
||||||
|
|
||||||
// Operator-fees consumer (aiolabs/lamassu-next#57)
|
// Operator-fees consumer (aiolabs/lamassu-next#57)
|
||||||
getFeeConfig: (): Promise<{
|
getFeeConfig: (): Promise<{
|
||||||
|
|
@ -237,13 +205,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// Bolt Card reader (main process → renderer). removeAllListeners first: a
|
// Bolt Card reader (main process → renderer). removeAllListeners first: a
|
||||||
// renderer reload re-runs this, and a duplicated card-tap listener would
|
// renderer reload re-runs this, and a duplicated card-tap listener would
|
||||||
// trigger the LNURL-withdraw twice.
|
// trigger the LNURL-withdraw twice.
|
||||||
// The main process changed the cassettes table (an operator-command dispense,
|
|
||||||
// boot seeding). The renderer reloads its inventory and republishes state.
|
|
||||||
onCassettesChanged: (callback: () => void) => {
|
|
||||||
ipcRenderer.removeAllListeners('cassettes:changed')
|
|
||||||
ipcRenderer.on('cassettes:changed', () => callback())
|
|
||||||
},
|
|
||||||
|
|
||||||
onNfcCardTapped: (callback: (lnurlw: string) => void) => {
|
onNfcCardTapped: (callback: (lnurlw: string) => void) => {
|
||||||
ipcRenderer.removeAllListeners('nfc:card-tapped')
|
ipcRenderer.removeAllListeners('nfc:card-tapped')
|
||||||
ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw))
|
ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw))
|
||||||
|
|
@ -304,13 +265,11 @@ declare global {
|
||||||
emptyCashbox: () => Promise<void>
|
emptyCashbox: () => Promise<void>
|
||||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||||
getLastStatePublishedAt: () => Promise<number | null>
|
getBootstrapPublishedAt: () => Promise<number | null>
|
||||||
getCountsUncertainSince: () => Promise<number | null>
|
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||||
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
|
||||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
|
||||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||||
clearBunkerBinding: () => Promise<void>
|
clearBunkerBinding: () => Promise<void>
|
||||||
resetStatePublishWatermark: () => Promise<void>
|
resetBootstrapGate: () => Promise<void>
|
||||||
resetForRepair: () => Promise<void>
|
resetForRepair: () => Promise<void>
|
||||||
saveSpireSeed: (seed: string) => Promise<void>
|
saveSpireSeed: (seed: string) => Promise<void>
|
||||||
relaunchApp: () => Promise<void>
|
relaunchApp: () => Promise<void>
|
||||||
|
|
@ -324,22 +283,10 @@ declare global {
|
||||||
lnurlw: string
|
lnurlw: string
|
||||||
amountMsat: number
|
amountMsat: number
|
||||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||||
applyOperatorCassetteOps: (
|
applyOperatorCassettesConfig: (
|
||||||
ops: {
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
id: string
|
eventCreatedAt: number
|
||||||
at: number
|
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
||||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
|
||||||
position: number
|
|
||||||
bills?: number
|
|
||||||
count?: number
|
|
||||||
denomination?: number
|
|
||||||
}[]
|
|
||||||
) => Promise<{
|
|
||||||
applied: string[]
|
|
||||||
rejected: { id: string; reason: string }[]
|
|
||||||
}>
|
|
||||||
getAppliedOpIds: (limit?: number) => Promise<string[]>
|
|
||||||
getCassetteStateSeq: () => Promise<number>
|
|
||||||
getFeeConfig: () => Promise<{
|
getFeeConfig: () => Promise<{
|
||||||
cashInFeeFraction: number
|
cashInFeeFraction: number
|
||||||
cashOutFeeFraction: number
|
cashOutFeeFraction: number
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '13'
|
const SCHEMA_VERSION = '12'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/bitspire'
|
const prodDir = '/var/lib/bitspire'
|
||||||
|
|
@ -57,17 +57,6 @@ export function initDatabase(dbPath?: string): void {
|
||||||
count INTEGER NOT NULL DEFAULT 0
|
count INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS cassette_ops (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
position INTEGER NOT NULL,
|
|
||||||
op_type TEXT NOT NULL,
|
|
||||||
bills INTEGER,
|
|
||||||
count INTEGER,
|
|
||||||
denomination INTEGER,
|
|
||||||
op_at INTEGER NOT NULL,
|
|
||||||
applied_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS cashbox (
|
CREATE TABLE IF NOT EXISTS cashbox (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
total_bills INTEGER NOT NULL DEFAULT 0,
|
total_bills INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
@ -306,9 +295,7 @@ export function initDatabase(dbPath?: string): void {
|
||||||
`)
|
`)
|
||||||
db.pragma('foreign_keys = ON')
|
db.pragma('foreign_keys = ON')
|
||||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version')
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version')
|
||||||
console.log(
|
console.log('[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)')
|
||||||
'[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)'
|
|
||||||
)
|
|
||||||
existing.value = '9'
|
existing.value = '9'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -384,47 +371,12 @@ export function initDatabase(dbPath?: string): void {
|
||||||
console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)')
|
console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing && existing.value === '12') {
|
|
||||||
// Migration v12 → v13: operator OPERATIONS replace operator counts
|
|
||||||
// (aiolabs/bitspire ADR-004).
|
|
||||||
//
|
|
||||||
// The operator used to publish absolute counts and this machine applied
|
|
||||||
// them outright. Both sides wrote the same value over a transport that
|
|
||||||
// never tells a writer it lost, so a dashboard form loaded before a
|
|
||||||
// dispense silently discarded that dispense — and nothing on either side
|
|
||||||
// could detect it afterwards. The operator now publishes what it DID and
|
|
||||||
// this machine, which holds the notes, owns the running total.
|
|
||||||
//
|
|
||||||
// `cassette_ops` is the dedup ledger. A delta applied twice is wrong, and
|
|
||||||
// addressable events are re-delivered on every reconnect, so the operator
|
|
||||||
// mints an id per operation and we record the ones we have applied. The
|
|
||||||
// operator's window is a slice of recent operations rather than just the
|
|
||||||
// newest, so one we missed arrives with the next publish; dedup is what
|
|
||||||
// makes re-delivery free instead of dangerous.
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS cassette_ops (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
position INTEGER NOT NULL,
|
|
||||||
op_type TEXT NOT NULL,
|
|
||||||
bills INTEGER,
|
|
||||||
count INTEGER,
|
|
||||||
denomination INTEGER,
|
|
||||||
op_at INTEGER NOT NULL,
|
|
||||||
applied_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('13', 'schema_version')
|
|
||||||
console.log('[StateStore] Migrated schema v12 → v13 (added cassette_ops)')
|
|
||||||
existing.value = '13'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||||||
// Seed the operator-config meta rows if they're missing (idempotent).
|
// Seed the operator-config meta rows if they're missing (idempotent).
|
||||||
const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)')
|
const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)')
|
||||||
seedMeta.run('lastKnownConfigCreatedAt', '0')
|
seedMeta.run('lastKnownConfigCreatedAt', '0')
|
||||||
seedMeta.run('bootstrapPublishedAt', '')
|
seedMeta.run('bootstrapPublishedAt', '')
|
||||||
seedMeta.run('lastKnownFeeConfigCreatedAt', '0')
|
seedMeta.run('lastKnownFeeConfigCreatedAt', '0')
|
||||||
seedMeta.run('cassetteStateSeq', '0')
|
|
||||||
|
|
||||||
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
||||||
if (!cashboxRow) {
|
if (!cashboxRow) {
|
||||||
|
|
@ -445,110 +397,32 @@ export function initDatabase(dbPath?: string): void {
|
||||||
*/
|
*/
|
||||||
export function getLastKnownConfigCreatedAt(): number {
|
export function getLastKnownConfigCreatedAt(): number {
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('lastKnownConfigCreatedAt') as
|
const row = db
|
||||||
| { value: string }
|
.prepare('SELECT value FROM meta WHERE key = ?')
|
||||||
| undefined
|
.get('lastKnownConfigCreatedAt') as { value: string } | undefined
|
||||||
return row ? Number(row.value) || 0 : 0
|
return row ? Number(row.value) || 0 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `created_at` of the last `bitspire-cassettes-state` event this machine
|
* Read the one-shot bootstrap-publish gate. Returns null if the ATM has
|
||||||
* published, or null if it has never published one.
|
* not yet published its `bitspire-cassettes-state:<machine_id>` hello-event.
|
||||||
*
|
|
||||||
* This used to be a one-shot gate ("have we said hello yet"), which meant a
|
|
||||||
* layout change after first boot was never announced (#94). It is now a
|
|
||||||
* high-water mark: every publish records its stamp, and the next one is forced
|
|
||||||
* strictly above it. Addressable events are ordered by `created_at` at second
|
|
||||||
* granularity, and a relay silently keeps the higher one, so a clock that steps
|
|
||||||
* backwards would otherwise make this machine's reports vanish with an `OK`.
|
|
||||||
*
|
|
||||||
* Stored under the original `bootstrapPublishedAt` meta key so no migration is
|
|
||||||
* needed; the name is historical, the meaning is not.
|
|
||||||
*/
|
*/
|
||||||
export function getLastStatePublishedAt(): number | null {
|
export function getBootstrapPublishedAt(): number | null {
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as
|
const row = db
|
||||||
| { value: string }
|
.prepare('SELECT value FROM meta WHERE key = ?')
|
||||||
| undefined
|
.get('bootstrapPublishedAt') as { value: string } | undefined
|
||||||
if (!row || row.value === '') return null
|
if (!row || row.value === '') return null
|
||||||
const n = Number(row.value)
|
const n = Number(row.value)
|
||||||
return Number.isFinite(n) ? n : null
|
return Number.isFinite(n) ? n : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the bay counts are known to be unverified, and since when.
|
* Mark the bootstrap hello-event as published. Idempotent — only takes
|
||||||
*
|
* effect the first time it's set. Subsequent calls overwrite the
|
||||||
* Set when a dispense ends without the dispenser reporting what it moved — a
|
* timestamp (harmless; the gate just needs to be non-null).
|
||||||
* driver throw, or the dispense timeout. Bills may well have reached the
|
|
||||||
* customer, but nothing knows how many, so neither the rows here nor HAL's
|
|
||||||
* bays were debited and both now read high. Reporting that number as fact is
|
|
||||||
* the worst option available; saying the number is unverified is honest and
|
|
||||||
* tells the operator to open the machine and recount.
|
|
||||||
*
|
|
||||||
* Cleared when an operator asserts authoritative counts (a config apply),
|
|
||||||
* which is precisely what a recount is. Uses an upsert so no migration is
|
|
||||||
* needed for machines whose meta table predates the key.
|
|
||||||
*/
|
*/
|
||||||
export function getCountsUncertainSince(): number | null {
|
export function markBootstrapPublished(unixTimestamp: number): void {
|
||||||
if (!db) throw new Error('Database not initialized')
|
|
||||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('countsUncertainSince') as
|
|
||||||
| { value: string }
|
|
||||||
| undefined
|
|
||||||
if (!row || row.value === '') return null
|
|
||||||
const n = Number(row.value)
|
|
||||||
return Number.isFinite(n) ? n : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Flag the counts as unverified. Keeps the earliest time it went bad. */
|
|
||||||
export function markCountsUncertain(unixTimestamp: number): void {
|
|
||||||
if (!db) throw new Error('Database not initialized')
|
|
||||||
if (getCountsUncertainSince() !== null) return
|
|
||||||
db.prepare(
|
|
||||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
|
||||||
).run('countsUncertainSince', String(unixTimestamp))
|
|
||||||
console.warn('[StateStore] Cassette counts flagged unverified at', unixTimestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear the flag — an operator has asserted real counts. */
|
|
||||||
export function clearCountsUncertain(): void {
|
|
||||||
if (!db) throw new Error('Database not initialized')
|
|
||||||
db.prepare(
|
|
||||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
|
||||||
).run('countsUncertainSince', '')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A counter bumped on every local change to a bay count, from any cause.
|
|
||||||
*
|
|
||||||
* It rides along in the state document so a reader can reject a regression
|
|
||||||
* without trusting a clock. `created_at` cannot carry that: it has
|
|
||||||
* second granularity, so two publishes in the same second are ordered by
|
|
||||||
* whichever event id hashes lower — and a machine whose clock stepped
|
|
||||||
* backwards would otherwise have every later report look older than the one
|
|
||||||
* already on the relay.
|
|
||||||
*/
|
|
||||||
export function getCassetteStateSeq(): number {
|
|
||||||
if (!db) throw new Error('Database not initialized')
|
|
||||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cassetteStateSeq') as
|
|
||||||
| { value: string }
|
|
||||||
| undefined
|
|
||||||
return row ? Number(row.value) || 0 : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bump the counter. Safe to call inside an open transaction — every caller
|
|
||||||
* that mutates a count does, so the bump commits or rolls back with it.
|
|
||||||
*/
|
|
||||||
export function bumpCassetteStateSeq(): void {
|
|
||||||
if (!db) throw new Error('Database not initialized')
|
|
||||||
db.prepare(
|
|
||||||
'INSERT INTO meta (key, value) VALUES (?, ?) ' +
|
|
||||||
'ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(meta.value AS INTEGER) + 1 AS TEXT)'
|
|
||||||
).run('cassetteStateSeq', '1')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record the `created_at` just published, as the next publish's floor. */
|
|
||||||
export function markStatePublished(unixTimestamp: number): void {
|
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run(
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run(
|
||||||
String(unixTimestamp),
|
String(unixTimestamp),
|
||||||
|
|
@ -657,12 +531,11 @@ export function clearBunkerBinding(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forget the publish high-water mark. Called on a re-pair (new seed): the
|
* Reset the bootstrap-publish gate so the ATM re-publishes its
|
||||||
* next publish is then free to use the wall clock, which is what a fresh
|
* `bitspire-cassettes-state` hello-event. Called on a re-pair (new seed) so
|
||||||
* operator relationship wants. The state itself is republished on startup
|
* the new operator receives the spire's current state (aiolabs/bitspire#56).
|
||||||
* regardless, so the new operator always receives current counts.
|
|
||||||
*/
|
*/
|
||||||
export function resetStatePublishWatermark(): void {
|
export function resetBootstrapGate(): void {
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
|
||||||
}
|
}
|
||||||
|
|
@ -693,190 +566,108 @@ export function resetForRepair(): void {
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export type OperatorCassettesPayload = {
|
||||||
* Outcome of applying an operator-authored absolute config. Still the right
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
* shape for fee config, where the operator is the only writer of the value
|
|
||||||
* and a later event simply supersedes an earlier one. Cassette counts left
|
|
||||||
* this model in ADR-004 precisely because they had two writers.
|
|
||||||
*/
|
|
||||||
export type ApplyResult = { applied: true } | { applied: false; reason: string }
|
|
||||||
|
|
||||||
/** One operator-authored operation, as it arrives on the wire. */
|
|
||||||
export type CassetteOp = {
|
|
||||||
id: string
|
|
||||||
at: number
|
|
||||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
|
||||||
position: number
|
|
||||||
bills?: number
|
|
||||||
count?: number
|
|
||||||
denomination?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApplyOpsResult = {
|
export type ApplyResult =
|
||||||
/** Ids applied by this call. Empty when every op was already on file. */
|
| { applied: true }
|
||||||
applied: string[]
|
| { applied: false; reason: string }
|
||||||
/** Ids rejected, with why. These stay unapplied and unrecorded. */
|
|
||||||
rejected: { id: string; reason: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const CASSETTE_OP_TYPES = new Set(['refill', 'empty', 'recount', 'set_denomination'])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate one operation in isolation. Returns null when it is well-formed.
|
* Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56).
|
||||||
*
|
*
|
||||||
* Shape errors and unknown positions are treated the same way by the caller:
|
* Caller has already verified the event signature and decrypted the
|
||||||
* the op is neither applied nor recorded, so it stays pending on the
|
* content. This function:
|
||||||
* operator's dashboard. That is the honest outcome — it did not happen — and
|
*
|
||||||
* it beats recording it as applied to stop the noise, which would tell the
|
* 1. Rechecks replay-protection against `meta.lastKnownConfigCreatedAt`
|
||||||
* operator their refill landed when the notes are unaccounted for.
|
* (defense-in-depth — caller should have done this too).
|
||||||
|
* 2. Validates the payload's `positions` key set is *exactly* the set of
|
||||||
|
* positions currently in the `cassettes` table. The bay count is
|
||||||
|
* hardware-determined and can't be added to or removed from via this
|
||||||
|
* path; only the per-bay denomination and count are operator-mutable.
|
||||||
|
* 3. Validates per-entry `denomination` is a positive int, `count` is a
|
||||||
|
* non-negative int. **Duplicate denominations across positions are
|
||||||
|
* intentionally permitted** — real machines load multiple cassettes
|
||||||
|
* with the same denomination for cash-out throughput.
|
||||||
|
* 4. In a single SQLite transaction: updates `cassettes` rows by position
|
||||||
|
* (denomination + count both mutable per row) AND advances
|
||||||
|
* `meta.lastKnownConfigCreatedAt` to `eventCreatedAt`.
|
||||||
|
*
|
||||||
|
* Mid-write crashes roll back cleanly; on restart the same event is
|
||||||
|
* re-delivered by the relay and the watermark check drops it as already
|
||||||
|
* consumed (or the watermark is pre-event because the tx rolled back,
|
||||||
|
* and the apply runs again from scratch).
|
||||||
*/
|
*/
|
||||||
function validateCassetteOp(op: CassetteOp, knownPositions: Set<number>): string | null {
|
export function applyOperatorCassettesConfig(
|
||||||
if (typeof op.id !== 'string' || op.id.length === 0) return 'missing id'
|
payload: OperatorCassettesPayload,
|
||||||
if (!CASSETTE_OP_TYPES.has(op.type)) return `unknown type ${String(op.type)}`
|
eventCreatedAt: number
|
||||||
if (!Number.isInteger(op.position)) return `position must be an integer (got ${op.position})`
|
): ApplyResult {
|
||||||
if (!knownPositions.has(op.position)) return `unknown position ${op.position}`
|
|
||||||
if (!Number.isFinite(op.at)) return 'missing at'
|
|
||||||
|
|
||||||
if (op.type === 'refill') {
|
|
||||||
if (!Number.isInteger(op.bills) || (op.bills as number) <= 0) {
|
|
||||||
return `refill needs a positive integer bills (got ${op.bills})`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (op.type === 'recount') {
|
|
||||||
if (!Number.isInteger(op.count) || (op.count as number) < 0) {
|
|
||||||
return `recount needs a non-negative integer count (got ${op.count})`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (op.type === 'set_denomination') {
|
|
||||||
if (!Number.isInteger(op.denomination) || (op.denomination as number) <= 0) {
|
|
||||||
return `set_denomination needs a positive integer denomination (got ${op.denomination})`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply an operator's cassette operations, skipping any already on file.
|
|
||||||
*
|
|
||||||
* This replaces applying absolute counts. The operator authors what it DID —
|
|
||||||
* a refill in notes added, an empty, a recount, a denomination change — and
|
|
||||||
* this machine, which holds the physical notes, keeps the running total.
|
|
||||||
* Nobody but this process writes a count any more, so there is no second
|
|
||||||
* writer to lose a race to.
|
|
||||||
*
|
|
||||||
* Deltas are not idempotent and addressable events ARE re-delivered on every
|
|
||||||
* relay reconnect, so idempotency is carried explicitly: the operator mints an
|
|
||||||
* id per operation, `cassette_ops` records the ones applied, and a repeat is a
|
|
||||||
* no-op. That is also why there is no `created_at` watermark here any more.
|
|
||||||
* Under absolute counts the watermark was the only replay defence; with
|
|
||||||
* per-op ids it is strictly weaker than the dedup and would do active harm,
|
|
||||||
* because an event that arrives out of order may still carry an operation this
|
|
||||||
* machine has never seen.
|
|
||||||
*
|
|
||||||
* Applied oldest-first by `at`, ties broken by id so two operations stamped in
|
|
||||||
* the same second still order the same way on every machine. Ordering matters
|
|
||||||
* because a recount followed by a refill is not the same as the reverse.
|
|
||||||
*
|
|
||||||
* The whole batch runs in one SQLite transaction with the sequence bump, so a
|
|
||||||
* crash mid-apply rolls back to a coherent count and the next publish re-offers
|
|
||||||
* every op in the window.
|
|
||||||
*/
|
|
||||||
export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult {
|
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
const database = db
|
|
||||||
const result: ApplyOpsResult = { applied: [], rejected: [] }
|
|
||||||
if (ops.length === 0) return result
|
|
||||||
|
|
||||||
const knownPositions = new Set(
|
const watermark = getLastKnownConfigCreatedAt()
|
||||||
(database.prepare('SELECT position FROM cassettes').all() as { position: number }[]).map(
|
if (eventCreatedAt <= watermark) {
|
||||||
(r) => r.position
|
return {
|
||||||
)
|
applied: false,
|
||||||
)
|
reason: `event.created_at (${eventCreatedAt}) <= lastKnownConfigCreatedAt (${watermark})`,
|
||||||
const seen = database.prepare('SELECT 1 FROM cassette_ops WHERE id = ?')
|
|
||||||
|
|
||||||
const pending: CassetteOp[] = []
|
|
||||||
for (const op of ops) {
|
|
||||||
if (op && typeof op.id === 'string' && seen.get(op.id)) continue
|
|
||||||
const reason = validateCassetteOp(op, knownPositions)
|
|
||||||
if (reason) {
|
|
||||||
result.rejected.push({ id: op?.id ?? '<no id>', reason })
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
pending.push(op)
|
|
||||||
}
|
}
|
||||||
if (pending.length === 0) return result
|
|
||||||
|
|
||||||
pending.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
const currentRows = db
|
||||||
|
.prepare('SELECT position FROM cassettes')
|
||||||
|
.all() as { position: number }[]
|
||||||
|
const currentPositions = new Set(currentRows.map((r) => r.position))
|
||||||
|
const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k)))
|
||||||
|
|
||||||
const addBills = database.prepare(
|
if (currentPositions.size !== payloadPositions.size) {
|
||||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
|
return {
|
||||||
)
|
applied: false,
|
||||||
const setCount = database.prepare('UPDATE cassettes SET count = ? WHERE position = ?')
|
reason: `position count mismatch: state.db has ${currentPositions.size}, payload has ${payloadPositions.size}`,
|
||||||
const setDenomination = database.prepare(
|
|
||||||
'UPDATE cassettes SET denomination = ? WHERE position = ?'
|
|
||||||
)
|
|
||||||
const recordOp = database.prepare(
|
|
||||||
'INSERT INTO cassette_ops (id, position, op_type, bills, count, denomination, op_at, applied_at) ' +
|
|
||||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
||||||
)
|
|
||||||
const upsertMeta = database.prepare(
|
|
||||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
|
||||||
)
|
|
||||||
|
|
||||||
const appliedAt = Math.floor(Date.now() / 1000)
|
|
||||||
let sawRecount = false
|
|
||||||
|
|
||||||
database.transaction(() => {
|
|
||||||
for (const op of pending) {
|
|
||||||
if (op.type === 'refill') addBills.run(op.bills, op.position)
|
|
||||||
else if (op.type === 'empty') setCount.run(0, op.position)
|
|
||||||
else if (op.type === 'recount') {
|
|
||||||
setCount.run(op.count, op.position)
|
|
||||||
sawRecount = true
|
|
||||||
} else setDenomination.run(op.denomination, op.position)
|
|
||||||
|
|
||||||
recordOp.run(
|
|
||||||
op.id,
|
|
||||||
op.position,
|
|
||||||
op.type,
|
|
||||||
op.bills ?? null,
|
|
||||||
op.count ?? null,
|
|
||||||
op.denomination ?? null,
|
|
||||||
Math.floor(op.at),
|
|
||||||
appliedAt
|
|
||||||
)
|
|
||||||
result.applied.push(op.id)
|
|
||||||
}
|
}
|
||||||
bumpCassetteStateSeq()
|
}
|
||||||
// A recount is an operator opening the bay and counting it, which is
|
for (const p of currentPositions) {
|
||||||
// exactly what resolves an unverified count. Nothing else does: a refill
|
if (!payloadPositions.has(p)) {
|
||||||
// adds to a number still known to be wrong.
|
return { applied: false, reason: `payload missing position ${p}` }
|
||||||
if (sawRecount) upsertMeta.run('countsUncertainSince', '')
|
}
|
||||||
})()
|
}
|
||||||
|
for (const p of payloadPositions) {
|
||||||
|
if (!currentPositions.has(p)) {
|
||||||
|
return { applied: false, reason: `payload includes unknown position ${p}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
||||||
|
if (!Number.isInteger(entry.denomination) || entry.denomination <= 0) {
|
||||||
|
return {
|
||||||
|
applied: false,
|
||||||
|
reason: `denomination must be positive int (position ${posKey}, got ${entry.denomination})`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(entry.count) || entry.count < 0) {
|
||||||
|
return {
|
||||||
|
applied: false,
|
||||||
|
reason: `count must be non-negative int (position ${posKey}, got ${entry.count})`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCassette = db.prepare(
|
||||||
|
'UPDATE cassettes SET denomination = ?, count = ? WHERE position = ?'
|
||||||
|
)
|
||||||
|
const setWatermark = db.prepare('UPDATE meta SET value = ? WHERE key = ?')
|
||||||
|
|
||||||
|
const run = db.transaction(() => {
|
||||||
|
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
||||||
|
updateCassette.run(entry.denomination, entry.count, Number(posKey))
|
||||||
|
}
|
||||||
|
setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt')
|
||||||
|
})
|
||||||
|
|
||||||
|
run()
|
||||||
console.log(
|
console.log(
|
||||||
`[StateStore] Applied ${result.applied.length} cassette op(s)` +
|
`[StateStore] Applied operator cassettes config @ created_at=${eventCreatedAt} (${Object.keys(payload.positions).length} positions)`
|
||||||
(result.rejected.length ? `, rejected ${result.rejected.length}` : '')
|
|
||||||
)
|
)
|
||||||
return result
|
return { applied: true }
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ids most recently applied, newest first — the acknowledgement leg of
|
|
||||||
* the protocol.
|
|
||||||
*
|
|
||||||
* An addressable event gives its publisher no failure signal at all: the relay
|
|
||||||
* returns OK for an event it then discards, and a losing writer is never told.
|
|
||||||
* Echoing the ids back in this machine's own state document is the only way
|
|
||||||
* the operator can distinguish an operation that landed from one that was
|
|
||||||
* merely sent.
|
|
||||||
*/
|
|
||||||
export function getAppliedOpIds(limit = 50): string[] {
|
|
||||||
if (!db) throw new Error('Database not initialized')
|
|
||||||
const rows = db
|
|
||||||
.prepare('SELECT id FROM cassette_ops ORDER BY applied_at DESC, rowid DESC LIMIT ?')
|
|
||||||
.all(limit) as { id: string }[]
|
|
||||||
return rows.map((r) => r.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -961,7 +752,10 @@ export interface FeeConfigPayload {
|
||||||
*/
|
*/
|
||||||
const FEE_CAP_PER_DIRECTION = 0.15
|
const FEE_CAP_PER_DIRECTION = 0.15
|
||||||
|
|
||||||
export function applyFeeConfig(payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult {
|
export function applyFeeConfig(
|
||||||
|
payload: FeeConfigPayload,
|
||||||
|
eventCreatedAt: number
|
||||||
|
): ApplyResult {
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
const watermark = getLastKnownFeeConfigCreatedAt()
|
const watermark = getLastKnownFeeConfigCreatedAt()
|
||||||
|
|
@ -1054,7 +848,6 @@ export function setCassettes(
|
||||||
const row = rows[i]!
|
const row = rows[i]!
|
||||||
upsert.run(row.position ?? i + 1, row.denomination, row.count)
|
upsert.run(row.position ?? i + 1, row.denomination, row.count)
|
||||||
}
|
}
|
||||||
bumpCassetteStateSeq()
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1069,14 +862,11 @@ export function setCassettes(
|
||||||
*/
|
*/
|
||||||
export function updateCassetteCountByPosition(position: number, delta: number): void {
|
export function updateCassetteCountByPosition(position: number, delta: number): void {
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
const database = db
|
|
||||||
|
|
||||||
database.transaction(() => {
|
db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?').run(
|
||||||
database
|
delta,
|
||||||
.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?')
|
position
|
||||||
.run(delta, position)
|
)
|
||||||
bumpCassetteStateSeq()
|
|
||||||
})()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1089,14 +879,9 @@ export function getInventory(): Record<number, number> {
|
||||||
const rows = loadCassettes()
|
const rows = loadCassettes()
|
||||||
const inv: Record<number, number> = {}
|
const inv: Record<number, number> = {}
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
// Zero-count bays are KEPT. Dropping them made a drained machine
|
if (row.count > 0) {
|
||||||
// indistinguishable from an unconfigured one, and every caller reads an
|
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
|
||||||
// empty map as "I don't know, ask the hardware" — so the last non-empty
|
}
|
||||||
// snapshot stuck and the availability beacon went on advertising bills
|
|
||||||
// that had already been dispensed. An empty map now means exactly one
|
|
||||||
// thing: no cassettes are configured. Consumers already filter for
|
|
||||||
// `> 0` before offering a denomination (CashOutView, machine.ts).
|
|
||||||
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
|
|
||||||
}
|
}
|
||||||
return inv
|
return inv
|
||||||
}
|
}
|
||||||
|
|
@ -1241,15 +1026,7 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any dispense empties bays, whoever asked for it. `manual_dispense`
|
if (t.type === 'cash_out') {
|
||||||
// (operator remediation, via the command poller or a kind-21003 command)
|
|
||||||
// used to fall outside this branch: HAL decremented its in-memory bays but
|
|
||||||
// the rows here did not move, and on the next boot HAL re-seeds from these
|
|
||||||
// rows — so the machine came back believing it still held bills a customer
|
|
||||||
// had already been handed (#76). A remediation against a partly-dispensed
|
|
||||||
// original decrements again on purpose: the original only ever debited what
|
|
||||||
// physically left, and this is a second lot of bills leaving.
|
|
||||||
if (t.type === 'cash_out' || t.type === 'manual_dispense') {
|
|
||||||
// Decrement cassettes by ACTUALLY dispensed count (not requested).
|
// Decrement cassettes by ACTUALLY dispensed count (not requested).
|
||||||
// Position is the addressable unit (v9): duplicate denominations
|
// Position is the addressable unit (v9): duplicate denominations
|
||||||
// across bays are legal, so a denomination-keyed UPDATE would
|
// across bays are legal, so a denomination-keyed UPDATE would
|
||||||
|
|
@ -1279,10 +1056,6 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The counts moved, so the sequence must move with them, inside this
|
|
||||||
// same transaction. It rides in the state document as the operator's
|
|
||||||
// way to reject a regression without trusting either clock.
|
|
||||||
bumpCassetteStateSeq()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.type === 'cash_in') {
|
if (t.type === 'cash_in') {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/logo.png" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
<!--
|
<!--
|
||||||
Content Security Policy:
|
Content Security Policy:
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'"
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'"
|
||||||
/>
|
/>
|
||||||
<title>bitSpire ATM</title>
|
<title>Lamassu ATM</title>
|
||||||
<style>
|
<style>
|
||||||
/* Prevent text selection and context menu on kiosk */
|
/* Prevent text selection and context menu on kiosk */
|
||||||
* {
|
* {
|
||||||
|
|
@ -33,17 +33,12 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
/* Kiosk-only: lock overflow. The cursor is NOT hidden here — that is
|
/* Kiosk-only: lock overflow and hide cursor */
|
||||||
src/style.css's `.kiosk` rule, which main.ts applies at runtime unless
|
|
||||||
VITE_DEMO_TAG is set. This block can't make that distinction (static
|
|
||||||
HTML, and the CSP forbids an inline script to read the env), so a
|
|
||||||
`cursor: none` here would also blank the pointer on the public web
|
|
||||||
demo, where people drive the kiosk with a mouse. One owner for cursor
|
|
||||||
hiding, and it is the one that knows whether this is a real machine. */
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
cursor: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } 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 { useSessionSecurity } from '@/composables/useSessionSecurity'
|
|
||||||
import { setBranding } from '@/composables/useBranding'
|
import { setBranding } from '@/composables/useBranding'
|
||||||
import { classifyInitError } from '@/services/init-error'
|
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 PairingWizard from '@/components/PairingWizard.vue'
|
import PairingWizard from '@/components/PairingWizard.vue'
|
||||||
import LockedView from '@/views/LockedView.vue'
|
|
||||||
import ColorModeToggle from '@/components/ColorModeToggle.vue'
|
|
||||||
|
|
||||||
const atmStore = useAtmStore()
|
const atmStore = useAtmStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const { current: currentTheme, themes, colorMode } = useTheme()
|
const { current: currentTheme, themes, colorMode } = useTheme()
|
||||||
|
|
||||||
// ADR-003 session security: idle-inactivity re-lock (resets on touch) + an
|
|
||||||
// absolute hard session cap. Enforced here at the always-mounted shell so it
|
|
||||||
// spans the whole unlocked session, not just the idle view.
|
|
||||||
useSessionSecurity()
|
|
||||||
|
|
||||||
// When the machine re-locks after a transaction (access gate enabled), the
|
|
||||||
// router is still on /cash-in or /cash-out under the LockedView overlay. Reset
|
|
||||||
// it to home so that when the gate reopens to `idle`, IdleView shows — not the
|
|
||||||
// stale transaction view (e.g. a completed cash-out's collect screen). Runs
|
|
||||||
// while locked, so the router-view is hidden; no flicker. The disabled-gate path
|
|
||||||
// (never dwells in `locked`) still returns home via each view's isIdle watch.
|
|
||||||
watch(
|
|
||||||
() => atmStore.isLocked,
|
|
||||||
(locked) => {
|
|
||||||
if (locked && route.path !== '/') void router.push('/')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const debugExpanded = ref(false)
|
const debugExpanded = ref(false)
|
||||||
const isSupport = computed(() => route.path === '/support')
|
const isSupport = computed(() => route.path === '/support')
|
||||||
// Network detected dynamically from Lightning invoice prefix
|
// Network detected dynamically from Lightning invoice prefix
|
||||||
|
|
@ -131,7 +110,9 @@ onMounted(async () => {
|
||||||
// Same env → pairing-seed precedence as lightning.ts: on a blank-.env
|
// Same env → pairing-seed precedence as lightning.ts: on a blank-.env
|
||||||
// seed-driven machine the relay comes from the pairing transport, not env.
|
// seed-driven machine the relay comes from the pairing transport, not env.
|
||||||
const relayUrl =
|
const relayUrl =
|
||||||
config?.relayUrl || import.meta.env.VITE_RELAY_URL || resolved?.transport?.relays?.[0]
|
config?.relayUrl ||
|
||||||
|
import.meta.env.VITE_RELAY_URL ||
|
||||||
|
resolved?.transport?.relays?.[0]
|
||||||
if (signer && relayUrl) {
|
if (signer && relayUrl) {
|
||||||
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
||||||
await client.connect()
|
await client.connect()
|
||||||
|
|
@ -308,11 +289,6 @@ function toggleLiveServices() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Access gate (ADR-003): shown when the machine is healthy but locked,
|
|
||||||
below the init/maintenance gates above. Never renders when access
|
|
||||||
control is disabled (the machine never dwells in `locked`). -->
|
|
||||||
<LockedView v-else-if="atmStore.isLocked" />
|
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<router-view />
|
<router-view />
|
||||||
|
|
||||||
|
|
@ -364,10 +340,16 @@ function toggleLiveServices() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Light/dark toggle (production only — debug panel has this in dev) -->
|
<!-- Light/dark toggle (production only — debug panel has this in dev) -->
|
||||||
<ColorModeToggle
|
<Button
|
||||||
v-if="!atmStore.allowMockFallback"
|
v-if="!atmStore.allowMockFallback"
|
||||||
class="fixed bottom-3 right-3 z-50 lg:bottom-6 lg:right-6"
|
variant="outline"
|
||||||
/>
|
class="fixed bottom-3 right-3 lg:bottom-6 lg:right-6 z-50 h-10 px-3 py-1 text-sm rounded-lg lg:h-[7vh] lg:min-h-[70px] lg:px-8 lg:py-3 lg:text-2xl lg:rounded-xl gap-2 lg:gap-3"
|
||||||
|
@click="colorMode = colorMode === 'dark' ? 'light' : 'dark'"
|
||||||
|
>
|
||||||
|
<Sun v-if="colorMode === 'dark'" class="w-5 h-5 lg:w-7 lg:h-7" />
|
||||||
|
<Moon v-else class="w-5 h-5 lg:w-7 lg:h-7" />
|
||||||
|
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<!-- Debug overlay (dev only) -->
|
<!-- Debug overlay (dev only) -->
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* The Bolt Card loaded for this session (ADR-003 tap-to-enter): card label,
|
|
||||||
* and the card wallet's balance HIDDEN BY DEFAULT behind an eye toggle — a
|
|
||||||
* kiosk in a public space must not show a stranger's balance unasked. The
|
|
||||||
* revealed line mirrors the LNbits wallet page: sats, then the fiat
|
|
||||||
* equivalent in the wallet's own currency (Intl currency formatting), falling
|
|
||||||
* back to the ATM's fiat at its display rate when the card server priced
|
|
||||||
* nothing. Reveal state lives in the store and resets on re-lock.
|
|
||||||
*/
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useAtmStore } from '@/stores/atm'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Nfc, Eye, EyeOff } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const atmStore = useAtmStore()
|
|
||||||
const card = computed(() => atmStore.loadedBoltCard)
|
|
||||||
|
|
||||||
const label = computed(() => {
|
|
||||||
const c = card.value
|
|
||||||
if (!c) return ''
|
|
||||||
return c.cardName
|
|
||||||
? `${c.cardName} · ••${c.externalId.slice(-4)}`
|
|
||||||
: `Card ••${c.externalId.slice(-4)}`
|
|
||||||
})
|
|
||||||
const sats = computed(() =>
|
|
||||||
card.value ? new Intl.NumberFormat().format(card.value.balanceSats) : ''
|
|
||||||
)
|
|
||||||
const fiat = computed(() => {
|
|
||||||
const f = atmStore.loadedCardFiat
|
|
||||||
if (!f) return null
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: f.currency }).format(
|
|
||||||
f.amount
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return `${f.amount.toFixed(2)} ${f.currency}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="card"
|
|
||||||
class="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-2 text-left lg:gap-4 lg:px-6 lg:py-3"
|
|
||||||
>
|
|
||||||
<Nfc class="size-6 shrink-0 text-primary lg:size-8" />
|
|
||||||
<div class="flex min-w-0 flex-col leading-tight">
|
|
||||||
<span class="truncate text-xs uppercase tracking-wide text-muted-foreground lg:text-sm">
|
|
||||||
{{ label }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="atmStore.cardBalanceRevealed"
|
|
||||||
class="text-base font-semibold text-foreground lg:text-2xl"
|
|
||||||
>
|
|
||||||
{{ sats }} sats
|
|
||||||
<span v-if="fiat" class="ml-2 font-normal text-muted-foreground">≈ {{ fiat }}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="text-base font-semibold tracking-widest text-muted-foreground lg:text-2xl"
|
|
||||||
aria-label="Balance hidden"
|
|
||||||
>
|
|
||||||
••••••
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="ml-auto h-10 w-10 shrink-0 rounded-full lg:h-14 lg:w-14"
|
|
||||||
:aria-label="atmStore.cardBalanceRevealed ? 'Hide balance' : 'Show balance'"
|
|
||||||
@click="atmStore.toggleCardBalance()"
|
|
||||||
>
|
|
||||||
<EyeOff v-if="atmStore.cardBalanceRevealed" class="size-5 lg:size-7" />
|
|
||||||
<Eye v-else class="size-5 lg:size-7" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Light/dark toggle — the single reusable control for switching color mode.
|
|
||||||
*
|
|
||||||
* Kiosk-sized by default (large touch target for a public display). colorMode
|
|
||||||
* is global + persisted (toggles `.dark` on <html>, and dark mode pulls
|
|
||||||
* branding.json's dark palette + logo-dark.png), so this stays in sync
|
|
||||||
* wherever it's used. Position it via a fallthrough `class` on the consumer,
|
|
||||||
* e.g. `<ColorModeToggle class="fixed bottom-6 right-6" />`.
|
|
||||||
*/
|
|
||||||
import { useTheme } from '@/composables/useTheme'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Sun, Moon } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const { colorMode } = useTheme()
|
|
||||||
|
|
||||||
function toggle() {
|
|
||||||
colorMode.value = colorMode.value === 'dark' ? 'light' : 'dark'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
class="h-10 gap-2 rounded-lg px-3 py-1 text-sm lg:h-[7vh] lg:min-h-[70px] lg:gap-3 lg:rounded-xl lg:px-8 lg:py-3 lg:text-2xl"
|
|
||||||
:aria-label="colorMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
|
|
||||||
@click="toggle"
|
|
||||||
>
|
|
||||||
<Sun v-if="colorMode === 'dark'" class="h-5 w-5 lg:h-7 lg:w-7" />
|
|
||||||
<Moon v-else class="h-5 w-5 lg:h-7 lg:w-7" />
|
|
||||||
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { watch } from 'vue'
|
|
||||||
import { useEventListener, useIntervalFn } from '@vueuse/core'
|
|
||||||
import { useAtmStore } from '@/stores/atm'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session security timeouts for the ADR-003 access gate.
|
|
||||||
*
|
|
||||||
* Enforced at the DOM layer on purpose: the state machine can't observe raw
|
|
||||||
* pointer events, so an XState `after` delay can only count from state entry —
|
|
||||||
* it never resets on a screen touch and therefore can't measure *inactivity*.
|
|
||||||
* Two independent, fail-closed limits, both of which re-lock via the machine's
|
|
||||||
* root-level END_SESSION transition:
|
|
||||||
*
|
|
||||||
* - SOFT idle (SOFT_IDLE_MS): re-lock after this long with no trusted user
|
|
||||||
* input while on the idle menu. Resets on every genuine pointer/touch/key
|
|
||||||
* event. Scoped to `idle` so it never interrupts an in-flight cash-in/out
|
|
||||||
* (those carry their own, longer machine timeouts).
|
|
||||||
* - HARD cap (HARD_CAP_MS): re-lock this long after the session began,
|
|
||||||
* regardless of activity. Anchored to unlock time and never reset — an
|
|
||||||
* absolute ceiling a forgotten or relayed card can't hold open. Like the
|
|
||||||
* soft limit it only fires while on the idle menu: locking mid-transaction
|
|
||||||
* would strand stacked bills or an in-flight dispense, and every
|
|
||||||
* transaction already returns to `locked` on its own, so the cap simply
|
|
||||||
* takes effect the moment the machine is back at idle.
|
|
||||||
*
|
|
||||||
* Security properties:
|
|
||||||
* - Only `event.isTrusted` input resets the soft timer, so synthetic/scripted
|
|
||||||
* events in the renderer can't keep a session alive.
|
|
||||||
* - Limits are wall-clock deadline comparisons, not chained setTimeouts: a
|
|
||||||
* suspended/resumed renderer re-locks on the very next tick instead of
|
|
||||||
* silently extending the session past its deadline.
|
|
||||||
* - Both limits only ever *lock*. The machine's accessGateActive guard makes
|
|
||||||
* END_SESSION a no-op when the gate is off, so this is inert on a
|
|
||||||
* gate-disabled machine.
|
|
||||||
* - One-shot per session: after firing, it disarms until the next unlock, so
|
|
||||||
* a lock that (under dev bypass) doesn't take can't spin.
|
|
||||||
*
|
|
||||||
* Call once from the always-mounted App shell.
|
|
||||||
*/
|
|
||||||
const SOFT_IDLE_MS = 60_000 // 60s of no interaction on the idle menu
|
|
||||||
const HARD_CAP_MS = 600_000 // 10min absolute session ceiling
|
|
||||||
|
|
||||||
export function useSessionSecurity() {
|
|
||||||
const atm = useAtmStore()
|
|
||||||
|
|
||||||
// Wall-clock anchors. `null` sessionStartedAt == disarmed (no live session).
|
|
||||||
let sessionStartedAt: number | null = null
|
|
||||||
let lastActivityAt = 0
|
|
||||||
|
|
||||||
function arm() {
|
|
||||||
const now = Date.now()
|
|
||||||
sessionStartedAt = now
|
|
||||||
lastActivityAt = now
|
|
||||||
}
|
|
||||||
function disarm() {
|
|
||||||
sessionStartedAt = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arm on each locked → unlocked edge; disarm on lock. Anchored to the
|
|
||||||
// isLocked transition so the hard cap starts at unlock and does NOT restart
|
|
||||||
// when moving idle → cashIn → idle within a single session.
|
|
||||||
watch(
|
|
||||||
() => atm.isLocked,
|
|
||||||
(locked, wasLocked) => {
|
|
||||||
if (wasLocked && !locked && atm.accessControl.enabled) arm()
|
|
||||||
else if (locked) disarm()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Only genuine hardware input counts as activity. Passive + capture so it
|
|
||||||
// observes every touch without interfering with handling. useEventListener
|
|
||||||
// auto-detaches on unmount.
|
|
||||||
const onActivity = (e: Event) => {
|
|
||||||
if (e.isTrusted) lastActivityAt = Date.now()
|
|
||||||
}
|
|
||||||
for (const type of ['pointerdown', 'touchstart', 'keydown', 'wheel'] as const) {
|
|
||||||
useEventListener(document, type, onActivity, { passive: true, capture: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single 1s evaluator — cheap, and coarse enough that timer drift/suspend
|
|
||||||
// can only ever make it fire late-then-immediately, never early.
|
|
||||||
useIntervalFn(() => {
|
|
||||||
if (sessionStartedAt === null || atm.isLocked || !atm.accessControl.enabled) return
|
|
||||||
// Never lock out from under a transaction (the machine only accepts
|
|
||||||
// END_SESSION from idle anyway); the deadlines keep counting meanwhile, so
|
|
||||||
// an expired session re-locks on the first tick back at the menu.
|
|
||||||
if (atm.currentState !== 'idle') return
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
// Hard cap first — absolute, activity-independent.
|
|
||||||
if (now - sessionStartedAt >= HARD_CAP_MS) {
|
|
||||||
disarm() // one-shot; re-arms on next unlock
|
|
||||||
atm.endSession('session-cap')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Soft inactivity — resets on trusted input.
|
|
||||||
if (now - lastActivityAt >= SOFT_IDLE_MS) {
|
|
||||||
disarm()
|
|
||||||
atm.endSession('inactivity')
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
@ -5,26 +5,3 @@ import { twMerge } from 'tailwind-merge'
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a denomination/count list for the journal.
|
|
||||||
*
|
|
||||||
* Electron's console bridge stringifies every console argument on its way to
|
|
||||||
* the journal, so passing the array itself arrives as `[object Object]` and
|
|
||||||
* the numbers are lost. Interpolate one of these instead. See CLAUDE.md,
|
|
||||||
* "Useful invariants when debugging".
|
|
||||||
*/
|
|
||||||
export function formatBays(rows: { denomination: number; count?: number }[]): string {
|
|
||||||
if (!rows?.length) return '(none)'
|
|
||||||
// `count` is optional on a device-config cassette: a preset can declare the
|
|
||||||
// denomination a bay holds without claiming how many notes are in it. Show
|
|
||||||
// that as unknown rather than as zero, which would read as a drained bay.
|
|
||||||
return rows.map((r) => `${r.denomination}x${r.count ?? '?'}`).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same, for a denomination-keyed count map as `getInventory()` returns. */
|
|
||||||
export function formatInventory(inv: Record<number, number>): string {
|
|
||||||
const entries = Object.entries(inv ?? {})
|
|
||||||
if (!entries.length) return '(none)'
|
|
||||||
return entries.map(([denom, count]) => `${denom}x${count}`).join(' ')
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
import { initialContext, type ATMContext } from '@bitSpire/state-machine'
|
|
||||||
import type { LnbitsClient, LnbitsPayment } from '@bitSpire/lnbits'
|
|
||||||
import { createATMServices } from '../lightning'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The cash-out settlement watch (2026-09-22 regression).
|
|
||||||
*
|
|
||||||
* A one-tap Bolt Card Complete settles in about a second; subscribing over
|
|
||||||
* nostr takes several. When the watch was armed at display time the push —
|
|
||||||
* an ephemeral event with no replay — fired before anything listened, and the
|
|
||||||
* machine sat on a paid invoice until it timed out, taking the sats without
|
|
||||||
* dispensing. These pin the three defences: arm before the invoice is handed
|
|
||||||
* out, latch a settlement that still beats the consumer, and poll so a push
|
|
||||||
* that never arrives cannot strand a payment.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const BOLT11 = 'lnbc265u1p4t9gthpp5td44vd9a0s5er'
|
|
||||||
const HASH = 'aa'.repeat(32)
|
|
||||||
|
|
||||||
const paid = (preimage = 'PREIMAGE'): LnbitsPayment =>
|
|
||||||
({ payment_hash: HASH, status: 'success', preimage }) as LnbitsPayment
|
|
||||||
|
|
||||||
function makeLnbits(over: Partial<Record<string, unknown>> = {}) {
|
|
||||||
let pushTo: ((p: LnbitsPayment) => void) | null = null
|
|
||||||
const api = {
|
|
||||||
createInvoice: vi.fn(async () => ({ payment_request: BOLT11, payment_hash: HASH })),
|
|
||||||
subscribePayments: vi.fn(
|
|
||||||
async (_w: unknown, _f: unknown, onPush: (p: LnbitsPayment) => void) => {
|
|
||||||
pushTo = onPush
|
|
||||||
return 'sub-1'
|
|
||||||
}
|
|
||||||
),
|
|
||||||
getPayment: vi.fn(async (): Promise<LnbitsPayment | null> => null),
|
|
||||||
unsubscribe: vi.fn(async () => true),
|
|
||||||
decodePayment: vi.fn(async () => ({ payment_hash: HASH })),
|
|
||||||
...over,
|
|
||||||
}
|
|
||||||
return { api, push: (p: LnbitsPayment) => pushTo?.(p) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = (): ATMContext => ({ ...initialContext, satsAmount: 26_500, exchangeRate: 1325 })
|
|
||||||
|
|
||||||
const services = (l: { api: Record<string, unknown> }) =>
|
|
||||||
createATMServices(vi.fn(), l.api as unknown as LnbitsClient, 'wallet-1')
|
|
||||||
|
|
||||||
beforeEach(() => vi.useFakeTimers())
|
|
||||||
afterEach(() => vi.useRealTimers())
|
|
||||||
|
|
||||||
describe('cash-out settlement watch', () => {
|
|
||||||
it('is armed before the invoice is handed out, without decoding it back', async () => {
|
|
||||||
const l = makeLnbits()
|
|
||||||
const invoice = await services(l).generateInvoice(ctx())
|
|
||||||
|
|
||||||
expect(invoice).toBe(BOLT11)
|
|
||||||
// Armed during generateInvoice, not later at display time.
|
|
||||||
expect(l.api.subscribePayments).toHaveBeenCalledTimes(1)
|
|
||||||
expect(l.api.subscribePayments.mock.calls[0]![1]).toMatchObject({ payment_hash: HASH })
|
|
||||||
// The hash came from the creation response, so no round trip to recover it.
|
|
||||||
expect(l.api.decodePayment).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('replays a settlement that beat the consumer (the race that lost payments)', async () => {
|
|
||||||
const l = makeLnbits()
|
|
||||||
const svc = services(l)
|
|
||||||
const invoice = await svc.generateInvoice(ctx())
|
|
||||||
|
|
||||||
// Card pays before the machine reaches displayingInvoice.
|
|
||||||
l.push(paid())
|
|
||||||
|
|
||||||
const onPaid = vi.fn()
|
|
||||||
svc.watchInvoice(invoice, onPaid)
|
|
||||||
await vi.advanceTimersByTimeAsync(0)
|
|
||||||
|
|
||||||
expect(onPaid).toHaveBeenCalledWith('PREIMAGE')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('delivers a push that arrives while the consumer is attached', async () => {
|
|
||||||
const l = makeLnbits()
|
|
||||||
const svc = services(l)
|
|
||||||
const invoice = await svc.generateInvoice(ctx())
|
|
||||||
|
|
||||||
const onPaid = vi.fn()
|
|
||||||
svc.watchInvoice(invoice, onPaid)
|
|
||||||
l.push(paid('LATER'))
|
|
||||||
|
|
||||||
expect(onPaid).toHaveBeenCalledWith('LATER')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('settles from the poll when no push ever arrives', async () => {
|
|
||||||
const l = makeLnbits()
|
|
||||||
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
|
|
||||||
const svc = services(l)
|
|
||||||
const invoice = await svc.generateInvoice(ctx())
|
|
||||||
|
|
||||||
const onPaid = vi.fn()
|
|
||||||
svc.watchInvoice(invoice, onPaid)
|
|
||||||
expect(onPaid).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(7_000)
|
|
||||||
expect(onPaid).toHaveBeenCalledWith('VIA-POLL')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('polls even when arming the subscription fails', async () => {
|
|
||||||
const l = makeLnbits()
|
|
||||||
l.api.subscribePayments = vi.fn(async () => {
|
|
||||||
throw new Error('relay down')
|
|
||||||
})
|
|
||||||
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
|
|
||||||
const svc = services(l)
|
|
||||||
const invoice = await svc.generateInvoice(ctx())
|
|
||||||
|
|
||||||
const onPaid = vi.fn()
|
|
||||||
svc.watchInvoice(invoice, onPaid)
|
|
||||||
await vi.advanceTimersByTimeAsync(7_000)
|
|
||||||
|
|
||||||
expect(onPaid).toHaveBeenCalledWith('VIA-POLL')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports each settlement once, whichever path saw it first', async () => {
|
|
||||||
const l = makeLnbits()
|
|
||||||
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
|
|
||||||
const svc = services(l)
|
|
||||||
const invoice = await svc.generateInvoice(ctx())
|
|
||||||
|
|
||||||
const onPaid = vi.fn()
|
|
||||||
svc.watchInvoice(invoice, onPaid)
|
|
||||||
l.push(paid('VIA-PUSH'))
|
|
||||||
await vi.advanceTimersByTimeAsync(20_000)
|
|
||||||
|
|
||||||
expect(onPaid).toHaveBeenCalledTimes(1)
|
|
||||||
expect(onPaid).toHaveBeenCalledWith('VIA-PUSH')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stops polling and unsubscribes when the transaction ends', async () => {
|
|
||||||
const l = makeLnbits()
|
|
||||||
const svc = services(l)
|
|
||||||
const invoice = await svc.generateInvoice(ctx())
|
|
||||||
const stop = svc.watchInvoice(invoice, vi.fn())
|
|
||||||
|
|
||||||
stop()
|
|
||||||
expect(l.api.unsubscribe).toHaveBeenCalledWith(undefined, 'sub-1')
|
|
||||||
|
|
||||||
const pollsAfterStop = (l.api.getPayment as ReturnType<typeof vi.fn>).mock.calls.length
|
|
||||||
await vi.advanceTimersByTimeAsync(30_000)
|
|
||||||
expect((l.api.getPayment as ReturnType<typeof vi.fn>).mock.calls.length).toBe(pollsAfterStop)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { npubEncode, nprofileEncode } from 'nostr-tools/nip19'
|
|
||||||
import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize'
|
|
||||||
import { parseBoltcardLnurlw } from '../boltcard'
|
|
||||||
|
|
||||||
const SALT = 'test-salt'
|
|
||||||
const HEX_A = 'aa'.repeat(32)
|
|
||||||
const HEX_B = 'bb'.repeat(32)
|
|
||||||
const NPUB_A = npubEncode(HEX_A)
|
|
||||||
const NPUB_B = npubEncode(HEX_B)
|
|
||||||
|
|
||||||
describe('access authorize (ADR-003)', () => {
|
|
||||||
describe('open enrollment (prototype)', () => {
|
|
||||||
it('grants any valid npub as user', async () => {
|
|
||||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
|
|
||||||
salt: SALT,
|
|
||||||
openEnrollment: true,
|
|
||||||
})
|
|
||||||
expect(out.status).toBe('granted')
|
|
||||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a stray / non-npub QR', async () => {
|
|
||||||
const out = await authorize({ kind: 'npub', npub: 'https://example.com/not-an-npub' }, [], {
|
|
||||||
salt: SALT,
|
|
||||||
openEnrollment: true,
|
|
||||||
})
|
|
||||||
expect(out.status).toBe('denied')
|
|
||||||
expect(out).toMatchObject({ reason: 'not a valid npub' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts a `nostr:` URI prefix (with surrounding whitespace)', async () => {
|
|
||||||
const out = await authorize({ kind: 'npub', npub: ` nostr:${NPUB_A}\n` }, [], {
|
|
||||||
salt: SALT,
|
|
||||||
openEnrollment: true,
|
|
||||||
})
|
|
||||||
expect(out.status).toBe('granted')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts an nprofile and resolves to the same identity as its npub', async () => {
|
|
||||||
const nprofile = nprofileEncode({ pubkey: HEX_A, relays: ['wss://relay.example'] })
|
|
||||||
const viaNprofile = await authorize({ kind: 'npub', npub: nprofile }, [], {
|
|
||||||
salt: SALT,
|
|
||||||
openEnrollment: true,
|
|
||||||
})
|
|
||||||
const viaNpub = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
|
|
||||||
salt: SALT,
|
|
||||||
openEnrollment: true,
|
|
||||||
})
|
|
||||||
expect(viaNprofile.status).toBe('granted')
|
|
||||||
// Same underlying pubkey → same credential hash.
|
|
||||||
expect(viaNprofile.credentialIdHash).toBe(viaNpub.credentialIdHash)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('allow-list (closed)', () => {
|
|
||||||
it('denies an unlisted npub when not open-enrollment', async () => {
|
|
||||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], { salt: SALT })
|
|
||||||
expect(out).toMatchObject({ status: 'denied', reason: 'not authorized' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('grants a listed npub with its role, no PIN', async () => {
|
|
||||||
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'operator' }
|
|
||||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [entry], { salt: SALT })
|
|
||||||
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not match npub B against npub A entry', async () => {
|
|
||||||
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'user' }
|
|
||||||
const out = await authorize({ kind: 'npub', npub: NPUB_B }, [entry], { salt: SALT })
|
|
||||||
expect(out.status).toBe('denied')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('PIN second factor', () => {
|
|
||||||
const makeEntry = async (): Promise<AllowListEntry> => ({
|
|
||||||
idHash: await hashId(HEX_A, SALT),
|
|
||||||
role: 'user',
|
|
||||||
pinHash: await hashPin('1234', SALT),
|
|
||||||
})
|
|
||||||
|
|
||||||
it('asks for a PIN when one is configured and none supplied', async () => {
|
|
||||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
|
||||||
salt: SALT,
|
|
||||||
})
|
|
||||||
expect(out.status).toBe('pin-required')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('grants on correct PIN', async () => {
|
|
||||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
|
||||||
salt: SALT,
|
|
||||||
pin: '1234',
|
|
||||||
})
|
|
||||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('denies on wrong PIN', async () => {
|
|
||||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
|
||||||
salt: SALT,
|
|
||||||
pin: '9999',
|
|
||||||
})
|
|
||||||
expect(out).toMatchObject({ status: 'denied', reason: 'incorrect PIN' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('challenge credential (v2 seam)', () => {
|
|
||||||
it('is not yet authorized', async () => {
|
|
||||||
const out = await authorize({ kind: 'challenge', pubkey: HEX_A, nonce: 'n', sig: 's' }, [], {
|
|
||||||
salt: SALT,
|
|
||||||
openEnrollment: true,
|
|
||||||
})
|
|
||||||
expect(out.status).toBe('denied')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('boltcard credential (tap-to-enter)', () => {
|
|
||||||
it('open-enrollment grants any card as user', async () => {
|
|
||||||
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, [], {
|
|
||||||
salt: SALT,
|
|
||||||
openEnrollment: true,
|
|
||||||
})
|
|
||||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a card with no external_id', async () => {
|
|
||||||
const out = await authorize({ kind: 'boltcard', externalId: '' }, [], {
|
|
||||||
salt: SALT,
|
|
||||||
openEnrollment: true,
|
|
||||||
})
|
|
||||||
expect(out).toMatchObject({ status: 'denied', reason: 'not a valid card' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allow-list matches by external_id hash', async () => {
|
|
||||||
const idHash = await hashId('abc123', SALT)
|
|
||||||
const list: AllowListEntry[] = [{ idHash, role: 'operator' }]
|
|
||||||
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, list, {
|
|
||||||
salt: SALT,
|
|
||||||
openEnrollment: false,
|
|
||||||
})
|
|
||||||
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('parseBoltcardLnurlw', () => {
|
|
||||||
it('extracts external_id from a tapped lnurlw', () => {
|
|
||||||
expect(
|
|
||||||
parseBoltcardLnurlw('lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEAD&c=BEEF')
|
|
||||||
).toEqual({ externalId: 'abc123' })
|
|
||||||
})
|
|
||||||
it('strips a lightning: prefix and accepts https', () => {
|
|
||||||
expect(parseBoltcardLnurlw('lightning:lnurlw://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({
|
|
||||||
externalId: 'xyz',
|
|
||||||
})
|
|
||||||
expect(parseBoltcardLnurlw('https://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({
|
|
||||||
externalId: 'xyz',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
it('returns null for non-card / malformed input', () => {
|
|
||||||
expect(parseBoltcardLnurlw('https://h/something/else')).toBeNull()
|
|
||||||
expect(parseBoltcardLnurlw('not a url')).toBeNull()
|
|
||||||
expect(parseBoltcardLnurlw('')).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
/**
|
|
||||||
* Credential authorization (ADR-003).
|
|
||||||
*
|
|
||||||
* Decides whether a presented credential may unlock the terminal. Matching is
|
|
||||||
* against a local allow-list of salted identity hashes, optionally behind a
|
|
||||||
* PIN second factor; `openEnrollment` admits any well-formed credential when
|
|
||||||
* the allow-list has no match (the current posture — see the ADR amendment:
|
|
||||||
* with it on, the gate is a convenience, not a security boundary). Only
|
|
||||||
* salted hashes are compared, stored or logged — never the raw id (KYC-free).
|
|
||||||
*
|
|
||||||
* Identity id per scan kind:
|
|
||||||
* - boltcard → the card's boltcards `external_id` (parsed locally from the
|
|
||||||
* lnurlw; the SUN p/c are NOT verified here — that happens at
|
|
||||||
* payment time, where the voucher is actually spent)
|
|
||||||
* - npub → hex pubkey (decoded, canonical)
|
|
||||||
* - challenge → v2 seam, not yet authorized
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { decode as nip19Decode } from 'nostr-tools/nip19'
|
|
||||||
import type { AccessRole } from '@bitSpire/state-machine'
|
|
||||||
import type { AccessScan } from './types'
|
|
||||||
|
|
||||||
/** One authorized identity. `idHash` = hashId(<canonical id>, salt). */
|
|
||||||
export interface AllowListEntry {
|
|
||||||
idHash: string
|
|
||||||
role: AccessRole
|
|
||||||
/** When set, access requires this PIN (hashPin(pin, salt)) as a 2nd factor. */
|
|
||||||
pinHash?: string
|
|
||||||
/** Optional operator-facing label (never a person's real identity). */
|
|
||||||
label?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthorizeOptions {
|
|
||||||
/** Per-machine salt for all hashing. */
|
|
||||||
salt: string
|
|
||||||
/** Admit any valid credential when the allow-list has no match (prototype). */
|
|
||||||
openEnrollment?: boolean
|
|
||||||
/** PIN supplied on the follow-up call after a `pin-required` outcome. */
|
|
||||||
pin?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Three outcomes, so the caller can drive a two-step flow:
|
|
||||||
* - `granted` → send ACCESS_GRANTED
|
|
||||||
* - `pin-required` → prompt for a PIN, then call authorize() again with `pin`
|
|
||||||
* - `denied` → send ACCESS_DENIED(reason)
|
|
||||||
*/
|
|
||||||
export type AuthorizeOutcome =
|
|
||||||
| { status: 'granted'; role: AccessRole; credentialIdHash: string }
|
|
||||||
| { status: 'pin-required'; credentialIdHash: string }
|
|
||||||
| { status: 'denied'; credentialIdHash: string; reason: string }
|
|
||||||
|
|
||||||
/** Salted SHA-256, hex-encoded. */
|
|
||||||
async function sha256Hex(input: string): Promise<string> {
|
|
||||||
const data = new TextEncoder().encode(input)
|
|
||||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
|
||||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const hashId = (id: string, salt: string): Promise<string> => sha256Hex(`id:${salt}:${id}`)
|
|
||||||
export const hashPin = (pin: string, salt: string): Promise<string> =>
|
|
||||||
sha256Hex(`pin:${salt}:${pin}`)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a scan to a canonical identity string, or `null` if malformed.
|
|
||||||
* npub is decoded to its hex pubkey so npub/hex forms compare equal and a
|
|
||||||
* stray (non-npub) string is rejected.
|
|
||||||
*/
|
|
||||||
function canonicalId(scan: AccessScan): string | null {
|
|
||||||
if (scan.kind === 'boltcard') return scan.externalId || null
|
|
||||||
if (scan.kind === 'challenge') return null // v2 — handled separately
|
|
||||||
// Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI
|
|
||||||
// prefix, and `nprofile1…` (npub + relay hints, what many clients export).
|
|
||||||
const raw = scan.npub.trim().replace(/^nostr:/i, '')
|
|
||||||
try {
|
|
||||||
const decoded = nip19Decode(raw)
|
|
||||||
if (decoded.type === 'npub' && typeof decoded.data === 'string') {
|
|
||||||
return decoded.data
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
decoded.type === 'nprofile' &&
|
|
||||||
decoded.data &&
|
|
||||||
typeof (decoded.data as { pubkey?: unknown }).pubkey === 'string'
|
|
||||||
) {
|
|
||||||
return (decoded.data as { pubkey: string }).pubkey
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide whether a scanned credential is authorized.
|
|
||||||
* Always resolves (never throws) so the caller can uniformly react.
|
|
||||||
*/
|
|
||||||
export async function authorize(
|
|
||||||
scan: AccessScan,
|
|
||||||
allowList: AllowListEntry[],
|
|
||||||
opts: AuthorizeOptions
|
|
||||||
): Promise<AuthorizeOutcome> {
|
|
||||||
if (scan.kind === 'challenge') {
|
|
||||||
return {
|
|
||||||
status: 'denied',
|
|
||||||
credentialIdHash: '',
|
|
||||||
reason: 'challenge-response credentials not yet supported',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = canonicalId(scan)
|
|
||||||
if (!id) {
|
|
||||||
return {
|
|
||||||
status: 'denied',
|
|
||||||
credentialIdHash: '',
|
|
||||||
reason:
|
|
||||||
scan.kind === 'npub'
|
|
||||||
? 'not a valid npub'
|
|
||||||
: scan.kind === 'boltcard'
|
|
||||||
? 'not a valid card'
|
|
||||||
: 'invalid credential',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentialIdHash = await hashId(id, opts.salt)
|
|
||||||
const entry = allowList.find((e) => e.idHash === credentialIdHash)
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
if (opts.openEnrollment) {
|
|
||||||
return { status: 'granted', role: 'user', credentialIdHash }
|
|
||||||
}
|
|
||||||
return { status: 'denied', credentialIdHash, reason: 'not authorized' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// No PIN configured → single-factor grant.
|
|
||||||
if (!entry.pinHash) {
|
|
||||||
return { status: 'granted', role: entry.role, credentialIdHash }
|
|
||||||
}
|
|
||||||
|
|
||||||
// PIN configured but not yet supplied → ask for it.
|
|
||||||
if (opts.pin === undefined) {
|
|
||||||
return { status: 'pin-required', credentialIdHash }
|
|
||||||
}
|
|
||||||
|
|
||||||
// PIN supplied → verify.
|
|
||||||
const pinHash = await hashPin(opts.pin, opts.salt)
|
|
||||||
if (pinHash !== entry.pinHash) {
|
|
||||||
return { status: 'denied', credentialIdHash, reason: 'incorrect PIN' }
|
|
||||||
}
|
|
||||||
return { status: 'granted', role: entry.role, credentialIdHash }
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* Bolt Card lnurlw parsing for the access gate (ADR-003).
|
|
||||||
*
|
|
||||||
* The tap-to-enter flow reads a Bolt Card's `lnurlw://…/scan/<external_id>?p=&c=`
|
|
||||||
* voucher and needs the `external_id` for the session identity — WITHOUT hitting
|
|
||||||
* the server (that would burn the single-use SUN p/c we want to reuse at
|
|
||||||
* Complete). So this is a purely local parse: extract the id from the URL path;
|
|
||||||
* the p/c ride along in the stored lnurlw and are only spent at payment time.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Extract a Bolt Card's `external_id` from its tapped lnurlw. Null if not one. */
|
|
||||||
export function parseBoltcardLnurlw(lnurlw: string): { externalId: string } | null {
|
|
||||||
let s = lnurlw.trim()
|
|
||||||
if (!s) return null
|
|
||||||
if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length)
|
|
||||||
const https = s.replace(/^lnurlw:\/\//i, 'https://').replace(/^lnurl:\/\//i, 'https://')
|
|
||||||
if (!/^https:\/\//i.test(https)) return null
|
|
||||||
try {
|
|
||||||
const u = new URL(https)
|
|
||||||
// …/boltcards/api/v1/scan/<external_id>
|
|
||||||
const m = u.pathname.match(/\/scan\/([^/?#]+)/)
|
|
||||||
if (!m || !m[1]) return null
|
|
||||||
return { externalId: decodeURIComponent(m[1]) }
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/**
|
|
||||||
* Access-control module surface (ADR-003).
|
|
||||||
*
|
|
||||||
* Credential capture is NOT here: the reader is the main-process NFC service
|
|
||||||
* (`electron/nfc-service.ts`, over the `nfc:card-tapped` IPC), and the store
|
|
||||||
* turns a tapped lnurlw into a `boltcard` scan. This module only decides —
|
|
||||||
* parse the card, hash the identity, match the allow-list.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type { AccessScan, AccessRole } from './types'
|
|
||||||
export { authorize, hashId, hashPin } from './authorize'
|
|
||||||
export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize'
|
|
||||||
export { parseBoltcardLnurlw } from './boltcard'
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
/**
|
|
||||||
* Access-control credential types (ADR-003).
|
|
||||||
*
|
|
||||||
* A credential is captured elsewhere — for Bolt Cards by the main-process NFC
|
|
||||||
* reader (`electron/nfc-service.ts`), which hands the tapped lnurlw to the
|
|
||||||
* store over IPC — and arrives here as a RAW `AccessScan`. Hashing and
|
|
||||||
* authorization (and the optional PIN second factor) happen in `authorize.ts`,
|
|
||||||
* so raw ids never leave this layer (KYC-free).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AccessRole } from '@bitSpire/state-machine'
|
|
||||||
|
|
||||||
export type { AccessRole }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A raw credential. Discriminated union so new factors are additive:
|
|
||||||
* - `boltcard` — what ships: a tapped Bolt Card, already verified by the
|
|
||||||
* card server's `/session` (the tap's SUN was spent there).
|
|
||||||
* `externalId` is the identity as the server returned it;
|
|
||||||
* it is the only thing hashed/authorized. The session's
|
|
||||||
* payment steps stay in the store, never in this layer.
|
|
||||||
* - `npub` — a Nostr pubkey (bare npub, `nostr:` URI or nprofile).
|
|
||||||
* No reader emits it today; kept, with the PIN second
|
|
||||||
* factor, for a future non-card credential.
|
|
||||||
* - `challenge` — card-signed nonce, challenge-response. v2 seam; not yet
|
|
||||||
* authorized.
|
|
||||||
*/
|
|
||||||
export type AccessScan =
|
|
||||||
| { kind: 'boltcard'; externalId: string }
|
|
||||||
| { kind: 'npub'; npub: string }
|
|
||||||
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string }
|
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ATMServices } from '@bitSpire/state-machine'
|
import type { ATMServices } from '@bitSpire/state-machine'
|
||||||
import { formatBays } from '@/lib/utils'
|
|
||||||
|
|
||||||
export interface CassetteConfig {
|
export interface CassetteConfig {
|
||||||
denomination: number
|
denomination: number
|
||||||
|
|
@ -127,7 +126,7 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
|
|
||||||
const atmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = {
|
const atmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = {
|
||||||
dispenseCash: async (amounts) => {
|
dispenseCash: async (amounts) => {
|
||||||
console.log(`[HAL] Dispensing: ${formatBays(amounts)}`)
|
console.log('[HAL] Dispensing:', amounts)
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import type { ATMServices, ATMContext } from '@bitSpire/state-machine'
|
||||||
|
|
||||||
// Import Electron types
|
// Import Electron types
|
||||||
import type {} from '@/types/electron'
|
import type {} from '@/types/electron'
|
||||||
import { formatBays, formatInventory } from '@/lib/utils'
|
|
||||||
|
|
||||||
// Check if we're running in Electron (electronAPI is exposed via preload)
|
// Check if we're running in Electron (electronAPI is exposed via preload)
|
||||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||||
|
|
@ -218,7 +217,7 @@ export interface LightningBackend {
|
||||||
}): Promise<{ paymentRequest: string; paymentHash?: string }>
|
}): Promise<{ paymentRequest: string; paymentHash?: string }>
|
||||||
payInvoice(
|
payInvoice(
|
||||||
bolt11: string,
|
bolt11: string,
|
||||||
amountSats: number
|
amountSats: number,
|
||||||
): Promise<{ success: boolean; preimage?: string; error?: string }>
|
): Promise<{ success: boolean; preimage?: string; error?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,12 +434,12 @@ export async function initializeLightningServices(options?: {
|
||||||
console.log(
|
console.log(
|
||||||
'[Lightning] Relay(s):',
|
'[Lightning] Relay(s):',
|
||||||
relays.join(', '),
|
relays.join(', '),
|
||||||
envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)'
|
envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)',
|
||||||
)
|
)
|
||||||
console.log(
|
console.log(
|
||||||
'[Lightning] LNbits server pubkey:',
|
'[Lightning] LNbits server pubkey:',
|
||||||
CONFIG.lnbitsServerPubkey || '(not configured)',
|
CONFIG.lnbitsServerPubkey || '(not configured)',
|
||||||
envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : ''
|
envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : '',
|
||||||
)
|
)
|
||||||
// Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS
|
// Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS
|
||||||
// (env). An empty set disables the fees/operator-config services → the machine
|
// (env). An empty set disables the fees/operator-config services → the machine
|
||||||
|
|
@ -450,7 +449,7 @@ export async function initializeLightningServices(options?: {
|
||||||
'[Lightning] Operator pubkey(s):',
|
'[Lightning] Operator pubkey(s):',
|
||||||
CONFIG.operatorPubkeys.length
|
CONFIG.operatorPubkeys.length
|
||||||
? CONFIG.operatorPubkeys.join(', ') + ' (env)'
|
? CONFIG.operatorPubkeys.join(', ') + ' (env)'
|
||||||
: '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)'
|
: '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)',
|
||||||
)
|
)
|
||||||
|
|
||||||
// Strict mode: validate the RESOLVED config is production-ready (no
|
// Strict mode: validate the RESOLVED config is production-ready (no
|
||||||
|
|
@ -474,7 +473,7 @@ export async function initializeLightningServices(options?: {
|
||||||
if (!CONFIG.lnbitsServerPubkey) {
|
if (!CONFIG.lnbitsServerPubkey) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' +
|
'[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' +
|
||||||
'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).'
|
'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -543,11 +542,7 @@ export async function initializeLightningServices(options?: {
|
||||||
const mc = await lnbits.getMachineConfig()
|
const mc = await lnbits.getMachineConfig()
|
||||||
if (mc.operator_pubkey) {
|
if (mc.operator_pubkey) {
|
||||||
CONFIG.operatorPubkeys = [mc.operator_pubkey]
|
CONFIG.operatorPubkeys = [mc.operator_pubkey]
|
||||||
console.log(
|
console.log('[Lightning] Operator pubkey(s):', mc.operator_pubkey, '(server-delivered, #70 P1)')
|
||||||
'[Lightning] Operator pubkey(s):',
|
|
||||||
mc.operator_pubkey,
|
|
||||||
'(server-delivered, #70 P1)'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (mc.fee_config && isElectron && window.electronAPI) {
|
if (mc.fee_config && isElectron && window.electronAPI) {
|
||||||
// Persist the server-delivered fee config so atm.ts's awaiting-fees gate
|
// Persist the server-delivered fee config so atm.ts's awaiting-fees gate
|
||||||
|
|
@ -560,17 +555,17 @@ export async function initializeLightningServices(options?: {
|
||||||
cashOutFeeFraction: mc.fee_config.cash_out_fee_fraction,
|
cashOutFeeFraction: mc.fee_config.cash_out_fee_fraction,
|
||||||
schemaVersion: mc.fee_config.schema_version,
|
schemaVersion: mc.fee_config.schema_version,
|
||||||
},
|
},
|
||||||
mc.created_at
|
mc.created_at,
|
||||||
)
|
)
|
||||||
console.log(
|
console.log(
|
||||||
'[Lightning] Server-delivered fee config:',
|
'[Lightning] Server-delivered fee config:',
|
||||||
applied.applied ? 'applied' : `skipped (${applied.reason})`
|
applied.applied ? 'applied' : `skipped (${applied.reason})`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[Lightning] get_machine_config unavailable; falling back to env/kind-30078 for operator config:',
|
'[Lightning] get_machine_config unavailable; falling back to env/kind-30078 for operator config:',
|
||||||
(e as Error).message
|
(e as Error).message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -676,7 +671,7 @@ export async function initializeLightningServices(options?: {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
lnbits,
|
lnbits,
|
||||||
lnbitsWalletId
|
lnbitsWalletId,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -711,142 +706,13 @@ export async function initializeLightningServices(options?: {
|
||||||
/**
|
/**
|
||||||
* Create ATMServices implementation using the LNbits nostr-transport.
|
* Create ATMServices implementation using the LNbits nostr-transport.
|
||||||
*/
|
*/
|
||||||
export function createATMServices(
|
function createATMServices(
|
||||||
onPaymentSuccess: (preimage: string) => void,
|
onPaymentSuccess: (preimage: string) => void,
|
||||||
lnbits: LnbitsClient,
|
lnbits: LnbitsClient,
|
||||||
lnbitsWalletId: string
|
lnbitsWalletId: string,
|
||||||
): ATMServices {
|
): ATMServices {
|
||||||
const onPaymentCallback = onPaymentSuccess
|
const onPaymentCallback = onPaymentSuccess
|
||||||
|
|
||||||
// ── Cash-out settlement watch ───────────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* A watch on one cash-out invoice, armed the moment the invoice exists and
|
|
||||||
* consumed later by the state machine's `displayingInvoice` actor.
|
|
||||||
*
|
|
||||||
* Arming at creation rather than at display closes a race that swallowed
|
|
||||||
* real payments on 2026-09-22 (sintra). Subscribing costs a nostr round
|
|
||||||
* trip — about four seconds against a remote relay — while a one-tap Bolt
|
|
||||||
* Card Complete settles in roughly one. The settlement push is an ephemeral
|
|
||||||
* event with no replay, so it fired before anything was listening and the
|
|
||||||
* machine sat on a paid invoice until it timed out, taking the sats without
|
|
||||||
* dispensing. The old two-tap flow only worked because fumbling with the
|
|
||||||
* card covered the gap.
|
|
||||||
*
|
|
||||||
* Three defences, in order: the invoice is not returned until its watch is
|
|
||||||
* armed; a settlement that still beats the UI is latched and replayed when
|
|
||||||
* the consumer attaches; and a poll runs alongside the subscription so a
|
|
||||||
* lost or unsent push cannot strand a payment either way.
|
|
||||||
*/
|
|
||||||
interface InvoiceWatch {
|
|
||||||
paymentHash: string
|
|
||||||
subId: string | null
|
|
||||||
/** Preimage seen before a consumer attached; replayed on attach. */
|
|
||||||
settled: string | null
|
|
||||||
consumer: ((preimage: string) => void) | null
|
|
||||||
poll: ReturnType<typeof setInterval> | null
|
|
||||||
released: boolean
|
|
||||||
}
|
|
||||||
const invoiceWatches = new Map<string, InvoiceWatch>()
|
|
||||||
|
|
||||||
// Backstop cadence. One cheap RPC; on the money path a little extra relay
|
|
||||||
// traffic is worth far more than a payment that lands with no cash.
|
|
||||||
const SETTLEMENT_POLL_MS = 6_000
|
|
||||||
|
|
||||||
function stopInvoiceWatchPoll(watch: InvoiceWatch): void {
|
|
||||||
if (watch.poll) {
|
|
||||||
clearInterval(watch.poll)
|
|
||||||
watch.poll = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deliver a settlement exactly once, to the consumer or into the latch. */
|
|
||||||
function settleInvoiceWatch(watch: InvoiceWatch, preimage: string, via: string): void {
|
|
||||||
if (watch.released || watch.settled) return
|
|
||||||
watch.settled = preimage
|
|
||||||
stopInvoiceWatchPoll(watch)
|
|
||||||
console.log(`[ATM Service] Invoice paid (${via})!`)
|
|
||||||
watch.consumer?.(preimage)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startInvoiceWatchPoll(watch: InvoiceWatch): void {
|
|
||||||
let inFlight = false
|
|
||||||
watch.poll = setInterval(() => {
|
|
||||||
if (inFlight || watch.settled || watch.released) return
|
|
||||||
inFlight = true
|
|
||||||
void lnbits
|
|
||||||
.getPayment(watch.paymentHash)
|
|
||||||
.then((payment) => {
|
|
||||||
if (payment?.status === 'success') {
|
|
||||||
settleInvoiceWatch(watch, payment.preimage ?? 'payment-confirmed', 'poll')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* transport blip — the next tick retries */
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
inFlight = false
|
|
||||||
})
|
|
||||||
}, SETTLEMENT_POLL_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Arm the watch for a freshly created invoice. Resolves once it is live. */
|
|
||||||
async function armInvoiceWatch(bolt11: string, paymentHash: string): Promise<void> {
|
|
||||||
const startedAt = Date.now()
|
|
||||||
const watch: InvoiceWatch = {
|
|
||||||
paymentHash,
|
|
||||||
subId: null,
|
|
||||||
settled: null,
|
|
||||||
consumer: null,
|
|
||||||
poll: null,
|
|
||||||
released: false,
|
|
||||||
}
|
|
||||||
invoiceWatches.set(bolt11, watch)
|
|
||||||
try {
|
|
||||||
// walletId omitted: payment_hash is the natural primary key for "wait
|
|
||||||
// for THIS invoice to settle." Under path B
|
|
||||||
// (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment to
|
|
||||||
// the operator's wallet, so a subscription scoped to the ATM's
|
|
||||||
// pre-override wallet_id would AND-filter the settlement out and never
|
|
||||||
// fire. With wallet_id omitted, lnbits resolves the wallet from
|
|
||||||
// get_standalone_payment(payment_hash) and ownership-checks against the
|
|
||||||
// auth'd account — works on both pre/post-override wallets.
|
|
||||||
// Coordination log 2026-05-31T18:50Z (lnbits) for the confirmation,
|
|
||||||
// §18:35Z for the joint smoke that surfaced the bug.
|
|
||||||
watch.subId = await lnbits.subscribePayments(
|
|
||||||
undefined,
|
|
||||||
{ payment_hash: paymentHash, max_seconds: 600 },
|
|
||||||
(push) => {
|
|
||||||
if (push.payment_hash !== paymentHash || push.status !== 'success') return
|
|
||||||
settleInvoiceWatch(watch, push.preimage ?? 'payment-confirmed', 'LNbits push')
|
|
||||||
},
|
|
||||||
(reason) => console.log(`[ATM Service] Settlement subscription closed (${reason})`)
|
|
||||||
)
|
|
||||||
console.log(
|
|
||||||
`[ATM Service] Settlement watch armed in ${Date.now() - startedAt} ms ` +
|
|
||||||
`(hash ${paymentHash.slice(0, 12)}…)`
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
// The poll below then carries settlement on its own, which is exactly
|
|
||||||
// why it runs whether or not the subscription came up.
|
|
||||||
console.error('[ATM Service] Settlement subscribe failed — polling only:', e)
|
|
||||||
}
|
|
||||||
startInvoiceWatchPoll(watch)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tear a watch down: the transaction ended, one way or another. */
|
|
||||||
function releaseInvoiceWatch(bolt11: string): void {
|
|
||||||
const watch = invoiceWatches.get(bolt11)
|
|
||||||
if (!watch) return
|
|
||||||
watch.released = true
|
|
||||||
watch.consumer = null
|
|
||||||
stopInvoiceWatchPoll(watch)
|
|
||||||
invoiceWatches.delete(bolt11)
|
|
||||||
if (watch.subId) {
|
|
||||||
// wallet_id omitted to match the subscribePayments call above.
|
|
||||||
void lnbits.unsubscribe(undefined, watch.subId).catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
* 3b.4: ndebit cash-in path removed. CashInView.vue ignores this
|
* 3b.4: ndebit cash-in path removed. CashInView.vue ignores this
|
||||||
|
|
@ -940,7 +806,7 @@ export function createATMServices(
|
||||||
if (onPaymentCallback) {
|
if (onPaymentCallback) {
|
||||||
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`)
|
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
// 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.link_id)
|
||||||
|
|
@ -983,14 +849,15 @@ export function createATMServices(
|
||||||
* matches machine fiat_code
|
* matches machine fiat_code
|
||||||
* - `type: "cash_out"` / `source: "bitspire"` — discriminators
|
* - `type: "cash_out"` / `source: "bitspire"` — discriminators
|
||||||
*/
|
*/
|
||||||
// (see armInvoiceWatch below — the watch is armed before this resolves)
|
|
||||||
generateInvoice: async (context: ATMContext): Promise<string> => {
|
generateInvoice: async (context: ATMContext): Promise<string> => {
|
||||||
const amountSats = context.satsAmount
|
const amountSats = context.satsAmount
|
||||||
// Cash-out: satsAmount = principal + commission. principal is
|
// Cash-out: satsAmount = principal + commission. principal is
|
||||||
// derived from the raw market rate (no commission baked in) so a
|
// derived from the raw market rate (no commission baked in) so a
|
||||||
// consumer can independently audit the split.
|
// consumer can independently audit the split.
|
||||||
const principalSats =
|
const principalSats =
|
||||||
context.exchangeRate > 0 ? Math.floor((context.fiatCents / 100) * context.exchangeRate) : 0
|
context.exchangeRate > 0
|
||||||
|
? Math.floor((context.fiatCents / 100) * context.exchangeRate)
|
||||||
|
: 0
|
||||||
const feeSats = Math.max(0, amountSats - principalSats)
|
const feeSats = Math.max(0, amountSats - principalSats)
|
||||||
console.log(
|
console.log(
|
||||||
'[ATM Service] Generating invoice — gross',
|
'[ATM Service] Generating invoice — gross',
|
||||||
|
|
@ -1030,17 +897,6 @@ export function createATMServices(
|
||||||
if (!payment.payment_request) {
|
if (!payment.payment_request) {
|
||||||
throw new Error('LNbits createInvoice returned empty payment_request')
|
throw new Error('LNbits createInvoice returned empty payment_request')
|
||||||
}
|
}
|
||||||
// Arm the settlement watch BEFORE the invoice reaches the screen, and
|
|
||||||
// take the payment hash from the response we already have rather than
|
|
||||||
// spending a round trip decoding it back off the bolt11. See
|
|
||||||
// armInvoiceWatch for why the timing matters.
|
|
||||||
if (payment.payment_hash) {
|
|
||||||
await armInvoiceWatch(payment.payment_request, payment.payment_hash)
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
'[ATM Service] createInvoice returned no payment_hash — settlement watch will arm late'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return payment.payment_request
|
return payment.payment_request
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1093,7 +949,7 @@ export function createATMServices(
|
||||||
* Dispense cash (mock for development)
|
* Dispense cash (mock for development)
|
||||||
*/
|
*/
|
||||||
dispenseCash: async (amounts) => {
|
dispenseCash: async (amounts) => {
|
||||||
console.log(`[ATM Service] Dispensing cash: ${formatBays(amounts)}`)
|
console.log('[ATM Service] Dispensing cash:', amounts)
|
||||||
|
|
||||||
// In production, this would interface with the Rust HAL
|
// In production, this would interface with the Rust HAL
|
||||||
// For now, simulate dispense delay
|
// For now, simulate dispense delay
|
||||||
|
|
@ -1207,30 +1063,15 @@ export function createATMServices(
|
||||||
* push, filtered by payment_hash. Returns a cleanup function.
|
* push, filtered by payment_hash. Returns a cleanup function.
|
||||||
*/
|
*/
|
||||||
watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => {
|
watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => {
|
||||||
|
console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...')
|
||||||
|
|
||||||
if (!invoice.toLowerCase().startsWith('ln')) {
|
if (!invoice.toLowerCase().startsWith('ln')) {
|
||||||
console.error('[ATM Service] Invalid invoice format - expected BOLT11')
|
console.error('[ATM Service] Invalid invoice format - expected BOLT11')
|
||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...')
|
|
||||||
|
|
||||||
const armed = invoiceWatches.get(invoice)
|
|
||||||
if (armed) {
|
|
||||||
armed.consumer = callback
|
|
||||||
// Settled between arming and display (a one-tap card pull can beat the
|
|
||||||
// state transition): replay the latched settlement instead of waiting
|
|
||||||
// on a push that has already come and gone.
|
|
||||||
if (armed.settled) {
|
|
||||||
const preimage = armed.settled
|
|
||||||
queueMicrotask(() => callback(preimage))
|
|
||||||
}
|
|
||||||
return () => releaseInvoiceWatch(invoice)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No armed watch: an invoice this service didn't create. Recover the
|
|
||||||
// hash over the wire and arm now. This is the pre-2026-09-22 behaviour
|
|
||||||
// and carries the race that arming-at-creation fixes, so say so.
|
|
||||||
console.warn('[ATM Service] No armed settlement watch for this invoice — arming late')
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
let subId: string | null = null
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const decoded = await lnbits.decodePayment(invoice)
|
const decoded = await lnbits.decodePayment(invoice)
|
||||||
|
|
@ -1240,18 +1081,36 @@ export function createATMServices(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
await armInvoiceWatch(invoice, paymentHash)
|
// walletId omitted: payment_hash is the natural primary key for
|
||||||
const late = invoiceWatches.get(invoice)
|
// "wait for THIS invoice to settle." Under path B
|
||||||
if (!late || cancelled) return
|
// (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment
|
||||||
late.consumer = callback
|
// to the operator's wallet, so a subscription scoped to the ATM's
|
||||||
if (late.settled) callback(late.settled)
|
// pre-override wallet_id would AND-filter the settlement out and
|
||||||
|
// never fire. With wallet_id omitted, lnbits resolves the wallet
|
||||||
|
// from get_standalone_payment(payment_hash) and ownership-checks
|
||||||
|
// against the auth'd account — works on both pre/post-override
|
||||||
|
// wallets. Coordination log 2026-05-31T18:50Z (lnbits) for the
|
||||||
|
// confirmation, §18:35Z for the joint smoke that surfaced the bug.
|
||||||
|
subId = await lnbits.subscribePayments(
|
||||||
|
undefined,
|
||||||
|
{ payment_hash: paymentHash, max_seconds: 600 },
|
||||||
|
(push) => {
|
||||||
|
if (push.payment_hash !== paymentHash) return
|
||||||
|
if (push.status !== 'success') return
|
||||||
|
console.log('[ATM Service] Invoice paid (LNbits push)!')
|
||||||
|
callback(push.preimage ?? 'payment-confirmed')
|
||||||
|
},
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[ATM Service] LNbits watchInvoice failed:', e)
|
console.error('[ATM Service] LNbits watchInvoice failed:', e)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
releaseInvoiceWatch(invoice)
|
if (subId) {
|
||||||
|
// wallet_id omitted to match the subscribePayments call above.
|
||||||
|
void lnbits.unsubscribe(undefined, subId).catch(() => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1270,7 +1129,7 @@ export function createATMServices(
|
||||||
20: 50, // 50 x $20 bills = $1000 capacity
|
20: 50, // 50 x $20 bills = $1000 capacity
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[ATM Service] Inventory: ${formatInventory(inventory)}`)
|
console.log('[ATM Service] Inventory:', inventory)
|
||||||
return inventory
|
return inventory
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,25 @@
|
||||||
/**
|
/**
|
||||||
* Operator-config consumer (aiolabs/lamassu-next#56, v2 per bitspire ADR-004).
|
* Operator-config consumer (aiolabs/lamassu-next#56).
|
||||||
*
|
*
|
||||||
* Subscribes to operator-published kind-30078 events carrying cassette
|
* Subscribes to operator-published kind-30078 events carrying cassette
|
||||||
* OPERATIONS — a refill, an empty, a recount, a denomination change —
|
* config updates, validates + applies them to state.db, and hot-reloads
|
||||||
* applies the ones it has not already seen, and hot-reloads the HAL
|
* the HAL dispenser. Also publishes a one-shot ATM-state hello-event on
|
||||||
* dispenser. It also publishes this machine's cassette state, which is
|
* first boot so the operator dashboard (satmachineadmin) can auto-populate
|
||||||
* what populates the operator dashboard's bay rows.
|
* `cassette_configs` rows for this machine.
|
||||||
*
|
|
||||||
* The operator used to publish absolute counts and this machine applied them
|
|
||||||
* outright. Both sides wrote the same value over a transport that never tells
|
|
||||||
* a writer it lost: a dashboard form loaded before a dispense silently
|
|
||||||
* discarded that dispense, and neither side could detect it. This machine now
|
|
||||||
* owns the count — it holds the notes — and the operator says what it did.
|
|
||||||
*
|
*
|
||||||
* Architecture (see ~/dev/coordination/log.md entries on 2026-05-30):
|
* Architecture (see ~/dev/coordination/log.md entries on 2026-05-30):
|
||||||
*
|
*
|
||||||
* - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:<machine_id>"]`,
|
* - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:<machine_id>"]`,
|
||||||
* `["p", <atm_npub>]`, NIP-44 v2 encrypted content, author = operator pubkey
|
* `["p", <atm_npub>]`, NIP-44 v2 encrypted content, author = operator pubkey
|
||||||
* - ATM state: `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`,
|
* - ATM bootstrap: `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`,
|
||||||
* `["p", <operator_pubkey>]`, NIP-44 v2 encrypted content, author = ATM pubkey
|
* `["p", <operator_pubkey>]`, NIP-44 v2 encrypted content, author = ATM pubkey
|
||||||
*
|
*
|
||||||
* The ATM's hex pubkey serves as `<machine_id>` — globally unique, no
|
* The ATM's hex pubkey serves as `<machine_id>` — globally unique, no
|
||||||
* extra provisioning step required.
|
* extra provisioning step required.
|
||||||
*
|
*
|
||||||
* The ATM publishes its state on startup, after every change to the bays, and
|
* v1 only publishes the one-shot bootstrap hello-event. The continuous
|
||||||
* on a heartbeat. It was once a single hello-event gated on a one-shot flag,
|
* ATM-state reverse channel (publish on every count change + heartbeat)
|
||||||
* which left the operator validating against a layout the machine no longer
|
* is v2 territory.
|
||||||
* had (#94), and left a dispense published during a relay outage lost for good.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -41,36 +34,9 @@ import type {} from '@/types/electron'
|
||||||
|
|
||||||
const KIND_NIP78 = 30078
|
const KIND_NIP78 = 30078
|
||||||
|
|
||||||
/** The wire schema this machine speaks. Operations, not counts (ADR-004). */
|
|
||||||
const CASSETTE_SCHEMA_VERSION = 2
|
|
||||||
|
|
||||||
/** One operator-authored operation, as it arrives on the wire. */
|
|
||||||
type CassetteOp = {
|
|
||||||
id: string
|
|
||||||
at: number
|
|
||||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
|
||||||
position: number
|
|
||||||
bills?: number
|
|
||||||
count?: number
|
|
||||||
denomination?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Accept operator events stamped up to this many seconds in the future. */
|
/** Accept operator events stamped up to this many seconds in the future. */
|
||||||
const MAX_FUTURE_SKEW_S = 60
|
const MAX_FUTURE_SKEW_S = 60
|
||||||
|
|
||||||
/**
|
|
||||||
* Republish the cassette state on this interval even when nothing changed.
|
|
||||||
*
|
|
||||||
* A publish is a single fire-and-forget event with no retry: if the relay is
|
|
||||||
* unreachable at the moment of a dispense, that update is simply gone and the
|
|
||||||
* operator's view stays wrong until the next customer happens to buy cash. A
|
|
||||||
* relay also acknowledges an event it then discards, so a publish that returns
|
|
||||||
* cleanly is not proof of anything. The heartbeat is what makes the channel
|
|
||||||
* self-healing, and it is also the only way an out-of-band edit to the table
|
|
||||||
* (atm-tui, direct SQL) ever reaches the operator.
|
|
||||||
*/
|
|
||||||
const STATE_HEARTBEAT_MS = 5 * 60 * 1000
|
|
||||||
|
|
||||||
const operatorConfigDTag = (machineId: string) => `bitspire-cassettes:${machineId}`
|
const operatorConfigDTag = (machineId: string) => `bitspire-cassettes:${machineId}`
|
||||||
const atmStateDTag = (machineId: string) => `bitspire-cassettes-state:${machineId}`
|
const atmStateDTag = (machineId: string) => `bitspire-cassettes-state:${machineId}`
|
||||||
|
|
||||||
|
|
@ -79,7 +45,7 @@ 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 our state. */
|
/** Signer for the ATM identity. Decrypts operator events + signs the bootstrap. */
|
||||||
signer: Signer
|
signer: Signer
|
||||||
/** 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[]
|
||||||
|
|
@ -117,15 +83,12 @@ export async function startOperatorConfigService(
|
||||||
const api = window.electronAPI
|
const api = window.electronAPI
|
||||||
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
const machineId = cfg.machineId ?? cfg.signer.pubkey
|
||||||
|
|
||||||
// Announce current state on every start. This used to be gated on a
|
// Bootstrap hello-event on first boot (best-effort — failure leaves the
|
||||||
// one-shot "have we said hello" flag, so any later change to the layout —
|
// gate null so the next boot retries).
|
||||||
// a reseed, an atm-tui edit, direct SQL — was never published and the
|
|
||||||
// operator's dashboard kept validating against a bay set that no longer
|
|
||||||
// existed (#94). Best-effort; the heartbeat below is the safety net.
|
|
||||||
try {
|
try {
|
||||||
await publishCassettesState(cfg, api, machineId)
|
await maybePublishBootstrap(cfg, api, machineId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[OperatorConfig] Startup cassettes-state publish failed:', err)
|
console.warn('[OperatorConfig] Bootstrap publish failed (will retry next boot):', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to operator-published cassette config events.
|
// Subscribe to operator-published cassette config events.
|
||||||
|
|
@ -147,19 +110,10 @@ export async function startOperatorConfigService(
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
console.log(`[OperatorConfig] Subscribed: d=${dTag} sub=${subscriptionId}`)
|
console.log('[OperatorConfig] Subscribed:', { dTag, subscriptionId })
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
|
||||||
publishCassettesState(cfg, api, machineId).catch((err) =>
|
|
||||||
console.warn('[OperatorConfig] cassettes-state heartbeat failed:', err)
|
|
||||||
)
|
|
||||||
}, STATE_HEARTBEAT_MS)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stop: () => {
|
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
||||||
clearInterval(heartbeat)
|
|
||||||
cfg.nostrClient.unsubscribe(subscriptionId)
|
|
||||||
},
|
|
||||||
publishCassettesState: () =>
|
publishCassettesState: () =>
|
||||||
publishCassettesState(cfg, api, machineId)
|
publishCassettesState(cfg, api, machineId)
|
||||||
.then(() => {})
|
.then(() => {})
|
||||||
|
|
@ -185,13 +139,17 @@ async function handleOperatorConfigEvent(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. There is deliberately no `created_at` watermark here any more.
|
// 2. Replay protection — drop stale events. NIP-78 replaceable events
|
||||||
// Under absolute counts it was the only replay defence, and it cost us:
|
// DO get re-delivered on reconnect/restart; without this check, the
|
||||||
// an event re-delivered out of order was dropped whole, operations
|
// ATM would re-apply the same payload on every boot and clobber any
|
||||||
// included. Idempotency now rides on the operations themselves — the
|
// cash-out decrements that landed between operator publishes.
|
||||||
// operator mints an id per op and this machine records the ones it
|
const watermark = await api.getLastKnownConfigCreatedAt()
|
||||||
// applied — which is strictly stronger, because it survives an event
|
if (event.created_at <= watermark) {
|
||||||
// that mixes operations we have seen with ones we have not.
|
console.log(
|
||||||
|
`[OperatorConfig] Stale event dropped (created_at=${event.created_at} <= watermark=${watermark})`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Clock-skew defense — reject events stamped too far in the future.
|
// 3. Clock-skew defense — reject events stamped too far in the future.
|
||||||
// Limits damage from a leaked operator nsec future-stamping a fake
|
// Limits damage from a leaked operator nsec future-stamping a fake
|
||||||
|
|
@ -205,7 +163,7 @@ async function handleOperatorConfigEvent(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Decrypt content (NIP-44 v2).
|
// 4. Decrypt content (NIP-44 v2).
|
||||||
let parsed: { schema_version?: number; ops?: unknown }
|
let parsed: { positions: Record<string, { denomination: number; count: number }> }
|
||||||
try {
|
try {
|
||||||
const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
|
const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
|
||||||
parsed = JSON.parse(plaintext) as typeof parsed
|
parsed = JSON.parse(plaintext) as typeof parsed
|
||||||
|
|
@ -213,36 +171,22 @@ async function handleOperatorConfigEvent(
|
||||||
console.error('[OperatorConfig] Decrypt/parse failed:', err)
|
console.error('[OperatorConfig] Decrypt/parse failed:', err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.ops)) {
|
if (!parsed || typeof parsed !== 'object' || !parsed.positions) {
|
||||||
// A v1 operator publishing absolute counts lands here and is ignored.
|
console.error('[OperatorConfig] Payload missing `positions` field')
|
||||||
// That direction fails safe: the machine keeps its own counts, which it
|
|
||||||
// is now the only writer of, and simply will not dispense notes it
|
|
||||||
// believes it lacks. The opposite — applying a count from a form loaded
|
|
||||||
// before a dispense — is what ADR-004 exists to stop.
|
|
||||||
console.error('[OperatorConfig] Payload missing `ops` array — dropped')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const ops = parsed.ops as CassetteOp[]
|
|
||||||
|
|
||||||
// 5. Apply the ones we have not seen, in one transaction with the sequence
|
// 5. Atomic apply (cassettes + meta watermark) via IPC. The state-store
|
||||||
// bump. No `created_at` watermark: each op carries an operator-minted id
|
// function re-validates watermark + position key-set equality +
|
||||||
// and the machine records what it applied, so a re-delivered event is a
|
// per-entry types inside the SQLite transaction. Duplicate
|
||||||
// no-op on its own merits. The watermark would be strictly weaker and
|
// denominations across positions are allowed — real machines load
|
||||||
// actively harmful — an event arriving out of order can still carry an
|
// N cassettes of the same denomination for cash-out throughput.
|
||||||
// operation this machine has never seen.
|
const result = await api.applyOperatorCassettesConfig(
|
||||||
const result = await api.applyOperatorCassetteOps(ops)
|
{ positions: parsed.positions },
|
||||||
for (const bad of result.rejected) {
|
event.created_at
|
||||||
console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`)
|
)
|
||||||
}
|
if (!result.applied) {
|
||||||
if (result.applied.length === 0) {
|
console.warn('[OperatorConfig] Apply rejected:', result.reason)
|
||||||
console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`)
|
|
||||||
// Still republish: the operator learns from our applied_ops echo that
|
|
||||||
// earlier operations landed, and an event carrying nothing new can be the
|
|
||||||
// first one we successfully answer after a relay outage.
|
|
||||||
const machineIdNoop = cfg.machineId ?? cfg.signer.pubkey
|
|
||||||
await publishCassettesState(cfg, api, machineIdNoop).catch((err) =>
|
|
||||||
console.warn('[OperatorConfig] post-apply cassettes-state republish failed:', err)
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,8 +194,8 @@ async function handleOperatorConfigEvent(
|
||||||
// picks up the new per-position mapping. state.db is already updated;
|
// picks up the new per-position mapping. state.db is already updated;
|
||||||
// HAL re-init failure means the renderer's persistedInventory may be
|
// HAL re-init failure means the renderer's persistedInventory may be
|
||||||
// ahead of the HAL until next service restart — log loudly but don't
|
// ahead of the HAL until next service restart — log loudly but don't
|
||||||
// unwind the state.db apply (the operation happened physically; HAL
|
// unwind the state.db apply (the operator wants their config landed;
|
||||||
// can catch up).
|
// HAL can catch up).
|
||||||
const cassettesAfter = await api.loadCassettes()
|
const cassettesAfter = await api.loadCassettes()
|
||||||
const halResult = await api.halReloadCassettes(
|
const halResult = await api.halReloadCassettes(
|
||||||
cassettesAfter.map((c) => ({
|
cassettesAfter.map((c) => ({
|
||||||
|
|
@ -263,7 +207,9 @@ async function handleOperatorConfigEvent(
|
||||||
if (!halResult.ok) {
|
if (!halResult.ok) {
|
||||||
console.error('[OperatorConfig] HAL reload failed:', halResult.error)
|
console.error('[OperatorConfig] HAL reload failed:', halResult.error)
|
||||||
}
|
}
|
||||||
console.log(`[OperatorConfig] Applied ops: ${result.applied.join(', ')}`)
|
console.log(
|
||||||
|
`[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
|
// Republish our resulting cassette state so the operator's view reflects the
|
||||||
// applied config (the "on cassette reload" case). Different d-tag from the
|
// applied config (the "on cassette reload" case). Different d-tag from the
|
||||||
|
|
@ -278,11 +224,11 @@ async function handleOperatorConfigEvent(
|
||||||
* Publish the ATM's current cassette state as a replaceable kind-30078 event
|
* Publish the ATM's current cassette state as a replaceable kind-30078 event
|
||||||
* (`bitspire-cassettes-state:<machineId>`), NIP-44-encrypted to the operator.
|
* (`bitspire-cassettes-state:<machineId>`), NIP-44-encrypted to the operator.
|
||||||
* Replaceable → latest wins; the operator consumes every update. Call after a
|
* Replaceable → latest wins; the operator consumes every update. Call after a
|
||||||
* dispense, on a cassette reload, at startup and on a heartbeat, so the
|
* dispense and on a cassette reload so the operator view tracks reality, not
|
||||||
* operator view tracks reality (coord 2026-06-21 / lamassu-next#56).
|
* the frozen bootstrap snapshot (coord 2026-06-21 / lamassu-next#56).
|
||||||
*
|
*
|
||||||
* Returns whether an event was published (false when there are no cassettes /
|
* NOT gated on the bootstrap flag — this is the live update. Returns whether an
|
||||||
* no operator).
|
* event was published (false when there are no cassettes / no operator).
|
||||||
*/
|
*/
|
||||||
async function publishCassettesState(
|
async function publishCassettesState(
|
||||||
cfg: OperatorConfigServiceConfig,
|
cfg: OperatorConfigServiceConfig,
|
||||||
|
|
@ -298,36 +244,7 @@ async function publishCassettesState(
|
||||||
for (const c of cassettes) {
|
for (const c of cassettes) {
|
||||||
positions[String(c.position)] = { denomination: c.denomination, count: c.count }
|
positions[String(c.position)] = { denomination: c.denomination, count: c.count }
|
||||||
}
|
}
|
||||||
// Additive field: an operator on the old consumer reads `positions` and
|
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions }))
|
||||||
// ignores this, so it needs no coordinated release. When set, the counts
|
|
||||||
// above are the machine's best guess, not a measurement.
|
|
||||||
const countsUncertainSince = await api.getCountsUncertainSince()
|
|
||||||
|
|
||||||
// `applied_ops` is the acknowledgement leg. An addressable event gives its
|
|
||||||
// publisher no failure signal — the relay returns OK for an event it then
|
|
||||||
// discards — so echoing the ids back is the only way the operator can tell
|
|
||||||
// an operation that landed from one that was merely sent. `seq` lets a
|
|
||||||
// reader reject a regression without trusting a clock: `created_at` has
|
|
||||||
// second granularity and ties break on event id, so it cannot order two
|
|
||||||
// reports from the same second.
|
|
||||||
const [appliedOps, seq] = await Promise.all([api.getAppliedOpIds(), api.getCassetteStateSeq()])
|
|
||||||
const payload: Record<string, unknown> = {
|
|
||||||
schema_version: CASSETTE_SCHEMA_VERSION,
|
|
||||||
positions,
|
|
||||||
seq,
|
|
||||||
applied_ops: appliedOps,
|
|
||||||
}
|
|
||||||
if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince
|
|
||||||
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
|
|
||||||
|
|
||||||
// Force the stamp strictly above our last one. Addressable events are ordered
|
|
||||||
// by `created_at` at second granularity, ties broken by lowest event id, and
|
|
||||||
// the relay keeps one and silently drops the other while acknowledging both.
|
|
||||||
// So two publishes inside one second would leave the winner decided by a hash,
|
|
||||||
// permanently — and a clock that stepped backwards would make every report
|
|
||||||
// from this machine disappear. Neither failure is visible from here.
|
|
||||||
const lastPublished = (await api.getLastStatePublishedAt()) ?? 0
|
|
||||||
const createdAt = Math.max(Math.floor(Date.now() / 1000), lastPublished + 1)
|
|
||||||
|
|
||||||
const dTag = atmStateDTag(machineId)
|
const dTag = atmStateDTag(machineId)
|
||||||
const event = await createSignedEvent(cfg.signer, {
|
const event = await createSignedEvent(cfg.signer, {
|
||||||
|
|
@ -337,14 +254,34 @@ async function publishCassettesState(
|
||||||
['d', dTag],
|
['d', dTag],
|
||||||
['p', operatorPubkey],
|
['p', operatorPubkey],
|
||||||
],
|
],
|
||||||
created_at: createdAt,
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
})
|
})
|
||||||
|
|
||||||
await cfg.nostrClient.publish(event)
|
await cfg.nostrClient.publish(event)
|
||||||
await api.markStatePublished(createdAt)
|
console.log('[OperatorConfig] cassettes-state published:', { dTag, eventId: event.id })
|
||||||
console.log(
|
|
||||||
`[OperatorConfig] cassettes-state published: id=${event.id.slice(0, 12)}… ` +
|
|
||||||
`created_at=${createdAt} seq=${seq} applied_ops=${appliedOps.length} d=${dTag}`
|
|
||||||
)
|
|
||||||
return true
|
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(
|
||||||
|
cfg: OperatorConfigServiceConfig,
|
||||||
|
api: NonNullable<typeof window.electronAPI>,
|
||||||
|
machineId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const already = await api.getBootstrapPublishedAt()
|
||||||
|
if (already !== null) {
|
||||||
|
console.log('[OperatorConfig] Bootstrap already published at unix', already)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const published = await publishCassettesState(cfg, api, machineId)
|
||||||
|
if (published) {
|
||||||
|
await api.markBootstrapPublished(Math.floor(Date.now() / 1000))
|
||||||
|
console.log('[OperatorConfig] Bootstrap hello-event published')
|
||||||
|
} else {
|
||||||
|
console.log('[OperatorConfig] No cassettes/operator — skipping bootstrap')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ export async function startOperatorFeesService(
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
console.log(`[Fees] Subscribed: d=${dTag} sub=${subscriptionId}`)
|
console.log('[Fees] Subscribed:', { dTag, subscriptionId })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* 1. A seed is present whose fingerprint differs from the stored binding
|
* 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
|
* (first pair or re-pair) → generate a fresh NIP-46 transport key, redeem
|
||||||
* the one-shot connect secret, persist the binding, and reset the
|
* the one-shot connect secret, persist the binding, and reset the
|
||||||
* publish watermark so the (possibly new) operator gets current state (#56).
|
* 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
|
* 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
|
* binding exists → resume the bunker session with the persisted transport
|
||||||
* key (no re-redeem — the binding is server-persistent).
|
* key (no re-redeem — the binding is server-persistent).
|
||||||
|
|
@ -121,7 +121,7 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Resolve
|
||||||
if (binding) {
|
if (binding) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[Signer] Stored spire seed is unparseable; resuming from existing binding:',
|
'[Signer] Stored spire seed is unparseable; resuming from existing binding:',
|
||||||
(err as Error).message
|
(err as Error).message,
|
||||||
)
|
)
|
||||||
return { signer: await resume(binding), transport: transportFromBinding(binding) }
|
return { signer: await resume(binding), transport: transportFromBinding(binding) }
|
||||||
}
|
}
|
||||||
|
|
@ -150,9 +150,7 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Resolve
|
||||||
// first pair (no prior binding) has nothing to reset. Cash accounting is
|
// first pair (no prior binding) has nothing to reset. Cash accounting is
|
||||||
// preserved — see resetForRepair; a full wipe is the factory-reset path.
|
// preserved — see resetForRepair; a full wipe is the factory-reset path.
|
||||||
if (binding) {
|
if (binding) {
|
||||||
console.log(
|
console.log('[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state')
|
||||||
'[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state'
|
|
||||||
)
|
|
||||||
await window.electronAPI.resetForRepair()
|
await window.electronAPI.resetForRepair()
|
||||||
}
|
}
|
||||||
// Persist the seed's transport config alongside the binding so a later
|
// Persist the seed's transport config alongside the binding so a later
|
||||||
|
|
@ -167,7 +165,7 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Resolve
|
||||||
lnbitsServerPubkey: seed.lnbitsServerPubkey,
|
lnbitsServerPubkey: seed.lnbitsServerPubkey,
|
||||||
})
|
})
|
||||||
// Re-pair → re-publish the cassette-state hello to the new operator (#56).
|
// Re-pair → re-publish the cassette-state hello to the new operator (#56).
|
||||||
await window.electronAPI.resetStatePublishWatermark()
|
await window.electronAPI.resetBootstrapGate()
|
||||||
}
|
}
|
||||||
return { signer, transport: transportFromSeed(seed) }
|
return { signer, transport: transportFromSeed(seed) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,11 @@ import {
|
||||||
type ActorRefFrom,
|
type ActorRefFrom,
|
||||||
type SnapshotFrom,
|
type SnapshotFrom,
|
||||||
type ATMMachine,
|
type ATMMachine,
|
||||||
type AccessRole,
|
|
||||||
} from '@bitSpire/state-machine'
|
} from '@bitSpire/state-machine'
|
||||||
import type { AccessControlConfig, CardSession } from '@/types/electron'
|
|
||||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||||
import { classifyInitError } from '@/services/init-error'
|
import { classifyInitError } from '@/services/init-error'
|
||||||
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
|
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
|
||||||
import { startOperatorFeesService, type OperatorFeesService } from '@/services/operator-fees'
|
import { startOperatorFeesService, type OperatorFeesService } from '@/services/operator-fees'
|
||||||
import { formatBays, formatInventory } from '@/lib/utils'
|
|
||||||
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'
|
||||||
|
|
@ -28,7 +25,6 @@ import {
|
||||||
} from '@bitSpire/clink'
|
} from '@bitSpire/clink'
|
||||||
import type { TransactionRecord } from '@/types/state'
|
import type { TransactionRecord } from '@/types/state'
|
||||||
import { useAvailabilityBroadcast } from '@/composables/useAvailabilityBroadcast'
|
import { useAvailabilityBroadcast } from '@/composables/useAvailabilityBroadcast'
|
||||||
import { authorize, parseBoltcardLnurlw, type AccessScan } from '@/services/access'
|
|
||||||
|
|
||||||
// Check if we're running in Electron
|
// Check if we're running in Electron
|
||||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||||
|
|
@ -73,13 +69,7 @@ async function handleManagementCommand(
|
||||||
request: ManagementRequest,
|
request: ManagementRequest,
|
||||||
dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise<any>,
|
dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise<any>,
|
||||||
machineIdle: boolean,
|
machineIdle: boolean,
|
||||||
currency: string,
|
currency: string
|
||||||
/**
|
|
||||||
* Called once the dispense has been persisted. Bills left the bays whether or
|
|
||||||
* not the dispense completed, so the caller refreshes its inventory and
|
|
||||||
* republishes the operator's view — this path used to do neither.
|
|
||||||
*/
|
|
||||||
onCassettesChanged?: () => Promise<void>
|
|
||||||
): Promise<ManagementResponse | null> {
|
): Promise<ManagementResponse | null> {
|
||||||
if (!isMachineDispenseRequest(request)) return null
|
if (!isMachineDispenseRequest(request)) return null
|
||||||
|
|
||||||
|
|
@ -137,7 +127,6 @@ async function handleManagementCommand(
|
||||||
cassettes: result.cassettes,
|
cassettes: result.cassettes,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
})
|
})
|
||||||
await onCassettesChanged?.()
|
|
||||||
|
|
||||||
// Only remediate the original tx if ALL requested bills were dispensed
|
// Only remediate the original tx if ALL requested bills were dispensed
|
||||||
let refRemediated = false
|
let refRemediated = false
|
||||||
|
|
@ -169,24 +158,20 @@ async function handleManagementCommand(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load inventory from SQLite via IPC.
|
* Load inventory from SQLite via IPC (Electron only).
|
||||||
*
|
* Returns empty object in browser dev mode.
|
||||||
* Returns `null` when the DB could not be asked at all — browser dev mode, or
|
|
||||||
* a failed IPC call — so callers can tell "no answer" from an answer of "the
|
|
||||||
* bays are empty". An empty map is a real, actionable reading: cassettes are
|
|
||||||
* configured and drained, or none are configured.
|
|
||||||
*/
|
*/
|
||||||
async function loadInventoryFromDb(): Promise<Record<number, number> | null> {
|
async function loadInventoryFromDb(): Promise<Record<number, number>> {
|
||||||
if (isElectron && window.electronAPI) {
|
if (isElectron && window.electronAPI) {
|
||||||
try {
|
try {
|
||||||
const inv = await window.electronAPI.getInventory()
|
const inv = await window.electronAPI.getInventory()
|
||||||
console.log(`[ATM] Loaded inventory from DB: ${formatInventory(inv)}`)
|
console.log('[ATM] Loaded inventory from DB:', inv)
|
||||||
return inv
|
return inv
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[ATM] Failed to load inventory from DB:', e)
|
console.warn('[ATM] Failed to load inventory from DB:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -246,7 +231,7 @@ const mockServices: ATMServices = {
|
||||||
},
|
},
|
||||||
|
|
||||||
dispenseCash: async (amounts) => {
|
dispenseCash: async (amounts) => {
|
||||||
console.log(`[Mock] Dispensing cash: ${formatBays(amounts)}`)
|
console.log('[Mock] Dispensing cash:', amounts)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||||
return {
|
return {
|
||||||
bills: amounts.map((a) => ({
|
bills: amounts.map((a) => ({
|
||||||
|
|
@ -306,69 +291,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// is in flight (settlement still arrives via the normal invoice watcher).
|
// is in flight (settlement still arrives via the normal invoice watcher).
|
||||||
const nfcStatus = ref<{ state: string; message?: string } | null>(null)
|
const nfcStatus = ref<{ state: string; message?: string } | null>(null)
|
||||||
const boltCardProcessing = ref(false)
|
const boltCardProcessing = ref(false)
|
||||||
// What a tap handler did with the voucher it was given:
|
|
||||||
// 'skipped' — a guard bounced it before any server call; the SUN p/c are
|
|
||||||
// untouched and the lnurlw can still be presented later.
|
|
||||||
// 'accepted' — the card's server took the voucher (payment in flight).
|
|
||||||
// 'declined' — presented and refused, or failed after presentation. The
|
|
||||||
// boltcards server bumps the SUN counter on the first GET, so
|
|
||||||
// treat the voucher as spent even when the failure was ours.
|
|
||||||
// 'blocked' — refused BEFORE anything was presented, because the card
|
|
||||||
// server withheld that step when the session opened (e.g. the
|
|
||||||
// card's daily limit is already spent). Nothing was consumed
|
|
||||||
// and no re-tap can lift it, so the session stays loaded.
|
|
||||||
type BoltCardOutcome = 'skipped' | 'accepted' | 'declined' | 'blocked'
|
|
||||||
// Where a Complete gets its voucher: a card tapped right now on the cash
|
|
||||||
// screen (raw lnurlw, spent by this call) or the session opened at entry
|
|
||||||
// (hit-keyed steps, no p/c).
|
|
||||||
type BoltCardSource = { lnurlw: string } | { session: CardSession }
|
|
||||||
// Electron IPC structured-clones its arguments and rejects Vue reactive
|
|
||||||
// proxies with "An object could not be cloned". `loadedBoltCard` is a ref,
|
|
||||||
// so anything reached through it is a proxy — copy the steps field by field
|
|
||||||
// into plain objects before they cross the bridge.
|
|
||||||
const plainWithdrawStep = (w: NonNullable<CardSession['withdraw']>) => ({
|
|
||||||
callback: w.callback,
|
|
||||||
k1: w.k1,
|
|
||||||
minWithdrawable: w.minWithdrawable,
|
|
||||||
maxWithdrawable: w.maxWithdrawable,
|
|
||||||
})
|
|
||||||
const plainPayStep = (p: CardSession['pay']) => ({
|
|
||||||
callback: p.callback,
|
|
||||||
minSendable: p.minSendable,
|
|
||||||
maxSendable: p.maxSendable,
|
|
||||||
metadata: p.metadata,
|
|
||||||
})
|
|
||||||
// Access-control gate config (ADR-003). Defaults disabled → the machine's
|
|
||||||
// `locked` state bypasses straight to `idle` (behaviour identical to no gate).
|
|
||||||
// Populated from RuntimeConfig.accessControl in initializeForProduction.
|
|
||||||
const accessControl = ref<AccessControlConfig>({
|
|
||||||
enabled: false,
|
|
||||||
devUnlock: false,
|
|
||||||
openEnrollment: false,
|
|
||||||
salt: 'bitspire-access-v1',
|
|
||||||
allowList: [],
|
|
||||||
})
|
|
||||||
// Build/dev bypass — opens the gate even when enabled (browser dev / CI).
|
|
||||||
const accessBypassFlag = import.meta.env.VITE_SKIP_ACCESS_GATE === 'true'
|
|
||||||
// Tap-to-enter (ADR-003): the Bolt Card session opened at the locked screen
|
|
||||||
// is held here for the whole visit so buy/sell just need "Complete" — no
|
|
||||||
// second tap. The tap's SUN was spent opening it; what we hold are the
|
|
||||||
// hit-keyed withdraw/pay steps plus balance + fiat for display. Cleared when
|
|
||||||
// the machine re-locks. Never logged.
|
|
||||||
const loadedBoltCard = ref<CardSession | null>(null)
|
|
||||||
// The card balance is hidden by default on the public screen; the holder
|
|
||||||
// reveals it with the eye toggle. Resets on re-lock.
|
|
||||||
const cardBalanceRevealed = ref(false)
|
|
||||||
// Set when a card accepted a cash-out pull and the machine then left the
|
|
||||||
// invoice screen without ever seeing the payment settle. The customer's
|
|
||||||
// wallet paid and no cash came out, so this must be visible and carry the
|
|
||||||
// reference an operator can reconcile against — never a silent return to
|
|
||||||
// the amount screen. Cleared when the next transaction starts.
|
|
||||||
const settlementError = ref<{ txid: string | null; message: string } | null>(null)
|
|
||||||
// When the card server names a currency but didn't price the balance (its
|
|
||||||
// rate cache was cold — it never blocks the unlock on a rate lookup), price
|
|
||||||
// it here from the ATM's own rate source, in that currency.
|
|
||||||
const cardFiatRate = ref<{ currency: string; btcPrice: number } | null>(null)
|
|
||||||
const fiatCode = ref('USD')
|
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,24 +380,12 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
*/
|
*/
|
||||||
const persistedInventory = ref<Record<number, number>>({})
|
const persistedInventory = ref<Record<number, number>>({})
|
||||||
|
|
||||||
/**
|
|
||||||
* The bays moved outside the normal cash-out flow — an operator-command
|
|
||||||
* dispense, a main-process seed. Refresh the renderer's view and push the
|
|
||||||
* operator's. Best-effort: a publish failure must not fail the dispense.
|
|
||||||
*/
|
|
||||||
async function refreshAndPublishCassettes() {
|
|
||||||
await reloadPersistedInventory()
|
|
||||||
await operatorConfigSvc?.publishCassettesState()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reloadPersistedInventory() {
|
async function reloadPersistedInventory() {
|
||||||
const inv = await loadInventoryFromDb()
|
const inv = await loadInventoryFromDb()
|
||||||
// Only a failed read is ignored. An empty map used to be skipped too,
|
if (Object.keys(inv).length > 0) {
|
||||||
// which meant the last bill out of the machine never updated anything and
|
persistedInventory.value = inv
|
||||||
// the availability beacon kept advertising a full cassette.
|
console.log('[ATM] Persisted inventory updated:', inv)
|
||||||
if (inv === null) return
|
}
|
||||||
persistedInventory.value = inv
|
|
||||||
console.log(`[ATM] Persisted inventory updated: ${formatInventory(inv)}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */
|
/** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */
|
||||||
|
|
@ -511,34 +421,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
|
|
||||||
const isIdle = computed(() => currentState.value === 'idle')
|
const isIdle = computed(() => currentState.value === 'idle')
|
||||||
|
|
||||||
// ADR-003: the machine is sitting at the access gate. When the gate is
|
|
||||||
// disabled this is never true (the `locked` state bypasses to `idle` on
|
|
||||||
// start), so App.vue's LockedView branch never renders on a non-access machine.
|
|
||||||
const isLocked = computed(() => currentState.value === 'locked')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fiat view of the loaded card's balance, the way the LNbits wallet page
|
|
||||||
* prices it: the card server's own currency and rate first (the wallet's
|
|
||||||
* currency, else the instance default); when it priced nothing, the ATM's
|
|
||||||
* fiat at its display rate. Null when neither is available.
|
|
||||||
*/
|
|
||||||
const loadedCardFiat = computed<{ amount: number; currency: string } | null>(() => {
|
|
||||||
const card = loadedBoltCard.value
|
|
||||||
if (!card) return null
|
|
||||||
if (card.currency && card.fiat !== null) return { amount: card.fiat, currency: card.currency }
|
|
||||||
const rate = cardFiatRate.value
|
|
||||||
if (card.currency && rate && rate.currency === card.currency) {
|
|
||||||
return { amount: (card.balanceSats / 1e8) * rate.btcPrice, currency: card.currency }
|
|
||||||
}
|
|
||||||
if (btcPrice.value && btcPrice.value > 0) {
|
|
||||||
return { amount: (card.balanceSats / 1e8) * btcPrice.value, currency: fiatCode.value }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
function toggleCardBalance() {
|
|
||||||
cardBalanceRevealed.value = !cardBalanceRevealed.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCashIn = computed(() => {
|
const isCashIn = computed(() => {
|
||||||
const state = snapshot.value?.value
|
const state = snapshot.value?.value
|
||||||
return typeof state === 'object' && 'cashIn' in state
|
return typeof state === 'object' && 'cashIn' in state
|
||||||
|
|
@ -566,8 +448,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
currency: fiatCode.value,
|
currency: fiatCode.value,
|
||||||
cashInFeeFraction: cashInFeeFraction.value,
|
cashInFeeFraction: cashInFeeFraction.value,
|
||||||
cashOutFeeFraction: cashOutFeeFraction.value,
|
cashOutFeeFraction: cashOutFeeFraction.value,
|
||||||
accessControlEnabled: accessControl.value.enabled,
|
|
||||||
accessBypassFlag,
|
|
||||||
})
|
})
|
||||||
actor.value = createActor(machine)
|
actor.value = createActor(machine)
|
||||||
|
|
||||||
|
|
@ -593,14 +473,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
lnurlCleanupFn()
|
lnurlCleanupFn()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the tapped-at-entry Bolt Card when the session ends (machine
|
|
||||||
// re-locks) so the next customer starts fresh — never carry a card over.
|
|
||||||
if (state === 'locked' && loadedBoltCard.value) {
|
|
||||||
loadedBoltCard.value = null
|
|
||||||
cardBalanceRevealed.value = false
|
|
||||||
cardFiatRate.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect network from first invoice we see
|
// Detect network from first invoice we see
|
||||||
if (newSnapshot.context.invoice) {
|
if (newSnapshot.context.invoice) {
|
||||||
detectNetworkFromInvoice(newSnapshot.context.invoice)
|
detectNetworkFromInvoice(newSnapshot.context.invoice)
|
||||||
|
|
@ -634,19 +506,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
bills = dr.bills
|
bills = dr.bills
|
||||||
.filter((b) => b.dispensed > 0)
|
.filter((b) => b.dispensed > 0)
|
||||||
.map((b) => ({ denomination: b.denomination, count: b.dispensed }))
|
.map((b) => ({ denomination: b.denomination, count: b.dispensed }))
|
||||||
} else {
|
|
||||||
// The dispenser threw, or the dispense timed out, so there is no
|
|
||||||
// per-bay report. Bills may well have reached the customer, and
|
|
||||||
// nothing knows how many: neither the cassette rows nor HAL's bays
|
|
||||||
// were debited, so both now read high. Record that the counts are
|
|
||||||
// unverified instead of letting a number we know may be wrong go
|
|
||||||
// on being treated as fact.
|
|
||||||
console.error(
|
|
||||||
`[ATM] Dispense ended with no report — bay counts are unverified (txid=${ctx.txid})`
|
|
||||||
)
|
|
||||||
void window.electronAPI
|
|
||||||
?.markCountsUncertain(Math.floor(Date.now() / 1000))
|
|
||||||
.catch((e) => console.warn('[ATM] Could not flag counts unverified:', e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
persistTransaction({
|
persistTransaction({
|
||||||
|
|
@ -710,26 +569,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
(prevNestedState === 'displayingInvoice' && currentNested !== 'displayingInvoice') ||
|
(prevNestedState === 'displayingInvoice' && currentNested !== 'displayingInvoice') ||
|
||||||
(prevNestedState === 'displayingQR' && currentNested !== 'displayingQR')
|
(prevNestedState === 'displayingQR' && currentNested !== 'displayingQR')
|
||||||
if (leftBoltCardScreen) {
|
if (leftBoltCardScreen) {
|
||||||
// The card accepted the pull (that's what leaves processing latched
|
|
||||||
// with an 'accepted' status) but we left the invoice screen for
|
|
||||||
// somewhere other than the dispenser: the payment was taken and no
|
|
||||||
// cash followed. Surface it with the txid instead of dropping the
|
|
||||||
// customer back on the amount screen as if nothing had happened.
|
|
||||||
if (
|
|
||||||
prevNestedState === 'displayingInvoice' &&
|
|
||||||
currentNested !== 'dispensingCash' &&
|
|
||||||
boltCardProcessing.value &&
|
|
||||||
nfcStatus.value?.state === 'accepted'
|
|
||||||
) {
|
|
||||||
const txid = context.value?.txid ?? null
|
|
||||||
console.error(
|
|
||||||
`[ATM] Settlement never confirmed after the card accepted the pull — txid=${txid}`
|
|
||||||
)
|
|
||||||
settlementError.value = {
|
|
||||||
txid,
|
|
||||||
message: 'Your payment was accepted but the cash was not dispensed.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
boltCardProcessing.value = false
|
boltCardProcessing.value = false
|
||||||
nfcStatus.value = null
|
nfcStatus.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -740,7 +579,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// Start the machine
|
// Start the machine
|
||||||
actor.value.start()
|
actor.value.start()
|
||||||
setupNfcListener()
|
setupNfcListener()
|
||||||
setupCassettesChangedListener()
|
|
||||||
console.log('[ATM] State machine initialized')
|
console.log('[ATM] State machine initialized')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -752,46 +590,26 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
* arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash;
|
* arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash;
|
||||||
* ok here only means the card accepted the pull.
|
* ok here only means the card accepted the pull.
|
||||||
*/
|
*/
|
||||||
async function handleBoltCardTap(source: BoltCardSource): Promise<BoltCardOutcome> {
|
async function handleBoltCardTap(lnurlw: string) {
|
||||||
if (nestedState.value !== 'displayingInvoice') return 'skipped'
|
if (nestedState.value !== 'displayingInvoice') return
|
||||||
const invoice = context.value?.invoice
|
const invoice = context.value?.invoice
|
||||||
if (!invoice) return 'skipped'
|
if (!invoice) return
|
||||||
if (boltCardProcessing.value) return 'skipped' // one pull at a time
|
if (boltCardProcessing.value) return // one pull at a time
|
||||||
// The card server withheld the withdraw step when this session opened, so
|
|
||||||
// there is nothing to present. Say why instead of attempting a payment.
|
|
||||||
if ('session' in source && !source.session.withdraw) {
|
|
||||||
nfcStatus.value = {
|
|
||||||
state: 'declined',
|
|
||||||
message: source.session.withdrawBlockedReason ?? 'This card cannot sell right now',
|
|
||||||
}
|
|
||||||
return 'blocked'
|
|
||||||
}
|
|
||||||
boltCardProcessing.value = true
|
boltCardProcessing.value = true
|
||||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||||
try {
|
try {
|
||||||
const amountMsat = (context.value?.satsAmount ?? 0) * 1000
|
const amountMsat = (context.value?.satsAmount ?? 0) * 1000
|
||||||
const api = window.electronAPI!
|
const res = await window.electronAPI!.lnurlWithdraw({ lnurlw, bolt11: invoice, amountMsat })
|
||||||
const res =
|
|
||||||
'session' in source
|
|
||||||
? await api.withdrawWithSession({
|
|
||||||
// Non-null: a withheld step is pre-flighted above.
|
|
||||||
withdraw: plainWithdrawStep(source.session.withdraw!),
|
|
||||||
bolt11: invoice,
|
|
||||||
amountMsat,
|
|
||||||
})
|
|
||||||
: await api.lnurlWithdraw({ lnurlw: source.lnurlw, bolt11: invoice, amountMsat })
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
|
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
|
||||||
return 'accepted'
|
} else {
|
||||||
|
boltCardProcessing.value = false
|
||||||
|
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' }
|
||||||
}
|
}
|
||||||
boltCardProcessing.value = false
|
|
||||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' }
|
|
||||||
return 'declined'
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[ATM] Bolt Card withdraw failed:', e)
|
console.warn('[ATM] Bolt Card withdraw failed:', e)
|
||||||
boltCardProcessing.value = false
|
boltCardProcessing.value = false
|
||||||
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
||||||
return 'declined'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -802,192 +620,51 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
* over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED
|
* over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED
|
||||||
* path. Settlement + completion reuse the tested cash-in flow.
|
* path. Settlement + completion reuse the tested cash-in flow.
|
||||||
*/
|
*/
|
||||||
async function handleBoltCardReceive(source: BoltCardSource): Promise<BoltCardOutcome> {
|
async function handleBoltCardReceive(lnurlw: string) {
|
||||||
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return 'skipped'
|
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return
|
||||||
const amountSats = context.value?.satsAmount ?? 0
|
const amountSats = context.value?.satsAmount ?? 0
|
||||||
if (amountSats <= 0) return 'skipped'
|
if (amountSats <= 0) return
|
||||||
if (boltCardProcessing.value) return 'skipped' // one at a time
|
if (boltCardProcessing.value) return // one at a time
|
||||||
boltCardProcessing.value = true
|
boltCardProcessing.value = true
|
||||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||||
try {
|
try {
|
||||||
const amountMsat = amountSats * 1000
|
const amountMsat = amountSats * 1000
|
||||||
const api = window.electronAPI!
|
const res = await window.electronAPI!.resolveCardInvoice({ lnurlw, amountMsat })
|
||||||
const res =
|
|
||||||
'session' in source
|
|
||||||
? await api.resolveSessionInvoice({ pay: plainPayStep(source.session.pay), amountMsat })
|
|
||||||
: await api.resolveCardInvoice({ lnurlw: source.lnurlw, amountMsat })
|
|
||||||
if (!res.ok || !res.bolt11) {
|
if (!res.ok || !res.bolt11) {
|
||||||
boltCardProcessing.value = false
|
boltCardProcessing.value = false
|
||||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
|
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
|
||||||
return 'declined'
|
return
|
||||||
}
|
}
|
||||||
nfcStatus.value = { state: 'accepted', message: 'Card found — sending sats…' }
|
nfcStatus.value = { state: 'accepted', message: 'Card found — sending sats…' }
|
||||||
const paid = await payInvoice(res.bolt11)
|
const paid = await payInvoice(res.bolt11)
|
||||||
if (!paid) {
|
if (!paid) {
|
||||||
boltCardProcessing.value = false
|
boltCardProcessing.value = false
|
||||||
nfcStatus.value = { state: 'error', message: paymentError.value ?? 'Payment failed' }
|
nfcStatus.value = { state: 'error', message: paymentError.value ?? 'Payment failed' }
|
||||||
return 'declined'
|
|
||||||
}
|
}
|
||||||
// On success payInvoice fires PAYMENT_RECEIVED; state leaves displayingQR
|
// On success payInvoice fires PAYMENT_RECEIVED; state leaves displayingQR
|
||||||
// and the subscribe-cleanup above resets nfcStatus/boltCardProcessing.
|
// and the subscribe-cleanup above resets nfcStatus/boltCardProcessing.
|
||||||
return 'accepted'
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[ATM] Bolt Card receive failed:', e)
|
console.warn('[ATM] Bolt Card receive failed:', e)
|
||||||
boltCardProcessing.value = false
|
boltCardProcessing.value = false
|
||||||
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
||||||
return 'declined'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Verified
|
|
||||||
* entry: the tap's single-use SUN is spent ONCE, on the card server's
|
|
||||||
* `/session` (main process), which proves a genuine, non-replayed card and
|
|
||||||
* returns the wallet balance plus the hit-keyed withdraw/pay steps. Then the
|
|
||||||
* card's external_id is authorized locally (open-enrollment or allow-list)
|
|
||||||
* and the session is held for the visit — Complete needs no second tap.
|
|
||||||
*/
|
|
||||||
async function handleBoltCardEntry(lnurlw: string) {
|
|
||||||
if (!isLocked.value) return
|
|
||||||
if (boltCardProcessing.value) return
|
|
||||||
// Reject a non-card tag before spending anything or calling anyone.
|
|
||||||
if (!parseBoltcardLnurlw(lnurlw)) {
|
|
||||||
nfcStatus.value = { state: 'declined', message: 'Not a Bolt Card' }
|
|
||||||
denyAccess('not a Bolt Card')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isElectron || !window.electronAPI?.openCardSession) {
|
|
||||||
nfcStatus.value = { state: 'declined', message: 'Card sessions need the machine build' }
|
|
||||||
denyAccess('card sessions need the machine build')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
boltCardProcessing.value = true
|
|
||||||
nfcStatus.value = { state: 'processing', message: 'Verifying card…' }
|
|
||||||
try {
|
|
||||||
const t0 = Date.now()
|
|
||||||
const opened = await window.electronAPI.openCardSession({ lnurlw })
|
|
||||||
console.info(
|
|
||||||
`[ATM] Card session ${opened.ok ? 'opened' : 'refused'} in ${Date.now() - t0} ms` +
|
|
||||||
(opened.ok
|
|
||||||
? ` (fiat ${opened.session.fiat === null ? 'not ' : ''}priced by card server; sell ` +
|
|
||||||
(opened.session.withdraw
|
|
||||||
? 'available)'
|
|
||||||
: `BLOCKED: ${opened.session.withdrawBlockedReason ?? 'withdraw step withheld'})`)
|
|
||||||
: '')
|
|
||||||
)
|
|
||||||
if (!opened.ok) {
|
|
||||||
nfcStatus.value = { state: 'declined', message: opened.reason }
|
|
||||||
denyAccess(opened.reason)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const scan: AccessScan = { kind: 'boltcard', externalId: opened.session.externalId }
|
|
||||||
const outcome = await authorize(scan, accessControl.value.allowList, {
|
|
||||||
salt: accessControl.value.salt,
|
|
||||||
openEnrollment: accessControl.value.openEnrollment,
|
|
||||||
})
|
|
||||||
if (outcome.status === 'granted') {
|
|
||||||
loadedBoltCard.value = opened.session
|
|
||||||
cardBalanceRevealed.value = false
|
|
||||||
cardFiatRate.value = null
|
|
||||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
|
|
||||||
grantAccess(outcome.role, outcome.credentialIdHash)
|
|
||||||
// Price the balance in the card's currency off the unlock path.
|
|
||||||
const { currency, fiat, externalId } = opened.session
|
|
||||||
if (currency && fiat === null) {
|
|
||||||
void fetchBtcPrice(currency).then((price) => {
|
|
||||||
if (price && loadedBoltCard.value?.externalId === externalId) {
|
|
||||||
cardFiatRate.value = { currency, btcPrice: price }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// pin-required can't occur for card-only open-enrollment; treat as denied.
|
|
||||||
const reason = outcome.status === 'denied' ? outcome.reason : 'card not authorized'
|
|
||||||
nfcStatus.value = { state: 'declined', message: reason }
|
|
||||||
denyAccess(reason, outcome.credentialIdHash)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[ATM] Bolt Card session failed:', e)
|
|
||||||
nfcStatus.value = { state: 'error', message: 'Card verification failed' }
|
|
||||||
denyAccess('card verification failed')
|
|
||||||
} finally {
|
|
||||||
boltCardProcessing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete a buy/sell using the Bolt Card loaded at entry — no second tap.
|
|
||||||
* Cash-out pulls via the stored lnurlw; cash-in resolves it to the card
|
|
||||||
* wallet's lnurlp and pays. Reuses the tap handlers verbatim.
|
|
||||||
*
|
|
||||||
* The loaded session is SINGLE-SHOT: its withdraw/pay steps are keyed by one
|
|
||||||
* server-side hit that the first use spends, so once a step has been
|
|
||||||
* presented — accepted or declined — it can never succeed again. Drop the
|
|
||||||
* session after the first real attempt and tell the customer to re-tap; a
|
|
||||||
* fresh tap on the cash screen goes straight through the normal
|
|
||||||
* tap-to-pay/receive path. A 'skipped' outcome (guard bounced it, no server
|
|
||||||
* call) keeps the session.
|
|
||||||
*/
|
|
||||||
async function completeWithCard() {
|
|
||||||
const card = loadedBoltCard.value
|
|
||||||
if (!card) return
|
|
||||||
const t0 = Date.now()
|
|
||||||
let outcome: BoltCardOutcome = 'skipped'
|
|
||||||
if (isCashOut.value) outcome = await handleBoltCardTap({ session: card })
|
|
||||||
else if (isCashIn.value) outcome = await handleBoltCardReceive({ session: card })
|
|
||||||
console.info(
|
|
||||||
`[ATM] Complete with card: ${outcome} in ${Date.now() - t0} ms` +
|
|
||||||
(outcome === 'accepted' ? '' : ` — ${nfcStatus.value?.message ?? 'no reason given'}`)
|
|
||||||
)
|
|
||||||
if (outcome === 'skipped') return
|
|
||||||
// Blocked is the card server's standing answer for this visit: nothing was
|
|
||||||
// consumed, and re-tapping cannot lift it. Keep the session loaded so the
|
|
||||||
// chip still shows whose card it is, and skip the re-tap advice below.
|
|
||||||
if (outcome === 'blocked') return
|
|
||||||
loadedBoltCard.value = null
|
|
||||||
if (outcome === 'declined') {
|
|
||||||
const reason = nfcStatus.value?.message ?? 'Card declined'
|
|
||||||
nfcStatus.value = {
|
|
||||||
state: nfcStatus.value?.state ?? 'declined',
|
|
||||||
message: `${reason} — tap your card to try again`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The main process can move the bays without the renderer knowing — an
|
|
||||||
* operator-command dispense runs entirely there, and boot seeding writes the
|
|
||||||
* table before the store exists. Listen for that and catch up, otherwise the
|
|
||||||
* renderer serves a stale inventory and the operator's view never updates.
|
|
||||||
* Idempotent via preload removeAllListeners.
|
|
||||||
*/
|
|
||||||
function setupCassettesChangedListener() {
|
|
||||||
if (!isElectron || !window.electronAPI?.onCassettesChanged) return
|
|
||||||
window.electronAPI.onCassettesChanged(() => {
|
|
||||||
console.log('[ATM] Cassettes changed in the main process — refreshing')
|
|
||||||
void refreshAndPublishCassettes()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
||||||
function setupNfcListener() {
|
function setupNfcListener() {
|
||||||
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
||||||
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
||||||
// Route the tap by state: locked → enter + load the card; then cash-out
|
// Route the same physical tap by flow: cash-out pulls, cash-in receives.
|
||||||
// pulls, cash-in receives (fallback if no card was loaded at entry).
|
if (isCashOut.value && nestedState.value === 'displayingInvoice') {
|
||||||
if (isLocked.value) {
|
void handleBoltCardTap(lnurlw)
|
||||||
void handleBoltCardEntry(lnurlw)
|
|
||||||
} else if (isCashOut.value && nestedState.value === 'displayingInvoice') {
|
|
||||||
void handleBoltCardTap({ lnurlw })
|
|
||||||
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
|
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
|
||||||
void handleBoltCardReceive({ lnurlw })
|
void handleBoltCardReceive(lnurlw)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
window.electronAPI.onNfcStatus?.((status) => {
|
window.electronAPI.onNfcStatus?.((status) => {
|
||||||
// Surface reader status on a tap screen (locked / invoice / QR); don't
|
// Only surface reader status on a tap screen, and don't clobber an
|
||||||
// clobber an in-flight tap's message.
|
// in-flight tap's message.
|
||||||
const onTapScreen =
|
const onTapScreen =
|
||||||
isLocked.value ||
|
|
||||||
(isCashOut.value && nestedState.value === 'displayingInvoice') ||
|
(isCashOut.value && nestedState.value === 'displayingInvoice') ||
|
||||||
(isCashIn.value && nestedState.value === 'displayingQR')
|
(isCashIn.value && nestedState.value === 'displayingQR')
|
||||||
if (onTapScreen && !boltCardProcessing.value) {
|
if (onTapScreen && !boltCardProcessing.value) {
|
||||||
|
|
@ -998,17 +675,12 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
|
|
||||||
/** Dev/mock: simulate a cash-out tap with a pasted lnurlw (test without a card). */
|
/** Dev/mock: simulate a cash-out tap with a pasted lnurlw (test without a card). */
|
||||||
function simulateBoltCardTap(lnurlw: string) {
|
function simulateBoltCardTap(lnurlw: string) {
|
||||||
void handleBoltCardTap({ lnurlw })
|
void handleBoltCardTap(lnurlw)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dev/mock: simulate a cash-in (receive) tap with a pasted lnurlw. */
|
/** Dev/mock: simulate a cash-in (receive) tap with a pasted lnurlw. */
|
||||||
function simulateBoltCardReceive(lnurlw: string) {
|
function simulateBoltCardReceive(lnurlw: string) {
|
||||||
void handleBoltCardReceive({ lnurlw })
|
void handleBoltCardReceive(lnurlw)
|
||||||
}
|
|
||||||
|
|
||||||
/** Dev/mock: simulate tapping a card at the locked screen (tap-to-enter). */
|
|
||||||
function simulateBoltCardEntry(lnurlw: string) {
|
|
||||||
void handleBoltCardEntry(lnurlw)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1117,8 +789,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
}),
|
}),
|
||||||
getInventory: async () => {
|
getInventory: async () => {
|
||||||
const fresh = await loadInventoryFromDb()
|
const fresh = await loadInventoryFromDb()
|
||||||
// null == the DB could not be asked; an empty map is a real reading.
|
return Object.keys(fresh).length > 0 ? fresh : services.atmServices.getInventory()
|
||||||
return fresh ?? services.atmServices.getInventory()
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1366,8 +1037,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
request,
|
request,
|
||||||
(amounts) => hal.atmServices.dispenseCash(amounts),
|
(amounts) => hal.atmServices.dispenseCash(amounts),
|
||||||
isIdle.value,
|
isIdle.value,
|
||||||
fiatCode.value,
|
fiatCode.value
|
||||||
refreshAndPublishCassettes
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1382,8 +1052,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// If DB has inventory, use it; otherwise fall back to HAL
|
// If DB has inventory, use it; otherwise fall back to HAL
|
||||||
getInventory: async () => {
|
getInventory: async () => {
|
||||||
const fresh = await loadInventoryFromDb()
|
const fresh = await loadInventoryFromDb()
|
||||||
// null == the DB could not be asked; an empty map is a real reading.
|
return Object.keys(fresh).length > 0 ? fresh : hal.atmServices.getInventory()
|
||||||
return fresh ?? hal.atmServices.getInventory()
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1514,19 +1183,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
|
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
|
||||||
fiatCode.value = runtimeFiatCode
|
fiatCode.value = runtimeFiatCode
|
||||||
|
|
||||||
// Access-control gate (ADR-003). Must be set BEFORE the machine is built
|
|
||||||
// (initialize() reads accessControl.value to seed the `locked` state).
|
|
||||||
if (runtimeConfig.accessControl) {
|
|
||||||
accessControl.value = runtimeConfig.accessControl
|
|
||||||
if (runtimeConfig.accessControl.enabled) {
|
|
||||||
console.log(
|
|
||||||
`[ATM] Access control ENABLED (openEnrollment=${runtimeConfig.accessControl.openEnrollment}, ` +
|
|
||||||
`allowList=${runtimeConfig.accessControl.allowList.length} entries, ` +
|
|
||||||
`devUnlock=${runtimeConfig.accessControl.devUnlock})`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load persisted operator fee config (aiolabs/lamassu-next#57). If no
|
// Load persisted operator fee config (aiolabs/lamassu-next#57). If no
|
||||||
// config has ever been applied (fresh ATM, pre-operator-publish),
|
// config has ever been applied (fresh ATM, pre-operator-publish),
|
||||||
// enter the 'awaiting-fees' maintenance state — UI shows the operator
|
// enter the 'awaiting-fees' maintenance state — UI shows the operator
|
||||||
|
|
@ -1590,7 +1246,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
console.log('[ATM] Fiat currency:', devConfig.fiatCode)
|
console.log('[ATM] Fiat currency:', devConfig.fiatCode)
|
||||||
console.log('[ATM] Validator device:', devConfig.validator.device)
|
console.log('[ATM] Validator device:', devConfig.validator.device)
|
||||||
console.log('[ATM] Dispenser device:', devConfig.dispenser.device)
|
console.log('[ATM] Dispenser device:', devConfig.dispenser.device)
|
||||||
console.log(`[ATM] Cassettes: ${formatBays(devConfig.dispenser.cassettes)}`)
|
console.log('[ATM] Cassettes:', devConfig.dispenser.cassettes)
|
||||||
|
|
||||||
const halConfig = toHalConfig(devConfig)
|
const halConfig = toHalConfig(devConfig)
|
||||||
await initializeWithHalIpc(halConfig)
|
await initializeWithHalIpc(halConfig)
|
||||||
|
|
@ -1663,15 +1319,13 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// HAL dispenseCash via IPC
|
// HAL dispenseCash via IPC
|
||||||
const halAtmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = {
|
const halAtmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = {
|
||||||
dispenseCash: async (amounts) => {
|
dispenseCash: async (amounts) => {
|
||||||
console.log(`[ATM] Dispensing via IPC: ${formatBays(amounts)}`)
|
console.log('[ATM] Dispensing via IPC:', amounts)
|
||||||
return await api.halDispense(amounts)
|
return await api.halDispense(amounts)
|
||||||
},
|
},
|
||||||
getInventory: async () => {
|
getInventory: async () => {
|
||||||
// Priority: DB inventory > HAL hardware inventory > empty. Only a
|
// Priority: DB inventory > HAL hardware inventory > empty
|
||||||
// null (unreadable) DB defers to HAL — a drained machine reports
|
|
||||||
// drained rather than borrowing the hardware's view.
|
|
||||||
const fresh = await loadInventoryFromDb()
|
const fresh = await loadInventoryFromDb()
|
||||||
if (fresh !== null) return fresh
|
if (Object.keys(fresh).length > 0) return fresh
|
||||||
// Fall back to HAL's cassette-based inventory
|
// Fall back to HAL's cassette-based inventory
|
||||||
try {
|
try {
|
||||||
const halInv = await api.halGetInventory()
|
const halInv = await api.halGetInventory()
|
||||||
|
|
@ -1699,8 +1353,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
request,
|
request,
|
||||||
(amounts) => api.halDispense(amounts),
|
(amounts) => api.halDispense(amounts),
|
||||||
isIdle.value,
|
isIdle.value,
|
||||||
fiatCode.value,
|
fiatCode.value
|
||||||
refreshAndPublishCassettes
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1838,69 +1491,12 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
actor.value.send(event)
|
actor.value.send(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Access control (ADR-003) ===
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Audit stub — records an access decision. PR1 logs only (hashed id, never a
|
|
||||||
* raw credential); a fast-follow persists to state.db and optionally a Nostr
|
|
||||||
* event (see ADR-003).
|
|
||||||
*/
|
|
||||||
function recordAccessAudit(outcome: {
|
|
||||||
result: 'granted' | 'denied'
|
|
||||||
role?: AccessRole
|
|
||||||
credentialIdHash: string
|
|
||||||
reason?: string
|
|
||||||
}) {
|
|
||||||
// Truncate the hash — it's already non-reversible, but no need to splash
|
|
||||||
// the full value across the journal. Interpolated rather than passed as an
|
|
||||||
// object: the console bridge would stringify it to `[object Object]` and
|
|
||||||
// this line is the audit trail until #90 persists it to state.db.
|
|
||||||
console.info(
|
|
||||||
`[Access] audit result=${outcome.result} role=${outcome.role ?? 'none'} ` +
|
|
||||||
`hash=${outcome.credentialIdHash.slice(0, 12)} ` +
|
|
||||||
`reason=${outcome.reason ?? 'none'} at=${Date.now()}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Grant terminal access after a credential (and any PIN) is authorized. */
|
|
||||||
function grantAccess(role: AccessRole, credentialIdHash: string) {
|
|
||||||
recordAccessAudit({ result: 'granted', role, credentialIdHash })
|
|
||||||
send({ type: 'ACCESS_GRANTED', role, credentialIdHash })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reject an access attempt; the machine stays locked and shows the reason. */
|
|
||||||
function denyAccess(reason: string, credentialIdHash = '') {
|
|
||||||
recordAccessAudit({ result: 'denied', credentialIdHash, reason })
|
|
||||||
send({ type: 'ACCESS_DENIED', reason })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Runtime dev/operator unlock (gated by the machine's devUnlockAllowed guard). */
|
|
||||||
function devUnlock() {
|
|
||||||
recordAccessAudit({ result: 'granted', role: 'operator', credentialIdHash: 'dev-unlock' })
|
|
||||||
send({ type: 'DEV_UNLOCK' })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End the current tap-in session and re-lock immediately (drops the loaded
|
|
||||||
* Bolt Card via `locked`'s entry). Routed through the machine's root-level
|
|
||||||
* END_SESSION so it locks from any unlocked state. Callers: the "End session"
|
|
||||||
* button, the idle-inactivity timer, and the absolute session cap. `reason`
|
|
||||||
* is recorded for the access audit trail — never a raw credential. No-op
|
|
||||||
* unless the gate is active (guarded in the machine).
|
|
||||||
*/
|
|
||||||
function endSession(reason: 'user' | 'inactivity' | 'session-cap' = 'user') {
|
|
||||||
console.info(`[ATM] Ending session — reason=${reason}`)
|
|
||||||
send({ type: 'END_SESSION' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience methods for common events
|
// Convenience methods for common events
|
||||||
function selectCashIn() {
|
function selectCashIn() {
|
||||||
settlementError.value = null
|
|
||||||
send({ type: 'SELECT_CASH_IN' })
|
send({ type: 'SELECT_CASH_IN' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectCashOut() {
|
function selectCashOut() {
|
||||||
settlementError.value = null
|
|
||||||
send({ type: 'SELECT_CASH_OUT' })
|
send({ type: 'SELECT_CASH_OUT' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2044,22 +1640,6 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
boltCardProcessing,
|
boltCardProcessing,
|
||||||
simulateBoltCardTap,
|
simulateBoltCardTap,
|
||||||
simulateBoltCardReceive,
|
simulateBoltCardReceive,
|
||||||
// Tap-to-enter: card session opened at the locked screen, reused at Complete
|
|
||||||
loadedBoltCard,
|
|
||||||
cardBalanceRevealed,
|
|
||||||
loadedCardFiat,
|
|
||||||
toggleCardBalance,
|
|
||||||
completeWithCard,
|
|
||||||
simulateBoltCardEntry,
|
|
||||||
|
|
||||||
// Access control (ADR-003)
|
|
||||||
settlementError,
|
|
||||||
accessControl,
|
|
||||||
isLocked,
|
|
||||||
grantAccess,
|
|
||||||
denyAccess,
|
|
||||||
devUnlock,
|
|
||||||
endSession,
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initialize,
|
initialize,
|
||||||
|
|
|
||||||
97
apps/machine/src/types/electron.d.ts
vendored
97
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -2,57 +2,6 @@
|
||||||
* Type declarations for Electron API exposed via preload
|
* Type declarations for Electron API exposed via preload
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AllowListEntry } from '../services/access/authorize'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Access-control config (ADR-003). Loaded by the main process from env +
|
|
||||||
* an optional /var/lib/bitspire/access.json. `enabled` defaults false, so a
|
|
||||||
* machine with no access config behaves exactly as before.
|
|
||||||
*/
|
|
||||||
export interface AccessControlConfig {
|
|
||||||
/** Master switch for the badge-to-enter gate. */
|
|
||||||
enabled: boolean
|
|
||||||
/** Allow the runtime dev/operator unlock gesture on the locked screen. */
|
|
||||||
devUnlock: boolean
|
|
||||||
/** Prototype: admit any valid npub when the allow-list has no match. */
|
|
||||||
openEnrollment: boolean
|
|
||||||
/** Per-machine salt for hashing credentials/PINs. */
|
|
||||||
salt: string
|
|
||||||
/** Authorized identities (hashed). Empty in open-enrollment prototype mode. */
|
|
||||||
allowList: AllowListEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Bolt Card session opened at tap-to-enter (ADR-003). Wire payload from the
|
|
||||||
* main process (electron/boltcard-session.ts) — mirrored here rather than
|
|
||||||
* imported to avoid a cross-project electron↔renderer import. The withdraw and
|
|
||||||
* pay steps are keyed by a single-use server-side hit; no card secret is held.
|
|
||||||
*/
|
|
||||||
export interface CardSession {
|
|
||||||
externalId: string
|
|
||||||
cardName: string
|
|
||||||
balanceSats: number
|
|
||||||
/** ISO currency the card server priced the balance in; null → no fiat. */
|
|
||||||
currency: string | null
|
|
||||||
/** Balance in `currency` at the card server's rate; null when unknown. */
|
|
||||||
fiat: number | null
|
|
||||||
/** LUD-03 second step, or null when the card server withheld it. */
|
|
||||||
withdraw: {
|
|
||||||
callback: string
|
|
||||||
k1: string
|
|
||||||
minWithdrawable?: number
|
|
||||||
maxWithdrawable?: number
|
|
||||||
} | null
|
|
||||||
/** Why `withdraw` is null (e.g. daily limit spent); safe to show on-screen. */
|
|
||||||
withdrawBlockedReason: string | null
|
|
||||||
/** LUD-06 second step for topping the card wallet up. */
|
|
||||||
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OpenCardSessionResult =
|
|
||||||
| { ok: true; session: CardSession }
|
|
||||||
| { ok: false; reason: string }
|
|
||||||
|
|
||||||
export interface RuntimeConfig {
|
export interface RuntimeConfig {
|
||||||
relayUrl: string
|
relayUrl: string
|
||||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||||
|
|
@ -68,8 +17,6 @@ export interface RuntimeConfig {
|
||||||
maintenanceMode: boolean
|
maintenanceMode: boolean
|
||||||
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
|
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
|
||||||
branding: BrandingConfig | null
|
branding: BrandingConfig | null
|
||||||
/** Access-control gate config (ADR-003). Always present; `enabled` defaults false. */
|
|
||||||
accessControl: AccessControlConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */
|
/** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */
|
||||||
|
|
@ -146,14 +93,11 @@ declare global {
|
||||||
emptyCashbox: () => Promise<void>
|
emptyCashbox: () => Promise<void>
|
||||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||||
getLastStatePublishedAt: () => Promise<number | null>
|
getBootstrapPublishedAt: () => Promise<number | null>
|
||||||
/** When the bay counts became unverified (a dispense that reported nothing), or null. */
|
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||||
getCountsUncertainSince: () => Promise<number | null>
|
|
||||||
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
|
||||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
|
||||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||||
clearBunkerBinding: () => Promise<void>
|
clearBunkerBinding: () => Promise<void>
|
||||||
resetStatePublishWatermark: () => Promise<void>
|
resetBootstrapGate: () => Promise<void>
|
||||||
resetForRepair: () => Promise<void>
|
resetForRepair: () => Promise<void>
|
||||||
saveSpireSeed: (seed: string) => Promise<void>
|
saveSpireSeed: (seed: string) => Promise<void>
|
||||||
relaunchApp: () => Promise<void>
|
relaunchApp: () => Promise<void>
|
||||||
|
|
@ -170,35 +114,10 @@ declare global {
|
||||||
lnurlw: string
|
lnurlw: string
|
||||||
amountMsat: number
|
amountMsat: number
|
||||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||||
/** Bolt Card tap-to-enter: open one verified session for a tapped card (spends the SUN). */
|
applyOperatorCassettesConfig: (
|
||||||
openCardSession: (args: { lnurlw: string }) => Promise<OpenCardSessionResult>
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
/** Cash-out via a session's withdraw step (no tap). */
|
eventCreatedAt: number
|
||||||
withdrawWithSession: (args: {
|
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
||||||
withdraw: NonNullable<CardSession['withdraw']>
|
|
||||||
bolt11: string
|
|
||||||
amountMsat?: number
|
|
||||||
}) => Promise<{ ok: boolean; reason?: string }>
|
|
||||||
/** Cash-in via a session's pay step (no tap): a BOLT11 to pay. */
|
|
||||||
resolveSessionInvoice: (args: {
|
|
||||||
pay: CardSession['pay']
|
|
||||||
amountMsat: number
|
|
||||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
|
||||||
applyOperatorCassetteOps: (
|
|
||||||
ops: {
|
|
||||||
id: string
|
|
||||||
at: number
|
|
||||||
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
|
||||||
position: number
|
|
||||||
bills?: number
|
|
||||||
count?: number
|
|
||||||
denomination?: number
|
|
||||||
}[]
|
|
||||||
) => Promise<{
|
|
||||||
applied: string[]
|
|
||||||
rejected: { id: string; reason: string }[]
|
|
||||||
}>
|
|
||||||
getAppliedOpIds: (limit?: number) => Promise<string[]>
|
|
||||||
getCassetteStateSeq: () => Promise<number>
|
|
||||||
getFeeConfig: () => Promise<{
|
getFeeConfig: () => Promise<{
|
||||||
cashInFeeFraction: number
|
cashInFeeFraction: number
|
||||||
cashOutFeeFraction: number
|
cashOutFeeFraction: number
|
||||||
|
|
@ -232,8 +151,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
|
||||||
/** The main process mutated the cassettes table; reload + republish. */
|
|
||||||
onCassettesChanged: (callback: () => void) => void
|
|
||||||
/** Bolt Card reader: a tapped card's lnurlw voucher. */
|
/** Bolt Card reader: a tapped card's lnurlw voucher. */
|
||||||
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
|
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
|
||||||
/** Bolt Card reader status (ready / reading / error / unavailable). */
|
/** Bolt Card reader status (ready / reading / error / unavailable). */
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { watch, computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAtmStore } from '@/stores/atm'
|
import { useAtmStore } from '@/stores/atm'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import CardChip from '@/components/CardChip.vue'
|
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import QRCode from '@/components/QRCode.vue'
|
import QRCode from '@/components/QRCode.vue'
|
||||||
|
|
@ -364,22 +363,6 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tap-to-enter: card already loaded → one-press Complete (no re-tap) -->
|
|
||||||
<div
|
|
||||||
v-if="atmStore.loadedBoltCard"
|
|
||||||
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
|
|
||||||
>
|
|
||||||
<CardChip class="w-full" />
|
|
||||||
<Button
|
|
||||||
class="w-full bg-success text-success-foreground"
|
|
||||||
size="kiosk-lg"
|
|
||||||
:disabled="atmStore.boltCardProcessing"
|
|
||||||
@click="atmStore.completeWithCard()"
|
|
||||||
>
|
|
||||||
{{ atmStore.boltCardProcessing ? 'Completing…' : 'Complete Purchase' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- LNURL URI (web-ui only) -->
|
<!-- LNURL URI (web-ui only) -->
|
||||||
<div v-if="!isElectron && currentQrValue" class="pt-4 text-center space-y-1">
|
<div v-if="!isElectron && currentQrValue" class="pt-4 text-center space-y-1">
|
||||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { watch, computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAtmStore } from '@/stores/atm'
|
import { useAtmStore } from '@/stores/atm'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import CardChip from '@/components/CardChip.vue'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import QRCode from '@/components/QRCode.vue'
|
import QRCode from '@/components/QRCode.vue'
|
||||||
|
|
@ -178,24 +177,6 @@ function formatFiat(cents: number): string {
|
||||||
key="selectingAmount"
|
key="selectingAmount"
|
||||||
class="flex flex-1 flex-col justify-center px-4 lg:px-[8vw] py-4 lg:py-6 gap-4 lg:gap-6"
|
class="flex flex-1 flex-col justify-center px-4 lg:px-[8vw] py-4 lg:py-6 gap-4 lg:gap-6"
|
||||||
>
|
>
|
||||||
<!-- A payment was taken and no cash came out. Say so plainly, with
|
|
||||||
the reference an operator needs; do not let the customer wander
|
|
||||||
back into the amount grid believing nothing happened. -->
|
|
||||||
<div
|
|
||||||
v-if="atmStore.settlementError"
|
|
||||||
class="rounded-lg border-2 border-destructive bg-destructive/10 px-4 py-3 text-center lg:px-6 lg:py-4"
|
|
||||||
>
|
|
||||||
<p class="text-base font-bold text-destructive lg:text-2xl">
|
|
||||||
{{ atmStore.settlementError.message }}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground lg:text-lg">
|
|
||||||
Please contact the operator<template v-if="atmStore.settlementError.txid">
|
|
||||||
and quote
|
|
||||||
<span class="font-mono-code">{{ atmStore.settlementError.txid }}</span> </template
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Denomination grid -->
|
<!-- Denomination grid -->
|
||||||
<div class="grid grid-cols-2 gap-3 lg:gap-6">
|
<div class="grid grid-cols-2 gap-3 lg:gap-6">
|
||||||
<div
|
<div
|
||||||
|
|
@ -347,35 +328,6 @@ function formatFiat(cents: number): string {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tap-to-enter: card already loaded → one-press Complete (no re-tap) -->
|
|
||||||
<div
|
|
||||||
v-if="atmStore.loadedBoltCard"
|
|
||||||
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
|
|
||||||
>
|
|
||||||
<CardChip class="w-full" />
|
|
||||||
<!-- The card server withheld this session's withdraw step (e.g.
|
|
||||||
the card's daily limit is spent). Nothing the customer does
|
|
||||||
here can lift it, so say so rather than offering a button
|
|
||||||
that always fails. The QR remains as the way to get paid. -->
|
|
||||||
<p
|
|
||||||
v-if="!atmStore.loadedBoltCard.withdraw"
|
|
||||||
class="w-full text-center text-sm font-medium text-destructive lg:text-lg"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
atmStore.loadedBoltCard.withdrawBlockedReason ?? 'This card cannot sell right now'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
v-else
|
|
||||||
class="w-full bg-success text-success-foreground"
|
|
||||||
size="kiosk-lg"
|
|
||||||
:disabled="atmStore.boltCardProcessing"
|
|
||||||
@click="atmStore.completeWithCard()"
|
|
||||||
>
|
|
||||||
{{ atmStore.boltCardProcessing ? 'Completing…' : 'Complete Sale' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Invoice info with copy button (web-ui only) -->
|
<!-- Invoice info with copy button (web-ui only) -->
|
||||||
<div v-if="context?.invoice && !isElectron" class="pt-4 text-center">
|
<div v-if="context?.invoice && !isElectron" class="pt-4 text-center">
|
||||||
<p class="font-mono-code mb-2 truncate text-xs text-muted-foreground max-w-[300px]">
|
<p class="font-mono-code mb-2 truncate text-xs text-muted-foreground max-w-[300px]">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ 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'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import CardChip from '@/components/CardChip.vue'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import BitcoinIcon from '@/components/BitcoinIcon.vue'
|
import BitcoinIcon from '@/components/BitcoinIcon.vue'
|
||||||
import QRCode from '@/components/QRCode.vue'
|
import QRCode from '@/components/QRCode.vue'
|
||||||
|
|
@ -74,12 +73,16 @@ function handleCashOut() {
|
||||||
>Just Bitcoin</Badge
|
>Just Bitcoin</Badge
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<!-- Commission rates. The machine's own balance is NOT repeated here: App.vue
|
<!-- Balance + Commission rates -->
|
||||||
already shows it in the top-right status chip, and next to the holder's
|
|
||||||
card chip an "Available: N sats" line reads as *their* balance. -->
|
|
||||||
<div
|
<div
|
||||||
class="mt-1 flex flex-wrap items-center justify-center gap-2 lg:gap-4 text-[11px] lg:text-[2vh] text-muted-foreground"
|
class="mt-1 flex flex-wrap items-center justify-center gap-2 lg:gap-4 text-[11px] lg:text-[2vh] text-muted-foreground"
|
||||||
>
|
>
|
||||||
|
<span v-if="atmStore.balanceSats !== null">
|
||||||
|
Available:
|
||||||
|
<span class="font-bold text-primary"
|
||||||
|
>{{ atmStore.balanceSats.toLocaleString() }} sats</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Buy:
|
Buy:
|
||||||
<span class="font-bold text-bitcoin"
|
<span class="font-bold text-bitcoin"
|
||||||
|
|
@ -101,8 +104,6 @@ function handleCashOut() {
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tap-to-enter: the holder's card session, balance hidden until revealed -->
|
|
||||||
<CardChip v-if="atmStore.loadedBoltCard" class="mt-1 w-full max-w-md" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Touch Zones -->
|
<!-- Touch Zones -->
|
||||||
|
|
@ -172,37 +173,15 @@ function handleCashOut() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Top-left utility buttons: Help, plus an End-session "✕" while the access
|
<!-- Help button (top-left) -->
|
||||||
gate is engaged. Kept in the left corner (not top-right) so they never
|
<Button
|
||||||
collide with the centered balance/commission chips, which wrap into the
|
variant="outline"
|
||||||
top-right on narrower screens (e.g. sintra). A tap-in loads the holder's
|
size="icon"
|
||||||
Bolt Card for the whole session, so the ✕ gives them an explicit way to
|
class="absolute top-4 left-4 lg:top-8 lg:left-8 h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
|
||||||
re-lock the moment they're done rather than waiting out the idle timeout
|
@click="$router.push('/support')"
|
||||||
(which would leave the card usable by the next person meanwhile). -->
|
>
|
||||||
<div class="absolute top-4 left-4 lg:top-8 lg:left-8 flex items-center gap-2 lg:gap-3">
|
?
|
||||||
<!-- End session first (leftmost): a solid `destructive` swatch so the exit
|
</Button>
|
||||||
reads as red in every theme (--destructive is theme-scoped). Shown
|
|
||||||
only while the access gate is engaged. -->
|
|
||||||
<Button
|
|
||||||
v-if="atmStore.accessControl.enabled"
|
|
||||||
variant="destructive"
|
|
||||||
size="icon"
|
|
||||||
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full text-2xl lg:text-4xl font-bold"
|
|
||||||
aria-label="End session"
|
|
||||||
@click="atmStore.endSession()"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
|
|
||||||
aria-label="Help"
|
|
||||||
@click="$router.push('/support')"
|
|
||||||
>
|
|
||||||
?
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Debug toggle (only visible when debug bar is hidden, never in production) -->
|
<!-- Debug toggle (only visible when debug bar is hidden, never in production) -->
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Access gate — "tap your Bolt Card" screen (ADR-003, tap-to-enter).
|
|
||||||
*
|
|
||||||
* Shown when the machine is healthy but locked (App.vue's `isLocked` branch).
|
|
||||||
* Entry is a single Bolt Card tap: the card is read by the main-process NFC
|
|
||||||
* reader and routed to the store (`handleBoltCardEntry`) which authorizes it
|
|
||||||
* (open-enrollment) and, on grant, loads the card into the session so buy/sell
|
|
||||||
* only need "Complete". This view is presentation-only — it shows the prompt
|
|
||||||
* and live reader status; the store owns the tap handling and machine events.
|
|
||||||
*
|
|
||||||
* Card-only by design: no camera/npub-QR, no PIN. A dev paste-box (debug builds)
|
|
||||||
* and a dev-unlock button remain for testing without hardware.
|
|
||||||
*/
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { useAtmStore } from '@/stores/atm'
|
|
||||||
import { useBranding } from '@/composables/useBranding'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import ColorModeToggle from '@/components/ColorModeToggle.vue'
|
|
||||||
import { Nfc } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const atmStore = useAtmStore()
|
|
||||||
const { logoUrl, title } = useBranding()
|
|
||||||
|
|
||||||
const denyReason = computed(() => atmStore.snapshot?.context.accessDenyReason ?? null)
|
|
||||||
const showDevUnlock = computed(() => atmStore.accessControl.devUnlock)
|
|
||||||
const nfc = computed(() => atmStore.nfcStatus)
|
|
||||||
const reading = computed(() => atmStore.boltCardProcessing)
|
|
||||||
|
|
||||||
// Dev: paste an lnurlw to simulate a tap-to-enter without a card.
|
|
||||||
const mockLnurlw = ref('')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="relative flex flex-1 flex-col items-center justify-center gap-10 bg-background p-8 text-foreground"
|
|
||||||
>
|
|
||||||
<!-- Light/dark toggle — shared kiosk-sized component -->
|
|
||||||
<ColorModeToggle class="absolute right-4 top-4 z-10" />
|
|
||||||
|
|
||||||
<!-- Brand: logo + title only, colours from the active theme (branding.json) -->
|
|
||||||
<div class="flex flex-col items-center gap-4">
|
|
||||||
<img v-if="logoUrl" :src="logoUrl" alt="" class="h-[16vh] max-h-44 w-auto object-contain" />
|
|
||||||
<h1 class="text-3xl font-bold tracking-tight lg:text-5xl">{{ title }}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tap target -->
|
|
||||||
<div class="flex flex-col items-center gap-6">
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-center rounded-full border-4 border-primary bg-card shadow-xl"
|
|
||||||
:class="reading ? 'animate-pulse' : ''"
|
|
||||||
style="width: min(48vw, 15rem); aspect-ratio: 1 / 1"
|
|
||||||
>
|
|
||||||
<Nfc class="size-24 text-primary lg:size-28" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-2xl font-semibold text-foreground lg:text-4xl">
|
|
||||||
{{ reading ? 'Reading card…' : 'Tap your Bolt Card to begin' }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Reader status / denial reason -->
|
|
||||||
<p v-if="denyReason" class="text-lg font-medium text-destructive lg:text-2xl">
|
|
||||||
{{ denyReason }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-else-if="nfc?.message"
|
|
||||||
class="text-base lg:text-xl"
|
|
||||||
:class="
|
|
||||||
nfc.state === 'declined' || nfc.state === 'error'
|
|
||||||
? 'text-destructive'
|
|
||||||
: 'text-muted-foreground'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ nfc.message }}
|
|
||||||
</p>
|
|
||||||
<p v-else class="max-w-md text-center text-base text-muted-foreground lg:text-xl">
|
|
||||||
Hold your card flat against the reader
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dev affordances -->
|
|
||||||
<div class="mt-2 flex flex-col items-center gap-2">
|
|
||||||
<Button
|
|
||||||
v-if="showDevUnlock"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
|
|
||||||
@click="atmStore.devUnlock()"
|
|
||||||
>
|
|
||||||
Dev unlock
|
|
||||||
</Button>
|
|
||||||
<div v-if="atmStore.debugMode" class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
v-model="mockLnurlw"
|
|
||||||
placeholder="lnurlw://… (paste to simulate a tap)"
|
|
||||||
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:disabled="!mockLnurlw"
|
|
||||||
@click="atmStore.simulateBoltCardEntry(mockLnurlw)"
|
|
||||||
>
|
|
||||||
Tap
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"openEnrollment": true,
|
|
||||||
"devUnlock": false
|
|
||||||
}
|
|
||||||
|
|
@ -33,7 +33,7 @@ esac
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
echo "=== Building bitSpire ATM Live USB ISO (model: $MODEL) ==="
|
echo "=== Building Lamassu ATM Live USB ISO (model: $MODEL) ==="
|
||||||
echo ""
|
echo ""
|
||||||
echo "This is a pure Nix build — no local pnpm required."
|
echo "This is a pure Nix build — no local pnpm required."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
|
|
@ -3,122 +3,6 @@
|
||||||
|
|
||||||
{ config, lib, pkgs, pkgs-unstable, ... }:
|
{ config, lib, pkgs, pkgs-unstable, ... }:
|
||||||
|
|
||||||
let
|
|
||||||
# ── Firmware pruning (bitspire#70 sizing) ────────────────────────────
|
|
||||||
# hardware.enableRedistributableFirmware installs the entire linux-firmware
|
|
||||||
# tree: 752MB compressed, 16% of the image and its single largest item. The
|
|
||||||
# fleet is four fixed Intel boards. The other ~640MB is firmware for
|
|
||||||
# Qualcomm, Mellanox, NVIDIA, Marvell, AMD and MediaTek parts that will
|
|
||||||
# never appear in one of these machines.
|
|
||||||
#
|
|
||||||
# Keep only what a bitSpire board can plausibly load. Entries are paths
|
|
||||||
# inside lib/firmware; nothing outside this list is copied.
|
|
||||||
firmwareKeep = [
|
|
||||||
# Intel GPU. Gen9 (Apollo Lake) loads DMC from here. Bay Trail and
|
|
||||||
# Haswell load nothing, but 9.6MB is cheap insurance against a board swap.
|
|
||||||
"i915"
|
|
||||||
# Intel WiFi, 89MB and the bulk of what survives, covering every Intel
|
|
||||||
# card since 2008. This is the conservative half of the trade: losing the
|
|
||||||
# network on a deployed ATM is not remotely recoverable. Narrow it to the
|
|
||||||
# specific generation once each machine's card is known, via
|
|
||||||
# `lspci -k | grep -A3 Network` on the box.
|
|
||||||
"intel/iwlwifi"
|
|
||||||
"rtl_nic" # Realtek GbE (r8169) — the UP Board's onboard NIC
|
|
||||||
"rtw88" # Realtek WiFi, the usual M.2 or USB retrofit
|
|
||||||
"rtw89"
|
|
||||||
"brcm" # Broadcom WiFi, the other usual retrofit
|
|
||||||
# Intel Smart Sound Technology DSP, 420KB. Cherry Trail boards (sintra,
|
|
||||||
# tejo) probe intel_sst_acpi at boot whether or not anything will use the
|
|
||||||
# audio, and without the blob every boot logs
|
|
||||||
# Direct firmware load for intel/fw_sst_22a8.bin failed with error -2
|
|
||||||
# Found by pruning, rebooting sintra and reading dmesg. The audio stack is
|
|
||||||
# gone so this changes no behaviour, but a recurring error in a payment
|
|
||||||
# terminal's boot log is worth 420KB to remove: an error people learn to
|
|
||||||
# ignore is one they will ignore when it matters.
|
|
||||||
"intel/fw_sst_0f28.bin"
|
|
||||||
"intel/fw_sst_0f28_ssp0.bin"
|
|
||||||
"intel/fw_sst_22a8.bin"
|
|
||||||
];
|
|
||||||
|
|
||||||
# Prune the tree rather than hand-pick files, so a firmware bump can't
|
|
||||||
# silently drop a blob we depend on. Left UNCOMPRESSED on purpose: NixOS
|
|
||||||
# compresses each hardware.firmware entry itself, zstd or xz depending on
|
|
||||||
# what the machine's kernel understands, and douro's 5.15 predates zstd
|
|
||||||
# firmware support. Pre-compressing here would hand douro a tree it cannot
|
|
||||||
# read.
|
|
||||||
bitspireFirmware = pkgs.runCommand "linux-firmware-bitspire"
|
|
||||||
{
|
|
||||||
inherit (pkgs.linux-firmware) version;
|
|
||||||
meta = pkgs.linux-firmware.meta // {
|
|
||||||
description = "linux-firmware pruned to the hardware bitSpire ships on";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
''
|
|
||||||
src=${pkgs.linux-firmware}/lib/firmware
|
|
||||||
dst=$out/lib/firmware
|
|
||||||
mkdir -p "$dst"
|
|
||||||
|
|
||||||
for p in ${lib.escapeShellArgs firmwareKeep}; do
|
|
||||||
if [ ! -e "$src/$p" ]; then
|
|
||||||
echo "ERROR: firmwareKeep entry '$p' is not in linux-firmware" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mkdir -p "$dst/$(dirname "$p")"
|
|
||||||
cp -a "$src/$p" "$dst/$p"
|
|
||||||
done
|
|
||||||
|
|
||||||
# A kept directory can contain symlinks pointing at blobs OUTSIDE it:
|
|
||||||
# brcm/brcmfmac*.bin are links into cypress/, for instance. Left dangling
|
|
||||||
# they fail nixpkgs' firmware compression step, and silently deleting
|
|
||||||
# them would quietly drop firmware a device needs. So pull the targets in
|
|
||||||
# instead. Looped because a resolved target can itself be a link.
|
|
||||||
for _pass in 1 2 3; do
|
|
||||||
_pulled=0
|
|
||||||
while IFS= read -r link; do
|
|
||||||
tgt=$(readlink -m "$link")
|
|
||||||
case "$tgt" in
|
|
||||||
"$dst"/*) rel=''${tgt#"$dst"/} ;;
|
|
||||||
*) continue ;;
|
|
||||||
esac
|
|
||||||
if [ ! -e "$dst/$rel" ] && [ -e "$src/$rel" ]; then
|
|
||||||
mkdir -p "$dst/$(dirname "$rel")"
|
|
||||||
cp -a "$src/$rel" "$dst/$rel"
|
|
||||||
_pulled=1
|
|
||||||
fi
|
|
||||||
done < <(find "$dst" -xtype l)
|
|
||||||
[ "$_pulled" -eq 0 ] && break
|
|
||||||
done
|
|
||||||
|
|
||||||
# Anything still dangling is not in linux-firmware at all. Fail loudly
|
|
||||||
# rather than ship a tree with holes in it.
|
|
||||||
if find "$dst" -xtype l | grep -q .; then
|
|
||||||
echo "ERROR: dangling firmware symlinks after resolution:" >&2
|
|
||||||
find "$dst" -xtype l >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# linux-firmware stores many blobs under a vendor directory and leaves a
|
|
||||||
# flat top-level symlink pointing at them, e.g.
|
|
||||||
# iwlwifi-cc-a0-77.ucode -> intel/iwlwifi/iwlwifi-cc-a0-77.ucode. The
|
|
||||||
# kernel requests the flat name, so a kept blob is useless without its
|
|
||||||
# link. Recreate every top-level link whose target survived the prune.
|
|
||||||
( cd "$src"
|
|
||||||
find . -maxdepth 1 -type l -printf '%f\t%l\n' \
|
|
||||||
| while IFS="$(printf '\t')" read -r link target; do
|
|
||||||
# if/then, not `[ ... ] && ln`: the latter makes the loop's exit
|
|
||||||
# status depend on whether the LAST candidate matched, and a
|
|
||||||
# non-match returns 1, which set -e turns into a build failure.
|
|
||||||
# Whether it fails is then a function of readdir order.
|
|
||||||
if [ -e "$dst/$target" ]; then
|
|
||||||
ln -s "$target" "$dst/$link"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
)
|
|
||||||
|
|
||||||
echo "firmware kept: $(find "$dst" -type f | wc -l) files, \
|
|
||||||
$(find "$dst" -type l | wc -l) links, $(du -sh "$dst" | cut -f1) uncompressed"
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
{
|
{
|
||||||
# System basics
|
# System basics
|
||||||
system.stateVersion = "24.05";
|
system.stateVersion = "24.05";
|
||||||
|
|
@ -129,130 +13,10 @@ in
|
||||||
# - speechd: text-to-speech (speech-dispatcher → espeak-ng → mbrola, ~1GB).
|
# - speechd: text-to-speech (speech-dispatcher → espeak-ng → mbrola, ~1GB).
|
||||||
# An ATM does not talk.
|
# An ATM does not talk.
|
||||||
# - documentation: man/info/NixOS manual — no one reads them on a kiosk.
|
# - documentation: man/info/NixOS manual — no one reads them on a kiosk.
|
||||||
# - pipewire: the audio stack (+ WirePlumber, ALSA, the PulseAudio shim),
|
|
||||||
# ~353MB. The app has never played a sound — nothing under apps/machine or
|
|
||||||
# packages/ constructs an Audio element or ships an audio file.
|
|
||||||
#
|
|
||||||
# All three need mkForce, not just an absent/false assignment: enabling
|
|
||||||
# services.xserver pulls in NixOS's `graphical-desktop` module, which
|
|
||||||
# mkDefault-enables speechd AND pipewire (services/misc/graphical-desktop.nix).
|
|
||||||
# Dropping our own `enable = true` simply falls back to that default — the
|
|
||||||
# 353MB stayed until this was forced off. Re-enable pipewire (with alsa +
|
|
||||||
# pulse) and security.rtkit if transaction sounds are ever added.
|
|
||||||
services.speechd.enable = lib.mkForce false;
|
services.speechd.enable = lib.mkForce false;
|
||||||
services.pipewire.enable = lib.mkForce false;
|
|
||||||
documentation.enable = false;
|
documentation.enable = false;
|
||||||
documentation.nixos.enable = false;
|
documentation.nixos.enable = false;
|
||||||
|
|
||||||
# Ship the pruned firmware tree instead of all of linux-firmware. mkForce
|
|
||||||
# because every hardware/*.nix sets enableRedistributableFirmware = true;
|
|
||||||
# overriding once here keeps the four machines in step. Turning that option
|
|
||||||
# off also drops the extras it bundles (sof-firmware, libreelec-dvb,
|
|
||||||
# alsa-firmware, intel2200BG, zd1211fw and friends), none of which applies to
|
|
||||||
# a soundless kiosk on a wired Intel board. The regulatory database is
|
|
||||||
# normally implied by the same option, so ask for it explicitly: without it
|
|
||||||
# WiFi is pinned to the most restrictive channel set.
|
|
||||||
hardware.enableRedistributableFirmware = lib.mkForce false;
|
|
||||||
hardware.wirelessRegulatoryDatabase = true;
|
|
||||||
hardware.firmware = [ bitspireFirmware ];
|
|
||||||
|
|
||||||
# Make the prune stick. Without this, a nixpkgs bump or a stray module
|
|
||||||
# setting enableRedistributableFirmware back to true silently re-adds 750MB
|
|
||||||
# and nobody notices until an eMMC runs out of room at 04:00. The regex
|
|
||||||
# matches the upstream package's versioned name (linux-firmware-20260519)
|
|
||||||
# and deliberately not ours (linux-firmware-bitspire), so the pruned tree
|
|
||||||
# passes and the full one fails the build with a readable error.
|
|
||||||
system.forbiddenDependenciesRegexes = [ "linux-firmware-[0-9]" ];
|
|
||||||
|
|
||||||
# Mesa without an LLVM-backed rasterizer.
|
|
||||||
#
|
|
||||||
# nixpkgs builds Mesa with 21 gallium drivers. Two of them, llvmpipe and
|
|
||||||
# radeonsi, link LLVM, and that RPATH pulls llvm-21-lib into the system
|
|
||||||
# closure: 540MB, a ninth of the image, on a kiosk with a soldered Intel GPU.
|
|
||||||
#
|
|
||||||
# The driver list has to span three Intel generations:
|
|
||||||
# crocus EVERY machine in the fleet. Surveyed, not assumed: sintra
|
|
||||||
# and tejo are Braswell [8086:22b0], batm3 is Haswell GT2
|
|
||||||
# [8086:0412], douro is Bay Trail. sintra and batm3 were read
|
|
||||||
# straight off their running X logs; both say crocus.
|
|
||||||
# i915 pre-Gen4, insurance against an older board turning up.
|
|
||||||
# softpipe the software rasterizer that does NOT use LLVM. Kept so a
|
|
||||||
# board whose KMS driver fails still brings up X, slowly,
|
|
||||||
# rather than dying headless in the field.
|
|
||||||
#
|
|
||||||
# ── IRIS IS DELIBERATELY ABSENT AND RE-ADDING IT COSTS 540MB ────────
|
|
||||||
# iris covers Gen8+ big-core Intel, which nothing here has. Its absence is
|
|
||||||
# what lets -Dllvm=disabled below work: mesa's meson puts
|
|
||||||
# with_gallium_iris in with_driver_using_cl and then
|
|
||||||
# with_llvm.enable_if(with_clc, 'CLC requires LLVM')
|
|
||||||
# so asking for iris drags in the OpenCL frontend and the whole of
|
|
||||||
# llvm-lib. Mesa's closure is 88MB without iris, 633MB with.
|
|
||||||
#
|
|
||||||
# A newer x86 board — a modern NUC, the "build it from these parts" kiosk
|
|
||||||
# — WILL need iris. Until one exists, such a board falls back to softpipe
|
|
||||||
# and renders in software: it boots, it displays, it looks fine, and it is
|
|
||||||
# very slow. Check `DRI driver:` in /var/log/X.0.log on any new hardware
|
|
||||||
# rather than assuming this list still covers it.
|
|
||||||
# i915 pre-Gen4, insurance against an older board turning up
|
|
||||||
# softpipe the software rasterizer that does NOT use LLVM. Kept so a
|
|
||||||
# board whose KMS driver fails still brings up X, slowly,
|
|
||||||
# rather than dying headless in the field. This is the role
|
|
||||||
# llvmpipe was playing, for 540MB.
|
|
||||||
#
|
|
||||||
# Vulkan is emptied because nothing here uses it, and its software ICD
|
|
||||||
# (lavapipe) is the other LLVM consumer. The VDPAU and VA state trackers
|
|
||||||
# have to go with it: meson refuses to build them unless one of the AMD or
|
|
||||||
# NVIDIA gallium drivers is present. Intel VA-API is unaffected, it comes
|
|
||||||
# from intel-media-driver in hardware/*.nix.
|
|
||||||
hardware.graphics.package =
|
|
||||||
(pkgs.mesa.override {
|
|
||||||
galliumDrivers = [ "crocus" "i915" "softpipe" ];
|
|
||||||
vulkanDrivers = [ ];
|
|
||||||
vulkanLayers = [ ];
|
|
||||||
}).overrideAttrs
|
|
||||||
(old: {
|
|
||||||
mesonFlags = old.mesonFlags ++ [
|
|
||||||
# Severs LLVM outright. Only possible because iris is out of the
|
|
||||||
# driver list above; with iris present meson refuses this flag.
|
|
||||||
# Verified with patchelf: libgallium.so ends up with no libLLVM in
|
|
||||||
# its DT_NEEDED, not merely absent from the closure listing.
|
|
||||||
# Dropping llvmpipe alone never achieved this.
|
|
||||||
(lib.mesonEnable "llvm" false)
|
|
||||||
(lib.mesonBool "gallium-rusticl" false)
|
|
||||||
# nixpkgs builds the asahi/panfrost cross tools and installs
|
|
||||||
# mesa-clc on native builds. Both reference prog_mesa_clc, which
|
|
||||||
# exists only when CLC is on, so they go with LLVM. An x86 kiosk
|
|
||||||
# has no use for either.
|
|
||||||
(lib.mesonOption "tools" "")
|
|
||||||
(lib.mesonBool "install-mesa-clc" false)
|
|
||||||
(lib.mesonBool "install-precomp-compiler" false)
|
|
||||||
(lib.mesonEnable "gallium-vdpau" false)
|
|
||||||
(lib.mesonEnable "gallium-va" false)
|
|
||||||
(lib.mesonEnable "intel-rt" false)
|
|
||||||
];
|
|
||||||
# Mesa declares spirv2dxil and cross_tools as outputs unconditionally,
|
|
||||||
# but they only receive files when the d3d12, asahi or panfrost gallium
|
|
||||||
# drivers are built, and none of those are in the list above. Nix fails
|
|
||||||
# a build that leaves a declared output unproduced, so create them
|
|
||||||
# empty. (Mesa sets __structuredAttrs, so $outputs is a bash array and
|
|
||||||
# a plain `for o in $outputs` loop silently does nothing here.)
|
|
||||||
postInstall = (old.postInstall or "") + ''
|
|
||||||
mkdir -p "$spirv2dxil" "$cross_tools" "$opencl"
|
|
||||||
'';
|
|
||||||
|
|
||||||
# With rusticl off there is no libRusticlOpenCL.so, and Mesa's
|
|
||||||
# postFixup patchelfs it unconditionally. Drop just that argument.
|
|
||||||
# The assert makes a nixpkgs bump that reshapes this line fail loudly
|
|
||||||
# here rather than silently stop removing LLVM.
|
|
||||||
postFixup =
|
|
||||||
let
|
|
||||||
marker = " $opencl/lib/libRusticlOpenCL.so";
|
|
||||||
in
|
|
||||||
assert lib.assertMsg (lib.hasInfix marker old.postFixup)
|
|
||||||
"mesa postFixup no longer patchelfs libRusticlOpenCL.so; revisit this override";
|
|
||||||
lib.replaceStrings [ marker ] [ "" ] old.postFixup;
|
|
||||||
});
|
|
||||||
|
|
||||||
# Networking
|
# Networking
|
||||||
networking = {
|
networking = {
|
||||||
hostName = "bitspire";
|
hostName = "bitspire";
|
||||||
|
|
@ -335,38 +99,38 @@ in
|
||||||
user = "bitspire";
|
user = "bitspire";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Audio (for transaction sounds)
|
||||||
|
security.rtkit.enable = true;
|
||||||
|
services.pipewire = {
|
||||||
|
enable = true;
|
||||||
|
alsa.enable = true;
|
||||||
|
pulse.enable = true;
|
||||||
|
};
|
||||||
|
|
||||||
# System packages
|
# System packages
|
||||||
#
|
|
||||||
# Kept deliberately thin — this is a kiosk, and every entry here is closure
|
|
||||||
# that ships to each ATM and eats eMMC headroom the nightly rebuild needs.
|
|
||||||
# Deliberately absent (see #70 sizing):
|
|
||||||
# git 70MB. nixos-rebuild fetches the flake with its OWN git-minimal,
|
|
||||||
# which stays in the closure via unit-nixos-upgrade.service, so
|
|
||||||
# auto-upgrade is unaffected.
|
|
||||||
# vim 43MB. Replaced by nano — an on-box editor is worth a few MB for
|
|
||||||
# field edits to /var/lib/bitspire/.env, vim's bulk is not.
|
|
||||||
# nodejs_22 94MB. Nothing runs it: the app is Electron (which embeds its
|
|
||||||
# own node) and fund-atm already pins pkgs-unstable.nodejs itself.
|
|
||||||
# wget curl covers it.
|
|
||||||
environment.systemPackages = with pkgs; [
|
environment.systemPackages = with pkgs; [
|
||||||
# System utilities
|
# System utilities
|
||||||
htop
|
htop
|
||||||
nano
|
vim
|
||||||
|
git
|
||||||
curl
|
curl
|
||||||
|
wget
|
||||||
|
|
||||||
# Hardware debugging
|
# Hardware debugging
|
||||||
usbutils
|
usbutils
|
||||||
pciutils
|
pciutils
|
||||||
lsof
|
lsof
|
||||||
|
|
||||||
# Serial port tools (validator/dispenser live on ttyJ5/ttyJ7 — these are
|
# Serial port tools
|
||||||
# how a field fault gets diagnosed, and they cost ~2MB between them)
|
|
||||||
minicom
|
minicom
|
||||||
screen
|
screen
|
||||||
|
|
||||||
# For the Electron app
|
# For the Electron app
|
||||||
pkgs-unstable.electron
|
pkgs-unstable.electron
|
||||||
|
|
||||||
|
# Node.js for the application
|
||||||
|
pkgs-unstable.nodejs_22
|
||||||
|
|
||||||
# Camera support. v4l-utils' default build drags in the whole Qt6 stack
|
# Camera support. v4l-utils' default build drags in the whole Qt6 stack
|
||||||
# for its qv4l2 GUI (~0.5GB) — we only ever use the v4l2-ctl CLI, so drop
|
# for its qv4l2 GUI (~0.5GB) — we only ever use the v4l2-ctl CLI, so drop
|
||||||
# the GUI.
|
# the GUI.
|
||||||
|
|
@ -395,18 +159,6 @@ in
|
||||||
# Auto-updates (optional - disabled by default for stability)
|
# Auto-updates (optional - disabled by default for stability)
|
||||||
# system.autoUpgrade.enable = false;
|
# system.autoUpgrade.enable = false;
|
||||||
|
|
||||||
# Trust the Forgejo host key up front. system.autoUpgrade fetches the flake
|
|
||||||
# over ssh AS ROOT, and a machine whose root has never connected by hand has
|
|
||||||
# no known_hosts entry, so every nightly run dies at
|
|
||||||
# "Host key verification failed" before it reaches authentication. batm3 did
|
|
||||||
# exactly that, silently, from its 2026-08-06 install until 09-22 (#98): it
|
|
||||||
# sat on its install generation for six weeks while reporting a failed unit
|
|
||||||
# nobody was watching. sintra only ever worked because a human had ssh'd as
|
|
||||||
# root once and accepted the key. Declaring it means a freshly flashed ATM
|
|
||||||
# can update from first boot with no manual step.
|
|
||||||
programs.ssh.knownHosts."git.atitlan.io".publicKey =
|
|
||||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMlo3f05o4+bk0+8x2VG91o9GubshOb46HmBPvND9pJx";
|
|
||||||
|
|
||||||
# pragma: allowlist secret
|
# pragma: allowlist secret
|
||||||
# Ensure WireGuard private key directory exists with correct permissions
|
# Ensure WireGuard private key directory exists with correct permissions
|
||||||
system.activationScripts.wireguard-key = ''
|
system.activationScripts.wireguard-key = ''
|
||||||
|
|
@ -417,40 +169,6 @@ in
|
||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# The tunnel is operator-provisioned: wg0.key is written per machine after
|
|
||||||
# flashing, and until it is, `wg set … private-key` exits 1 with
|
|
||||||
# "fopen: No such file or directory". One failed unit makes
|
|
||||||
# switch-to-configuration exit 4, which marks the entire nightly
|
|
||||||
# system.autoUpgrade run as failed — so an ATM that simply never had its
|
|
||||||
# tunnel provisioned reports a broken updater for the life of the machine
|
|
||||||
# (sintra, #98). Skip the unit when there is no key instead of failing
|
|
||||||
# activation over an interface that was never set up; a provisioned machine
|
|
||||||
# is unaffected. Guarded on wg0 still being declared so the live image,
|
|
||||||
# which mkForce's the interfaces away, doesn't get a unit with no ExecStart.
|
|
||||||
systemd.services = lib.mkIf (config.networking.wireguard.interfaces ? wg0) (
|
|
||||||
let
|
|
||||||
iface = config.networking.wireguard.interfaces.wg0;
|
|
||||||
guard = { unitConfig.ConditionPathExists = "/var/lib/wireguard/wg0.key"; };
|
|
||||||
# The module emits one unit per peer alongside the interface unit, and a
|
|
||||||
# skipped interface is NOT a failed dependency, so the peer units still
|
|
||||||
# run and die on "Unable to modify interface: No such device" — same
|
|
||||||
# exit 4, different unit. Guard them too. Names come from the module's
|
|
||||||
# own `peers.*.name` option (whose default is the escaped public key)
|
|
||||||
# rather than re-deriving the escaping here; the `-refresh` suffix
|
|
||||||
# follows nixpkgs' peerUnitServiceName, where a peer's null refresh
|
|
||||||
# interval falls back to the interface's.
|
|
||||||
refreshes = peer:
|
|
||||||
(if peer.dynamicEndpointRefreshSeconds != null then
|
|
||||||
peer.dynamicEndpointRefreshSeconds
|
|
||||||
else
|
|
||||||
iface.dynamicEndpointRefreshSeconds) != 0;
|
|
||||||
peerUnit = peer:
|
|
||||||
"wireguard-wg0-peer-${peer.name}" + lib.optionalString (refreshes peer) "-refresh";
|
|
||||||
in
|
|
||||||
{ wireguard-wg0 = guard; }
|
|
||||||
// lib.listToAttrs (map (peer: lib.nameValuePair (peerUnit peer) guard) iface.peers)
|
|
||||||
);
|
|
||||||
|
|
||||||
# In-place rename migration: lamassu user → bitspire user.
|
# In-place rename migration: lamassu user → bitspire user.
|
||||||
# Runs after `users` activation so the bitspire user exists with its UID.
|
# Runs after `users` activation so the bitspire user exists with its UID.
|
||||||
# Idempotent: re-running on an already-migrated system is a chown no-op.
|
# Idempotent: re-running on an already-migrated system is a chown no-op.
|
||||||
|
|
|
||||||
|
|
@ -91,27 +91,6 @@
|
||||||
cpuFreqGovernor = "performance";
|
cpuFreqGovernor = "performance";
|
||||||
};
|
};
|
||||||
|
|
||||||
# PC/SC daemon for the HID Global OMNIKEY 5022 contactless reader
|
|
||||||
# (076b:5022, a CCID smart-card reader) used for Bolt Card tap-to-enter
|
|
||||||
# (ADR-003). pcscd binds the CCID driver; the app talks to pcscd's socket
|
|
||||||
# (via nfc-pcsc) rather than the USB device directly. Device-agnostic —
|
|
||||||
# same wiring as batm3's Feitian KP382; harmless if no reader is attached,
|
|
||||||
# pcscd just idles. Shared by every upboard machine (sintra, tejo).
|
|
||||||
services.pcscd.enable = true;
|
|
||||||
|
|
||||||
# pcscd gates client access via polkit; without a rule the sandboxed
|
|
||||||
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
|
|
||||||
# it to talk to the daemon and the card.
|
|
||||||
security.polkit.extraConfig = ''
|
|
||||||
polkit.addRule(function(action, subject) {
|
|
||||||
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
|
||||||
action.id == "org.debian.pcsc-lite.access_card") &&
|
|
||||||
subject.user == "bitspire") {
|
|
||||||
return polkit.Result.YES;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Disable suspend/hibernate for kiosk
|
# Disable suspend/hibernate for kiosk
|
||||||
systemd.targets = {
|
systemd.targets = {
|
||||||
sleep.enable = false;
|
sleep.enable = false;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# bitSpire ATM Live USB Configuration
|
# Lamassu ATM Live USB Configuration
|
||||||
# Bootable ISO for testing on physical hardware without installing to disk.
|
# Bootable ISO for testing on physical hardware without installing to disk.
|
||||||
#
|
#
|
||||||
# Parameterized by machineModel (passed via specialArgs from flake.nix):
|
# Parameterized by machineModel (passed via specialArgs from flake.nix):
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
# Does NOT import hardware/upboard.nix (its fileSystems conflict with live boot).
|
# Does NOT import hardware/upboard.nix (its fileSystems conflict with live boot).
|
||||||
# Instead, duplicates only the hardware-relevant kernel modules and GPU config.
|
# Instead, duplicates only the hardware-relevant kernel modules and GPU config.
|
||||||
|
|
||||||
{ config, lib, pkgs, pkgs-unstable, nixpkgs, machineModel ? "douro", atm-app, kioskLauncher, ... }:
|
{ config, lib, pkgs, pkgs-unstable, nixpkgs, machineModel ? "douro", atm-app, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
# Fiat code per machine model (for envTemplate display only)
|
# Fiat code per machine model (for envTemplate display only)
|
||||||
|
|
@ -162,7 +162,7 @@ in
|
||||||
Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib";
|
Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib";
|
||||||
# Electron needs --no-sandbox in the live/testing environment
|
# Electron needs --no-sandbox in the live/testing environment
|
||||||
# --enable-logging makes renderer console.log visible in journalctl
|
# --enable-logging makes renderer console.log visible in journalctl
|
||||||
ExecStart = lib.mkForce "${kioskLauncher}";
|
ExecStart = lib.mkForce "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-gpu --disable-software-rasterizer --enable-logging ${atm-app}";
|
||||||
# Prevent Electron from consuming all RAM on memory-constrained ATMs
|
# Prevent Electron from consuming all RAM on memory-constrained ATMs
|
||||||
MemoryMax = lib.mkForce "1G";
|
MemoryMax = lib.mkForce "1G";
|
||||||
# Disable all security hardening that conflicts with Electron
|
# Disable all security hardening that conflicts with Electron
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Provision the access-control gate (ADR-003) to a deployed bitSpire ATM.
|
|
||||||
# Pushes an access.json to /var/lib/bitspire/ and restarts the service, so the
|
|
||||||
# gate can be toggled on a machine without an image rebuild (mirrors
|
|
||||||
# provision-branding.sh). Env defaults are overridden by whatever this file sets.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# bash provision-access.sh <access.json> # SSH to localhost:2222 (QEMU)
|
|
||||||
# bash provision-access.sh <access.json> 192.168.1.50 # a real ATM on the LAN
|
|
||||||
# bash provision-access.sh <access.json> 192.168.1.50 22 # custom SSH port
|
|
||||||
#
|
|
||||||
# access.json schema (all keys optional; omitted keys fall back to env/defaults):
|
|
||||||
# {
|
|
||||||
# "enabled": true, // master switch for the gate
|
|
||||||
# "openEnrollment": true, // admit any Bolt Card (gate is not a security boundary)
|
|
||||||
# "devUnlock": false, // on-screen dev/operator unlock (bypasses the gate; default off)
|
|
||||||
# "salt": "per-machine", // hashing salt (provision a real one for prod)
|
|
||||||
# "allowList": [ // authorized identities (hashed); empty in open mode
|
|
||||||
# { "idHash": "<hashId(external_id,salt)>", "role": "user", "pinHash": "<hashPin(pin,salt)>" }
|
|
||||||
# ]
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
# To DISABLE the gate again: push a file with {"enabled": false} (or delete
|
|
||||||
# /var/lib/bitspire/access.json on the machine) and restart.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ACCESS_FILE="${1:-}"
|
|
||||||
ATM_HOST="${2:-localhost}"
|
|
||||||
ATM_SSH_PORT="${3:-2222}"
|
|
||||||
ATM_USER="bitspire"
|
|
||||||
REMOTE_FILE="/var/lib/bitspire/access.json"
|
|
||||||
|
|
||||||
if [ -z "$ACCESS_FILE" ]; then
|
|
||||||
echo "Usage: $0 <access.json> [host] [port]" >&2
|
|
||||||
echo " $0 ./access.json (QEMU on localhost:2222)" >&2
|
|
||||||
echo " $0 ./access.json 192.168.1.50 (real ATM)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$ACCESS_FILE" ]; then
|
|
||||||
echo "ERROR: access file not found: $ACCESS_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fail fast on malformed JSON before touching the machine.
|
|
||||||
if command -v jq >/dev/null 2>&1; then
|
|
||||||
jq empty "$ACCESS_FILE" || { echo "ERROR: $ACCESS_FILE is not valid JSON" >&2; exit 1; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Provisioning access gate to $ATM_HOST:$ATM_SSH_PORT ==="
|
|
||||||
echo "Local file : $ACCESS_FILE"
|
|
||||||
echo "Remote file: $REMOTE_FILE"
|
|
||||||
cat "$ACCESS_FILE"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Copy over SSH. --rsync-path=sudo because /var/lib/bitspire is owned by the
|
|
||||||
# bitspire service user, not the SSH user.
|
|
||||||
rsync -avz \
|
|
||||||
--rsync-path="sudo rsync" \
|
|
||||||
-e "ssh -o StrictHostKeyChecking=no -p $ATM_SSH_PORT" \
|
|
||||||
"$ACCESS_FILE" \
|
|
||||||
"$ATM_USER@$ATM_HOST:$REMOTE_FILE"
|
|
||||||
|
|
||||||
# Restart so loadAccessControl() re-reads the file.
|
|
||||||
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" \
|
|
||||||
"sudo systemctl restart bitspire"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Access gate provisioned. Service restarted. ==="
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# bitSpire ATM Hardware udev Rules
|
# Lamassu ATM Hardware udev Rules
|
||||||
# Place in /etc/udev/rules.d/ or use services.udev.extraRules in NixOS
|
# Place in /etc/udev/rules.d/ or use services.udev.extraRules in NixOS
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
# ADR-003: NFC Access-Control Layer (badge-to-enter) + Developer Bypass
|
|
||||||
|
|
||||||
**Status:** Accepted — amended 2026-09-20 (see [Amendment](#amendment-2026-09-20-what-shipped) below; the original text follows it unchanged)
|
|
||||||
**Date:** 2026-07-29
|
|
||||||
**Context:** batm3 gaining a physical access layer — an NFC card must be presented to unlock the machine before anyone can transact. Reader hardware is not yet on hand; this ADR defines the direction and a non-breaking skeleton that is fully testable without it.
|
|
||||||
|
|
||||||
## Amendment (2026-09-20): what shipped
|
|
||||||
|
|
||||||
The gate landed in aiolabs/bitspire#86 as **Bolt Card tap-to-enter**, not the npub-QR → UID → serial-reader path planned below. The decisions still stand (opt-in `locked` state, operator-owned authorization, hashed identities, fail-closed, audited); the mechanism differs:
|
|
||||||
|
|
||||||
- **Reader.** The batm3 / upboard reader is a USB CCID contactless reader (Feitian KP382, OMNIKEY 5022) driven by `pcscd` + `nfc-pcsc` in the **main process** (`electron/nfc-service.ts`, from #83), not the serial `/dev/ttyNFC` device or Web NFC. Taps reach the renderer over the existing `nfc:card-tapped` IPC and the store routes them by state (`locked` → enter; the cash screens → pay / receive). The renderer-side `AccessReader` abstraction, the camera npub-QR reader and the mock reader were never wired to anything and have been removed; `services/access/` now holds only `authorize`, the Bolt Card parser and the credential types.
|
|
||||||
- **Credential.** Identity is the card's boltcards `external_id`, parsed **locally** from the tapped `lnurlw` (`AccessScan` kind `boltcard`), not the NFC UID. Only `hashId(external_id, salt)` is compared or logged. The `npub` variant (with its PIN second factor) stays in `authorize()` and its tests for a future non-card credential; `challenge` remains the v2 seam; the planned `uid` variant is gone.
|
|
||||||
- **Verified entry via `/session`** (superseding #86's soft entry). A tap yields a single-use SUN `p`/`c`, and verifying it at entry would have spent the voucher Complete needed, so #86 made no server call at entry. The fork now exposes `/session/<id>?p=&c=` (`docs/boltcard-session.md`): it spends the SUN **once**, proves a genuine non-replayed card, returns the card wallet's balance + fiat, and hands back the hit-keyed LUD-03 / LUD-06 second steps so Complete still needs no second tap. The session is single-shot — after the first Complete attempt, accepted or declined, the store drops it and the customer re-taps. The balance is shown on the idle menu and cash screens **hidden by default** behind an eye toggle (`CardChip.vue`), priced the way the LNbits wallet page prices it (wallet currency, else instance default, else the ATM's fiat at its own rate).
|
|
||||||
- **Open enrollment is still not a security boundary.** `/session` proves the card is genuine *to the server the card names*: the session URL is derived from the tapped `lnurlw`'s host, so with `openEnrollment` on a forged NDEF tag pointing at a server that answers `authenticated: true` still unlocks the terminal. Money is unaffected (cash-out never dispenses without `PAYMENT_RECEIVED`; cash-in pays where the holder pointed). Closing the gate means pinning the accepted card-server host(s) and/or a provisioned allow-list — tracked in aiolabs/bitspire#91.
|
|
||||||
- **Session semantics.** One tap = one session; every transaction terminal state returns to `locked`. Inactivity (60 s on the idle menu) and the absolute cap (10 min) are measured at the DOM layer (`useSessionSecurity`) because an XState `after` cannot observe touches. Both send `END_SESSION`, which the machine accepts **from `idle` only**, so a timer can never abandon stacked bills or an in-flight dispense. An explicit End Session button re-locks immediately.
|
|
||||||
- **Config.** `ACCESS_CONTROL_ENABLED` / `ACCESS_OPEN_ENROLLMENT` / `ACCESS_DEV_UNLOCK` / `ACCESS_SALT` from env, overridden by `/var/lib/bitspire/access.json` (pushed with `deploy/nixos/provision-access.sh`, no rebuild). `devUnlock` defaults **off**.
|
|
||||||
- **Audit** is still a console stub; the state.db write promised in decision 7 is tracked in aiolabs/bitspire#90.
|
|
||||||
- **Phased plan superseded.** PR3 (Web NFC) and PR4 (serial HAL) will not happen — the pcscd reader covers real hardware and there is no laptop dev path beyond the debug paste-box on `LockedView`. PR5's challenge-response idea survives as the `challenge` seam.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
1. **Add a top-level `locked` state to the ATM state machine, and make it the initial state.** It sits *below* the existing initialization gates (`unpaired` / `awaiting-fees` / `maintenance` / `signer-unreachable`, which live in `App.vue`). A healthy, paired machine boots into `locked` and only reveals `idle` (Buy/Sell) after an access grant.
|
|
||||||
|
|
||||||
2. **Access control is opt-in via runtime config (`accessControl.enabled`, default `false`).** When disabled, the machine behaves exactly as today (boots straight to `idle`). This makes the whole feature non-breaking for the current test unit and for production ATMs, and lets a half-built access layer never brick a working box. This is the single most important constraint on the design.
|
|
||||||
|
|
||||||
3. **The reader lives behind an `AccessReader` abstraction that mirrors the existing `PairingSource` seam.** First implementation is a `MockAccessReader` (dev button / hotkey), so the locked→idle→transaction path is exercisable today with zero hardware. Web NFC and serial-NFC implementations follow.
|
|
||||||
|
|
||||||
4. **Credential model is a discriminated union with an explicit upgrade path.** v1 = card UID matched against a hashed allow-list. v2 = challenge-response (card-held key signs a machine nonce), verified against an operator-authorized set. Ship v1; design the types so v2 is additive.
|
|
||||||
|
|
||||||
5. **Three-tier developer bypass**, following existing conventions: a build flag (`VITE_SKIP_ACCESS_GATE`), the config disable (`accessControl.enabled=false`), and a runtime operator/dev unlock gesture that dispatches a synthetic grant.
|
|
||||||
|
|
||||||
6. **Authorization is owned by the machine operator, not the SaaS operator** — consistent with [ADR-002](./002-remote-access-and-fleet-management.md). The card allow-list is authorized by the operator key (the [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) allow-list mechanism), local first, operator-synced later. When access control is *enabled* and the reader is absent/broken, the machine **fails closed** (with the operator/dev unlock as the escape hatch); when *disabled*, reader state is irrelevant.
|
|
||||||
|
|
||||||
7. **Every grant/deny is audited** to `state.db` (hashed credential + timestamp + role + outcome), with optional later publication as a Nostr event. No PII, consistent with the KYC-free principle.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
### What this is (and what it is not)
|
|
||||||
|
|
||||||
This is the **end-user physical access plane**: a person must badge in to use the machine. It is distinct from the three planes in [ADR-002](./002-remote-access-and-fleet-management.md) — it is *not* the operator's SSH/NetBird recovery plane, and *not* the SaaS payment plane. It shares one idea with ADR-002: **the machine operator owns who is authorized**, expressed through the operator key / #42 allow-list.
|
|
||||||
|
|
||||||
### Why the codebase is well-shaped for this
|
|
||||||
|
|
||||||
Three seams already exist; we extend them rather than invent:
|
|
||||||
|
|
||||||
- **The idle→transaction transition is unguarded.** `packages/state-machine/src/machine.ts` starts at `initial: 'idle'` (~L431) and `idle` moves to `cashIn`/`cashOut` via plain `SELECT_CASH_IN` / `SELECT_CASH_OUT` transitions with no guards (~L457-464). Inserting a `locked` predecessor state is a localized change.
|
|
||||||
- **`PairingSource` is a reader abstraction designed to grow.** Its doc (`apps/machine/src/services/pairing/types.ts`) explicitly anticipates *"an NFC reader or a HAL barcode scanner… a HAL-scanner source can be added the same way without touching the wizard."* `AccessReader` mirrors it: `qr-source.ts` / `nfc-source.ts` → `mock-reader.ts` / `web-nfc-reader.ts` / `serial-reader.ts`.
|
|
||||||
- **Dev-flag and config conventions are established.** `import.meta.env.VITE_* === 'true'` (e.g. `VITE_MAINTENANCE_MODE`, `VITE_FORCE_MOCK`), plus Electron `get-config` fields that the renderer reads (`electron/main.ts` L280–312: `maintenanceMode`, `branding`). A new `accessControl` config field and a `VITE_SKIP_ACCESS_GATE` flag follow the same shape.
|
|
||||||
|
|
||||||
### Hardware reality check (important)
|
|
||||||
|
|
||||||
The batm3 already exposes an NFC device, but it is **serial**: `deploy/nixos/hardware/batm3.nix` L154 maps udev serial `A9ZF8ELY` → `/dev/ttyNFC`. The *existing* `pairing/nfc-source.ts` uses **Web NFC** (`NDEFReader`), which drives a phone/laptop NFC radio, **not** a serial reader. So the real batm3 access reader needs a **main-process serial driver** (HAL-style, per [ADR-001](./001-hal-architecture.md)) exposing card events to the renderer over IPC — the Web NFC path is only useful for laptop/phone dev. This ADR keeps that driver as a clearly-scoped later PR so the skeleton doesn't pretend the scaffold "just works" on the panel.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Boot / render layering
|
|
||||||
|
|
||||||
```
|
|
||||||
Electron get-config ─┐
|
|
||||||
▼
|
|
||||||
App.vue init gates (unchanged):
|
|
||||||
unpaired? → PairingWizard
|
|
||||||
initError (maintenance / awaiting-fees / signer-unreachable)? → maintenance screen
|
|
||||||
else ▼
|
|
||||||
State machine (paired + healthy):
|
|
||||||
┌───────────────────────────────────────────────┐
|
|
||||||
│ locked ──ACCESS_GRANTED──▶ idle │ ← NEW initial state
|
|
||||||
│ ▲ │ SELECT_CASH_* │
|
|
||||||
│ │ re-lock (session end / ▼ │
|
|
||||||
│ │ inactivity / complete) cashIn / cashOut │
|
|
||||||
│ └──────────────────────────┘ │
|
|
||||||
└───────────────────────────────────────────────┘
|
|
||||||
(when accessControl.enabled === false,
|
|
||||||
`locked` immediately `always`-bypasses to `idle`)
|
|
||||||
```
|
|
||||||
|
|
||||||
The access gate is strictly below App.vue's init gates: a machine that is unpaired or in maintenance never reaches `locked`.
|
|
||||||
|
|
||||||
### 1. State machine (`packages/state-machine`)
|
|
||||||
|
|
||||||
- New top-level state `locked`, `initial: 'locked'`.
|
|
||||||
- New events on the machine's event union: `ACCESS_GRANTED` (carries an authorized `CardCredential` + resolved role), `ACCESS_DENIED` (carries a reason), `DEV_UNLOCK`.
|
|
||||||
- `locked` transitions:
|
|
||||||
- `always: [{ guard: 'accessBypass', target: 'idle' }]` — instant pass-through when disabled/bypassed (no UI flicker; the view is gated on the same predicate).
|
|
||||||
- `on: { ACCESS_GRANTED: { target: 'idle', actions: ['startSession', 'recordAccessGrant'] }, ACCESS_DENIED: { actions: 'recordAccessDeny' }, DEV_UNLOCK: { guard: 'devUnlockAllowed', target: 'idle', actions: 'startDevSession' } }`.
|
|
||||||
- Re-lock: the existing `complete` auto-return (currently 60s → `idle`) and the inactivity timeouts (`INACTIVITY_TIMEOUT`/`TIMEOUT_MS`, ~L422-429) target `locked` instead of `idle`. Because `locked` `always`-bypasses when disabled, this is one code path for both modes.
|
|
||||||
- Purity: the state-machine package must not read Vite env. `accessControl.enabled` and the bypass boolean are passed in as **actor input → context** (`context.accessControlEnabled`, `context.accessBypass`); guard `accessBypass` reads context only. New context fields: `accessControlEnabled`, `accessBypass`, `session` (`{ role, grantedAt, credentialIdHash } | null`).
|
|
||||||
- Guards: `accessBypass`, `devUnlockAllowed`. Actions: `startSession`, `startDevSession`, `recordAccessGrant`, `recordAccessDeny`, and `resetContext` extended to clear `session`.
|
|
||||||
|
|
||||||
### 2. Config plumbing
|
|
||||||
|
|
||||||
- `apps/machine/src/types/electron.d.ts` — extend `RuntimeConfig` (L5) with:
|
|
||||||
```ts
|
|
||||||
accessControl: {
|
|
||||||
enabled: boolean // default false
|
|
||||||
devUnlock: boolean // allow the runtime operator/dev unlock gesture
|
|
||||||
// v2: allowListSource, challengeRequired, …
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `apps/machine/electron/main.ts` — the `get-config` handler (L280) returns `accessControl`, sourced from env for now (`ACCESS_CONTROL_ENABLED === 'true'`, `VITE_SKIP_ACCESS_GATE` → forces `enabled:false`), later from a provisioned file under `/var/lib/bitspire/` alongside branding.
|
|
||||||
- `apps/machine/.env.example` — document `VITE_SKIP_ACCESS_GATE=true` (browser/dev straight to idle) and `ACCESS_CONTROL_ENABLED`.
|
|
||||||
|
|
||||||
### 3. Reader abstraction (`apps/machine/src/services/access/`)
|
|
||||||
|
|
||||||
Mirrors `services/pairing/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
services/access/
|
|
||||||
types.ts # AccessReader, CardCredential (union), AccessRole, StopCapture
|
|
||||||
mock-reader.ts # PR1 — fires a card event on demand (dev button / hotkey)
|
|
||||||
web-nfc-reader.ts # PR3 — NDEFReader, dev on laptop/phone
|
|
||||||
serial-reader.ts # PR4 — /dev/ttyNFC via main-process HAL + IPC
|
|
||||||
authorize.ts # allow-list check + role resolution (hashed UID v1)
|
|
||||||
index.ts # availableAccessReaders(): AccessReader[]
|
|
||||||
__tests__/
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export type AccessRole = 'user' | 'operator'
|
|
||||||
|
|
||||||
export type CardCredential =
|
|
||||||
| { kind: 'uid'; uidHash: string } // v1
|
|
||||||
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string } // v2 (seam)
|
|
||||||
|
|
||||||
export interface AccessReader {
|
|
||||||
readonly kind: 'mock' | 'nfc-web' | 'nfc-serial'
|
|
||||||
readonly label: string
|
|
||||||
isAvailable(): Promise<boolean>
|
|
||||||
start(opts: {
|
|
||||||
onCard: (cred: CardCredential) => void
|
|
||||||
onError?: (e: unknown) => void
|
|
||||||
}): Promise<StopCapture>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Renderer wiring
|
|
||||||
|
|
||||||
- `apps/machine/src/views/LockedView.vue` (new) — the badge-in screen. Shows brand/logo + "Present your card", a live reader status, and (when `accessControl.devUnlock`) a discreet operator/dev unlock affordance (hidden long-press corner, or a button on the existing debug bar).
|
|
||||||
- `apps/machine/src/App.vue` — add a `locked` render branch mirroring the `PairingWizard` branch (L179) and the `initError` branch (L183): `<LockedView v-else-if="atmStore.isLocked" />`, then the existing `<router-view>` only when unlocked. Keeps rendering state-driven and matches the current shape.
|
|
||||||
- `apps/machine/src/stores/atm.ts` —
|
|
||||||
- `createActor(machine, { input: { accessControlEnabled, accessBypass } })` at the existing `createActor(machine)` site (L452), seeded from `RuntimeConfig`.
|
|
||||||
- `isLocked` computed off the snapshot (peer of `isIdle`, ~L422).
|
|
||||||
- `grantAccess(cred, role)` / `denyAccess(reason)` / `devUnlock()` that `send({ type: 'ACCESS_GRANTED' | 'ACCESS_DENIED' | 'DEV_UNLOCK', … })` (peers of `selectCashIn` at L1382, using the existing `send` at L1378).
|
|
||||||
- On init, when `accessControl.enabled`, subscribe to `availableAccessReaders()[0]`; on `onCard`, run `authorize()` → `grantAccess`/`denyAccess`. When disabled, do nothing (machine `always`-bypasses).
|
|
||||||
|
|
||||||
### 5. Audit
|
|
||||||
|
|
||||||
- Add `recordAccessEvent({ credentialIdHash, role, outcome, at })` alongside the existing state.db handlers (`state:record-transaction` etc. in `electron/main.ts`, exposed via `preload.ts`). v1 writes locally; a later PR can mirror to a replaceable Nostr event.
|
|
||||||
|
|
||||||
### Session semantics (decided)
|
|
||||||
|
|
||||||
**One badge = one transaction-scoped session.** A grant unlocks `idle`, the user runs a single transaction (Buy or Sell), and the machine re-locks on `complete`, on inactivity, or on an explicit "Done". The `session` context field is deliberately shaped as a general access session (`{ role, grantedAt, credentialIdHash }`), not a transaction handle, because this terminal may later handle **non-transaction functions** — so "unlock the terminal" and "authorize a transaction" stay separate concepts.
|
|
||||||
|
|
||||||
**Step-up authorization (future seam, not in PR1).** The badge tap grants *terminal access*; a specific sensitive action can independently *request re-authorization* — e.g. "tap your phone" or "enter a PIN" — without conflating the two. This is why the credential model is a union and the machine carries a `session` rather than a boolean "unlocked": a later `REQUIRE_STEPUP` event can gate an individual action against a fresh credential/PIN while the terminal session stays open. PR1 ships only the entry gate; step-up is a documented extension.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
- **Gate between `idle` and the transaction** (idle visible, tap requires a card). Rejected: the requirement is "gain access to the exchange" — the whole machine should be locked, not just the transact button. A `locked` predecessor matches the mental model and gives a clean re-lock boundary.
|
|
||||||
- **Web NFC only** (reuse `nfc-source.ts` as-is). Rejected: the batm3 reader is serial (`ttyNFC`); Web NFC can't drive it. Web NFC stays a dev-only convenience.
|
|
||||||
- **Fail-open by default** (no card → allow). Rejected for an access-control feature; but note the *disabled* default sidesteps this — access is simply off until an operator turns it on, at which point it fails **closed**.
|
|
||||||
- **OS/kiosk-level lock** (lock the desktop, not the app). Rejected: too coarse, no per-transaction audit, no role model, and it fights the existing state-driven UI.
|
|
||||||
- **UID allow-list as the permanent model.** Rejected as an endpoint (UIDs clone trivially) but accepted as v1 behind a union type, so challenge-response is additive.
|
|
||||||
|
|
||||||
## Security considerations
|
|
||||||
|
|
||||||
- **UID cloning** — card UIDs are not secret and are cloneable; v1 is "better than nothing" and is explicitly labeled upgradeable. The v2 challenge-response path (card signs a machine nonce) is the real security boundary; design the credential union and the `authorize()` seam for it now.
|
|
||||||
- **Data at rest** — store only a salted hash of the credential id; never raw UIDs or any PII (KYC-free). Never log a card secret or nsec (repo security priority #1).
|
|
||||||
- **Fail-closed when enabled** — reader absent/broken + `enabled` ⇒ locked, escape hatch = operator/dev unlock. Reader problems on a *disabled* machine are inert.
|
|
||||||
- **Operator ownership** — authorization derives from the operator key / #42 allow-list, not the SaaS operator (ADR-002 boundary). Local allow-list first; operator-published (NIP-51-style) sync later.
|
|
||||||
- **Dev bypass blast radius** — `VITE_SKIP_ACCESS_GATE` is build-time and never set in a production image; `devUnlock` is gated by `accessControl.devUnlock` (off in a locked-down deployment) and every dev unlock is audited with role `operator`/`dev`.
|
|
||||||
|
|
||||||
## Resolved decisions (2026-07-29)
|
|
||||||
|
|
||||||
1. **Session model** — ✅ one badge = one **transaction-scoped session**, with the `session` context modeled generally (terminal access, not a transaction handle) to allow non-transaction functions and per-action **step-up auth** (tap phone / PIN) later. See *Session semantics* above.
|
|
||||||
2. **v1 credential** — ✅ **UID allow-list first** (hashed), behind a `CardCredential` union so challenge-response is additive (PR5).
|
|
||||||
3. **Allow-list home** — ✅ **local first** (`state.db` / provisioned `access.json`, peer of `branding/`); operator-Nostr sync is a later PR.
|
|
||||||
|
|
||||||
Still open (cosmetic, decide during PR2):
|
|
||||||
|
|
||||||
4. **Dev unlock affordance** — hidden long-press corner vs a labeled button on the existing debug bar.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation plan (phased PRs)
|
|
||||||
|
|
||||||
Each PR is independently mergeable. **PR1 changes nothing observable while `accessControl.enabled=false` (the default).**
|
|
||||||
|
|
||||||
### PR1 — Non-breaking skeleton (state + config + mock reader + dev bypass + audit stub)
|
|
||||||
**Goal:** the locked→idle→transaction path is exercisable on the batm3 today, and the flag-off machine is byte-for-byte behavior-identical.
|
|
||||||
- `packages/state-machine`: add `locked` state (`initial`), `ACCESS_GRANTED`/`ACCESS_DENIED`/`DEV_UNLOCK` events, `accessBypass`/`devUnlockAllowed` guards, `startSession`/`recordAccess*` actions, context fields + actor `input`. Re-point `complete`/inactivity re-lock targets to `locked`.
|
|
||||||
- `apps/machine/src/types/electron.d.ts`: `RuntimeConfig.accessControl`.
|
|
||||||
- `apps/machine/electron/main.ts`: `get-config` returns `accessControl` (env-sourced); `.env.example` documents `VITE_SKIP_ACCESS_GATE` + `ACCESS_CONTROL_ENABLED`.
|
|
||||||
- `apps/machine/src/services/access/`: `types.ts`, `mock-reader.ts`, `authorize.ts` (UID allow-list, hashed), `index.ts`.
|
|
||||||
- `apps/machine/src/stores/atm.ts`: actor `input`, `isLocked`, `grantAccess`/`denyAccess`/`devUnlock`, reader subscription (only when enabled).
|
|
||||||
- `apps/machine/src/views/LockedView.vue` + `App.vue` `locked` render branch.
|
|
||||||
- Audit stub: `recordAccessEvent` handler + preload exposure (local write only).
|
|
||||||
- **Tests:** state-machine `locked → idle` on grant; `always`-bypass when disabled; re-lock from `complete`; `authorize()` allow/deny; `devUnlock` gated by config.
|
|
||||||
- **Flag state on merge:** `enabled=false`. CI green = no behavior change.
|
|
||||||
|
|
||||||
### PR2 — Locked UX polish
|
|
||||||
- Reader status/animation, brand-aware LockedView, denied-flash + reason, inactivity copy, operator/dev unlock affordance per open-question #4. Pure renderer.
|
|
||||||
|
|
||||||
### PR3 — Web NFC reader (dev)
|
|
||||||
- `web-nfc-reader.ts` (`NDEFReader`), registered in `availableAccessReaders()` behind availability check. Lets a laptop/phone drive the gate for demos/dev. No hardware dependency.
|
|
||||||
|
|
||||||
### PR4 — Serial NFC HAL driver (real batm3 hardware)
|
|
||||||
- Main-process serial driver for `/dev/ttyNFC` (HAL-style per ADR-001), IPC channel `access:watch-card` + `preload.ts` exposure; `serial-reader.ts` renderer client. Requires the physical reader to validate. Document the reader's protocol/baud in `docs/device-configuration.md`.
|
|
||||||
|
|
||||||
### PR5 — Challenge-response credential + operator allow-list sync
|
|
||||||
- Extend `CardCredential` with the `challenge` variant; `authorize.ts` verifies a signature over a machine nonce against the operator-authorized set; allow-list synced from an operator-published event (#42 mechanism). This is the real security upgrade; v1 UID path stays as a fallback/dev mode.
|
|
||||||
|
|
||||||
### Cross-cutting
|
|
||||||
- **Docs:** update `docs/machine-installation.md` (enabling access control, enrolling cards) and `deploy/nixos/README.md` (the `accessControl` config + `/dev/ttyNFC`) as PR4/PR5 land.
|
|
||||||
- **Provisioning:** a later change can add an `access.json` under `/var/lib/bitspire/` (peer of `branding/`) with the allow-list + `enabled`, plus a `provision-access.sh` mirroring `provision-branding.sh`.
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
# ADR-004: Cassette-State Synchronization
|
|
||||||
|
|
||||||
**Status:** Accepted
|
|
||||||
**Date:** 2026-09-22
|
|
||||||
**Context:** Cassette counts exist on two machines that both write them, over a transport
|
|
||||||
that cannot report a losing write. This has been load-bearing since #56 shipped, and until
|
|
||||||
now its only specification was a closed issue and a chat log — which is how four separate
|
|
||||||
divergence bugs went unnoticed.
|
|
||||||
|
|
||||||
## The problem
|
|
||||||
|
|
||||||
The ATM holds per-bay rows in `state.db` (`position` → `denomination`, `count`). spirekeeper
|
|
||||||
holds its own `cassette_configs` view for the operator dashboard. They are kept in step over
|
|
||||||
Nostr kind-30078, one addressable document per direction:
|
|
||||||
|
|
||||||
| d-tag | Direction | Author |
|
|
||||||
| --------------------------------------- | --------------------- | -------- |
|
|
||||||
| `bitspire-cassettes-state:<atm_pubkey>` | ATM reports counts up | ATM |
|
|
||||||
| `bitspire-cassettes:<atm_pubkey>` | operator pushes down | operator |
|
|
||||||
|
|
||||||
Counts drive cash dispensing and the public availability beacon, so a wrong number either
|
|
||||||
strands a customer at a machine that will not pay out or advertises cash that is not there.
|
|
||||||
|
|
||||||
The transport shapes everything else. Per NIP-01, an addressable event is identified by
|
|
||||||
`kind:pubkey:d` and ordered by `created_at` at **second granularity**, ties broken by lowest
|
|
||||||
event id. Relays MAY discard the loser, and a relay returns `OK` for an event it then
|
|
||||||
discards — so **acceptance is not persistence, and a losing writer is never told**. That
|
|
||||||
single fact rules out the obvious design.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### 1. The ATM owns `count`. The operator publishes operations, not counts.
|
|
||||||
|
|
||||||
A value with one writer cannot be clobbered. Compare-and-swap was considered and rejected:
|
|
||||||
CAS works because the writer learns it failed and retries, and every standard implementation
|
|
||||||
of it — HTTP `412`, Kubernetes `409`, a zero rowcount, `CMPXCHG` returning false — delivers
|
|
||||||
that signal. A kind-30078 publish cannot. Bolting a version onto the current design would let
|
|
||||||
the ATM refuse a stale push but leave the operator believing they set a count they did not,
|
|
||||||
trading a wrong number for a phantom edit.
|
|
||||||
|
|
||||||
So the operator publishes `refill`, `empty`, `recount` and `set_denomination` operations. The
|
|
||||||
vocabulary mirrors lamassu-server's `cash_unit_operation_type`, which is the same shape the
|
|
||||||
ancestor of this HAL arrived at. Absolute writes survive only as `recount`, which is what an
|
|
||||||
operator opening a bay and counting actually does.
|
|
||||||
|
|
||||||
`denomination` stays operator-authoritative: the machine cannot know what was physically
|
|
||||||
loaded into a bay.
|
|
||||||
|
|
||||||
### 2. Idempotency is explicit, because deltas are not idempotent.
|
|
||||||
|
|
||||||
Addressable events are re-delivered on reconnect, so a naive delta would be applied twice.
|
|
||||||
Every operation carries an operator-minted `id`; the ATM records applied ids and ignores
|
|
||||||
duplicates. This is lamassu-server's `pullNewBills` pattern — a client-minted UUID per unit of
|
|
||||||
work, making resend free and ordering irrelevant — rather than a sequence number.
|
|
||||||
|
|
||||||
### 3. The operator publishes a window of recent operations, not one.
|
|
||||||
|
|
||||||
An event the ATM missed self-heals on the next publish, because the next event still carries
|
|
||||||
the earlier operations. This is the same trick as Lightning.Pub piggybacking `latest_balance`
|
|
||||||
on every incremental message so a client that missed events corrects itself.
|
|
||||||
|
|
||||||
### 4. The ATM echoes applied ids back, which is the acknowledgement.
|
|
||||||
|
|
||||||
The state document carries `applied_ops`, so the dashboard can render each published operation
|
|
||||||
as applied or pending. This supplies the feedback leg a replaceable event cannot, without
|
|
||||||
needing the transport to report failures.
|
|
||||||
|
|
||||||
### 4a. Superseded. Before decisions 1 to 4 shipped, the overwrite was warned about.
|
|
||||||
|
|
||||||
The dashboard's publish dialog stated the failure plainly — that the publish would overwrite
|
|
||||||
the ATM's tracked counts, that decrements since the last baseline would be lost, and that it
|
|
||||||
should follow a physical refill rather than a mid-day tweak.
|
|
||||||
|
|
||||||
Kept here rather than deleted, because it is the calibration for how much a warning is worth.
|
|
||||||
It was a known, deliberately accepted risk carrying a human-factors mitigation, not an
|
|
||||||
oversight, and the product had already reached the same conclusion these decisions formalise.
|
|
||||||
It was also the weakest control available: it depended on an operator reading a dialog at the
|
|
||||||
end of a refill round, and it could not help at all when the stale value was the one already
|
|
||||||
in the form. Confirmed live on 2026-09-22 — a dispense moved a bay from 54 to 53 while a form
|
|
||||||
loaded at 54 stayed open, and nothing but that dialog stood between the operator and
|
|
||||||
discarding the decrement.
|
|
||||||
|
|
||||||
The dialog and the endpoint behind it are both gone. The operator dashboard no longer has a
|
|
||||||
field that accepts a count, which is a stronger guarantee than any wording could be.
|
|
||||||
|
|
||||||
### 5. Ordering is decided by `created_at`, never by arrival order, on both sides.
|
|
||||||
|
|
||||||
The ATM forces each stamp strictly above its last published one, so a same-second publish or a
|
|
||||||
clock stepping backwards cannot silently discard a report. spirekeeper applies an event only
|
|
||||||
when strictly newer than the **oldest** stamp on file for that machine.
|
|
||||||
|
|
||||||
Oldest, not newest, because LNbits' `Connection.execute` commits per call: a multi-row apply
|
|
||||||
cannot be made atomic through that data layer, so a crash mid-apply leaves some rows advanced.
|
|
||||||
Gating on the oldest means a partial apply is re-applied rather than mistaken for a complete
|
|
||||||
one, and the ATM's heartbeat makes it converge.
|
|
||||||
|
|
||||||
### 6. The machine's bay set is authoritative for layout.
|
|
||||||
|
|
||||||
Bay count is hardware-determined. spirekeeper deletes positions absent from a report rather
|
|
||||||
than leaving them; the operator cannot add or remove bays.
|
|
||||||
|
|
||||||
### 7. Unverified counts are declared, not guessed.
|
|
||||||
|
|
||||||
When a dispense ends with no per-bay report — a driver throw, or the dispense timeout — bills
|
|
||||||
may have reached the customer with nothing knowing how many. The ATM flags
|
|
||||||
`counts_uncertain_since` and carries it in the state document rather than letting a number
|
|
||||||
known to read high stand as measurement. An operator `recount` clears it.
|
|
||||||
|
|
||||||
### 8. State is published on every change and on a heartbeat.
|
|
||||||
|
|
||||||
A publish is one fire-and-forget event with no retry. The heartbeat is what makes the channel
|
|
||||||
self-healing after a relay outage, and the only way an out-of-band edit to the table ever
|
|
||||||
reaches the operator.
|
|
||||||
|
|
||||||
## What this replaces
|
|
||||||
|
|
||||||
The original design published a single hello-event gated on a one-shot flag, deduplicated on
|
|
||||||
one remembered event id, and never compared `created_at` at all. In practice that produced:
|
|
||||||
|
|
||||||
| Failure | Issue |
|
|
||||||
| --------------------------------------------------------------- | -------------- |
|
|
||||||
| Layout changes after first boot never published | bitspire#94 |
|
|
||||||
| Remediation dispenses debited HAL but not the rows | bitspire#76 |
|
|
||||||
| Absent positions never deleted; publishes then rejected forever | spirekeeper#43 |
|
|
||||||
| A stale dashboard publish overwriting a newer report | spirekeeper#43 |
|
|
||||||
| A drained machine advertising bills it had already dispensed | found in audit |
|
|
||||||
| A re-delivered A, B, A applied three times | found in audit |
|
|
||||||
|
|
||||||
The last of these is the only one decisions 5 to 8 do not close, because it is not a defect
|
|
||||||
in the mechanism: the operator is permitted to write the count, so a stale write is
|
|
||||||
indistinguishable from an intended one. Only decisions 1 to 4 remove it, by removing the
|
|
||||||
operator's ability to write counts at all.
|
|
||||||
|
|
||||||
## Status of implementation
|
|
||||||
|
|
||||||
Decisions 5 through 8 shipped in bitspire#104 and spirekeeper#44, on the existing wire format,
|
|
||||||
and were verified against the deployed code on sintra on 2026-09-22: a zeroed machine reported
|
|
||||||
drained rather than freezing its beacon, a re-delivered operator config was dropped as stale on
|
|
||||||
eight consecutive restarts, three heartbeat republishes carried strictly increasing stamps read
|
|
||||||
back off the relay, and the machine, the relay and the operator dashboard agreed on the counts
|
|
||||||
with timestamps correlated to the second.
|
|
||||||
|
|
||||||
Decisions 1 through 4 are the v2 operations wire and shipped in spirekeeper#46 and
|
|
||||||
bitspire#106.
|
|
||||||
|
|
||||||
On the operator side there is no longer any endpoint that accepts a count: the absolute
|
|
||||||
publish, its CRUD write and its request model were removed rather than deprecated. The
|
|
||||||
dashboard records operations and renders each as applied or pending from the machine's
|
|
||||||
`applied_ops` echo. On the machine side, schema v13 adds a `cassette_ops` dedup ledger, the
|
|
||||||
`created_at` watermark on this path is retired in favour of per-op ids, and the state document
|
|
||||||
carries `schema_version`, `seq` and `applied_ops`.
|
|
||||||
|
|
||||||
The wire shapes are those given under decisions 1 and 4 above.
|
|
||||||
|
|
||||||
Cutover for v2 is strict, no compatibility code: spirekeeper deploys first, machines follow on
|
|
||||||
their nightly pull. During that window a not-yet-updated ATM ignores an ops payload, so an
|
|
||||||
operator refill does not land until it updates — which fails safe, since the machine
|
|
||||||
under-counts and will not dispense bills it believes it lacks. In the other direction an
|
|
||||||
updated machine drops a v1 absolute-count payload on the missing `ops` array, which is the
|
|
||||||
same safe direction: the machine keeps the counts it is now the only writer of.
|
|
||||||
|
|
||||||
One gap stays open deliberately. An operation recorded while the relay is unreachable waits
|
|
||||||
for the operator's next action to be published, because only an operator action triggers a
|
|
||||||
publish. The window makes that self-healing once anything is published, but nothing on the
|
|
||||||
operator side republishes on its own. An operator-side heartbeat is the fix; it is not built.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
- **Compare-and-swap on absolute writes.** Rejected: see decision 1. Viable only with a
|
|
||||||
feedback leg the transport cannot provide, and decision 4 gets the same benefit without
|
|
||||||
pretending the transport is something it is not.
|
|
||||||
- **NIP-77 negentropy for reconciliation.** Rejected: it reconciles sets of event ids and
|
|
||||||
still requires a separate fetch. For a single mutable document it costs more than
|
|
||||||
re-reading it.
|
|
||||||
- **One envelope carrying all operator config.** Rejected earlier and still right: a fee edit
|
|
||||||
that republished a stale cassette inventory is a real failure mode. One d-tag per lifecycle.
|
|
||||||
- **Publishing the operation log as kind-78.** Deferred. The operator authors the operations
|
|
||||||
and the ATM records what it applied, so both sides already hold an audit trail.
|
|
||||||
|
|
@ -99,12 +99,6 @@ wallet …".
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Tap-to-enter uses `/session` instead.** When the access gate is on, the
|
|
||||||
card was already verified at entry and the ATM holds the LUD-06 second step
|
|
||||||
from `/session` (see `boltcard-session.md`), so Complete calls `pay.callback`
|
|
||||||
directly and never touches `/pay`. `/pay` remains the path for a card tapped
|
|
||||||
directly on the cash-in screen (gate off, or a second card).
|
|
||||||
|
|
||||||
- **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a
|
- **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
|
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
|
while a tap is in flight and leaves `displayingQR` on success; the withdraw
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
# Bolt Card session — one verified tap for a terminal visit
|
|
||||||
|
|
||||||
Wire contract for the `/session` endpoint the ATM's access gate (ADR-003
|
|
||||||
tap-to-enter) uses on the LNbits `boltcards` extension. Implemented in the
|
|
||||||
aiolabs fork (`git.atitlan.io/aiolabs/boltcards`, `v1.1.1-aio.3`+); consumed by
|
|
||||||
`apps/machine/electron/boltcard-session.ts`.
|
|
||||||
|
|
||||||
## Why a session
|
|
||||||
|
|
||||||
A Bolt Card tap yields a single-use SUN `p`/`c`: the card server verifies it
|
|
||||||
and advances the card's read counter, so any endpoint that checks it — `/scan`,
|
|
||||||
`/pay`, `/verify` — spends it. The gate wants two things from one tap:
|
|
||||||
|
|
||||||
1. **Verify at entry** — a genuine, non-replayed card unlocks the terminal and
|
|
||||||
we can show the holder their balance.
|
|
||||||
2. **Complete without a second tap** — the buy or sell later in the visit
|
|
||||||
moves sats with the same card.
|
|
||||||
|
|
||||||
`/session` does the verification once and hands back the _second steps_ of
|
|
||||||
both LNURL flows, keyed by a single-use server-side `hit` — the same bearer
|
|
||||||
`/scan` (as `k1`) and `/pay` already issue. The terminal holds no `p`/`c`
|
|
||||||
afterwards.
|
|
||||||
|
|
||||||
## Endpoint
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /boltcards/api/v1/session/{external_id}?p={p}&c={c}
|
|
||||||
```
|
|
||||||
|
|
||||||
Same URL shape as `/scan/{external_id}?p=&c=` with `scan` → `session`; the ATM
|
|
||||||
derives it by string substitution on the tapped `lnurlw`
|
|
||||||
(`scanUrlToSessionUrl()`). SUN verification is byte-for-byte `/scan`'s (shared
|
|
||||||
helper in the fork): unknown / disabled card, UID mismatch, bad CMAC, replayed
|
|
||||||
counter all reject with `/scan`'s reasons. On success the counter advances and
|
|
||||||
one `hit` is recorded.
|
|
||||||
|
|
||||||
## Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"authenticated": true,
|
|
||||||
"external_id": "abc123",
|
|
||||||
"card_name": "Alice",
|
|
||||||
"balance_msat": 123456000,
|
|
||||||
"currency": "USD",
|
|
||||||
"fiat": 98.76,
|
|
||||||
"withdraw": {
|
|
||||||
"callback": "https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/<hit>",
|
|
||||||
"k1": "<hit>",
|
|
||||||
"minWithdrawable": 1000,
|
|
||||||
"maxWithdrawable": 50000000
|
|
||||||
},
|
|
||||||
"withdraw_blocked_reason": null,
|
|
||||||
"pay": {
|
|
||||||
"callback": "https://lnbits.l484.com/boltcards/api/v1/pay/cb/<hit>",
|
|
||||||
"minSendable": 1000,
|
|
||||||
"maxSendable": 50000000,
|
|
||||||
"metadata": "[[\"text/plain\",\"Bolt Card top-up\"]]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `balance_msat` — the card wallet's balance. Display only.
|
|
||||||
- `currency` / `fiat` — the balance priced the way the LNbits wallet page does
|
|
||||||
it: the wallet's own currency (per-wallet setting) first, else the instance's
|
|
||||||
default accounting currency. `fiat` is filled **only from the server's
|
|
||||||
already-warm rate cache** — this response gates the unlock, and a cold rate
|
|
||||||
lookup queries external exchanges (~1 s). On a cache miss it is `null` and
|
|
||||||
the ATM prices the sats itself: in `currency` from its own rate source, else
|
|
||||||
in its own fiat at its display rate. No rate lookup ever blocks the session.
|
|
||||||
- `withdraw` — the LUD-03 second step. The ATM calls
|
|
||||||
`callback?k1=<hit>&pr=<bolt11>` at cash-out Complete. `null` with
|
|
||||||
`withdraw_blocked_reason` set when `/scan` would have refused (daily limit
|
|
||||||
spent); cash-in stays possible.
|
|
||||||
- `pay` — the LUD-06 second step. The ATM calls `callback?amount=<msat>` at
|
|
||||||
cash-in Complete and pays the returned BOLT11 over its own nostr transport.
|
|
||||||
- Limits are the card's `tx_limit`, as on `/scan` and `/pay`.
|
|
||||||
|
|
||||||
Rejection:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "authenticated": false, "reason": "This link is already used." }
|
|
||||||
```
|
|
||||||
|
|
||||||
`reason` is surfaced verbatim on the locked screen — terse, non-sensitive.
|
|
||||||
|
|
||||||
## Semantics of the hit
|
|
||||||
|
|
||||||
- The first withdraw that uses the hit spends it (`spent = true`), exactly as
|
|
||||||
after a `/scan`; a second withdraw is refused with "Payment already claimed."
|
|
||||||
- A top-up does not mark the hit spent (as `/pay` today).
|
|
||||||
- The ATM treats the whole session as single-shot regardless: after the first
|
|
||||||
Complete attempt, accepted or declined, it drops the session and asks for a
|
|
||||||
re-tap (`completeWithCard` in `stores/atm.ts`).
|
|
||||||
- Hits do not expire server-side. The ATM's session security (60 s idle,
|
|
||||||
10 min cap, End Session) bounds how long one is held.
|
|
||||||
|
|
||||||
## Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
locked ── tap ─▶ GET /session/<id>?p=&c= (spends the SUN)
|
|
||||||
├─ authenticated:false → stay locked, show reason
|
|
||||||
└─ authenticated:true → authorize(external_id) → idle, session held
|
|
||||||
CardChip: "Alice · ••c123 •••••• [eye]"
|
|
||||||
sell: pick amount → Complete Sale → withdraw.callback?k1&pr → PAYMENT_RECEIVED → dispense
|
|
||||||
buy: insert cash → Complete Purchase → pay.callback?amount → BOLT11 → ATM pays → complete
|
|
||||||
… re-lock (End Session / idle / complete) drops the session
|
|
||||||
```
|
|
||||||
|
|
||||||
## Trust boundary (read this)
|
|
||||||
|
|
||||||
The ATM derives the session URL from the **card's own `lnurlw` host**. With
|
|
||||||
`openEnrollment` on, a forged NDEF tag pointing at an attacker's server that
|
|
||||||
answers `{"authenticated": true, …}` still unlocks the terminal. Money is not
|
|
||||||
at risk — a fake server can only make the ATM pay an invoice the holder chose
|
|
||||||
(cash-in) or accept a pull it never honours (cash-out never dispenses without
|
|
||||||
`PAYMENT_RECEIVED`) — but the _gate_ is only as trustworthy as the host it was
|
|
||||||
told to ask. Closing that means pinning the card-server host(s) the gate
|
|
||||||
accepts; tracked in aiolabs/bitspire#91.
|
|
||||||
94
flake.nix
94
flake.nix
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
description = "bitSpire - Nostr-Native Lightning ATM";
|
description = "Lamassu Next - Nostr-Native Lightning ATM";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
# Stable NixOS for the ATM OS base
|
# Stable NixOS for the ATM OS base
|
||||||
|
|
@ -53,55 +53,6 @@
|
||||||
overlays = [ (import rust-overlay) ];
|
overlays = [ (import rust-overlay) ];
|
||||||
};
|
};
|
||||||
|
|
||||||
# Kiosk launcher. The GPU-related Electron flags sit in a shell variable
|
|
||||||
# rather than being baked into ExecStart, so they can be changed on a
|
|
||||||
# running machine by editing /var/lib/bitspire/.env and restarting the
|
|
||||||
# unit. No rebuild, no reboot, and a bad value is one edit away from
|
|
||||||
# being undone — which matters on a box whose screen nobody can see.
|
|
||||||
#
|
|
||||||
# THE DEFAULT IS NOW HARDWARE ACCELERATION.
|
|
||||||
#
|
|
||||||
# From the first ISO commit (19d43c2) until today the kiosk launched with
|
|
||||||
# --disable-gpu AND --disable-software-rasterizer, which turns off GPU
|
|
||||||
# compositing and the SwiftShader fallback together and leaves Chromium
|
|
||||||
# rasterising every pixel on the CPU. Nothing in git ever justified the
|
|
||||||
# pair: no comment, no issue, no commit message. Meanwhile the
|
|
||||||
# descriptive config at /etc/bitspire/config.env claimed
|
|
||||||
# ELECTRON_DISABLE_GPU=false, contradicting the actual command line.
|
|
||||||
#
|
|
||||||
# Tested on sintra 2026-09-24. With the flags removed the GPU process is
|
|
||||||
# stable (zero crashes, zero service restarts) and genuinely on hardware
|
|
||||||
# — /proc/<gpu-pid>/maps shows libgallium, libGLX_mesa and dri_gbm, with
|
|
||||||
# no swrast and no SwiftShader — rendering through crocus on Braswell.
|
|
||||||
# Confirmed by eye on the panel.
|
|
||||||
#
|
|
||||||
# DOURO IS EXEMPT. It keeps the old flags. Bay Trail carries three
|
|
||||||
# separate display workarounds already — a 5.15 kernel pin for an i915
|
|
||||||
# eDP regression, i915.enable_psr=0, and vt.handoff=7 to preserve the
|
|
||||||
# BIOS display init — so it is the most plausible machine for the
|
|
||||||
# original flags to have been a real fix rather than scaffolding. It is
|
|
||||||
# also down pending a reflash, so it cannot be tested. Drop this
|
|
||||||
# exemption once douro is back and accelerates cleanly.
|
|
||||||
#
|
|
||||||
# To override per machine, in /var/lib/bitspire/.env:
|
|
||||||
# BITSPIRE_ELECTRON_GPU_FLAGS= acceleration
|
|
||||||
# BITSPIRE_ELECTRON_GPU_FLAGS=--disable-gpu no GPU
|
|
||||||
# BITSPIRE_ELECTRON_GPU_FLAGS=--use-gl=egl force EGL
|
|
||||||
# (line absent) model default
|
|
||||||
#
|
|
||||||
# Note `-` and not `:-`: an explicitly EMPTY value means "no GPU flags at
|
|
||||||
# all", and must not fall back to the default. Unquoted on purpose so the
|
|
||||||
# value word-splits into argv.
|
|
||||||
mkKioskLauncher = machineModel: atm-app: pkgs.writeShellScript "bitspire-kiosk" ''
|
|
||||||
default_gpu_flags="${
|
|
||||||
if machineModel == "douro" then "--disable-gpu --disable-software-rasterizer" else ""
|
|
||||||
}"
|
|
||||||
exec ${pkgs-unstable.electron}/bin/electron \
|
|
||||||
--no-sandbox --disable-gpu-sandbox --enable-logging \
|
|
||||||
''${BITSPIRE_ELECTRON_GPU_FLAGS-$default_gpu_flags} \
|
|
||||||
${atm-app}
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Pure ATM app builder (no --impure needed)
|
# Pure ATM app builder (no --impure needed)
|
||||||
mkAtmApp = import ./nix/mkAtmApp.nix {
|
mkAtmApp = import ./nix/mkAtmApp.nix {
|
||||||
inherit pkgs pkgs-unstable;
|
inherit pkgs pkgs-unstable;
|
||||||
|
|
@ -116,34 +67,6 @@
|
||||||
batm3 = "USD";
|
batm3 = "USD";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Nightly auto-upgrade window per machine model, as a systemd calendar
|
|
||||||
# spec. An upgrade restarts the app, and the Fujitsu dispenser runs an
|
|
||||||
# audible init routine when it does, so this wants to land in the middle
|
|
||||||
# of the machine's own night rather than its business hours.
|
|
||||||
#
|
|
||||||
# The timezone suffix (systemd 252+) is what makes that work WITHOUT
|
|
||||||
# setting the system clock: the timer follows the named zone and its DST,
|
|
||||||
# while time.timeZone stays a fleet-wide default nobody has to maintain
|
|
||||||
# per host. Verified on sintra with systemd-analyze — `04:00
|
|
||||||
# Europe/Paris` resolves to 02:00 UTC in summer, `04:00
|
|
||||||
# America/Guatemala` to 10:00 UTC.
|
|
||||||
#
|
|
||||||
# Do NOT check a spec like this under `nix-shell -p systemd`. The sandbox
|
|
||||||
# cannot resolve named zones and silently computes EVERY one of them as
|
|
||||||
# UTC, while still echoing the zone back in its "Normalized form" line.
|
|
||||||
# It looks accepted and is wrong. Test on a real system.
|
|
||||||
#
|
|
||||||
# Keyed on model like fiatCodeForModel above, and inheriting the same
|
|
||||||
# limitation: model is a hardware model, and it only doubles as host
|
|
||||||
# identity while there is one machine of each. A second sintra in another
|
|
||||||
# country needs this keyed on host instead, along with the fiat code and
|
|
||||||
# the app build that bakes it in.
|
|
||||||
#
|
|
||||||
# Unlisted models get 04:00 in whatever time.timeZone says, unchanged.
|
|
||||||
upgradeWindowForModel = {
|
|
||||||
sintra = "04:00 Europe/Paris";
|
|
||||||
};
|
|
||||||
|
|
||||||
lib = nixpkgs.lib;
|
lib = nixpkgs.lib;
|
||||||
|
|
||||||
# Helper to create a live USB NixOS config for a specific machine model
|
# Helper to create a live USB NixOS config for a specific machine model
|
||||||
|
|
@ -158,7 +81,6 @@
|
||||||
inherit system;
|
inherit system;
|
||||||
specialArgs = {
|
specialArgs = {
|
||||||
inherit pkgs-unstable nixpkgs machineModel atm-app;
|
inherit pkgs-unstable nixpkgs machineModel atm-app;
|
||||||
kioskLauncher = mkKioskLauncher machineModel atm-app;
|
|
||||||
};
|
};
|
||||||
modules = [
|
modules = [
|
||||||
./deploy/nixos/live.nix
|
./deploy/nixos/live.nix
|
||||||
|
|
@ -260,9 +182,7 @@
|
||||||
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/bitspire.git?ref=dev#${machineModel}-installed";
|
||||||
flags = [ "--refresh" ];
|
flags = [ "--refresh" ];
|
||||||
# Daily at 4am in the machine's own zone; see
|
dates = "04:00"; # daily at 4am
|
||||||
# upgradeWindowForModel above.
|
|
||||||
dates = upgradeWindowForModel.${machineModel} or "04:00";
|
|
||||||
allowReboot = false;
|
allowReboot = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -289,14 +209,6 @@
|
||||||
VITE_SPIRE_SEED=
|
VITE_SPIRE_SEED=
|
||||||
ELECTRON_FORCE_PROD=1
|
ELECTRON_FORCE_PROD=1
|
||||||
DISPLAY=:0
|
DISPLAY=:0
|
||||||
# Uncomment to change Electron's GPU flags without a
|
|
||||||
# rebuild, then `systemctl restart bitspire`. An empty
|
|
||||||
# value means full GPU acceleration; the line being absent
|
|
||||||
# means the shipped default (GPU and software rasterizer
|
|
||||||
# both off). Commented rather than set, because a present
|
|
||||||
# -but-empty value here would silently enable the GPU on
|
|
||||||
# every machine that regenerates its .env.
|
|
||||||
# BITSPIRE_ELECTRON_GPU_FLAGS=
|
|
||||||
'' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") ''
|
'' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") ''
|
||||||
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
|
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
|
||||||
'' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") ''
|
'' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") ''
|
||||||
|
|
@ -312,7 +224,7 @@
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
EnvironmentFile = lib.mkForce "/var/lib/bitspire/.env";
|
EnvironmentFile = lib.mkForce "/var/lib/bitspire/.env";
|
||||||
Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib";
|
Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib";
|
||||||
ExecStart = lib.mkForce "${mkKioskLauncher machineModel atm-app}";
|
ExecStart = lib.mkForce "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-gpu --disable-software-rasterizer --enable-logging ${atm-app}";
|
||||||
MemoryMax = lib.mkForce "1G";
|
MemoryMax = lib.mkForce "1G";
|
||||||
NoNewPrivileges = lib.mkForce false;
|
NoNewPrivileges = lib.mkForce false;
|
||||||
ProtectSystem = lib.mkForce false;
|
ProtectSystem = lib.mkForce false;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Pure Nix derivation for the bitSpire ATM Electron app.
|
# Pure Nix derivation for the Lamassu ATM Electron app.
|
||||||
#
|
#
|
||||||
# Uses fetchPnpmDeps + pnpmConfigHook to build entirely inside the Nix sandbox,
|
# Uses fetchPnpmDeps + pnpmConfigHook to build entirely inside the Nix sandbox,
|
||||||
# eliminating the need for --impure or a local pnpm install.
|
# eliminating the need for --impure or a local pnpm install.
|
||||||
|
|
@ -190,33 +190,6 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
copy_pnpm_pkg "@serialport/$parser" "$out/node_modules/@serialport/$parser"
|
copy_pnpm_pkg "@serialport/$parser" "$out/node_modules/@serialport/$parser"
|
||||||
done
|
done
|
||||||
|
|
||||||
# ── Strip node-gyp build detritus ──────────────────────────────────
|
|
||||||
# node-gyp leaves its scaffolding beside the compiled addons, and several
|
|
||||||
# of those files embed absolute /nix/store paths to the BUILD toolchain:
|
|
||||||
# build/node_gyp_bins/python3 an ELF copy of python3 with an RPATH
|
|
||||||
# build/config.gypi python3 + nodejs + npm paths
|
|
||||||
# build/Release/.deps/**.o.d pcsclite.dev include paths
|
|
||||||
# Nix scans $out for store hashes, so each becomes a RUNTIME reference and
|
|
||||||
# drags python311 + nodejs + npm + pcsclite.dev (~212MB of closure) onto
|
|
||||||
# every ATM. Nothing reads them at runtime — only build/Release/*.node is
|
|
||||||
# loaded, via `bindings` / `node-gyp-build`. Keep the addons, drop the
|
|
||||||
# scaffolding. obj.target/*.node is node-gyp's pre-copy of the same addon;
|
|
||||||
# the loaded one at build/Release/*.node is untouched.
|
|
||||||
find $out/node_modules -type d \
|
|
||||||
\( -name node_gyp_bins -o -name .deps -o -name obj.target -o -name obj \) \
|
|
||||||
-prune -exec rm -rf {} +
|
|
||||||
find $out/node_modules -path '*/build/*' -type f \
|
|
||||||
\( -name config.gypi -o -name '*.mk' -o -name Makefile \
|
|
||||||
-o -name binding.Makefile -o -name '*.a' -o -name '*.o' \) -delete
|
|
||||||
|
|
||||||
# pnpm/node-gyp rewrote these CLI helpers' shebangs to the build nodejs,
|
|
||||||
# which alone retains the full nodejs (not the slim one Electron needs).
|
|
||||||
# They are build-time utilities — the runtime entry of each package
|
|
||||||
# (index.js) carries no shebang — so point them at PATH instead of
|
|
||||||
# deleting files a package might still require.
|
|
||||||
find $out/node_modules -type f -name '*.js' \
|
|
||||||
-exec sed -i '1s|^#!/nix/store/[^ ]*/bin/node$|#!/usr/bin/env node|' {} +
|
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@bitSpire/hal",
|
"name": "@bitSpire/hal",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Hardware Abstraction Layer for bitSpire ATM devices",
|
"description": "Hardware Abstraction Layer for Lamassu ATM devices",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
"src"
|
"src"
|
||||||
],
|
],
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"bitspire",
|
"lamassu",
|
||||||
"atm",
|
"atm",
|
||||||
"hardware",
|
"hardware",
|
||||||
"bill-validator",
|
"bill-validator",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* @bitSpire/hal - Hardware Abstraction Layer
|
* @bitSpire/hal - Hardware Abstraction Layer
|
||||||
*
|
*
|
||||||
* Provides drivers for bitSpire ATM hardware devices:
|
* Provides drivers for Lamassu ATM hardware devices:
|
||||||
* - Bill validators (JCM iVIZION via ID003 protocol)
|
* - Bill validators (JCM iVIZION via ID003 protocol)
|
||||||
* - Bill dispensers (Fujitsu F53/F56)
|
* - Bill dispensers (Fujitsu F53/F56)
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@bitSpire/nostr-client",
|
"name": "@bitSpire/nostr-client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Nostr client library for bitSpire ATM",
|
"description": "Nostr client library for Lamassu ATM",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Nostr client for bitSpire ATM
|
* Nostr client for Lamassu ATM
|
||||||
*
|
*
|
||||||
* Manages connections to Nostr relays with support for:
|
* Manages connections to Nostr relays with support for:
|
||||||
* - NIP-42 authentication
|
* - NIP-42 authentication
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Event creation utilities for bitSpire ATM
|
* Event creation utilities for Lamassu ATM
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools'
|
import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools'
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* @bitSpire/nostr-client
|
* @bitSpire/nostr-client
|
||||||
*
|
*
|
||||||
* Nostr client library for bitSpire ATM communication.
|
* Nostr client library for Lamassu ATM communication.
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - NIP-42 authentication for private relays
|
* - NIP-42 authentication for private relays
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Nostr client type definitions for bitSpire ATM
|
* Nostr client type definitions for Lamassu ATM
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Event } from 'nostr-tools'
|
import type { Event } from 'nostr-tools'
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { createActor } from 'xstate'
|
|
||||||
import { createATMMachine } from '../machine.js'
|
|
||||||
|
|
||||||
// ADR-003 access gate. Key invariant: with the gate DISABLED (the default),
|
|
||||||
// the machine is behaviourally identical to the pre-access machine — it
|
|
||||||
// settles into `idle` on start via the `locked` state's `always` bypass.
|
|
||||||
describe('ATM access control (ADR-003)', () => {
|
|
||||||
describe('gate disabled (default)', () => {
|
|
||||||
it('settles into idle on start (non-breaking)', () => {
|
|
||||||
const actor = createActor(createATMMachine())
|
|
||||||
actor.start()
|
|
||||||
expect(actor.getSnapshot().value).toBe('idle')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('settles into idle even with accessControlEnabled:false explicit', () => {
|
|
||||||
const actor = createActor(createATMMachine({}, { accessControlEnabled: false }))
|
|
||||||
actor.start()
|
|
||||||
expect(actor.getSnapshot().value).toBe('idle')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('gate enabled', () => {
|
|
||||||
it('stays locked on start', () => {
|
|
||||||
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
|
|
||||||
actor.start()
|
|
||||||
expect(actor.getSnapshot().value).toBe('locked')
|
|
||||||
expect(actor.getSnapshot().context.accessSession).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ACCESS_GRANTED unlocks to idle and records the session', () => {
|
|
||||||
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
|
|
||||||
actor.start()
|
|
||||||
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc123' })
|
|
||||||
|
|
||||||
const snap = actor.getSnapshot()
|
|
||||||
expect(snap.value).toBe('idle')
|
|
||||||
expect(snap.context.accessSession).toMatchObject({ role: 'user', credentialIdHash: 'abc123' })
|
|
||||||
expect(typeof snap.context.accessSession?.grantedAt).toBe('number')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ACCESS_DENIED stays locked and surfaces the reason', () => {
|
|
||||||
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
|
|
||||||
actor.start()
|
|
||||||
actor.send({ type: 'ACCESS_DENIED', reason: 'card not authorized' })
|
|
||||||
|
|
||||||
const snap = actor.getSnapshot()
|
|
||||||
expect(snap.value).toBe('locked')
|
|
||||||
expect(snap.context.accessDenyReason).toBe('card not authorized')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('DEV_UNLOCK unlocks to idle (operator session)', () => {
|
|
||||||
const actor = createATMMachine({}, { accessControlEnabled: true })
|
|
||||||
const running = createActor(actor)
|
|
||||||
running.start()
|
|
||||||
running.send({ type: 'DEV_UNLOCK' })
|
|
||||||
|
|
||||||
const snap = running.getSnapshot()
|
|
||||||
expect(snap.value).toBe('idle')
|
|
||||||
expect(snap.context.accessSession?.role).toBe('operator')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// NOTE: idle inactivity re-lock is no longer an XState `after` delay — an
|
|
||||||
// entry-anchored timer can't measure inactivity (it never resets on screen
|
|
||||||
// touches). It's enforced at the DOM layer (useSessionSecurity), which sends
|
|
||||||
// END_SESSION on true idleness / at the hard cap. The machine's contract is
|
|
||||||
// just: END_SESSION re-locks from idle when the gate is active, and is
|
|
||||||
// ignored mid-transaction (a transaction re-locks on its own when it ends).
|
|
||||||
describe('session end (button / inactivity / hard cap all route here)', () => {
|
|
||||||
it('END_SESSION re-locks immediately from idle when the gate is active', () => {
|
|
||||||
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
|
|
||||||
actor.start()
|
|
||||||
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' })
|
|
||||||
expect(actor.getSnapshot().value).toBe('idle')
|
|
||||||
actor.send({ type: 'END_SESSION' })
|
|
||||||
expect(actor.getSnapshot().value).toBe('locked')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('END_SESSION is ignored mid-transaction (never strands funds in flight)', () => {
|
|
||||||
// A root-level END_SESSION would bypass confirmAbandon / an in-flight
|
|
||||||
// dispense. The transition lives on `idle` only; a transaction's own
|
|
||||||
// terminal states return to `locked` when it ends.
|
|
||||||
const rateServices = {
|
|
||||||
getExchangeRate: () => Promise.resolve(2500),
|
|
||||||
getAvailableBalance: () => Promise.resolve(1_000_000),
|
|
||||||
}
|
|
||||||
const actor = createActor(createATMMachine(rateServices, { accessControlEnabled: true }))
|
|
||||||
actor.start()
|
|
||||||
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' })
|
|
||||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
|
||||||
const before = actor.getSnapshot().value
|
|
||||||
expect(before).not.toBe('locked') // now inside cashOut
|
|
||||||
actor.send({ type: 'END_SESSION' })
|
|
||||||
expect(actor.getSnapshot().value).toEqual(before)
|
|
||||||
expect(actor.getSnapshot().context.accessSession).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('END_SESSION is a no-op when the gate is disabled (stays at idle)', () => {
|
|
||||||
const actor = createActor(createATMMachine()) // gate off → rests at idle
|
|
||||||
actor.start()
|
|
||||||
expect(actor.getSnapshot().value).toBe('idle')
|
|
||||||
actor.send({ type: 'END_SESSION' })
|
|
||||||
expect(actor.getSnapshot().value).toBe('idle')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('build/dev bypass', () => {
|
|
||||||
it('accessBypassFlag opens the gate even when enabled', () => {
|
|
||||||
const actor = createActor(
|
|
||||||
createATMMachine({}, { accessControlEnabled: true, accessBypassFlag: true })
|
|
||||||
)
|
|
||||||
actor.start()
|
|
||||||
expect(actor.getSnapshot().value).toBe('idle')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('DEV_UNLOCK is a no-op while bypassing (already idle)', () => {
|
|
||||||
const actor = createActor(
|
|
||||||
createATMMachine({}, { accessControlEnabled: true, accessBypassFlag: true })
|
|
||||||
)
|
|
||||||
actor.start()
|
|
||||||
// already idle; DEV_UNLOCK guard is false, so no throw / no change
|
|
||||||
actor.send({ type: 'DEV_UNLOCK' })
|
|
||||||
expect(actor.getSnapshot().value).toBe('idle')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -52,8 +52,6 @@ export {
|
||||||
type PaymentMethod,
|
type PaymentMethod,
|
||||||
type DispenseCashResult,
|
type DispenseCashResult,
|
||||||
type CassetteBillResult,
|
type CassetteBillResult,
|
||||||
type AccessRole,
|
|
||||||
type AccessSession,
|
|
||||||
initialContext,
|
initialContext,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,6 @@ export interface ATMMachineOptions {
|
||||||
currency?: string
|
currency?: string
|
||||||
cashInFeeFraction?: number
|
cashInFeeFraction?: number
|
||||||
cashOutFeeFraction?: number
|
cashOutFeeFraction?: number
|
||||||
/**
|
|
||||||
* ADR-003 access gate. When true, the machine boots into `locked` and
|
|
||||||
* waits for an ACCESS_GRANTED (or DEV_UNLOCK) before reaching `idle`.
|
|
||||||
* Defaults false → `locked` immediately bypasses to `idle` (no gate).
|
|
||||||
*/
|
|
||||||
accessControlEnabled?: boolean
|
|
||||||
/** Build/dev bypass (VITE_SKIP_ACCESS_GATE) — opens the gate even when enabled. */
|
|
||||||
accessBypassFlag?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createATMMachine(
|
export function createATMMachine(
|
||||||
|
|
@ -175,41 +167,9 @@ export function createATMMachine(
|
||||||
inventory: context.inventory,
|
inventory: context.inventory,
|
||||||
cashInFeeFraction: context.cashInFeeFraction,
|
cashInFeeFraction: context.cashInFeeFraction,
|
||||||
cashOutFeeFraction: context.cashOutFeeFraction,
|
cashOutFeeFraction: context.cashOutFeeFraction,
|
||||||
// Preserve the access-gate config across resets — it comes from
|
|
||||||
// machine options, not the transaction, and must survive re-lock.
|
|
||||||
accessControlEnabled: context.accessControlEnabled,
|
|
||||||
accessBypassFlag: context.accessBypassFlag,
|
|
||||||
// Preserve the active access session: `idle`'s entry runs resetContext
|
|
||||||
// AFTER the locked→idle transition action that set the session, so
|
|
||||||
// without this the just-granted session would be wiped. The session is
|
|
||||||
// cleared instead on re-lock (locked's entry), i.e. when access ends.
|
|
||||||
accessSession: context.accessSession,
|
|
||||||
cashInSessionId: null,
|
cashInSessionId: null,
|
||||||
dispenseResult: null,
|
dispenseResult: null,
|
||||||
})),
|
})),
|
||||||
// ADR-003 access-control actions
|
|
||||||
startAccessSession: assign({
|
|
||||||
accessSession: ({ event }) => {
|
|
||||||
if (event.type !== 'ACCESS_GRANTED') return null
|
|
||||||
return { role: event.role, grantedAt: Date.now(), credentialIdHash: event.credentialIdHash }
|
|
||||||
},
|
|
||||||
accessDenyReason: null,
|
|
||||||
}),
|
|
||||||
startDevAccessSession: assign({
|
|
||||||
accessSession: () => ({
|
|
||||||
role: 'operator' as const,
|
|
||||||
grantedAt: Date.now(),
|
|
||||||
credentialIdHash: 'dev-unlock',
|
|
||||||
}),
|
|
||||||
accessDenyReason: null,
|
|
||||||
}),
|
|
||||||
setAccessDenyReason: assign({
|
|
||||||
accessDenyReason: ({ event }) => (event.type === 'ACCESS_DENIED' ? event.reason : null),
|
|
||||||
}),
|
|
||||||
clearAccessDenyReason: assign({ accessDenyReason: null }),
|
|
||||||
// On (re-)entering `locked`, access has ended: drop any prior session so a
|
|
||||||
// stale grant can't leak across the gate.
|
|
||||||
clearAccessSession: assign({ accessSession: null }),
|
|
||||||
setStartTime: assign({
|
setStartTime: assign({
|
||||||
startedAt: () => Date.now(),
|
startedAt: () => Date.now(),
|
||||||
txid: () => generateTxId(),
|
txid: () => generateTxId(),
|
||||||
|
|
@ -416,18 +376,6 @@ export function createATMMachine(
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
guards: {
|
guards: {
|
||||||
// ADR-003: gate is open when access control is off, or the build/dev
|
|
||||||
// bypass is set. Used by `locked`'s eventless `always` transition so a
|
|
||||||
// machine with the gate disabled settles straight into `idle`.
|
|
||||||
accessBypass: ({ context }) => !context.accessControlEnabled || context.accessBypassFlag,
|
|
||||||
// The dev unlock is only meaningful when the gate is actually engaged.
|
|
||||||
devUnlockAllowed: ({ context }) => context.accessControlEnabled && !context.accessBypassFlag,
|
|
||||||
// Gate is actively engaged (enabled + not bypassed) — used to auto re-lock
|
|
||||||
// the `idle` menu on inactivity so an unattended unlocked session (a tapped
|
|
||||||
// card left behind) can't be used by the next person. Same condition as
|
|
||||||
// devUnlockAllowed; named for the lock-timeout intent.
|
|
||||||
accessGateActive: ({ context }) =>
|
|
||||||
context.accessControlEnabled && !context.accessBypassFlag,
|
|
||||||
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
|
// Legacy brain.js parity: "send coins" is a no-op while a bill is
|
||||||
// between the stack command and the validator's stacked-confirmation.
|
// between the stack command and the validator's stacked-confirmation.
|
||||||
|
|
@ -478,16 +426,10 @@ export function createATMMachine(
|
||||||
COMPLETE_DELAY: 60000,
|
COMPLETE_DELAY: 60000,
|
||||||
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond
|
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond
|
||||||
DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState
|
DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState
|
||||||
// NOTE: idle inactivity re-lock + hard session cap are enforced at the
|
|
||||||
// DOM layer (useSessionSecurity), not as XState `after` delays — see the
|
|
||||||
// idle state comment. No IDLE_LOCK_TIMEOUT delay here by design.
|
|
||||||
},
|
},
|
||||||
}).createMachine({
|
}).createMachine({
|
||||||
id: 'atm',
|
id: 'atm',
|
||||||
// ADR-003: `locked` is the resting state. With the gate disabled (the
|
initial: 'idle',
|
||||||
// default), its `always` transition bypasses straight to `idle` on start,
|
|
||||||
// so behavior is identical to the pre-access machine.
|
|
||||||
initial: 'locked',
|
|
||||||
context: {
|
context: {
|
||||||
...initialContext,
|
...initialContext,
|
||||||
...(options?.currency ? { currency: options.currency } : {}),
|
...(options?.currency ? { currency: options.currency } : {}),
|
||||||
|
|
@ -495,12 +437,6 @@ export function createATMMachine(
|
||||||
...(options?.cashOutFeeFraction !== undefined
|
...(options?.cashOutFeeFraction !== undefined
|
||||||
? { cashOutFeeFraction: options.cashOutFeeFraction }
|
? { cashOutFeeFraction: options.cashOutFeeFraction }
|
||||||
: {}),
|
: {}),
|
||||||
...(options?.accessControlEnabled !== undefined
|
|
||||||
? { accessControlEnabled: options.accessControlEnabled }
|
|
||||||
: {}),
|
|
||||||
...(options?.accessBypassFlag !== undefined
|
|
||||||
? { accessBypassFlag: options.accessBypassFlag }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
// Root-level handler: lets the operator-fees subscriber update the
|
// Root-level handler: lets the operator-fees subscriber update the
|
||||||
// active fee fractions reactively. Next cashIn/cashOut entry will
|
// active fee fractions reactively. Next cashIn/cashOut entry will
|
||||||
|
|
@ -515,43 +451,9 @@ export function createATMMachine(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
// === ACCESS GATE (ADR-003) ===
|
|
||||||
// Resting/locked state. A paired, healthy machine sits here until a
|
|
||||||
// valid credential is presented. When the gate is disabled (default)
|
|
||||||
// the eventless `always` transition immediately hands off to `idle`,
|
|
||||||
// so a non-access machine never dwells here.
|
|
||||||
locked: {
|
|
||||||
entry: ['clearAccessDenyReason', 'clearAccessSession'],
|
|
||||||
always: [{ guard: 'accessBypass', target: 'idle' }],
|
|
||||||
on: {
|
|
||||||
ACCESS_GRANTED: { target: 'idle', actions: 'startAccessSession' },
|
|
||||||
DEV_UNLOCK: { guard: 'devUnlockAllowed', target: 'idle', actions: 'startDevAccessSession' },
|
|
||||||
// A denied tap keeps us locked; record the reason for the screen.
|
|
||||||
ACCESS_DENIED: { actions: 'setAccessDenyReason' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
idle: {
|
idle: {
|
||||||
entry: 'resetContext',
|
entry: 'resetContext',
|
||||||
// Idle inactivity re-lock is NOT modeled here as an XState `after`:
|
|
||||||
// that timer is anchored to state ENTRY and never resets on screen
|
|
||||||
// touches (the machine can't see raw pointer events), so it would fire
|
|
||||||
// a fixed countdown regardless of activity. Inactivity is measured at
|
|
||||||
// the DOM layer (useSessionSecurity) and drives END_SESSION on true
|
|
||||||
// idleness. The hard session cap is handled the same way.
|
|
||||||
on: {
|
on: {
|
||||||
// Session kill switch (ADR-003): the "End session" button, the
|
|
||||||
// idle-inactivity timer, and the absolute session cap all route here.
|
|
||||||
// Deliberately handled ONLY from `idle`, not at the machine root: a
|
|
||||||
// root-level END_SESSION would bypass the money-path protections
|
|
||||||
// (`confirmAbandon` with bills stacked, an in-flight dispense, an
|
|
||||||
// outbound payment) and strand the customer's funds. Every
|
|
||||||
// transaction terminal state already returns to `locked` on its own,
|
|
||||||
// so a cap that fires mid-transaction has nothing to gain — the
|
|
||||||
// DOM-layer timers defer until the machine is back at idle. Guarded to
|
|
||||||
// the active gate so a gate-disabled machine (which rests at idle)
|
|
||||||
// can't be knocked out of it. `locked`'s entry clears the session.
|
|
||||||
END_SESSION: { guard: 'accessGateActive', target: '#atm.locked' },
|
|
||||||
SELECT_CASH_IN: {
|
SELECT_CASH_IN: {
|
||||||
target: 'cashIn',
|
target: 'cashIn',
|
||||||
actions: ['setStartTime', 'setCashInFee'],
|
actions: ['setStartTime', 'setCashInFee'],
|
||||||
|
|
@ -589,7 +491,7 @@ export function createATMMachine(
|
||||||
INACTIVITY_TIMEOUT: [
|
INACTIVITY_TIMEOUT: [
|
||||||
{
|
{
|
||||||
guard: ({ context }) => context.billsInserted.length === 0,
|
guard: ({ context }) => context.billsInserted.length === 0,
|
||||||
target: '#atm.locked',
|
target: '#atm.idle',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: 'confirmAbandon',
|
target: 'confirmAbandon',
|
||||||
|
|
@ -622,7 +524,7 @@ export function createATMMachine(
|
||||||
{
|
{
|
||||||
// No bills inserted yet: safe to cancel
|
// No bills inserted yet: safe to cancel
|
||||||
guard: ({ context }) => context.billsInserted.length === 0,
|
guard: ({ context }) => context.billsInserted.length === 0,
|
||||||
target: '#atm.locked',
|
target: '#atm.idle',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Bills already stacked: warn user before abandoning
|
// Bills already stacked: warn user before abandoning
|
||||||
|
|
@ -632,7 +534,7 @@ export function createATMMachine(
|
||||||
TIMEOUT: [
|
TIMEOUT: [
|
||||||
{
|
{
|
||||||
guard: ({ context }) => context.billsInserted.length === 0,
|
guard: ({ context }) => context.billsInserted.length === 0,
|
||||||
target: '#atm.locked',
|
target: '#atm.idle',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: 'confirmAbandon',
|
target: 'confirmAbandon',
|
||||||
|
|
@ -684,10 +586,10 @@ export function createATMMachine(
|
||||||
// like the rest — clear the marker so nothing blocks on it.
|
// like the rest — clear the marker so nothing blocks on it.
|
||||||
entry: 'clearBillPending',
|
entry: 'clearBillPending',
|
||||||
after: {
|
after: {
|
||||||
60000: '#atm.locked',
|
60000: '#atm.idle',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
CANCEL: '#atm.locked', // User confirms they want to leave
|
CANCEL: '#atm.idle', // User confirms they want to leave
|
||||||
RETRY: 'generatingNdebit', // Go back and try again
|
RETRY: 'generatingNdebit', // Go back and try again
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -712,10 +614,10 @@ export function createATMMachine(
|
||||||
},
|
},
|
||||||
complete: {
|
complete: {
|
||||||
after: {
|
after: {
|
||||||
COMPLETE_DELAY: '#atm.locked',
|
COMPLETE_DELAY: '#atm.idle',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
CANCEL: '#atm.locked',
|
CANCEL: '#atm.idle',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
|
|
@ -744,7 +646,7 @@ export function createATMMachine(
|
||||||
{
|
{
|
||||||
// No bills inserted: safe to cancel
|
// No bills inserted: safe to cancel
|
||||||
guard: ({ context }) => context.billsInserted.length === 0,
|
guard: ({ context }) => context.billsInserted.length === 0,
|
||||||
target: '#atm.locked',
|
target: '#atm.idle',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Bills inserted: show abandon warning first
|
// Bills inserted: show abandon warning first
|
||||||
|
|
@ -787,7 +689,7 @@ export function createATMMachine(
|
||||||
// User selects denomination buttons to build up the cash amount
|
// User selects denomination buttons to build up the cash amount
|
||||||
// UI shows: available denominations, running total, sats equivalent
|
// UI shows: available denominations, running total, sats equivalent
|
||||||
after: {
|
after: {
|
||||||
INACTIVITY_TIMEOUT: '#atm.locked',
|
INACTIVITY_TIMEOUT: '#atm.idle',
|
||||||
},
|
},
|
||||||
entry: 'clearCashOutSelection',
|
entry: 'clearCashOutSelection',
|
||||||
on: {
|
on: {
|
||||||
|
|
@ -807,8 +709,8 @@ export function createATMMachine(
|
||||||
target: 'generatingInvoice',
|
target: 'generatingInvoice',
|
||||||
actions: 'calculateDispenseFromSelection',
|
actions: 'calculateDispenseFromSelection',
|
||||||
},
|
},
|
||||||
CANCEL: '#atm.locked',
|
CANCEL: '#atm.idle',
|
||||||
TIMEOUT: '#atm.locked',
|
TIMEOUT: '#atm.idle',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
generatingInvoice: {
|
generatingInvoice: {
|
||||||
|
|
@ -856,7 +758,7 @@ export function createATMMachine(
|
||||||
TIMEOUT: {
|
TIMEOUT: {
|
||||||
target: 'selectingAmount',
|
target: 'selectingAmount',
|
||||||
},
|
},
|
||||||
CANCEL: '#atm.locked',
|
CANCEL: '#atm.idle',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dispensingCash: {
|
dispensingCash: {
|
||||||
|
|
@ -924,20 +826,20 @@ export function createATMMachine(
|
||||||
},
|
},
|
||||||
complete: {
|
complete: {
|
||||||
after: {
|
after: {
|
||||||
COMPLETE_DELAY: '#atm.locked',
|
COMPLETE_DELAY: '#atm.idle',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
CANCEL: '#atm.locked',
|
CANCEL: '#atm.idle',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dispenseError: {
|
dispenseError: {
|
||||||
// Payment received but cash not (fully) dispensed.
|
// Payment received but cash not (fully) dispensed.
|
||||||
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState).
|
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState).
|
||||||
after: {
|
after: {
|
||||||
DISPENSE_ERROR_TIMEOUT: '#atm.locked',
|
DISPENSE_ERROR_TIMEOUT: '#atm.idle',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
CANCEL: '#atm.locked',
|
CANCEL: '#atm.idle',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
|
|
@ -947,7 +849,7 @@ export function createATMMachine(
|
||||||
target: 'fetchingRate',
|
target: 'fetchingRate',
|
||||||
actions: 'incrementRetry',
|
actions: 'incrementRetry',
|
||||||
},
|
},
|
||||||
CANCEL: '#atm.locked',
|
CANCEL: '#atm.idle',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -48,47 +48,8 @@ export interface OfferRequestEvent {
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Access-control role resolved from a presented credential.
|
|
||||||
* `user` may transact; `operator` may additionally reach operator
|
|
||||||
* functions (config/maintenance/enrollment) — reserved for later PRs.
|
|
||||||
* See ADR-003.
|
|
||||||
*/
|
|
||||||
export type AccessRole = 'user' | 'operator'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An active access session, created when a valid credential is presented
|
|
||||||
* (or via the dev unlock). Modeled as general *terminal access*, not a
|
|
||||||
* transaction handle, so the terminal can later gate non-transaction
|
|
||||||
* functions and per-action step-up auth (ADR-003).
|
|
||||||
*/
|
|
||||||
export interface AccessSession {
|
|
||||||
role: AccessRole
|
|
||||||
/** ms epoch when access was granted */
|
|
||||||
grantedAt: number
|
|
||||||
/** salted hash of the presented credential id — never the raw UID (KYC-free) */
|
|
||||||
credentialIdHash: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ATM machine context */
|
/** ATM machine context */
|
||||||
export interface ATMContext {
|
export interface ATMContext {
|
||||||
// Access control (ADR-003)
|
|
||||||
/**
|
|
||||||
* Whether the badge-to-enter access gate is active. When false (the
|
|
||||||
* default), the machine's `locked` initial state immediately bypasses
|
|
||||||
* to `idle` — behavior is identical to a machine with no access layer.
|
|
||||||
*/
|
|
||||||
accessControlEnabled: boolean
|
|
||||||
/**
|
|
||||||
* Build/dev bypass (VITE_SKIP_ACCESS_GATE). Forces the gate open even
|
|
||||||
* when accessControlEnabled is true — for browser dev / CI.
|
|
||||||
*/
|
|
||||||
accessBypassFlag: boolean
|
|
||||||
/** Active access session, or null while locked. */
|
|
||||||
accessSession: AccessSession | null
|
|
||||||
/** Reason for the last denied access attempt (for the locked screen). */
|
|
||||||
accessDenyReason: string | null
|
|
||||||
|
|
||||||
// Transaction details
|
// Transaction details
|
||||||
/** Fiat amount in cents */
|
/** Fiat amount in cents */
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
|
|
@ -175,14 +136,6 @@ export type ATMEvent =
|
||||||
| { type: 'SELECT_CASH_IN' }
|
| { type: 'SELECT_CASH_IN' }
|
||||||
| { type: 'SELECT_CASH_OUT' }
|
| { type: 'SELECT_CASH_OUT' }
|
||||||
| { type: 'CANCEL' }
|
| { type: 'CANCEL' }
|
||||||
// Access control (ADR-003)
|
|
||||||
| { type: 'ACCESS_GRANTED'; role: AccessRole; credentialIdHash: string }
|
|
||||||
| { type: 'ACCESS_DENIED'; reason: string }
|
|
||||||
| { type: 'DEV_UNLOCK' }
|
|
||||||
// User-initiated end of a tap-in session: re-lock immediately instead of
|
|
||||||
// waiting out IDLE_LOCK_TIMEOUT, so a loaded Bolt Card can't be reused by
|
|
||||||
// the next person the moment its holder steps away.
|
|
||||||
| { type: 'END_SESSION' }
|
|
||||||
| { type: 'SELECT_AMOUNT'; amount: number }
|
| { type: 'SELECT_AMOUNT'; amount: number }
|
||||||
| { type: 'FINISH_INSERTING' }
|
| { type: 'FINISH_INSERTING' }
|
||||||
| { type: 'USER_SCANNED_NPUB'; npub: string }
|
| { type: 'USER_SCANNED_NPUB'; npub: string }
|
||||||
|
|
@ -220,12 +173,6 @@ export type ATMEvent =
|
||||||
|
|
||||||
/** Initial context values */
|
/** Initial context values */
|
||||||
export const initialContext: ATMContext = {
|
export const initialContext: ATMContext = {
|
||||||
// Access control defaults OFF — a machine built without the access
|
|
||||||
// options behaves exactly as before (locked → bypass → idle). See ADR-003.
|
|
||||||
accessControlEnabled: false,
|
|
||||||
accessBypassFlag: false,
|
|
||||||
accessSession: null,
|
|
||||||
accessDenyReason: null,
|
|
||||||
fiatCents: 0,
|
fiatCents: 0,
|
||||||
satsAmount: 0,
|
satsAmount: 0,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@bitSpire/ui-shared",
|
"name": "@bitSpire/ui-shared",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Shared Vue 3 components for bitSpire ATM and dashboard",
|
"description": "Shared Vue 3 components for Lamassu ATM and dashboard",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* @bitSpire/ui-shared
|
* @bitSpire/ui-shared
|
||||||
*
|
*
|
||||||
* Shared Vue 3 components for bitSpire ATM and dashboard.
|
* Shared Vue 3 components for Lamassu ATM and dashboard.
|
||||||
* This package will contain common UI components like:
|
* This package will contain common UI components like:
|
||||||
* - QR code display
|
* - QR code display
|
||||||
* - Number pad
|
* - Number pad
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue