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
|
|
@ -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,19 +1052,26 @@ 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.'
|
||||||
)
|
)
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
cashInFeeFraction.value = persistedFees.cashInFeeFraction
|
cashInFeeFraction.value = persistedFees.cashInFeeFraction
|
||||||
cashOutFeeFraction.value = persistedFees.cashOutFeeFraction
|
cashOutFeeFraction.value = persistedFees.cashOutFeeFraction
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -1046,6 +1079,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
`cash_in=${persistedFees.cashInFeeFraction} cash_out=${persistedFees.cashOutFeeFraction} ` +
|
`cash_in=${persistedFees.cashInFeeFraction} cash_out=${persistedFees.cashOutFeeFraction} ` +
|
||||||
`(event_created_at=${persistedFees.eventCreatedAt})`
|
`(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')
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue