Compare commits
68 commits
844fd5f821
...
23fe4a59f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 23fe4a59f1 | |||
| 92a82712da | |||
| d28d5ecd85 | |||
| 0347530511 | |||
| db5f433706 | |||
| f565e004e5 | |||
| 1691511aea | |||
| c1ada736d2 | |||
| 645fd57e5b | |||
| 425f00a71d | |||
| e516ab449a | |||
| cda3f3f17e | |||
| 73a77c82b3 | |||
| 48c71f7902 | |||
| d66f50dbdf | |||
| 74e488cd00 | |||
| a68e462462 | |||
| 8d2509b092 | |||
| 65f5f982ec | |||
| c889f7f0df | |||
| 9077f9c299 | |||
| 7e3112987d | |||
| e4742963a3 | |||
| 8c0336c4f6 | |||
| 474903c38b | |||
| 54c59fadcc | |||
| db68e6e244 | |||
| 0d43c4e033 | |||
| 5b22447dae | |||
| ac27bc36e0 | |||
| e3b51eaa37 | |||
| 387bed0679 | |||
| 71b2691f8c | |||
| 012fecef5e | |||
| aab2d074c3 | |||
| 61d5bf0231 | |||
| 67763d6e8d | |||
| 67573008ee | |||
| ebb07ce22d | |||
| da4969d510 | |||
| 54244a4708 | |||
| aa22ba1c27 | |||
| 8e11c41f62 | |||
| 42e3657fe1 | |||
| d304e59ef0 | |||
| a497f0ca08 | |||
| 3efbdf164b | |||
| ec2b15c08f | |||
| 6779d8ef55 | |||
| 735132b032 | |||
| ce4b5a5dc6 | |||
| f11aced450 | |||
| a652089441 | |||
| f84cad76a9 | |||
| 04767080a1 | |||
| 5114619fce | |||
| 82fbf12950 | |||
| 4ce68c1301 | |||
| fbcaa3121f | |||
| d35faf1c93 | |||
| c83b40fe5e | |||
|
|
6676c26761 | ||
|
|
44a5ebbd12 | ||
|
|
c7e312a63f | ||
|
|
a7b409b109 | ||
| 2ea3df01d1 | |||
| cb236703d6 | |||
| 46e52f6598 |
63 changed files with 4753 additions and 497 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. 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**.
|
**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.
|
||||||
|
|
||||||
Core principles:
|
Core principles:
|
||||||
|
|
||||||
|
|
@ -25,8 +25,53 @@ bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lam
|
||||||
|
|
||||||
## Branch model
|
## Branch model
|
||||||
|
|
||||||
- `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.
|
- `dev` — **what every live machine runs.** Not a staging branch any more. Verified
|
||||||
- `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.
|
2026-09-24 on batm3, whose `nixos-upgrade` unit pulls
|
||||||
|
`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
|
||||||
|
|
||||||
|
|
@ -219,7 +264,8 @@ 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.
|
||||||
- `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`).
|
- **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 `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,3 +93,31 @@ 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,10 +12,16 @@
|
||||||
|
|
||||||
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'
|
||||||
|
|
@ -168,3 +174,322 @@ 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
125
apps/machine/electron/boltcard-session.test.ts
Normal file
125
apps/machine/electron/boltcard-session.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
167
apps/machine/electron/boltcard-session.ts
Normal file
167
apps/machine/electron/boltcard-session.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
/**
|
||||||
|
* 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,5 +1,10 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { resolveCardInvoice, scanUrlToResolver, lnAddressToLnurlp } from './lnurl-pay'
|
import {
|
||||||
|
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'
|
||||||
|
|
@ -118,3 +123,43 @@ 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,6 +59,17 @@ 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
|
||||||
|
|
@ -163,17 +174,18 @@ async function toPayRequest(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestInvoice(
|
async function requestInvoice(
|
||||||
pr: PayRequest,
|
pr: PayStep,
|
||||||
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) })
|
||||||
|
|
@ -222,5 +234,19 @@ 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, amountMsat, ctx)
|
return requestInvoice({ ...pr.payRequest, callback: pr.payRequest.callback! }, amountMsat, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The LUD-06 second step alone: fetch a BOLT11 for `amountMsat` from an
|
||||||
|
* already-obtained pay step (from a Bolt Card session opened at tap-to-enter).
|
||||||
|
* Never throws — every failure returns `{ ok: false, reason }`.
|
||||||
|
*/
|
||||||
|
export async function resolveInvoiceFromPayStep(
|
||||||
|
step: PayStep,
|
||||||
|
amountMsat: number,
|
||||||
|
opts: ResolveCardInvoiceOptions = {}
|
||||||
|
): Promise<ResolveCardInvoiceResult> {
|
||||||
|
const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
|
||||||
|
return requestInvoice(step, amountMsat, ctx)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { executeLnurlWithdraw, lnurlwToHttps } from './lnurl-withdraw'
|
import { executeLnurlWithdraw, executeWithdrawCallback, lnurlwToHttps } from './lnurl-withdraw'
|
||||||
|
|
||||||
const BOLT11 = 'lnbc10u1p3xyz...'
|
const BOLT11 = 'lnbc10u1p3xyz...'
|
||||||
const LNURLW =
|
const LNURLW =
|
||||||
|
|
@ -101,3 +101,44 @@ 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,6 +36,17 @@ 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
|
||||||
|
|
@ -73,7 +84,8 @@ function appendQuery(url: string, params: Record<string, string>): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function errMsg(e: unknown): string {
|
function errMsg(e: unknown): string {
|
||||||
if (e instanceof Error) return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
if (e instanceof Error)
|
||||||
|
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||||
return String(e)
|
return String(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,16 +117,46 @@ 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 params.maxWithdrawable === 'number' &&
|
typeof step.maxWithdrawable === 'number' &&
|
||||||
opts.amountMsat > params.maxWithdrawable
|
opts.amountMsat > step.maxWithdrawable
|
||||||
) {
|
) {
|
||||||
return { ok: false, reason: 'card limit is below this amount' }
|
return { ok: false, reason: 'card limit is below this amount' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Hand our invoice to the callback — the card's wallet pays it.
|
const cbUrl = appendQuery(step.callback, { k1: step.k1, pr: bolt11.trim() })
|
||||||
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,26 +24,36 @@ import {
|
||||||
markCommandExecuting,
|
markCommandExecuting,
|
||||||
completeCommand,
|
completeCommand,
|
||||||
getLastKnownConfigCreatedAt,
|
getLastKnownConfigCreatedAt,
|
||||||
getBootstrapPublishedAt,
|
getCountsUncertainSince,
|
||||||
markBootstrapPublished,
|
getLastStatePublishedAt,
|
||||||
resetBootstrapGate,
|
markCountsUncertain,
|
||||||
|
markStatePublished,
|
||||||
|
resetStatePublishWatermark,
|
||||||
resetForRepair,
|
resetForRepair,
|
||||||
applyOperatorCassettesConfig,
|
applyOperatorCassetteOps,
|
||||||
|
getAppliedOpIds,
|
||||||
|
getCassetteStateSeq,
|
||||||
getFeeConfig,
|
getFeeConfig,
|
||||||
getLastKnownFeeConfigCreatedAt,
|
getLastKnownFeeConfigCreatedAt,
|
||||||
applyFeeConfig,
|
applyFeeConfig,
|
||||||
getBunkerBinding,
|
getBunkerBinding,
|
||||||
saveBunkerBinding,
|
saveBunkerBinding,
|
||||||
clearBunkerBinding,
|
clearBunkerBinding,
|
||||||
type OperatorCassettesPayload,
|
type CassetteOp,
|
||||||
|
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 { executeLnurlWithdraw } from './lnurl-withdraw.js'
|
import {
|
||||||
import { resolveCardInvoice } from './lnurl-pay.js'
|
executeLnurlWithdraw,
|
||||||
|
executeWithdrawCallback,
|
||||||
|
type WithdrawStep,
|
||||||
|
} from './lnurl-withdraw.js'
|
||||||
|
import { resolveCardInvoice, resolveInvoiceFromPayStep, type PayStep } from './lnurl-pay.js'
|
||||||
|
import { openCardSession, type OpenCardSessionResult } from './boltcard-session.js'
|
||||||
import { startNfcReader, type NfcStatus } from './nfc-service.js'
|
import { startNfcReader, type NfcStatus } from './nfc-service.js'
|
||||||
|
|
||||||
// ESM equivalent of __dirname
|
// ESM equivalent of __dirname
|
||||||
|
|
@ -164,6 +174,62 @@ 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' &&
|
||||||
|
|
@ -311,6 +377,9 @@ 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(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -346,7 +415,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 bootstrap gate so the
|
// successful pairing (connectNewSeed), and resets the publish watermark 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)
|
||||||
|
|
@ -354,8 +423,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-bootstrap-gate', (): void => {
|
ipcMain.handle('state:reset-state-publish-watermark', (): void => {
|
||||||
resetBootstrapGate()
|
resetStatePublishWatermark()
|
||||||
})
|
})
|
||||||
ipcMain.handle('state:reset-for-repair', (): void => {
|
ipcMain.handle('state:reset-for-repair', (): void => {
|
||||||
resetForRepair()
|
resetForRepair()
|
||||||
|
|
@ -444,6 +513,37 @@ 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))
|
||||||
|
|
@ -460,15 +560,22 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
|
||||||
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
|
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
|
||||||
getLastKnownConfigCreatedAt()
|
getLastKnownConfigCreatedAt()
|
||||||
)
|
)
|
||||||
ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt())
|
ipcMain.handle('state:get-last-state-published-at', (): number | null => getLastStatePublishedAt())
|
||||||
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
|
ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCountsUncertainSince())
|
||||||
markBootstrapPublished(unixTimestamp)
|
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
|
||||||
|
markCountsUncertain(unixTimestamp)
|
||||||
|
})
|
||||||
|
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
|
||||||
|
markStatePublished(unixTimestamp)
|
||||||
})
|
})
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'state:apply-operator-cassettes-config',
|
'state:apply-operator-cassette-ops',
|
||||||
(_event, payload: OperatorCassettesPayload, eventCreatedAt: number): ApplyResult =>
|
(_event, ops: CassetteOp[]): ApplyOpsResult => applyOperatorCassetteOps(ops)
|
||||||
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
|
||||||
|
|
@ -746,6 +853,11 @@ 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,16 +108,21 @@ 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'),
|
||||||
getBootstrapPublishedAt: (): Promise<number | null> =>
|
getLastStatePublishedAt: (): Promise<number | null> =>
|
||||||
ipcRenderer.invoke('state:get-bootstrap-published-at'),
|
ipcRenderer.invoke('state:get-last-state-published-at'),
|
||||||
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
|
getCountsUncertainSince: (): Promise<number | null> =>
|
||||||
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
|
ipcRenderer.invoke('state:get-counts-uncertain-since'),
|
||||||
|
markCountsUncertain: (unixTimestamp: number): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp),
|
||||||
|
markStatePublished: (unixTimestamp: number): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
|
||||||
|
|
||||||
// Bunker binding persistence (aiolabs/bitspire#52)
|
// 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'),
|
||||||
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'),
|
resetStatePublishWatermark: (): Promise<void> =>
|
||||||
|
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,
|
||||||
|
|
@ -141,13 +146,40 @@ 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),
|
||||||
|
|
||||||
applyOperatorCassettesConfig: (
|
// Bolt Card tap-to-enter: one verified session per tap (balance + fiat +
|
||||||
payload: {
|
// the withdraw/pay second steps reused at Complete). Payload shapes are
|
||||||
positions: Record<string, { denomination: number; count: number }>
|
// declared in src/types/electron.d.ts (CardSession).
|
||||||
},
|
openCardSession: (args: { lnurlw: string }): Promise<unknown> =>
|
||||||
eventCreatedAt: number
|
ipcRenderer.invoke('lnurl:open-card-session', args),
|
||||||
): Promise<{ applied: true } | { applied: false; reason: string }> =>
|
withdrawWithSession: (args: {
|
||||||
ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt),
|
withdraw: { callback: string; k1: string; minWithdrawable?: number; maxWithdrawable?: number }
|
||||||
|
bolt11: string
|
||||||
|
amountMsat?: number
|
||||||
|
}): Promise<{ ok: boolean; reason?: string }> =>
|
||||||
|
ipcRenderer.invoke('lnurl:withdraw-session', args),
|
||||||
|
resolveSessionInvoice: (args: {
|
||||||
|
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
|
||||||
|
amountMsat: number
|
||||||
|
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
|
||||||
|
ipcRenderer.invoke('lnurl:pay-session', args),
|
||||||
|
|
||||||
|
applyOperatorCassetteOps: (
|
||||||
|
ops: {
|
||||||
|
id: string
|
||||||
|
at: number
|
||||||
|
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||||
|
position: number
|
||||||
|
bills?: number
|
||||||
|
count?: number
|
||||||
|
denomination?: number
|
||||||
|
}[]
|
||||||
|
): Promise<{
|
||||||
|
applied: string[]
|
||||||
|
rejected: { id: string; reason: string }[]
|
||||||
|
}> => ipcRenderer.invoke('state:apply-operator-cassette-ops', ops),
|
||||||
|
getAppliedOpIds: (limit?: number): Promise<string[]> =>
|
||||||
|
ipcRenderer.invoke('state:get-applied-op-ids', limit),
|
||||||
|
getCassetteStateSeq: (): Promise<number> => ipcRenderer.invoke('state:get-cassette-state-seq'),
|
||||||
|
|
||||||
// Operator-fees consumer (aiolabs/lamassu-next#57)
|
// Operator-fees consumer (aiolabs/lamassu-next#57)
|
||||||
getFeeConfig: (): Promise<{
|
getFeeConfig: (): Promise<{
|
||||||
|
|
@ -205,6 +237,13 @@ 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))
|
||||||
|
|
@ -265,11 +304,13 @@ 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>
|
||||||
getBootstrapPublishedAt: () => Promise<number | null>
|
getLastStatePublishedAt: () => Promise<number | 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>
|
||||||
resetBootstrapGate: () => Promise<void>
|
resetStatePublishWatermark: () => Promise<void>
|
||||||
resetForRepair: () => Promise<void>
|
resetForRepair: () => Promise<void>
|
||||||
saveSpireSeed: (seed: string) => Promise<void>
|
saveSpireSeed: (seed: string) => Promise<void>
|
||||||
relaunchApp: () => Promise<void>
|
relaunchApp: () => Promise<void>
|
||||||
|
|
@ -283,10 +324,22 @@ declare global {
|
||||||
lnurlw: string
|
lnurlw: string
|
||||||
amountMsat: number
|
amountMsat: number
|
||||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassetteOps: (
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
ops: {
|
||||||
eventCreatedAt: number
|
id: string
|
||||||
) => Promise<{ applied: true } | { applied: false; reason: 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
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '12'
|
const SCHEMA_VERSION = '13'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/bitspire'
|
const prodDir = '/var/lib/bitspire'
|
||||||
|
|
@ -57,6 +57,17 @@ 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,
|
||||||
|
|
@ -295,7 +306,9 @@ 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('[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)')
|
console.log(
|
||||||
|
'[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)'
|
||||||
|
)
|
||||||
existing.value = '9'
|
existing.value = '9'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,12 +384,47 @@ 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) {
|
||||||
|
|
@ -397,32 +445,110 @@ 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
|
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('lastKnownConfigCreatedAt') as
|
||||||
.prepare('SELECT value FROM meta WHERE key = ?')
|
| { value: string }
|
||||||
.get('lastKnownConfigCreatedAt') as { value: string } | undefined
|
| undefined
|
||||||
return row ? Number(row.value) || 0 : 0
|
return row ? Number(row.value) || 0 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the one-shot bootstrap-publish gate. Returns null if the ATM has
|
* The `created_at` of the last `bitspire-cassettes-state` event this machine
|
||||||
* not yet published its `bitspire-cassettes-state:<machine_id>` hello-event.
|
* published, or null if it has never published one.
|
||||||
|
*
|
||||||
|
* This used to be a one-shot gate ("have we said hello yet"), which meant a
|
||||||
|
* layout change after first boot was never announced (#94). It is now a
|
||||||
|
* high-water mark: every publish records its stamp, and the next one is forced
|
||||||
|
* strictly above it. Addressable events are ordered by `created_at` at second
|
||||||
|
* granularity, and a relay silently keeps the higher one, so a clock that steps
|
||||||
|
* backwards would otherwise make this machine's reports vanish with an `OK`.
|
||||||
|
*
|
||||||
|
* Stored under the original `bootstrapPublishedAt` meta key so no migration is
|
||||||
|
* needed; the name is historical, the meaning is not.
|
||||||
*/
|
*/
|
||||||
export function getBootstrapPublishedAt(): number | null {
|
export function getLastStatePublishedAt(): number | null {
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
const row = db
|
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as
|
||||||
.prepare('SELECT value FROM meta WHERE key = ?')
|
| { value: string }
|
||||||
.get('bootstrapPublishedAt') as { value: string } | undefined
|
| 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the bootstrap hello-event as published. Idempotent — only takes
|
* Whether the bay counts are known to be unverified, and since when.
|
||||||
* effect the first time it's set. Subsequent calls overwrite the
|
*
|
||||||
* timestamp (harmless; the gate just needs to be non-null).
|
* Set when a dispense ends without the dispenser reporting what it moved — a
|
||||||
|
* driver throw, or the dispense timeout. Bills may well have reached the
|
||||||
|
* customer, but nothing knows how many, so neither the rows here nor HAL's
|
||||||
|
* bays were debited and both now read high. Reporting that number as fact is
|
||||||
|
* the worst option available; saying the number is unverified is honest and
|
||||||
|
* tells the operator to open the machine and recount.
|
||||||
|
*
|
||||||
|
* Cleared when an operator asserts authoritative counts (a config apply),
|
||||||
|
* which is precisely what a recount is. Uses an upsert so no migration is
|
||||||
|
* needed for machines whose meta table predates the key.
|
||||||
*/
|
*/
|
||||||
export function markBootstrapPublished(unixTimestamp: number): void {
|
export function getCountsUncertainSince(): number | null {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('countsUncertainSince') as
|
||||||
|
| { value: string }
|
||||||
|
| undefined
|
||||||
|
if (!row || row.value === '') return null
|
||||||
|
const n = Number(row.value)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flag the counts as unverified. Keeps the earliest time it went bad. */
|
||||||
|
export function markCountsUncertain(unixTimestamp: number): void {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
if (getCountsUncertainSince() !== null) return
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
|
).run('countsUncertainSince', String(unixTimestamp))
|
||||||
|
console.warn('[StateStore] Cassette counts flagged unverified at', unixTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the flag — an operator has asserted real counts. */
|
||||||
|
export function clearCountsUncertain(): void {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
|
).run('countsUncertainSince', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A counter bumped on every local change to a bay count, from any cause.
|
||||||
|
*
|
||||||
|
* It rides along in the state document so a reader can reject a regression
|
||||||
|
* without trusting a clock. `created_at` cannot carry that: it has
|
||||||
|
* second granularity, so two publishes in the same second are ordered by
|
||||||
|
* whichever event id hashes lower — and a machine whose clock stepped
|
||||||
|
* backwards would otherwise have every later report look older than the one
|
||||||
|
* already on the relay.
|
||||||
|
*/
|
||||||
|
export function getCassetteStateSeq(): number {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cassetteStateSeq') as
|
||||||
|
| { value: string }
|
||||||
|
| undefined
|
||||||
|
return row ? Number(row.value) || 0 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bump the counter. Safe to call inside an open transaction — every caller
|
||||||
|
* that mutates a count does, so the bump commits or rolls back with it.
|
||||||
|
*/
|
||||||
|
export function bumpCassetteStateSeq(): void {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO meta (key, value) VALUES (?, ?) ' +
|
||||||
|
'ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(meta.value AS INTEGER) + 1 AS TEXT)'
|
||||||
|
).run('cassetteStateSeq', '1')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record the `created_at` just published, as the next publish's floor. */
|
||||||
|
export function markStatePublished(unixTimestamp: number): void {
|
||||||
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),
|
||||||
|
|
@ -531,11 +657,12 @@ export function clearBunkerBinding(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset the bootstrap-publish gate so the ATM re-publishes its
|
* Forget the publish high-water mark. Called on a re-pair (new seed): the
|
||||||
* `bitspire-cassettes-state` hello-event. Called on a re-pair (new seed) so
|
* next publish is then free to use the wall clock, which is what a fresh
|
||||||
* the new operator receives the spire's current state (aiolabs/bitspire#56).
|
* operator relationship wants. The state itself is republished on startup
|
||||||
|
* regardless, so the new operator always receives current counts.
|
||||||
*/
|
*/
|
||||||
export function resetBootstrapGate(): void {
|
export function resetStatePublishWatermark(): 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')
|
||||||
}
|
}
|
||||||
|
|
@ -566,108 +693,190 @@ export function resetForRepair(): void {
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OperatorCassettesPayload = {
|
/**
|
||||||
positions: Record<string, { denomination: number; count: number }>
|
* Outcome of applying an operator-authored absolute config. Still the right
|
||||||
|
* shape for fee config, where the operator is the only writer of the value
|
||||||
|
* and a later event simply supersedes an earlier one. Cassette counts left
|
||||||
|
* this model in ADR-004 precisely because they had two writers.
|
||||||
|
*/
|
||||||
|
export type ApplyResult = { applied: true } | { applied: false; reason: string }
|
||||||
|
|
||||||
|
/** One operator-authored operation, as it arrives on the wire. */
|
||||||
|
export type CassetteOp = {
|
||||||
|
id: string
|
||||||
|
at: number
|
||||||
|
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||||
|
position: number
|
||||||
|
bills?: number
|
||||||
|
count?: number
|
||||||
|
denomination?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApplyResult =
|
export type ApplyOpsResult = {
|
||||||
| { applied: true }
|
/** Ids applied by this call. Empty when every op was already on file. */
|
||||||
| { applied: false; reason: string }
|
applied: string[]
|
||||||
|
/** Ids rejected, with why. These stay unapplied and unrecorded. */
|
||||||
|
rejected: { id: string; reason: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CASSETTE_OP_TYPES = new Set(['refill', 'empty', 'recount', 'set_denomination'])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56).
|
* Validate one operation in isolation. Returns null when it is well-formed.
|
||||||
*
|
*
|
||||||
* Caller has already verified the event signature and decrypted the
|
* Shape errors and unknown positions are treated the same way by the caller:
|
||||||
* content. This function:
|
* the op is neither applied nor recorded, so it stays pending on the
|
||||||
*
|
* operator's dashboard. That is the honest outcome — it did not happen — and
|
||||||
* 1. Rechecks replay-protection against `meta.lastKnownConfigCreatedAt`
|
* it beats recording it as applied to stop the noise, which would tell the
|
||||||
* (defense-in-depth — caller should have done this too).
|
* operator their refill landed when the notes are unaccounted for.
|
||||||
* 2. Validates the payload's `positions` key set is *exactly* the set of
|
|
||||||
* positions currently in the `cassettes` table. The bay count is
|
|
||||||
* hardware-determined and can't be added to or removed from via this
|
|
||||||
* path; only the per-bay denomination and count are operator-mutable.
|
|
||||||
* 3. Validates per-entry `denomination` is a positive int, `count` is a
|
|
||||||
* non-negative int. **Duplicate denominations across positions are
|
|
||||||
* intentionally permitted** — real machines load multiple cassettes
|
|
||||||
* with the same denomination for cash-out throughput.
|
|
||||||
* 4. In a single SQLite transaction: updates `cassettes` rows by position
|
|
||||||
* (denomination + count both mutable per row) AND advances
|
|
||||||
* `meta.lastKnownConfigCreatedAt` to `eventCreatedAt`.
|
|
||||||
*
|
|
||||||
* Mid-write crashes roll back cleanly; on restart the same event is
|
|
||||||
* re-delivered by the relay and the watermark check drops it as already
|
|
||||||
* consumed (or the watermark is pre-event because the tx rolled back,
|
|
||||||
* and the apply runs again from scratch).
|
|
||||||
*/
|
*/
|
||||||
export function applyOperatorCassettesConfig(
|
function validateCassetteOp(op: CassetteOp, knownPositions: Set<number>): string | null {
|
||||||
payload: OperatorCassettesPayload,
|
if (typeof op.id !== 'string' || op.id.length === 0) return 'missing id'
|
||||||
eventCreatedAt: number
|
if (!CASSETTE_OP_TYPES.has(op.type)) return `unknown type ${String(op.type)}`
|
||||||
): ApplyResult {
|
if (!Number.isInteger(op.position)) return `position must be an integer (got ${op.position})`
|
||||||
|
if (!knownPositions.has(op.position)) return `unknown position ${op.position}`
|
||||||
|
if (!Number.isFinite(op.at)) return 'missing at'
|
||||||
|
|
||||||
|
if (op.type === 'refill') {
|
||||||
|
if (!Number.isInteger(op.bills) || (op.bills as number) <= 0) {
|
||||||
|
return `refill needs a positive integer bills (got ${op.bills})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (op.type === 'recount') {
|
||||||
|
if (!Number.isInteger(op.count) || (op.count as number) < 0) {
|
||||||
|
return `recount needs a non-negative integer count (got ${op.count})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (op.type === 'set_denomination') {
|
||||||
|
if (!Number.isInteger(op.denomination) || (op.denomination as number) <= 0) {
|
||||||
|
return `set_denomination needs a positive integer denomination (got ${op.denomination})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an operator's cassette operations, skipping any already on file.
|
||||||
|
*
|
||||||
|
* This replaces applying absolute counts. The operator authors what it DID —
|
||||||
|
* a refill in notes added, an empty, a recount, a denomination change — and
|
||||||
|
* this machine, which holds the physical notes, keeps the running total.
|
||||||
|
* Nobody but this process writes a count any more, so there is no second
|
||||||
|
* writer to lose a race to.
|
||||||
|
*
|
||||||
|
* Deltas are not idempotent and addressable events ARE re-delivered on every
|
||||||
|
* relay reconnect, so idempotency is carried explicitly: the operator mints an
|
||||||
|
* id per operation, `cassette_ops` records the ones applied, and a repeat is a
|
||||||
|
* no-op. That is also why there is no `created_at` watermark here any more.
|
||||||
|
* Under absolute counts the watermark was the only replay defence; with
|
||||||
|
* per-op ids it is strictly weaker than the dedup and would do active harm,
|
||||||
|
* because an event that arrives out of order may still carry an operation this
|
||||||
|
* machine has never seen.
|
||||||
|
*
|
||||||
|
* Applied oldest-first by `at`, ties broken by id so two operations stamped in
|
||||||
|
* the same second still order the same way on every machine. Ordering matters
|
||||||
|
* because a recount followed by a refill is not the same as the reverse.
|
||||||
|
*
|
||||||
|
* The whole batch runs in one SQLite transaction with the sequence bump, so a
|
||||||
|
* crash mid-apply rolls back to a coherent count and the next publish re-offers
|
||||||
|
* every op in the window.
|
||||||
|
*/
|
||||||
|
export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult {
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
const database = db
|
||||||
|
const result: ApplyOpsResult = { applied: [], rejected: [] }
|
||||||
|
if (ops.length === 0) return result
|
||||||
|
|
||||||
const watermark = getLastKnownConfigCreatedAt()
|
const knownPositions = new Set(
|
||||||
if (eventCreatedAt <= watermark) {
|
(database.prepare('SELECT position FROM cassettes').all() as { position: number }[]).map(
|
||||||
return {
|
(r) => r.position
|
||||||
applied: false,
|
)
|
||||||
reason: `event.created_at (${eventCreatedAt}) <= lastKnownConfigCreatedAt (${watermark})`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentRows = db
|
|
||||||
.prepare('SELECT position FROM cassettes')
|
|
||||||
.all() as { position: number }[]
|
|
||||||
const currentPositions = new Set(currentRows.map((r) => r.position))
|
|
||||||
const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k)))
|
|
||||||
|
|
||||||
if (currentPositions.size !== payloadPositions.size) {
|
|
||||||
return {
|
|
||||||
applied: false,
|
|
||||||
reason: `position count mismatch: state.db has ${currentPositions.size}, payload has ${payloadPositions.size}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const p of currentPositions) {
|
|
||||||
if (!payloadPositions.has(p)) {
|
|
||||||
return { applied: false, reason: `payload missing position ${p}` }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const p of payloadPositions) {
|
|
||||||
if (!currentPositions.has(p)) {
|
|
||||||
return { applied: false, reason: `payload includes unknown position ${p}` }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
|
||||||
if (!Number.isInteger(entry.denomination) || entry.denomination <= 0) {
|
|
||||||
return {
|
|
||||||
applied: false,
|
|
||||||
reason: `denomination must be positive int (position ${posKey}, got ${entry.denomination})`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 seen = database.prepare('SELECT 1 FROM cassette_ops WHERE id = ?')
|
||||||
|
|
||||||
const run = db.transaction(() => {
|
const pending: CassetteOp[] = []
|
||||||
for (const [posKey, entry] of Object.entries(payload.positions)) {
|
for (const op of ops) {
|
||||||
updateCassette.run(entry.denomination, entry.count, Number(posKey))
|
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
|
||||||
}
|
}
|
||||||
setWatermark.run(String(eventCreatedAt), 'lastKnownConfigCreatedAt')
|
pending.push(op)
|
||||||
})
|
}
|
||||||
|
if (pending.length === 0) return result
|
||||||
|
|
||||||
|
pending.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||||
|
|
||||||
|
const addBills = database.prepare(
|
||||||
|
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
|
||||||
|
)
|
||||||
|
const setCount = database.prepare('UPDATE cassettes SET count = ? WHERE position = ?')
|
||||||
|
const setDenomination = database.prepare(
|
||||||
|
'UPDATE cassettes SET denomination = ? WHERE position = ?'
|
||||||
|
)
|
||||||
|
const recordOp = database.prepare(
|
||||||
|
'INSERT INTO cassette_ops (id, position, op_type, bills, count, denomination, op_at, applied_at) ' +
|
||||||
|
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
const upsertMeta = database.prepare(
|
||||||
|
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
|
)
|
||||||
|
|
||||||
|
const appliedAt = Math.floor(Date.now() / 1000)
|
||||||
|
let sawRecount = false
|
||||||
|
|
||||||
|
database.transaction(() => {
|
||||||
|
for (const op of pending) {
|
||||||
|
if (op.type === 'refill') addBills.run(op.bills, op.position)
|
||||||
|
else if (op.type === 'empty') setCount.run(0, op.position)
|
||||||
|
else if (op.type === 'recount') {
|
||||||
|
setCount.run(op.count, op.position)
|
||||||
|
sawRecount = true
|
||||||
|
} else setDenomination.run(op.denomination, op.position)
|
||||||
|
|
||||||
|
recordOp.run(
|
||||||
|
op.id,
|
||||||
|
op.position,
|
||||||
|
op.type,
|
||||||
|
op.bills ?? null,
|
||||||
|
op.count ?? null,
|
||||||
|
op.denomination ?? null,
|
||||||
|
Math.floor(op.at),
|
||||||
|
appliedAt
|
||||||
|
)
|
||||||
|
result.applied.push(op.id)
|
||||||
|
}
|
||||||
|
bumpCassetteStateSeq()
|
||||||
|
// A recount is an operator opening the bay and counting it, which is
|
||||||
|
// exactly what resolves an unverified count. Nothing else does: a refill
|
||||||
|
// adds to a number still known to be wrong.
|
||||||
|
if (sawRecount) upsertMeta.run('countsUncertainSince', '')
|
||||||
|
})()
|
||||||
|
|
||||||
run()
|
|
||||||
console.log(
|
console.log(
|
||||||
`[StateStore] Applied operator cassettes config @ created_at=${eventCreatedAt} (${Object.keys(payload.positions).length} positions)`
|
`[StateStore] Applied ${result.applied.length} cassette op(s)` +
|
||||||
|
(result.rejected.length ? `, rejected ${result.rejected.length}` : '')
|
||||||
)
|
)
|
||||||
return { applied: true }
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ids most recently applied, newest first — the acknowledgement leg of
|
||||||
|
* the protocol.
|
||||||
|
*
|
||||||
|
* An addressable event gives its publisher no failure signal at all: the relay
|
||||||
|
* returns OK for an event it then discards, and a losing writer is never told.
|
||||||
|
* Echoing the ids back in this machine's own state document is the only way
|
||||||
|
* the operator can distinguish an operation that landed from one that was
|
||||||
|
* merely sent.
|
||||||
|
*/
|
||||||
|
export function getAppliedOpIds(limit = 50): string[] {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT id FROM cassette_ops ORDER BY applied_at DESC, rowid DESC LIMIT ?')
|
||||||
|
.all(limit) as { id: string }[]
|
||||||
|
return rows.map((r) => r.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -752,10 +961,7 @@ export interface FeeConfigPayload {
|
||||||
*/
|
*/
|
||||||
const FEE_CAP_PER_DIRECTION = 0.15
|
const FEE_CAP_PER_DIRECTION = 0.15
|
||||||
|
|
||||||
export function applyFeeConfig(
|
export function applyFeeConfig(payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult {
|
||||||
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()
|
||||||
|
|
@ -848,6 +1054,7 @@ 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()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -862,11 +1069,14 @@ 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
|
||||||
|
|
||||||
db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?').run(
|
database.transaction(() => {
|
||||||
delta,
|
database
|
||||||
position
|
.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?')
|
||||||
)
|
.run(delta, position)
|
||||||
|
bumpCassetteStateSeq()
|
||||||
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -879,9 +1089,14 @@ 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) {
|
||||||
if (row.count > 0) {
|
// Zero-count bays are KEPT. Dropping them made a drained machine
|
||||||
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
|
// indistinguishable from an unconfigured one, and every caller reads an
|
||||||
}
|
// empty map as "I don't know, ask the hardware" — so the last non-empty
|
||||||
|
// snapshot stuck and the availability beacon went on advertising bills
|
||||||
|
// that had already been dispensed. An empty map now means exactly one
|
||||||
|
// thing: no cassettes are configured. Consumers already filter for
|
||||||
|
// `> 0` before offering a denomination (CashOutView, machine.ts).
|
||||||
|
inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count
|
||||||
}
|
}
|
||||||
return inv
|
return inv
|
||||||
}
|
}
|
||||||
|
|
@ -1026,7 +1241,15 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.type === 'cash_out') {
|
// Any dispense empties bays, whoever asked for it. `manual_dispense`
|
||||||
|
// (operator remediation, via the command poller or a kind-21003 command)
|
||||||
|
// used to fall outside this branch: HAL decremented its in-memory bays but
|
||||||
|
// the rows here did not move, and on the next boot HAL re-seeds from these
|
||||||
|
// rows — so the machine came back believing it still held bills a customer
|
||||||
|
// had already been handed (#76). A remediation against a partly-dispensed
|
||||||
|
// original decrements again on purpose: the original only ever debited what
|
||||||
|
// physically left, and this is a second lot of bills leaving.
|
||||||
|
if (t.type === 'cash_out' || t.type === 'manual_dispense') {
|
||||||
// Decrement cassettes by ACTUALLY dispensed count (not requested).
|
// 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
|
||||||
|
|
@ -1056,6 +1279,10 @@ 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/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/logo.png" />
|
||||||
<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>Lamassu ATM</title>
|
<title>bitSpire ATM</title>
|
||||||
<style>
|
<style>
|
||||||
/* Prevent text selection and context menu on kiosk */
|
/* Prevent text selection and context menu on kiosk */
|
||||||
* {
|
* {
|
||||||
|
|
@ -33,12 +33,17 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
/* Kiosk-only: lock overflow and hide cursor */
|
/* Kiosk-only: lock overflow. The cursor is NOT hidden here — that is
|
||||||
|
src/style.css's `.kiosk` rule, which main.ts applies at runtime unless
|
||||||
|
VITE_DEMO_TAG is set. This block can't make that distinction (static
|
||||||
|
HTML, and the CSP forbids an inline script to read the env), so a
|
||||||
|
`cursor: none` here would also blank the pointer on the public web
|
||||||
|
demo, where people drive the kiosk with a mouse. One owner for cursor
|
||||||
|
hiding, and it is the one that knows whether this is a real machine. */
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,39 @@
|
||||||
<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 } from 'vue-router'
|
import { useRoute, useRouter } 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
|
||||||
|
|
@ -110,9 +131,7 @@ 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 ||
|
config?.relayUrl || import.meta.env.VITE_RELAY_URL || resolved?.transport?.relays?.[0]
|
||||||
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()
|
||||||
|
|
@ -289,6 +308,11 @@ 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 />
|
||||||
|
|
||||||
|
|
@ -340,16 +364,10 @@ 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) -->
|
||||||
<Button
|
<ColorModeToggle
|
||||||
v-if="!atmStore.allowMockFallback"
|
v-if="!atmStore.allowMockFallback"
|
||||||
variant="outline"
|
class="fixed bottom-3 right-3 z-50 lg:bottom-6 lg:right-6"
|
||||||
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
|
||||||
|
|
|
||||||
78
apps/machine/src/components/CardChip.vue
Normal file
78
apps/machine/src/components/CardChip.vue
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<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>
|
||||||
33
apps/machine/src/components/ColorModeToggle.vue
Normal file
33
apps/machine/src/components/ColorModeToggle.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<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>
|
||||||
103
apps/machine/src/composables/useSessionSecurity.ts
Normal file
103
apps/machine/src/composables/useSessionSecurity.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
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,3 +5,26 @@ 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(' ')
|
||||||
|
}
|
||||||
|
|
|
||||||
148
apps/machine/src/services/__tests__/settlement-watch.test.ts
Normal file
148
apps/machine/src/services/__tests__/settlement-watch.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
164
apps/machine/src/services/access/__tests__/authorize.test.ts
Normal file
164
apps/machine/src/services/access/__tests__/authorize.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
150
apps/machine/src/services/access/authorize.ts
Normal file
150
apps/machine/src/services/access/authorize.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
/**
|
||||||
|
* 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 }
|
||||||
|
}
|
||||||
27
apps/machine/src/services/access/boltcard.ts
Normal file
27
apps/machine/src/services/access/boltcard.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/machine/src/services/access/index.ts
Normal file
13
apps/machine/src/services/access/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* 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'
|
||||||
31
apps/machine/src/services/access/types.ts
Normal file
31
apps/machine/src/services/access/types.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* 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,6 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -126,7 +127,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:', amounts)
|
console.log(`[HAL] Dispensing: ${formatBays(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,6 +21,7 @@ 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
|
||||||
|
|
@ -217,7 +218,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 }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,12 +435,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
|
||||||
|
|
@ -449,7 +450,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
|
||||||
|
|
@ -473,7 +474,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).'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -542,7 +543,11 @@ 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('[Lightning] Operator pubkey(s):', mc.operator_pubkey, '(server-delivered, #70 P1)')
|
console.log(
|
||||||
|
'[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
|
||||||
|
|
@ -555,17 +560,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -671,7 +676,7 @@ export async function initializeLightningServices(options?: {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
lnbits,
|
lnbits,
|
||||||
lnbitsWalletId,
|
lnbitsWalletId
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -706,13 +711,142 @@ export async function initializeLightningServices(options?: {
|
||||||
/**
|
/**
|
||||||
* Create ATMServices implementation using the LNbits nostr-transport.
|
* Create ATMServices implementation using the LNbits nostr-transport.
|
||||||
*/
|
*/
|
||||||
function createATMServices(
|
export 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
|
||||||
|
|
@ -806,7 +940,7 @@ 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)
|
||||||
|
|
@ -849,15 +983,14 @@ 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
|
context.exchangeRate > 0 ? Math.floor((context.fiatCents / 100) * 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',
|
||||||
|
|
@ -897,6 +1030,17 @@ 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
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -949,7 +1093,7 @@ function createATMServices(
|
||||||
* Dispense cash (mock for development)
|
* Dispense cash (mock for development)
|
||||||
*/
|
*/
|
||||||
dispenseCash: async (amounts) => {
|
dispenseCash: async (amounts) => {
|
||||||
console.log('[ATM Service] Dispensing cash:', amounts)
|
console.log(`[ATM Service] Dispensing cash: ${formatBays(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
|
||||||
|
|
@ -1063,15 +1207,30 @@ 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)
|
||||||
|
|
@ -1081,36 +1240,18 @@ function createATMServices(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
// walletId omitted: payment_hash is the natural primary key for
|
await armInvoiceWatch(invoice, paymentHash)
|
||||||
// "wait for THIS invoice to settle." Under path B
|
const late = invoiceWatches.get(invoice)
|
||||||
// (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment
|
if (!late || cancelled) return
|
||||||
// to the operator's wallet, so a subscription scoped to the ATM's
|
late.consumer = callback
|
||||||
// pre-override wallet_id would AND-filter the settlement out and
|
if (late.settled) callback(late.settled)
|
||||||
// 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
|
||||||
if (subId) {
|
releaseInvoiceWatch(invoice)
|
||||||
// wallet_id omitted to match the subscribePayments call above.
|
|
||||||
void lnbits.unsubscribe(undefined, subId).catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1129,7 +1270,7 @@ function createATMServices(
|
||||||
20: 50, // 50 x $20 bills = $1000 capacity
|
20: 50, // 50 x $20 bills = $1000 capacity
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[ATM Service] Inventory:', inventory)
|
console.log(`[ATM Service] Inventory: ${formatInventory(inventory)}`)
|
||||||
return inventory
|
return inventory
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,32 @@
|
||||||
/**
|
/**
|
||||||
* Operator-config consumer (aiolabs/lamassu-next#56).
|
* Operator-config consumer (aiolabs/lamassu-next#56, v2 per bitspire ADR-004).
|
||||||
*
|
*
|
||||||
* Subscribes to operator-published kind-30078 events carrying cassette
|
* Subscribes to operator-published kind-30078 events carrying cassette
|
||||||
* config updates, validates + applies them to state.db, and hot-reloads
|
* OPERATIONS — a refill, an empty, a recount, a denomination change —
|
||||||
* the HAL dispenser. Also publishes a one-shot ATM-state hello-event on
|
* applies the ones it has not already seen, and hot-reloads the HAL
|
||||||
* first boot so the operator dashboard (satmachineadmin) can auto-populate
|
* dispenser. It also publishes this machine's cassette state, which is
|
||||||
* `cassette_configs` rows for this machine.
|
* what populates the operator dashboard's bay rows.
|
||||||
|
*
|
||||||
|
* The operator used to publish absolute counts and this machine applied them
|
||||||
|
* outright. Both sides wrote the same value over a transport that never tells
|
||||||
|
* a writer it lost: a dashboard form loaded before a dispense silently
|
||||||
|
* discarded that dispense, and neither side could detect it. This machine now
|
||||||
|
* owns the count — it holds the notes — and the operator says what it did.
|
||||||
*
|
*
|
||||||
* 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 bootstrap: `kind=30078`, `["d", "bitspire-cassettes-state:<machine_id>"]`,
|
* - ATM state: `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.
|
||||||
*
|
*
|
||||||
* v1 only publishes the one-shot bootstrap hello-event. The continuous
|
* The ATM publishes its state on startup, after every change to the bays, and
|
||||||
* ATM-state reverse channel (publish on every count change + heartbeat)
|
* on a heartbeat. It was once a single hello-event gated on a one-shot flag,
|
||||||
* is v2 territory.
|
* which left the operator validating against a layout the machine no longer
|
||||||
|
* had (#94), and left a dispense published during a relay outage lost for good.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -34,9 +41,36 @@ 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}`
|
||||||
|
|
||||||
|
|
@ -45,7 +79,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 the bootstrap. */
|
/** Signer for the ATM identity. Decrypts operator events + signs our state. */
|
||||||
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[]
|
||||||
|
|
@ -83,12 +117,15 @@ 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
|
||||||
|
|
||||||
// Bootstrap hello-event on first boot (best-effort — failure leaves the
|
// Announce current state on every start. This used to be gated on a
|
||||||
// gate null so the next boot retries).
|
// one-shot "have we said hello" flag, so any later change to the layout —
|
||||||
|
// a reseed, an atm-tui edit, direct SQL — was never published and the
|
||||||
|
// operator's dashboard kept validating against a bay set that no longer
|
||||||
|
// existed (#94). Best-effort; the heartbeat below is the safety net.
|
||||||
try {
|
try {
|
||||||
await maybePublishBootstrap(cfg, api, machineId)
|
await publishCassettesState(cfg, api, machineId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[OperatorConfig] Bootstrap publish failed (will retry next boot):', err)
|
console.warn('[OperatorConfig] Startup cassettes-state publish failed:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to operator-published cassette config events.
|
// Subscribe to operator-published cassette config events.
|
||||||
|
|
@ -110,10 +147,19 @@ export async function startOperatorConfigService(
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
console.log('[OperatorConfig] Subscribed:', { dTag, subscriptionId })
|
console.log(`[OperatorConfig] Subscribed: d=${dTag} sub=${subscriptionId}`)
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
publishCassettesState(cfg, api, machineId).catch((err) =>
|
||||||
|
console.warn('[OperatorConfig] cassettes-state heartbeat failed:', err)
|
||||||
|
)
|
||||||
|
}, STATE_HEARTBEAT_MS)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
|
stop: () => {
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
cfg.nostrClient.unsubscribe(subscriptionId)
|
||||||
|
},
|
||||||
publishCassettesState: () =>
|
publishCassettesState: () =>
|
||||||
publishCassettesState(cfg, api, machineId)
|
publishCassettesState(cfg, api, machineId)
|
||||||
.then(() => {})
|
.then(() => {})
|
||||||
|
|
@ -139,17 +185,13 @@ async function handleOperatorConfigEvent(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Replay protection — drop stale events. NIP-78 replaceable events
|
// 2. There is deliberately no `created_at` watermark here any more.
|
||||||
// DO get re-delivered on reconnect/restart; without this check, the
|
// Under absolute counts it was the only replay defence, and it cost us:
|
||||||
// ATM would re-apply the same payload on every boot and clobber any
|
// an event re-delivered out of order was dropped whole, operations
|
||||||
// cash-out decrements that landed between operator publishes.
|
// included. Idempotency now rides on the operations themselves — the
|
||||||
const watermark = await api.getLastKnownConfigCreatedAt()
|
// operator mints an id per op and this machine records the ones it
|
||||||
if (event.created_at <= watermark) {
|
// applied — which is strictly stronger, because it survives an event
|
||||||
console.log(
|
// that mixes operations we have seen with ones we have not.
|
||||||
`[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
|
||||||
|
|
@ -163,7 +205,7 @@ async function handleOperatorConfigEvent(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Decrypt content (NIP-44 v2).
|
// 4. Decrypt content (NIP-44 v2).
|
||||||
let parsed: { positions: Record<string, { denomination: number; count: number }> }
|
let parsed: { schema_version?: number; ops?: unknown }
|
||||||
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
|
||||||
|
|
@ -171,22 +213,36 @@ async function handleOperatorConfigEvent(
|
||||||
console.error('[OperatorConfig] Decrypt/parse failed:', err)
|
console.error('[OperatorConfig] Decrypt/parse failed:', err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!parsed || typeof parsed !== 'object' || !parsed.positions) {
|
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.ops)) {
|
||||||
console.error('[OperatorConfig] Payload missing `positions` field')
|
// A v1 operator publishing absolute counts lands here and is ignored.
|
||||||
|
// That direction fails safe: the machine keeps its own counts, which it
|
||||||
|
// is now the only writer of, and simply will not dispense notes it
|
||||||
|
// believes it lacks. The opposite — applying a count from a form loaded
|
||||||
|
// before a dispense — is what ADR-004 exists to stop.
|
||||||
|
console.error('[OperatorConfig] Payload missing `ops` array — dropped')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const ops = parsed.ops as CassetteOp[]
|
||||||
|
|
||||||
// 5. Atomic apply (cassettes + meta watermark) via IPC. The state-store
|
// 5. Apply the ones we have not seen, in one transaction with the sequence
|
||||||
// function re-validates watermark + position key-set equality +
|
// bump. No `created_at` watermark: each op carries an operator-minted id
|
||||||
// per-entry types inside the SQLite transaction. Duplicate
|
// and the machine records what it applied, so a re-delivered event is a
|
||||||
// denominations across positions are allowed — real machines load
|
// no-op on its own merits. The watermark would be strictly weaker and
|
||||||
// N cassettes of the same denomination for cash-out throughput.
|
// actively harmful — an event arriving out of order can still carry an
|
||||||
const result = await api.applyOperatorCassettesConfig(
|
// operation this machine has never seen.
|
||||||
{ positions: parsed.positions },
|
const result = await api.applyOperatorCassetteOps(ops)
|
||||||
event.created_at
|
for (const bad of result.rejected) {
|
||||||
)
|
console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`)
|
||||||
if (!result.applied) {
|
}
|
||||||
console.warn('[OperatorConfig] Apply rejected:', result.reason)
|
if (result.applied.length === 0) {
|
||||||
|
console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`)
|
||||||
|
// Still republish: the operator learns from our applied_ops echo that
|
||||||
|
// earlier operations landed, and an event carrying nothing new can be the
|
||||||
|
// first one we successfully answer after a relay outage.
|
||||||
|
const machineIdNoop = cfg.machineId ?? cfg.signer.pubkey
|
||||||
|
await publishCassettesState(cfg, api, machineIdNoop).catch((err) =>
|
||||||
|
console.warn('[OperatorConfig] post-apply cassettes-state republish failed:', err)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,8 +250,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 operator wants their config landed;
|
// unwind the state.db apply (the operation happened physically; HAL
|
||||||
// HAL can catch up).
|
// 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) => ({
|
||||||
|
|
@ -207,9 +263,7 @@ 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(
|
console.log(`[OperatorConfig] Applied ops: ${result.applied.join(', ')}`)
|
||||||
`[OperatorConfig] Applied — created_at=${event.created_at}, positions=${Object.keys(parsed.positions).join(',')}`
|
|
||||||
)
|
|
||||||
|
|
||||||
// Republish our resulting cassette state so the operator's view reflects the
|
// 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
|
||||||
|
|
@ -224,11 +278,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 and on a cassette reload so the operator view tracks reality, not
|
* dispense, on a cassette reload, at startup and on a heartbeat, so the
|
||||||
* the frozen bootstrap snapshot (coord 2026-06-21 / lamassu-next#56).
|
* operator view tracks reality (coord 2026-06-21 / lamassu-next#56).
|
||||||
*
|
*
|
||||||
* NOT gated on the bootstrap flag — this is the live update. Returns whether an
|
* Returns whether an event was published (false when there are no cassettes /
|
||||||
* event was published (false when there are no cassettes / no operator).
|
* no operator).
|
||||||
*/
|
*/
|
||||||
async function publishCassettesState(
|
async function publishCassettesState(
|
||||||
cfg: OperatorConfigServiceConfig,
|
cfg: OperatorConfigServiceConfig,
|
||||||
|
|
@ -244,7 +298,36 @@ 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 }
|
||||||
}
|
}
|
||||||
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions }))
|
// Additive field: an operator on the old consumer reads `positions` and
|
||||||
|
// ignores this, so it needs no coordinated release. When set, the counts
|
||||||
|
// above are the machine's best guess, not a measurement.
|
||||||
|
const countsUncertainSince = await api.getCountsUncertainSince()
|
||||||
|
|
||||||
|
// `applied_ops` is the acknowledgement leg. An addressable event gives its
|
||||||
|
// publisher no failure signal — the relay returns OK for an event it then
|
||||||
|
// discards — so echoing the ids back is the only way the operator can tell
|
||||||
|
// an operation that landed from one that was merely sent. `seq` lets a
|
||||||
|
// reader reject a regression without trusting a clock: `created_at` has
|
||||||
|
// second granularity and ties break on event id, so it cannot order two
|
||||||
|
// reports from the same second.
|
||||||
|
const [appliedOps, seq] = await Promise.all([api.getAppliedOpIds(), api.getCassetteStateSeq()])
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
schema_version: CASSETTE_SCHEMA_VERSION,
|
||||||
|
positions,
|
||||||
|
seq,
|
||||||
|
applied_ops: appliedOps,
|
||||||
|
}
|
||||||
|
if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince
|
||||||
|
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
|
||||||
|
|
||||||
|
// Force the stamp strictly above our last one. Addressable events are ordered
|
||||||
|
// by `created_at` at second granularity, ties broken by lowest event id, and
|
||||||
|
// the relay keeps one and silently drops the other while acknowledging both.
|
||||||
|
// So two publishes inside one second would leave the winner decided by a hash,
|
||||||
|
// permanently — and a clock that stepped backwards would make every report
|
||||||
|
// from this machine disappear. Neither failure is visible from here.
|
||||||
|
const lastPublished = (await api.getLastStatePublishedAt()) ?? 0
|
||||||
|
const createdAt = Math.max(Math.floor(Date.now() / 1000), lastPublished + 1)
|
||||||
|
|
||||||
const dTag = atmStateDTag(machineId)
|
const dTag = atmStateDTag(machineId)
|
||||||
const event = await createSignedEvent(cfg.signer, {
|
const event = await createSignedEvent(cfg.signer, {
|
||||||
|
|
@ -254,34 +337,14 @@ async function publishCassettesState(
|
||||||
['d', dTag],
|
['d', dTag],
|
||||||
['p', operatorPubkey],
|
['p', operatorPubkey],
|
||||||
],
|
],
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: createdAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
await cfg.nostrClient.publish(event)
|
await cfg.nostrClient.publish(event)
|
||||||
console.log('[OperatorConfig] cassettes-state published:', { dTag, eventId: event.id })
|
await api.markStatePublished(createdAt)
|
||||||
|
console.log(
|
||||||
|
`[OperatorConfig] cassettes-state published: id=${event.id.slice(0, 12)}… ` +
|
||||||
|
`created_at=${createdAt} seq=${seq} applied_ops=${appliedOps.length} d=${dTag}`
|
||||||
|
)
|
||||||
return true
|
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:', { dTag, subscriptionId })
|
console.log(`[Fees] Subscribed: d=${dTag} sub=${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
|
||||||
* bootstrap gate so the (possibly new) operator gets a hello-event (#56).
|
* publish watermark so the (possibly new) operator gets current state (#56).
|
||||||
* 2. A seed is present matching the stored binding, OR no seed but a stored
|
* 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,7 +150,9 @@ 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('[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state')
|
console.log(
|
||||||
|
'[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
|
||||||
|
|
@ -165,7 +167,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.resetBootstrapGate()
|
await window.electronAPI.resetStatePublishWatermark()
|
||||||
}
|
}
|
||||||
return { signer, transport: transportFromSeed(seed) }
|
return { signer, transport: transportFromSeed(seed) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,14 @@ 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'
|
||||||
|
|
@ -25,6 +28,7 @@ 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
|
||||||
|
|
@ -69,7 +73,13 @@ 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
|
||||||
|
|
||||||
|
|
@ -127,6 +137,7 @@ 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
|
||||||
|
|
@ -158,20 +169,24 @@ async function handleManagementCommand(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load inventory from SQLite via IPC (Electron only).
|
* Load inventory from SQLite via IPC.
|
||||||
* 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>> {
|
async function loadInventoryFromDb(): Promise<Record<number, number> | null> {
|
||||||
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:', inv)
|
console.log(`[ATM] Loaded inventory from DB: ${formatInventory(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 {}
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -231,7 +246,7 @@ const mockServices: ATMServices = {
|
||||||
},
|
},
|
||||||
|
|
||||||
dispenseCash: async (amounts) => {
|
dispenseCash: async (amounts) => {
|
||||||
console.log('[Mock] Dispensing cash:', amounts)
|
console.log(`[Mock] Dispensing cash: ${formatBays(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) => ({
|
||||||
|
|
@ -291,6 +306,69 @@ 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)
|
||||||
|
|
@ -380,12 +458,24 @@ 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()
|
||||||
if (Object.keys(inv).length > 0) {
|
// Only a failed read is ignored. An empty map used to be skipped too,
|
||||||
persistedInventory.value = inv
|
// which meant the last bill out of the machine never updated anything and
|
||||||
console.log('[ATM] Persisted inventory updated:', inv)
|
// the availability beacon kept advertising a full cassette.
|
||||||
}
|
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) */
|
||||||
|
|
@ -421,6 +511,34 @@ 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
|
||||||
|
|
@ -448,6 +566,8 @@ 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)
|
||||||
|
|
||||||
|
|
@ -473,6 +593,14 @@ 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)
|
||||||
|
|
@ -506,6 +634,19 @@ 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({
|
||||||
|
|
@ -569,6 +710,26 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -579,6 +740,7 @@ 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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -590,26 +752,46 @@ 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(lnurlw: string) {
|
async function handleBoltCardTap(source: BoltCardSource): Promise<BoltCardOutcome> {
|
||||||
if (nestedState.value !== 'displayingInvoice') return
|
if (nestedState.value !== 'displayingInvoice') return 'skipped'
|
||||||
const invoice = context.value?.invoice
|
const invoice = context.value?.invoice
|
||||||
if (!invoice) return
|
if (!invoice) return 'skipped'
|
||||||
if (boltCardProcessing.value) return // one pull at a time
|
if (boltCardProcessing.value) return 'skipped' // one pull at a time
|
||||||
|
// The card server withheld the withdraw step when this session opened, so
|
||||||
|
// there is nothing to present. Say why instead of attempting a payment.
|
||||||
|
if ('session' in source && !source.session.withdraw) {
|
||||||
|
nfcStatus.value = {
|
||||||
|
state: 'declined',
|
||||||
|
message: source.session.withdrawBlockedReason ?? 'This card cannot sell right now',
|
||||||
|
}
|
||||||
|
return 'blocked'
|
||||||
|
}
|
||||||
boltCardProcessing.value = true
|
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 res = await window.electronAPI!.lnurlWithdraw({ lnurlw, bolt11: invoice, amountMsat })
|
const api = window.electronAPI!
|
||||||
|
const res =
|
||||||
|
'session' in source
|
||||||
|
? await api.withdrawWithSession({
|
||||||
|
// Non-null: a withheld step is pre-flighted above.
|
||||||
|
withdraw: plainWithdrawStep(source.session.withdraw!),
|
||||||
|
bolt11: invoice,
|
||||||
|
amountMsat,
|
||||||
|
})
|
||||||
|
: await api.lnurlWithdraw({ lnurlw: source.lnurlw, bolt11: invoice, amountMsat })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
|
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
|
||||||
} else {
|
return 'accepted'
|
||||||
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -620,51 +802,192 @@ 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(lnurlw: string) {
|
async function handleBoltCardReceive(source: BoltCardSource): Promise<BoltCardOutcome> {
|
||||||
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return
|
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return 'skipped'
|
||||||
const amountSats = context.value?.satsAmount ?? 0
|
const amountSats = context.value?.satsAmount ?? 0
|
||||||
if (amountSats <= 0) return
|
if (amountSats <= 0) return 'skipped'
|
||||||
if (boltCardProcessing.value) return // one at a time
|
if (boltCardProcessing.value) return 'skipped' // 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 res = await window.electronAPI!.resolveCardInvoice({ lnurlw, amountMsat })
|
const api = window.electronAPI!
|
||||||
|
const res =
|
||||||
|
'session' in source
|
||||||
|
? await api.resolveSessionInvoice({ pay: plainPayStep(source.session.pay), amountMsat })
|
||||||
|
: await api.resolveCardInvoice({ lnurlw: source.lnurlw, amountMsat })
|
||||||
if (!res.ok || !res.bolt11) {
|
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
|
return 'declined'
|
||||||
}
|
}
|
||||||
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 same physical tap by flow: cash-out pulls, cash-in receives.
|
// Route the tap by state: locked → enter + load the card; then cash-out
|
||||||
if (isCashOut.value && nestedState.value === 'displayingInvoice') {
|
// pulls, cash-in receives (fallback if no card was loaded at entry).
|
||||||
void handleBoltCardTap(lnurlw)
|
if (isLocked.value) {
|
||||||
|
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) => {
|
||||||
// Only surface reader status on a tap screen, and don't clobber an
|
// Surface reader status on a tap screen (locked / invoice / QR); don't
|
||||||
// in-flight tap's message.
|
// clobber an 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) {
|
||||||
|
|
@ -675,12 +998,17 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -789,7 +1117,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
}),
|
}),
|
||||||
getInventory: async () => {
|
getInventory: async () => {
|
||||||
const fresh = await loadInventoryFromDb()
|
const fresh = await loadInventoryFromDb()
|
||||||
return Object.keys(fresh).length > 0 ? fresh : services.atmServices.getInventory()
|
// null == the DB could not be asked; an empty map is a real reading.
|
||||||
|
return fresh ?? services.atmServices.getInventory()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1037,7 +1366,8 @@ 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
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1052,7 +1382,8 @@ 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()
|
||||||
return Object.keys(fresh).length > 0 ? fresh : hal.atmServices.getInventory()
|
// null == the DB could not be asked; an empty map is a real reading.
|
||||||
|
return fresh ?? hal.atmServices.getInventory()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1183,6 +1514,19 @@ 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
|
||||||
|
|
@ -1246,7 +1590,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:', devConfig.dispenser.cassettes)
|
console.log(`[ATM] Cassettes: ${formatBays(devConfig.dispenser.cassettes)}`)
|
||||||
|
|
||||||
const halConfig = toHalConfig(devConfig)
|
const halConfig = toHalConfig(devConfig)
|
||||||
await initializeWithHalIpc(halConfig)
|
await initializeWithHalIpc(halConfig)
|
||||||
|
|
@ -1319,13 +1663,15 @@ 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:', amounts)
|
console.log(`[ATM] Dispensing via IPC: ${formatBays(amounts)}`)
|
||||||
return await api.halDispense(amounts)
|
return await api.halDispense(amounts)
|
||||||
},
|
},
|
||||||
getInventory: async () => {
|
getInventory: async () => {
|
||||||
// Priority: DB inventory > HAL hardware inventory > empty
|
// Priority: DB inventory > HAL hardware inventory > empty. Only a
|
||||||
|
// null (unreadable) DB defers to HAL — a drained machine reports
|
||||||
|
// drained rather than borrowing the hardware's view.
|
||||||
const fresh = await loadInventoryFromDb()
|
const fresh = await loadInventoryFromDb()
|
||||||
if (Object.keys(fresh).length > 0) return fresh
|
if (fresh !== null) 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()
|
||||||
|
|
@ -1353,7 +1699,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
request,
|
request,
|
||||||
(amounts) => api.halDispense(amounts),
|
(amounts) => api.halDispense(amounts),
|
||||||
isIdle.value,
|
isIdle.value,
|
||||||
fiatCode.value
|
fiatCode.value,
|
||||||
|
refreshAndPublishCassettes
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1491,12 +1838,69 @@ 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' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1640,6 +2044,22 @@ 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,6 +2,57 @@
|
||||||
* 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). */
|
||||||
|
|
@ -17,6 +68,8 @@ 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(). */
|
||||||
|
|
@ -93,11 +146,14 @@ 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>
|
||||||
getBootstrapPublishedAt: () => Promise<number | null>
|
getLastStatePublishedAt: () => Promise<number | null>
|
||||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
/** When the bay counts became unverified (a dispense that reported nothing), or null. */
|
||||||
|
getCountsUncertainSince: () => Promise<number | null>
|
||||||
|
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
||||||
|
markStatePublished: (unixTimestamp: number) => Promise<void>
|
||||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||||
clearBunkerBinding: () => Promise<void>
|
clearBunkerBinding: () => Promise<void>
|
||||||
resetBootstrapGate: () => Promise<void>
|
resetStatePublishWatermark: () => Promise<void>
|
||||||
resetForRepair: () => Promise<void>
|
resetForRepair: () => Promise<void>
|
||||||
saveSpireSeed: (seed: string) => Promise<void>
|
saveSpireSeed: (seed: string) => Promise<void>
|
||||||
relaunchApp: () => Promise<void>
|
relaunchApp: () => Promise<void>
|
||||||
|
|
@ -114,10 +170,35 @@ declare global {
|
||||||
lnurlw: string
|
lnurlw: string
|
||||||
amountMsat: number
|
amountMsat: number
|
||||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||||
applyOperatorCassettesConfig: (
|
/** Bolt Card tap-to-enter: open one verified session for a tapped card (spends the SUN). */
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
openCardSession: (args: { lnurlw: string }) => Promise<OpenCardSessionResult>
|
||||||
eventCreatedAt: number
|
/** Cash-out via a session's withdraw step (no tap). */
|
||||||
) => Promise<{ applied: true } | { applied: false; reason: string }>
|
withdrawWithSession: (args: {
|
||||||
|
withdraw: NonNullable<CardSession['withdraw']>
|
||||||
|
bolt11: string
|
||||||
|
amountMsat?: number
|
||||||
|
}) => Promise<{ ok: boolean; reason?: string }>
|
||||||
|
/** Cash-in via a session's pay step (no tap): a BOLT11 to pay. */
|
||||||
|
resolveSessionInvoice: (args: {
|
||||||
|
pay: CardSession['pay']
|
||||||
|
amountMsat: number
|
||||||
|
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||||
|
applyOperatorCassetteOps: (
|
||||||
|
ops: {
|
||||||
|
id: string
|
||||||
|
at: number
|
||||||
|
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
|
||||||
|
position: number
|
||||||
|
bills?: number
|
||||||
|
count?: number
|
||||||
|
denomination?: number
|
||||||
|
}[]
|
||||||
|
) => Promise<{
|
||||||
|
applied: string[]
|
||||||
|
rejected: { id: string; reason: string }[]
|
||||||
|
}>
|
||||||
|
getAppliedOpIds: (limit?: number) => Promise<string[]>
|
||||||
|
getCassetteStateSeq: () => Promise<number>
|
||||||
getFeeConfig: () => Promise<{
|
getFeeConfig: () => Promise<{
|
||||||
cashInFeeFraction: number
|
cashInFeeFraction: number
|
||||||
cashOutFeeFraction: number
|
cashOutFeeFraction: number
|
||||||
|
|
@ -151,6 +232,8 @@ 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,6 +3,7 @@ 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'
|
||||||
|
|
@ -363,6 +364,22 @@ 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,6 +3,7 @@ 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'
|
||||||
|
|
@ -177,6 +178,24 @@ 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
|
||||||
|
|
@ -328,6 +347,35 @@ 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,6 +5,7 @@ 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'
|
||||||
|
|
@ -73,16 +74,12 @@ function handleCashOut() {
|
||||||
>Just Bitcoin</Badge
|
>Just Bitcoin</Badge
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<!-- Balance + Commission rates -->
|
<!-- Commission rates. The machine's own balance is NOT repeated here: App.vue
|
||||||
|
already shows it in the top-right status chip, and next to the holder's
|
||||||
|
card chip an "Available: N sats" line reads as *their* balance. -->
|
||||||
<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"
|
||||||
|
|
@ -104,6 +101,8 @@ 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 -->
|
||||||
|
|
@ -173,15 +172,37 @@ function handleCashOut() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Help button (top-left) -->
|
<!-- Top-left utility buttons: Help, plus an End-session "✕" while the access
|
||||||
<Button
|
gate is engaged. Kept in the left corner (not top-right) so they never
|
||||||
variant="outline"
|
collide with the centered balance/commission chips, which wrap into the
|
||||||
size="icon"
|
top-right on narrower screens (e.g. sintra). A tap-in loads the holder's
|
||||||
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"
|
Bolt Card for the whole session, so the ✕ gives them an explicit way to
|
||||||
@click="$router.push('/support')"
|
re-lock the moment they're done rather than waiting out the idle timeout
|
||||||
>
|
(which would leave the card usable by the next person meanwhile). -->
|
||||||
?
|
<div class="absolute top-4 left-4 lg:top-8 lg:left-8 flex items-center gap-2 lg:gap-3">
|
||||||
</Button>
|
<!-- End session first (leftmost): a solid `destructive` swatch so the exit
|
||||||
|
reads as red in every theme (--destructive is theme-scoped). Shown
|
||||||
|
only while the access gate is engaged. -->
|
||||||
|
<Button
|
||||||
|
v-if="atmStore.accessControl.enabled"
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full text-2xl lg:text-4xl font-bold"
|
||||||
|
aria-label="End session"
|
||||||
|
@click="atmStore.endSession()"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
|
||||||
|
aria-label="Help"
|
||||||
|
@click="$router.push('/support')"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</Button>
|
||||||
|
</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
|
||||||
|
|
|
||||||
109
apps/machine/src/views/LockedView.vue
Normal file
109
apps/machine/src/views/LockedView.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<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>
|
||||||
|
|
@ -33,9 +33,19 @@ Each ATM model has two flake outputs:
|
||||||
| `nixosConfigurations.<model>-installed` | installed | full GPT + systemd-boot install, ext4 root, supports `nixos-rebuild switch` |
|
| `nixosConfigurations.<model>-installed` | installed | full GPT + systemd-boot install, ext4 root, supports `nixos-rebuild switch` |
|
||||||
| `packages.x86_64-linux.iso-<model>` | ISO | ISO image of the live variant |
|
| `packages.x86_64-linux.iso-<model>` | ISO | ISO image of the live variant |
|
||||||
| `packages.x86_64-linux.disk-image-<model>` | raw image | dd-able full disk image of the installed variant |
|
| `packages.x86_64-linux.disk-image-<model>` | raw image | dd-able full disk image of the installed variant |
|
||||||
|
| `nixosConfigurations.<model>-usb` | installed, on a stick | `<model>-installed` hardened to run from a USB stick: `nixos-usb`/`ESP-USB` labels, `nofail` `/boot`, no partition growing, auto-upgrade off (`batm3`, `douro` only) |
|
||||||
|
| `packages.x86_64-linux.disk-image-<model>-usb` | raw image | dd-able image of the `-usb` variant — flash, plug in, boot; no installer step |
|
||||||
|
|
||||||
Models: `douro`, `tejo`, `sintra`, `batm3`.
|
Models: `douro`, `tejo`, `sintra`, `batm3`.
|
||||||
|
|
||||||
|
**Run-from-USB deployments** (`batm3` today, `douro` since the LNbits cutover)
|
||||||
|
skip the Alpine + dd-to-internal-disk procedure below entirely: the stick *is*
|
||||||
|
the system. Flash `disk-image-<model>-usb` with balenaEtcher (it verifies the
|
||||||
|
write — a truncated or bad copy fails stage-1 fsck on first boot) onto a stick
|
||||||
|
of 16 GB or more, plug it in, power on. Updates go in-place against the
|
||||||
|
`<model>-usb` config (`nix copy` the toplevel + `switch-to-configuration`),
|
||||||
|
which keeps pairing and `/var/lib/bitspire`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build a Sintra disk image
|
# Build a Sintra disk image
|
||||||
nix build .#disk-image-sintra
|
nix build .#disk-image-sintra
|
||||||
|
|
|
||||||
5
deploy/nixos/access.example.json
Normal file
5
deploy/nixos/access.example.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"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 Lamassu ATM Live USB ISO (model: $MODEL) ==="
|
echo "=== Building bitSpire 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,6 +3,122 @@
|
||||||
|
|
||||||
{ 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";
|
||||||
|
|
@ -13,10 +129,130 @@
|
||||||
# - 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";
|
||||||
|
|
@ -99,38 +335,38 @@
|
||||||
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
|
||||||
vim
|
nano
|
||||||
git
|
|
||||||
curl
|
curl
|
||||||
wget
|
|
||||||
|
|
||||||
# Hardware debugging
|
# Hardware debugging
|
||||||
usbutils
|
usbutils
|
||||||
pciutils
|
pciutils
|
||||||
lsof
|
lsof
|
||||||
|
|
||||||
# Serial port tools
|
# Serial port tools (validator/dispenser live on ttyJ5/ttyJ7 — these are
|
||||||
|
# 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.
|
||||||
|
|
@ -159,6 +395,18 @@
|
||||||
# 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 = ''
|
||||||
|
|
@ -169,6 +417,40 @@
|
||||||
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.
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,24 @@
|
||||||
initrd.availableKernelModules = [
|
initrd.availableKernelModules = [
|
||||||
"xhci_pci"
|
"xhci_pci"
|
||||||
"ahci"
|
"ahci"
|
||||||
|
# USB mass-storage: required to boot the dd'd image from a USB stick
|
||||||
|
# (stage-1 must bind the flash drive as a SCSI disk so
|
||||||
|
# /dev/disk/by-label/* appears). Harmless on the internal install.
|
||||||
|
#
|
||||||
|
# NOTE: deliberately NO "uas" here. Many USB sticks/bridges advertise
|
||||||
|
# UAS but drop off the bus ("device offline error, dev sdb") under
|
||||||
|
# sustained write load. Blacklisting uas below forces the slower-but-
|
||||||
|
# reliable usb-storage (Bulk-Only Transport) path. SATA/mSATA installs
|
||||||
|
# don't use uas anyway. (Same hardening as batm3.nix.)
|
||||||
"usb_storage"
|
"usb_storage"
|
||||||
"sd_mod"
|
"sd_mod"
|
||||||
"sdhci_pci"
|
"sdhci_pci"
|
||||||
"i915"
|
"i915"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# Keep the USB flash drive off the flaky UAS driver (see note above).
|
||||||
|
blacklistedKernelModules = [ "uas" ];
|
||||||
|
|
||||||
kernelModules = [
|
kernelModules = [
|
||||||
"kvm-intel"
|
"kvm-intel"
|
||||||
"i2c-dev"
|
"i2c-dev"
|
||||||
|
|
@ -37,6 +49,9 @@
|
||||||
"vt.handoff=7" # Bay Trail: preserve BIOS display init
|
"vt.handoff=7" # Bay Trail: preserve BIOS display init
|
||||||
"quiet"
|
"quiet"
|
||||||
"splash"
|
"splash"
|
||||||
|
# Disable USB autosuspend so the boot medium (and kiosk peripherals)
|
||||||
|
# aren't power-suspended mid-I/O — another cause of "device offline".
|
||||||
|
"usbcore.autosuspend=-1"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,27 @@
|
||||||
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 @@
|
||||||
# Lamassu ATM Live USB Configuration
|
# bitSpire 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, ... }:
|
{ config, lib, pkgs, pkgs-unstable, nixpkgs, machineModel ? "douro", atm-app, kioskLauncher, ... }:
|
||||||
|
|
||||||
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 "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-gpu --disable-software-rasterizer --enable-logging ${atm-app}";
|
ExecStart = lib.mkForce "${kioskLauncher}";
|
||||||
# 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
|
||||||
|
|
|
||||||
69
deploy/nixos/provision-access.sh
Executable file
69
deploy/nixos/provision-access.sh
Executable file
|
|
@ -0,0 +1,69 @@
|
||||||
|
#!/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 @@
|
||||||
# Lamassu ATM Hardware udev Rules
|
# bitSpire 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
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
216
docs/adr/003-nfc-access-control-layer.md
Normal file
216
docs/adr/003-nfc-access-control-layer.md
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
# 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`.
|
||||||
178
docs/adr/004-cassette-state-synchronization.md
Normal file
178
docs/adr/004-cassette-state-synchronization.md
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
# 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,6 +99,12 @@ 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
|
||||||
|
|
|
||||||
119
docs/boltcard-session.md
Normal file
119
docs/boltcard-session.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# 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.
|
||||||
249
flake.nix
249
flake.nix
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
description = "Lamassu Next - Nostr-Native Lightning ATM";
|
description = "bitSpire - Nostr-Native Lightning ATM";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
# Stable NixOS for the ATM OS base
|
# Stable NixOS for the ATM OS base
|
||||||
|
|
@ -53,6 +53,55 @@
|
||||||
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;
|
||||||
|
|
@ -67,6 +116,34 @@
|
||||||
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
|
||||||
|
|
@ -81,6 +158,7 @@
|
||||||
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
|
||||||
|
|
@ -182,7 +260,9 @@
|
||||||
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" ];
|
||||||
dates = "04:00"; # daily at 4am
|
# Daily at 4am in the machine's own zone; see
|
||||||
|
# upgradeWindowForModel above.
|
||||||
|
dates = upgradeWindowForModel.${machineModel} or "04:00";
|
||||||
allowReboot = false;
|
allowReboot = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -209,6 +289,14 @@
|
||||||
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 != "") ''
|
||||||
|
|
@ -224,7 +312,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 "${pkgs-unstable.electron}/bin/electron --no-sandbox --disable-gpu-sandbox --disable-gpu --disable-software-rasterizer --enable-logging ${atm-app}";
|
ExecStart = lib.mkForce "${mkKioskLauncher machineModel 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;
|
||||||
|
|
@ -261,6 +349,61 @@
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Module that turns an <model>-installed config into the one a dd'd USB
|
||||||
|
# stick actually runs. Shared by every *-usb variant so the USB-boot
|
||||||
|
# hazards are solved once:
|
||||||
|
# - distinct fs labels (nixos-usb / ESP-USB) so stage-1 can't latch an
|
||||||
|
# internal drive that already holds a generic nixos/ESP-labelled install;
|
||||||
|
# - nofail /boot: the firmware already loaded the bootloader before Linux;
|
||||||
|
# without nofail a slow/late ESP-USB enumeration (BOT is slower than UAS)
|
||||||
|
# blows past systemd's 90s device-timeout into emergency mode with root
|
||||||
|
# locked — a dead end. nofail + short timeout lets the already-mounted
|
||||||
|
# root carry the boot; /boot mounts if/when it shows;
|
||||||
|
# - NO growPartition/autoResize: sfdisk rewriting the partition table on
|
||||||
|
# first boot is the single most bus-stressing write, and flaky USB
|
||||||
|
# bridges drop off the bus mid-rewrite (sfdisk wedges in D-state and
|
||||||
|
# ESP-USB vanishes with the device). Persistent state is a few MB and
|
||||||
|
# the image ships ~2GB free. The internal-disk images keep it;
|
||||||
|
# - autoUpgrade off: no scheduled nix-store churn or bootloader writes on
|
||||||
|
# the stick. Updates go in-place via `nix copy` + switch-to-configuration
|
||||||
|
# against the named <model>-usb config (preserves pairing + /var/lib).
|
||||||
|
usbBootModule = { lib, ... }: {
|
||||||
|
fileSystems."/".device = lib.mkForce "/dev/disk/by-label/nixos-usb";
|
||||||
|
fileSystems."/boot".device = lib.mkForce "/dev/disk/by-label/ESP-USB";
|
||||||
|
fileSystems."/boot".options = [ "nofail" "x-systemd.device-timeout=10s" ];
|
||||||
|
system.autoUpgrade.enable = lib.mkForce false;
|
||||||
|
};
|
||||||
|
|
||||||
|
# dd-able USB image of a <model>-usb config. make-disk-image gives the
|
||||||
|
# ext4 root the nixos-usb label directly (-L) but hardcodes the ESP FAT
|
||||||
|
# label to "ESP", so the volume is relabelled to ESP-USB afterwards —
|
||||||
|
# volume label only; bootloader files are untouched and UEFI loads
|
||||||
|
# /EFI/BOOT/BOOTX64.EFI regardless. Keeps systemd-boot: both the batm3
|
||||||
|
# and douro firmware UEFI-USB-boot fine via that removable fallback.
|
||||||
|
mkUsbDiskImage = machineModel: usbConfig:
|
||||||
|
let
|
||||||
|
baseImage = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
|
||||||
|
inherit pkgs lib;
|
||||||
|
config = usbConfig.config;
|
||||||
|
format = "raw";
|
||||||
|
partitionTableType = "efi";
|
||||||
|
diskSize = "auto";
|
||||||
|
label = "nixos-usb"; # ext4 root label (make-disk-image -L)
|
||||||
|
};
|
||||||
|
in
|
||||||
|
pkgs.runCommand "nixos-disk-image-${machineModel}-usb"
|
||||||
|
{ nativeBuildInputs = [ pkgs.parted pkgs.mtools ]; }
|
||||||
|
''
|
||||||
|
mkdir -p $out
|
||||||
|
cp --sparse=always ${baseImage}/nixos.img $out/nixos.img
|
||||||
|
chmod +w $out/nixos.img
|
||||||
|
espStart=$(parted -sm "$out/nixos.img" unit B print | awk -F: '$1==1 {gsub("B","",$2); print $2}')
|
||||||
|
echo "ESP partition starts at byte $espStart — relabelling to ESP-USB"
|
||||||
|
export MTOOLS_SKIP_CHECK=1
|
||||||
|
mlabel -i "$out/nixos.img@@$espStart" ::ESP-USB
|
||||||
|
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
|
||||||
|
'';
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# ── NixOS Configurations (top-level, not per-system) ──────────
|
# ── NixOS Configurations (top-level, not per-system) ──────────
|
||||||
|
|
@ -293,35 +436,22 @@
|
||||||
sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix;
|
sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix;
|
||||||
batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix;
|
batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix;
|
||||||
|
|
||||||
# USB-bootable variant of batm3-installed. This is the config the
|
# USB-bootable variants of <model>-installed (see usbBootModule for
|
||||||
# flashed USB stick actually runs — distinct fs labels so stage-1 can't
|
# what changes). These are the configs a flashed stick actually runs.
|
||||||
# latch the internal drive, nofail /boot, no growPartition, autoUpgrade
|
# Exposed as named configs (not just inline in the disk-image targets)
|
||||||
# off. Exposed as a named config (not just inline in the disk-image
|
# so their system closures can be built here and deployed in-place with
|
||||||
# target) so its system closure can be built here and deployed in-place
|
# `nix copy` + `switch-to-configuration` — updating the app on a running
|
||||||
# with `nix copy` + `switch-to-configuration` — updating the app on a
|
# stick WITHOUT reflashing (preserves pairing + /var/lib state).
|
||||||
# running stick WITHOUT reflashing (preserves pairing + /var/lib state).
|
# disk-image-<model>-usb builds its filesystem image from the same config.
|
||||||
# disk-image-batm3-usb builds its filesystem image from this same config.
|
|
||||||
batm3-usb = self.nixosConfigurations.batm3-installed.extendModules {
|
batm3-usb = self.nixosConfigurations.batm3-installed.extendModules {
|
||||||
modules = [
|
modules = [ usbBootModule ];
|
||||||
({ lib, ... }: {
|
};
|
||||||
fileSystems."/".device = lib.mkForce "/dev/disk/by-label/nixos-usb";
|
# douro: the production unit's internal drive is not NixOS, so the
|
||||||
fileSystems."/boot".device = lib.mkForce "/dev/disk/by-label/ESP-USB";
|
# label disambiguation is moot today, but the nofail /boot and the
|
||||||
# /boot must NOT be a hard boot dependency on the USB image. The
|
# uas/autosuspend hardening in douro.nix are what make a stick a
|
||||||
# firmware already loaded the bootloader before Linux; without
|
# reliable boot medium on the Bay Trail box. Same in-place update flow.
|
||||||
# nofail, a slow/late ESP-USB enumeration (BOT is slower than UAS)
|
douro-usb = self.nixosConfigurations.douro-installed.extendModules {
|
||||||
# blows past systemd's 90s device-timeout into emergency mode with
|
modules = [ usbBootModule ];
|
||||||
# root locked — a dead end. nofail + short timeout lets the
|
|
||||||
# already-mounted root carry the boot; /boot mounts if/when it shows.
|
|
||||||
fileSystems."/boot".options = [ "nofail" "x-systemd.device-timeout=10s" ];
|
|
||||||
# NO growPartition/autoResize: sfdisk rewriting the partition table
|
|
||||||
# on first boot is the single most bus-stressing write, and flaky
|
|
||||||
# USB bridges drop off the bus mid-rewrite (sfdisk wedges in D-state
|
|
||||||
# and ESP-USB vanishes with the device). Persistent state is a few
|
|
||||||
# MB and the image ships ~2GB free. The internal-SATA disk-image-
|
|
||||||
# batm3 keeps growPartition (a real AHCI SSD won't drop the bus).
|
|
||||||
system.autoUpgrade.enable = lib.mkForce false;
|
|
||||||
})
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -455,53 +585,16 @@
|
||||||
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
|
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# USB-bootable BATM3 TEST image with DISTINCT partition labels
|
# USB-bootable images (see usbBootModule / mkUsbDiskImage in the let
|
||||||
# (nixos-usb / ESP-USB). The plain disk-image-batm3 reuses the generic
|
# block). Flash with dd or balenaEtcher, boot the stick, done — no
|
||||||
# nixos/ESP labels, so a USB stick carrying it, booted on a batm3 whose
|
# installer step. The plain disk-image-<model> reuses the generic
|
||||||
# internal SATA drive ALREADY holds a nixos/ESP-labelled install, makes
|
# nixos/ESP labels, so a stick carrying it, booted on a machine whose
|
||||||
# stage-1's by-label/nixos resolve to the internal drive (larger fs,
|
# internal drive ALREADY holds a nixos/ESP-labelled install, makes
|
||||||
# journal recovers) instead of the stick — the stage-2 init path baked
|
# stage-1's by-label/nixos resolve to the internal drive instead of the
|
||||||
# into the USB's boot entry isn't on that root, so stage 1 aborts.
|
# stick — the stage-2 init path baked into the USB's boot entry isn't on
|
||||||
# Distinct labels make stage-1 pick the stick unambiguously WITHOUT
|
# that root, so stage 1 aborts. These variants can't hit that.
|
||||||
# touching the internal drive. Unlike disk-image-sintra-usb this keeps
|
disk-image-batm3-usb = mkUsbDiskImage "batm3" self.nixosConfigurations.batm3-usb;
|
||||||
# systemd-boot: the batm3 firmware UEFI-USB-boots fine via the ESP's
|
disk-image-douro-usb = mkUsbDiskImage "douro" self.nixosConfigurations.douro-usb;
|
||||||
# /EFI/BOOT/BOOTX64.EFI removable fallback, so no GRUB/hybrid-table
|
|
||||||
# change is needed — only the label disambiguation here plus the
|
|
||||||
# usb_storage/uas initrd modules (in batm3.nix). Does NOT grow to fill
|
|
||||||
# the stick (see the growPartition note below — sfdisk on first boot
|
|
||||||
# wedges flaky USB bridges); auto-upgrade off (test image, not a managed
|
|
||||||
# fleet member — also stops scheduled bootloader writes landing on the
|
|
||||||
# internal drive's ESP).
|
|
||||||
disk-image-batm3-usb =
|
|
||||||
let
|
|
||||||
# Filesystem image of the batm3-usb config (defined in
|
|
||||||
# nixosConfigurations). Same config that in-place deploys target, so
|
|
||||||
# a reflash and a `switch-to-configuration` converge on one system.
|
|
||||||
baseImage = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
|
|
||||||
inherit pkgs lib;
|
|
||||||
config = self.nixosConfigurations.batm3-usb.config;
|
|
||||||
format = "raw";
|
|
||||||
partitionTableType = "efi";
|
|
||||||
diskSize = "auto";
|
|
||||||
label = "nixos-usb"; # ext4 root label (make-disk-image -L)
|
|
||||||
};
|
|
||||||
in
|
|
||||||
pkgs.runCommand "nixos-disk-image-batm3-usb"
|
|
||||||
{ nativeBuildInputs = [ pkgs.parted pkgs.mtools ]; }
|
|
||||||
''
|
|
||||||
mkdir -p $out
|
|
||||||
cp --sparse=always ${baseImage}/nixos.img $out/nixos.img
|
|
||||||
chmod +w $out/nixos.img
|
|
||||||
# make-disk-image hardcodes the ESP FAT label to "ESP"; relabel the
|
|
||||||
# volume to ESP-USB so /boot (by-label/ESP-USB) can't resolve to an
|
|
||||||
# internal drive's ESP. Volume label only — bootloader files are
|
|
||||||
# untouched, and UEFI loads /EFI/BOOT/BOOTX64.EFI regardless.
|
|
||||||
espStart=$(parted -sm "$out/nixos.img" unit B print | awk -F: '$1==1 {gsub("B","",$2); print $2}')
|
|
||||||
echo "ESP partition starts at byte $espStart — relabelling to ESP-USB"
|
|
||||||
export MTOOLS_SKIP_CHECK=1
|
|
||||||
mlabel -i "$out/nixos.img@@$espStart" ::ESP-USB
|
|
||||||
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Backwards compat
|
# Backwards compat
|
||||||
iso = self.nixosConfigurations.douro.config.system.build.isoImage;
|
iso = self.nixosConfigurations.douro.config.system.build.isoImage;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Pure Nix derivation for the Lamassu ATM Electron app.
|
# Pure Nix derivation for the bitSpire 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,6 +190,33 @@ 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 Lamassu ATM devices",
|
"description": "Hardware Abstraction Layer for bitSpire 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": [
|
||||||
"lamassu",
|
"bitspire",
|
||||||
"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 Lamassu ATM hardware devices:
|
* Provides drivers for bitSpire 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 Lamassu ATM",
|
"description": "Nostr client library for bitSpire 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 Lamassu ATM
|
* Nostr client for bitSpire 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 Lamassu ATM
|
* Event creation utilities for bitSpire 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 Lamassu ATM communication.
|
* Nostr client library for bitSpire 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 Lamassu ATM
|
* Nostr client type definitions for bitSpire ATM
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Event } from 'nostr-tools'
|
import type { Event } from 'nostr-tools'
|
||||||
|
|
|
||||||
127
packages/state-machine/src/__tests__/access-control.test.ts
Normal file
127
packages/state-machine/src/__tests__/access-control.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
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,6 +52,8 @@ 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,6 +22,14 @@ 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(
|
||||||
|
|
@ -167,9 +175,41 @@ 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(),
|
||||||
|
|
@ -376,6 +416,18 @@ 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.
|
||||||
|
|
@ -426,10 +478,16 @@ 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',
|
||||||
initial: 'idle',
|
// ADR-003: `locked` is the resting state. With the gate disabled (the
|
||||||
|
// 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 } : {}),
|
||||||
|
|
@ -437,6 +495,12 @@ 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
|
||||||
|
|
@ -451,9 +515,43 @@ 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'],
|
||||||
|
|
@ -491,7 +589,7 @@ export function createATMMachine(
|
||||||
INACTIVITY_TIMEOUT: [
|
INACTIVITY_TIMEOUT: [
|
||||||
{
|
{
|
||||||
guard: ({ context }) => context.billsInserted.length === 0,
|
guard: ({ context }) => context.billsInserted.length === 0,
|
||||||
target: '#atm.idle',
|
target: '#atm.locked',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: 'confirmAbandon',
|
target: 'confirmAbandon',
|
||||||
|
|
@ -524,7 +622,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.idle',
|
target: '#atm.locked',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Bills already stacked: warn user before abandoning
|
// Bills already stacked: warn user before abandoning
|
||||||
|
|
@ -534,7 +632,7 @@ export function createATMMachine(
|
||||||
TIMEOUT: [
|
TIMEOUT: [
|
||||||
{
|
{
|
||||||
guard: ({ context }) => context.billsInserted.length === 0,
|
guard: ({ context }) => context.billsInserted.length === 0,
|
||||||
target: '#atm.idle',
|
target: '#atm.locked',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: 'confirmAbandon',
|
target: 'confirmAbandon',
|
||||||
|
|
@ -586,10 +684,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.idle',
|
60000: '#atm.locked',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
CANCEL: '#atm.idle', // User confirms they want to leave
|
CANCEL: '#atm.locked', // User confirms they want to leave
|
||||||
RETRY: 'generatingNdebit', // Go back and try again
|
RETRY: 'generatingNdebit', // Go back and try again
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -614,10 +712,10 @@ export function createATMMachine(
|
||||||
},
|
},
|
||||||
complete: {
|
complete: {
|
||||||
after: {
|
after: {
|
||||||
COMPLETE_DELAY: '#atm.idle',
|
COMPLETE_DELAY: '#atm.locked',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
|
|
@ -646,7 +744,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.idle',
|
target: '#atm.locked',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Bills inserted: show abandon warning first
|
// Bills inserted: show abandon warning first
|
||||||
|
|
@ -689,7 +787,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.idle',
|
INACTIVITY_TIMEOUT: '#atm.locked',
|
||||||
},
|
},
|
||||||
entry: 'clearCashOutSelection',
|
entry: 'clearCashOutSelection',
|
||||||
on: {
|
on: {
|
||||||
|
|
@ -709,8 +807,8 @@ export function createATMMachine(
|
||||||
target: 'generatingInvoice',
|
target: 'generatingInvoice',
|
||||||
actions: 'calculateDispenseFromSelection',
|
actions: 'calculateDispenseFromSelection',
|
||||||
},
|
},
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.locked',
|
||||||
TIMEOUT: '#atm.idle',
|
TIMEOUT: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
generatingInvoice: {
|
generatingInvoice: {
|
||||||
|
|
@ -758,7 +856,7 @@ export function createATMMachine(
|
||||||
TIMEOUT: {
|
TIMEOUT: {
|
||||||
target: 'selectingAmount',
|
target: 'selectingAmount',
|
||||||
},
|
},
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dispensingCash: {
|
dispensingCash: {
|
||||||
|
|
@ -826,20 +924,20 @@ export function createATMMachine(
|
||||||
},
|
},
|
||||||
complete: {
|
complete: {
|
||||||
after: {
|
after: {
|
||||||
COMPLETE_DELAY: '#atm.idle',
|
COMPLETE_DELAY: '#atm.locked',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
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.idle',
|
DISPENSE_ERROR_TIMEOUT: '#atm.locked',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
|
|
@ -849,7 +947,7 @@ export function createATMMachine(
|
||||||
target: 'fetchingRate',
|
target: 'fetchingRate',
|
||||||
actions: 'incrementRetry',
|
actions: 'incrementRetry',
|
||||||
},
|
},
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,47 @@ 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
|
||||||
|
|
@ -136,6 +175,14 @@ 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 }
|
||||||
|
|
@ -173,6 +220,12 @@ 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 Lamassu ATM and dashboard",
|
"description": "Shared Vue 3 components for bitSpire 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 Lamassu ATM and dashboard.
|
* Shared Vue 3 components for bitSpire 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