nsecbunkerd#27 enforces token lifecycle at sign time (Option D): an expired
token (`expiresAt`) now stops signing post-bind, not just at connect —
reversing the earlier #24 "TTL is connect-window-only" note. A lapsed TTL
now surfaces as the same BunkerRejectedError as a revoke, so the Phase D
re-pair handling covers both. Docstring corrected to say so.
refs nsecbunkerd#27/#24/#25, aiolabs/bitspire#52
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fund-atm resolves its signer by resuming the bunker binding from state.db
(the connect token is already spent by the main app, so it can't re-pair);
falls back to a dev nsec via VITE_ATM_PRIVATE_KEY. better-sqlite3 marked
external in the esbuild bundle. .env.example + CLAUDE.md document
VITE_SPIRE_SEED as the prod identity, VITE_ATM_PRIVATE_KEY as dev-only.
(fund-atm is slated for deprecation in favour of the operator funding the
wallet directly via the LNbits UI — kept working for now.)
Part of Phase C, aiolabs/bitspire#52.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New signer-resolver.ts turns the ATM's pairing state into a Signer:
- seed present, fingerprint differs from stored binding → pair: generate a
transport key, redeem the one-shot connect secret, persist the binding,
reset the bootstrap gate (re-publish hello to the new operator, #56);
- seed matches binding, or binding-only → resume (no re-redeem);
- neither → ephemeral LocalSigner (dev) or throw (strict/prod).
lightning.ts drops the atmPrivateKey plumbing and calls resolveSigner; the
Phase-A Signer seam means nothing downstream changes. App.vue's maintenance
beacon resolves the same way (best-effort, skips if unpaired).
Part of Phase C, aiolabs/bitspire#52.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get-atm-secrets now returns { spireSeed, bunkerBinding } instead of the raw
nsec (one-shot semantics kept). Adds IPC handlers + preload bindings for
saveBunkerBinding / clearBunkerBinding / resetBootstrapGate so the renderer
can persist a pairing and re-arm the cassette-state hello on re-pair (#56).
resetBootstrapGate added to state-store. Types mirrored in electron.d.ts.
Part of Phase C, aiolabs/bitspire#52.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swap CLINKClient's MachineIdentity for the Signer abstraction: sign_event /
nip44 now go through the signer (async), so the spire identity can live in a
NIP-46 bunker. The kind-21003 management path (operator-driven manual
dispense, the one live CLINK path on dev) decrypts as the spire via the
bunker; the dormant offer/debit paths are migrated too so they're
bunker-ready when CLINK is re-implemented for the upcoming ndebit/k1 spec
(shocknet/CLINK#7, #8).
Part of Phase C, aiolabs/bitspire#52.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a bunker_binding singleton table + get/save/clearBunkerBinding
accessors holding the ATM's own NIP-46 transport key (client_nsec), the
spire signing pubkey, the bunker URL, and the seed fingerprint. Persisted
so a restart resumes the bunker session without re-redeeming the one-shot
connect secret; a changed fingerprint signals a re-pair.
The v10→v11 migration is idempotent (CREATE TABLE IF NOT EXISTS), and the
v9→v10 block now advances existing.value so a v9 install chains straight
through to v11 in one boot (matching the v6→v8 blocks).
Phase B of aiolabs/bitspire#52. The IPC bridge + bootstrap resolution that
consume these accessors land in Phase C.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase B of aiolabs/bitspire#52 — the consumer surface for routing signing
to the operator's nsecbunkerd (model A1: the ATM holds only its own NIP-46
transport key; the signing identity lives in the bunker).
- seed.ts: parseSpireSeed for the `spire-seed:v1:<base64url>` contract from
spirekeeper pairing.py — re-pads stripped base64url, validates
{v, spire_pubkey, bunker_url, relays}, leaves percent-decoding of the
bunker URL to parseBunkerInput. seedFingerprint() detects a re-pair.
- bunker-signer.ts: BunkerSigner implements Signer by delegating
sign_event / nip44_* to nostr-tools' nip46 over the bunker relay. pubkey
is the spire identity, known synchronously from the seed. connectNewSeed
redeems the one-shot connect secret; resumeFromBinding reuses the
persisted transport key WITHOUT re-redeeming (the binding is
server-persistent). Per-RPC timeout + typed BunkerRejectedError /
BunkerTimeoutError so callers can distinguish revoked-binding (re-pair)
from a transient outage.
Unit-tested against a fake inner client (delegation, sync pubkey, timeout,
error mapping) + seed round-trip/validation fixtures. Live-relay wiring is
Phase C; live bunker integration is Phase F.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop encryptContent / decryptContent / decryptJSON and the hand-rolled
XChaCha20 + v1 conversation-key machinery they depended on (~230 lines).
The only callers were createMachineStatusEvent / createTransactionEvent,
which had no callers in apps/ and were removed in the Signer migration.
This closes the open question carried in aiolabs/bitspire#52: every live
encryption path is NIP-44 v2, and the nsecbunkerd signer is v2-only, so
there is nothing to keep v1 for. encryptContentV2 / decryptContentV2 stay
as the v2 helpers used by the dormant CLINK client + tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a Signer interface (signEvent / nip44Encrypt / nip44Decrypt +
sync pubkey) with an in-process LocalSigner backed by an nsec, and route
every signing/encryption call site through it. Behaviour is unchanged —
LocalSigner wraps the same MachineIdentity the code used directly before.
This is Phase A of the bunker migration (aiolabs/bitspire#52): it puts the
seam in place so Phase B can drop in a NIP-46 BunkerSigner at the bootstrap
without touching any call site. The whole chain becomes async (the bunker
path is a relay round-trip; LocalSigner resolves immediately).
Sites moved onto the signer:
- packages/nostr-client: createSignedEvent / createAuthEvent (now async),
NostrClient config (signer not identity), AUTH challenge handler.
- packages/lnbits: LnbitsClient.initialize(nostr, signer); kind-21000 RPC
encrypt + sign + reply-decrypt; handleReply is now async (event-id dedup
still runs synchronously before the awaited decrypt, so replay safety and
per-subscription hash dedup are preserved).
- apps/machine: lightning.ts builds a LocalSigner and exposes it on
LightningServices; operator-config / operator-fees / availability beacon /
maintenance beacon / fund-atm all sign + encrypt via the signer.
NIP-42 auth (kind 22242) is included — under the bunker it must be in the
spire policy (aiolabs/spirekeeper#26, already merged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Separate payment (Nostr↔LNbits, SaaS-operator-owned), fleet control
(Nostr #42, machine-operator-owned), and access/recovery (SSH) planes
by trust owner. Recovery access is provisioned at install and
app-independent. Adopt NetBird for the access plane (scale + fully FOSS
self-hostable control plane; rejects Tailscale's closed control plane).
Reject a dashboard 'revoke SaaS-operator access' toggle as a false
promise — the SaaS operator controls LNbits and the default control
plane, so exclusion is by ownership (operator self-hosts), not by
toggle.
The VALID_THEMES set in electron/main.ts duplicated the renderer's
ThemeId list and silently coerced any unlisted branding.json theme to
null — which is how darkmatter regressed to the localStorage theme after
db074e2 added themes to the renderer but not this allowlist (fixed in
a0c2f38). Remove the second list entirely: pass raw.theme through and
let useTheme's applyBrandingTheme (themes[] + the 'custom' branch) be
the single validation point. Unknown values are ignored downstream, so
nothing reaches the DOM unvetted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db074e2 added countrysidecastle/darkmatter/emeraldforest/lightgreen/
neobrut/starrynight to the renderer's ThemeId union and style.css but
not to the electron main-process branding allowlist. branding.json
'theme' values outside the allowlist were silently dropped (theme=null),
so the renderer fell through to the localStorage theme (cyberpunk).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pulls webapp's tuned Catppuccin oklch palette (mauve primary, teal
accent, white card) over the prior straight-from-the-spec hex values,
and adds the other six webapp themes: Countryside Castle, Dark Matter,
Emerald Forest, Light Green, Neo Brutalist, Starry Night. Each new
block extends the webapp palette with ATM-specific success/warning/
bitcoin/qr semantic colors tuned to the theme's vibe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fresh-boot `/var/lib/bitspire/.env` template at flake.nix's
`bitspire-env` activation script seeded `VITE_RELAY_URL=` empty, which
forced every operator to run `provision-atm.sh` (or hand-edit .env)
before the renderer could resolve a relay. Meanwhile the NixOS option
`services.bitspire.relayUrl` was wired only to the dead-code
`/etc/bitspire/config.env` (mkForce-shadowed by `/var/lib/bitspire/.env`).
Thread the NixOS option through: seed `VITE_RELAY_URL=${cfg.relayUrl}`
on first boot. The .env override path remains intact — provision-atm.sh
or a hand edit still take precedence at runtime (the file is the
EnvironmentFile, not the activation-time template). Existing ATMs
already have a populated `.env` and aren't affected (the activation
script's `if [ ! -f ... ]` guard skips the rewrite).
Renderer resolution order:
/var/lib/bitspire/.env → NixOS module default → renderer fallback
(`ws://localhost:7777` in lightning.ts:63 / main.ts:284)
Also expand the `services.bitspire.relayUrl` option description so
future readers see the wire-through + the override path documented
where the option lives.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes gap 2 from coord log 2026-06-01T18:30Z. The LNbits withdraw
extension's nostr-transport RPC now populates `link.lnurl` from
`settings.lnbits_baseurl` (aiolabs/withdraw#1 / commit e9d911e), so the
ATM no longer needs a separate HTTP URL on the wire to compose the
LNURL-withdraw callback itself.
What goes:
- `VITE_LNBITS_HTTP_URL` env var (renderer + Electron main)
- `lnbitsHttpUrl` field on `LightningConfig`, `RuntimeConfig`, and the
Window mirror in `src/types/electron.d.ts`
- The manual `${lnbitsHttpUrl}/withdraw/api/v1/lnurl/${unique_hash}`
composition in `generateLnurlWithdraw`
- The `encodeLnurl` bech32 helper in `lightning.ts` (LNbits returns
bech32-encoded; we just `.toUpperCase()` to match BOLT/LNURL convention)
- `@scure/base` dep from `apps/machine/package.json` (only used by the
removed helper; clink still uses it directly)
- The `lnbitsHttpUrl` option + `LNBITS_HTTP_URL=…` env var + boot echo
in `deploy/nixos/bitspire-atm.nix`
- Doc references in CLAUDE.md, README.md, deploy/nixos/README.md,
docs/architecture-comparison.md, and the lightning-check skill
What stays:
- `link.lnurl` consumption, with an explicit error if LNbits returns
null (which signals `LNBITS_BASEURL` is unset on the server side —
better to fail clearly than silently)
- The receiver-side bech32 uppercasing (LNbits returns lowercase per
the standard library)
Why this is a net win:
- Removes a config-drift surface — if LNbits's external URL moved
(DNS, port, reverse-proxy rewrite), every ATM in the field would
stop issuing redeemable LNURL-withdraw QRs until reconfigured.
Now LNbits derives its own URL from `settings.lnbits_baseurl`,
one source of truth.
- Removes an extra provisioning step. No more `LNBITS_HTTP_URL=…`
before running `provision-atm.sh`; the relay + server pubkey suffice.
- Removes the misleading boot echo that triggered the §`18:30Z`
smoke triage confusion ("LNbits HTTP: <url>" read like ATM-→-LNbits
connectivity, when it was only ever a URL embedded in customer QRs).
Also adds a `# pragma: allowlist secret` marker above the
`VITE_ATM_PRIVATE_KEY` doc block in `.env.example` so the global
secret scanner stops false-positiving on the documentation prose.
Workspace typecheck + 24/24 apps/machine tests still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fixes gap-3 from coord log 2026-06-01T18:30Z: the operator-fees
subscriber wasn't running during the 'awaiting-fees' maintenance state,
so the maintenance state had no path to clear. Every restart found
empty state.db, entered maintenance, never subscribed, never wrote.
Forever stuck.
Root cause: `initializeForProduction` bailed via early `return` when
the persisted fee config was null. The subscriber starts inside
`initializeWithHalIpc`, which was never reached.
Fix has three pieces:
1. Remove the early return. HAL + Lightning + operator-fees subscriber
all init even when `initError = 'awaiting-fees'` is set. The
maintenance card UI still blocks user interaction (no router-view
renders), and the state machine starts with zero fractions until
the first event lands.
2. New `UPDATE_FEE_CONFIG` event on the state machine, handled at the
root level — assigns `cashInFeeFraction` / `cashOutFeeFraction` onto
context so subsequent cashIn/cashOut entries pick them up via
setCashInFee / setCashOutFee actions. No actor restart needed.
3. `applyFeeConfig` (the operator-fees subscriber's onApply callback)
now dispatches UPDATE_FEE_CONFIG into the running actor AND clears
`initError` when it was 'awaiting-fees'. Operator publishes the
first event → ATM auto-unblocks → UI flips from maintenance card
to IdleView showing the new fee%. No `systemctl restart bitspire`
needed.
Adds three tests covering the new UPDATE_FEE_CONFIG handler:
- updates context fractions
- does not leave idle state
- propagates to context.feeFraction on next cashIn entry
(the load-bearing chain: subscriber → context → setCashInFee → fee
math is correct for the next transaction)
Total state-machine tests: 21 (was 18); apps/machine tests unchanged
at 24. All 12 workspace packages typecheck.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Main's commit e5d7a86 ("chore: set ATM_DB_PATH env var for atm-tui")
added two ATM_DB_PATH lines pointing at /var/lib/lamassu-atm/state.db
AFTER dev had forked. Dev's path-rename commit (10d2869) couldn't
touch those lines because they didn't exist on dev's base; the
rebase brought them in unchanged, undoing the lamassu-atm → bitspire
data-dir migration for the ATM_DB_PATH consumers.
Re-point both occurrences at /var/lib/bitspire/state.db so atm-tui
and other ATM_DB_PATH consumers reach the actual on-disk location.
24 tests for the load-bearing logic introduced by the previous commit:
`src/services/__tests__/operator-fees.test.ts` (10):
- canonical v1 payload with components parses cleanly
- absent schema_version treated as v1 (back-compat with cassette config
doc that shipped without one)
- unknown top-level keys silently ignored (v2 forward-compat)
- absent `components` → WARN + zero breakdown (graceful degrade,
producer-mandatory at v1 but consumer-safe)
- components present but sums disagree with totals → WARN + still
parses (totals authoritative per coord log §`14:25Z`)
- tiny float drift (well under 1e-6) does NOT trip the consistency
assert
- required fields missing → throws
- non-numeric component → throws with the offending key in the message
- FEE_CAP_PER_DIRECTION exposed at 0.15
`electron/__tests__/state-store-fees.test.ts` (14):
- null pre-apply (`getFeeConfig` + watermark)
- round-trip via getFeeConfig after applyFeeConfig
- upsert on subsequent newer event (singleton id=1)
- watermark dedup: rejects equal AND older event.created_at
- persisted row unchanged when stale event is rejected
- 15% per-direction cap: rejects above-cap on either direction
- accepts at the cap boundary exactly
- rejects negative + non-finite fractions
- schema_version < 1 rejected
- non-integer event_created_at rejected
- watermark does NOT advance when payload validation fails (atomicity)
Uses in-memory SQLite (`:memory:`) — fresh DB per test, no on-disk
artifacts, no parallel-test interference.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Layer 3 of the operator-configurable fee architecture (parent
aiolabs/satmachineadmin#37). Replaces the hardcoded
`ref(0.0333)` / `ref(0.0777)` constants in `atm.ts` with a Nostr-
delivered, operator-pushed fee config sourced from satmachineadmin.
Wire envelope (locked with sat-side at #39 + coord log 2026-06-01):
kind=30078 (NIP-78 replaceable), NIP-44 v2 encrypted
d-tag: bitspire-fees:<atm_pubkey_hex>
["p", atm_pubkey], signed by operator account
watermark: event.created_at (no envelope-level published_at)
Plaintext:
{ schema_version: 1,
cash_in_fee_fraction: …, sum ≤ 0.15
cash_out_fee_fraction: …, sum ≤ 0.15
components: { super_cash_in, super_cash_out,
operator_cash_in, operator_cash_out } }
Consumer-side invariants:
- Signature + author whitelist + watermark + clock-skew gates
- 15% per-direction hardcoded cap (defense in depth with sat's
producer-side refuse-to-publish at the same threshold)
- Consistency assert when `components` present: sum of super+operator
must equal each total within 1e-6; drift logs WARN + still applies
(totals are authoritative — see coord log §`07:33Z` and §`14:25Z`)
- Unknown top-level keys silently ignored (v2 forward-compat for
future promo additions); absent `schema_version` treated as v1
- Apply-mid-transaction defers to next tx by XState's context-snapshot
boundary; no explicit timer/lock code needed
Persistence (state.db schema v9→v10):
- New `fee_config` singleton row (id=1) with the totals, schema_version,
event_created_at watermark, and applied_at audit timestamp.
- New `meta.lastKnownFeeConfigCreatedAt` row — independent from the
cassette watermark per the d-tag-per-lifecycle convention.
- Super/operator components are NOT persisted on the ATM —
satmachineadmin is the canonical audit substrate per Layer 1 #38
(dumb-machine / smart-server split, see coord log §`07:56Z`). The
breakdown survives in the parser's receipt log line in journalctl
for offline forensics.
Fail-closed posture:
- First boot with no persisted config + no inbound event →
`initError = 'awaiting-fees'` → maintenance screen ("Awaiting fee
configuration from operator. Contact operator to publish initial
fee config."). Matches path-B `roster_required` posture.
- Persisted config present + relay unreachable → ATM operates with
the persisted values; subscriber catches up when relay returns.
Env-var fallback dropped:
- `VITE_CASH_IN_FEE` / `VITE_CASH_OUT_FEE` no longer read by the
Electron main process. Operator-config-over-Nostr is the single
source of truth — removes the env-vs-Nostr ambiguity surface.
- `parseFee` helper deleted (was its only caller).
Subscriber wired into all three init paths (Lightning-only,
direct-HAL, HAL-via-IPC) alongside the existing cassette-config
subscriber from #56. `onApply` callback receives just the totals
(components stay parser-side per the architectural split above).
IPC surface:
- state:get-fee-config → persisted singleton or null
- state:get-last-known-fee-config-created-at → watermark
- state:apply-fee-config → atomic upsert + watermark advance
Closesaiolabs/lamassu-next#57.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`initialContext.cashInFeeFraction` / `cashOutFeeFraction` drop from
0.0333 / 0.0777 → 0. The state-machine no longer carries a fee
opinion; callers (the renderer's atm-store) are responsible for
supplying explicit fractions via `createATMMachine(..., options)`.
Why now: aiolabs/lamassu-next#57 makes the operator's Nostr-pushed
fee config the source of truth on the ATM. Keeping non-zero defaults
in the state machine would mean a misconfigured caller could silently
fall back to a 7.77% cash-out fee instead of failing closed into the
"awaiting fee configuration" maintenance screen.
Extracts `ATMMachineOptions` as a named interface (was inline). No
functional change to the option spread.
Updates the `should calculate sats amount from fiat with fee` test
to pass `cashOutFeeFraction: 0.0777` explicitly so it still exercises
the cash-out fee math; the post-refactor zero default would otherwise
land 50,000 sats instead of 53,885.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First test surface for the Electron + Vue 3 app — apps/machine has had
no tests until now (workspace packages cover state-machine, nostr-client,
clink, lnbits). Cassette config #56 shipped untested under the same
posture; #57 (operator fee config consumer) introduces enough load-
bearing parser + state-store logic to want unit coverage, so growing
the test substrate now.
Adds `test` / `test:watch` scripts + vitest devDep + minimal config
that picks up `src/**/*.test.ts` and `electron/**/*.test.ts`. No tests
land here — that comes with the feature commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Under path B (NOSTR_TRANSPORT_ROSTER_REQUIRED=true), lnbits's
roster-lookup override routes create_invoice to the operator's
wallet, but the subsequent subscribe_payments was scoping its
filter to the ATM's pre-override wallet_id. The dispatcher
AND-filters payment_hash + wallet_id, so the settlement on the
operator wallet was invisible to the subscription — bitspire
stayed in "Watching invoice" forever, dispense never fired.
Omit wallet_id on the single-invoice watcher: lnbits already
resolves the wallet from get_standalone_payment(payment_hash)
and ownership-checks against the auth'd account. Works pre/post-
override; payment_hash is the natural primary key for "wait for
THIS invoice" anyway.
Cash-out subscription site at services/lightning.ts:1008-1010
(production caller) + watchInvoice convenience helper at
packages/lnbits/src/client.ts:286-313 both flipped.
LNURL-withdraw subscription at services/lightning.ts:720
(filter: tag+link_id) is the symmetric case but pending lnbits
confirmation that the tag+link_id branch of _resolve_owner_wallet_id
exists alongside the payment_hash branch.
Coordination: ~/dev/coordination/log.md 2026-05-31T18:35Z (joint
smoke surfaced the bug), 18:40Z (bitspire diagnosis), 18:50Z
(lnbits narrowed the fix shape + confirmed path-2 works against
deployed lnbits today).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Picks up aiolabs/atm-tui@3dffaf9 — cassettes table PK flips to position,
helpers operate by position, supports multiple cassettes with the same
denomination. Matches the lamassu-next v9 schema migration at bfd0b11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors satmachineadmin's PR #30 v1.1 commits (df6e8e0..1cebefc). Three
load-bearing corrections from the v1.0 implementation:
1. **Wire shape flips from denomination-keyed to position-keyed**
(`{positions: {<pos>: {denomination, count}}}`). The original `#56`
spec was position-keyed; my `06:40Z` audit-and-flip was wrong on
both the load-bearingness of the ATM denom-PK invariant AND on the
operational requirement (per-slot denomination must be operator-
editable for swap-during-refill).
2. **Drop "one cassette per denomination" invariant.** Real production
machines load multiple cassettes with the same denomination for
cash-out throughput on a single bill class (4 × $20 cassettes on
Tejo/batm3 are normal). NO unique index on denomination.
3. **HAL refactor for per-position state + greedy distribution.** When
asked for N of denomination D, iterate matching bays in position
order draining greedy until the request is satisfied or all matching
bays empty. Surfaces "Insufficient inventory for denomination D:
short K" rather than crashing on the first under-stocked bay.
Schema migration v8 → v9: rebuild `cassettes` with `position INTEGER
PRIMARY KEY`, `denomination INTEGER NOT NULL`, `count INTEGER NOT NULL
DEFAULT 0`. SQLite create-copy-drop-rename per the v4→v5 precedent
(FKs off during, no data loss). Existing rows backfill column-by-column.
`setCassettes()` upserts `ON CONFLICT(position)`. `updateCassetteCount
(denomination, delta)` → `updateCassetteCountByPosition(position, delta)`
since the dispenser returns per-position results. `getInventory()`
boundary stays denomination-keyed (sums across matching bays) for
backwards compat with renderer callers.
HAL `inventory: Record<denom, count>` + `cassetteDenominations: number[]`
collapse into a single `bays: {position, denomination, count}[]` array.
Dispense per-bay note assignment + per-bay decrement on result. Bay
ordering by position throughout.
Operator-config consumer (`operator-config.ts`) flips both the apply
direction (`{positions: ...}` parse + validate position-set equality +
denom/count int checks, NO denom-uniqueness) and the bootstrap publish
direction (position-keyed payload encoding).
IPC type signatures updated in `preload.ts` + `types/electron.d.ts` for
both the new `OperatorCassettesPayload` shape and the per-position
`halReloadCassettes` argument.
`atm-tui` schema flip + handler updates land in a separate commit on
`aiolabs/atm-tui` (this commit's changes are limited to lamassu-next).
Bumping the atm-tui flake input on `deploy/server-deploy` (or the local
flake.lock here) after the atm-tui push reaches the sintra closure.
12/12 typecheck, 18/18 state-machine tests, 11/11 clink, 11/11 lnbits,
11/11 nostr-client all green.
Design history: `~/dev/coordination/log.md` entries 2026-05-30T06:30Z →
20:55Z. Satmachineadmin counterpart at PR #30. Issue body refreshed.
refs: aiolabs/lamassu-next#56, aiolabs/satmachineadmin#29, aiolabs/satmachineadmin PR #30 (commits df6e8e0..1cebefc)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the ATM-side consumer of operator-driven cassette config per
aiolabs/lamassu-next#56 v1. Operator → ATM only, with a one-shot ATM
bootstrap hello-event so satmachineadmin can auto-populate
`cassette_configs` rows on first boot.
Transport (decision rationale in coordination log 2026-05-30 entries):
- kind=30078 (NIP-78 replaceable), ["p", atm_npub]-tagged, ["d",
"bitspire-cassettes:<machine_id>"], NIP-44 v2 encrypted content,
authored by operator. Subscribed via filter
{kinds:[30078], "#p":[my_npub], "#d":[...], authors:OPERATOR_PUBKEYS}
- machine_id = ATM hex pubkey (no extra provisioning step)
Wire payload is denomination-keyed (per satmachineadmin's 06:40Z
audit of the ATM stack — every layer beneath the wire keys on
denomination, position is a sortable display column):
{ "denominations": { "<denom>": { "position": N, "count": M } } }
Validation:
- event signature + author in VITE_OPERATOR_PUBKEYS allowlist
- replay protection via meta.lastKnownConfigCreatedAt (drops events
re-delivered on relay reconnect or after restart)
- clock-skew defense: reject created_at > now + 60s
- denomination key set EXACTLY equal to state.db denominations
(no add/remove cassettes from the dashboard)
- per-row position positive int, count non-negative int
Apply in a single SQLite transaction (cassettes upsert by denomination
PK + meta watermark update), then hot-reload HAL via new IPC
`hal:reload-cassettes` so dispense math picks up the new layout
without restarting the bitspire service.
Bootstrap hello-event (one-shot):
- on init, if meta.bootstrapPublishedAt IS NULL AND cassettes
non-empty, publish kind=30078 with d=bitspire-cassettes-state:<id>,
encrypted to operator pubkey, signed by ATM
- on success set meta.bootstrapPublishedAt; on failure leave null and
retry next boot (best-effort; doesn't block service startup)
Schema v7 → v8: adds meta rows lastKnownConfigCreatedAt + bootstrap-
PublishedAt. Fresh installs at v8 seed via INSERT OR IGNORE.
HAL service grows setCassettes(cassettes) — closes + re-inits the
dispenser, rebuilds the inventory map + cassetteDenominations index.
Exposed as `hal:reload-cassettes` IPC + window.electronAPI.halReload-
Cassettes for the renderer.
Out of scope (v2 / separate issue):
- continuous ATM-state reverse-channel publish (dashboard
reconciliation + ✅/⏳ apply confirmation + safe "Add N bills" UX)
12/12 typecheck + 18/18 state-machine + 11/11 clink + 11/11 lnbits
suites pass.
refs: aiolabs/lamassu-next#56, aiolabs/satmachineadmin#29,
~/dev/coordination/log.md 2026-05-30 entries (06:30Z, 06:40Z, 07:30Z,
07:50Z, 07:55Z), ~/dev/CLAUDE.md (Nostr architecture → "Respect
protocol semantics over friction reduction")
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up aiolabs/atm-tui@2326e63 — DB path default now resolves to
/var/lib/bitspire/state.db (production layout), with a schema guard
that refuses stray files. Closes the silent-wrong-file footgun caught
on sintra 2026-05-28 (issue aiolabs/atm-tui#5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns lamassu-next with the canonical sat-amount vocabulary agreed
across lnbits/bitspire/satmachineadmin (satmachineadmin@d717a6e,
coordination log 2026-05-26T17:10Z):
- `feePercent` / `cashInFeePercent` / `cashOutFeePercent`
→ `feeFraction` / `cashInFeeFraction` / `cashOutFeeFraction`
(canonical: unit fraction in [0, 1], NEVER a percentage)
- `cashInFeeRate` / `cashOutFeeRate` (config option names)
→ `cashInFeeFraction` / `cashOutFeeFraction`
- `fee_percent` (wire field on Payment.extra + state.db column)
→ `fee_fraction`
Bug fix bundled with the rename:
`lightning.ts:780` previously stamped `Payment.extra.fee_percent =
context.feePercent * 100` (0.05 → 5.0). state.db stored the unit
fraction (0.05) but Payment.extra carried the percent (5.0) — 100×
divergence that any consumer reading Payment.extra computed fees
wrong by exactly 100×. Now stamps `fee_fraction` directly as unit
fraction. Display layers (atm-tui, view components) multiply by 100
themselves.
Defensive invariants added:
- `computeFeeSats` (atm store) throws if `feeFraction` outside [0, 1]
or if cash-in `feeSats > principalSats` (would mean negative payout)
- `recordTransaction` (state-store) throws on the same range
- state-machine + electron + Vue views propagate the rename
state.db migration v6 → v7: `ALTER TABLE transactions RENAME COLUMN
fee_percent TO fee_fraction`. Historical migrations preserved
verbatim (they wrote `fee_percent`, future installs see the same
sequence followed by the v7 rename).
12/12 typecheck + 18/18 state-machine tests green. Coordinated with
~/dev/bitspire/atm-tui (separate commit) reading `fee_fraction`
from the new column.
refs: log:2026-05-26T17:10Z, log:2026-05-26T18:50Z,
satmachineadmin@d717a6e
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tonight's Sintra re-flash surfaced two procedure gaps the doc didn't
cover. Burn them in so future-us doesn't rediscover them:
1. Step 0 (re-flash only): preserve .env + state.db before powering
off. The .env carries VITE_ATM_PRIVATE_KEY — without it LNbits
treats the reflashed unit as new and spawns a fresh wallet,
stranding the old wallet's balance. Also: push origin/dev +
push-cache.sh BEFORE flashing, or the 04:00 auto-upgrade either
fails to substitute or silently downgrades.
2. Step 5: e2fsck "No such file or directory" after parted resize.
Kernel re-read the partition table (lsblk shows mmcblk0p2) but
Alpine's udev didn't create the /dev/ node. partprobe and
blockdev --rereadpt don't fix it — they refresh the kernel's view,
not /dev/. Fix is udevadm trigger + settle, or mknod 179:N by
hand. Caught tonight, cost ~4 round-trips of confusion.
Step 7 split into first-time vs re-flash provisioning paths — the
re-flash path sources from the step-0 backup so the ATM keeps its
wallet identity. Also added optional state.db restore command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 24.05-era image was ~6 GB actual; the README's count=2200 (8.8 GB)
gave a small margin. The 25.11 image is 7.3 GB actual (linux-firmware-
zstd grew, plus the version churn) — count=2200 would now truncate.
Bump to count=2800 (~11.2 GB) and note that future bumps may need
another increase — image size is the right thing to check via
\`ls -lh result/\`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was: weekly, no persistence. Problem: 15GB eMMC + ~7GB closure means
cross-release upgrades are always tight. Weekly cadence stacked up to
a week of generations under the 04:00 auto-upgrade. Persistent=false
also meant a Sintra powered off at 03:30 skipped GC until next week.
Now runs daily at 03:30, 30 min before the 04:00 nixos-upgrade so each
upgrade attempt gets the freshest headroom. 7d retention unchanged.
This is a periodic-cleanup fix only — cross-release in-place upgrades
on 15GB eMMC will still hit the disk-full wall (caught 2026-05-26 on
24.05 → 24.11). Reflash remains the answer there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
isoImage.isoName was renamed to image.fileName in 25.11 (unified
image module). Split the rename out — the rest of isoImage.* stays
put (makeEfiBootable, makeBiosBootable, squashfsCompression).
All 8 nixosConfigurations evaluate clean on 25.11 with zero
deprecation warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes aiolabs/lamassu-next#50. Builds on #49 (commit 0dcbe44):
isAuthenticServerEvent now Schnorr-verifies inbound events, so ev.id
is by construction the id of a server-signed event and can be used
as a dedup key without risk of attacker pre-poisoning.
Two layers:
1. Client-global `seenEventIds` (Set<string>, FIFO cap 1000) in
`LnbitsClient.handleReply`. Skips events whose id has been seen.
Catches exact-replay — relay re-delivers the same bytes after
reconnect, or any other source that emits a bit-identical event.
Without #49's guard, an attacker could pre-poison this set with
chosen ids; with the guard, every entry is a server-signed event.
2. Per-subscription `seenPaymentHashes` (Set<string>, FIFO cap 500)
on `ActiveSubscription`. Skips `onPush` invocations whose payment
hash has been seen on the same subscription. Catches logical
duplicates — server fan-out across two relays produces two
different ev.ids carrying the same payment_hash, which the
client-global ev.id layer can't dedup but the per-sub hash layer
does. Scoped per-sub so independent subscriptions seeing the same
hash for their own reasons still fire.
Why this matters in production: without dedup, a relay rebroadcast of
a settlement push would invoke `onPush` twice. The state machine's
`watchInvoice` callback resolves on the first push and unsubscribes,
but a race between the second push and the unsubscribe round-trip
could land a second `dispenseCash()` for the same cash-out — customer
walks away with double the cash. Per-sub `payment_hash` is the only
field guaranteed-unique per settlement (the customer's payment hash
is fixed at invoice creation), so it's the right dedup key.
5 new tests:
- forged event doesn't poison `seenEventIds` (proves guard + dedup
interact: a refactor that removes either #49's guard or this
patch's `seenEventIds.add()` ordering would fail this test)
- exact-replay: same event injected twice, callback fires once
- distinct ev.ids with same payment_hash, callback fires once
(per-sub hash dedup; ev.id dedup doesn't apply)
- distinct payment_hashes fire normally (negative dedup case)
- per-sub isolation: same hash on two subs fires both callbacks
Total: 11/11 lnbits package tests pass. Workspace typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to commit 0dcbe44 (closesaiolabs/lamassu-next#49) addressing
two review notes from the bitspire session:
1. Integration test gap. The four unit tests on isAuthenticServerEvent
cover the predicate in isolation — a refactor that dropped the
`if (!isAuthenticServerEvent(...)) return` line from handleReply
would still pass them silently. Adds two integration tests that
exercise the full handleReply wiring through a mock NostrClient
that captures the subscribe callback:
- Forged event injected through the callback → pre-registered
pending entry's resolve is NOT called (asserts handleReply
short-circuited).
- Legitimate server-signed reply (NIP-44 v2 encrypted with
real keys) → pending entry's resolve IS called with the
decoded payload (positive sanity).
2. `@internal` JSDoc on isAuthenticServerEvent. The helper is exported
only so the unit tests can reach it directly; production callers
should go through handleReply. The annotation makes the intent
explicit and discourages accidental wider use.
6/6 tests pass. Workspace typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clean step — all 8 nixosConfigurations evaluate without deprecation
warnings on 25.05.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LnbitsClient.handleReply matched replies by request_id alone — no
verification that the inbound event was actually signed by the
configured LNbits server pubkey. The relay's subscription `authors`
filter is relay-honour, not relay-enforced; a malicious or buggy
relay could forward an event with the server's pubkey in the body
but signed by a different key (or with a tampered body whose id no
longer matches).
Adds `isAuthenticServerEvent(ev, expectedPubkey)`:
- `ev.pubkey === expectedPubkey` (explicit, not relying on filter)
- `verifyEvent(ev)` (catches sig/id/content tampering)
handleReply calls it before passing the event to decryptContentV2.
NIP-44 v2 already binds ciphertext to sender via ECDH, so a relay
without the server's nsec can't forge decryptable content — but
verifying the outer event keeps `ev.id` trustworthy for any
downstream dedup/logging code and matches the symmetric defence on
the server side (`nostr_transport/relay_pool.py:~320`) and on the
lnbits-bunker-client side (`aiolabs/lnbits` commit 4ebcd959,
`NsecBunkerAdminClient._match_response`).
Test `packages/lnbits/src/__tests__/client.test.ts` covers:
- legitimate server-signed event accepted
- event whose pubkey field doesn't match config rejected
- forged event (signed by attacker, pubkey overwritten to server)
rejected — recomputed id no longer matches stored id
- event with tampered content (id mismatch) rejected
Gotcha worth noting: `finalizeEvent` stamps `event[verifiedSymbol] = true`
to cache the verification result, and `{...ev}` spread copies symbol-
keyed properties. So forged/tampered events constructed via spread
inherit the cached `true` and `verifyEvent` short-circuits. The test
JSON-round-trips through `stripVerifiedCache` to drop the cache.
Closesaiolabs/lamassu-next#49.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 5-minute expiration tag to every outbound RPC envelope. Belt-
and-suspenders with the handler-side max_age check (aiolabs/lnbits
e4b5bcd7) — the tag lets compliant relays drop expired events at the
relay layer before they reach LNbits, while the handler's own
time-bounds check defends against a stripped tag.
Closesaiolabs/satmachineadmin#15 (S1 / G4 — no replay window on RPC
events) on the ATM emission side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nixpkgs 24.11 made `pkgs.python3` an alias for Python 3.12, which
removed `distutils` from stdlib. node-gyp@9.4.1 (transitively pulled
in by better-sqlite3) still imports `distutils.version`, so the app
derivation failed with `ModuleNotFoundError: No module named
'distutils'` on the better-sqlite3 rebuild step.
Pin to python311 — still in 24.11 nixpkgs. Drop once pnpm-lock bumps
better-sqlite3 to a release whose node-gyp@10+ doesn't need distutils.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three breakages handled:
- hardware.opengl → hardware.graphics (renamed in 24.11). Touches
upboard.nix, douro.nix, batm3.nix, live.nix.
- vaapiIntel dropped — legacy pre-Broadwell driver, removed in
nixpkgs. UP Board (Cherry Trail) and OptiPlex 9030 (Haswell) both
use intel-media-driver, which stays.
- vaapiVdpau renamed → libva-vdpau-driver.
system.stateVersion stays 24.05 — convention is to never bump after
install. Existing Sintra and fresh flashes keep the 24.05 state
semantics; that's correct.
All 8 nixosConfigurations (4 models × {live,installed}) evaluate
clean with zero deprecation warnings on 24.11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sibling-file convention: drop a logo-dark.png alongside logo.png in
/var/lib/bitspire/branding/ and the renderer uses it whenever the
effective color mode is dark, falling back to logo.png when absent.
No branding.json change — the file name itself is the contract.
Wiring:
- electron/main.ts:loadBranding() reads logo-dark.png and base64-encodes
it into logoDarkDataUrl on the IPC payload
- composables/useTheme.ts exposes an `isDark` computed that resolves
the 'system' colorMode via the prefers-color-scheme media query (and
reacts to OS-level dark-mode changes via the existing listener)
- composables/useBranding.ts switches logoUrl reactively based on isDark
- IdleView already binds to logoUrl — no template change needed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cachix push by default only walks the RUNTIME closure of the paths it
gets on stdin. Build-time inputs like fetchPnpmDeps tarballs are
consumed during a build and then thrown away — never referenced from
the final output — so they never make it into the cache.
This bites when an ATM has the cached runtime output for an older
version of bitspire-atm-app but then needs to rebuild it (e.g. because
a flake.nix change shifts the toplevel hash, cascading through to a
new app derivation). Sintra OOM-killed itself today (2026-05-25) doing
exactly this: 1.4 GB of pnpm install + node-headers on 1 GB of RAM.
Fix: query the .drv that produced each output path, then walk
`nix-store -qR --include-outputs <drv>` — that gives the full
build-time closure (every path needed to realize the build, including
fixed-output fetchers like fetchPnpmDeps). cachix push then uploads
all of them.
Cost: somewhat larger pushes, but the dev box has the headroom.
Benefit: ATM nixos-upgrade never falls into the rebuild-from-source
trap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`./deploy/push-cache.sh sintra` was building+pushing `iso-sintra` while
the Sintra's auto-upgrade pulls `nixosConfigurations.sintra-installed`.
Result: the cache had the ISO closure but not the installed closure, so
every Sintra nixos-upgrade ran the substitution loop, came up empty for
small text-stitch derivations (nix.conf, etc, system-units), and bailed
with `local builds are disabled (max-jobs = 0)`. Caught when
re-provisioning the dev unit to the demo LNbits.
Symmetric fix:
- `sintra` now pushes `nixosConfigurations.sintra-installed.…toplevel`
(matches the douro/batm3 convention)
- New `sintra-live` target pushes the ISO (matches douro-live)
- `all` now includes `sintra-installed` (was silently missing)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Read /var/lib/bitspire/branding/{logo.png,branding.json} on startup and
apply across the renderer. branding.json may set title, theme (one of
the 6 built-ins or "custom"), and a custom_colors map (with optional
.dark overlay) — unset CSS vars fall back to gruvbox.
Wiring:
- electron/main.ts:loadBranding() reads + validates the JSON and
base64-encodes logo.png; surfaced via the existing get-config IPC
- composables/useBranding.ts holds reactive logoUrl/title refs and a
single setBranding() setter — the seam where #48's Nostr-event
source will eventually overlay the local-file source
- composables/useTheme.ts:applyBrandingTheme() handles built-in theme
swap and injects a <style#branding-custom-theme> block for custom
- IdleView binds :src/title; App.vue calls setBranding() before the
maintenance screen renders so "Under Service" wears operator branding
Provisioning: new deploy/nixos/provision-branding.sh rsyncs a local dir
to /var/lib/bitspire/branding/ via sudo-on-the-far-side and restarts
bitspire.service. The existing provision-atm.sh stays focused on .env.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
System-level half of the lamassu → bitspire rebrand the rest of dev
already did at the path / service / package layers. Touches user/group
declarations, every systemd `User=` block, the udev rules filename, all
chown calls in flake.nix + live.nix, the displayManager autoLogin user,
the trusted-users nix entry, provision-atm.sh's ATM_USER, plus README +
CLAUDE.md doc references.
In-place migration for the Sintra dev unit (which auto-pulls dev at
04:00) lives in `system.activationScripts.bitspire-user-migration` and:
- copies `/home/lamassu/.ssh/authorized_keys` → `/home/bitspire/` once,
so SSH access survives the rename
- recursively chowns `/var/lib/bitspire` to the new bitspire UID on
every boot — cheap no-op once done, but covers the case where the
data dir was written by the now-removed lamassu UID
- leaves `/home/lamassu/` in place as evidence; operator can `rm -rf`
after confirming bitspire login works
Recovery path if the migration breaks SSH access: root key is still in
configuration.nix:142-144 (padreug@gizmo), so ssh root@<host> works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default FIAT_CODE per-model (sintra=EUR, douro/tejo=GTQ, batm3=USD) to
match the flake's fiatCodeForModel table; both overridable via env var.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 138cd1a. Adds the customer-transacted fiat amount as a
top-level field on the kind-21000 Payment.extra payload, sourced
directly from `context.fiatCents` (the bill validator/dispenser
ledger — canonical record of what bills entered/exited the machine).
Why a separate field instead of letting the consumer divide:
principal_sats / exchange_rate
…is close but not equal to the bill-counted truth. It assumes the
commission was paid entirely in BTC (true today on cash-out) and
introduces sub-cent rounding from `floor()` in the principalSats
calc. The bill-validator number doesn't have those problems and is
the only authoritative record of what cash actually changed hands.
Belongs with the rest of the #44 metadata. Spec didn't enumerate it
originally; adding now before the field name locks in across the
fleet.
Cash-out invoices created via `lnbits.createInvoice()` now carry the
principal / commission / exchange-rate metadata satmachineadmin needs
to drive DCA distribution without back-deriving from a stored rate.
Closes the wire-format side of `aiolabs/lamassu-next#44`.
Wire payload (matches the canonical names agreed in #44 comments
#598/#599/#600 — `principal_sats` not `net_sats`, `fee_percent` not
`fee_pct`):
extra: {
source: 'bitspire',
type: 'cash_out',
txid: context.txid,
principal_sats: floor((fiatCents / 100) * exchangeRate),
fee_sats: max(0, satsAmount - principal_sats),
fee_percent: feePercent * 100,
exchange_rate: context.exchangeRate, // raw market rate, sats/fiat
currency: context.currency, // customer-paid currency
}
`bills` / `cassettes` deferred — they're meaningful for cash-in and
partial-dispense reconciliation, neither of which is wired on the
satmachineadmin side yet (#22, #3).
Plumbing:
- `ATMServices.generateInvoice` signature changes from
`(amountMsat: number) => Promise<string>` to
`(context: ATMContext) => Promise<string>`. The on-wire BOLT11
amount is derived inside the service as `satsAmount * 1000` msats;
the rest of the context drives the extra payload.
- State-machine `generatingInvoice` actor passes the full context
instead of just msats.
- Dev mock in `apps/machine/src/stores/atm.ts` updated to match.
All 18 state-machine tests pass. Typecheck clean across the app.
Two `// pragma: allowlist secret` markers added to lightning.ts on
existing doc-comment lines that mention "private key" — the dev-env
pre-commit secret scanner flagged them as false positives (every
prior commit touching this file had bypassed via --no-verify).
Cash-in (`generateLnurlWithdraw`) intentionally left alone for now —
satmachineadmin's listener doesn't handle the outbound LNURL-withdraw
flow yet (`aiolabs/satmachineadmin#22`), so stamping metadata it
won't read would be premature. Will land alongside that issue.
"Gross" was operator-vs-customer ambiguous (cash-out: customer's gross
payment = principal + commission, not the variable's value). atm-tui
already settled on "principal" for the same quantity (bitspire/atm-tui
src/db.zig:166-171, src/main.zig:98,716), and #44's Payment.extra
proposal will surface it as `principal_sats` on the kind-21000 wire.
Aligning the internal name removes one translation step across DB →
TUI → state machine → wire envelope.
Pure mechanical rename — no behavioral change. Also rewrites the
computeFeeSats JSDoc to drop the "gross"/"net" framing and document
the principalSats / on-wire satsAmount relationship explicitly.
Refs aiolabs/lamassu-next#44
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second + final batch of the doc refresh. README + CLAUDE went out in
8c9ae29; deploy/nixos/README + obsolete-flow flags in 924844f. This
commit covers everything left.
docs/machine-installation.md
Was describing a manual AppImage scp deploy + a `lamassu-kiosk`
systemd unit that hasn't been the deployment path for months.
Replaced with a high-level "what the pipeline does and why"
overview that points at deploy/nixos/README.md for the full
command-by-command walkthrough. Includes the BATM3 chassis-mod
note (custom Dell OptiPlex retrofit, not a stock Dell).
docs/architecture-comparison.md
Rewrote the comparison to be lamassu-server (≤ v8.1.5) vs bitSpire
(LNbits-backed) instead of the original lamassu-server vs LP-backed
lamassu-next framing. Updated the cash-out + cash-in flow diagrams
to show the actual nostr-transport path (LNbits-bundled nostrrelay
extension at ws://<host>:5001/nostrrelay/test, no separate strfry
container). Replaced the migration-path section with a softer
"when to choose what" framing that includes Lamassu's current
commercial offering as a legitimate third option. Added a header
pointer to the Acknowledgements section.
docs/business-model.md
Light touch-ups: Lightning.Pub → LNbits where it appeared, swapped
the [[ndebit-cash-in-flow]] link for [[architecture-comparison]],
noted the kind-30078 service beacon for availability broadcasts.
docs/device-configuration.md
Dropped "Lamassu" branding from the machine-model headings
(Sintra / tejo / douro / batm3 are referenced by hardware identity
here, not by Lamassu's product line). Added the Sintra-specific
ttyS4-vs-placeholder-ttyS1..3 gotcha we hard-learned during the
first flash. Corrected the BATM3 entry: stock GeneralBytes chassis
with a Dell OptiPlex 9030 AIO motherboard physically grafted in,
NOT a Dell out of the box. Updated the example /dev/ttyJ* symlink
output to match what a healthy Sintra actually shows.
docs/adr/001-hal-architecture.md
ADRs are historical artifacts — kept the original decision text
intact. Added a postscript noting:
- The package rename @lamassu/hal → @bitSpire/hal
- The v8.1.5 boundary on any lamassu-machine source-tree
references (Lamassu's 2024-01-26 license transition)
- That the "Remaining Work" list is complete and the first
successful Sintra hardware integration ran on 2026-05-13
.claude/skills/lightning-check.md
Rewrote end-to-end. Was Lightning.Pub-flavoured with CLINK kinds
21001/21002 as the primary flows; now validates the LNbits nostr-
transport surface (kind-21000 envelope, NIP-44 v2 encryption,
subscribe_payments filter discipline, lnurlw link composition).
Preserved a --clink mode for the still-live kind-21003 operator-
management surface. Includes a "what to check" rubric for cash-out
vs cash-in flows that mirrors the actual code in
apps/machine/src/services/lightning.ts.
.claude/skills/hal-check.md
Two pivots: (1) acknowledge ADR-001's TypeScript-not-Rust choice
and reframe all the safety checklists in TS-flavour (type safety,
discriminated unions, single-writer serial, bounded emitters)
instead of Rust-flavour (unsafe, borrow checker). (2) Add explicit
v8.1.5 provenance boundary plus a "forbidden operations" section
that prohibits diffing or porting from v8.1.6+ lamassu-machine
source. Updated the port-validation source-reference table to
list TS file paths under packages/hal/ instead of Rust paths.
.claude/skills/docs.md
@lamassu/* → @bitSpire/*. Replaced the Lightning.Pub mermaid
diagram with a current cash-out flow showing the nostr-transport
RPC + subscribe_payments push path. Left the createOffer noffer
example in the API-docs template section since it's illustrative
("here's what a good TSDoc block looks like") rather than current
reference documentation.
.claude/skills/test.md
One-line: @lamassu/nostr-client → @bitSpire/nostr-client in the
pnpm-filter example.
deploy/nixos/README.md
Single touch-up: clarified the douro/batm3 hardware-module comments
to reflect that BATM3 is a custom-installed Dell board in a
GeneralBytes BATM3 chassis (not a Dell OEM).
Files NOT touched in this sweep (intentionally):
- packages/hal/src/**/*.ts attribution comments — those reference
"lamassu-machine" in their port-source headers. Those are
factually accurate (the drivers ARE ported from there, up to
v8.1.5) and constitute necessary license/attribution metadata.
Editing them would erase the provenance trail.
- .claude/skills/{nostr-check,security}.md — already protocol-
neutral, no LP/lamassu references to clean up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
deploy/nixos/README.md was the most-stale doc in the tree: it still
talked about a `lamassu-atm` systemd unit, `/opt/lamassu-atm` paths,
nixos-install with a non-existent `lamassu-atm` flake output, and an
scp-the-built-electron-bundle workflow that hasn't been the deploy
path for many months. Replaced with a rewrite that documents the
actual current pipeline:
- File layout: bitspire-atm.nix (not lamassu-atm.nix), live.nix,
hardware/{douro,batm3,upboard}.nix, and the udev / helper scripts
- Build pipeline: `nix build .#disk-image-<model>` and the four
flake-output flavours per model (live config, installed config,
iso, disk-image)
- Full Sintra walkthrough end-to-end: prep a flashing USB on the
dev box, boot Alpine live on the Sintra, identify the eMMC,
dd with count= to skip the trailing USB padding, repair the
GPT secondary header + grow root with parted, poweroff, boot,
provision via provision-atm.sh. Every quirk we hit during the
first real flash is now baked in (mdev for /dev nodes, parted
Fix prompt, lbu-style notes).
- Runtime layout cheat-sheet: /var/lib/bitspire/.env (0600
lamassu:lamassu), state.db, /etc/bitspire/config.env, etc.
- Common-operations playbook: re-provision, nixos-rebuild switch
over SSH with --use-remote-sudo (much faster than reflashing),
journalctl filtering, hardware-side health checks.
- NixOS module reference for services.bitspire, including the
LNbits-flavoured options (relayUrl, lnbitsServerPubkey,
lnbitsHttpUrl) instead of the retired lightningPubUrl.
- Sintra-specific gotchas section: eMMC-via-sdhci-acpi, the
ttyS4 dispenser placement, the ttyS1..3 phantom-node issue.
- Security-notes section updated to reflect passwordless sudo
enabled for nixos-rebuild deploys, and the implications.
Auto-upgrade behaviour explained explicitly (the ?ref=dev pin) so
contributors understand why production ATMs on main don't pick up
dev branch changes.
docs/ndebit-cash-in-flow.md: added a header banner flagging the
document as historical — cash-in on dev is LNURL-withdraw +
subscribe_payments push, not ndebit. Original content kept as a
reference for any future revival of nostr-native cash-in.
docs/clink-protocol.md: same treatment — flagged as dormant on dev,
explaining which pieces still apply (kind-21003 management) and
which are unused (kinds 21001/21002). Protocol reference content
left intact since the wire format is unchanged upstream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both top-level docs were stale after the lamassu-next → bitSpire +
Lightning.Pub → LNbits migration shipped on this branch. They still
listed @lamassu/* package scopes, treated Lightning.Pub as the backend,
described cash-in as ndebit/CLINK, and pointed at removed packages.
README.md: rewritten end-to-end. Calls out the branch model (main vs
dev) so contributors don't accidentally affect production ATMs.
Documents the actual cash-out (BOLT11 + subscribe_payments by hash)
and cash-in (LNURL-withdraw + subscribe_payments by tag/link_id) wire
flows. Quick-start uses the dev compose's bundled LNbits with
nostr-transport + the LNbits-internal nostrrelay extension (the relay
endpoint is ws://<host>:5001/nostrrelay/test — no separate strfry
container, this was a pitfall during Sintra provisioning). Disk-image
deployment summary points at deploy/nixos/README for full details.
CLAUDE.md: rewritten to describe dev-branch reality. Lists actual
package names (@bitSpire/*), notes packages/lightning was deleted in
3d, calls out the LightningBackend adapter pattern in services/
lightning.ts, and gives an env-var reference table + a wire-envelope
crib for kind-21000. Sintra-specific hardware notes (UART layout,
initrd modules for the ACPI eMMC controller) bake in everything we
hard-learned during the first flash.
Both docs now include an Acknowledgements / Provenance section that:
- credits Lamassu Industries AG's open-source lamassu-machine /
lamassu-server (the v8.1.5 release line) as the prior art the
HAL drivers and state machine derive from — bitSpire wouldn't
exist without that foundation
- explicitly states Lamassu transitioned to a proprietary,
source-available "Appendix A SLA" on 2024-01-26 with v8.1.6+
gated behind a paid OSA subscription, and that bitSpire
incorporates no code from v8.1.6 or later
- declares bitSpire independent of Lamassu Industries AG
- in CLAUDE.md specifically: a hard rule that future contributors
(or future Claude runs) must not pull / port / copy code from
lamassu-machine at v8.1.6+; only the 8.1.5 tree is in-scope
License clarification: AGPL-3.0 (matches LNbits, which we link
against) — dropped the earlier "matches LNbits + lamassu-machine
pedigree" phrasing since lamassu-machine is no longer under a free
license.
References:
https://blog.lamassu.is/updates-to-our-lamassu-software-license/https://github.com/lamassu/lamassu-machine
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>