fix(machine): reactive unblock from 'awaiting-fees' maintenance (#57)

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>
This commit is contained in:
Padreug 2026-06-01 19:46:08 +02:00
commit 9bdb9333fd
4 changed files with 125 additions and 17 deletions

View file

@ -334,14 +334,40 @@ export const useAtmStore = defineStore('atm', () => {
/** /**
* Push a freshly-applied fee config from the operator-fees consumer * Push a freshly-applied fee config from the operator-fees consumer
* into the renderer's reactive refs. XState's per-flow context-snapshot * into the renderer's reactive refs + the running state machine.
* boundary defers the effective change to the next transaction (the *
* in-flight flow keeps using its captured fractions). See operator- * Three responsibilities (see coord log 2026-06-01T18:30Z for the
* fees.ts file header for the full reasoning. * gap-3 reactive unblock):
*
* 1. Update the pinia refs so future `createATMMachine()` calls bake
* in the right fractions.
* 2. Dispatch UPDATE_FEE_CONFIG into the running actor so the in-memory
* machine picks up the new fees on its next cashIn/cashOut entry
* (no service restart needed; XState's per-flow context-snapshot
* boundary still defers the in-flight transaction).
* 3. If we're sitting in the 'awaiting-fees' maintenance state, clear
* `initError` so the UI flips from maintenance card → IdleView.
* Operator just published the first config; ATM goes live.
*/ */
function applyFeeConfig(fees: { cashInFeeFraction: number; cashOutFeeFraction: number }) { function applyFeeConfig(fees: { cashInFeeFraction: number; cashOutFeeFraction: number }) {
cashInFeeFraction.value = fees.cashInFeeFraction cashInFeeFraction.value = fees.cashInFeeFraction
cashOutFeeFraction.value = fees.cashOutFeeFraction cashOutFeeFraction.value = fees.cashOutFeeFraction
if (actor.value) {
actor.value.send({
type: 'UPDATE_FEE_CONFIG',
cashInFeeFraction: fees.cashInFeeFraction,
cashOutFeeFraction: fees.cashOutFeeFraction,
})
}
if (initError.value === 'awaiting-fees') {
console.log(
`[ATM] Operator fee config received — clearing 'awaiting-fees' maintenance state ` +
`(cash_in=${fees.cashInFeeFraction} cash_out=${fees.cashOutFeeFraction})`
)
initError.value = null
}
} }
/** /**
@ -1026,26 +1052,34 @@ export const useAtmStore = defineStore('atm', () => {
// Load persisted operator fee config (aiolabs/lamassu-next#57). If no // Load persisted operator fee config (aiolabs/lamassu-next#57). If no
// config has ever been applied (fresh ATM, pre-operator-publish), // config has ever been applied (fresh ATM, pre-operator-publish),
// fail-closed into maintenance screen. The operator-fees subscriber // enter the 'awaiting-fees' maintenance state — UI shows the operator
// started below will unblock this once it receives a valid event from // a directive to publish initial fee config, transactions are blocked
// the operator's satmachineadmin (publish triggered by machine create // (no user-facing route renders).
// / update / super-config change per aiolabs/satmachineadmin#39). //
// Crucially, we DO NOT bail out of init here — HAL + Lightning + the
// operator-fees subscriber still need to start so the first inbound
// kind-30078 `bitspire-fees:<atm_pubkey>` event can land. `applyFeeConfig`
// (the subscriber's onApply callback) clears `initError` reactively
// when that first event arrives, flipping the UI live without a
// service restart. See coord log 2026-06-01T18:30Z for the gap-3
// dead-end this fixes.
const persistedFees = await api.getFeeConfig() const persistedFees = await api.getFeeConfig()
if (persistedFees === null) { if (persistedFees === null) {
initError.value = 'awaiting-fees' initError.value = 'awaiting-fees'
console.warn( console.warn(
'[ATM] No persisted fee config and no inbound event yet — entering maintenance state. ' + '[ATM] No persisted fee config and no inbound event yet — entering maintenance state. ' +
'Operator must publish initial fee config via satmachineadmin.' 'Operator must publish initial fee config via satmachineadmin. ' +
'Subscriber is starting; ATM will unblock reactively on first valid event.'
)
} else {
cashInFeeFraction.value = persistedFees.cashInFeeFraction
cashOutFeeFraction.value = persistedFees.cashOutFeeFraction
console.log(
`[ATM] Restored fee config from state.db: ` +
`cash_in=${persistedFees.cashInFeeFraction} cash_out=${persistedFees.cashOutFeeFraction} ` +
`(event_created_at=${persistedFees.eventCreatedAt})`
) )
return
} }
cashInFeeFraction.value = persistedFees.cashInFeeFraction
cashOutFeeFraction.value = persistedFees.cashOutFeeFraction
console.log(
`[ATM] Restored fee config from state.db: ` +
`cash_in=${persistedFees.cashInFeeFraction} cash_out=${persistedFees.cashOutFeeFraction} ` +
`(event_created_at=${persistedFees.eventCreatedAt})`
)
// Build device config from runtime values // Build device config from runtime values
const { getDeviceConfig, toHalConfig, MACHINE_PRESETS } = await import('@/config') const { getDeviceConfig, toHalConfig, MACHINE_PRESETS } = await import('@/config')

View file

@ -47,6 +47,62 @@ describe('ATM State Machine', () => {
}) })
}) })
describe('UPDATE_FEE_CONFIG', () => {
// Operator-fees subscriber dispatches this on every valid kind-30078
// event (aiolabs/lamassu-next#57 + coord log 2026-06-01T18:30Z gap-3
// reactive unblock). Next cashIn/cashOut entry stamps the new
// fractions onto context.feeFraction via setCashInFee / setCashOutFee.
it('updates cashInFeeFraction + cashOutFeeFraction on context', () => {
const machine = createATMMachine()
const actor = createActor(machine)
actor.start()
actor.send({
type: 'UPDATE_FEE_CONFIG',
cashInFeeFraction: 0.06,
cashOutFeeFraction: 0.11,
})
const context = actor.getSnapshot().context
expect(context.cashInFeeFraction).toBe(0.06)
expect(context.cashOutFeeFraction).toBe(0.11)
})
it('does not leave the idle state when applied', () => {
const machine = createATMMachine()
const actor = createActor(machine)
actor.start()
actor.send({
type: 'UPDATE_FEE_CONFIG',
cashInFeeFraction: 0.06,
cashOutFeeFraction: 0.11,
})
expect(actor.getSnapshot().value).toBe('idle')
})
it('propagates to context.feeFraction on the next cashIn entry', async () => {
// Machine starts with zero fees (post-#57 default). UPDATE_FEE_CONFIG
// lands. SELECT_CASH_IN fires setCashInFee, which stamps the new
// fraction onto context.feeFraction for the in-flight transaction.
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({
type: 'UPDATE_FEE_CONFIG',
cashInFeeFraction: 0.06,
cashOutFeeFraction: 0.11,
})
actor.send({ type: 'SELECT_CASH_IN' })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(actor.getSnapshot().context.feeFraction).toBe(0.06)
})
})
describe('cash-in flow', () => { describe('cash-in flow', () => {
it('should transition to cashIn on SELECT_CASH_IN', async () => { it('should transition to cashIn on SELECT_CASH_IN', async () => {
const machine = createATMMachine(mockServices) const machine = createATMMachine(mockServices)

View file

@ -415,6 +415,18 @@ export function createATMMachine(
? { cashOutFeeFraction: options.cashOutFeeFraction } ? { cashOutFeeFraction: options.cashOutFeeFraction }
: {}), : {}),
}, },
// Root-level handler: lets the operator-fees subscriber update the
// active fee fractions reactively. Next cashIn/cashOut entry will
// snapshot the new values via setCashInFee / setCashOutFee actions.
// See coord log 2026-06-01T18:30Z (the gap-3 reactive unblock).
on: {
UPDATE_FEE_CONFIG: {
actions: assign({
cashInFeeFraction: ({ event }) => event.cashInFeeFraction,
cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction,
}),
},
},
states: { states: {
idle: { idle: {
entry: 'resetContext', entry: 'resetContext',

View file

@ -156,6 +156,12 @@ export type ATMEvent =
| { type: 'TIMEOUT' } | { type: 'TIMEOUT' }
| { type: 'ERROR'; error: string } | { type: 'ERROR'; error: string }
| { type: 'EXCHANGE_RATE_UPDATED'; rate: number } | { type: 'EXCHANGE_RATE_UPDATED'; rate: number }
// Operator config events — assigns new fee fractions to context so
// subsequent cashIn/cashOut entry actions pick them up. Per coord log
// 2026-06-01T18:30Z: subscriber runs during 'awaiting-fees' maintenance
// and dispatches this on first valid event, enabling reactive unblock
// without a service restart.
| { type: 'UPDATE_FEE_CONFIG'; cashInFeeFraction: number; cashOutFeeFraction: number }
/** Initial context values */ /** Initial context values */
export const initialContext: ATMContext = { export const initialContext: ATMContext = {