diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index f74d534..ae06f43 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -24,9 +24,9 @@ import { markCommandExecuting, completeCommand, getLastKnownConfigCreatedAt, - getBootstrapPublishedAt, - markBootstrapPublished, - resetBootstrapGate, + getLastStatePublishedAt, + markStatePublished, + resetStatePublishWatermark, resetForRepair, applyOperatorCassettesConfig, getFeeConfig, @@ -410,7 +410,7 @@ ipcMain.handle('get-atm-secrets', () => { }) // 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). ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => { saveBunkerBinding(binding) @@ -418,8 +418,8 @@ ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBindin ipcMain.handle('state:clear-bunker-binding', (): void => { clearBunkerBinding() }) -ipcMain.handle('state:reset-bootstrap-gate', (): void => { - resetBootstrapGate() +ipcMain.handle('state:reset-state-publish-watermark', (): void => { + resetStatePublishWatermark() }) ipcMain.handle('state:reset-for-repair', (): void => { resetForRepair() @@ -555,9 +555,9 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB ipcMain.handle('state:get-last-known-config-created-at', (): number => getLastKnownConfigCreatedAt() ) -ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt()) -ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => { - markBootstrapPublished(unixTimestamp) +ipcMain.handle('state:get-last-state-published-at', (): number | null => getLastStatePublishedAt()) +ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => { + markStatePublished(unixTimestamp) }) ipcMain.handle( 'state:apply-operator-cassettes-config', diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 78b0e13..cae791e 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -108,16 +108,16 @@ contextBridge.exposeInMainWorld('electronAPI', { // Operator-config consumer (aiolabs/lamassu-next#56) getLastKnownConfigCreatedAt: (): Promise => ipcRenderer.invoke('state:get-last-known-config-created-at'), - getBootstrapPublishedAt: (): Promise => - ipcRenderer.invoke('state:get-bootstrap-published-at'), - markBootstrapPublished: (unixTimestamp: number): Promise => - ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp), + getLastStatePublishedAt: (): Promise => + ipcRenderer.invoke('state:get-last-state-published-at'), + markStatePublished: (unixTimestamp: number): Promise => + ipcRenderer.invoke('state:mark-state-published', unixTimestamp), // Bunker binding persistence (aiolabs/bitspire#52) saveBunkerBinding: (binding: BunkerBindingRecord): Promise => ipcRenderer.invoke('state:save-bunker-binding', binding), clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), - resetBootstrapGate: (): Promise => ipcRenderer.invoke('state:reset-bootstrap-gate'), + resetStatePublishWatermark: (): Promise => ipcRenderer.invoke('state:reset-state-publish-watermark'), resetForRepair: (): Promise => ipcRenderer.invoke('state:reset-for-repair'), // QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, @@ -289,11 +289,11 @@ declare global { emptyCashbox: () => Promise remediateTransaction: (txid: string, remediatedByTxid: string) => Promise getLastKnownConfigCreatedAt: () => Promise - getBootstrapPublishedAt: () => Promise - markBootstrapPublished: (unixTimestamp: number) => Promise + getLastStatePublishedAt: () => Promise + markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise - resetBootstrapGate: () => Promise + resetStatePublishWatermark: () => Promise resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 39e32b6..dca1170 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -406,10 +406,20 @@ export function getLastKnownConfigCreatedAt(): number { } /** - * Read the one-shot bootstrap-publish gate. Returns null if the ATM has - * not yet published its `bitspire-cassettes-state:` hello-event. + * The `created_at` of the last `bitspire-cassettes-state` event this machine + * 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') const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as | { value: string } @@ -419,12 +429,8 @@ export function getBootstrapPublishedAt(): number | null { return Number.isFinite(n) ? n : null } -/** - * Mark the bootstrap hello-event as published. Idempotent — only takes - * 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 { +/** 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') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run( String(unixTimestamp), @@ -533,11 +539,12 @@ export function clearBunkerBinding(): void { } /** - * Reset the bootstrap-publish gate so the ATM re-publishes its - * `bitspire-cassettes-state` hello-event. Called on a re-pair (new seed) so - * the new operator receives the spire's current state (aiolabs/bitspire#56). + * Forget the publish high-water mark. Called on a re-pair (new seed): the + * next publish is then free to use the wall clock, which is what a fresh + * 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') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt') } diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index d853114..44b1979 100644 --- a/apps/machine/src/services/operator-config.ts +++ b/apps/machine/src/services/operator-config.ts @@ -11,15 +11,16 @@ * * - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:"]`, * `["p", ]`, NIP-44 v2 encrypted content, author = operator pubkey - * - ATM bootstrap: `kind=30078`, `["d", "bitspire-cassettes-state:"]`, + * - ATM state: `kind=30078`, `["d", "bitspire-cassettes-state:"]`, * `["p", ]`, NIP-44 v2 encrypted content, author = ATM pubkey * * The ATM's hex pubkey serves as `` — globally unique, no * extra provisioning step required. * - * v1 only publishes the one-shot bootstrap hello-event. The continuous - * ATM-state reverse channel (publish on every count change + heartbeat) - * is v2 territory. + * The ATM publishes its state on startup, after every change to the bays, and + * on a heartbeat. It was once a single hello-event gated on a one-shot flag, + * 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 { @@ -37,6 +38,19 @@ const KIND_NIP78 = 30078 /** Accept operator events stamped up to this many seconds in the future. */ 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 atmStateDTag = (machineId: string) => `bitspire-cassettes-state:${machineId}` @@ -45,7 +59,7 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef export interface OperatorConfigServiceConfig { /** Connected NostrClient — shared with the Lightning service. */ 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 /** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */ operatorPubkeys: string[] @@ -83,12 +97,15 @@ export async function startOperatorConfigService( const api = window.electronAPI const machineId = cfg.machineId ?? cfg.signer.pubkey - // Bootstrap hello-event on first boot (best-effort — failure leaves the - // gate null so the next boot retries). + // Announce current state on every start. This used to be gated on a + // 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 { - await maybePublishBootstrap(cfg, api, machineId) + await publishCassettesState(cfg, api, machineId) } 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. @@ -112,8 +129,17 @@ export async function startOperatorConfigService( ) 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 { - stop: () => cfg.nostrClient.unsubscribe(subscriptionId), + stop: () => { + clearInterval(heartbeat) + cfg.nostrClient.unsubscribe(subscriptionId) + }, publishCassettesState: () => publishCassettesState(cfg, api, machineId) .then(() => {}) @@ -224,11 +250,11 @@ async function handleOperatorConfigEvent( * Publish the ATM's current cassette state as a replaceable kind-30078 event * (`bitspire-cassettes-state:`), NIP-44-encrypted to the operator. * Replaceable → latest wins; the operator consumes every update. Call after a - * dispense and on a cassette reload so the operator view tracks reality, not - * the frozen bootstrap snapshot (coord 2026-06-21 / lamassu-next#56). + * dispense, on a cassette reload, at startup and on a heartbeat, so the + * 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 - * event was published (false when there are no cassettes / no operator). + * Returns whether an event was published (false when there are no cassettes / + * no operator). */ async function publishCassettesState( cfg: OperatorConfigServiceConfig, @@ -246,6 +272,15 @@ async function publishCassettesState( } 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 event = await createSignedEvent(cfg.signer, { kind: KIND_NIP78, @@ -254,34 +289,11 @@ async function publishCassettesState( ['d', dTag], ['p', operatorPubkey], ], - created_at: Math.floor(Date.now() / 1000), + created_at: createdAt, }) 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 } - -/** - * 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, - machineId: string -): Promise { - 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') - } -} diff --git a/apps/machine/src/services/signer-resolver.ts b/apps/machine/src/services/signer-resolver.ts index f830073..9db99b1 100644 --- a/apps/machine/src/services/signer-resolver.ts +++ b/apps/machine/src/services/signer-resolver.ts @@ -5,7 +5,7 @@ * 1. A seed is present whose fingerprint differs from the stored binding * (first pair or re-pair) → generate a fresh NIP-46 transport key, redeem * the one-shot connect secret, persist the binding, and reset the - * bootstrap gate so the (possibly new) operator gets a hello-event (#56). + * 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 * binding exists → resume the bunker session with the persisted transport * key (no re-redeem — the binding is server-persistent). @@ -121,7 +121,7 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise Promise remediateTransaction: (txid: string, remediatedByTxid: string) => Promise getLastKnownConfigCreatedAt: () => Promise - getBootstrapPublishedAt: () => Promise - markBootstrapPublished: (unixTimestamp: number) => Promise + getLastStatePublishedAt: () => Promise + markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise - resetBootstrapGate: () => Promise + resetStatePublishWatermark: () => Promise resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise