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:
parent
a8e984e12b
commit
9bdb9333fd
4 changed files with 125 additions and 17 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue