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

@ -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', () => {
it('should transition to cashIn on SELECT_CASH_IN', async () => {
const machine = createATMMachine(mockServices)

View file

@ -415,6 +415,18 @@ export function createATMMachine(
? { 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: {
idle: {
entry: 'resetContext',

View file

@ -156,6 +156,12 @@ export type ATMEvent =
| { type: 'TIMEOUT' }
| { type: 'ERROR'; error: string }
| { 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 */
export const initialContext: ATMContext = {