fix(cassettes): close the machine-side divergence paths #104

Merged
padreug merged 5 commits from fix/cassette-sync-machine into dev 2026-09-22 20:30:24 +00:00
6 changed files with 98 additions and 77 deletions
Showing only changes of commit 54c59fadcc - Show all commits

fix(cassettes): publish state on every change, and make the stamps monotonic

Three linked failures in one mechanism, so one commit.

The state publish was gated on a one-shot 'have we said hello' flag. It
fired once on first boot and then only after a dispense or an applied
operator config, so any change to the layout itself — a reseed, an
atm-tui edit, direct SQL — was never announced. The operator kept
validating against a bay set the machine no longer had, and a publish
from the dashboard could overwrite a fresh seed (#94). State is now
published on every start.

A publish is one fire-and-forget event with no retry. If the relay was
unreachable at the moment of a dispense, that update was gone until the
next customer bought cash. A five-minute heartbeat makes the channel
self-healing and is also the only way an out-of-band edit to the table
ever reaches the operator.

Addressable events are ordered by created_at at second granularity with
ties broken by lowest event id, and a relay acknowledges an event it
then discards. Two publishes inside one second therefore left the winner
decided by a hash, permanently, and a clock stepping backwards would
have made every report from this machine vanish silently. Each publish
now takes a stamp strictly above the last, recorded in the meta row that
used to hold the gate — same key, no migration, honest name.

Closes #94

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Padreug 2026-09-22 22:12:07 +02:00

View file

@ -24,9 +24,9 @@ import {
markCommandExecuting, markCommandExecuting,
completeCommand, completeCommand,
getLastKnownConfigCreatedAt, getLastKnownConfigCreatedAt,
getBootstrapPublishedAt, getLastStatePublishedAt,
markBootstrapPublished, markStatePublished,
resetBootstrapGate, resetStatePublishWatermark,
resetForRepair, resetForRepair,
applyOperatorCassettesConfig, applyOperatorCassettesConfig,
getFeeConfig, getFeeConfig,
@ -410,7 +410,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)
@ -418,8 +418,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()
@ -555,9 +555,9 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
ipcMain.handle('state:get-last-known-config-created-at', (): number => ipcMain.handle('state:get-last-known-config-created-at', (): number =>
getLastKnownConfigCreatedAt() getLastKnownConfigCreatedAt()
) )
ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt()) ipcMain.handle('state:get-last-state-published-at', (): number | null => getLastStatePublishedAt())
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => { ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
markBootstrapPublished(unixTimestamp) markStatePublished(unixTimestamp)
}) })
ipcMain.handle( ipcMain.handle(
'state:apply-operator-cassettes-config', 'state:apply-operator-cassettes-config',

View file

@ -108,16 +108,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Operator-config consumer (aiolabs/lamassu-next#56) // Operator-config consumer (aiolabs/lamassu-next#56)
getLastKnownConfigCreatedAt: (): Promise<number> => getLastKnownConfigCreatedAt: (): Promise<number> =>
ipcRenderer.invoke('state:get-last-known-config-created-at'), ipcRenderer.invoke('state:get-last-known-config-created-at'),
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> => markStatePublished: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp), 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,
@ -289,11 +289,11 @@ declare global {
emptyCashbox: () => Promise<void> emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean> remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getLastKnownConfigCreatedAt: () => Promise<number> getLastKnownConfigCreatedAt: () => Promise<number>
getBootstrapPublishedAt: () => Promise<number | null> getLastStatePublishedAt: () => Promise<number | null>
markBootstrapPublished: (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>

View file

@ -406,10 +406,20 @@ export function getLastKnownConfigCreatedAt(): number {
} }
/** /**
* 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.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as
| { value: string } | { value: string }
@ -419,12 +429,8 @@ export function getBootstrapPublishedAt(): number | null {
return Number.isFinite(n) ? n : null return Number.isFinite(n) ? n : null
} }
/** /** Record the `created_at` just published, as the next publish's floor. */
* Mark the bootstrap hello-event as published. Idempotent — only takes export function markStatePublished(unixTimestamp: number): void {
* effect the first time it's set. Subsequent calls overwrite the
* timestamp (harmless; the gate just needs to be non-null).
*/
export function markBootstrapPublished(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),
@ -533,11 +539,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')
} }

View file

@ -11,15 +11,16 @@
* *
* - 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 {
@ -37,6 +38,19 @@ const KIND_NIP78 = 30078
/** 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 +59,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 +97,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.
@ -112,8 +129,17 @@ export async function startOperatorConfigService(
) )
console.log('[OperatorConfig] Subscribed:', { dTag, subscriptionId }) console.log('[OperatorConfig] Subscribed:', { dTag, subscriptionId })
const heartbeat = setInterval(() => {
publishCassettesState(cfg, api, machineId).catch((err) =>
console.warn('[OperatorConfig] cassettes-state heartbeat failed:', err)
)
}, STATE_HEARTBEAT_MS)
return { return {
stop: () => cfg.nostrClient.unsubscribe(subscriptionId), stop: () => {
clearInterval(heartbeat)
cfg.nostrClient.unsubscribe(subscriptionId)
},
publishCassettesState: () => publishCassettesState: () =>
publishCassettesState(cfg, api, machineId) publishCassettesState(cfg, api, machineId)
.then(() => {}) .then(() => {})
@ -224,11 +250,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,
@ -246,6 +272,15 @@ async function publishCassettesState(
} }
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions })) const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions }))
// 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, {
kind: KIND_NIP78, kind: KIND_NIP78,
@ -254,34 +289,11 @@ 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:', { dTag, eventId: event.id, createdAt })
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')
}
}

View file

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

View file

@ -146,11 +146,11 @@ declare global {
emptyCashbox: () => Promise<void> emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean> remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getLastKnownConfigCreatedAt: () => Promise<number> getLastKnownConfigCreatedAt: () => Promise<number>
getBootstrapPublishedAt: () => Promise<number | null> getLastStatePublishedAt: () => Promise<number | null>
markBootstrapPublished: (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>