diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 126ddd8..d0e2005 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -334,14 +334,40 @@ export const useAtmStore = defineStore('atm', () => { /** * Push a freshly-applied fee config from the operator-fees consumer - * into the renderer's reactive refs. XState's per-flow context-snapshot - * boundary defers the effective change to the next transaction (the - * in-flight flow keeps using its captured fractions). See operator- - * fees.ts file header for the full reasoning. + * into the renderer's reactive refs + the running state machine. + * + * Three responsibilities (see coord log 2026-06-01T18:30Z for the + * 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 }) { cashInFeeFraction.value = fees.cashInFeeFraction 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 // config has ever been applied (fresh ATM, pre-operator-publish), - // fail-closed into maintenance screen. The operator-fees subscriber - // started below will unblock this once it receives a valid event from - // the operator's satmachineadmin (publish triggered by machine create - // / update / super-config change per aiolabs/satmachineadmin#39). + // enter the 'awaiting-fees' maintenance state — UI shows the operator + // a directive to publish initial fee config, transactions are blocked + // (no user-facing route renders). + // + // 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:` 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() if (persistedFees === null) { initError.value = 'awaiting-fees' console.warn( '[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 const { getDeviceConfig, toHalConfig, MACHINE_PRESETS } = await import('@/config') diff --git a/packages/state-machine/src/__tests__/machine.test.ts b/packages/state-machine/src/__tests__/machine.test.ts index 4d50ad2..00d742a 100644 --- a/packages/state-machine/src/__tests__/machine.test.ts +++ b/packages/state-machine/src/__tests__/machine.test.ts @@ -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) diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index fc87fca..de0cb90 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -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', diff --git a/packages/state-machine/src/types.ts b/packages/state-machine/src/types.ts index b99c78b..6be35af 100644 --- a/packages/state-machine/src/types.ts +++ b/packages/state-machine/src/types.ts @@ -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 = {