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>
This commit is contained in:
parent
db68e6e244
commit
54c59fadcc
6 changed files with 98 additions and 77 deletions
|
|
@ -11,15 +11,16 @@
|
|||
*
|
||||
* - Operator → ATM: `kind=30078`, `["d", "bitspire-cassettes:<machine_id>"]`,
|
||||
* `["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
|
||||
*
|
||||
* The ATM's hex pubkey serves as `<machine_id>` — 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:<machineId>`), 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<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')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Resolve
|
|||
if (binding) {
|
||||
console.warn(
|
||||
'[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) }
|
||||
}
|
||||
|
|
@ -150,7 +150,9 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Resolve
|
|||
// first pair (no prior binding) has nothing to reset. Cash accounting is
|
||||
// preserved — see resetForRepair; a full wipe is the factory-reset path.
|
||||
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()
|
||||
}
|
||||
// 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,
|
||||
})
|
||||
// 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) }
|
||||
}
|
||||
|
|
|
|||
6
apps/machine/src/types/electron.d.ts
vendored
6
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -146,11 +146,11 @@ declare global {
|
|||
emptyCashbox: () => Promise<void>
|
||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||
getLastKnownConfigCreatedAt: () => Promise<number>
|
||||
getBootstrapPublishedAt: () => Promise<number | null>
|
||||
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
|
||||
getLastStatePublishedAt: () => Promise<number | null>
|
||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
resetBootstrapGate: () => Promise<void>
|
||||
resetStatePublishWatermark: () => Promise<void>
|
||||
resetForRepair: () => Promise<void>
|
||||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue